From 68591dab06959d2827884138703ea1ad74865f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 21:28:18 +0000 Subject: [PATCH 001/596] Add API abstraction layer for mediafiles in native API Introduce a mapping layer between internal model.MediaFile and the API response type. The native API /song endpoint now returns apimodel.Song instead of exposing the database model directly. This decouples the API surface from the persistence layer, enabling calculated fields and hiding internal details (Path, FolderID, LibraryPath, PID, etc.). The architecture is designed for expansion to other models: - mappedRepository: generic REST repository wrapper that transforms Read/ReadAll results through mapping functions - apimodel package: dedicated API response types with explicit field selection and JSON tags independent of the model https://claude.ai/code/session_01TvZ2CgPPfFoxzNYoWuPUBg --- server/nativeapi/apimodel/song.go | 184 +++++++++++++++++++++++ server/nativeapi/apimodel/song_test.go | 132 ++++++++++++++++ server/nativeapi/mapped_repository.go | 49 ++++++ server/nativeapi/native_api.go | 21 ++- server/nativeapi/native_api_song_test.go | 33 +++- 5 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 server/nativeapi/apimodel/song.go create mode 100644 server/nativeapi/apimodel/song_test.go create mode 100644 server/nativeapi/mapped_repository.go diff --git a/server/nativeapi/apimodel/song.go b/server/nativeapi/apimodel/song.go new file mode 100644 index 000000000..05627531a --- /dev/null +++ b/server/nativeapi/apimodel/song.go @@ -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 +} diff --git a/server/nativeapi/apimodel/song_test.go b/server/nativeapi/apimodel/song_test.go new file mode 100644 index 000000000..ac001da22 --- /dev/null +++ b/server/nativeapi/apimodel/song_test.go @@ -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)) + } +} diff --git a/server/nativeapi/mapped_repository.go b/server/nativeapi/mapped_repository.go new file mode 100644 index 000000000..2776c6d9b --- /dev/null +++ b/server/nativeapi/mapped_repository.go @@ -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) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 91ddd0fa3..704450d2e 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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)) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b192e00ac..f9e319022 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -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()) From b64d8ad334a05ad2d7a5014e752ce4d24b843c6b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 15 Feb 2026 22:36:58 -0500 Subject: [PATCH 002/596] 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. --- server/nativeapi/playlists.go | 52 ++++----- server/nativeapi/playlists_test.go | 167 +++++++++++++++++++++++++++++ tests/mock_playlist_repo.go | 9 +- 3 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 server/nativeapi/playlists_test.go diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index cc0006926..afa964e15 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -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 { diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go new file mode 100644 index 000000000..319e41cd5 --- /dev/null +++ b/server/nativeapi/playlists_test.go @@ -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)) + }) + }) +}) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 60dc98be9..1c37107e2 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -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 From a20d56c137d3c7ac5e37e642e51df89261163179 Mon Sep 17 00:00:00 2001 From: Alanna Date: Tue, 17 Feb 2026 08:34:24 -0500 Subject: [PATCH 003/596] 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 --- ui/src/reducers/playerReducer.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 92fe85df4..0392736e5 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -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, } } From b774133cd1112372f0df8fc9f4d7ad02a35519d1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Feb 2026 08:20:16 -0500 Subject: [PATCH 004/596] chore(deps): update go-sqlite3 to v1.14.34 and pocketbase/dbx to v1.12.0 Signed-off-by: Deluan --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index da005b7c7..c62ada0e4 100644 --- a/go.mod +++ b/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.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 diff --git a/go.sum b/go.sum index 769e45818..ddf017df2 100644 --- a/go.sum +++ b/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.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= From cad9cdc53e7e3f37551df0ceefa936ca531b83c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 17 Feb 2026 08:37:05 -0500 Subject: [PATCH 005/596] 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. --- model/mediafile.go | 2 +- persistence/mediafile_repository.go | 4 +- persistence/mediafile_repository_test.go | 62 ++++++++++++ scanner/phase_2_missing_tracks.go | 13 +++ scanner/phase_2_missing_tracks_test.go | 114 +++++++++++++++++++++++ tests/mock_album_repo.go | 27 ++++++ 6 files changed, 220 insertions(+), 2 deletions(-) diff --git a/model/mediafile.go b/model/mediafile.go index a2fec6a1e..831f006bf 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -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"` diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 9b59a4bd1..617cce4c8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -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 diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 6b4d0db3b..84cfd464b 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -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()) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 023944d00..c47565036 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -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 } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index 6c25ec7e8..fa6ef5724 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -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 diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 642ce6b41..8b5f5d9c1 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -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 { From 5fa8356b31756ff06b78997f8eaf9980ce4ce4cc Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Feb 2026 09:26:45 -0500 Subject: [PATCH 006/596] 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 --- Makefile | 2 +- adapters/deezer/client_auth.go | 2 +- adapters/lastfm/auth_router.go | 2 +- adapters/listenbrainz/client.go | 2 +- conf/configuration.go | 10 +++++----- core/artwork/sources.go | 2 +- core/metrics/insights.go | 2 +- core/storage/local/local.go | 2 +- model/user.go | 2 +- persistence/artist_repository.go | 2 +- scanner/external.go | 2 +- server/backgrounds/handler.go | 2 +- server/events/sse.go | 2 +- server/nativeapi/inspect.go | 2 +- server/nativeapi/native_api.go | 4 ++-- server/nativeapi/playlists.go | 10 +++++----- server/nativeapi/queue.go | 2 +- server/public/handle_shares.go | 2 +- server/server.go | 2 +- server/subsonic/api.go | 2 +- 20 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Makefile b/Makefile index 52aa5a8cd..dea912b9b 100644 --- a/Makefile +++ b/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.9.0 +GOLANGCI_LINT_VERSION ?= v2.10.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go index c88c2bcb6..d0924b768 100644 --- a/adapters/deezer/client_auth.go +++ b/adapters/deezer/client_auth.go @@ -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 diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 0052f73d7..162ae9037 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -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 } diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 0427ed07f..708f02f28 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -57,7 +57,7 @@ type listenBrainzResponse struct { } type listenBrainzRequest struct { - ApiKey string + ApiKey string //nolint:gosec Body listenBrainzRequestBody } diff --git a/conf/configuration.go b/conf/configuration.go index e2aca6c13..8994c1c88 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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 } } diff --git a/core/artwork/sources.go b/core/artwork/sources.go index c7da7b19b..b1b9b5454 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -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 } diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 07162dedb..849ddd6fc 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -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 diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 5c335ddb9..cd60c9ef1 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -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 diff --git a/model/user.go b/model/user.go index 2127b635c..1c8541ccf 100644 --- a/model/user.go +++ b/model/user.go @@ -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"` } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b888256a4..5623bd7f8 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -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", diff --git a/scanner/external.go b/scanner/external.go index 75ee2bead..29ca90be6 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -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) } } diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index 61b7d48b8..b00a51696 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -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 diff --git a/server/events/sse.go b/server/events/sse.go index 54a602985..39e217160 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -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) + _, 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 } diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index 3178395ce..7c96312ed 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -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) } } diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 91ddd0fa3..52e633bee 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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}`)) } diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index afa964e15..1e2c5e07e 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -59,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) @@ -90,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 @@ -162,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) } @@ -204,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) } @@ -225,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 } } diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go index 0a3136660..a7700c02c 100644 --- a/server/nativeapi/queue.go +++ b/server/nativeapi/queue.go @@ -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 } } diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index ad8a5da6b..36764dece 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -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) { diff --git a/server/server.go b/server/server.go index aa9043ba4..b05c20cc5 100644 --- a/server/server.go +++ b/server/server.go @@ -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) } diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1d13e2c0a..c3108ea5b 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -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) } } From 44a54824931fcf96e3a9dbffc9544730e1066e6c Mon Sep 17 00:00:00 2001 From: Raphael Catolino Date: Tue, 17 Feb 2026 20:47:20 +0100 Subject: [PATCH 007/596] fix(ui): activity Indicator switching constantly between online/offline (#5054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Deluan Quintão --- server/events/sse.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/events/sse.go b/server/events/sse.go index 39e217160..565d8c016 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -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 ( From 08a71320eae544af22bab98f119f8669c5c66282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 18 Feb 2026 15:38:20 -0500 Subject: [PATCH 008/596] 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. --- ui/src/themes/gruvboxDark.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index b1a2e4c90..20f5c732f 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -97,6 +97,16 @@ export default { boxShadow: '3px 3px 5px #3c3836', }, }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: '#458588', + }, + '&$checked + $track': { + backgroundColor: '#458588', + }, + }, + }, NDMobileArtistDetails: { bgContainer: { background: From ddab0da207525b521b23d898275e5cec59166eb5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 20 Feb 2026 11:00:34 -0500 Subject: [PATCH 009/596] docs: update commit message format in CONTRIBUTING.md Signed-off-by: Deluan --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2631f597..71c13b497 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following: ### Commit Conventions Each commit message must adhere to the following format: ``` -(scope): - +(scope): [optional body] ``` From dc4607c657d71401a3da77a6467e3069d904eba8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:44:32 -0500 Subject: [PATCH 010/596] chore(deps): bump ajv from 6.12.6 to 6.14.0 in /ui (#5080) Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0. - [Release notes](https://github.com/ajv-validator/ajv/releases) - [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0) --- updated-dependencies: - dependency-name: ajv dependency-version: 6.14.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index e9ef2aaa4..b4efed744 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -4011,9 +4011,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -12709,9 +12709,9 @@ "license": "MIT" }, "node_modules/workbox-build/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", From 74aa4d6fa527b781b6250edecf87c9313f3589a5 Mon Sep 17 00:00:00 2001 From: Boris Rorsvort Date: Sat, 21 Feb 2026 20:39:38 +0100 Subject: [PATCH 011/596] fix(ui): Search focus after clear (#4932) * wip * refactor implem * fixes --- ui/src/common/index.js | 1 + ui/src/common/useSearchRefocus.js | 50 +++++++++++++++ ui/src/common/useSearchRefocus.test.js | 84 ++++++++++++++++++++++++++ ui/src/layout/Layout.jsx | 2 + 4 files changed, 137 insertions(+) create mode 100644 ui/src/common/useSearchRefocus.js create mode 100644 ui/src/common/useSearchRefocus.test.js diff --git a/ui/src/common/index.js b/ui/src/common/index.js index f64d4fe0c..356225680 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,3 +41,4 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './useSearchRefocus' diff --git a/ui/src/common/useSearchRefocus.js b/ui/src/common/useSearchRefocus.js new file mode 100644 index 000000000..4daad26f9 --- /dev/null +++ b/ui/src/common/useSearchRefocus.js @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' + +// Search field names used by SearchInput across different list views: +// - 'name': AlbumList, ArtistList, LibraryList, PlayerList, RadioList, UserList +// - 'title': SongList +// - 'q': PlaylistList +// If a new list view uses a different source field, add it here. +const SEARCH_FIELDS = ['name', 'title', 'q'] + +const getSearchValue = (filter) => { + for (const field of SEARCH_FIELDS) { + if (filter[field]) return filter[field] + } + return '' +} + +export const useSearchRefocus = () => { + const location = useLocation() + const prevSearchValue = useRef(null) + + useEffect(() => { + const params = new URLSearchParams(location.search) + const filterStr = params.get('filter') || '{}' + + let filter = {} + try { + filter = JSON.parse(filterStr) + } catch (e) { + // Invalid JSON, ignore + } + + const searchValue = getSearchValue(filter) + + if (prevSearchValue.current && !searchValue) { + // Use requestAnimationFrame to wait for React to finish re-rendering + // after the URL change before focusing the input + requestAnimationFrame(() => { + // Selector depends on react-admin's internal class naming. + // If react-admin changes these class names, this will need updating. + const input = document.querySelector('[class*="RaSearchInput"] input') + if (input) { + input.focus() + } + }) + } + + prevSearchValue.current = searchValue + }, [location.search]) +} diff --git a/ui/src/common/useSearchRefocus.test.js b/ui/src/common/useSearchRefocus.test.js new file mode 100644 index 000000000..2bce8320d --- /dev/null +++ b/ui/src/common/useSearchRefocus.test.js @@ -0,0 +1,84 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react-hooks' +import { useSearchRefocus } from './useSearchRefocus' + +const mockLocation = { search: '' } +vi.mock('react-router-dom', () => ({ + useLocation: () => mockLocation, +})) + +describe('useSearchRefocus', () => { + let container + let rafCallbacks + + beforeEach(() => { + rafCallbacks = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + container = document.createElement('div') + container.innerHTML = ` +
+ +
+ ` + document.body.appendChild(container) + mockLocation.search = '' + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.removeChild(container) + }) + + const flushRAF = () => { + rafCallbacks.forEach((cb) => cb()) + rafCallbacks = [] + } + + it('focuses the input when search filter is cleared', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={"name":"test"}' + const { rerender } = renderHook(() => useSearchRefocus()) + + expect(focusSpy).not.toHaveBeenCalled() + + mockLocation.search = '?filter={}' + rerender() + flushRAF() + + expect(focusSpy).toHaveBeenCalledTimes(1) + }) + + it('does not focus if filter was already empty', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={}' + const { rerender } = renderHook(() => useSearchRefocus()) + + mockLocation.search = '?filter={}' + rerender() + flushRAF() + + expect(focusSpy).not.toHaveBeenCalled() + }) + + it('does not focus if filter value changed but not cleared', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={"name":"test"}' + const { rerender } = renderHook(() => useSearchRefocus()) + + mockLocation.search = '?filter={"name":"other"}' + rerender() + flushRAF() + + expect(focusSpy).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index e3f13d25f..44cf9b42c 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -7,6 +7,7 @@ import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' +import { useSearchRefocus } from '../common' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -17,6 +18,7 @@ const Layout = (props) => { const queue = useSelector((state) => state.player?.queue) const classes = useStyles({ addPadding: queue.length > 0 }) const dispatch = useDispatch() + useSearchRefocus() const keyHandlers = { TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]), From 821f22a86f8ec50a5eb0125da42f1efbef9b37a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 21 Feb 2026 16:52:48 -0500 Subject: [PATCH 012/596] feat(scanner): upgrade TagLib to 2.2, with MKA/Matroska support (#5071) * 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). * chore(deps): update cross-taglib version to 2.2.0-1 Signed-off-by: Deluan * chore(make): rename run-docker target to docker-run for consistency Signed-off-by: Deluan * chore(go-taglib): update version to 2.2 WASM and add debug logging Signed-off-by: Deluan * chore(deps): update go-taglib to v0.0.0-20260220032326 for MKA fixes Signed-off-by: Deluan --------- Signed-off-by: Deluan --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 2 +- .github/workflows/pipeline.yml | 2 +- Dockerfile | 2 +- Makefile | 8 ++++---- adapters/gotaglib/gotaglib.go | 6 +++++- go.mod | 2 +- go.sum | 4 ++-- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 078267fae..c7ccbf9fa 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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 \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0519f25fc..81398a3ce 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": "", diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index bb5b0e2ff..336205550 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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' }} diff --git a/Dockerfile b/Dockerfile index 791854729..ad43e247b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index dea912b9b..d16cb2821 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ 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 +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= - @if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag="; exit 1; fi +docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag= + @if [ -z "$(tag)" ]; then echo "Usage: make docker-run 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 diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 1e9660362..f434d1c71 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -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()) + }) } diff --git a/go.mod b/go.mod index c62ada0e4..10ee461d1 100644 --- a/go.mod +++ b/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 ( diff --git a/go.sum b/go.sum index ddf017df2..adafaf207 100644 --- a/go.sum +++ b/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= From 6f5f58ae9d0d69999163cc5fd0b384bd2e628333 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 21 Feb 2026 17:04:59 -0500 Subject: [PATCH 013/596] chore(deps): update go-taglib to v0.0.0-20260221220301-2fab4903f48e Signed-off-by: Deluan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10ee461d1..f84ea65d0 100644 --- a/go.mod +++ b/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-20260220032326-c5973f82d98a + go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e ) require ( diff --git a/go.sum b/go.sum index adafaf207..4e05f46bc 100644 --- a/go.sum +++ b/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-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/go-taglib v0.0.0-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I= +github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/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= From 54de0dbc52a25a8a5fbf1b44d5ca6703a61d87ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 21 Feb 2026 17:52:42 -0500 Subject: [PATCH 014/596] feat(server): implement FTS5-based full-text search (#5079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: add sqlite_fts5 build tag to enable FTS5 support * feat: add SearchBackend config option (default: fts) * feat: add buildFTS5Query for safe FTS5 query preprocessing * feat: add FTS5 search backend with config toggle, refactor legacy search - Add searchExprFunc type and getSearchExpr() for backend selection - Rename fullTextExpr to legacySearchExpr - Add ftsSearchExpr using FTS5 MATCH subquery - Update fullTextFilter in sql_restful.go to use configured backend * feat: add FTS5 migration with virtual tables, triggers, and search_participants Creates FTS5 virtual tables for media_file, album, and artist with unicode61 tokenizer and diacritic folding. Adds search_participants column, populates from JSON, and sets up INSERT/UPDATE/DELETE triggers. * feat: populate search_participants in PostMapArgs for FTS5 indexing * test: add FTS5 search integration tests * fix: exclude FTS5 virtual tables from e2e DB restore The restoreDB function iterates all tables in sqlite_master and runs DELETE + INSERT to reset state. FTS5 contentless virtual tables cannot be directly deleted from. Since triggers handle FTS5 sync automatically, simply skip tables matching *_fts and *_fts_* patterns. * build: add compile-time guard for sqlite_fts5 build tag Same pattern as netgo: compilation fails with a clear error if the sqlite_fts5 build tag is missing. * build: add sqlite_fts5 tag to reflex dev server config * build: extract GO_BUILD_TAGS variable in Makefile to avoid duplication * fix: strip leading * from FTS5 queries to prevent "unknown special query" error * feat: auto-append prefix wildcard to FTS5 search tokens for broader matching Every plain search token now gets a trailing * appended (e.g., "love" becomes "love*"), so searching for "love" also matches "lovelace", "lovely", etc. Quoted phrases are preserved as exact matches without wildcards. Results are ordered alphabetically by name/title, so shorter exact matches naturally appear first. * fix: clarify comments about FTS5 operator neutralization The comments said "strip" but the code lowercases operators to neutralize them (FTS5 operators are case-sensitive). Updated comments to accurately describe the behavior. * fix: use fmt.Sprintf for FTS5 phrase placeholders The previous encoding used rune('0'+index) which silently breaks with 10+ quoted phrases. Use fmt.Sprintf for arbitrary index support. * fix: validate and normalize SearchBackend config option Normalize the value to lowercase and fall back to "fts" with a log warning for unrecognized values. This prevents silent misconfiguration from typos like "FTS", "Legacy", or "fts5". * refactor: improve documentation for build tags and FTS5 requirements Signed-off-by: Deluan * refactor: convert FTS5 query and search backend normalization tests to DescribeTable format Signed-off-by: Deluan * fix: add sqlite_fts5 build tag to golangci configuration Signed-off-by: Deluan * feat: add UISearchDebounceMs configuration option and update related components Signed-off-by: Deluan * fix: fall back to legacy search when SearchFullString is enabled FTS5 is token-based and cannot match substrings within words, so getSearchExpr now returns legacySearchExpr when SearchFullString is true, regardless of SearchBackend setting. * fix: add sqlite_fts5 build tag to CI pipeline and Dockerfile * fix: add WHEN clauses to FTS5 AFTER UPDATE triggers Added WHEN clauses to the media_file_fts_au, album_fts_au, and artist_fts_au triggers so they only fire when FTS-indexed columns actually change. Previously, every row update (e.g., play count, rating, starred status) triggered an unnecessary delete+insert cycle in the FTS shadow tables. The WHEN clauses use IS NOT for NULL-safe comparison of each indexed column, avoiding FTS index churn for non-indexed updates. * feat: add SearchBackend configuration option to data and insights components Signed-off-by: Deluan * fix: enhance input sanitization for FTS5 by stripping additional punctuation and special characters Signed-off-by: Deluan * feat: add search_normalized column for punctuated name search (R.E.M., AC/DC) Add index-time normalization and query-time single-letter collapsing to fix FTS5 search for punctuated names. A new search_normalized column stores concatenated forms of punctuated words (e.g., "R.E.M." → "REM", "AC/DC" → "ACDC") and is indexed in FTS5 tables. At query time, runs of consecutive single letters (from dot-stripping) are collapsed into OR expressions like ("R E M" OR REM*) to match both the original tokens and the normalized form. This enables searching by "R.E.M.", "REM", "AC/DC", "ACDC", "A-ha", or "Aha" and finding the correct results. * refactor: simplify isSingleUnicodeLetter to avoid []rune allocation Use utf8.DecodeRuneInString to check for a single Unicode letter instead of converting the entire string to a []rune slice. * feat: define ftsSearchColumns for flexible FTS5 search column inclusion Signed-off-by: Deluan * feat: update collapseSingleLetterRuns to return quoted phrases for abbreviations Signed-off-by: Deluan * feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation Signed-off-by: Deluan * feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation Signed-off-by: Deluan * refactor: punctuated word handling to improve processing of artist/album names Signed-off-by: Deluan * feat: add CJK support for search queries with LIKE filters Signed-off-by: Deluan * feat: enhance FTS5 search by adding album version support and CJK handling Signed-off-by: Deluan * refactor: search configuration to use structured options Signed-off-by: Deluan * feat: enhance search functionality to support punctuation-only queries and update related tests Signed-off-by: Deluan --------- Signed-off-by: Deluan --- .github/workflows/pipeline.yml | 2 +- .golangci.yml | 1 + Dockerfile | 2 +- Makefile | 13 +- conf/buildtags/buildtags.go | 4 - conf/buildtags/doc.go | 6 + conf/buildtags/netgo.go | 6 +- conf/buildtags/sqlite_fts5.go | 8 + conf/configuration.go | 26 +- conf/configuration_test.go | 14 + conf/export_test.go | 2 + consts/consts.go | 11 +- core/metrics/insights.go | 3 +- core/metrics/insights/data.go | 1 + .../20260220173400_add_fts5_search.go | 391 ++++++++++++++++++ main.go | 5 +- persistence/album_repository.go | 5 +- persistence/album_repository_test.go | 6 + persistence/artist_repository.go | 1 + persistence/artist_repository_test.go | 50 ++- persistence/mediafile_repository.go | 5 +- persistence/mediafile_repository_test.go | 2 +- persistence/persistence_suite_test.go | 31 +- persistence/sql_restful.go | 3 +- persistence/sql_restful_test.go | 9 +- persistence/sql_search.go | 32 +- persistence/sql_search_fts.go | 261 ++++++++++++ persistence/sql_search_fts_test.go | 333 +++++++++++++++ persistence/sql_search_test.go | 97 +++++ reflex.conf | 2 +- server/e2e/e2e_suite_test.go | 2 +- server/serve_index.go | 1 + server/serve_index_test.go | 1 + ui/src/common/List.jsx | 2 + ui/src/config.js | 1 + 35 files changed, 1283 insertions(+), 56 deletions(-) delete mode 100644 conf/buildtags/buildtags.go create mode 100644 conf/buildtags/doc.go create mode 100644 conf/buildtags/sqlite_fts5.go create mode 100644 db/migrations/20260220173400_add_fts5_search.go create mode 100644 persistence/sql_search_fts.go create mode 100644 persistence/sql_search_fts_test.go diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 336205550..fd8edcd1c 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -117,7 +117,7 @@ jobs: - name: Test run: | pkg-config --define-prefix --cflags --libs taglib # for debugging - go test -shuffle=on -tags netgo -race ./... -v + go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v - name: Test ndpgen run: | diff --git a/.golangci.yml b/.golangci.yml index 996dafccb..1937c2f77 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,7 @@ version: "2" run: build-tags: - netgo + - sqlite_fts5 linters: enable: - asasalint diff --git a/Dockerfile b/Dockerfile index ad43e247b..b32c1df56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \ export EXT=".exe" fi - go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \ + go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome${EXT} . diff --git a/Makefile b/Makefile index d16cb2821..f7b7b1b05 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') NODE_VERSION=$(shell cat .nvmrc) +GO_BUILD_TAGS=netgo,sqlite_fts5 # Set global environment variables, required for most targets export CGO_CFLAGS_ALLOW=--define-prefix @@ -46,12 +47,12 @@ stop: ##@Development Stop development servers (UI and backend) .PHONY: stop watch: ##@Development Start Go tests in watch mode (re-run when code changes) - go tool ginkgo watch -tags=netgo -notify ./... + go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./... .PHONY: watch PKG ?= ./... test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server - go test -tags netgo $(PKG) + go test -tags $(GO_BUILD_TAGS) $(PKG) .PHONY: test test-ndpgen: ##@Development Run tests for ndpgen plugin @@ -62,7 +63,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests .PHONY: testall test-race: ##@Development Run Go tests with race detector - go test -tags netgo -race -shuffle=on $(PKG) + go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG) .PHONY: test-race test-js: ##@Development Run JS tests @@ -108,7 +109,7 @@ format: ##@Development Format code .PHONY: format wire: check_go_env ##@Development Update Dependency Injection - go tool wire gen -tags=netgo ./... + go tool wire gen -tags=$(GO_BUILD_TAGS) ./... .PHONY: wire gen: check_go_env ##@Development Run go generate for code generation @@ -144,14 +145,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push) .PHONY: setup-git build: check_go_env buildjs ##@Build Build the project - go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo + go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS) .PHONY: build buildall: deprecated build .PHONY: buildall debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on) - go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo + go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS) .PHONY: debug-build buildjs: check_node_env ui/build/index.html ##@Build Build only frontend diff --git a/conf/buildtags/buildtags.go b/conf/buildtags/buildtags.go deleted file mode 100644 index 5fc125087..000000000 --- a/conf/buildtags/buildtags.go +++ /dev/null @@ -1,4 +0,0 @@ -package buildtags - -// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all -// required build tags are disabled. diff --git a/conf/buildtags/doc.go b/conf/buildtags/doc.go new file mode 100644 index 000000000..f637b6355 --- /dev/null +++ b/conf/buildtags/doc.go @@ -0,0 +1,6 @@ +// Package buildtags provides compile-time enforcement of required build tags. +// +// Each file in this package is guarded by a build constraint and exports a variable +// that main.go references. If a required tag is missing during compilation, the build +// fails with an "undefined" error, directing the developer to use `make build`. +package buildtags diff --git a/conf/buildtags/netgo.go b/conf/buildtags/netgo.go index 0062ad2bc..407004703 100644 --- a/conf/buildtags/netgo.go +++ b/conf/buildtags/netgo.go @@ -2,10 +2,6 @@ package buildtags -// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project. -// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go` -// file requires it. - -// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700 +// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700 var NETGO = true diff --git a/conf/buildtags/sqlite_fts5.go b/conf/buildtags/sqlite_fts5.go new file mode 100644 index 000000000..1476e04cd --- /dev/null +++ b/conf/buildtags/sqlite_fts5.go @@ -0,0 +1,8 @@ +//go:build sqlite_fts5 + +package buildtags + +// FTS5 is required for full-text search. Without this tag, the SQLite driver +// won't include FTS5 support, causing runtime failures on migrations and search queries. + +var SQLITE_FTS5 = true diff --git a/conf/configuration.go b/conf/configuration.go index 8994c1c88..000bffb58 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -58,7 +58,7 @@ type configOptions struct { SmartPlaylistRefreshDelay time.Duration AutoTranscodeDownload bool DefaultDownsamplingFormat string - SearchFullString bool + Search searchOptions `json:",omitzero"` SimilarSongsMatchThreshold int RecentlyAddedByModTime bool PreferSortTags bool @@ -82,6 +82,7 @@ type configOptions struct { DefaultTheme string DefaultLanguage string DefaultUIVolume int + UISearchDebounceMs int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool @@ -251,6 +252,11 @@ type extAuthOptions struct { UserHeader string } +type searchOptions struct { + Backend string + FullString bool +} + var ( Server = &configOptions{} hooks []func() @@ -344,6 +350,8 @@ func Load(noConfigDump bool) { os.Exit(1) } + Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend) + if Server.BaseURL != "" { u, err := url.Parse(Server.BaseURL) if err != nil { @@ -392,6 +400,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("Scanner.GenreSeparators", "") logDeprecatedOptions("Scanner.GroupAlbumReleases", "") logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored + logDeprecatedOptions("SearchFullString", "Search.FullString") logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") @@ -539,6 +548,17 @@ func validateSchedule(schedule, field string) (string, error) { return schedule, err } +func normalizeSearchBackend(value string) string { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "fts", "legacy": + return v + default: + log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value) + return "fts" + } +} + // AddHook is used to register initialization code that should run as soon as the config is loaded func AddHook(hook func()) { hooks = append(hooks, hook) @@ -585,7 +605,8 @@ func setViperDefaults() { viper.SetDefault("enablemediafilecoverart", true) viper.SetDefault("autotranscodedownload", false) viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) - viper.SetDefault("searchfullstring", false) + viper.SetDefault("search.fullstring", false) + viper.SetDefault("search.backend", "fts") viper.SetDefault("similarsongsmatchthreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) @@ -604,6 +625,7 @@ func setViperDefaults() { viper.SetDefault("defaulttheme", "Dark") viper.SetDefault("defaultlanguage", "") viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) + viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs) viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 90604f54f..b4ed6ca2d 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -52,6 +52,20 @@ var _ = Describe("Configuration", func() { }) }) + DescribeTable("NormalizeSearchBackend", + func(input, expected string) { + Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected)) + }, + Entry("accepts 'fts'", "fts", "fts"), + Entry("accepts 'legacy'", "legacy", "legacy"), + Entry("normalizes 'FTS' to lowercase", "FTS", "fts"), + Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"), + Entry("trims whitespace", " fts ", "fts"), + Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"), + Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"), + Entry("falls back to 'fts' for empty string", "", "fts"), + ) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index abd2fa5a4..7344dc4ca 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -7,3 +7,5 @@ func ResetConf() { var SetViperDefaults = setViperDefaults var ParseLanguages = parseLanguages + +var NormalizeSearchBackend = normalizeSearchBackend diff --git a/consts/consts.go b/consts/consts.go index eb10fdc03..ebde9d1d9 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -66,11 +66,12 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - UICoverArtSize = 300 - DefaultUIVolume = 100 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + UICoverArtSize = 300 + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 849ddd6fc..f059d739a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -208,7 +208,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize data.Config.ImageCacheSize = conf.Server.ImageCacheSize data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds())) - data.Config.SearchFullString = conf.Server.SearchFullString + data.Config.SearchFullString = conf.Server.Search.FullString + data.Config.SearchBackend = conf.Server.Search.Backend data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime data.Config.PreferSortTags = conf.Server.PreferSortTags data.Config.BackupSchedule = conf.Server.Backup.Schedule diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 99eb542a7..5580d895d 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -68,6 +68,7 @@ type Data struct { EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` SearchFullString bool `json:"searchFullString,omitempty"` + SearchBackend string `json:"searchBackend,omitempty"` RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` PreferSortTags bool `json:"preferSortTags,omitempty"` BackupSchedule string `json:"backupSchedule,omitempty"` diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go new file mode 100644 index 000000000..dc4cd647b --- /dev/null +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -0,0 +1,391 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddFts5Search, downAddFts5Search) +} + +// stripPunct generates a SQL expression that strips common punctuation from a column or expression. +// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized. +func stripPunct(col string) string { + return fmt.Sprintf( + `REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`, + col, + ) +} + +func upAddFts5Search(ctx context.Context, tx *sql.Tx) error { + notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") + + // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) + if err != nil { + return fmt.Errorf("adding search_participants to media_file: %w", err) + } + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`) + if err != nil { + return fmt.Errorf("adding search_normalized to media_file: %w", err) + } + _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) + if err != nil { + return fmt.Errorf("adding search_participants to album: %w", err) + } + _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`) + if err != nil { + return fmt.Errorf("adding search_normalized to album: %w", err) + } + _, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`) + if err != nil { + return fmt.Errorf("adding search_normalized to artist: %w", err) + } + + // Step 2: Populate search_participants from participants JSON. + // Extract all "name" values from the participants JSON structure. + // participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]} + // We use json_each + json_extract to flatten all names into a space-separated string. + _, err = tx.ExecContext(ctx, ` + UPDATE media_file SET search_participants = COALESCE( + (SELECT group_concat(json_extract(je2.value, '$.name'), ' ') + FROM json_each(media_file.participants) AS je1, + json_each(je1.value) AS je2 + WHERE json_extract(je2.value, '$.name') IS NOT NULL), + '' + ) + WHERE participants IS NOT NULL AND participants != '' AND participants != '{}' + `) + if err != nil { + return fmt.Errorf("populating media_file search_participants: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + UPDATE album SET search_participants = COALESCE( + (SELECT group_concat(json_extract(je2.value, '$.name'), ' ') + FROM json_each(album.participants) AS je1, + json_each(je1.value) AS je2 + WHERE json_extract(je2.value, '$.name') IS NOT NULL), + '' + ) + WHERE participants IS NOT NULL AND participants != '' AND participants != '{}' + `) + if err != nil { + return fmt.Errorf("populating album search_participants: %w", err) + } + + // Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation. + // The Go code will compute the precise value on next scan; this is a best-effort approximation. + _, err = tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE artist SET search_normalized = %s + WHERE name != %s`, + stripPunct("name"), stripPunct("name"))) + if err != nil { + return fmt.Errorf("populating artist search_normalized: %w", err) + } + + _, err = tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE album SET search_normalized = TRIM(%s || ' ' || %s) + WHERE name != %s OR COALESCE(album_artist, '') != %s`, + stripPunct("name"), stripPunct("COALESCE(album_artist, '')"), + stripPunct("name"), stripPunct("COALESCE(album_artist, '')"))) + if err != nil { + return fmt.Errorf("populating album search_normalized: %w", err) + } + + _, err = tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE media_file SET search_normalized = + TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s) + WHERE title != %s + OR COALESCE(album, '') != %s + OR COALESCE(artist, '') != %s + OR COALESCE(album_artist, '') != %s`, + stripPunct("title"), stripPunct("COALESCE(album, '')"), + stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"), + stripPunct("title"), stripPunct("COALESCE(album, '')"), + stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"))) + if err != nil { + return fmt.Errorf("populating media_file search_normalized: %w", err) + } + + // Step 3: Create FTS5 virtual tables + _, err = tx.ExecContext(ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5( + title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized, + content='', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ) + `) + if err != nil { + return fmt.Errorf("creating media_file_fts: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( + name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized, + content='', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ) + `) + if err != nil { + return fmt.Errorf("creating album_fts: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( + name, sort_artist_name, search_normalized, + content='', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ) + `) + if err != nil { + return fmt.Errorf("creating artist_fts: %w", err) + } + + // Step 4: Bulk-populate FTS5 indexes from existing data + _, err = tx.ExecContext(ctx, ` + INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + SELECT rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''), + COALESCE(search_normalized, '') + FROM media_file + `) + if err != nil { + return fmt.Errorf("populating media_file_fts: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''), + COALESCE(search_participants, ''), COALESCE(discs, ''), + COALESCE(catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(album.tags, '$.albumversion') AS je), ''), + COALESCE(search_normalized, '') + FROM album + `) + if err != nil { + return fmt.Errorf("populating album_fts: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') + FROM artist + `) + if err != nil { + return fmt.Errorf("populating artist_fts: %w", err) + } + + // Step 5: Create triggers for media_file + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN + INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, + NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, + COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), + COALESCE(NEW.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating media_file_fts insert trigger: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN + INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, + OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, + COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), + COALESCE(OLD.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating media_file_fts delete trigger: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file + WHEN + OLD.title IS NOT NEW.title OR + OLD.album IS NOT NEW.album OR + OLD.artist IS NOT NEW.artist OR + OLD.album_artist IS NOT NEW.album_artist OR + OLD.sort_title IS NOT NEW.sort_title OR + OLD.sort_album_name IS NOT NEW.sort_album_name OR + OLD.sort_artist_name IS NOT NEW.sort_artist_name OR + OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR + OLD.disc_subtitle IS NOT NEW.disc_subtitle OR + OLD.search_participants IS NOT NEW.search_participants OR + OLD.search_normalized IS NOT NEW.search_normalized + BEGIN + INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, + OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, + COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), + COALESCE(OLD.search_normalized, '')); + INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, + NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, + COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), + COALESCE(NEW.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating media_file_fts update trigger: %w", err) + } + + // Step 6: Create triggers for album + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN + INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), + COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), + COALESCE(NEW.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(NEW.tags, '$.albumversion') AS je), ''), + COALESCE(NEW.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating album_fts insert trigger: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN + INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), + COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), + COALESCE(OLD.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(OLD.tags, '$.albumversion') AS je), ''), + COALESCE(OLD.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating album_fts delete trigger: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER album_fts_au AFTER UPDATE ON album + WHEN + OLD.name IS NOT NEW.name OR + OLD.sort_album_name IS NOT NEW.sort_album_name OR + OLD.album_artist IS NOT NEW.album_artist OR + OLD.search_participants IS NOT NEW.search_participants OR + OLD.discs IS NOT NEW.discs OR + OLD.catalog_num IS NOT NEW.catalog_num OR + OLD.tags IS NOT NEW.tags OR + OLD.search_normalized IS NOT NEW.search_normalized + BEGIN + INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), + COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), + COALESCE(OLD.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(OLD.tags, '$.albumversion') AS je), ''), + COALESCE(OLD.search_normalized, '')); + INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), + COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), + COALESCE(NEW.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(NEW.tags, '$.albumversion') AS je), ''), + COALESCE(NEW.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating album_fts update trigger: %w", err) + } + + // Step 7: Create triggers for artist + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), + COALESCE(NEW.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating artist_fts insert trigger: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN + INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), + COALESCE(OLD.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating artist_fts delete trigger: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist + WHEN + OLD.name IS NOT NEW.name OR + OLD.sort_artist_name IS NOT NEW.sort_artist_name OR + OLD.search_normalized IS NOT NEW.search_normalized + BEGIN + INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), + COALESCE(OLD.search_normalized, '')); + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), + COALESCE(NEW.search_normalized, '')); + END + `) + if err != nil { + return fmt.Errorf("creating artist_fts update trigger: %w", err) + } + + return nil +} + +func downAddFts5Search(ctx context.Context, tx *sql.Tx) error { + for _, trigger := range []string{ + "media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au", + "album_fts_ai", "album_fts_ad", "album_fts_au", + "artist_fts_ai", "artist_fts_ad", "artist_fts_au", + } { + _, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger) + if err != nil { + return fmt.Errorf("dropping trigger %s: %w", trigger, err) + } + } + + for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} { + _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table) + if err != nil { + return fmt.Errorf("dropping table %s: %w", table, err) + } + } + + // Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN + // on older versions, and the column is harmless if left in place. + return nil +} diff --git a/main.go b/main.go index 65db162ac..b5fb508b4 100644 --- a/main.go +++ b/main.go @@ -9,11 +9,12 @@ import ( //goland:noinspection GoBoolExpressions func main() { - // This import is used to force the inclusion of the `netgo` tag when compiling the project. + // These references force the inclusion of build tags when compiling the project. // If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify - // the `netgo` build tag when compiling the project. + // the required build tags when compiling the project. // To avoid these kind of errors, you should use `make build` to compile the project. _ = buildtags.NETGO + _ = buildtags.SQLITE_FTS5 cmd.Execute() } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 651953a12..58bfcca51 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -62,11 +62,14 @@ func (a *dbAlbum) PostScan() error { func (a *dbAlbum) PostMapArgs(args map[string]any) error { fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist} - fullText = append(fullText, a.Album.Participants.AllNames()...) + participantNames := a.Album.Participants.AllNames() + fullText = append(fullText, participantNames...) fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...) fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...) fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...) args["full_text"] = formatFullText(fullText...) + args["search_participants"] = strings.Join(participantNames, " ") + args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist) args["tags"] = marshalTags(a.Album.Tags) args["participants"] = marshalParticipants(a.Album.Participants) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 8c82dc47c..9fbc6b974 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -56,17 +56,23 @@ var _ = Describe("AlbumRepository", func() { It("returns all records sorted", func() { Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{ albumAbbeyRoad, + albumWithVersion, + albumCJK, albumMultiDisc, albumRadioactivity, albumSgtPeppers, + albumPunctuation, })) }) It("returns all records sorted desc", func() { Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{ + albumPunctuation, albumSgtPeppers, albumRadioactivity, albumMultiDisc, + albumCJK, + albumWithVersion, albumAbbeyRoad, })) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 5623bd7f8..f801787d8 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -102,6 +102,7 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error { similarArtists, _ := json.Marshal(sa) m["similar_artists"] = string(similarArtists) m["full_text"] = formatFullText(a.Name, a.SortArtistName) + m["search_normalized"] = normalizeForFTS(a.Name) // Do not override the sort_artist_name and mbz_artist_id fields if they are empty // TODO: Better way to handle this? diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 18883378d..15340eeb4 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -193,7 +193,7 @@ var _ = Describe("ArtistRepository", func() { Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll()).To(Equal(int64(4))) }) }) @@ -228,13 +228,19 @@ var _ = Describe("ArtistRepository", func() { idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("F")) Expect(idx[0].Artists).To(HaveLen(1)) Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) Expect(idx[1].ID).To(Equal("K")) Expect(idx[1].Artists).To(HaveLen(1)) Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + Expect(idx[2].ID).To(Equal("R")) + Expect(idx[2].Artists).To(HaveLen(1)) + Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) + Expect(idx[3].ID).To(Equal("S")) + Expect(idx[3].Artists).To(HaveLen(1)) + Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) // Restore the original value artistBeatles.SortArtistName = "" @@ -246,13 +252,19 @@ var _ = Describe("ArtistRepository", func() { XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) Expect(idx[0].Artists).To(HaveLen(1)) Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) Expect(idx[1].ID).To(Equal("K")) Expect(idx[1].Artists).To(HaveLen(1)) Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + Expect(idx[2].ID).To(Equal("R")) + Expect(idx[2].Artists).To(HaveLen(1)) + Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) + Expect(idx[3].ID).To(Equal("S")) + Expect(idx[3].Artists).To(HaveLen(1)) + Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) }) }) @@ -268,13 +280,19 @@ var _ = Describe("ArtistRepository", func() { idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) Expect(idx[0].Artists).To(HaveLen(1)) Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) Expect(idx[1].ID).To(Equal("K")) Expect(idx[1].Artists).To(HaveLen(1)) Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + Expect(idx[2].ID).To(Equal("R")) + Expect(idx[2].Artists).To(HaveLen(1)) + Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) + Expect(idx[3].ID).To(Equal("S")) + Expect(idx[3].Artists).To(HaveLen(1)) + Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) // Restore the original value artistBeatles.SortArtistName = "" @@ -285,13 +303,19 @@ var _ = Describe("ArtistRepository", func() { It("returns the index when SortArtistName is empty", func() { idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) Expect(idx[0].Artists).To(HaveLen(1)) Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) Expect(idx[1].ID).To(Equal("K")) Expect(idx[1].Artists).To(HaveLen(1)) Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + Expect(idx[2].ID).To(Equal("R")) + Expect(idx[2].Artists).To(HaveLen(1)) + Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) + Expect(idx[3].ID).To(Equal("S")) + Expect(idx[3].Artists).To(HaveLen(1)) + Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) }) }) @@ -377,7 +401,7 @@ var _ = Describe("ArtistRepository", func() { // Admin users can see all content when valid library IDs are provided idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) // With non-existent library ID, admin users see no content because no artists are associated with that library idx, err = repo.GetIndex(false, []int{999}) @@ -625,11 +649,11 @@ var _ = Describe("ArtistRepository", func() { It("sees all artists regardless of library permissions", func() { count, err := repo.CountAll() Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(2))) + Expect(count).To(Equal(int64(4))) artists, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(2)) + Expect(artists).To(HaveLen(4)) exists, err := repo.Exists(artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) @@ -661,7 +685,7 @@ var _ = Describe("ArtistRepository", func() { // Should see missing artist in GetAll by default for admin users artists, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(3)) // Including the missing artist + Expect(artists).To(HaveLen(5)) // Including the missing artist // Search never returns missing artists (hardcoded behavior) results, err := repo.Search("Missing Artist", 0, 10) @@ -767,19 +791,19 @@ var _ = Describe("ArtistRepository", func() { It("CountAll returns correct count after gaining access", func() { count, err := restrictedRepo.CountAll() Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk + Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots }) It("GetAll returns artists after gaining access", func() { artists, err := restrictedRepo.GetAll() Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(2)) + Expect(artists).To(HaveLen(4)) var names []string for _, artist := range artists { names = append(names, artist.Name) } - Expect(names).To(ContainElements("The Beatles", "Kraftwerk")) + Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots")) }) It("Exists returns true for accessible artists", func() { @@ -796,7 +820,7 @@ var _ = Describe("ArtistRepository", func() { // With valid library access, should see artists idx, err := restrictedRepo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) // With non-existent library ID, should see nothing (non-admin user) idx, err = restrictedRepo.GetIndex(false, []int{999}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 617cce4c8..264be6f3f 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -58,8 +58,11 @@ func (m *dbMediaFile) PostScan() error { func (m *dbMediaFile) PostMapArgs(args map[string]any) error { fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist, m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle} - fullText = append(fullText, m.MediaFile.Participants.AllNames()...) + participantNames := m.MediaFile.Participants.AllNames() + fullText = append(fullText, participantNames...) args["full_text"] = formatFullText(fullText...) + args["search_participants"] = strings.Join(participantNames, " ") + args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) args["tags"] = marshalTags(m.MediaFile.Tags) args["participants"] = marshalParticipants(m.MediaFile.Participants) return nil diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 84cfd464b..853480b32 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() { }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll()).To(Equal(int64(10))) + Expect(mr.CountAll()).To(Equal(int64(13))) }) Describe("CountBySuffix", func() { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 559ca3d4c..0ee1570a1 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -56,12 +56,22 @@ func al(al model.Album) model.Album { return al } +func alWithTags(a model.Album, tags model.Tags) model.Album { + a = al(a) + a.Tags = tags + return a +} + var ( - artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"} - artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"} - testArtists = model.Artists{ + artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"} + artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"} + artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"} + artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"} + testArtists = model.Artists{ artistKraftwerk, artistBeatles, + artistCJK, + artistPunctuation, } ) @@ -70,11 +80,18 @@ var ( albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2}) albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4}) - testAlbums = model.Albums{ + albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1}) + albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019}, + model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}) + albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1}) + testAlbums = model.Albums{ albumSgtPeppers, albumAbbeyRoad, albumRadioactivity, albumMultiDisc, + albumCJK, + albumWithVersion, + albumPunctuation, } ) @@ -101,6 +118,9 @@ var ( songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")}) + songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")}) + songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")}) testSongs = model.MediaFiles{ songDayInALife, songComeTogether, @@ -112,6 +132,9 @@ var ( songDisc1Track01, songDisc2Track01, songDisc1Track02, + songCJK, + songVersioned, + songPunctuation, } ) diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index ff0d06a8b..27207c45d 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -109,9 +109,10 @@ func booleanFilter(field string, value any) Sqlizer { func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { return func(field string, value any) Sqlizer { v := strings.ToLower(value.(string)) + searchExpr := getSearchExpr() cond := cmp.Or( mbidExpr(tableName, v, mbidFields...), - fullTextExpr(tableName, v), + searchExpr(tableName, v), ) return cond } diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go index e3399df48..ea0f802af 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -26,7 +26,9 @@ var _ = Describe("sqlRestful", func() { Expect(r.parseRestFilters(context.Background(), options)).To(BeNil()) }) - It(`returns nil if tries a filter with fullTextExpr("'")`, func() { + It(`returns nil if tries a filter with legacySearchExpr("'")`, func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" r.filterMappings = map[string]filterFunc{ "name": fullTextFilter("table"), } @@ -77,6 +79,7 @@ var _ = Describe("sqlRestful", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" tableName = "test_table" mbidFields = []string{"mbid", "artist_mbid"} filter = fullTextFilter(tableName, mbidFields...) @@ -136,7 +139,7 @@ var _ = Describe("sqlRestful", func() { Context("when SearchFullString config changes behavior", func() { It("uses different separator with SearchFullString=false", func() { - conf.Server.SearchFullString = false + conf.Server.Search.FullString = false result := filter("search", "test query") andCondition, ok := result.(squirrel.And) @@ -149,7 +152,7 @@ var _ = Describe("sqlRestful", func() { }) It("uses no separator with SearchFullString=true", func() { - conf.Server.SearchFullString = true + conf.Server.Search.FullString = true result := filter("search", "test query") andCondition, ok := result.(squirrel.And) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 0d3bfb743..e5c245bdf 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -6,6 +6,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) @@ -15,6 +16,26 @@ func formatFullText(text ...string) string { return " " + fullText } +// searchExprFunc is the function signature for search expression builders. +type searchExprFunc func(tableName string, query string) Sqlizer + +// getSearchExpr returns the active search expression function based on config. +// It falls back to legacySearchExpr when Search.FullString is enabled, because +// FTS5 is token-based and cannot match substrings within words. +// CJK queries are routed to likeSearchExpr, since FTS5's unicode61 tokenizer +// cannot segment CJK text. +func getSearchExpr() searchExprFunc { + if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString { + return legacySearchExpr + } + return func(tableName, query string) Sqlizer { + if containsCJK(query) { + return likeSearchExpr(tableName, query) + } + return ftsSearchExpr(tableName, query) + } +} + // doSearch performs a full-text search with the specified parameters. // The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like // OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter @@ -26,7 +47,8 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, re return nil } - filter := fullTextExpr(r.tableName, q) + searchExpr := getSearchExpr() + filter := searchExpr(r.tableName, q) if filter != nil { sq = sq.Where(filter) sq = sq.OrderBy(orderBys...) @@ -59,13 +81,16 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { return Or(cond) } -func fullTextExpr(tableName string, s string) Sqlizer { +// legacySearchExpr generates LIKE-based search filters against the full_text column. +// This is the original search implementation, used when Search.Backend="legacy". +func legacySearchExpr(tableName string, s string) Sqlizer { q := str.SanitizeStrings(s) if q == "" { + log.Trace("Search using legacy backend, query is empty", "table", tableName) return nil } var sep string - if !conf.Server.SearchFullString { + if !conf.Server.Search.FullString { sep = " " } parts := strings.Split(q, " ") @@ -73,5 +98,6 @@ func fullTextExpr(tableName string, s string) Sqlizer { for _, part := range parts { filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"}) } + log.Trace("Search using legacy backend", "query", filters, "table", tableName) return filters } diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go new file mode 100644 index 000000000..25f16cb8d --- /dev/null +++ b/persistence/sql_search_fts.go @@ -0,0 +1,261 @@ +package persistence + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" +) + +// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters. +// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire +// CJK phrases as single tokens, making token-based search ineffective for CJK content. +func containsCJK(s string) bool { + for _, r := range s { + if unicode.Is(unicode.Han, r) || + unicode.Is(unicode.Hiragana, r) || + unicode.Is(unicode.Katakana, r) || + unicode.Is(unicode.Hangul, r) { + return true + } + } + return false +} + +// fts5SpecialChars matches characters that should be stripped from user input. +// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes), +// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61 +// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors +// as unbalanced string delimiters. +var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`) + +// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes). +// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM"). +var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`) + +// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive). +var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) + +// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). +var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) + +// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word, +// and returns a space-separated string of words that changed after stripping (deduplicated). +// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC". +func normalizeForFTS(values ...string) string { + seen := make(map[string]struct{}) + var result []string + for _, v := range values { + for _, word := range strings.Fields(v) { + stripped := fts5PunctStrip.ReplaceAllString(word, "") + if stripped == "" || stripped == word { + continue + } + lower := strings.ToLower(stripped) + if _, ok := seen[lower]; ok { + continue + } + seen[lower] = struct{}{} + result = append(result, stripped) + } + } + return strings.Join(result, " ") +} + +// isSingleUnicodeLetter returns true if token is exactly one Unicode letter. +func isSingleUnicodeLetter(token string) bool { + r, size := utf8.DecodeRuneInString(token) + return size == len(token) && size > 0 && unicode.IsLetter(r) +} + +// namePunctuation is the set of characters commonly used as separators in artist/album +// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates +// for punctuated-word processing; other special characters (^, :, &) are just stripped. +const namePunctuation = `-/.''` + +// processPunctuatedWords handles words with embedded name punctuation before the general +// special-character stripping. For each punctuated word it produces either: +// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M" +// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*) +func processPunctuatedWords(input string, phrases []string) (string, []string) { + words := strings.Fields(input) + var result []string + for _, w := range words { + if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) { + result = append(result, w) + continue + } + concat := fts5PunctStrip.ReplaceAllString(w, "") + if concat == "" || concat == w { + result = append(result, w) + continue + } + subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " ")) + if len(subTokens) < 2 { + // Single sub-token after splitting (e.g., N' → N): just use the stripped form + result = append(result, concat) + continue + } + // Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only + if isDottedAbbreviation(w, subTokens) { + phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " "))) + } else { + // Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized + phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat)) + } + result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1)) + } + return strings.Join(result, " "), phrases +} + +// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens +// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC"). +func isDottedAbbreviation(w string, subTokens []string) bool { + for _, r := range w { + if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' { + return false + } + } + for _, st := range subTokens { + if !isSingleUnicodeLetter(st) { + return false + } + } + return true +} + +// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression. +// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators +// (by lowercasing them, since FTS5 operators are case-sensitive) and strips +// special characters to prevent query injection. +func buildFTS5Query(userInput string) string { + q := strings.TrimSpace(userInput) + if q == "" { + return "" + } + + var phrases []string + result := q + for { + start := strings.Index(result, `"`) + if start == -1 { + break + } + end := strings.Index(result[start+1:], `"`) + if end == -1 { + // Unmatched quote — remove it + result = result[:start] + result[start+1:] + break + } + end += start + 1 + phrase := result[start : end+1] // includes quotes + phrases = append(phrases, phrase) + result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:] + } + + // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive: + // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens) + result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower) + + // Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping + result, phrases = processPunctuatedWords(result, phrases) + + result = fts5SpecialChars.ReplaceAllString(result, " ") + result = fts5LeadingStar.ReplaceAllString(result, "$1") + tokens := strings.Fields(result) + + // Append * to plain tokens for prefix matching (e.g., "love" → "love*"). + // Skip tokens that are already wildcarded or are quoted phrase placeholders. + for i, t := range tokens { + if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") { + continue + } + tokens[i] = t + "*" + } + + result = strings.Join(tokens, " ") + + for i, phrase := range phrases { + placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) + result = strings.ReplaceAll(result, placeholder, phrase) + } + + return result +} + +// likeSearchColumns defines the core columns to search with LIKE queries. +// These are the primary user-visible fields for each entity type. +// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). +var likeSearchColumns = map[string][]string{ + "media_file": {"title", "album", "artist", "album_artist"}, + "album": {"name", "album_artist"}, + "artist": {"name"}, +} + +// likeSearchExpr generates LIKE-based search filters against core columns. +// Each word in the query must match at least one column (AND between words), +// and each word can match any column (OR within a word). +// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). +func likeSearchExpr(tableName string, s string) Sqlizer { + s = strings.TrimSpace(s) + if s == "" { + log.Trace("Search using LIKE backend, query is empty", "table", tableName) + return nil + } + columns, ok := likeSearchColumns[tableName] + if !ok { + log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName) + return nil + } + words := strings.Fields(s) + wordFilters := And{} + for _, word := range words { + colFilters := Or{} + for _, col := range columns { + colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"}) + } + wordFilters = append(wordFilters, colFilters) + } + log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName) + return wordFilters +} + +// ftsSearchColumns defines which FTS5 columns are included in general search. +// Columns not listed here are indexed but not searched by default, +// enabling future additions (comments, lyrics, bios) without affecting general search. +var ftsSearchColumns = map[string]string{ + "media_file": "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}", + "album": "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}", + "artist": "{name sort_artist_name search_normalized}", +} + +// ftsSearchExpr generates an FTS5 MATCH-based search filter. +// If the query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!"), +// it falls back to LIKE-based search. +func ftsSearchExpr(tableName string, s string) Sqlizer { + q := buildFTS5Query(s) + if q == "" { + s = strings.TrimSpace(s) + if s != "" { + log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s) + return likeSearchExpr(tableName, s) + } + return nil + } + ftsTable := tableName + "_fts" + matchExpr := q + if cols, ok := ftsSearchColumns[tableName]; ok { + matchExpr = cols + " : (" + q + ")" + } + + filter := Expr( + tableName+".rowid IN (SELECT rowid FROM "+ftsTable+" WHERE "+ftsTable+" MATCH ?)", + matchExpr, + ) + log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", filter) + return filter +} diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go new file mode 100644 index 000000000..317252950 --- /dev/null +++ b/persistence/sql_search_fts_test.go @@ -0,0 +1,333 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("buildFTS5Query", + func(input, expected string) { + Expect(buildFTS5Query(input)).To(Equal(expected)) + }, + Entry("returns empty string for empty input", "", ""), + Entry("returns empty string for whitespace-only input", " ", ""), + Entry("appends * to a single word for prefix matching", "beatles", "beatles*"), + Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"), + Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`), + Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"), + Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"), + Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"), + Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`), + Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"), + Entry("collapses multiple spaces", "abbey road", "abbey* road*"), + Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"), + Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"), + Entry("strips standalone *", "*", ""), + Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"), + Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), + Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), + Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), + Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), + Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), + Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`), + Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"), + Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"), + Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), + Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`), + Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), + Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"), + Entry("does not collapse single standalone letter", "A test", "A* test*"), + Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), + Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), + Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), + Entry("returns empty string for mixed punctuation", "!@#$%^&", ""), +) + +var _ = DescribeTable("normalizeForFTS", + func(expected string, values ...string) { + Expect(normalizeForFTS(values...)).To(Equal(expected)) + }, + Entry("strips dots and concatenates", "REM", "R.E.M."), + Entry("strips slash", "ACDC", "AC/DC"), + Entry("strips hyphen", "Aha", "A-ha"), + Entry("skips unchanged words", "", "The Beatles"), + Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), + Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), + Entry("strips apostrophe from word", "N", "Guns N' Roses"), + Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), +) + +var _ = DescribeTable("containsCJK", + func(input string, expected bool) { + Expect(containsCJK(input)).To(Equal(expected)) + }, + Entry("returns false for empty string", "", false), + Entry("returns false for ASCII text", "hello world", false), + Entry("returns false for Latin with diacritics", "Björk début", false), + Entry("detects Chinese characters (Han)", "周杰伦", true), + Entry("detects Japanese Hiragana", "こんにちは", true), + Entry("detects Japanese Katakana", "カタカナ", true), + Entry("detects Korean Hangul", "한국어", true), + Entry("detects CJK mixed with Latin", "best of 周杰伦", true), + Entry("detects single CJK character", "a曲b", true), +) + +var _ = Describe("likeSearchExpr", func() { + It("returns nil for empty query", func() { + Expect(likeSearchExpr("media_file", "")).To(BeNil()) + }) + + It("returns nil for whitespace-only query", func() { + Expect(likeSearchExpr("media_file", " ")).To(BeNil()) + }) + + It("generates LIKE filters against core columns for single CJK word", func() { + expr := likeSearchExpr("media_file", "周杰伦") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should have OR between columns for the single word + Expect(sql).To(ContainSubstring("OR")) + Expect(sql).To(ContainSubstring("media_file.title LIKE")) + Expect(sql).To(ContainSubstring("media_file.album LIKE")) + Expect(sql).To(ContainSubstring("media_file.artist LIKE")) + Expect(sql).To(ContainSubstring("media_file.album_artist LIKE")) + Expect(args).To(HaveLen(4)) + for _, arg := range args { + Expect(arg).To(Equal("%周杰伦%")) + } + }) + + It("generates AND of OR groups for multi-word query", func() { + expr := likeSearchExpr("media_file", "周杰伦 greatest") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two groups AND'd together, each with 4 columns OR'd + Expect(sql).To(ContainSubstring("AND")) + Expect(args).To(HaveLen(8)) + }) + + It("uses correct columns for album table", func() { + expr := likeSearchExpr("album", "周杰伦") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("album.name LIKE")) + Expect(sql).To(ContainSubstring("album.album_artist LIKE")) + Expect(args).To(HaveLen(2)) + }) + + It("uses correct columns for artist table", func() { + expr := likeSearchExpr("artist", "周杰伦") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("artist.name LIKE")) + Expect(args).To(HaveLen(1)) + }) + + It("returns nil for unknown table", func() { + Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil()) + }) +}) + +var _ = Describe("ftsSearchExpr", func() { + It("returns nil for empty query", func() { + Expect(ftsSearchExpr("media_file", "")).To(BeNil()) + }) + + It("generates rowid IN subquery with MATCH and column filter", func() { + expr := ftsSearchExpr("media_file", "beatles") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.rowid IN")) + Expect(sql).To(ContainSubstring("media_file_fts")) + Expect(sql).To(ContainSubstring("MATCH")) + Expect(args).To(HaveLen(1)) + Expect(args[0]).To(HavePrefix("{title album artist album_artist")) + Expect(args[0]).To(ContainSubstring("beatles*")) + }) + + It("generates correct FTS table name per entity", func() { + for _, table := range []string{"media_file", "album", "artist"} { + expr := ftsSearchExpr(table, "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring(table + ".rowid IN")) + Expect(sql).To(ContainSubstring(table + "_fts")) + } + }) + + It("wraps query with column filter for known tables", func() { + expr := ftsSearchExpr("artist", "Beatles") + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) + }) + + It("passes query without column filter for unknown tables", func() { + expr := ftsSearchExpr("unknown_table", "test") + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(Equal("test*")) + }) + + It("preserves phrase queries inside column filter", func() { + expr := ftsSearchExpr("media_file", `"the beatles"`) + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(ContainSubstring(`"the beatles"`)) + }) + + It("preserves prefix queries inside column filter", func() { + expr := ftsSearchExpr("media_file", "beat*") + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(ContainSubstring("beat*")) + }) + + It("falls back to LIKE search for punctuation-only query", func() { + expr := ftsSearchExpr("media_file", "!!!!!!!") + Expect(expr).ToNot(BeNil()) + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + Expect(args).To(ContainElement("%!!!!!!!%")) + }) + + It("returns nil for empty string even with LIKE fallback", func() { + Expect(ftsSearchExpr("media_file", "")).To(BeNil()) + Expect(ftsSearchExpr("media_file", " ")).To(BeNil()) + }) +}) + +var _ = Describe("FTS5 Integration Search", func() { + var ( + mr model.MediaFileRepository + alr model.AlbumRepository + arr model.ArtistRepository + ) + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + conn := GetDBXBuilder() + mr = NewMediaFileRepository(ctx, conn) + alr = NewAlbumRepository(ctx, conn) + arr = NewArtistRepository(ctx, conn) + }) + + Describe("MediaFile search", func() { + It("finds media files by title", func() { + results, err := mr.Search("Radioactivity", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Title).To(Equal("Radioactivity")) + Expect(results[0].ID).To(Equal(songRadioactivity.ID)) + }) + + It("finds media files by artist name", func() { + results, err := mr.Search("Beatles", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + for _, r := range results { + Expect(r.Artist).To(Equal("The Beatles")) + } + }) + }) + + Describe("Album search", func() { + It("finds albums by name", func() { + results, err := alr.Search("Sgt Peppers", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("Sgt Peppers")) + Expect(results[0].ID).To(Equal(albumSgtPeppers.ID)) + }) + + It("finds albums with multi-word search", func() { + results, err := alr.Search("Abbey Road", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + }) + }) + + Describe("Artist search", func() { + It("finds artists by name", func() { + results, err := arr.Search("Kraftwerk", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("Kraftwerk")) + Expect(results[0].ID).To(Equal(artistKraftwerk.ID)) + }) + }) + + Describe("CJK search", func() { + It("finds media files by CJK title", func() { + results, err := mr.Search("プラチナ", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Title).To(Equal("プラチナ・ジェット")) + Expect(results[0].ID).To(Equal(songCJK.ID)) + }) + + It("finds media files by CJK artist name", func() { + results, err := mr.Search("シートベルツ", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Artist).To(Equal("シートベルツ")) + }) + + It("finds albums by CJK artist name", func() { + results, err := alr.Search("シートベルツ", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("COWBOY BEBOP")) + Expect(results[0].ID).To(Equal(albumCJK.ID)) + }) + + It("finds artists by CJK name", func() { + results, err := arr.Search("シートベルツ", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("シートベルツ")) + Expect(results[0].ID).To(Equal(artistCJK.ID)) + }) + }) + + Describe("Album version search", func() { + It("finds albums by version tag via FTS", func() { + results, err := alr.Search("Deluxe", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal(albumWithVersion.ID)) + }) + }) + + Describe("Punctuation-only search", func() { + It("finds media files with punctuation-only title", func() { + results, err := mr.Search("!!!!!!!", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Title).To(Equal("!!!!!!!")) + Expect(results[0].ID).To(Equal(songPunctuation.ID)) + }) + }) + + Describe("Legacy backend fallback", func() { + It("returns results using legacy LIKE-based search when configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + + results, err := mr.Search("Radioactivity", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Title).To(Equal("Radioactivity")) + }) + }) +}) diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go index 6bfd88d9f..b59570af3 100644 --- a/persistence/sql_search_test.go +++ b/persistence/sql_search_test.go @@ -1,6 +1,8 @@ package persistence import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -11,4 +13,99 @@ var _ = Describe("sqlRepository", func() { Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana")) }) }) + + Describe("legacySearchExpr", func() { + It("returns nil for empty query", func() { + Expect(legacySearchExpr("media_file", "")).To(BeNil()) + }) + + It("generates LIKE filter for single word", func() { + expr := legacySearchExpr("media_file", "beatles") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.full_text LIKE")) + Expect(args).To(ContainElement("% beatles%")) + }) + + It("generates AND of LIKE filters for multiple words", func() { + expr := legacySearchExpr("media_file", "abbey road") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("AND")) + Expect(args).To(HaveLen(2)) + }) + }) + + Describe("getSearchExpr", func() { + It("returns ftsSearchExpr by default", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("MATCH")) + }) + + It("returns legacySearchExpr when SearchBackend is legacy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + }) + + It("falls back to legacySearchExpr when SearchFullString is enabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = true + + expr := getSearchExpr()("media_file", "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + }) + + It("routes CJK queries to likeSearchExpr instead of ftsSearchExpr", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "周杰伦") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // CJK should use LIKE, not MATCH + Expect(sql).To(ContainSubstring("LIKE")) + Expect(sql).NotTo(ContainSubstring("MATCH")) + }) + + It("routes non-CJK queries to ftsSearchExpr", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "beatles") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("MATCH")) + }) + + It("uses legacy for CJK when SearchBackend is legacy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "周杰伦") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Legacy should still use full_text column LIKE + Expect(sql).To(ContainSubstring("LIKE")) + Expect(sql).To(ContainSubstring("full_text")) + }) + }) + }) diff --git a/reflex.conf b/reflex.conf index 9633ab910..47dd775ab 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo . +-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 . diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 479f9c680..92214950a 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -363,7 +363,7 @@ func restoreDB() { _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) Expect(err).ToNot(HaveOccurred()) - rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") Expect(err).ToNot(HaveOccurred()) var tables []string for rows.Next() { diff --git a/server/serve_index.go b/server/serve_index.go index b9a3b3a2b..b5b364267 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -54,6 +54,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultTheme": conf.Server.DefaultTheme, "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, + "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, "gaTrackingId": conf.Server.GATrackingID, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 4f179f22a..9d6f480ff 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -85,6 +85,7 @@ var _ = Describe("serveIndex", func() { Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"), Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), + Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index f74ab027e..72c2d9482 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -1,5 +1,6 @@ import React from 'react' import { List as RAList } from 'react-admin' +import config from '../config' import { Pagination } from './Pagination' import { Title } from './index' @@ -13,6 +14,7 @@ export const List = (props) => { args={{ smart_count: 2 }} /> } + debounce={config.uiSearchDebounceMs} perPage={15} pagination={} {...props} diff --git a/ui/src/config.js b/ui/src/config.js index 9582e95ee..5acf10b69 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -20,6 +20,7 @@ const defaultConfig = { defaultTheme: 'Dark', defaultLanguage: '', defaultUIVolume: 100, + uiSearchDebounceMs: 200, enableUserEditing: true, enableSharing: true, shareURL: '', From 1cf3fd91618cfd9eb3f1a4dde7f53b6881da9458 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 21 Feb 2026 18:51:16 -0500 Subject: [PATCH 015/596] fix(scanner): prevent ScanOnStartup when scanner is disabled Gate the ScanOnStartup config on Scanner.Enabled so that setting Scanner.Enabled=false prevents automatic startup scans. Other automatic scan triggers (interrupted scan resume, PID change, post-migration) are preserved regardless of the Enabled flag to maintain data integrity. --- cmd/root.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 74a15abc1..ff9a574ee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -196,7 +196,8 @@ func runInitialScan(ctx context.Context) func() error { if err != nil { return err } - scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged + scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup + scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { s := CreateScanner(ctx) From 76c01566a91f7dda869f38214e79f79da70e8fe6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 21 Feb 2026 18:56:26 -0500 Subject: [PATCH 016/596] test(ui): change datagrid from table to div to fix warning Signed-off-by: Deluan --- ui/src/plugin/PluginList.test.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx index 2fab2a3e0..0ed41b98c 100644 --- a/ui/src/plugin/PluginList.test.jsx +++ b/ui/src/plugin/PluginList.test.jsx @@ -34,9 +34,7 @@ vi.mock('react-admin', async () => { TopToolbar: ({ children }) => (
{children}
), - Datagrid: ({ children }) => ( - {children}
- ), + Datagrid: ({ children }) =>
{children}
, TextField: ({ source }) => , } }) From 7ad290771907e8ce4c1cb0455a7e9a5ef2016c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 21 Feb 2026 19:57:13 -0500 Subject: [PATCH 017/596] refactor: move playlist business logic from repositories to service layer (#5027) * refactor: move playlist business logic from repositories to core.Playlists service Move authorization, permission checks, and orchestration logic from playlist repositories to the core.Playlists service, following the existing pattern used by core.Share and core.Library. Changes: - Expand core.Playlists interface with read, mutation, track management, and REST adapter methods - Add playlistRepositoryWrapper for REST Save/Update/Delete with permission checks (follows Share/Library pattern) - Simplify persistence/playlist_repository.go: remove isWritable(), auth checks from Delete()/Put()/updatePlaylist() - Simplify persistence/playlist_track_repository.go: remove isTracksEditable() and permission checks from Add/Delete/Reorder - Update Subsonic API handlers to route through service - Update Native API handlers to accept core.Playlists instead of model.DataStore * test: add coverage for playlist service methods and REST wrapper Add 30 new tests covering the service methods added during the playlist refactoring: - Delete: owner, admin, denied, not found - Create: new playlist, replace tracks, admin bypass, denied, not found - AddTracks: owner, admin, denied, smart playlist, not found - RemoveTracks: owner, smart playlist denied, non-owner denied - ReorderTrack: owner, smart playlist denied - NewRepository wrapper: Save (owner assignment, ID clearing), Update (owner, admin, denied, ownership change, not found), Delete (delegation with permission checks) Expand mockedPlaylistRepo with Get, Delete, Tracks, GetWithTracks, and rest.Persistable methods. Add mockedPlaylistTrackRepo for track operation verification. * fix: add authorization check to playlist Update method Added ownership verification to the Subsonic Update endpoint in the playlist service layer. The authorization check was present in the old repository code but was not carried over during the refactoring to the service layer, allowing any authenticated user to modify playlists they don't own via the Subsonic API. Also added corresponding tests for the Update method's permission logic. * refactor: improve playlist permission checks and error handling, add e2e tests Signed-off-by: Deluan * refactor: rename core.Playlists to playlists package and update references Signed-off-by: Deluan * refactor: rename playlists_internal_test.go to parse_m3u_test.go and update tests; add new parse_nsp.go and rest_adapter.go files Signed-off-by: Deluan * fix: block track mutations on smart playlists in Create and Update Create now rejects replacing tracks on smart playlists (pre-existing gap). Update now uses checkTracksEditable instead of checkWritable when track changes are requested, restoring the protection that was removed from the repository layer during the refactoring. Metadata-only updates on smart playlists remain allowed. * test: add smart playlist protection tests to ensure readonly behavior and mutation restrictions * refactor: optimize track removal and renumbering in playlists Signed-off-by: Deluan * refactor: implement track reordering in playlists with SQL updates Signed-off-by: Deluan * refactor: wrap track deletion and reordering in transactions for consistency Signed-off-by: Deluan * refactor: remove unused getTracks method from playlistTrackRepository Signed-off-by: Deluan * refactor: optimize playlist track renumbering with CTE-based UPDATE Replace the DELETE + re-INSERT renumbering strategy with a two-step UPDATE approach using a materialized CTE and ROW_NUMBER() window function. The previous approach (SELECT all IDs, DELETE all tracks, re-INSERT in chunks of 200) required 13 SQL operations for a 2000-track playlist. The new approach uses just 2 UPDATEs: first negating all IDs to clear the positive space, then assigning sequential positions via UPDATE...FROM with a CTE. This avoids the UNIQUE constraint violations that affected the original correlated subquery while reducing per-delete request time from ~110ms to ~12ms on a 2000-track playlist. Signed-off-by: Deluan * refactor: rename New function to NewPlaylists for clarity Signed-off-by: Deluan * refactor: update mock playlist repository and tests for consistency Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/scan.go | 4 +- cmd/wire_gen.go | 21 +- core/playlists/import.go | 119 +++++ .../import_test.go} | 68 +-- core/{playlists.go => playlists/parse_m3u.go} | 300 +----------- .../parse_m3u_test.go} | 60 +-- core/playlists/parse_nsp.go | 103 ++++ core/playlists/parse_nsp_test.go | 213 +++++++++ core/playlists/playlists.go | 265 +++++++++++ core/playlists/playlists_suite_test.go | 17 + core/playlists/playlists_test.go | 297 ++++++++++++ core/playlists/rest_adapter.go | 95 ++++ core/playlists/rest_adapter_test.go | 120 +++++ core/wire_providers.go | 3 +- persistence/playlist_repository.go | 75 +-- persistence/playlist_repository_test.go | 73 +++ persistence/playlist_track_repository.go | 65 +-- scanner/controller.go | 8 +- scanner/controller_test.go | 4 +- scanner/folder_entry.go | 4 +- scanner/phase_4_playlists.go | 6 +- scanner/phase_4_playlists_test.go | 4 +- scanner/scanner.go | 4 +- scanner/scanner_benchmark_test.go | 4 +- scanner/scanner_multilibrary_test.go | 4 +- scanner/scanner_selective_test.go | 4 +- scanner/scanner_test.go | 4 +- server/e2e/e2e_suite_test.go | 25 +- server/e2e/subsonic_multilibrary_test.go | 4 +- server/e2e/subsonic_playlists_test.go | 446 +++++++++++++++++- server/e2e/subsonic_scan_test.go | 2 - server/nativeapi/native_api.go | 21 +- server/nativeapi/playlists.go | 70 ++- server/nativeapi/playlists_test.go | 27 +- server/subsonic/api.go | 7 +- server/subsonic/playlists.go | 36 +- server/subsonic/playlists_test.go | 6 +- tests/mock_data_store.go | 2 +- tests/mock_playlist_repo.go | 107 ++++- tests/mock_playlist_track_repo.go | 53 +++ 40 files changed, 2131 insertions(+), 619 deletions(-) create mode 100644 core/playlists/import.go rename core/{playlists_test.go => playlists/import_test.go} (94%) rename core/{playlists.go => playlists/parse_m3u.go} (53%) rename core/{playlists_internal_test.go => playlists/parse_m3u_test.go} (91%) create mode 100644 core/playlists/parse_nsp.go create mode 100644 core/playlists/parse_nsp_test.go create mode 100644 core/playlists/playlists.go create mode 100644 core/playlists/playlists_suite_test.go create mode 100644 core/playlists/playlists_test.go create mode 100644 core/playlists/rest_adapter.go create mode 100644 core/playlists/rest_adapter_test.go create mode 100644 tests/mock_playlist_track_repo.go diff --git a/cmd/scan.go b/cmd/scan.go index daf58b29c..ffb77b108 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -74,7 +74,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := core.NewPlaylists(ds) + pls := playlists.NewPlaylists(ds) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 7a9d38d92..204d90ba8 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" @@ -61,7 +62,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - playlists := core.NewPlaylists(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore) insights := metrics.GetInstance(dataStore) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() @@ -72,12 +73,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager) return router } @@ -98,11 +99,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlists := core.NewPlaylists(dataStore) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playlistsPlaylists := playlists.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics) return router } @@ -165,8 +166,8 @@ func CreateScanner(ctx context.Context) model.Scanner { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlists := core.NewPlaylists(dataStore) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playlistsPlaylists := playlists.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -182,8 +183,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlists := core.NewPlaylists(dataStore) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playlistsPlaylists := playlists.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } diff --git a/core/playlists/import.go b/core/playlists/import.go new file mode 100644 index 000000000..40e230527 --- /dev/null +++ b/core/playlists/import.go @@ -0,0 +1,119 @@ +package playlists + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" + "golang.org/x/text/unicode/norm" +) + +func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + pls, err := s.parsePlaylist(ctx, filename, folder) + if err != nil { + log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) + return nil, err + } + log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) + err = s.updatePlaylist(ctx, pls) + if err != nil { + log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) + } + return pls, err +} + +func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) { + owner, _ := request.UserFrom(ctx) + pls := &model.Playlist{ + OwnerID: owner.ID, + Public: false, + Sync: false, + } + err := s.parseM3U(ctx, pls, nil, reader) + if err != nil { + log.Error(ctx, "Error parsing playlist", err) + return nil, err + } + err = s.ds.Playlist(ctx).Put(pls) + if err != nil { + log.Error(ctx, "Error saving playlist", err) + return nil, err + } + return pls, nil +} + +func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) { + pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile) + if err != nil { + return nil, err + } + + file, err := os.Open(pls.Path) + if err != nil { + return nil, err + } + defer file.Close() + + reader := ioutils.UTF8Reader(file) + extension := strings.ToLower(filepath.Ext(playlistFile)) + switch extension { + case ".nsp": + err = s.parseNSP(ctx, pls, reader) + default: + err = s.parseM3U(ctx, pls, folder, reader) + } + return pls, err +} + +func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { + owner, _ := request.UserFrom(ctx) + + // Try to find existing playlist by path. Since filesystem normalization differs across + // platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match + // playlists that may have been imported on a different platform. + pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) + if errors.Is(err, model.ErrNotFound) { + // Try alternate normalization form + altPath := norm.NFD.String(newPls.Path) + if altPath == newPls.Path { + altPath = norm.NFC.String(newPls.Path) + } + if altPath != newPls.Path { + pls, err = s.ds.Playlist(ctx).FindByPath(altPath) + } + } + if err != nil && !errors.Is(err, model.ErrNotFound) { + return err + } + if err == nil && !pls.Sync { + log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) + return nil + } + + if err == nil { + log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) + newPls.ID = pls.ID + newPls.Name = pls.Name + newPls.Comment = pls.Comment + newPls.OwnerID = pls.OwnerID + newPls.Public = pls.Public + newPls.EvaluatedAt = &time.Time{} + } else { + log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) + newPls.OwnerID = owner.ID + // For NSP files, Public may already be set from the file; for M3U, use server default + if !newPls.IsSmartPlaylist() { + newPls.Public = conf.Server.DefaultPlaylistPublicVisibility + } + } + return s.ds.Playlist(ctx).Put(newPls) +} diff --git a/core/playlists_test.go b/core/playlists/import_test.go similarity index 94% rename from core/playlists_test.go rename to core/playlists/import_test.go index 7712a268e..a42c3f3eb 100644 --- a/core/playlists_test.go +++ b/core/playlists/import_test.go @@ -1,4 +1,4 @@ -package core_test +package playlists_test import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" @@ -19,18 +19,18 @@ import ( "golang.org/x/text/unicode/norm" ) -var _ = Describe("Playlists", func() { +var _ = Describe("Playlists - Import", func() { var ds *tests.MockDataStore - var ps core.Playlists - var mockPlsRepo mockedPlaylistRepo + var ps playlists.Playlists + var mockPlsRepo *tests.MockPlaylistRepo var mockLibRepo *tests.MockLibraryRepo ctx := context.Background() BeforeEach(func() { - mockPlsRepo = mockedPlaylistRepo{} + mockPlsRepo = tests.CreateMockPlaylistRepo() mockLibRepo = &tests.MockLibraryRepo{} ds = &tests.MockDataStore{ - MockedPlaylist: &mockPlsRepo, + MockedPlaylist: mockPlsRepo, MockedLibrary: mockLibRepo, } ctx = request.WithUser(ctx, model.User{ID: "123"}) @@ -39,7 +39,7 @@ var _ = Describe("Playlists", func() { Describe("ImportFile", func() { var folder *model.Folder BeforeEach(func() { - ps = core.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -61,7 +61,7 @@ var _ = Describe("Playlists", func() { Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg")) - Expect(mockPlsRepo.last).To(Equal(pls)) + Expect(mockPlsRepo.Last).To(Equal(pls)) }) It("parses playlists using LF ending", func() { @@ -99,7 +99,7 @@ var _ = Describe("Playlists", func() { It("parses well-formed playlists", func() { pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.last).To(Equal(pls)) + Expect(mockPlsRepo.Last).To(Equal(pls)) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Comment).To(Equal("Recently played tracks")) @@ -149,7 +149,7 @@ var _ = Describe("Playlists", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = core.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -163,7 +163,7 @@ var _ = Describe("Playlists", func() { Path: storedPath, Sync: true, } - mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls} + mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls} // Import using the filesystem's normalization form plsFolder := &model.Folder{ @@ -209,7 +209,7 @@ var _ = Describe("Playlists", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = core.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds) }) It("handles relative paths that reference files in other libraries", func() { @@ -365,7 +365,7 @@ var _ = Describe("Playlists", func() { }, } // Recreate playlists service to pick up new mock - ps = core.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -408,7 +408,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = core.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) @@ -439,7 +439,7 @@ var _ = Describe("Playlists", func() { Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg")) Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac")) Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3")) - Expect(mockPlsRepo.last).To(Equal(pls)) + Expect(mockPlsRepo.Last).To(Equal(pls)) }) It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() { @@ -460,7 +460,7 @@ var _ = Describe("Playlists", func() { Expect(pls.Tracks).To(HaveLen(2)) }) - It("returns only tracks that exist in the database and in the same other as the m3u", func() { + It("returns only tracks that exist in the database and in the same order as the m3u", func() { repo.data = []string{ "album1/test1.mp3", "album2/test2.mp3", @@ -570,7 +570,7 @@ var _ = Describe("Playlists", func() { }) - Describe("InPlaylistsPath", func() { + Describe("InPath", func() { var folder model.Folder BeforeEach(func() { @@ -584,27 +584,27 @@ var _ = Describe("Playlists", func() { It("returns true if PlaylistsPath is empty", func() { conf.Server.PlaylistsPath = "" - Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + Expect(playlists.InPath(folder)).To(BeTrue()) }) It("returns true if PlaylistsPath is any (**/**)", func() { conf.Server.PlaylistsPath = "**/**" - Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + Expect(playlists.InPath(folder)).To(BeTrue()) }) It("returns true if folder is in PlaylistsPath", func() { conf.Server.PlaylistsPath = "other/**:playlists/**" - Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + Expect(playlists.InPath(folder)).To(BeTrue()) }) It("returns false if folder is not in PlaylistsPath", func() { conf.Server.PlaylistsPath = "other" - Expect(core.InPlaylistsPath(folder)).To(BeFalse()) + Expect(playlists.InPath(folder)).To(BeFalse()) }) It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() { conf.Server.PlaylistsPath = "." - Expect(core.InPlaylistsPath(folder)).To(BeFalse()) + Expect(playlists.InPath(folder)).To(BeFalse()) folder2 := model.Folder{ LibraryPath: "/music", @@ -612,7 +612,7 @@ var _ = Describe("Playlists", func() { Name: ".", } - Expect(core.InPlaylistsPath(folder2)).To(BeTrue()) + Expect(playlists.InPath(folder2)).To(BeTrue()) }) }) }) @@ -693,23 +693,3 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi } return mfs, nil } - -type mockedPlaylistRepo struct { - last *model.Playlist - data map[string]*model.Playlist // keyed by path - model.PlaylistRepository -} - -func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { - if r.data != nil { - if pls, ok := r.data[path]; ok { - return pls, nil - } - } - return nil, model.ErrNotFound -} - -func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error { - r.last = pls - return nil -} diff --git a/core/playlists.go b/core/playlists/parse_m3u.go similarity index 53% rename from core/playlists.go rename to core/playlists/parse_m3u.go index 1c070a110..4e79d15c6 100644 --- a/core/playlists.go +++ b/core/playlists/parse_m3u.go @@ -1,183 +1,28 @@ -package core +package playlists import ( "cmp" "context" - "encoding/json" - "errors" "fmt" "io" "net/url" - "os" "path/filepath" "slices" "strings" "time" - "github.com/RaveNoX/go-jsoncommentstrip" - "github.com/bmatcuk/doublestar/v4" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/ioutils" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) -type Playlists interface { - ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) - Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error - ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) -} - -type playlists struct { - ds model.DataStore -} - -func NewPlaylists(ds model.DataStore) Playlists { - return &playlists{ds: ds} -} - -func InPlaylistsPath(folder model.Folder) bool { - if conf.Server.PlaylistsPath == "" { - return true - } - rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) - for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { - if match, _ := doublestar.Match(path, rel); match { - return true - } - } - return false -} - -func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { - pls, err := s.parsePlaylist(ctx, filename, folder) - if err != nil { - log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) - return nil, err - } - log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) - err = s.updatePlaylist(ctx, pls) - if err != nil { - log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) - } - return pls, err -} - -func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) { - owner, _ := request.UserFrom(ctx) - pls := &model.Playlist{ - OwnerID: owner.ID, - Public: false, - Sync: false, - } - err := s.parseM3U(ctx, pls, nil, reader) - if err != nil { - log.Error(ctx, "Error parsing playlist", err) - return nil, err - } - err = s.ds.Playlist(ctx).Put(pls) - if err != nil { - log.Error(ctx, "Error saving playlist", err) - return nil, err - } - return pls, nil -} - -func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) { - pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile) - if err != nil { - return nil, err - } - - file, err := os.Open(pls.Path) - if err != nil { - return nil, err - } - defer file.Close() - - reader := ioutils.UTF8Reader(file) - extension := strings.ToLower(filepath.Ext(playlistFile)) - switch extension { - case ".nsp": - err = s.parseNSP(ctx, pls, reader) - default: - err = s.parseM3U(ctx, pls, folder, reader) - } - return pls, err -} - -func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { - playlistPath := filepath.Join(baseDir, playlistFile) - info, err := os.Stat(playlistPath) - if err != nil { - return nil, err - } - - var extension = filepath.Ext(playlistFile) - var name = playlistFile[0 : len(playlistFile)-len(extension)] - - pls := &model.Playlist{ - Name: name, - Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile), - Public: false, - Path: playlistPath, - Sync: true, - UpdatedAt: info.ModTime(), - } - return pls, nil -} - -func getPositionFromOffset(data []byte, offset int64) (line, column int) { - line = 1 - for _, b := range data[:offset] { - if b == '\n' { - line++ - column = 1 - } else { - column++ - } - } - return -} - -func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error { - nsp := &nspFile{} - reader = io.LimitReader(reader, 100*1024) // Limit to 100KB - reader = jsoncommentstrip.NewReader(reader) - input, err := io.ReadAll(reader) - if err != nil { - return fmt.Errorf("reading SmartPlaylist: %w", err) - } - err = json.Unmarshal(input, nsp) - if err != nil { - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { - line, col := getPositionFromOffset(input, syntaxErr.Offset) - return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) - } - return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err) - } - pls.Rules = &nsp.Criteria - if nsp.Name != "" { - pls.Name = nsp.Name - } - if nsp.Comment != "" { - pls.Comment = nsp.Comment - } - if nsp.Public != nil { - pls.Public = *nsp.Public - } else { - pls.Public = conf.Server.DefaultPlaylistPublicVisibility - } - return nil -} - func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { mediaFileRepository := s.ds.MediaFile(ctx) + resolver, err := newPathResolver(ctx, s.ds) + if err != nil { + return err + } var mfs model.MediaFiles // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000. @@ -202,7 +47,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m } filteredLines = append(filteredLines, line) } - resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines) + resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines) if err != nil { log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err) continue @@ -258,7 +103,9 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m existing[key] = idx } - // Find media files in the order of the resolved paths, to keep playlist order + // Find media files in the order of the resolved paths, to keep playlist order. + // Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath", + // so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant). for _, path := range resolvedPaths { key := strings.ToLower(norm.NFC.String(path)) idx, ok := existing[key] @@ -398,15 +245,10 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { // resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath"). // For relative paths, it resolves them to absolute paths first, then determines which // library they belong to. This allows playlists to reference files across library boundaries. -func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) { - resolver, err := newPathResolver(ctx, s.ds) - if err != nil { - return nil, err - } - +func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) { results := make([]string, 0, len(lines)) for idx, line := range lines { - resolution := resolver.resolvePath(line, folder) + resolution := r.resolvePath(line, folder) if !resolution.valid { log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx) @@ -425,123 +267,3 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line return results, nil } - -func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { - owner, _ := request.UserFrom(ctx) - - // Try to find existing playlist by path. Since filesystem normalization differs across - // platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match - // playlists that may have been imported on a different platform. - pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) - if errors.Is(err, model.ErrNotFound) { - // Try alternate normalization form - altPath := norm.NFD.String(newPls.Path) - if altPath == newPls.Path { - altPath = norm.NFC.String(newPls.Path) - } - if altPath != newPls.Path { - pls, err = s.ds.Playlist(ctx).FindByPath(altPath) - } - } - if err != nil && !errors.Is(err, model.ErrNotFound) { - return err - } - if err == nil && !pls.Sync { - log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) - return nil - } - - if err == nil { - log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) - newPls.ID = pls.ID - newPls.Name = pls.Name - newPls.Comment = pls.Comment - newPls.OwnerID = pls.OwnerID - newPls.Public = pls.Public - newPls.EvaluatedAt = &time.Time{} - } else { - log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) - newPls.OwnerID = owner.ID - // For NSP files, Public may already be set from the file; for M3U, use server default - if !newPls.IsSmartPlaylist() { - newPls.Public = conf.Server.DefaultPlaylistPublicVisibility - } - } - return s.ds.Playlist(ctx).Put(newPls) -} - -func (s *playlists) Update(ctx context.Context, playlistID string, - name *string, comment *string, public *bool, - idsToAdd []string, idxToRemove []int) error { - needsInfoUpdate := name != nil || comment != nil || public != nil - needsTrackRefresh := len(idxToRemove) > 0 - - return s.ds.WithTxImmediate(func(tx model.DataStore) error { - var pls *model.Playlist - var err error - repo := tx.Playlist(ctx) - tracks := repo.Tracks(playlistID, true) - if tracks == nil { - return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID) - } - if needsTrackRefresh { - pls, err = repo.GetWithTracks(playlistID, true, false) - pls.RemoveTracks(idxToRemove) - pls.AddMediaFilesByID(idsToAdd) - } else { - if len(idsToAdd) > 0 { - _, err = tracks.Add(idsToAdd) - if err != nil { - return err - } - } - if needsInfoUpdate { - pls, err = repo.Get(playlistID) - } - } - if err != nil { - return err - } - if !needsTrackRefresh && !needsInfoUpdate { - return nil - } - - if name != nil { - pls.Name = *name - } - if comment != nil { - pls.Comment = *comment - } - if public != nil { - pls.Public = *public - } - // Special case: The playlist is now empty - if len(idxToRemove) > 0 && len(pls.Tracks) == 0 { - if err = tracks.DeleteAll(); err != nil { - return err - } - } - return repo.Put(pls) - }) -} - -type nspFile struct { - criteria.Criteria - Name string `json:"name"` - Comment string `json:"comment"` - Public *bool `json:"public"` -} - -func (i *nspFile) UnmarshalJSON(data []byte) error { - m := map[string]any{} - err := json.Unmarshal(data, &m) - if err != nil { - return err - } - i.Name, _ = m["name"].(string) - i.Comment, _ = m["comment"].(string) - if public, ok := m["public"].(bool); ok { - i.Public = &public - } - return json.Unmarshal(data, &i.Criteria) -} diff --git a/core/playlists_internal_test.go b/core/playlists/parse_m3u_test.go similarity index 91% rename from core/playlists_internal_test.go rename to core/playlists/parse_m3u_test.go index 88e36cc3a..05e1c30e1 100644 --- a/core/playlists_internal_test.go +++ b/core/playlists/parse_m3u_test.go @@ -1,4 +1,4 @@ -package core +package playlists import ( "context" @@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() { }) Describe("resolvePath", func() { - It("resolves absolute paths", func() { - resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil) + Context("basic", func() { + It("resolves absolute paths", func() { + resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil) - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(1)) - Expect(resolution.libraryPath).To(Equal("/music")) - Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3")) + Expect(resolution.valid).To(BeTrue()) + Expect(resolution.libraryID).To(Equal(1)) + Expect(resolution.libraryPath).To(Equal("/music")) + Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3")) + }) + + It("resolves relative paths when folder is provided", func() { + folder := &model.Folder{ + Path: "playlists", + LibraryPath: "/music", + LibraryID: 1, + } + + resolution := resolver.resolvePath("../artist/album/track.mp3", folder) + + Expect(resolution.valid).To(BeTrue()) + Expect(resolution.libraryID).To(Equal(1)) + Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3")) + }) + + It("returns invalid resolution for paths outside any library", func() { + resolution := resolver.resolvePath("/outside/library/track.mp3", nil) + + Expect(resolution.valid).To(BeFalse()) + }) }) - It("resolves relative paths when folder is provided", func() { - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - resolution := resolver.resolvePath("../artist/album/track.mp3", folder) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(1)) - Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3")) - }) - - It("returns invalid resolution for paths outside any library", func() { - resolution := resolver.resolvePath("/outside/library/track.mp3", nil) - - Expect(resolution.valid).To(BeFalse()) - }) - }) - - Describe("resolvePath", func() { - Context("With absolute paths", func() { + Context("cross-library", func() { It("resolves path within a library", func() { resolution := resolver.resolvePath("/music/track.mp3", nil) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go new file mode 100644 index 000000000..c7f7dd946 --- /dev/null +++ b/core/playlists/parse_nsp.go @@ -0,0 +1,103 @@ +package playlists + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/RaveNoX/go-jsoncommentstrip" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" +) + +func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { + playlistPath := filepath.Join(baseDir, playlistFile) + info, err := os.Stat(playlistPath) + if err != nil { + return nil, err + } + + var extension = filepath.Ext(playlistFile) + var name = playlistFile[0 : len(playlistFile)-len(extension)] + + pls := &model.Playlist{ + Name: name, + Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile), + Public: false, + Path: playlistPath, + Sync: true, + UpdatedAt: info.ModTime(), + } + return pls, nil +} + +func getPositionFromOffset(data []byte, offset int64) (line, column int) { + line = 1 + for _, b := range data[:offset] { + if b == '\n' { + line++ + column = 1 + } else { + column++ + } + } + return +} + +func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error { + nsp := &nspFile{} + reader = io.LimitReader(reader, 100*1024) // Limit to 100KB + reader = jsoncommentstrip.NewReader(reader) + input, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("reading SmartPlaylist: %w", err) + } + err = json.Unmarshal(input, nsp) + if err != nil { + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + line, col := getPositionFromOffset(input, syntaxErr.Offset) + return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) + } + return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err) + } + pls.Rules = &nsp.Criteria + if nsp.Name != "" { + pls.Name = nsp.Name + } + if nsp.Comment != "" { + pls.Comment = nsp.Comment + } + if nsp.Public != nil { + pls.Public = *nsp.Public + } else { + pls.Public = conf.Server.DefaultPlaylistPublicVisibility + } + return nil +} + +type nspFile struct { + criteria.Criteria + Name string `json:"name"` + Comment string `json:"comment"` + Public *bool `json:"public"` +} + +func (i *nspFile) UnmarshalJSON(data []byte) error { + m := map[string]any{} + err := json.Unmarshal(data, &m) + if err != nil { + return err + } + i.Name, _ = m["name"].(string) + i.Comment, _ = m["comment"].(string) + if public, ok := m["public"].(bool); ok { + i.Public = &public + } + return json.Unmarshal(data, &i.Criteria) +} diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go new file mode 100644 index 000000000..0a7b2727e --- /dev/null +++ b/core/playlists/parse_nsp_test.go @@ -0,0 +1,213 @@ +package playlists + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseNSP", func() { + var s *playlists + ctx := context.Background() + + BeforeEach(func() { + s = &playlists{} + }) + + It("parses a well-formed NSP with all fields", func() { + nsp := `{ + "name": "My Smart Playlist", + "comment": "A test playlist", + "public": true, + "all": [{"is": {"loved": true}}], + "sort": "title", + "order": "asc", + "limit": 50 + }` + pls := &model.Playlist{Name: "default-name"} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("My Smart Playlist")) + Expect(pls.Comment).To(Equal("A test playlist")) + Expect(pls.Public).To(BeTrue()) + Expect(pls.Rules).ToNot(BeNil()) + Expect(pls.Rules.Sort).To(Equal("title")) + Expect(pls.Rules.Order).To(Equal("asc")) + Expect(pls.Rules.Limit).To(Equal(50)) + Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{})) + }) + + It("keeps existing name when NSP has no name field", func() { + nsp := `{"all": [{"is": {"loved": true}}]}` + pls := &model.Playlist{Name: "Original Name"} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("Original Name")) + }) + + It("keeps existing comment when NSP has no comment field", func() { + nsp := `{"all": [{"is": {"loved": true}}]}` + pls := &model.Playlist{Comment: "Original Comment"} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Comment).To(Equal("Original Comment")) + }) + + It("strips JSON comments before parsing", func() { + nsp := `{ + // Line comment + "name": "Commented Playlist", + /* Block comment */ + "all": [{"is": {"loved": true}}] + }` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("Commented Playlist")) + }) + + It("uses server default when public field is absent", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultPlaylistPublicVisibility = true + + nsp := `{"all": [{"is": {"loved": true}}]}` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Public).To(BeTrue()) + }) + + It("honors explicit public: false over server default", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultPlaylistPublicVisibility = true + + nsp := `{"public": false, "all": [{"is": {"loved": true}}]}` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Public).To(BeFalse()) + }) + + It("returns a syntax error with line and column info", func() { + nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}" + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist")) + Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`)) + }) + + It("returns a parsing error for completely invalid JSON", func() { + nsp := `not json at all` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) + }) + + It("gracefully handles non-string name field", func() { + nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}` + pls := &model.Playlist{Name: "Original"} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + // Type assertion in UnmarshalJSON fails silently; name stays as original + Expect(pls.Name).To(Equal("Original")) + }) + + It("parses criteria with multiple rules", func() { + nsp := `{ + "all": [ + {"is": {"loved": true}}, + {"contains": {"title": "rock"}} + ], + "sort": "lastPlayed", + "order": "desc", + "limit": 100 + }` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Rules).ToNot(BeNil()) + Expect(pls.Rules.Sort).To(Equal("lastPlayed")) + Expect(pls.Rules.Order).To(Equal("desc")) + Expect(pls.Rules.Limit).To(Equal(100)) + }) +}) + +var _ = Describe("getPositionFromOffset", func() { + It("returns correct position on first line", func() { + data := []byte("hello world") + line, col := getPositionFromOffset(data, 5) + Expect(line).To(Equal(1)) + Expect(col).To(Equal(5)) + }) + + It("returns correct position after newlines", func() { + data := []byte("line1\nline2\nline3") + // Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8) + line, col := getPositionFromOffset(data, 8) + Expect(line).To(Equal(2)) + Expect(col).To(Equal(3)) + }) + + It("returns correct position at start of new line", func() { + data := []byte("line1\nline2") + // After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1 + line, col := getPositionFromOffset(data, 6) + Expect(line).To(Equal(2)) + Expect(col).To(Equal(1)) + }) + + It("handles multiple newlines", func() { + data := []byte("a\nb\nc\nd") + // a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6) + line, col := getPositionFromOffset(data, 6) + Expect(line).To(Equal(4)) + Expect(col).To(Equal(1)) + }) +}) + +var _ = Describe("newSyncedPlaylist", func() { + var s *playlists + + BeforeEach(func() { + s = &playlists{} + }) + + It("creates a synced playlist with correct attributes", func() { + tmpDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed()) + + pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("test")) + Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'")) + Expect(pls.Public).To(BeFalse()) + Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u"))) + Expect(pls.Sync).To(BeTrue()) + Expect(pls.UpdatedAt).ToNot(BeZero()) + }) + + It("strips extension from filename to derive name", func() { + tmpDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed()) + + pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("My Favorites")) + }) + + It("returns error for non-existent file", func() { + tmpDir := GinkgoT().TempDir() + _, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u") + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go new file mode 100644 index 000000000..5a9a908ee --- /dev/null +++ b/core/playlists/playlists.go @@ -0,0 +1,265 @@ +package playlists + +import ( + "context" + "io" + "path/filepath" + "strconv" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +type Playlists interface { + // Reads + GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) + Get(ctx context.Context, id string) (*model.Playlist, error) + GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) + GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) + + // Mutations + Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) + Delete(ctx context.Context, id string) error + Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error + + // Track management + AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) + AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) + AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) + AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) + RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error + ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error + + // Import + ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) + ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) + + // REST adapters (follows Share/Library pattern) + NewRepository(ctx context.Context) rest.Repository + TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository +} + +type playlists struct { + ds model.DataStore +} + +func NewPlaylists(ds model.DataStore) Playlists { + return &playlists{ds: ds} +} + +func InPath(folder model.Folder) bool { + if conf.Server.PlaylistsPath == "" { + return true + } + rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) + for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { + if match, _ := doublestar.Match(path, rel); match { + return true + } + } + return false +} + +// --- Read operations --- + +func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { + return s.ds.Playlist(ctx).GetAll(options...) +} + +func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) { + return s.ds.Playlist(ctx).Get(id) +} + +func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) { + return s.ds.Playlist(ctx).GetWithTracks(id, true, false) +} + +func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { + return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) +} + +// --- Mutation operations --- + +// Create creates a new playlist (when name is provided) or replaces tracks on an existing +// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics. +func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) { + usr, _ := request.UserFrom(ctx) + err := s.ds.WithTxImmediate(func(tx model.DataStore) error { + var pls *model.Playlist + var err error + + if playlistId != "" { + pls, err = tx.Playlist(ctx).Get(playlistId) + if err != nil { + return err + } + if pls.IsSmartPlaylist() { + return model.ErrNotAuthorized + } + if !usr.IsAdmin && pls.OwnerID != usr.ID { + return model.ErrNotAuthorized + } + } else { + pls = &model.Playlist{Name: name} + pls.OwnerID = usr.ID + } + pls.Tracks = nil + pls.AddMediaFilesByID(ids) + + err = tx.Playlist(ctx).Put(pls) + playlistId = pls.ID + return err + }) + return playlistId, err +} + +func (s *playlists) Delete(ctx context.Context, id string) error { + if _, err := s.checkWritable(ctx, id); err != nil { + return err + } + return s.ds.Playlist(ctx).Delete(id) +} + +func (s *playlists) Update(ctx context.Context, playlistID string, + name *string, comment *string, public *bool, + idsToAdd []string, idxToRemove []int) error { + var pls *model.Playlist + var err error + hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0 + if hasTrackChanges { + pls, err = s.checkTracksEditable(ctx, playlistID) + } else { + pls, err = s.checkWritable(ctx, playlistID) + } + if err != nil { + return err + } + return s.ds.WithTxImmediate(func(tx model.DataStore) error { + repo := tx.Playlist(ctx) + + if len(idxToRemove) > 0 { + tracksRepo := repo.Tracks(playlistID, false) + // Convert 0-based indices to 1-based position IDs and delete them directly, + // avoiding the need to load all tracks into memory. + positions := make([]string, len(idxToRemove)) + for i, idx := range idxToRemove { + positions[i] = strconv.Itoa(idx + 1) + } + if err := tracksRepo.Delete(positions...); err != nil { + return err + } + if len(idsToAdd) > 0 { + if _, err := tracksRepo.Add(idsToAdd); err != nil { + return err + } + } + return s.updateMetadata(ctx, tx, pls, name, comment, public) + } + + if len(idsToAdd) > 0 { + if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil { + return err + } + } + if name == nil && comment == nil && public == nil { + return nil + } + // Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters) + return s.updateMetadata(ctx, tx, pls, name, comment, public) + }) +} + +// --- Permission helpers --- + +// checkWritable fetches the playlist and verifies the current user can modify it. +func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) { + pls, err := s.ds.Playlist(ctx).Get(id) + if err != nil { + return nil, err + } + usr, _ := request.UserFrom(ctx) + if !usr.IsAdmin && pls.OwnerID != usr.ID { + return nil, model.ErrNotAuthorized + } + return pls, nil +} + +// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist). +func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { + pls, err := s.checkWritable(ctx, playlistID) + if err != nil { + return nil, err + } + if pls.IsSmartPlaylist() { + return nil, model.ErrNotAuthorized + } + return pls, nil +} + +// updateMetadata applies optional metadata changes to a playlist and persists it. +// Accepts a DataStore parameter so it can be used inside transactions. +// The caller is responsible for permission checks. +func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error { + if name != nil { + pls.Name = *name + } + if comment != nil { + pls.Comment = *comment + } + if public != nil { + pls.Public = *public + } + return ds.Playlist(ctx).Put(pls) +} + +// --- Track management operations --- + +func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids) +} + +func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds) +} + +func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds) +} + +func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs) +} + +func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return err + } + return s.ds.WithTx(func(tx model.DataStore) error { + return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...) + }) +} + +func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return err + } + return s.ds.WithTx(func(tx model.DataStore) error { + return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) + }) +} diff --git a/core/playlists/playlists_suite_test.go b/core/playlists/playlists_suite_test.go new file mode 100644 index 000000000..b57248490 --- /dev/null +++ b/core/playlists/playlists_suite_test.go @@ -0,0 +1,17 @@ +package playlists_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlaylists(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Playlists Suite") +} diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go new file mode 100644 index 000000000..a4c309d77 --- /dev/null +++ b/core/playlists/playlists_test.go @@ -0,0 +1,297 @@ +package playlists_test + +import ( + "context" + + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + var ds *tests.MockDataStore + var ps playlists.Playlists + var mockPlsRepo *tests.MockPlaylistRepo + ctx := context.Background() + + BeforeEach(func() { + mockPlsRepo = tests.CreateMockPlaylistRepo() + ds = &tests.MockDataStore{ + MockedPlaylist: mockPlsRepo, + MockedLibrary: &tests.MockLibraryRepo{}, + } + ctx = request.WithUser(ctx, model.User{ID: "123"}) + }) + + Describe("Delete", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds) + }) + + It("allows owner to delete their playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Delete(ctx, "pls-1") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) + }) + + It("allows admin to delete any playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) + err := ps.Delete(ctx, "pls-1") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) + }) + + It("denies non-owner, non-admin from deleting", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + err := ps.Delete(ctx, "pls-1") + Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(mockPlsRepo.Deleted).To(BeEmpty()) + }) + + It("returns error when playlist not found", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Delete(ctx, "nonexistent") + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + + Describe("Create", func() { + BeforeEach(func() { + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"}, + "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + ps = playlists.NewPlaylists(ds) + }) + + It("creates a new playlist with owner set from context", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeEmpty()) + Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist")) + Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1")) + }) + + It("replaces tracks on existing playlist when owner matches", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(id).To(Equal("pls-1")) + Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1)) + }) + + It("allows admin to replace tracks on any playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) + id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(id).To(Equal("pls-2")) + }) + + It("denies non-owner, non-admin from replacing tracks on existing playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-2", "", []string{"song-3"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("returns error when existing playlistId not found", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"}) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("denies replacing tracks on a smart playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + }) + + Describe("Update", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds) + }) + + It("allows owner to update their playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + newName := "Updated Name" + err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("allows admin to update any playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) + newName := "Updated Name" + err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies non-owner, non-admin from updating", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + newName := "Updated Name" + err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("returns error when playlist not found", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + newName := "Updated Name" + err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("denies adding tracks to a smart playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("denies removing tracks from a smart playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("allows metadata updates on a smart playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + newName := "Updated Smart" + err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("AddTracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds) + }) + + It("allows owner to add tracks", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(2)) + Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2")) + }) + + It("allows admin to add tracks to any playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) + count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(2)) + }) + + It("denies non-owner, non-admin", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("denies editing smart playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("returns error when playlist not found", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"}) + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + + Describe("RemoveTracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds) + }) + + It("allows owner to remove tracks", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2")) + }) + + It("denies on smart playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("denies non-owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + }) + + Describe("ReorderTrack", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds) + }) + + It("allows owner to reorder", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.ReorderTrack(ctx, "pls-1", 1, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(mockTracks.Reordered).To(BeTrue()) + }) + + It("denies on smart playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + }) +}) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go new file mode 100644 index 000000000..3865d97e9 --- /dev/null +++ b/core/playlists/rest_adapter.go @@ -0,0 +1,95 @@ +package playlists + +import ( + "context" + "errors" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +// --- REST adapter (follows Share/Library pattern) --- + +func (s *playlists) NewRepository(ctx context.Context) rest.Repository { + return &playlistRepositoryWrapper{ + ctx: ctx, + PlaylistRepository: s.ds.Playlist(ctx), + service: s, + } +} + +// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter. +// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository), +// and rest.Persistable by delegating to service methods for all mutations. +type playlistRepositoryWrapper struct { + model.PlaylistRepository + ctx context.Context + service *playlists +} + +func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { + return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) +} + +func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) +} + +func (r *playlistRepositoryWrapper) Delete(id string) error { + err := r.service.Delete(r.ctx, id) + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err + } +} + +func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository { + repo := s.ds.Playlist(ctx) + tracks := repo.Tracks(playlistId, refreshSmartPlaylist) + if tracks == nil { + return nil + } + return tracks.(rest.Repository) +} + +// savePlaylist creates a new playlist, assigning the owner from context. +func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) { + usr, _ := request.UserFrom(ctx) + pls.OwnerID = usr.ID + pls.ID = "" // Force new creation + err := s.ds.Playlist(ctx).Put(pls) + if err != nil { + return "", err + } + return pls.ID, nil +} + +// updatePlaylistEntity updates playlist metadata with permission checks. +// Used by the REST API wrapper. +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { + current, err := s.checkWritable(ctx, id) + if err != nil { + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err + } + } + usr, _ := request.UserFrom(ctx) + if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { + return rest.ErrPermissionDenied + } + // Apply ownership change (admin only) + if entity.OwnerID != "" { + current.OwnerID = entity.OwnerID + } + return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) +} diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go new file mode 100644 index 000000000..b65095957 --- /dev/null +++ b/core/playlists/rest_adapter_test.go @@ -0,0 +1,120 @@ +package playlists_test + +import ( + "context" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("REST Adapter", func() { + var ds *tests.MockDataStore + var ps playlists.Playlists + var mockPlsRepo *tests.MockPlaylistRepo + ctx := context.Background() + + BeforeEach(func() { + mockPlsRepo = tests.CreateMockPlaylistRepo() + ds = &tests.MockDataStore{ + MockedPlaylist: mockPlsRepo, + MockedLibrary: &tests.MockLibraryRepo{}, + } + ctx = request.WithUser(ctx, model.User{ID: "123"}) + }) + + Describe("NewRepository", func() { + var repo rest.Persistable + + BeforeEach(func() { + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + } + ps = playlists.NewPlaylists(ds) + }) + + Describe("Save", func() { + It("sets the owner from the context user", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "New Playlist"} + id, err := repo.Save(pls) + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeEmpty()) + Expect(pls.OwnerID).To(Equal("user-1")) + }) + + It("forces a new creation by clearing ID", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{ID: "should-be-cleared", Name: "New"} + _, err := repo.Save(pls) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).ToNot(Equal("should-be-cleared")) + }) + }) + + Describe("Update", func() { + It("allows owner to update their playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Updated"} + err := repo.Update("pls-1", pls) + Expect(err).ToNot(HaveOccurred()) + }) + + It("allows admin to update any playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Updated"} + err := repo.Update("pls-1", pls) + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies non-owner, non-admin", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Updated"} + err := repo.Update("pls-1", pls) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + + It("denies regular user from changing ownership", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"} + err := repo.Update("pls-1", pls) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + + It("returns rest.ErrNotFound when playlist doesn't exist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Updated"} + err := repo.Update("nonexistent", pls) + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) + + Describe("Delete", func() { + It("delegates to service Delete with permission checks", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Delete("pls-1") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) + }) + + It("denies non-owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Delete("pls-1") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + }) +}) diff --git a/core/wire_providers.go b/core/wire_providers.go index a8b1fde03..503feb789 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" ) @@ -16,7 +17,7 @@ var Set = wire.NewSet( NewArchiver, NewPlayers, NewShare, - NewPlaylists, + playlists.NewPlaylists, NewLibrary, NewUser, NewMaintenance, diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 220c1210b..955a13bc9 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -96,16 +96,6 @@ func (r *playlistRepository) Exists(id string) (bool, error) { } func (r *playlistRepository) Delete(id string) error { - usr := loggedUser(r.ctx) - if !usr.IsAdmin { - pls, err := r.Get(id) - if err != nil { - return err - } - if pls.OwnerID != usr.ID { - return rest.ErrPermissionDenied - } - } return r.delete(And{Eq{"id": id}, r.userFilter()}) } @@ -113,14 +103,6 @@ func (r *playlistRepository) Put(p *model.Playlist) error { pls := dbPlaylist{Playlist: *p} if pls.ID == "" { pls.CreatedAt = time.Now() - } else { - ok, err := r.Exists(pls.ID) - if err != nil { - return err - } - if !ok { - return model.ErrNotAuthorized - } } pls.UpdatedAt = time.Now() @@ -132,7 +114,6 @@ func (r *playlistRepository) Put(p *model.Playlist) error { if p.IsSmartPlaylist() { // Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process - //r.refreshSmartPlaylist(p) return nil } // Only update tracks if they were specified @@ -320,10 +301,6 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er } func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error { - if !r.isWritable(playlistId) { - return rest.ErrPermissionDenied - } - // Remove old tracks del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId}) _, err := r.executeSQL(del) @@ -439,8 +416,7 @@ func (r *playlistRepository) NewInstance() any { func (r *playlistRepository) Save(entity any) (string, error) { pls := entity.(*model.Playlist) - pls.OwnerID = loggedUser(r.ctx).ID - pls.ID = "" // Make sure we don't override an existing playlist + pls.ID = "" // Force new creation err := r.Put(pls) if err != nil { return "", err @@ -450,24 +426,9 @@ func (r *playlistRepository) Save(entity any) (string, error) { func (r *playlistRepository) Update(id string, entity any, cols ...string) error { pls := dbPlaylist{Playlist: *entity.(*model.Playlist)} - current, err := r.Get(id) - if err != nil { - return err - } - usr := loggedUser(r.ctx) - if !usr.IsAdmin { - // Only the owner can update the playlist - if current.OwnerID != usr.ID { - return rest.ErrPermissionDenied - } - // Regular users can't change the ownership of a playlist - if pls.OwnerID != "" && pls.OwnerID != usr.ID { - return rest.ErrPermissionDenied - } - } pls.ID = id pls.UpdatedAt = time.Now() - _, err = r.put(id, pls, append(cols, "updatedAt")...) + _, err := r.put(id, pls, append(cols, "updatedAt")...) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound } @@ -507,23 +468,31 @@ func (r *playlistRepository) removeOrphans() error { return nil } +// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their +// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering. +// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id). func (r *playlistRepository) renumber(id string) error { - var ids []string - sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id") - err := r.queryAllSlice(sq, &ids) + // Step 1: Negate all IDs to clear the positive ID space + _, err := r.executeSQL(Expr( + `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id)) if err != nil { return err } - return r.updatePlaylist(id, ids) -} - -func (r *playlistRepository) isWritable(playlistId string) bool { - usr := loggedUser(r.ctx) - if usr.IsAdmin { - return true + // Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE. + // The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues. + // ORDER BY id DESC restores original order since IDs are now negative. + _, err = r.executeSQL(Expr( + `WITH new_ids AS ( + SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id + FROM playlist_tracks WHERE playlist_id = ? + ) + UPDATE playlist_tracks SET id = new_ids.new_id + FROM new_ids + WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id)) + if err != nil { + return err } - pls, err := r.Get(playlistId) - return err == nil && pls.OwnerID == usr.ID + return r.refreshCounters(&model.Playlist{ID: id}) } var _ model.PlaylistRepository = (*playlistRepository)(nil) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 232eb14b4..5230390fb 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -401,6 +401,79 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Track Deletion and Renumbering", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + // helper to get track positions and media file IDs + getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) { + pls, err := repo.GetWithTracks(playlistID, false, false) + Expect(err).ToNot(HaveOccurred()) + for _, t := range pls.Tracks { + ids = append(ids, t.ID) + mediaFileIDs = append(mediaFileIDs, t.MediaFileID) + } + return + } + + It("renumbers correctly after deleting a track from the middle", func() { + By("creating a playlist with 4 tracks") + newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("deleting the second track (position 2)") + tracksRepo := repo.Tracks(newPls.ID, false) + Expect(tracksRepo.Delete("2")).To(Succeed()) + + By("verifying remaining tracks are renumbered sequentially") + ids, mediaFileIDs := getTrackInfo(newPls.ID) + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"})) + }) + + It("renumbers correctly after deleting the first track", func() { + By("creating a playlist with 3 tracks") + newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("deleting the first track (position 1)") + tracksRepo := repo.Tracks(newPls.ID, false) + Expect(tracksRepo.Delete("1")).To(Succeed()) + + By("verifying remaining tracks are renumbered sequentially") + ids, mediaFileIDs := getTrackInfo(newPls.ID) + Expect(ids).To(Equal([]string{"1", "2"})) + Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"})) + }) + + It("renumbers correctly after deleting the last track", func() { + By("creating a playlist with 3 tracks") + newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("deleting the last track (position 3)") + tracksRepo := repo.Tracks(newPls.ID, false) + Expect(tracksRepo.Delete("3")).To(Succeed()) + + By("verifying remaining tracks are renumbered sequentially") + ids, mediaFileIDs := getTrackInfo(newPls.ID) + Expect(ids).To(Equal([]string{"1", "2"})) + Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) + }) + }) + Describe("Smart Playlists Library Filtering", func() { var mfRepo model.MediaFileRepository var testPlaylistID string diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index c72abb180..1a7062cc2 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -140,15 +140,7 @@ func (r *playlistTrackRepository) NewInstance() any { return &model.PlaylistTrack{} } -func (r *playlistTrackRepository) isTracksEditable() bool { - return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist() -} - func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { - if !r.isTracksEditable() { - return 0, rest.ErrPermissionDenied - } - if len(mediaFileIds) > 0 { log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) } else { @@ -196,22 +188,7 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { return r.addMediaFileIds(clauses) } -// Get ids from all current tracks -func (r *playlistTrackRepository) getTracks() ([]string, error) { - all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id") - var ids []string - err := r.queryAllSlice(all, &ids) - if err != nil { - log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err) - return nil, err - } - return ids, nil -} - func (r *playlistTrackRepository) Delete(ids ...string) error { - if !r.isTracksEditable() { - return rest.ErrPermissionDenied - } err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}}) if err != nil { return err @@ -221,9 +198,6 @@ func (r *playlistTrackRepository) Delete(ids ...string) error { } func (r *playlistTrackRepository) DeleteAll() error { - if !r.isTracksEditable() { - return rest.ErrPermissionDenied - } err := r.delete(Eq{"playlist_id": r.playlistId}) if err != nil { return err @@ -232,16 +206,45 @@ func (r *playlistTrackRepository) DeleteAll() error { return r.playlistRepo.renumber(r.playlistId) } +// Reorder moves a track from pos to newPos, shifting other tracks accordingly. func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { - if !r.isTracksEditable() { - return rest.ErrPermissionDenied + if pos == newPos { + return nil } - ids, err := r.getTracks() + pid := r.playlistId + + // Step 1: Move the source track out of the way (temporary sentinel value) + _, err := r.executeSQL(Expr( + `UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos)) if err != nil { return err } - newOrder := slice.Move(ids, pos-1, newPos-1) - return r.playlistRepo.updatePlaylist(r.playlistId, newOrder) + + // Step 2: Shift the affected range using negative values to avoid unique constraint violations + if pos < newPos { + _, err = r.executeSQL(Expr( + `UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`, + pid, pos, newPos)) + } else { + _, err = r.executeSQL(Expr( + `UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`, + pid, newPos, pos)) + } + if err != nil { + return err + } + + // Step 3: Flip the shifted range back to positive + _, err = r.executeSQL(Expr( + `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid)) + if err != nil { + return err + } + + // Step 4: Place the source track at its new position + _, err = r.executeSQL(Expr( + `UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid)) + return err } var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil) diff --git a/scanner/controller.go b/scanner/controller.go index db6444fa9..94248ffd0 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -9,10 +9,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -27,7 +27,7 @@ var ( ) func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, - pls core.Playlists, m metrics.Metrics) model.Scanner { + pls playlists.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, @@ -53,7 +53,7 @@ func (s *controller) getScanner() scanner { // CallScan starts an in-process scan of specific library/folder pairs. // If targets is empty, it scans all libraries. // This is meant to be called from the command line (see cmd/scan.go). -func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) { +func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) { release, err := lockScan(ctx) if err != nil { return nil, err @@ -98,7 +98,7 @@ type controller struct { cw artwork.CacheWarmer broker events.Broker metrics metrics.Metrics - pls core.Playlists + pls playlists.Playlists limiter *rate.Sometimes devExternalScanner bool count atomic.Uint32 diff --git a/scanner/controller_test.go b/scanner/controller_test.go index f5ccabc86..2af52066b 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -5,9 +5,9 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -31,7 +31,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index 9d8d0c571..c7cc88ee1 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -10,7 +10,7 @@ import ( "slices" "time" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/chrono" ) @@ -72,7 +72,7 @@ func (f *folderEntry) isOutdated() bool { func (f *folderEntry) toFolder() *model.Folder { folder := model.NewFolder(f.job.lib, f.path) folder.NumAudioFiles = len(f.audioFiles) - if core.InPlaylistsPath(*folder) { + if playlists.InPath(*folder) { folder.NumPlaylists = f.numPlaylists } folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index c98b51ee6..ab5f77ae0 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -10,8 +10,8 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -21,12 +21,12 @@ type phasePlaylists struct { ctx context.Context scanState *scanState ds model.DataStore - pls core.Playlists + pls playlists.Playlists cw artwork.CacheWarmer refreshed atomic.Uint32 } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 218aa3c7b..0b50d39cb 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -9,8 +9,8 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -130,7 +130,7 @@ var _ = Describe("phasePlaylists", func() { type mockPlaylists struct { mock.Mock - core.Playlists + playlists.Playlists } func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { diff --git a/scanner/scanner.go b/scanner/scanner.go index ba1e76ff2..871b0c696 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,8 +11,8 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -23,7 +23,7 @@ import ( type scannerImpl struct { ds model.DataStore cw artwork.CacheWarmer - pls core.Playlists + pls playlists.Playlists } // scanState holds the state of an in-progress scan, to be passed to the various phases diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 2b1c0a140..1ac7b50a4 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -12,9 +12,9 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" @@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 107e66a99..6990f1984 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,9 +11,9 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -77,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 629826db4..6e4511179 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -8,9 +8,9 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -63,7 +63,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 351255ae8..d5688a1dc 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -11,9 +11,9 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -84,7 +84,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 92214950a..7bda1527c 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/db" @@ -69,6 +70,14 @@ var ( Name: "Admin User", IsAdmin: true, } + + // Regular (non-admin) user for permission tests + regularUser = model.User{ + ID: "regular-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + } ) func createFS(files fstest.MapFS) storagetest.FakeFS { @@ -288,19 +297,29 @@ var _ = BeforeSuite(func() { adminUserWithPass.NewPassword = "password" Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) Expect(err).ToNot(HaveOccurred()) adminUser.Libraries = loadedUser.Libraries + + loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName) + Expect(err).ToNot(HaveOccurred()) + regularUser.Libraries = loadedRegular.Libraries + ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(initDS), metrics.NewNoopInstance()) + playlists.NewPlaylists(initDS), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -334,7 +353,7 @@ func setupTestDB() { // Create the Subsonic Router with real DS + noop stubs s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -344,7 +363,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - core.NewPlaylists(ds), + playlists.NewPlaylists(ds), noopPlayTracker{}, core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index 2292bfab1..1eb04d41c 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -6,9 +6,9 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/scanner" @@ -53,7 +53,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 6e9c23765..3468979f4 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -1,7 +1,11 @@ package e2e import ( + "time" + + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,9 +19,9 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { setupTestDB() // Look up song IDs from scanned data for playlist operations - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) Expect(err).ToNot(HaveOccurred()) - Expect(len(songs)).To(BeNumerically(">=", 3)) + Expect(len(songs)).To(BeNumerically(">=", 5)) for _, s := range songs { songIDs = append(songIDs, s.ID) } @@ -32,24 +36,30 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { }) It("createPlaylist creates a new playlist with songs", func() { - resp := doReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1]) + resp := doReq("createPlaylist", "name", "Test Playlist", + "songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2]) Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Playlist).ToNot(BeNil()) Expect(resp.Playlist.Name).To(Equal("Test Playlist")) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.SongCount).To(Equal(int32(3))) + Expect(resp.Playlist.Entry).To(HaveLen(3)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) + Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2])) playlistID = resp.Playlist.Id }) - It("getPlaylist returns playlist with tracks", func() { + It("getPlaylist returns playlist with tracks in order", func() { resp := doReq("getPlaylist", "id", playlistID) Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Playlist).ToNot(BeNil()) Expect(resp.Playlist.Name).To(Equal("Test Playlist")) - Expect(resp.Playlist.Entry).To(HaveLen(2)) + Expect(resp.Playlist.Entry).To(HaveLen(3)) Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) + Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2])) }) It("createPlaylist without name or playlistId returns error", func() { @@ -59,40 +69,150 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) + It("createPlaylist with playlistId replaces tracks on existing playlist", func() { + // Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4] + resp := doReq("createPlaylist", "playlistId", playlistID, + "songId", songIDs[3], "songId", songIDs[4]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.Id).To(Equal(playlistID)) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4])) + }) + It("updatePlaylist can rename the playlist", func() { resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist") - Expect(resp.Status).To(Equal(responses.StatusOK)) // Verify the rename resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.Name).To(Equal("Renamed Playlist")) + // Tracks should be unchanged + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + }) + + It("updatePlaylist can set comment", func() { + resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.Comment).To(Equal("My favorite songs")) + }) + + It("updatePlaylist can set public visibility", func() { + resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.Public).To(BeTrue()) }) It("updatePlaylist can add songs", func() { - resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2]) - + // Playlist currently has [song3, song4], add song0 + resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0]) Expect(resp.Status).To(Equal(responses.StatusOK)) - // Verify the song was added resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(3))) Expect(resp.Playlist.Entry).To(HaveLen(3)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4])) + Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0])) }) - It("updatePlaylist can remove songs by index", func() { - // Remove the first song (index 0) - resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0") - + It("updatePlaylist can add multiple songs at once", func() { + // Playlist currently has [song3, song4, song0], add song1 and song2 + resp := doReq("updatePlaylist", "playlistId", playlistID, + "songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2]) Expect(resp.Status).To(Equal(responses.StatusOK)) - // Verify the song was removed resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.SongCount).To(Equal(int32(5))) + Expect(resp.Playlist.Entry).To(HaveLen(5)) + }) + It("updatePlaylist can remove songs by index and verifies correct songs remain", func() { + // Playlist has [song3, song4, song0, song1, song2] + // Remove index 0 (song3) and index 2 (song0) + resp := doReq("updatePlaylist", "playlistId", playlistID, + "songIndexToRemove", "0", "songIndexToRemove", "2") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.SongCount).To(Equal(int32(3))) + Expect(resp.Playlist.Entry).To(HaveLen(3)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) + Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2])) + }) + + It("updatePlaylist can remove and add songs in a single call", func() { + // Playlist has [song4, song1, song2] + // Remove index 1 (song1) and add song3 + resp := doReq("updatePlaylist", "playlistId", playlistID, + "songIndexToRemove", "1", "songIdToAdd", songIDs[3]) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.SongCount).To(Equal(int32(3))) + Expect(resp.Playlist.Entry).To(HaveLen(3)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2])) + Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3])) + }) + + It("updatePlaylist can combine metadata change with track removal", func() { + // Playlist has [song4, song2, song3] + // Rename + remove index 0 (song4) + resp := doReq("updatePlaylist", "playlistId", playlistID, + "name", "Final Playlist", "songIndexToRemove", "0") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.Name).To(Equal("Final Playlist")) Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - Expect(resp.Playlist.Entry).To(HaveLen(2)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3])) + }) + + It("updatePlaylist can remove all songs from playlist", func() { + // Playlist has [song2, song3] — remove both + resp := doReq("updatePlaylist", "playlistId", playlistID, + "songIndexToRemove", "0", "songIndexToRemove", "1") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.SongCount).To(Equal(int32(0))) + Expect(resp.Playlist.Entry).To(BeEmpty()) + }) + + It("updatePlaylist can add songs to an empty playlist", func() { + resp := doReq("updatePlaylist", "playlistId", playlistID, + "songIdToAdd", songIDs[0]) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", playlistID) + Expect(resp.Playlist.SongCount).To(Equal(int32(1))) + Expect(resp.Playlist.Entry).To(HaveLen(1)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) + }) + + It("updatePlaylist without playlistId returns error", func() { + resp := doReq("updatePlaylist", "name", "No ID") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("getPlaylists shows the playlist", func() { + resp := doReq("getPlaylists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlists.Playlist).To(HaveLen(1)) + Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID)) }) It("deletePlaylist removes the playlist", func() { @@ -107,4 +227,294 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) }) + + It("getPlaylists returns empty after deletion", func() { + resp := doReq("getPlaylists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlists.Playlist).To(BeEmpty()) + }) + + Describe("Playlist Permissions", Ordered, func() { + var songIDs []string + var adminPrivateID string + var adminPublicID string + var regularPlaylistID string + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(songs)).To(BeNumerically(">=", 3)) + for _, s := range songs { + songIDs = append(songIDs, s.ID) + } + }) + + It("admin creates a private playlist", func() { + resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private", + "songId", songIDs[0], "songId", songIDs[1]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + adminPrivateID = resp.Playlist.Id + }) + + It("admin creates a public playlist", func() { + resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public", + "songId", songIDs[0], "songId", songIDs[1]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + adminPublicID = resp.Playlist.Id + + // Make it public + resp = doReqWithUser(adminUser, "updatePlaylist", + "playlistId", adminPublicID, "public", "true") + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("regular user creates a playlist", func() { + resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist", + "songId", songIDs[0]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + regularPlaylistID = resp.Playlist.Id + }) + + // --- Private playlist: regular user gets "not found" (repo hides it entirely) --- + + It("regular user cannot see admin's private playlist", func() { + resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("regular user cannot update admin's private playlist (not found)", func() { + resp := doReqWithUser(regularUser, "updatePlaylist", + "playlistId", adminPrivateID, "name", "Hacked") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("regular user cannot delete admin's private playlist (not found)", func() { + resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + // --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) --- + + It("regular user can see admin's public playlist", func() { + resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.Name).To(Equal("Admin Public")) + }) + + It("regular user cannot update admin's public playlist", func() { + resp := doReqWithUser(regularUser, "updatePlaylist", + "playlistId", adminPublicID, "name", "Hacked") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("regular user cannot add songs to admin's public playlist", func() { + resp := doReqWithUser(regularUser, "updatePlaylist", + "playlistId", adminPublicID, "songIdToAdd", songIDs[2]) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("regular user cannot remove songs from admin's public playlist", func() { + resp := doReqWithUser(regularUser, "updatePlaylist", + "playlistId", adminPublicID, "songIndexToRemove", "0") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("regular user cannot delete admin's public playlist", func() { + resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() { + resp := doReqWithUser(regularUser, "createPlaylist", + "playlistId", adminPublicID, "songId", songIDs[2]) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + // --- Regular user can manage their own playlists --- + + It("regular user can update their own playlist", func() { + resp := doReqWithUser(regularUser, "updatePlaylist", + "playlistId", regularPlaylistID, "name", "My Updated Playlist") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID) + Expect(resp.Playlist.Name).To(Equal("My Updated Playlist")) + }) + + It("regular user can add songs to their own playlist", func() { + resp := doReqWithUser(regularUser, "updatePlaylist", + "playlistId", regularPlaylistID, "songIdToAdd", songIDs[1]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + }) + + It("regular user can delete their own playlist", func() { + resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + // --- Admin can manage any user's playlists --- + + It("admin can update any user's playlist", func() { + resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited", + "songId", songIDs[0]) + Expect(resp.Status).To(Equal(responses.StatusOK)) + plsID := resp.Playlist.Id + + resp = doReqWithUser(adminUser, "updatePlaylist", + "playlistId", plsID, "name", "Admin Edited") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID) + Expect(resp.Playlist.Name).To(Equal("Admin Edited")) + }) + + It("admin can delete any user's playlist", func() { + resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted", + "songId", songIDs[0]) + Expect(resp.Status).To(Equal(responses.StatusOK)) + plsID := resp.Playlist.Id + + resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + // --- Verify admin's playlists are unchanged --- + + It("admin's private playlist is unchanged after failed regular user operations", func() { + resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.Name).To(Equal("Admin Private")) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + }) + + It("admin's public playlist is unchanged after failed regular user operations", func() { + resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.Name).To(Equal("Admin Public")) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + }) + }) + + Describe("Smart Playlist Protection", Ordered, func() { + var smartPlaylistID string + var songID string + + BeforeAll(func() { + setupTestDB() + + // Look up a song ID for mutation tests + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Insert a smart playlist directly into the DB + smartPls := &model.Playlist{ + Name: "Smart Playlist", + OwnerID: adminUser.ID, + Public: false, + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, + } + Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed()) + smartPlaylistID = smartPls.ID + }) + + It("getPlaylist returns smart playlist with readonly flag and validUntil", func() { + resp := doReq("getPlaylist", "id", smartPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.Name).To(Equal("Smart Playlist")) + Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil()) + Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue()) + expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay) + Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second)) + }) + + It("createPlaylist rejects replacing tracks on smart playlist", func() { + resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("updatePlaylist rejects adding songs to smart playlist", func() { + resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, + "songIdToAdd", songID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("updatePlaylist rejects removing songs from smart playlist", func() { + resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, + "songIndexToRemove", "0") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(int32(50))) + }) + + It("updatePlaylist allows renaming smart playlist", func() { + resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, + "name", "Renamed Smart") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", smartPlaylistID) + Expect(resp.Playlist.Name).To(Equal("Renamed Smart")) + }) + + It("updatePlaylist allows setting comment on smart playlist", func() { + resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, + "comment", "Auto-generated playlist") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", smartPlaylistID) + Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist")) + }) + + It("deletePlaylist can delete smart playlist", func() { + resp := doReq("deletePlaylist", "id", smartPlaylistID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + resp = doReq("getPlaylist", "id", smartPlaylistID) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + }) }) diff --git a/server/e2e/subsonic_scan_test.go b/server/e2e/subsonic_scan_test.go index a6fb28bc4..8bac6fe96 100644 --- a/server/e2e/subsonic_scan_test.go +++ b/server/e2e/subsonic_scan_test.go @@ -22,8 +22,6 @@ var _ = Describe("Scan Endpoints", func() { }) It("startScan requires admin user", func() { - regularUser := createUser("user-2", "regular", "Regular User", false) - resp := doReqWithUser(regularUser, "startScan") Expect(resp.Status).To(Equal(responses.StatusFailed)) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 52e633bee..27b85a605 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/metrics" + playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -37,7 +38,7 @@ type Router struct { http.Handler ds model.DataStore share core.Share - playlists core.Playlists + playlists playlistsvc.Playlists insights metrics.Insights libs core.Library users core.User @@ -45,7 +46,7 @@ type Router struct { pluginManager PluginManager } -func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router { +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router { r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager} r.Handler = r.routes() return r @@ -121,7 +122,7 @@ func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito func (api *Router) addPlaylistRoute(r chi.Router) { constructor := func(ctx context.Context) rest.Repository { - return api.ds.Resource(ctx, model.Playlist{}) + return api.playlists.NewRepository(ctx) } r.Route("/playlist", func(r chi.Router) { @@ -146,26 +147,26 @@ func (api *Router) addPlaylistRoute(r chi.Router) { func (api *Router) addPlaylistTrackRoute(r chi.Router) { r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylist(api.ds)(w, r) + getPlaylist(api.playlists)(w, r) }) r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) { r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(api.ds)(w, r) + deleteFromPlaylist(api.playlists)(w, r) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { - addToPlaylist(api.ds)(w, r) + addToPlaylist(api.playlists)(w, r) }) }) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylistTrack(api.ds)(w, r) + getPlaylistTrack(api.playlists)(w, r) }) r.Put("/", func(w http.ResponseWriter, r *http.Request) { - reorderItem(api.ds)(w, r) + reorderItem(api.playlists)(w, r) }) r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(api.ds)(w, r) + deleteFromPlaylist(api.playlists)(w, r) }) }) }) @@ -173,7 +174,7 @@ func (api *Router) addPlaylistTrackRoute(r chi.Router) { func (api *Router) addSongPlaylistsRoute(r chi.Router) { r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) { - getSongPlaylists(api.ds)(w, r) + getSongPlaylists(api.playlists)(w, r) }) } diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 1e2c5e07e..60e8024bd 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -11,7 +11,7 @@ import ( "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" @@ -19,10 +19,10 @@ import ( type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc -func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { +func playlistTracksHandler(pls playlists.Playlists, 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)) + tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r)) if tracks == nil { http.Error(w, "not found", http.StatusNotFound) return @@ -31,27 +31,27 @@ func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmart } } -func getPlaylist(ds model.DataStore) http.HandlerFunc { - handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool { +func getPlaylist(pls playlists.Playlists) http.HandlerFunc { + handler := playlistTracksHandler(pls, 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) + handleExportPlaylist(pls)(w, r) return } handler(w, r) } } -func getPlaylistTrack(ds model.DataStore) http.HandlerFunc { - return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true }) +func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc { + return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true }) } -func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { +func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - pls, err := playlists.ImportM3U(ctx, r.Body) + pl, err := pls.ImportM3U(ctx, r.Body) if err != nil { log.Error(r.Context(), "Error parsing playlist", err) // TODO: consider returning StatusBadRequest for playlists that are malformed @@ -59,7 +59,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(pl.ToM3U8())) //nolint:gosec if err != nil { log.Error(ctx, "Error sending m3u contents", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -68,45 +68,41 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { } } -func handleExportPlaylist(ds model.DataStore) http.HandlerFunc { +func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - plsRepo := ds.Playlist(ctx) plsId := chi.URLParam(r, "playlistId") - pls, err := plsRepo.GetWithTracks(plsId, true, false) + playlist, err := pls.GetWithTracks(ctx, plsId) if errors.Is(err, model.ErrNotFound) { - log.Warn(r.Context(), "Playlist not found", "playlistId", plsId) + log.Warn(ctx, "Playlist not found", "playlistId", plsId) http.Error(w, "not found", http.StatusNotFound) return } if err != nil { - log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err) + log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name) + log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name) w.Header().Set("Content-Type", "audio/x-mpegurl") - disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name) + disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name) w.Header().Set("Content-Disposition", disposition) - _, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec + _, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec if err != nil { - log.Error(ctx, "Error sending playlist", "name", pls.Name) + log.Error(ctx, "Error sending playlist", "name", playlist.Name) return } } } -func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { +func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") ids, _ := p.Strings("id") - err := ds.WithTxImmediate(func(tx model.DataStore) error { - tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true) - return tracksRepo.Delete(ids...) - }) + err := pls.RemoveTracks(r.Context(), playlistId, ids) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) http.Error(w, "not found", http.StatusNotFound) @@ -121,7 +117,7 @@ func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { } } -func addToPlaylist(ds model.DataStore) http.HandlerFunc { +func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { type addTracksPayload struct { Ids []string `json:"ids"` AlbumIds []string `json:"albumIds"` @@ -130,6 +126,7 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() p := req.Params(r) playlistId, _ := p.String(":playlistId") var payload addTracksPayload @@ -138,24 +135,23 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc { http.Error(w, err.Error(), http.StatusBadRequest) return } - tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true) count, c := 0, 0 - if c, err = tracksRepo.Add(payload.Ids); err != nil { + if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil { + if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil { + if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil { + if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -169,12 +165,13 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc { } } -func reorderItem(ds model.DataStore) http.HandlerFunc { +func reorderItem(pls playlists.Playlists) http.HandlerFunc { type reorderPayload struct { InsertBefore string `json:"insert_before"` } return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() p := req.Params(r) playlistId, _ := p.String(":playlistId") id := p.IntOr(":id", 0) @@ -193,9 +190,8 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { http.Error(w, err.Error(), http.StatusBadRequest) return } - tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true) - err = tracksRepo.Reorder(id, newPos) - if errors.Is(err, rest.ErrPermissionDenied) { + err = pls.ReorderTrack(ctx, playlistId, id, newPos) + if errors.Is(err, model.ErrNotAuthorized) { http.Error(w, err.Error(), http.StatusForbidden) return } @@ -211,11 +207,11 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { } } -func getSongPlaylists(ds model.DataStore) http.HandlerFunc { +func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) trackId, _ := p.String(":id") - playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId) + playlists, err := svc.GetPlaylists(r.Context(), trackId) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 319e41cd5..961d10b68 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -1,6 +1,7 @@ package nativeapi import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -48,11 +50,19 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) { return nil, rest.ErrNotFound } +type mockPlaylistsService struct { + playlists.Playlists + tracksRepo rest.Repository +} + +func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository { + return m.tracksRepo +} + var _ = Describe("Playlist Tracks Endpoint", func() { var ( router http.Handler - ds *tests.MockDataStore - plsRepo *tests.MockPlaylistRepo + plsSvc *mockPlaylistsService userRepo *tests.MockedUserRepo w *httptest.ResponseRecorder ) @@ -61,11 +71,10 @@ var _ = Describe("Playlist Tracks Endpoint", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute - plsRepo = &tests.MockPlaylistRepo{} + plsSvc = &mockPlaylistsService{} userRepo = tests.CreateMockUserRepo() - ds = &tests.MockDataStore{ - MockedPlaylist: plsRepo, + ds := &tests.MockDataStore{ MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}, } @@ -82,7 +91,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) @@ -105,7 +114,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) It("returns tracks when playlist exists", func() { - plsRepo.TracksReturn = &mockPlaylistTrackRepo{ + plsSvc.tracksRepo = &mockPlaylistTrackRepo{ tracks: model.PlaylistTracks{ {ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"}, {ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"}, @@ -135,7 +144,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) It("returns the track when playlist exists", func() { - plsRepo.TracksReturn = &mockPlaylistTrackRepo{ + plsSvc.tracksRepo = &mockPlaylistTrackRepo{ tracks: model.PlaylistTracks{ {ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"}, }, @@ -154,7 +163,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) It("returns 404 when track does not exist in playlist", func() { - plsRepo.TracksReturn = &mockPlaylistTrackRepo{ + plsSvc.tracksRepo = &mockPlaylistTrackRepo{ tracks: model.PlaylistTracks{}, } diff --git a/server/subsonic/api.go b/server/subsonic/api.go index c3108ea5b..d0d9bb169 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -40,7 +41,7 @@ type Router struct { archiver core.Archiver players core.Players provider external.Provider - playlists core.Playlists + playlists playlistsvc.Playlists scanner model.Scanner broker events.Broker scrobbler scrobbler.PlayTracker @@ -51,7 +52,7 @@ type Router struct { func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, - playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, + playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, ) *Router { r := &Router{ @@ -290,6 +291,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorGeneric, err.Error()) case errors.Is(err, model.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") + case errors.Is(err, model.ErrNotAuthorized): + err = newError(responses.ErrorAuthorizationFail) default: err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) } diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index b8807563e..baae7514b 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -19,7 +19,7 @@ import ( func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"}) + allPls, err := api.playlists.GetAll(ctx, model.QueryOptions{Sort: "name"}) if err != nil { log.Error(r, err) return nil, err @@ -42,7 +42,7 @@ func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) { - pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false) + pls, err := api.playlists.GetWithTracks(ctx, id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, err.Error(), "id", id) return nil, newError(responses.ErrorDataNotFound, "playlist not found") @@ -60,34 +60,6 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso return response, nil } -func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) { - err := api.ds.WithTxImmediate(func(tx model.DataStore) error { - owner := getUser(ctx) - var pls *model.Playlist - var err error - - if playlistId != "" { - pls, err = tx.Playlist(ctx).Get(playlistId) - if err != nil { - return err - } - if owner.ID != pls.OwnerID { - return model.ErrNotAuthorized - } - } else { - pls = &model.Playlist{Name: name} - pls.OwnerID = owner.ID - } - pls.Tracks = nil - pls.AddMediaFilesByID(ids) - - err = tx.Playlist(ctx).Put(pls) - playlistId = pls.ID - return err - }) - return playlistId, err -} - func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) @@ -97,7 +69,7 @@ func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) if playlistId == "" && name == "" { return nil, errors.New("required parameter name is missing") } - id, err := api.create(ctx, playlistId, name, songIds) + id, err := api.playlists.Create(ctx, playlistId, name, songIds) if err != nil { log.Error(r, err) return nil, err @@ -111,7 +83,7 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - err = api.ds.Playlist(r.Context()).Delete(id) + err = api.playlists.Delete(r.Context(), id) if errors.Is(err, model.ErrNotAuthorized) { return nil, newError(responses.ErrorAuthorizationFail) } diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 05701fc1f..d99e244b0 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -5,7 +5,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" @@ -14,7 +14,7 @@ import ( . "github.com/onsi/gomega" ) -var _ core.Playlists = (*fakePlaylists)(nil) +var _ playlists.Playlists = (*fakePlaylists)(nil) var _ = Describe("buildPlaylist", func() { var router *Router @@ -272,7 +272,7 @@ var _ = Describe("UpdatePlaylist", func() { }) type fakePlaylists struct { - core.Playlists + playlists.Playlists lastPlaylistID string lastName *string lastComment *string diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index c4b0113fc..754f0c084 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -121,7 +121,7 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository if db.RealDS != nil { return db.RealDS.Playlist(ctx) } - db.MockedPlaylist = &MockPlaylistRepo{} + db.MockedPlaylist = CreateMockPlaylistRepo() return db.MockedPlaylist } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 1c37107e2..9bdc52152 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -1,38 +1,111 @@ package tests import ( + "errors" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" ) +func CreateMockPlaylistRepo() *MockPlaylistRepo { + return &MockPlaylistRepo{ + Data: make(map[string]*model.Playlist), + PathMap: make(map[string]*model.Playlist), + } +} + type MockPlaylistRepo struct { model.PlaylistRepository - - Entity *model.Playlist - Error error - TracksReturn model.PlaylistTrackRepository + Data map[string]*model.Playlist // keyed by ID + PathMap map[string]*model.Playlist // keyed by path + Last *model.Playlist + Deleted []string + Err bool + TracksRepo model.PlaylistTrackRepository } -func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) { - if m.Error != nil { - return nil, m.Error +func (m *MockPlaylistRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { + if m.Err { + return nil, errors.New("error") } - if m.Entity == nil { - return nil, model.ErrNotFound + if m.Data != nil { + if pls, ok := m.Data[id]; ok { + return pls, nil + } } - return m.Entity, nil + return nil, model.ErrNotFound +} + +func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, error) { + return m.Get(id) +} + +func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { + if m.Err { + return errors.New("error") + } + if pls.ID == "" { + pls.ID = id.NewRandom() + } + m.Last = pls + if m.Data != nil { + m.Data[pls.ID] = pls + } + return nil +} + +func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { + if m.Err { + return nil, errors.New("error") + } + if m.PathMap != nil { + if pls, ok := m.PathMap[path]; ok { + return pls, nil + } + } + return nil, model.ErrNotFound +} + +func (m *MockPlaylistRepo) Delete(id string) error { + if m.Err { + return errors.New("error") + } + m.Deleted = append(m.Deleted, id) + return nil } func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { - return m.TracksReturn + return m.TracksRepo +} + +func (m *MockPlaylistRepo) Exists(id string) (bool, error) { + if m.Err { + return false, errors.New("error") + } + if m.Data != nil { + _, found := m.Data[id] + return found, nil + } + return false, nil } func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { - if m.Error != nil { - return 0, m.Error + if m.Err { + return 0, errors.New("error") } - if m.Entity == nil { - return 0, nil - } - return 1, nil + return int64(len(m.Data)), nil } + +func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("error") + } + return int64(len(m.Data)), nil +} + +var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil) diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go new file mode 100644 index 000000000..c11b077d2 --- /dev/null +++ b/tests/mock_playlist_track_repo.go @@ -0,0 +1,53 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +type MockPlaylistTrackRepo struct { + model.PlaylistTrackRepository + AddedIds []string + DeletedIds []string + Reordered bool + AddCount int + Err error +} + +func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { + m.AddedIds = append(m.AddedIds, ids...) + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) AddArtists(_ []string) (int, error) { + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) AddDiscs(_ []model.DiscID) (int, error) { + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) Delete(ids ...string) error { + m.DeletedIds = append(m.DeletedIds, ids...) + return m.Err +} + +func (m *MockPlaylistTrackRepo) Reorder(_, _ int) error { + m.Reordered = true + return m.Err +} + +var _ model.PlaylistTrackRepository = (*MockPlaylistTrackRepo)(nil) From ec75808153e9478fb74b4df983df8f5ff7319b27 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 21 Feb 2026 22:00:00 -0500 Subject: [PATCH 018/596] fix(subsonic): handle empty quoted phrases in FTS5 query and search expression Signed-off-by: Deluan --- persistence/sql_search_fts.go | 4 ++-- persistence/sql_search_fts_test.go | 5 +++++ server/e2e/subsonic_searching_test.go | 10 ++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 25f16cb8d..ea70518b6 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -134,7 +134,7 @@ func isDottedAbbreviation(w string, subTokens []string) bool { // special characters to prevent query injection. func buildFTS5Query(userInput string) string { q := strings.TrimSpace(userInput) - if q == "" { + if q == "" || q == `""` { return "" } @@ -239,7 +239,7 @@ var ftsSearchColumns = map[string]string{ func ftsSearchExpr(tableName string, s string) Sqlizer { q := buildFTS5Query(s) if q == "" { - s = strings.TrimSpace(s) + s = strings.TrimSpace(strings.ReplaceAll(s, `"`, "")) if s != "" { log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s) return likeSearchExpr(tableName, s) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index 317252950..e0fead8ae 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -49,6 +49,7 @@ var _ = DescribeTable("buildFTS5Query", Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), Entry("returns empty string for mixed punctuation", "!@#$%^&", ""), + Entry("returns empty string for empty quoted phrase", `""`, ""), ) var _ = DescribeTable("normalizeForFTS", @@ -204,6 +205,10 @@ var _ = Describe("ftsSearchExpr", func() { Expect(ftsSearchExpr("media_file", "")).To(BeNil()) Expect(ftsSearchExpr("media_file", " ")).To(BeNil()) }) + + It("returns nil for empty quoted phrase", func() { + Expect(ftsSearchExpr("media_file", `""`)).To(BeNil()) + }) }) var _ = Describe("FTS5 Integration Search", func() { diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index 3a7512fd2..6ea6559c2 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -107,6 +107,16 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.SearchResult3.Artist[0].Id).ToNot(BeEmpty()) }) + It("returns all results when query is empty (OpenSubsonic)", func() { + resp := doReq("search3", "query", "") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(HaveLen(4)) + Expect(resp.SearchResult3.Album).To(HaveLen(5)) + Expect(resp.SearchResult3.Song).To(HaveLen(6)) + }) + It("finds across all entity types simultaneously", func() { // "Beatles" should match artist, albums, and songs by The Beatles resp := doReq("search3", "query", "Beatles") From d02bf9a53dbed0c1376d93cdb76f3f0a82749f41 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 22 Feb 2026 00:32:14 -0500 Subject: [PATCH 019/596] test(e2e): add MusicBrainz ID tests for song and album searches Signed-off-by: Deluan --- server/e2e/e2e_suite_test.go | 41 +++++++++++++--- server/e2e/subsonic_searching_test.go | 71 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 7bda1527c..5981f4a86 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -52,6 +52,17 @@ type _t = map[string]any var template = storagetest.Template var track = storagetest.Track +// MusicBrainz ID constants for test data (valid UUID v4 values) +const ( + mbidBeatlesArtist = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d" + mbidAbbeyRoadAlbum = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" + mbidAbbeyRoadRelGroup = "d4c3b2a1-f6e5-4b7a-9d8c-1f0e3a2b5c4d" + mbidComeTogether = "11111111-1111-4111-a111-111111111111" // mbz_release_track_id + mbidComeTogetherRec = "22222222-2222-4222-a222-222222222222" // mbz_recording_id + mbidSomething = "33333333-3333-4333-a333-333333333333" // mbz_release_track_id + mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id +) + // Shared test state var ( ctx context.Context @@ -89,23 +100,37 @@ func createFS(files fstest.MapFS) storagetest.FakeFS { // buildTestFS creates the full test filesystem matching the plan func buildTestFS() storagetest.FakeFS { - abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + abbeyRoad := template(_t{ + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock", + "musicbrainz_artistid": mbidBeatlesArtist, + "musicbrainz_albumartistid": mbidBeatlesArtist, + "musicbrainz_albumid": mbidAbbeyRoadAlbum, + "musicbrainz_releasegroupid": mbidAbbeyRoadRelGroup, + }) help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) return createFS(fstest.MapFS{ - // Rock / The Beatles / Abbey Road - "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")), - "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something")), - // Rock / The Beatles / Help! + // Rock / The Beatles / Abbey Road (with MBIDs) + // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), + // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). + "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", + _t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec})), + "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", + _t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})), + // Rock / The Beatles / Help! (no MBIDs) "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), - // Rock / Led Zeppelin / IV + // Rock / Led Zeppelin / IV (no MBIDs) "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), - // Jazz / Miles Davis / Kind of Blue + // Jazz / Miles Davis / Kind of Blue (no MBIDs) "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - // Pop (standalone track) + // Pop (standalone track, no MBIDs) "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index 6ea6559c2..d0447ecd7 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -1,6 +1,7 @@ package e2e import ( + "github.com/google/uuid" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -146,5 +147,75 @@ var _ = Describe("Search Endpoints", func() { Expect(s.Title).ToNot(BeEmpty()) } }) + + Describe("MBID search", func() { + It("finds songs by mbz_recording_id", func() { + resp := doReq("search3", "query", mbidComeTogetherRec) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).To(HaveLen(1)) + Expect(resp.SearchResult3.Song[0].Title).To(Equal("Come Together")) + }) + + It("finds songs by mbz_release_track_id", func() { + resp := doReq("search3", "query", mbidSomething) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).To(HaveLen(1)) + Expect(resp.SearchResult3.Song[0].Title).To(Equal("Something")) + }) + + It("finds albums by mbz_album_id", func() { + resp := doReq("search3", "query", mbidAbbeyRoadAlbum) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Album).To(HaveLen(1)) + Expect(resp.SearchResult3.Album[0].Name).To(Equal("Abbey Road")) + }) + + It("finds albums by mbz_release_group_id", func() { + resp := doReq("search3", "query", mbidAbbeyRoadRelGroup) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Album).To(HaveLen(1)) + Expect(resp.SearchResult3.Album[0].Name).To(Equal("Abbey Road")) + }) + + It("finds artists by mbz_artist_id", func() { + resp := doReq("search3", "query", mbidBeatlesArtist) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(HaveLen(1)) + Expect(resp.SearchResult3.Artist[0].Name).To(Equal("The Beatles")) + }) + + It("returns empty results for non-matching UUID", func() { + nonMatchingUUID := uuid.NewString() + resp := doReq("search3", "query", nonMatchingUUID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(BeEmpty()) + Expect(resp.SearchResult3.Album).To(BeEmpty()) + Expect(resp.SearchResult3.Song).To(BeEmpty()) + }) + + It("does not return songs for artist MBID", func() { + // media_file MBID search only checks mbz_recording_id and mbz_release_track_id, + // so an artist MBID should return only the artist, not songs + resp := doReq("search3", "query", mbidBeatlesArtist) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(HaveLen(1)) + Expect(resp.SearchResult3.Artist[0].Name).To(Equal("The Beatles")) + Expect(resp.SearchResult3.Song).To(BeEmpty()) + }) + }) }) }) From 23bf256a66f47713d225269546bc542bad1c9e40 Mon Sep 17 00:00:00 2001 From: Valeri Sokolov Date: Mon, 23 Feb 2026 04:05:59 +0100 Subject: [PATCH 020/596] feat: make album and artist annotations available to smart playlists (#4927) * feat(criteria): make album ratings available to smart playlist queries Expose an "albumrating" field mapping to album annotations. Signed-off-by: Valeri Sokolov * fix(criteria): use query parameters Signed-off-by: Valeri Sokolov * feat: add album and artist annotation fields to smart playlists Extend smart playlists to filter songs by album or artist annotations (rating, loved, play count, last played, date loved, date rated). This adds 12 new fields (6 album, 6 artist) with conditional JOINs that are only added when the criteria or sort references them, avoiding unnecessary query overhead. The album table JOIN is also removed since media_file.album_id can be used directly. --------- Signed-off-by: Valeri Sokolov Co-authored-by: Deluan --- model/criteria/criteria.go | 19 +++ model/criteria/criteria_test.go | 96 ++++++++++++++- model/criteria/fields.go | 151 ++++++++++++++++-------- model/criteria/operators_test.go | 62 ++++++---- persistence/playlist_repository.go | 23 +++- persistence/playlist_repository_test.go | 100 ++++++++++++++++ 6 files changed, 374 insertions(+), 77 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 54ac59697..bc3fe801c 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -95,6 +95,25 @@ func (c Criteria) ToSql() (sql string, args []any, err error) { return c.Expression.ToSql() } +// RequiredJoins inspects the expression tree and Sort field to determine which +// additional JOINs are needed when evaluating this criteria. +func (c Criteria) RequiredJoins() JoinType { + result := JoinNone + if c.Expression != nil { + result |= extractJoinTypes(c.Expression) + } + // Also check Sort fields + if c.Sort != "" { + for _, p := range strings.Split(c.Sort, ",") { + p = strings.TrimSpace(p) + p = strings.TrimLeft(p, "+-") + p = strings.TrimSpace(p) + result |= fieldJoinType(p) + } + } + return result +} + func (c Criteria) ChildPlaylistIds() []string { if c.Expression == nil { return nil diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 032ead5c8..9a4da360c 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -27,6 +27,7 @@ var _ = Describe("Criteria", func() { StartsWith{"comment": "this"}, InTheRange{"year": []int{1980, 1990}}, IsNot{"genre": "Rock"}, + Gt{"albumrating": 3}, }, }, Sort: "title", @@ -48,7 +49,8 @@ var _ = Describe("Criteria", func() { { "all": [ { "startsWith": {"comment": "this"} }, { "inTheRange": {"year":[1980,1990]} }, - { "isNot": { "genre": "Rock" }} + { "isNot": { "genre": "Rock" }}, + { "gt": { "albumrating": 3 } } ] } ], @@ -68,10 +70,10 @@ var _ = Describe("Criteria", func() { gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(sql).To(gomega.Equal( `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` + - `AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` + + `AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` + `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` + - `AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`)) - gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock")) + `AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`)) + gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3)) }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) @@ -172,13 +174,95 @@ var _ = Describe("Criteria", func() { sql, args, err := goObj.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(sql).To(gomega.Equal( - `(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` + - `exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`, + `(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` + + `exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`, )) gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%")) }) }) + Describe("RequiredJoins", func() { + It("returns JoinNone when no annotation fields are used", func() { + c := Criteria{ + Expression: All{ + Contains{"title": "love"}, + }, + } + gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) + }) + It("returns JoinNone for media_file annotation fields", func() { + c := Criteria{ + Expression: All{ + Is{"loved": true}, + Gt{"playCount": 5}, + }, + } + gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) + }) + It("returns JoinAlbumAnnotation for album annotation fields", func() { + c := Criteria{ + Expression: All{ + Gt{"albumRating": 3}, + }, + } + gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation)) + }) + It("returns JoinArtistAnnotation for artist annotation fields", func() { + c := Criteria{ + Expression: All{ + Is{"artistLoved": true}, + }, + } + gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation)) + }) + It("returns both join types when both are used", func() { + c := Criteria{ + Expression: All{ + Gt{"albumRating": 3}, + Is{"artistLoved": true}, + }, + } + j := c.RequiredJoins() + gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) + gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) + }) + It("detects join types in nested expressions", func() { + c := Criteria{ + Expression: All{ + Any{ + All{ + Is{"albumLoved": true}, + }, + }, + Any{ + Gt{"artistPlayCount": 10}, + }, + }, + } + j := c.RequiredJoins() + gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) + gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) + }) + It("detects join types from Sort field", func() { + c := Criteria{ + Expression: All{ + Contains{"title": "love"}, + }, + Sort: "albumRating", + } + gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) + }) + It("detects join types from Sort field with direction prefix", func() { + c := Criteria{ + Expression: All{ + Contains{"title": "love"}, + }, + Sort: "-artistRating", + } + gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue()) + }) + }) + Context("with child playlists", func() { var ( topLevelInPlaylistID string diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b5ac92718..4dfc50f11 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -9,45 +9,71 @@ import ( "github.com/navidrome/navidrome/log" ) +// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression. +type JoinType int + +const ( + JoinNone JoinType = 0 + JoinAlbumAnnotation JoinType = 1 << iota + JoinArtistAnnotation +) + +// Has returns true if j contains all bits in other. +func (j JoinType) Has(other JoinType) bool { return j&other != 0 } + var fieldMap = map[string]*mappedField{ - "title": {field: "media_file.title"}, - "album": {field: "media_file.album"}, - "hascoverart": {field: "media_file.has_cover_art"}, - "tracknumber": {field: "media_file.track_number"}, - "discnumber": {field: "media_file.disc_number"}, - "year": {field: "media_file.year"}, - "date": {field: "media_file.date", alias: "recordingdate"}, - "originalyear": {field: "media_file.original_year"}, - "originaldate": {field: "media_file.original_date"}, - "releaseyear": {field: "media_file.release_year"}, - "releasedate": {field: "media_file.release_date"}, - "size": {field: "media_file.size"}, - "compilation": {field: "media_file.compilation"}, - "explicitstatus": {field: "media_file.explicit_status"}, - "dateadded": {field: "media_file.created_at"}, - "datemodified": {field: "media_file.updated_at"}, - "discsubtitle": {field: "media_file.disc_subtitle"}, - "comment": {field: "media_file.comment"}, - "lyrics": {field: "media_file.lyrics"}, - "sorttitle": {field: "media_file.sort_title"}, - "sortalbum": {field: "media_file.sort_album_name"}, - "sortartist": {field: "media_file.sort_artist_name"}, - "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumcomment": {field: "media_file.mbz_album_comment"}, - "catalognumber": {field: "media_file.catalog_num"}, - "filepath": {field: "media_file.path"}, - "filetype": {field: "media_file.suffix"}, - "duration": {field: "media_file.duration"}, - "bitrate": {field: "media_file.bit_rate"}, - "bitdepth": {field: "media_file.bit_depth"}, - "bpm": {field: "media_file.bpm"}, - "channels": {field: "media_file.channels"}, - "loved": {field: "COALESCE(annotation.starred, false)"}, - "dateloved": {field: "annotation.starred_at"}, - "lastplayed": {field: "annotation.play_date"}, - "daterated": {field: "annotation.rated_at"}, - "playcount": {field: "COALESCE(annotation.play_count, 0)"}, - "rating": {field: "COALESCE(annotation.rating, 0)"}, + "title": {field: "media_file.title"}, + "album": {field: "media_file.album"}, + "hascoverart": {field: "media_file.has_cover_art"}, + "tracknumber": {field: "media_file.track_number"}, + "discnumber": {field: "media_file.disc_number"}, + "year": {field: "media_file.year"}, + "date": {field: "media_file.date", alias: "recordingdate"}, + "originalyear": {field: "media_file.original_year"}, + "originaldate": {field: "media_file.original_date"}, + "releaseyear": {field: "media_file.release_year"}, + "releasedate": {field: "media_file.release_date"}, + "size": {field: "media_file.size"}, + "compilation": {field: "media_file.compilation"}, + "explicitstatus": {field: "media_file.explicit_status"}, + "dateadded": {field: "media_file.created_at"}, + "datemodified": {field: "media_file.updated_at"}, + "discsubtitle": {field: "media_file.disc_subtitle"}, + "comment": {field: "media_file.comment"}, + "lyrics": {field: "media_file.lyrics"}, + "sorttitle": {field: "media_file.sort_title"}, + "sortalbum": {field: "media_file.sort_album_name"}, + "sortartist": {field: "media_file.sort_artist_name"}, + "sortalbumartist": {field: "media_file.sort_album_artist_name"}, + "albumcomment": {field: "media_file.mbz_album_comment"}, + "catalognumber": {field: "media_file.catalog_num"}, + "filepath": {field: "media_file.path"}, + "filetype": {field: "media_file.suffix"}, + "duration": {field: "media_file.duration"}, + "bitrate": {field: "media_file.bit_rate"}, + "bitdepth": {field: "media_file.bit_depth"}, + "bpm": {field: "media_file.bpm"}, + "channels": {field: "media_file.channels"}, + "loved": {field: "COALESCE(annotation.starred, false)"}, + "dateloved": {field: "annotation.starred_at"}, + "lastplayed": {field: "annotation.play_date"}, + "daterated": {field: "annotation.rated_at"}, + "playcount": {field: "COALESCE(annotation.play_count, 0)"}, + "rating": {field: "COALESCE(annotation.rating, 0)"}, + "albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation}, + "albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation}, + "albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation}, + "albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation}, + "albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation}, + "albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation}, + + "artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation}, + "artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation}, + "artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation}, + "artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation}, + "artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation}, + "artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation}, + "mbz_album_id": {field: "media_file.mbz_album_id"}, "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, "mbz_artist_id": {field: "media_file.mbz_artist_id"}, @@ -65,12 +91,13 @@ var fieldMap = map[string]*mappedField{ } type mappedField struct { - field string - order string - isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) - isTag bool // true if the field is a tag imported from the file metadata - alias string // name from `mappings.yml` that may differ from the name used in the smart playlist - numeric bool // true if the field/tag should be treated as numeric + field string + order string + isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) + isTag bool // true if the field is a tag imported from the file metadata + alias string // name from `mappings.yml` that may differ from the name used in the smart playlist + numeric bool // true if the field/tag should be treated as numeric + joinType JoinType // which additional JOINs this field requires } func mapFields(expr map[string]any) map[string]any { @@ -169,7 +196,7 @@ func (e tagCond) ToSql() (string, []any, error) { } } - cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", tagName, cond) if e.not { cond = "not " + cond @@ -189,7 +216,7 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`, + cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`, e.role, cond) if e.not { cond = "not " + cond @@ -197,6 +224,38 @@ func (e roleCond) ToSql() (string, []any, error) { return cond, args, err } +// fieldJoinType returns the JoinType for a given field name (case-insensitive). +func fieldJoinType(name string) JoinType { + if f, ok := fieldMap[strings.ToLower(name)]; ok { + return f.joinType + } + return JoinNone +} + +// extractJoinTypes walks an expression tree and collects all required JoinType flags. +func extractJoinTypes(expr any) JoinType { + result := JoinNone + switch e := expr.(type) { + case All: + for _, sub := range e { + result |= extractJoinTypes(sub) + } + case Any: + for _, sub := range e { + result |= extractJoinTypes(sub) + } + default: + // Leaf expression: use reflection to check if it's a map with field names + rv := reflect.ValueOf(expr) + if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { + for _, key := range rv.MapKeys() { + result |= fieldJoinType(key.String()) + } + } + } + return result +} + // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in // smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent. func AddRoles(roles []string) { diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 4c1db1303..f0681af6b 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -54,23 +54,43 @@ var _ = Describe("Operators", func() { Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())), Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), + // Album annotation fields + Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), + Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true), + Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5), + Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart), + Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart), + Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart), + Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())), + Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), + + // Artist annotation fields + Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3), + Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5), + Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart), + Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart), + Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart), + Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())), + Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), + // Tag tests - Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"), - Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"), - Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), - Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), + Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"), + Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"), + Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), + Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), // Artist roles tests - Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"), - Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), + Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"), + Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), ) // TODO Validate operators that are not valid for each field type. @@ -88,7 +108,7 @@ var _ = Describe("Operators", func() { op := EndsWith{"mood": "Soft"} sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) }) It("casts numeric comparisons", func() { @@ -96,7 +116,7 @@ var _ = Describe("Operators", func() { op := Lt{"rate": 6} sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) gomega.Expect(args).To(gomega.HaveExactElements(6)) }) It("skips unknown tag names", func() { @@ -110,7 +130,7 @@ var _ = Describe("Operators", func() { op := Contains{"releasetype": "soundtrack"} sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) }) It("supports albumtype as alias for releasetype", func() { @@ -118,7 +138,7 @@ var _ = Describe("Operators", func() { op := Contains{"albumtype": "live"} sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%live%")) }) It("supports albumtype alias with Is operator", func() { @@ -127,7 +147,7 @@ var _ = Describe("Operators", func() { sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) gomega.Expect(args).To(gomega.HaveExactElements("album")) }) It("supports albumtype alias with IsNot operator", func() { @@ -136,7 +156,7 @@ var _ = Describe("Operators", func() { sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) gomega.Expect(args).To(gomega.HaveExactElements("compilation")) }) }) @@ -147,7 +167,7 @@ var _ = Describe("Operators", func() { op := EndsWith{"producer": "Eno"} sql, args, err := op.ToSql() gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) }) It("skips unknown roles", func() { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 955a13bc9..11b9cd8b4 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -241,10 +241,25 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). - From("media_file").LeftJoin("annotation on (" + - "annotation.item_id = media_file.id" + - " AND annotation.item_type = 'media_file'" + - " AND annotation.user_id = '" + usr.ID + "')") + From("media_file").LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", usr.ID) + + // Conditionally join album/artist annotation tables only when referenced by criteria or sort + requiredJoins := rules.RequiredJoins() + if requiredJoins.Has(criteria.JoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", usr.ID) + } + if requiredJoins.Has(criteria.JoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", usr.ID) + } // Only include media files from libraries the user has access to sq = r.applyLibraryFilter(sq, "media_file") diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 5230390fb..c091cb32b 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -287,6 +287,106 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + }) + + It("matches tracks from starred albums using albumLoved", func() { + // albumRadioactivity (ID "103") is starred in test fixtures + // Songs in album 103: 1003, 1004, 1005, 1006 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) + }) + + It("matches tracks from starred artists using artistLoved", func() { + // artistBeatles (ID "3") is starred in test fixtures + // Songs with ArtistID "3": 1001, 1002, 3002 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) + }) + + It("matches tracks with combined album and artist criteria", func() { + // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) + // artistLoved=true → songs with artist 3 (1001, 1002) + // Using Any: union of both sets + rules := &criteria.Criteria{ + Expression: criteria.Any{ + criteria.Is{"albumLoved": true}, + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) + }) + + It("returns no tracks when no albums/artists match", func() { + // No album has rating 5 in fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumRating": 5}, + }, + } + newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + Expect(pls.Tracks).To(BeEmpty()) + }) + }) + Describe("Smart Playlists with Tag Criteria", func() { var mfRepo model.MediaFileRepository var testPlaylistID string From b59eb329610964f226f66fecb7190b06d28d052c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 23 Feb 2026 08:51:54 -0500 Subject: [PATCH 021/596] feat(subsonic): sort search3 results by relevance (#5086) * fix(subsonic): optimize search3 for high-cardinality FTS queries Use a two-phase query strategy for FTS5 searches to avoid the performance penalty of expensive LEFT JOINs (annotation, bookmark, library) on high-cardinality results like "the". Phase 1 runs a lightweight query (main table + FTS index only) to get sorted, paginated rowids. Phase 2 hydrates only those few rowids with the full JOINs, making them nearly free. For queries with complex ORDER BY expressions that reference joined tables (e.g. artist search sorted by play count), the optimization is skipped and the original single-query approach is used. * fix(search): update order by clauses to include 'rank' for FTS queries Signed-off-by: Deluan * fix(search): reintroduce 'rank' in Phase 2 ORDER BY for FTS queries Signed-off-by: Deluan * fix(search): remove 'rank' from ORDER BY in non-FTS queries and adjust two-phase query handling Signed-off-by: Deluan * fix(search): update FTS ranking to use bm25 weights and simplify ORDER BY qualification Signed-off-by: Deluan * fix(search): refine FTS query handling and improve comments for clarity Signed-off-by: Deluan * fix(search): refactor full-text search handling to streamline query strategy selection and improve LIKE fallback logic. Increase e2e coverage for search3 Signed-off-by: Deluan * refactor: enhance FTS column definitions and relevance weights Signed-off-by: Deluan * fix(search): refactor Search method signatures to remove offset and size parameters, streamline query handling Signed-off-by: Deluan * fix(search): allow single-character queries in search strategies and update related tests Signed-off-by: Deluan * fix(search): make FTS Phase 1 treat Max=0 as no limit, reorganize tests FTS Phase 1 unconditionally called Limit(uint64(options.Max)), which produced LIMIT 0 when Max was zero. This diverged from applyOptions where Max=0 means no limit. Now Phase 1 mirrors applyOptions: only add LIMIT/OFFSET when the value is positive. Also moved legacy backend integration tests from sql_search_fts_test.go to sql_search_like_test.go and added regression tests for the Max=0 behavior on both backends. * refactor: simplify callSearch function by removing variadic options and directly using QueryOptions Signed-off-by: Deluan * fix(search): implement ftsQueryDegraded function to detect significant content loss in FTS queries Signed-off-by: Deluan --------- Signed-off-by: Deluan --- model/searchable.go | 2 +- persistence/album_repository.go | 26 +- persistence/artist_repository.go | 32 +-- persistence/artist_repository_test.go | 16 +- persistence/mediafile_repository.go | 26 +- persistence/mediafile_repository_test.go | 14 +- persistence/sql_restful.go | 6 +- persistence/sql_restful_test.go | 122 +++++---- persistence/sql_search.go | 118 +++++---- persistence/sql_search_fts.go | 271 +++++++++++++++----- persistence/sql_search_fts_test.go | 299 +++++++++++++++-------- persistence/sql_search_like.go | 106 ++++++++ persistence/sql_search_like_test.go | 134 ++++++++++ persistence/sql_search_test.go | 83 +++---- server/e2e/e2e_suite_test.go | 3 + server/e2e/subsonic_album_lists_test.go | 40 +-- server/e2e/subsonic_browsing_test.go | 4 +- server/e2e/subsonic_multilibrary_test.go | 18 +- server/e2e/subsonic_searching_test.go | 59 ++++- server/subsonic/searching.go | 35 ++- tests/mock_album_repo.go | 2 +- tests/mock_artist_repo.go | 2 +- tests/mock_mediafile_repo.go | 2 +- 23 files changed, 1005 insertions(+), 415 deletions(-) create mode 100644 persistence/sql_search_like.go create mode 100644 persistence/sql_search_like_test.go diff --git a/model/searchable.go b/model/searchable.go index 631a11726..a64a0171c 100644 --- a/model/searchable.go +++ b/model/searchable.go @@ -1,5 +1,5 @@ package model type SearchableRepository[T any] interface { - Search(q string, offset, size int, options ...QueryOptions) (T, error) + Search(q string, options ...QueryOptions) (T, error) } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 58bfcca51..077e33d17 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -12,7 +12,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -353,18 +352,21 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error { return nil } -func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { +var albumSearchConfig = searchConfig{ + NaturalOrder: "album.rowid", + OrderBy: []string{"name"}, + MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"}, +} + +func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + } var res dbAlbums - if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res) - if err != nil { - return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) - } - } else { - err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name") - if err != nil { - return nil, fmt.Errorf("searching album by query %q: %w", q, err) - } + err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts) + if err != nil { + return nil, fmt.Errorf("searching album %q: %w", q, err) } return res.toModels(), nil } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f801787d8..07824e21f 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -11,7 +11,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -513,20 +512,25 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { return totalRowsAffected, nil } -func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { - var res dbArtists - if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res) - if err != nil { - return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) - } - } else { +func (r *artistRepository) searchCfg() searchConfig { + return searchConfig{ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist - err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id", - "sum(json_extract(stats, '$.total.m')) desc", "name") - if err != nil { - return nil, fmt.Errorf("searching artist by query %q: %w", q, err) - } + NaturalOrder: "artist.id", + OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, + MBIDFields: []string{"mbz_artist_id"}, + LibraryFilter: r.applyLibraryFilterToArtistQuery, + } +} + +func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + } + var res dbArtists + err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts) + if err != nil { + return nil, fmt.Errorf("searching artist %q: %w", q, err) } return res.toModels(), nil } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 15340eeb4..90e449e8d 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -512,7 +512,7 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) // Test the search - results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10) + results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) if shouldFind { @@ -543,12 +543,12 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) + results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // But admin should find it - results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) + results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) @@ -560,7 +560,7 @@ var _ = Describe("ArtistRepository", func() { Context("Text Search", func() { It("allows admin to find artists by name regardless of library", func() { - results, err := repo.Search("Beatles", 0, 10) + results, err := repo.Search("Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("The Beatles")) @@ -580,7 +580,7 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search("Unique Search Name", 0, 10) + results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "Text search should respect library filtering") @@ -688,7 +688,7 @@ var _ = Describe("ArtistRepository", func() { Expect(artists).To(HaveLen(5)) // Including the missing artist // Search never returns missing artists (hardcoded behavior) - results, err := repo.Search("Missing Artist", 0, 10) + results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -742,11 +742,11 @@ var _ = Describe("ArtistRepository", func() { }) It("Search returns empty results for users without library access", func() { - results, err := restrictedRepo.Search("Beatles", 0, 10) + results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) - results, err = restrictedRepo.Search("Kraftwerk", 0, 10) + results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 264be6f3f..394ca5b70 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -11,7 +11,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -428,18 +427,21 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil return res.toModels(), nil } -func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { +var mediaFileSearchConfig = searchConfig{ + NaturalOrder: "media_file.rowid", + OrderBy: []string{"title"}, + MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"}, +} + +func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + } var res dbMediaFiles - if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res) - if err != nil { - return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) - } - } else { - err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title") - if err != nil { - return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) - } + err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts) + if err != nil { + return nil, fmt.Errorf("searching media_file %q: %w", q, err) } return res.toModels(), nil } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 853480b32..7a04d79e4 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -527,7 +527,7 @@ var _ = Describe("MediaRepository", func() { Describe("Search", func() { Context("text search", func() { It("finds media files by title", func() { - results, err := mr.Search("Antenna", 0, 10) + results, err := mr.Search("Antenna", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 for _, result := range results { @@ -536,7 +536,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media files case insensitively", func() { - results, err := mr.Search("antenna", 0, 10) + results, err := mr.Search("antenna", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, result := range results { @@ -545,7 +545,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when no matches found", func() { - results, err := mr.Search("nonexistent", 0, 10) + results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -578,7 +578,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media file by mbz_recording_id", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -586,7 +586,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media file by mbz_release_track_id", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -594,7 +594,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when MBID is not found", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -614,7 +614,7 @@ var _ = Describe("MediaRepository", func() { Expect(err).ToNot(HaveOccurred()) // Search never returns missing media files (hardcoded behavior) - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 27207c45d..02162387c 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -109,12 +109,10 @@ func booleanFilter(field string, value any) Sqlizer { func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { return func(field string, value any) Sqlizer { v := strings.ToLower(value.(string)) - searchExpr := getSearchExpr() - cond := cmp.Or( + return cmp.Or[Sqlizer]( mbidExpr(tableName, v, mbidFields...), - searchExpr(tableName, v), + getSearchStrategy(tableName, v), ) - return cond } } diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go index ea0f802af..32f418b8e 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -102,11 +102,11 @@ var _ = Describe("sqlRestful", func() { uuid := "550e8400-e29b-41d4-a716-446655440000" result := noMbidFilter("search", uuid) - // mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr - expected := squirrel.And{ - squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"}, - } - Expect(result).To(Equal(expected)) + // mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%")) }) }) @@ -114,26 +114,25 @@ var _ = Describe("sqlRestful", func() { It("returns full text search condition only", func() { result := filter("search", "beatles") - // mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly - expected := squirrel.And{ - squirrel.Like{"test_table.full_text": "% beatles%"}, - } - Expect(result).To(Equal(expected)) + // mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(ContainElement("% beatles%")) }) It("handles multi-word search terms", func() { result := filter("search", "the beatles abbey road") - // Should return And condition directly - andCondition, ok := result.(squirrel.And) - Expect(ok).To(BeTrue()) - Expect(andCondition).To(HaveLen(4)) - - // Check that all words are present (order may vary) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"})) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"})) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"})) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"})) + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + // All words should be present as LIKE conditions + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(HaveLen(4)) + Expect(args).To(ContainElement("% the%")) + Expect(args).To(ContainElement("% beatles%")) + Expect(args).To(ContainElement("% abbey%")) + Expect(args).To(ContainElement("% road%")) }) }) @@ -142,26 +141,48 @@ var _ = Describe("sqlRestful", func() { conf.Server.Search.FullString = false result := filter("search", "test query") - andCondition, ok := result.(squirrel.And) - Expect(ok).To(BeTrue()) - Expect(andCondition).To(HaveLen(2)) - - // Check that all words are present with leading space (order may vary) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"})) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"})) + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(HaveLen(2)) + Expect(args).To(ContainElement("% test%")) + Expect(args).To(ContainElement("% query%")) }) It("uses no separator with SearchFullString=true", func() { conf.Server.Search.FullString = true result := filter("search", "test query") - andCondition, ok := result.(squirrel.And) - Expect(ok).To(BeTrue()) - Expect(andCondition).To(HaveLen(2)) + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(HaveLen(2)) + Expect(args).To(ContainElement("%test%")) + Expect(args).To(ContainElement("%query%")) + }) + }) - // Check that all words are present without leading space (order may vary) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"})) - Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"})) + Context("single-character queries (regression: must not be rejected)", func() { + It("returns valid filter for single-char query with legacy backend", func() { + conf.Server.Search.Backend = "legacy" + result := filter("search", "a") + Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped") + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + Expect(args).ToNot(BeEmpty()) + }) + + It("returns valid filter for single-char query with FTS backend", func() { + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + ftsFilter := fullTextFilter(tableName, mbidFields...) + result := ftsFilter("search", "a") + Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped") + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("MATCH")) + Expect(args).ToNot(BeEmpty()) }) }) @@ -179,10 +200,10 @@ var _ = Describe("sqlRestful", func() { It("handles special characters that are sanitized", func() { result := filter("search", "don't") - expected := squirrel.And{ - squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes - } - Expect(result).To(Equal(expected)) + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(ContainElement("% dont%")) }) It("returns nil for single quote (SQL injection protection)", func() { @@ -206,31 +227,30 @@ var _ = Describe("sqlRestful", func() { result := filter("search", "550e8400-invalid-uuid") // Should return full text filter since UUID is invalid - expected := squirrel.And{ - squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"}, - } - Expect(result).To(Equal(expected)) + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(ContainElement("% 550e8400-invalid-uuid%")) }) It("handles empty mbid fields array", func() { emptyMbidFilter := fullTextFilter(tableName, []string{}...) result := emptyMbidFilter("search", "test") - // mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr - expected := squirrel.And{ - squirrel.Like{"test_table.full_text": "% test%"}, - } - Expect(result).To(Equal(expected)) + // mbidExpr with empty fields returns nil, so search strategy result is returned directly + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(ContainElement("% test%")) }) It("converts value to lowercase before processing", func() { result := filter("search", "TEST") - // The function converts to lowercase internally - expected := squirrel.And{ - squirrel.Like{"test_table.full_text": "% test%"}, - } - Expect(result).To(Equal(expected)) + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) + Expect(args).To(ContainElement("% test%")) }) }) }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index e5c245bdf..43965ebb7 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -6,7 +6,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) @@ -16,57 +15,71 @@ func formatFullText(text ...string) string { return " " + fullText } -// searchExprFunc is the function signature for search expression builders. -type searchExprFunc func(tableName string, query string) Sqlizer - -// getSearchExpr returns the active search expression function based on config. -// It falls back to legacySearchExpr when Search.FullString is enabled, because -// FTS5 is token-based and cannot match substrings within words. -// CJK queries are routed to likeSearchExpr, since FTS5's unicode61 tokenizer -// cannot segment CJK text. -func getSearchExpr() searchExprFunc { - if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString { - return legacySearchExpr - } - return func(tableName, query string) Sqlizer { - if containsCJK(query) { - return likeSearchExpr(tableName, query) - } - return ftsSearchExpr(tableName, query) - } +// searchConfig holds per-repository constants for doSearch. +type searchConfig struct { + NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid") + OrderBy []string // ORDER BY for text search results (e.g. ["name"]) + MBIDFields []string // columns to match when query is a UUID + // LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1. + // Needed when library access requires a junction table (e.g. artist → library_artist). + LibraryFilter func(sq SelectBuilder) SelectBuilder } -// doSearch performs a full-text search with the specified parameters. -// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like -// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter -// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order. -func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error { +// searchStrategy defines how to execute a text search against a repository table. +// options carries filters and pagination that must reach all query phases, +// including FTS Phase 1 which builds its own query outside sq. +type searchStrategy interface { + Sqlizer + execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error +} + +// getSearchStrategy returns the appropriate search strategy based on config and query content. +// Returns nil when the query produces no searchable tokens. +func getSearchStrategy(tableName, query string) searchStrategy { + if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString { + return newLegacySearch(tableName, query) + } + if containsCJK(query) { + return newLikeSearch(tableName, query) + } + return newFTSSearch(tableName, query) +} + +// doSearch dispatches a search query: empty → natural order, UUID → MBID match, +// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set +// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same +// filters and pagination independently. +func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error { q = strings.TrimSpace(q) q = strings.TrimSuffix(q, "*") + + sq = sq.Where(Eq{r.tableName + ".missing": false}) + + // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order. + if q == "" || q == `""` { + sq = sq.OrderBy(cfg.NaturalOrder) + return r.queryAll(sq, results, options) + } + + // MBID search: if query is a valid UUID, search by MBID fields instead + if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 { + sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...)) + return r.queryAll(sq, results) + } + + // Min-length guard: single-character queries are too broad for search3. + // This check lives here (not in the strategies) so that fullTextFilter + // (REST filter path) can still use single-character queries. if len(q) < 2 { return nil } - searchExpr := getSearchExpr() - filter := searchExpr(r.tableName, q) - if filter != nil { - sq = sq.Where(filter) - sq = sq.OrderBy(orderBys...) - } else { - // This is to speed up the results of `search3?query=""`, for OpenSubsonic - // If the filter is empty, we sort by the specified natural order. - sq = sq.OrderBy(naturalOrder) + strategy := getSearchStrategy(r.tableName, q) + if strategy == nil { + return nil } - sq = sq.Where(Eq{r.tableName + ".missing": false}) - sq = sq.Limit(uint64(size)).Offset(uint64(offset)) - return r.queryAll(sq, results, model.QueryOptions{Offset: offset}) -} -func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error { - sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...)) - sq = sq.Where(Eq{r.tableName + ".missing": false}) - - return r.queryAll(sq, results) + return strategy.execute(r, sq, results, cfg, options) } func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { @@ -80,24 +93,3 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { } return Or(cond) } - -// legacySearchExpr generates LIKE-based search filters against the full_text column. -// This is the original search implementation, used when Search.Backend="legacy". -func legacySearchExpr(tableName string, s string) Sqlizer { - q := str.SanitizeStrings(s) - if q == "" { - log.Trace("Search using legacy backend, query is empty", "table", tableName) - return nil - } - var sep string - if !conf.Server.Search.FullString { - sep = " " - } - parts := strings.Split(q, " ") - filters := And{} - for _, part := range parts { - filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"}) - } - log.Trace("Search using legacy backend", "query", filters, "table", tableName) - return filters -} diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index ea70518b6..9eb01f0cf 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -9,6 +9,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" ) // containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters. @@ -187,75 +188,235 @@ func buildFTS5Query(userInput string) string { return result } -// likeSearchColumns defines the core columns to search with LIKE queries. -// These are the primary user-visible fields for each entity type. -// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). -var likeSearchColumns = map[string][]string{ - "media_file": {"title", "album", "artist", "album_artist"}, - "album": {"name", "album_artist"}, - "artist": {"name"}, +// ftsColumn pairs an FTS5 column name with its BM25 relevance weight. +type ftsColumn struct { + Name string + Weight float64 } -// likeSearchExpr generates LIKE-based search filters against core columns. -// Each word in the query must match at least one column (AND between words), -// and each word can match any column (OR within a word). -// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). -func likeSearchExpr(tableName string, s string) Sqlizer { - s = strings.TrimSpace(s) - if s == "" { - log.Trace("Search using LIKE backend, query is empty", "table", tableName) - return nil - } - columns, ok := likeSearchColumns[tableName] - if !ok { - log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName) - return nil - } - words := strings.Fields(s) - wordFilters := And{} - for _, word := range words { - colFilters := Or{} - for _, col := range columns { - colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"}) +// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights. +// The order MUST match the column order in the FTS5 table definition (see migrations). +// All columns are both searched and ranked. When adding indexed-but-not-searched +// columns in the future, use Weight: 0 to exclude from the search column filter. +var ftsColumnDefs = map[string][]ftsColumn{ + "media_file": { + {"title", 10.0}, + {"album", 5.0}, + {"artist", 3.0}, + {"album_artist", 3.0}, + {"sort_title", 1.0}, + {"sort_album_name", 1.0}, + {"sort_artist_name", 1.0}, + {"sort_album_artist_name", 1.0}, + {"disc_subtitle", 1.0}, + {"search_participants", 2.0}, + {"search_normalized", 1.0}, + }, + "album": { + {"name", 10.0}, + {"sort_album_name", 1.0}, + {"album_artist", 3.0}, + {"search_participants", 2.0}, + {"discs", 1.0}, + {"catalog_num", 1.0}, + {"album_version", 1.0}, + {"search_normalized", 1.0}, + }, + "artist": { + {"name", 10.0}, + {"sort_artist_name", 1.0}, + {"search_normalized", 1.0}, + }, +} + +// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time +// to avoid per-query allocations. +var ( + ftsColumnFilters = map[string]string{} + ftsBM25Weights = map[string]string{} +) + +func init() { + for table, cols := range ftsColumnDefs { + var names []string + weights := make([]string, len(cols)) + for i, c := range cols { + if c.Weight > 0 { + names = append(names, c.Name) + } + weights[i] = fmt.Sprintf("%.1f", c.Weight) } - wordFilters = append(wordFilters, colFilters) + ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}" + ftsBM25Weights[table] = strings.Join(weights, ", ") } - log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName) - return wordFilters } -// ftsSearchColumns defines which FTS5 columns are included in general search. -// Columns not listed here are indexed but not searched by default, -// enabling future additions (comments, lyrics, bios) without affecting general search. -var ftsSearchColumns = map[string]string{ - "media_file": "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}", - "album": "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}", - "artist": "{name sort_artist_name search_normalized}", +// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking. +type ftsSearch struct { + tableName string + ftsTable string + matchExpr string + rankExpr string } -// ftsSearchExpr generates an FTS5 MATCH-based search filter. -// If the query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!"), -// it falls back to LIKE-based search. -func ftsSearchExpr(tableName string, s string) Sqlizer { - q := buildFTS5Query(s) - if q == "" { - s = strings.TrimSpace(strings.ReplaceAll(s, `"`, "")) - if s != "" { - log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s) - return likeSearchExpr(tableName, s) +// ToSql returns a single-query fallback for the REST filter path (no two-phase split). +func (s *ftsSearch) ToSql() (string, []interface{}, error) { + sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" + return sql, []interface{}{s.matchExpr}, nil +} + +// execute runs a two-phase FTS5 search: +// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination. +// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set. +// +// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1. +func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { + qualifiedOrderBys := []string{s.rankExpr} + for _, ob := range cfg.OrderBy { + if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" { + qualifiedOrderBys = append(qualifiedOrderBys, qualified) + } + } + + // Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly. + // Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0. + rowidQuery := Select(s.tableName+".rowid"). + From(s.tableName). + Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr). + Where(Eq{s.tableName + ".missing": false}). + OrderBy(qualifiedOrderBys...) + if options.Max > 0 { + rowidQuery = rowidQuery.Limit(uint64(options.Max)) + } + if options.Offset > 0 { + rowidQuery = rowidQuery.Offset(uint64(options.Offset)) + } + + // Library filter + musicFolderId must be applied here, before pagination. + if cfg.LibraryFilter != nil { + rowidQuery = cfg.LibraryFilter(rowidQuery) + } else { + rowidQuery = r.applyLibraryFilter(rowidQuery) + } + if options.Filters != nil { + rowidQuery = rowidQuery.Where(options.Filters) + } + + rowidSQL, rowidArgs, err := rowidQuery.ToSql() + if err != nil { + return fmt.Errorf("building FTS rowid query: %w", err) + } + + // Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination), + // join on the ranked rowid set to hydrate with full columns. + sq = sq.RemoveLimit().RemoveOffset() + rankedSubquery := fmt.Sprintf( + "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked", + rowidSQL, + ) + sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...) + sq = sq.OrderBy("_ranked._rn") + return r.queryAll(sq, dest) +} + +// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for +// complex expressions (function calls, aggregations) that can't be used in Phase 1. +func qualifyOrderBy(tableName, orderBy string) string { + orderBy = strings.TrimSpace(orderBy) + if orderBy == "" || strings.ContainsAny(orderBy, "(,") { + return "" + } + parts := strings.Fields(orderBy) + if !strings.Contains(parts[0], ".") { + parts[0] = tableName + "." + parts[0] + } + return strings.Join(parts, " ") +} + +// ftsQueryDegraded returns true when the FTS query lost significant discriminating +// content compared to the original input. This happens when special characters that +// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS +// tokenization, leaving only very short/broad tokens. Also detects quoted phrases +// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1"). +func ftsQueryDegraded(original, ftsQuery string) bool { + original = strings.TrimSpace(original) + if original == "" || ftsQuery == "" { + return false + } + // Strip quotes from original for comparison — we want the raw content + stripped := strings.ReplaceAll(original, `"`, "") + // Extract the alphanumeric content from the original query + alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "") + // If the original is entirely alphanumeric, nothing was stripped — not degraded + if len(alphaNum) == len(stripped) { + return false + } + // Check if all effective FTS tokens are very short (≤2 chars). + // Short tokens with prefix matching are too broad when special chars were stripped. + // For quoted phrases, extract the content and check the tokens inside. + tokens := strings.Fields(ftsQuery) + for _, t := range tokens { + t = strings.TrimSuffix(t, "*") + // Skip internal phrase placeholders + if strings.HasPrefix(t, "\x00") { + return false + } + // For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)), + // the punctuated word was already handled meaningfully — not degraded. + if strings.HasPrefix(t, "(") { + return false + } + // For quoted phrases, check the tokens inside as FTS5 will tokenize them + if strings.HasPrefix(t, `"`) { + // Extract content between quotes + inner := strings.Trim(t, `"`) + innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") + for _, it := range strings.Fields(innerAlpha) { + if len(it) > 2 { + return false + } + } + continue + } + if len(t) > 2 { + return false + } + } + return true +} + +// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the +// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS +// tokenization stripped significant content from the query (e.g., "1+" → "1*"). +// Returns nil when the query produces no searchable tokens at all. +func newFTSSearch(tableName, query string) searchStrategy { + q := buildFTS5Query(query) + if q == "" || ftsQueryDegraded(query, q) { + // Fallback: try LIKE search with the raw query + cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, "")) + if cleaned != "" { + log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned) + return newLikeSearch(tableName, cleaned) } return nil } ftsTable := tableName + "_fts" matchExpr := q - if cols, ok := ftsSearchColumns[tableName]; ok { + if cols, ok := ftsColumnFilters[tableName]; ok { matchExpr = cols + " : (" + q + ")" } - filter := Expr( - tableName+".rowid IN (SELECT rowid FROM "+ftsTable+" WHERE "+ftsTable+" MATCH ?)", - matchExpr, - ) - log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", filter) - return filter + rankExpr := ftsTable + ".rank" + if weights, ok := ftsBM25Weights[tableName]; ok { + rankExpr = "bm25(" + ftsTable + ", " + weights + ")" + } + + s := &ftsSearch{ + tableName: tableName, + ftsTable: ftsTable, + matchExpr: matchExpr, + rankExpr: rankExpr, + } + log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s) + return s } diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index e0fead8ae..337d54201 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -3,8 +3,6 @@ package persistence import ( "context" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -52,6 +50,23 @@ var _ = DescribeTable("buildFTS5Query", Entry("returns empty string for empty quoted phrase", `""`, ""), ) +var _ = DescribeTable("ftsQueryDegraded", + func(original, ftsQuery string, expected bool) { + Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected)) + }, + Entry("not degraded for empty original", "", "1*", false), + Entry("not degraded for empty ftsQuery", "1+", "", false), + Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false), + Entry("not degraded when long tokens remain", "test^val", "test* val*", false), + Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false), + Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true), + Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false), + Entry("degraded when special chars stripped leaving short token", "1+", "1*", true), + Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true), + Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false), + Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false), +) + var _ = DescribeTable("normalizeForFTS", func(expected string, values ...string) { Expect(normalizeForFTS(values...)).To(Equal(expected)) @@ -81,133 +96,211 @@ var _ = DescribeTable("containsCJK", Entry("detects single CJK character", "a曲b", true), ) -var _ = Describe("likeSearchExpr", func() { - It("returns nil for empty query", func() { - Expect(likeSearchExpr("media_file", "")).To(BeNil()) +var _ = DescribeTable("qualifyOrderBy", + func(tableName, orderBy, expected string) { + Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected)) + }, + Entry("returns empty string for empty input", "artist", "", ""), + Entry("qualifies simple column with table name", "artist", "name", "artist.name"), + Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"), + Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"), + Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"), + Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""), + Entry("returns empty for expression with comma", "artist", "a, b", ""), + Entry("qualifies media_file column", "media_file", "title", "media_file.title"), +) + +var _ = Describe("ftsColumnDefs helpers", func() { + Describe("ftsColumnFilters", func() { + It("returns column filter for media_file", func() { + Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file", + "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}", + )) + }) + + It("returns column filter for album", func() { + Expect(ftsColumnFilters).To(HaveKeyWithValue("album", + "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}", + )) + }) + + It("returns column filter for artist", func() { + Expect(ftsColumnFilters).To(HaveKeyWithValue("artist", + "{name sort_artist_name search_normalized}", + )) + }) + + It("has no entry for unknown table", func() { + Expect(ftsColumnFilters).ToNot(HaveKey("unknown")) + }) }) - It("returns nil for whitespace-only query", func() { - Expect(likeSearchExpr("media_file", " ")).To(BeNil()) + Describe("ftsBM25Weights", func() { + It("returns weight CSV for media_file", func() { + Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file", + "10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0", + )) + }) + + It("returns weight CSV for album", func() { + Expect(ftsBM25Weights).To(HaveKeyWithValue("album", + "10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0", + )) + }) + + It("returns weight CSV for artist", func() { + Expect(ftsBM25Weights).To(HaveKeyWithValue("artist", + "10.0, 1.0, 1.0", + )) + }) + + It("has no entry for unknown table", func() { + Expect(ftsBM25Weights).ToNot(HaveKey("unknown")) + }) }) - It("generates LIKE filters against core columns for single CJK word", func() { - expr := likeSearchExpr("media_file", "周杰伦") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Should have OR between columns for the single word - Expect(sql).To(ContainSubstring("OR")) - Expect(sql).To(ContainSubstring("media_file.title LIKE")) - Expect(sql).To(ContainSubstring("media_file.album LIKE")) - Expect(sql).To(ContainSubstring("media_file.artist LIKE")) - Expect(sql).To(ContainSubstring("media_file.album_artist LIKE")) - Expect(args).To(HaveLen(4)) - for _, arg := range args { - Expect(arg).To(Equal("%周杰伦%")) + It("has definitions for all known tables", func() { + for _, table := range []string{"media_file", "album", "artist"} { + Expect(ftsColumnDefs).To(HaveKey(table)) + Expect(ftsColumnDefs[table]).ToNot(BeEmpty()) } }) - It("generates AND of OR groups for multi-word query", func() { - expr := likeSearchExpr("media_file", "周杰伦 greatest") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Two groups AND'd together, each with 4 columns OR'd - Expect(sql).To(ContainSubstring("AND")) - Expect(args).To(HaveLen(8)) - }) - - It("uses correct columns for album table", func() { - expr := likeSearchExpr("album", "周杰伦") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("album.name LIKE")) - Expect(sql).To(ContainSubstring("album.album_artist LIKE")) - Expect(args).To(HaveLen(2)) - }) - - It("uses correct columns for artist table", func() { - expr := likeSearchExpr("artist", "周杰伦") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("artist.name LIKE")) - Expect(args).To(HaveLen(1)) - }) - - It("returns nil for unknown table", func() { - Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil()) + It("has matching column count between filter and weights", func() { + for table, cols := range ftsColumnDefs { + // Column filter only includes Weight > 0 columns + filterCount := 0 + for _, c := range cols { + if c.Weight > 0 { + filterCount++ + } + } + // For now, all columns have Weight > 0, so filter count == total count + Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table) + } }) }) -var _ = Describe("ftsSearchExpr", func() { +var _ = Describe("newFTSSearch", func() { It("returns nil for empty query", func() { - Expect(ftsSearchExpr("media_file", "")).To(BeNil()) + Expect(newFTSSearch("media_file", "")).To(BeNil()) }) - It("generates rowid IN subquery with MATCH and column filter", func() { - expr := ftsSearchExpr("media_file", "beatles") - sql, args, err := expr.ToSql() + It("returns non-nil for single-character query", func() { + strategy := newFTSSearch("media_file", "a") + Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here") + sql, _, err := strategy.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("MATCH")) + }) + + It("returns ftsSearch with correct table names and MATCH expression", func() { + strategy := newFTSSearch("media_file", "beatles") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.tableName).To(Equal("media_file")) + Expect(fts.ftsTable).To(Equal("media_file_fts")) + Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist")) + Expect(fts.matchExpr).To(ContainSubstring("beatles*")) + }) + + It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() { + strategy := newFTSSearch("media_file", "beatles") + sql, args, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("media_file.rowid IN")) Expect(sql).To(ContainSubstring("media_file_fts")) Expect(sql).To(ContainSubstring("MATCH")) Expect(args).To(HaveLen(1)) - Expect(args[0]).To(HavePrefix("{title album artist album_artist")) - Expect(args[0]).To(ContainSubstring("beatles*")) }) It("generates correct FTS table name per entity", func() { for _, table := range []string{"media_file", "album", "artist"} { - expr := ftsSearchExpr(table, "test") - sql, _, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring(table + ".rowid IN")) - Expect(sql).To(ContainSubstring(table + "_fts")) + strategy := newFTSSearch(table, "test") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.tableName).To(Equal(table)) + Expect(fts.ftsTable).To(Equal(table + "_fts")) } }) + It("builds bm25() rank expression with column weights", func() { + strategy := newFTSSearch("media_file", "beatles") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,")) + Expect(fts.rankExpr).To(ContainSubstring("10.0")) + + strategy = newFTSSearch("artist", "beatles") + fts, ok = strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,")) + }) + + It("falls back to ftsTable.rank for unknown tables", func() { + strategy := newFTSSearch("unknown_table", "test") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank")) + }) + It("wraps query with column filter for known tables", func() { - expr := ftsSearchExpr("artist", "Beatles") - _, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(args[0]).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) + strategy := newFTSSearch("artist", "Beatles") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) }) It("passes query without column filter for unknown tables", func() { - expr := ftsSearchExpr("unknown_table", "test") - _, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(args[0]).To(Equal("test*")) + strategy := newFTSSearch("unknown_table", "test") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.matchExpr).To(Equal("test*")) }) It("preserves phrase queries inside column filter", func() { - expr := ftsSearchExpr("media_file", `"the beatles"`) - _, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(args[0]).To(ContainSubstring(`"the beatles"`)) + strategy := newFTSSearch("media_file", `"the beatles"`) + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`)) }) It("preserves prefix queries inside column filter", func() { - expr := ftsSearchExpr("media_file", "beat*") - _, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(args[0]).To(ContainSubstring("beat*")) + strategy := newFTSSearch("media_file", "beat*") + fts, ok := strategy.(*ftsSearch) + Expect(ok).To(BeTrue()) + Expect(fts.matchExpr).To(ContainSubstring("beat*")) }) It("falls back to LIKE search for punctuation-only query", func() { - expr := ftsSearchExpr("media_file", "!!!!!!!") - Expect(expr).ToNot(BeNil()) - sql, args, err := expr.ToSql() + strategy := newFTSSearch("media_file", "!!!!!!!") + Expect(strategy).ToNot(BeNil()) + _, ok := strategy.(*ftsSearch) + Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS") + sql, args, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("LIKE")) Expect(args).To(ContainElement("%!!!!!!!%")) }) + It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() { + strategy := newFTSSearch("album", "1+") + Expect(strategy).ToNot(BeNil()) + _, ok := strategy.(*ftsSearch) + Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS") + sql, args, err := strategy.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + Expect(args).To(ContainElement("%1+%")) + }) + It("returns nil for empty string even with LIKE fallback", func() { - Expect(ftsSearchExpr("media_file", "")).To(BeNil()) - Expect(ftsSearchExpr("media_file", " ")).To(BeNil()) + Expect(newFTSSearch("media_file", "")).To(BeNil()) + Expect(newFTSSearch("media_file", " ")).To(BeNil()) }) It("returns nil for empty quoted phrase", func() { - Expect(ftsSearchExpr("media_file", `""`)).To(BeNil()) + Expect(newFTSSearch("media_file", `""`)).To(BeNil()) }) }) @@ -229,7 +322,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("MediaFile search", func() { It("finds media files by title", func() { - results, err := mr.Search("Radioactivity", 0, 10) + results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("Radioactivity")) @@ -237,7 +330,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds media files by artist name", func() { - results, err := mr.Search("Beatles", 0, 10) + results, err := mr.Search("Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, r := range results { @@ -248,7 +341,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Album search", func() { It("finds albums by name", func() { - results, err := alr.Search("Sgt Peppers", 0, 10) + results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("Sgt Peppers")) @@ -256,7 +349,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds albums with multi-word search", func() { - results, err := alr.Search("Abbey Road", 0, 10) + results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) }) @@ -264,7 +357,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Artist search", func() { It("finds artists by name", func() { - results, err := arr.Search("Kraftwerk", 0, 10) + results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("Kraftwerk")) @@ -274,7 +367,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("CJK search", func() { It("finds media files by CJK title", func() { - results, err := mr.Search("プラチナ", 0, 10) + results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("プラチナ・ジェット")) @@ -282,14 +375,14 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds media files by CJK artist name", func() { - results, err := mr.Search("シートベルツ", 0, 10) + results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Artist).To(Equal("シートベルツ")) }) It("finds albums by CJK artist name", func() { - results, err := alr.Search("シートベルツ", 0, 10) + results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("COWBOY BEBOP")) @@ -297,7 +390,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds artists by CJK name", func() { - results, err := arr.Search("シートベルツ", 0, 10) + results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("シートベルツ")) @@ -307,7 +400,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Album version search", func() { It("finds albums by version tag via FTS", func() { - results, err := alr.Search("Deluxe", 0, 10) + results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal(albumWithVersion.ID)) @@ -316,7 +409,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Punctuation-only search", func() { It("finds media files with punctuation-only title", func() { - results, err := mr.Search("!!!!!!!", 0, 10) + results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("!!!!!!!")) @@ -324,15 +417,19 @@ var _ = Describe("FTS5 Integration Search", func() { }) }) - Describe("Legacy backend fallback", func() { - It("returns results using legacy LIKE-based search when configured", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - - results, err := mr.Search("Radioactivity", 0, 10) + Describe("Single-character search (doSearch min-length guard)", func() { + It("returns empty results for single-char query via Search", func() { + results, err := mr.Search("a", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Title).To(Equal("Radioactivity")) + Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") + }) + }) + + Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() { + It("returns results with Max=0", func() { + results, err := mr.Search("Beatles", model.QueryOptions{Max: 0}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) }) }) diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go new file mode 100644 index 000000000..769a911d5 --- /dev/null +++ b/persistence/sql_search_like.go @@ -0,0 +1,106 @@ +package persistence + +import ( + "strings" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +// likeSearch implements searchStrategy using LIKE-based SQL filters. +// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback. +type likeSearch struct { + filter Sqlizer +} + +func (s *likeSearch) ToSql() (string, []interface{}, error) { + return s.filter.ToSql() +} + +func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { + sq = sq.Where(s.filter) + sq = sq.OrderBy(cfg.OrderBy...) + return r.queryAll(sq, dest, options) +} + +// newLegacySearch creates a LIKE search against the full_text column. +// Returns nil when the query produces no searchable tokens. +func newLegacySearch(tableName, query string) searchStrategy { + filter := legacySearchExpr(tableName, query) + if filter == nil { + return nil + } + return &likeSearch{filter: filter} +} + +// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback). +// No minimum length is enforced, since single CJK characters are meaningful words. +// Returns nil when the query produces no searchable tokens. +func newLikeSearch(tableName, query string) searchStrategy { + filter := likeSearchExpr(tableName, query) + if filter == nil { + return nil + } + return &likeSearch{filter: filter} +} + +// legacySearchExpr generates LIKE-based search filters against the full_text column. +// This is the original search implementation, used when Search.Backend="legacy". +func legacySearchExpr(tableName string, s string) Sqlizer { + q := str.SanitizeStrings(s) + if q == "" { + log.Trace("Search using legacy backend, query is empty", "table", tableName) + return nil + } + var sep string + if !conf.Server.Search.FullString { + sep = " " + } + parts := strings.Split(q, " ") + filters := And{} + for _, part := range parts { + filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"}) + } + log.Trace("Search using legacy backend", "query", filters, "table", tableName) + return filters +} + +// likeSearchColumns defines the core columns to search with LIKE queries. +// These are the primary user-visible fields for each entity type. +// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). +var likeSearchColumns = map[string][]string{ + "media_file": {"title", "album", "artist", "album_artist"}, + "album": {"name", "album_artist"}, + "artist": {"name"}, +} + +// likeSearchExpr generates LIKE-based search filters against core columns. +// Each word in the query must match at least one column (AND between words), +// and each word can match any column (OR within a word). +// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). +func likeSearchExpr(tableName string, s string) Sqlizer { + s = strings.TrimSpace(s) + if s == "" { + log.Trace("Search using LIKE backend, query is empty", "table", tableName) + return nil + } + columns, ok := likeSearchColumns[tableName] + if !ok { + log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName) + return nil + } + words := strings.Fields(s) + wordFilters := And{} + for _, word := range words { + colFilters := Or{} + for _, col := range columns { + colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"}) + } + wordFilters = append(wordFilters, colFilters) + } + log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName) + return wordFilters +} diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go new file mode 100644 index 000000000..8ee4ef93c --- /dev/null +++ b/persistence/sql_search_like_test.go @@ -0,0 +1,134 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("newLegacySearch", func() { + It("returns non-nil for single-character query", func() { + strategy := newLegacySearch("media_file", "a") + Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here") + sql, _, err := strategy.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + }) +}) + +var _ = Describe("legacySearchExpr", func() { + It("returns nil for empty query", func() { + Expect(legacySearchExpr("media_file", "")).To(BeNil()) + }) + + It("generates LIKE filter for single word", func() { + expr := legacySearchExpr("media_file", "beatles") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.full_text LIKE")) + Expect(args).To(ContainElement("% beatles%")) + }) + + It("generates AND of LIKE filters for multiple words", func() { + expr := legacySearchExpr("media_file", "abbey road") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("AND")) + Expect(args).To(HaveLen(2)) + }) +}) + +var _ = Describe("likeSearchExpr", func() { + It("returns nil for empty query", func() { + Expect(likeSearchExpr("media_file", "")).To(BeNil()) + }) + + It("returns nil for whitespace-only query", func() { + Expect(likeSearchExpr("media_file", " ")).To(BeNil()) + }) + + It("generates LIKE filters against core columns for single CJK word", func() { + expr := likeSearchExpr("media_file", "周杰伦") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should have OR between columns for the single word + Expect(sql).To(ContainSubstring("OR")) + Expect(sql).To(ContainSubstring("media_file.title LIKE")) + Expect(sql).To(ContainSubstring("media_file.album LIKE")) + Expect(sql).To(ContainSubstring("media_file.artist LIKE")) + Expect(sql).To(ContainSubstring("media_file.album_artist LIKE")) + Expect(args).To(HaveLen(4)) + for _, arg := range args { + Expect(arg).To(Equal("%周杰伦%")) + } + }) + + It("generates AND of OR groups for multi-word query", func() { + expr := likeSearchExpr("media_file", "周杰伦 greatest") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two groups AND'd together, each with 4 columns OR'd + Expect(sql).To(ContainSubstring("AND")) + Expect(args).To(HaveLen(8)) + }) + + It("uses correct columns for album table", func() { + expr := likeSearchExpr("album", "周杰伦") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("album.name LIKE")) + Expect(sql).To(ContainSubstring("album.album_artist LIKE")) + Expect(args).To(HaveLen(2)) + }) + + It("uses correct columns for artist table", func() { + expr := likeSearchExpr("artist", "周杰伦") + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("artist.name LIKE")) + Expect(args).To(HaveLen(1)) + }) + + It("returns nil for unknown table", func() { + Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil()) + }) +}) + +var _ = Describe("Legacy Integration Search", func() { + var mr model.MediaFileRepository + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + conn := GetDBXBuilder() + mr = NewMediaFileRepository(ctx, conn) + }) + + It("returns results using legacy LIKE-based search", func() { + results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Title).To(Equal("Radioactivity")) + }) + + It("returns empty results for single-char query (doSearch min-length guard)", func() { + results, err := mr.Search("a", model.QueryOptions{Max: 10}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") + }) + + It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() { + results, err := mr.Search("Beatles", model.QueryOptions{Max: 0}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") + }) +}) diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go index b59570af3..68ee205b4 100644 --- a/persistence/sql_search_test.go +++ b/persistence/sql_search_test.go @@ -14,98 +14,99 @@ var _ = Describe("sqlRepository", func() { }) }) - Describe("legacySearchExpr", func() { - It("returns nil for empty query", func() { - Expect(legacySearchExpr("media_file", "")).To(BeNil()) - }) - - It("generates LIKE filter for single word", func() { - expr := legacySearchExpr("media_file", "beatles") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file.full_text LIKE")) - Expect(args).To(ContainElement("% beatles%")) - }) - - It("generates AND of LIKE filters for multiple words", func() { - expr := legacySearchExpr("media_file", "abbey road") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("AND")) - Expect(args).To(HaveLen(2)) - }) - }) - - Describe("getSearchExpr", func() { - It("returns ftsSearchExpr by default", func() { + Describe("getSearchStrategy", func() { + It("returns FTS strategy by default", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "fts" conf.Server.Search.FullString = false - expr := getSearchExpr()("media_file", "test") - sql, _, err := expr.ToSql() + strategy := getSearchStrategy("media_file", "test") + Expect(strategy).ToNot(BeNil()) + sql, _, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("MATCH")) }) - It("returns legacySearchExpr when SearchBackend is legacy", func() { + It("returns legacy LIKE strategy when SearchBackend is legacy", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "legacy" conf.Server.Search.FullString = false - expr := getSearchExpr()("media_file", "test") - sql, _, err := expr.ToSql() + strategy := getSearchStrategy("media_file", "test") + Expect(strategy).ToNot(BeNil()) + sql, _, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("LIKE")) }) - It("falls back to legacySearchExpr when SearchFullString is enabled", func() { + It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "fts" conf.Server.Search.FullString = true - expr := getSearchExpr()("media_file", "test") - sql, _, err := expr.ToSql() + strategy := getSearchStrategy("media_file", "test") + Expect(strategy).ToNot(BeNil()) + sql, _, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("LIKE")) }) - It("routes CJK queries to likeSearchExpr instead of ftsSearchExpr", func() { + It("routes CJK queries to LIKE strategy instead of FTS", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "fts" conf.Server.Search.FullString = false - expr := getSearchExpr()("media_file", "周杰伦") - sql, _, err := expr.ToSql() + strategy := getSearchStrategy("media_file", "周杰伦") + Expect(strategy).ToNot(BeNil()) + sql, _, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) // CJK should use LIKE, not MATCH Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).NotTo(ContainSubstring("MATCH")) }) - It("routes non-CJK queries to ftsSearchExpr", func() { + It("routes non-CJK queries to FTS strategy", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "fts" conf.Server.Search.FullString = false - expr := getSearchExpr()("media_file", "beatles") - sql, _, err := expr.ToSql() + strategy := getSearchStrategy("media_file", "beatles") + Expect(strategy).ToNot(BeNil()) + sql, _, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("MATCH")) }) + It("returns non-nil for single-character query (no min-length in strategy)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + strategy := getSearchStrategy("media_file", "a") + Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)") + }) + + It("returns non-nil for single-character query with legacy backend", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + conf.Server.Search.FullString = false + + strategy := getSearchStrategy("media_file", "a") + Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)") + }) + It("uses legacy for CJK when SearchBackend is legacy", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "legacy" conf.Server.Search.FullString = false - expr := getSearchExpr()("media_file", "周杰伦") - sql, _, err := expr.ToSql() + strategy := getSearchStrategy("media_file", "周杰伦") + Expect(strategy).ToNot(BeNil()) + sql, _, err := strategy.ToSql() Expect(err).ToNot(HaveOccurred()) // Legacy should still use full_text column LIKE Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).To(ContainSubstring("full_text")) }) }) - }) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 5981f4a86..0e0ca606a 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -115,6 +115,7 @@ func buildTestFS() storagetest.FakeFS { ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) + cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"}) return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) @@ -132,6 +133,8 @@ func buildTestFS() storagetest.FakeFS { "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), // Pop (standalone track, no MBIDs) "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), + // CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests) + "CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")), // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index f7a5af173..d3d24a7c5 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(5)) + Expect(resp.AlbumList.Album).To(HaveLen(6)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,13 +27,14 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(5)) - // Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop + Expect(albums).To(HaveLen(6)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop Expect(albums[0].Title).To(Equal("Abbey Road")) - Expect(albums[1].Title).To(Equal("Help!")) - Expect(albums[2].Title).To(Equal("IV")) - Expect(albums[3].Title).To(Equal("Kind of Blue")) - Expect(albums[4].Title).To(Equal("Pop")) + Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) + Expect(albums[2].Title).To(Equal("Help!")) + Expect(albums[3].Title).To(Equal("IV")) + Expect(albums[4].Title).To(Equal("Kind of Blue")) + Expect(albums[5].Title).To(Equal("Pop")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -41,29 +42,32 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(5)) + Expect(albums).To(HaveLen(6)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various + // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) Expect(albums[3].Artist).To(Equal("Miles Davis")) Expect(albums[4].Artist).To(Equal("Various")) + Expect(albums[5].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(5)) + Expect(resp.AlbumList.Album).To(HaveLen(6)) }) It("type=byGenre filters by genre parameter", func() { resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(1)) - Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + for _, a := range resp.AlbumList.Album { + Expect(a.Genre).To(Equal("Jazz")) + } }) It("type=byYear filters by fromYear/toYear range", func() { @@ -184,7 +188,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(5)) + Expect(albums).To(HaveLen(6)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -195,7 +199,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(5)) + Expect(resp.AlbumList2.Album).To(HaveLen(6)) }) }) @@ -240,7 +244,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.RandomSongs).ToNot(BeNil()) Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) - Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6)) + Expect(resp.RandomSongs.Songs).To(HaveLen(7)) }) It("respects size parameter", func() { @@ -254,8 +258,10 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz") Expect(resp.RandomSongs).ToNot(BeNil()) - Expect(resp.RandomSongs.Songs).To(HaveLen(1)) - Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz")) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + for _, s := range resp.RandomSongs.Songs { + Expect(s.Genre).To(Equal("Jazz")) + } }) }) diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go index 403e1ba6f..5a2da8737 100644 --- a/server/e2e/subsonic_browsing_test.go +++ b/server/e2e/subsonic_browsing_test.go @@ -327,8 +327,8 @@ var _ = Describe("Browsing Endpoints", func() { } } Expect(jazzGenre).ToNot(BeNil()) - Expect(jazzGenre.SongCount).To(Equal(int32(1))) - Expect(jazzGenre.AlbumCount).To(Equal(int32(1))) + Expect(jazzGenre.SongCount).To(Equal(int32(2))) + Expect(jazzGenre.AlbumCount).To(Equal(int32(2))) }) It("reports correct song and album counts for Pop", func() { diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index 1eb04d41c..aa4a0c626 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -141,7 +141,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(5)) + Expect(resp.AlbumList.Album).To(HaveLen(6)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } @@ -275,5 +275,21 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) }) + + It("non-admin user search returns only their library's content", func() { + resp := doReqWithUser(userLib1Only, "search3", "query", "Beethoven") + + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(BeEmpty(), "userLib1Only should not see Beethoven (lib2)") + Expect(resp.SearchResult3.Album).To(BeEmpty()) + Expect(resp.SearchResult3.Song).To(BeEmpty()) + }) + + It("non-admin user search finds content from their library", func() { + resp := doReqWithUser(userLib1Only, "search3", "query", "Beatles") + + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty(), "userLib1Only should find Beatles (lib1)") + }) }) }) diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index d0447ecd7..bfcbbc8ee 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -2,6 +2,8 @@ package e2e import ( "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -113,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(4)) - Expect(resp.SearchResult3.Album).To(HaveLen(5)) - Expect(resp.SearchResult3.Song).To(HaveLen(6)) + Expect(resp.SearchResult3.Artist).To(HaveLen(5)) + Expect(resp.SearchResult3.Album).To(HaveLen(6)) + Expect(resp.SearchResult3.Song).To(HaveLen(7)) }) It("finds across all entity types simultaneously", func() { @@ -217,5 +219,56 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.SearchResult3.Song).To(BeEmpty()) }) }) + + Describe("CJK search", func() { + It("finds songs by CJK title", func() { + resp := doReq("search3", "query", "プラチナ") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).To(HaveLen(1)) + Expect(resp.SearchResult3.Song[0].Title).To(Equal("プラチナ・ジェット")) + }) + + It("finds artists by CJK name", func() { + resp := doReq("search3", "query", "シートベルツ") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(HaveLen(1)) + Expect(resp.SearchResult3.Artist[0].Name).To(Equal("シートベルツ")) + }) + + It("finds albums by CJK artist name", func() { + resp := doReq("search3", "query", "シートベルツ") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Album).To(HaveLen(1)) + Expect(resp.SearchResult3.Album[0].Name).To(Equal("COWBOY BEBOP")) + }) + }) + + Describe("Legacy backend", func() { + It("returns results using legacy LIKE-based search when configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + + resp := doReq("search3", "query", "Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + + found := false + for _, a := range resp.SearchResult3.Artist { + if a.Name == "The Beatles" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find artist 'The Beatles' with legacy backend") + }) + }) }) }) diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index 5a19e0a3b..fd7e29587 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -42,17 +42,17 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { return sp, nil } -type searchFunc[T any] func(q string, offset int, size int, options ...model.QueryOptions) (T, error) +type searchFunc[T any] func(q string, options ...model.QueryOptions) (T, error) -func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error { +func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options model.QueryOptions, result *T) func() error { return func() error { - if size == 0 { + if options.Max == 0 { return nil } typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") var err error start := time.Now() - *result, err = s(q, offset, size, options...) + *result, err = s(q, options) if err != nil { log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) } else { @@ -66,27 +66,22 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI start := time.Now() q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*"))) - // Create query options for library filtering - var options []model.QueryOptions - var artistOptions []model.QueryOptions + // Build options with offset/size/filters packed in + songOpts := model.QueryOptions{Max: sp.songCount, Offset: sp.songOffset} + albumOpts := model.QueryOptions{Max: sp.albumCount, Offset: sp.albumOffset} + artistOpts := model.QueryOptions{Max: sp.artistCount, Offset: sp.artistOffset} + if len(musicFolderIds) > 0 { - // For MediaFiles and Albums, use direct library_id filter - options = append(options, model.QueryOptions{ - Filters: Eq{"library_id": musicFolderIds}, - }) - // For Artists, use the repository's built-in library filtering mechanism - // which properly handles the library_artist table joins - // TODO Revisit library filtering in sql_base_repository.go - artistOptions = append(artistOptions, model.QueryOptions{ - Filters: Eq{"library_artist.library_id": musicFolderIds}, - }) + songOpts.Filters = Eq{"library_id": musicFolderIds} + albumOpts.Filters = Eq{"library_id": musicFolderIds} + artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds} } // Run searches in parallel g, ctx := errgroup.WithContext(ctx) - g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...)) - g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...)) - g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, artistOptions...)) + g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, songOpts, &mediaFiles)) + g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, albumOpts, &albums)) + g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, artistOpts, &artists)) err := g.Wait() if err == nil { log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 8b5f5d9c1..3428813f6 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -119,7 +119,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { return nil } -func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { +func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 6d4792f83..b7a6fb811 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -145,7 +145,7 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } -func (m *MockArtistRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { +func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 2812fd507..1d4527c88 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -238,7 +238,7 @@ func (m *MockMediaFileRepo) NewInstance() any { return &model.MediaFile{} } -func (m *MockMediaFileRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { if len(options) > 0 { m.Options = options[0] } From 0c3cc8653591c677f91ba284fa7650481151976d Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 23 Feb 2026 18:16:50 -0500 Subject: [PATCH 022/596] fix(subsonic): restore `public` attribute for playlists in XML responses This was causing issues with DSub and DSub2000 Signed-off-by: Deluan --- .../Responses Playlists with data should match .XML | 4 ++-- server/subsonic/responses/responses.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML index 759f7b52d..38e0944cf 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML @@ -1,7 +1,7 @@ - - + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 8c4e330b0..0fdbf1be6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -303,7 +303,7 @@ type Playlist struct { Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` SongCount int32 `xml:"songCount,attr" json:"songCount"` Duration int32 `xml:"duration,attr" json:"duration"` - Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` + Public bool `xml:"public,attr" json:"public,omitempty"` Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` Created time.Time `xml:"created,attr" json:"created"` Changed time.Time `xml:"changed,attr" json:"changed"` From d1c5e6a2f2929e445fd1b527e9c3686079f62140 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:06:45 -0500 Subject: [PATCH 023/596] chore(deps): bump goreleaser/goreleaser-action in /.github/workflows (#5089) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6 to 7. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index fd8edcd1c..5fcc9526b 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -424,7 +424,7 @@ jobs: run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" From 2bb13e5ff1b693c74a1586327d8a012735db7224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 23 Feb 2026 20:28:38 -0500 Subject: [PATCH 024/596] feat(server): add ExtAuth logout URL configuration (#5074) * feat(server): add ExtAuth logout URL configuration (#4467) When external authentication (reverse proxy auth) is active, the Logout button is hidden because authentication is managed externally. Many external auth services (Authelia, Authentik, Keycloak) provide a logout URL that can terminate the session. Add `ExtAuth.LogoutURL` config option that, when set, shows the Logout button in the UI and redirects the user to the external auth provider's logout endpoint instead of the Navidrome login page. * feat(server): add validation for ExtAuth logout URL configuration * feat(server): refactor ExtAuth logout URL validation to a reusable function * fix(configuration): rename URL validation functions for consistency Signed-off-by: Deluan * fix(configuration): rename URL validation functions for consistency Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 30 +++++++++++++++++++++++++++ conf/configuration_test.go | 42 ++++++++++++++++++++++++++++++++++++++ conf/export_test.go | 2 ++ server/serve_index.go | 1 + server/serve_index_test.go | 1 + ui/src/authProvider.js | 4 ++++ ui/src/layout/UserMenu.jsx | 2 +- 7 files changed, 81 insertions(+), 1 deletion(-) diff --git a/conf/configuration.go b/conf/configuration.go index 000bffb58..555d8f587 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -250,6 +250,7 @@ type pluginsOptions struct { type extAuthOptions struct { TrustedSources string UserHeader string + LogoutURL string } type searchOptions struct { @@ -345,6 +346,7 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, + validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { os.Exit(1) @@ -548,6 +550,33 @@ func validateSchedule(schedule, field string) (string, error) { return schedule, err } +// validateURL checks if the provided URL is valid and has either http or https scheme. +// It returns a function that can be used as a hook to validate URLs in the config. +func validateURL(optionName, optionURL string) func() error { + return func() error { + if optionURL == "" { + return nil + } + u, err := url.Parse(optionURL) + if err != nil { + log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err) + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) + log.Error(err.Error()) + return err + } + // Require an absolute URL with a non-empty host and no opaque component. + if u.Host == "" || u.Opaque != "" { + err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) + log.Error(err.Error()) + return err + } + return nil + } +} + func normalizeSearchBackend(value string) string { v := strings.ToLower(strings.TrimSpace(value)) switch v { @@ -641,6 +670,7 @@ func setViperDefaults() { viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") + viper.SetDefault("extauth.logouturl", "") viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.password", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index b4ed6ca2d..73fec4196 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -52,6 +52,48 @@ var _ = Describe("Configuration", func() { }) }) + Describe("ValidateURL", func() { + It("accepts a valid http URL", func() { + fn := conf.ValidateURL("TestOption", "http://example.com/path") + Expect(fn()).To(Succeed()) + }) + + It("accepts a valid https URL", func() { + fn := conf.ValidateURL("TestOption", "https://example.com/path") + Expect(fn()).To(Succeed()) + }) + + It("rejects a URL with no scheme", func() { + fn := conf.ValidateURL("TestOption", "example.com/path") + Expect(fn()).To(MatchError(ContainSubstring("invalid scheme"))) + }) + + It("rejects a URL with an unsupported scheme", func() { + fn := conf.ValidateURL("TestOption", "javascript://example.com/path") + Expect(fn()).To(MatchError(ContainSubstring("invalid scheme"))) + }) + + It("accepts an empty URL (optional config)", func() { + fn := conf.ValidateURL("TestOption", "") + Expect(fn()).To(Succeed()) + }) + + It("includes the option name in the error message", func() { + fn := conf.ValidateURL("MyOption", "ftp://example.com") + Expect(fn()).To(MatchError(ContainSubstring("MyOption"))) + }) + + It("rejects a URL that cannot be parsed", func() { + fn := conf.ValidateURL("TestOption", "://invalid") + Expect(fn()).To(HaveOccurred()) + }) + + It("rejects a URL without a host", func() { + fn := conf.ValidateURL("TestOption", "http:///path") + Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required"))) + }) + }) + DescribeTable("NormalizeSearchBackend", func(input, expected string) { Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected)) diff --git a/conf/export_test.go b/conf/export_test.go index 7344dc4ca..d1d1bb3a9 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -8,4 +8,6 @@ var SetViperDefaults = setViperDefaults var ParseLanguages = parseLanguages +var ValidateURL = validateURL + var NormalizeSearchBackend = normalizeSearchBackend diff --git a/server/serve_index.go b/server/serve_index.go index b5b364267..92ef47e23 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -76,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, "pluginsEnabled": conf.Server.Plugins.Enabled, + "extAuthLogoutURL": conf.Server.ExtAuth.LogoutURL, } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL) diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 9d6f480ff..e08a42643 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -104,6 +104,7 @@ var _ = Describe("serveIndex", func() { Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false), Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), + Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), ) DescribeTable("sets other UI configuration values", diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 4ae238eec..813a4f5b4 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -66,6 +66,10 @@ const authProvider = { logout: () => { removeItems() + if (config.extAuthLogoutURL) { + window.location.href = config.extAuthLogoutURL + return Promise.resolve(false) + } return Promise.resolve() }, diff --git a/ui/src/layout/UserMenu.jsx b/ui/src/layout/UserMenu.jsx index a5757a73c..e33185578 100644 --- a/ui/src/layout/UserMenu.jsx +++ b/ui/src/layout/UserMenu.jsx @@ -122,7 +122,7 @@ const UserMenu = (props) => { }) : null, )} - {!config.auth && logout} + {(!config.auth || !!config.extAuthLogoutURL) && logout} From 652c27690be6abcce58839465f2f34bd00b041ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 24 Feb 2026 14:28:36 -0500 Subject: [PATCH 025/596] feat(plugins): add HTTP host service (#5095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(httpclient): implement HttpClient service for outbound HTTP requests in plugins Signed-off-by: Deluan * feat(httpclient): enhance SSRF protection by validating host requests against private IPs Signed-off-by: Deluan * feat(httpclient): support DELETE requests with body in HttpClient service Signed-off-by: Deluan * feat(httpclient): refactor HTTP client initialization and enhance redirect handling Signed-off-by: Deluan * refactor(http): standardize naming conventions for HTTP types and methods Signed-off-by: Deluan * refactor example plugin to use host.HTTPSend for improved error management Signed-off-by: Deluan * fix(plugins): fix IPv6 SSRF bypass and wildcard host matching Fix two bugs in the plugin HTTP/WebSocket host validation: 1. extractHostname now strips IPv6 brackets when no port is present (e.g. "[::1]" → "::1"). Previously, net.SplitHostPort failed for bracketed IPv6 without a port, leaving brackets intact. This caused net.ParseIP to return nil, bypassing the private/loopback SSRF guard. 2. matchHostPattern now treats "*" as an allow-all pattern. Previously, a bare "*" only matched via exact equality, so plugins declaring requiredHosts: ["*"] (like webhook-rs) had all requests rejected. --------- Signed-off-by: Deluan --- plugins/examples/wikimedia/main.go | 51 +- plugins/host/httpclient.go | 40 ++ plugins/host/httpclient_gen.go | 88 +++ plugins/host_httpclient.go | 190 ++++++ plugins/host_httpclient_test.go | 565 ++++++++++++++++++ plugins/host_websocket.go | 5 +- plugins/host_websocket_test.go | 6 + plugins/manager_loader.go | 9 + plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_httpclient.go | 87 +++ .../pdk/go/host/nd_host_httpclient_stub.go | 55 ++ plugins/pdk/python/host/nd_host_httpclient.py | 59 ++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../pdk/rust/nd-pdk-host/src/nd_host_http.rs | 83 +++ 14 files changed, 1228 insertions(+), 19 deletions(-) create mode 100644 plugins/host/httpclient.go create mode 100644 plugins/host/httpclient_gen.go create mode 100644 plugins/host_httpclient.go create mode 100644 plugins/host_httpclient_test.go create mode 100644 plugins/pdk/go/host/nd_host_httpclient.go create mode 100644 plugins/pdk/go/host/nd_host_httpclient_stub.go create mode 100644 plugins/pdk/python/host/nd_host_httpclient.py create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs diff --git a/plugins/examples/wikimedia/main.go b/plugins/examples/wikimedia/main.go index 6f56d4221..8508354cf 100644 --- a/plugins/examples/wikimedia/main.go +++ b/plugins/examples/wikimedia/main.go @@ -14,6 +14,7 @@ import ( "net/url" "strings" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/metadata" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -77,21 +78,28 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) { form := url.Values{} form.Set("query", query) - req := pdk.NewHTTPRequest(pdk.MethodPost, endpoint) - req.SetHeader("Accept", "application/sparql-results+json") - req.SetHeader("Content-Type", "application/x-www-form-urlencoded") - req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0") - req.SetBody([]byte(form.Encode())) - pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query)) - resp := req.Send() - if resp.Status() != 200 { - return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: endpoint, + Headers: map[string]string{ + "Accept": "application/sparql-results+json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "NavidromeWikimediaPlugin/1.0", + }, + Body: []byte(form.Encode()), + TimeoutMs: 10000, + }) + if err != nil { + return nil, fmt.Errorf("SPARQL HTTP error: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.StatusCode) } var result SPARQLResult - if err := json.Unmarshal(resp.Body(), &result); err != nil { + if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("failed to parse SPARQL response: %w", err) } if len(result.Results.Bindings) == 0 { @@ -104,15 +112,22 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) { func mediawikiQuery(params url.Values) ([]byte, error) { apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode()) - req := pdk.NewHTTPRequest(pdk.MethodGet, apiURL) - req.SetHeader("Accept", "application/json") - req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0") - - resp := req.Send() - if resp.Status() != 200 { - return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: apiURL, + Headers: map[string]string{ + "Accept": "application/json", + "User-Agent": "NavidromeWikimediaPlugin/1.0", + }, + TimeoutMs: 10000, + }) + if err != nil { + return nil, fmt.Errorf("MediaWiki HTTP error: %w", err) } - return resp.Body(), nil + if resp.StatusCode != 200 { + return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.StatusCode) + } + return resp.Body, nil } // getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name diff --git a/plugins/host/httpclient.go b/plugins/host/httpclient.go new file mode 100644 index 000000000..b61361c57 --- /dev/null +++ b/plugins/host/httpclient.go @@ -0,0 +1,40 @@ +package host + +import "context" + +// HTTPRequest represents an outbound HTTP request from a plugin. +type HTTPRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` + TimeoutMs int32 `json:"timeoutMs,omitempty"` +} + +// HTTPResponse represents the response from an outbound HTTP request. +type HTTPResponse struct { + StatusCode int32 `json:"statusCode"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// HTTPService provides outbound HTTP request capabilities for plugins. +// +// This service allows plugins to make HTTP requests to external services. +// Requests are validated against the plugin's declared requiredHosts patterns +// from the http permission in the manifest. Redirects are followed but each +// redirect destination is also validated against the allowed hosts. +// +//nd:hostservice name=HTTP permission=http +type HTTPService interface { + // Send executes an HTTP request and returns the response. + // + // Parameters: + // - request: The HTTP request to execute, including method, URL, headers, body, and timeout + // + // Returns the HTTP response with status code, headers, and body. + // Network errors, timeouts, and permission failures are returned as Go errors. + // Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. + //nd:hostfunc + Send(ctx context.Context, request HTTPRequest) (*HTTPResponse, error) +} diff --git a/plugins/host/httpclient_gen.go b/plugins/host/httpclient_gen.go new file mode 100644 index 000000000..c14a533d0 --- /dev/null +++ b/plugins/host/httpclient_gen.go @@ -0,0 +1,88 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// HTTPSendRequest is the request type for HTTP.Send. +type HTTPSendRequest struct { + Request HTTPRequest `json:"request"` +} + +// HTTPSendResponse is the response type for HTTP.Send. +type HTTPSendResponse struct { + Result *HTTPResponse `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterHTTPHostFunctions registers HTTP service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterHTTPHostFunctions(service HTTPService) []extism.HostFunction { + return []extism.HostFunction{ + newHTTPSendHostFunction(service), + } +} + +func newHTTPSendHostFunction(service HTTPService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "http_send", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + httpWriteError(p, stack, err) + return + } + var req HTTPSendRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + httpWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.Send(ctx, req.Request) + if svcErr != nil { + httpWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := HTTPSendResponse{ + Result: result, + } + httpWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// httpWriteResponse writes a JSON response to plugin memory. +func httpWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + httpWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// httpWriteError writes an error response to plugin memory. +func httpWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go new file mode 100644 index 000000000..4dc8a2b9e --- /dev/null +++ b/plugins/host_httpclient.go @@ -0,0 +1,190 @@ +package plugins + +import ( + "bytes" + "cmp" + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/host" +) + +const ( + httpClientDefaultTimeout = 10 * time.Second + httpClientMaxRedirects = 5 + httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB +) + +// httpServiceImpl implements host.HTTPService. +type httpServiceImpl struct { + pluginName string + requiredHosts []string + client *http.Client +} + +// newHTTPService creates a new HTTPService for a plugin. +func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceImpl { + var requiredHosts []string + if permission != nil { + requiredHosts = permission.RequiredHosts + } + svc := &httpServiceImpl{ + pluginName: pluginName, + requiredHosts: requiredHosts, + } + svc.client = &http.Client{ + Transport: http.DefaultTransport, + // Timeout is set per-request via context deadline, not here. + // CheckRedirect validates hosts and enforces redirect limits. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= httpClientMaxRedirects { + log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { + log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) + return err + } + return nil + }, + } + return svc +} + +func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*host.HTTPResponse, error) { + // Parse and validate URL + parsedURL, err := url.Parse(request.URL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + // Validate URL scheme + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, fmt.Errorf("invalid URL scheme %q: must be http or https", parsedURL.Scheme) + } + + // Validate host against allowed hosts and private IP restrictions + if err := s.validateHost(ctx, parsedURL.Host); err != nil { + return nil, err + } + + // Apply per-request timeout via context deadline + timeout := cmp.Or(time.Duration(request.TimeoutMs)*time.Millisecond, httpClientDefaultTimeout) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Build request body + method := strings.ToUpper(request.Method) + var body io.Reader + if len(request.Body) > 0 { + body = bytes.NewReader(request.Body) + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, method, request.URL, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + for k, v := range request.Headers { + httpReq.Header.Set(k, v) + } + + // Execute request + resp, err := s.client.Do(httpReq) //nolint:gosec // URL is validated against requiredHosts + if err != nil { + return nil, err + } + defer resp.Body.Close() + + log.Trace(ctx, "HTTP request", "plugin", s.pluginName, "method", method, "url", request.URL, "status", resp.StatusCode) + + // Read response body (with size limit to prevent memory exhaustion) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, httpClientMaxResponseBodyLen)) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + // Flatten response headers (first value only) + headers := make(map[string]string, len(resp.Header)) + for k, v := range resp.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + + return &host.HTTPResponse{ + StatusCode: int32(resp.StatusCode), + Headers: headers, + Body: respBody, + }, nil +} + +// validateHost checks whether a request to the given host is permitted. +// When requiredHosts is set, it checks against the allowlist. +// When requiredHosts is empty, it blocks private/loopback IPs to prevent SSRF. +func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) error { + hostname := extractHostname(hostStr) + + if len(s.requiredHosts) > 0 { + if !s.isHostAllowed(hostname) { + return fmt.Errorf("host %q is not allowed", hostStr) + } + return nil + } + + // No explicit allowlist: block private/loopback IPs + if isPrivateOrLoopback(hostname) { + log.Warn(ctx, "HTTP request to private/loopback address blocked", "plugin", s.pluginName, "host", hostStr) + return fmt.Errorf("host %q is not allowed: private/loopback addresses require explicit requiredHosts in manifest", hostStr) + } + return nil +} + +func (s *httpServiceImpl) isHostAllowed(hostname string) bool { + for _, pattern := range s.requiredHosts { + if matchHostPattern(pattern, hostname) { + return true + } + } + return false +} + +// extractHostname returns the hostname portion of a host string, stripping +// any port number and IPv6 brackets. It handles IPv6 addresses correctly +// (e.g. "[::1]:8080" → "::1", "[::1]" → "::1"). +func extractHostname(hostStr string) string { + if h, _, err := net.SplitHostPort(hostStr); err == nil { + return h + } + // Strip IPv6 brackets when no port is present (e.g. "[::1]" → "::1") + if strings.HasPrefix(hostStr, "[") && strings.HasSuffix(hostStr, "]") { + return hostStr[1 : len(hostStr)-1] + } + return hostStr +} + +// isPrivateOrLoopback returns true if the given hostname resolves to or is +// a private, loopback, or link-local IP address. This includes: +// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 +// IPv6: ::1, fc00::/7, fe80::/10 +// It also blocks "localhost" by name. +func isPrivateOrLoopback(hostname string) bool { + if strings.EqualFold(hostname, "localhost") { + return true + } + ip := net.ParseIP(hostname) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} + +// Verify interface implementation +var _ host.HTTPService = (*httpServiceImpl)(nil) diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go new file mode 100644 index 000000000..29796b052 --- /dev/null +++ b/plugins/host_httpclient_test.go @@ -0,0 +1,565 @@ +//go:build !windows + +package plugins + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/plugins/host" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("httpServiceImpl", func() { + var ( + svc *httpServiceImpl + ts *httptest.Server + ) + + AfterEach(func() { + if ts != nil { + ts.Close() + } + }) + + Context("without host restrictions (default SSRF protection)", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", nil) + }) + + It("should block requests to loopback IPs", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to localhost by name", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://localhost:12345/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to private IPs (10.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://10.0.0.1/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to private IPs (192.168.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://192.168.1.1/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to private IPs (172.16.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://172.16.0.1/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to link-local IPs (169.254.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://169.254.169.254/latest/meta-data/", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to IPv6 loopback with port", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://[::1]:8080/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to IPv6 loopback without port", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://[::1]/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should allow requests to public hostnames", func() { + // This will fail at the network level (connection refused or DNS), + // but it should NOT fail with a "private/loopback" error + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://203.0.113.1:1/test", // TEST-NET-3, non-routable but not private + TimeoutMs: 100, + }) + // Should get a network error, not a permission error + if err != nil { + Expect(err.Error()).ToNot(ContainSubstring("private/loopback")) + } + }) + + It("should return error for invalid URL", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "://bad-url", + }) + Expect(err).To(HaveOccurred()) + }) + + It("should reject non-http/https URL schemes", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "ftp://example.com/file", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("must be http or https")) + }) + }) + + Context("with explicit requiredHosts allowing loopback", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"127.0.0.1"}, + }) + }) + + It("should handle GET requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("GET")) + w.Header().Set("X-Test", "ok") + w.WriteHeader(201) + _, _ = w.Write([]byte("hello")) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + Headers: map[string]string{"Accept": "text/plain"}, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(201))) + Expect(string(resp.Body)).To(Equal("hello")) + Expect(resp.Headers["X-Test"]).To(Equal("ok")) + }) + + It("should handle POST requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("POST")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("got:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "POST", + URL: ts.URL, + Body: []byte("abc"), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("got:abc")) + }) + + It("should handle PUT requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("PUT")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("put:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "PUT", + URL: ts.URL, + Body: []byte("xyz"), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("put:xyz")) + }) + + It("should handle DELETE requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("DELETE")) + w.WriteHeader(204) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "DELETE", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(204))) + }) + + It("should handle DELETE requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("DELETE")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("del:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "DELETE", + URL: ts.URL, + Body: []byte(`{"id":"123"}`), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal(`del:{"id":"123"}`)) + }) + + It("should handle PATCH requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("PATCH")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("patch:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "PATCH", + URL: ts.URL, + Body: []byte("data"), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("patch:data")) + }) + + It("should handle HEAD requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("HEAD")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "HEAD", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(200))) + Expect(resp.Headers["Content-Type"]).To(Equal("application/json")) + Expect(resp.Body).To(BeEmpty()) + }) + + It("should use default timeout when TimeoutMs is 0", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(200))) + }) + + It("should return error on timeout", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("deadline exceeded")) + }) + + It("should return error on context cancellation", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(1 * time.Millisecond) + cancel() + }() + _, err := svc.Send(ctx, host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 5000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("context canceled")) + }) + + It("should send request headers", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(r.Header.Get("X-Custom"))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + Headers: map[string]string{"X-Custom": "myvalue"}, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("myvalue")) + }) + }) + + Context("with host restrictions", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"allowed.example.com", "*.allowed.org"}, + }) + }) + + It("should block requests to non-allowed hosts", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + // httptest server is on 127.0.0.1 which is not in requiredHosts + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + + It("should follow redirects to allowed hosts", func() { + // Create a destination server + dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("final")) + })) + defer dest.Close() + // Create a redirect server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, dest.URL, http.StatusFound) + })) + // Allow both servers (both on 127.0.0.1) + svc.requiredHosts = []string{"127.0.0.1"} + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(200))) + Expect(string(resp.Body)).To(Equal("final")) + }) + + It("should block redirects to non-allowed hosts", func() { + // Server that redirects to a disallowed host + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://evil.example.com/steal", http.StatusFound) + })) + // Override requiredHosts to allow the test server + svc.requiredHosts = []string{"127.0.0.1"} + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + + It("should block redirects to private IPs when allowlist is set", func() { + // Server that redirects to a private IP + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://10.0.0.1/internal", http.StatusFound) + })) + // Allow the test server; redirect to 10.0.0.1 is blocked by allowlist + svc.requiredHosts = []string{"127.0.0.1"} + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(resp).To(BeNil()) + }) + + It("should allow wildcard host patterns", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("wildcard")) + })) + // *.allowed.org is in the requiredHosts from BeforeEach, but test server is 127.0.0.1 + // Override with a wildcard that matches the test server + svc.requiredHosts = []string{"*.0.0.1"} + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("wildcard")) + }) + + It("should reject hosts not matching wildcard patterns", func() { + svc.requiredHosts = []string{"*.example.com"} + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://evil.other.com/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + }) + + Context("response body size limit", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"127.0.0.1"}, + }) + }) + + It("should truncate response body at the size limit", func() { + // Serve a body larger than the limit + oversizedBody := strings.Repeat("x", httpClientMaxResponseBodyLen+1024) + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(oversizedBody)) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 5000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(len(resp.Body)).To(Equal(httpClientMaxResponseBodyLen)) + }) + }) + + Context("edge cases", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"127.0.0.1"}, + }) + }) + + It("should default empty method to GET", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("method:" + r.Method)) + })) + // Empty method — Go's http.NewRequestWithContext normalizes "" to "GET" + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("method:GET")) + }) + }) +}) + +var _ = Describe("extractHostname", func() { + It("should extract hostname from host:port", func() { + Expect(extractHostname("example.com:8080")).To(Equal("example.com")) + }) + + It("should return hostname when no port", func() { + Expect(extractHostname("example.com")).To(Equal("example.com")) + }) + + It("should handle IPv6 with port", func() { + Expect(extractHostname("[::1]:8080")).To(Equal("::1")) + }) + + It("should handle IPv6 without port", func() { + Expect(extractHostname("::1")).To(Equal("::1")) + }) + + It("should strip brackets from IPv6 without port", func() { + Expect(extractHostname("[::1]")).To(Equal("::1")) + }) + + It("should handle IPv4 with port", func() { + Expect(extractHostname("127.0.0.1:9090")).To(Equal("127.0.0.1")) + }) + + It("should handle IPv4 without port", func() { + Expect(extractHostname("127.0.0.1")).To(Equal("127.0.0.1")) + }) +}) + +var _ = Describe("isPrivateOrLoopback", func() { + It("should detect IPv4 loopback", func() { + Expect(isPrivateOrLoopback("127.0.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("127.0.0.2")).To(BeTrue()) + }) + + It("should detect IPv6 loopback", func() { + Expect(isPrivateOrLoopback("::1")).To(BeTrue()) + }) + + It("should detect localhost by name", func() { + Expect(isPrivateOrLoopback("localhost")).To(BeTrue()) + Expect(isPrivateOrLoopback("LOCALHOST")).To(BeTrue()) + }) + + It("should detect 10.x.x.x private range", func() { + Expect(isPrivateOrLoopback("10.0.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("10.255.255.255")).To(BeTrue()) + }) + + It("should detect 172.16.x.x private range", func() { + Expect(isPrivateOrLoopback("172.16.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("172.31.255.255")).To(BeTrue()) + }) + + It("should detect 192.168.x.x private range", func() { + Expect(isPrivateOrLoopback("192.168.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("192.168.255.255")).To(BeTrue()) + }) + + It("should detect link-local addresses", func() { + Expect(isPrivateOrLoopback("169.254.169.254")).To(BeTrue()) + Expect(isPrivateOrLoopback("169.254.0.1")).To(BeTrue()) + }) + + It("should detect IPv6 private (fc00::/7)", func() { + Expect(isPrivateOrLoopback("fd00::1")).To(BeTrue()) + }) + + It("should detect IPv6 link-local (fe80::/10)", func() { + Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) + }) + + It("should allow public IPs", func() { + Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) + Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) + Expect(isPrivateOrLoopback("2001:db8::1")).To(BeFalse()) + }) + + It("should allow non-IP hostnames (DNS names)", func() { + Expect(isPrivateOrLoopback("example.com")).To(BeFalse()) + Expect(isPrivateOrLoopback("api.example.com")).To(BeFalse()) + }) + + It("should not treat 172.32.x.x as private", func() { + Expect(isPrivateOrLoopback("172.32.0.1")).To(BeFalse()) + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 06d905b49..84b28dd35 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -256,8 +256,11 @@ func (s *webSocketServiceImpl) isHostAllowed(host string) bool { } // matchHostPattern matches a host against a pattern. -// Supports wildcards like *.example.com +// Supports "*" (allow all) and wildcards like "*.example.com". func matchHostPattern(pattern, host string) bool { + if pattern == "*" { + return true + } if pattern == host { return true } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index a3d8ee74a..7a0439129 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -575,6 +575,12 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(matchHostPattern("*.example.com", "deep.api.example.com")).To(BeTrue()) }) + It("should match bare '*' as allow-all", func() { + Expect(matchHostPattern("*", "anything.example.com")).To(BeTrue()) + Expect(matchHostPattern("*", "127.0.0.1")).To(BeTrue()) + Expect(matchHostPattern("*", "::1")).To(BeTrue()) + }) + It("should not match partial patterns", func() { Expect(matchHostPattern("*.example.com", "example.com.evil.org")).To(BeFalse()) }) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index b558da1be..c6355911f 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -119,6 +119,15 @@ var hostServices = []hostServiceEntry{ return host.RegisterUsersHostFunctions(service), nil }, }, + { + name: "HTTP", + hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + perm := ctx.permissions.Http + service := newHTTPService(ctx.pluginName, perm) + return host.RegisterHTTPHostFunctions(service), nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 82dc2c4aa..b801db44b 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -38,6 +38,7 @@ The following host services are available: - Artwork: provides artwork public URL generation capabilities for plugins. - Cache: provides in-memory TTL-based caching capabilities for plugins. - Config: provides access to plugin configuration values. + - HTTP: provides outbound HTTP request capabilities for plugins. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. - Scheduler: provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/go/host/nd_host_httpclient.go b/plugins/pdk/go/host/nd_host_httpclient.go new file mode 100644 index 000000000..8bd960351 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_httpclient.go @@ -0,0 +1,87 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the HTTP host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// HTTPRequest represents an outbound HTTP request from a plugin. +type HTTPRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` +} + +// HTTPResponse represents the response from an outbound HTTP request. +type HTTPResponse struct { + StatusCode int32 `json:"statusCode"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` +} + +// http_send is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user http_send +func http_send(uint64) uint64 + +type httpSendRequest struct { + Request HTTPRequest `json:"request"` +} + +type httpSendResponse struct { + Result *HTTPResponse `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// HTTPSend calls the http_send host function. +// Send executes an HTTP request and returns the response. +// +// Parameters: +// - request: The HTTP request to execute, including method, URL, headers, body, and timeout +// +// Returns the HTTP response with status code, headers, and body. +// Network errors, timeouts, and permission failures are returned as Go errors. +// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. +func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { + // Marshal request to JSON + req := httpSendRequest{ + Request: request, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := http_send(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response httpSendResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_httpclient_stub.go b/plugins/pdk/go/host/nd_host_httpclient_stub.go new file mode 100644 index 000000000..053069391 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_httpclient_stub.go @@ -0,0 +1,55 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import "github.com/stretchr/testify/mock" + +// HTTPRequest represents an outbound HTTP request from a plugin. +type HTTPRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` +} + +// HTTPResponse represents the response from an outbound HTTP request. +type HTTPResponse struct { + StatusCode int32 `json:"statusCode"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` +} + +// mockHTTPService is the mock implementation for testing. +type mockHTTPService struct { + mock.Mock +} + +// HTTPMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.HTTPMock.On("MethodName", args...).Return(values...) +var HTTPMock = &mockHTTPService{} + +// Send is the mock method for HTTPSend. +func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) { + args := m.Called(request) + return args.Get(0).(*HTTPResponse), args.Error(1) +} + +// HTTPSend delegates to the mock instance. +// Send executes an HTTP request and returns the response. +// +// Parameters: +// - request: The HTTP request to execute, including method, URL, headers, body, and timeout +// +// Returns the HTTP response with status code, headers, and body. +// Network errors, timeouts, and permission failures are returned as Go errors. +// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. +func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { + return HTTPMock.Send(request) +} diff --git a/plugins/pdk/python/host/nd_host_httpclient.py b/plugins/pdk/python/host/nd_host_httpclient.py new file mode 100644 index 000000000..c6bfb77c0 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_httpclient.py @@ -0,0 +1,59 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the HTTP host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "http_send") +def _http_send(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def http_send(request: Any) -> Any: + """Send executes an HTTP request and returns the response. + +Parameters: + - request: The HTTP request to execute, including method, URL, headers, body, and timeout + +Returns the HTTP response with status code, headers, and body. +Network errors, timeouts, and permission failures are returned as errors. +Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. + + Args: + request: Any parameter. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "request": request, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _http_send(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", None) diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3dff68269..52a3a86cd 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -35,6 +35,7 @@ //! - [`artwork`] - provides artwork public URL generation capabilities for plugins. //! - [`cache`] - provides in-memory TTL-based caching capabilities for plugins. //! - [`config`] - provides access to plugin configuration values. +//! - [`http`] - provides outbound HTTP request capabilities for plugins. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. //! - [`scheduler`] - provides task scheduling capabilities for plugins. @@ -63,6 +64,13 @@ pub mod config { pub use super::nd_host_config::*; } +#[doc(hidden)] +mod nd_host_http; +/// provides outbound HTTP request capabilities for plugins. +pub mod http { + pub use super::nd_host_http::*; +} + #[doc(hidden)] mod nd_host_kvstore; /// provides persistent key-value storage for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs new file mode 100644 index 000000000..c73241c80 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -0,0 +1,83 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the HTTP host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// HTTPRequest represents an outbound HTTP request from a plugin. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpRequest { + pub method: String, + pub url: String, + #[serde(default)] + pub headers: std::collections::HashMap, + #[serde(default)] + pub body: Vec, + #[serde(default)] + pub timeout_ms: i32, +} + +/// HTTPResponse represents the response from an outbound HTTP request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpResponse { + pub status_code: i32, + #[serde(default)] + pub headers: std::collections::HashMap, + #[serde(default)] + pub body: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HTTPSendRequest { + request: HttpRequest, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HTTPSendResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn http_send(input: Json) -> Json; +} + +/// Send executes an HTTP request and returns the response. +/// +/// Parameters: +/// - request: The HTTP request to execute, including method, URL, headers, body, and timeout +/// +/// Returns the HTTP response with status code, headers, and body. +/// Network errors, timeouts, and permission failures are returned as errors. +/// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. +/// +/// # Arguments +/// * `request` - HttpRequest parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn send(request: HttpRequest) -> Result, Error> { + let response = unsafe { + http_send(Json(HTTPSendRequest { + request: request, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} From fc36f1daa69ddb46dc90cbfca929715158924e65 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 24 Feb 2026 20:45:58 -0500 Subject: [PATCH 026/596] chore(deps): update go-taglib dependency to latest version (mka fix) Signed-off-by: Deluan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f84ea65d0..8caddd5eb 100644 --- a/go.mod +++ b/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-20260221220301-2fab4903f48e + go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 ) require ( diff --git a/go.sum b/go.sum index 4e05f46bc..79f29c70a 100644 --- a/go.sum +++ b/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-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I= -github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 h1:seWJmkPAb+M1ysRNGzTGS7FfdrUe9wQTHhB9p2fxDWg= +github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1/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= From 14343d91b0165b49036f5022a22a39f783b37539 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 24 Feb 2026 21:44:04 -0500 Subject: [PATCH 027/596] chore(deps): update goose to 3.27.0 Signed-off-by: Deluan --- go.mod | 7 +++---- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 8caddd5eb..c52bb211d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.25 +go 1.25.0 replace ( // Fork to fix https://github.com/navidrome/navidrome/issues/3254 @@ -53,7 +53,7 @@ require ( 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/pressly/goose/v3 v3.26.0 + github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -88,7 +88,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -140,7 +140,6 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect golang.org/x/tools v0.42.0 // indirect diff --git a/go.sum b/go.sum index 79f29c70a..bbfd51e11 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -34,8 +34,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 h1:seWJmkPAb+M1ysRNGzTGS7FfdrUe9wQTHhB9p2fxDWg= github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -143,8 +143,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -193,8 +193,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= @@ -212,8 +212,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI 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/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= -github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -321,8 +321,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= @@ -423,11 +423,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= From 5bc2bbb70e6236d7361b1e0e68d657a74847fd1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 26 Feb 2026 10:50:12 -0500 Subject: [PATCH 028/596] feat(subsonic): append album version to names in Subsonic API (#5111) * feat(subsonic): append album version to album names in Subsonic API responses Add AppendAlbumVersion config option (default: true) that appends the album version tag to album names in Subsonic API responses, similar to how AppendSubtitle works for track titles. This affects album names in childFromAlbum and buildAlbumID3 responses. Signed-off-by: Deluan * feat(subsonic): append album version to media file album names in Subsonic API Add FullAlbumName() to MediaFile that appends the album version tag, mirroring the Album.FullName() behavior. Use it in childFromMediaFile and fakePath to ensure media file responses also show the album version. Signed-off-by: Deluan * fix(subsonic): use len() check for album version tag to prevent panic on empty slice Use len(tags) > 0 instead of != nil to safely guard against empty slices when accessing the first element of the album version tag. Signed-off-by: Deluan * fix(subsonic): use FullName in buildAlbumDirectory and deduplicate FullName calls Apply album.FullName() in buildAlbumDirectory (getMusicDirectory) so album names are consistent across all Subsonic endpoints. Also compute al.FullName() once in childFromAlbum to avoid redundant calls. Signed-off-by: Deluan * fix: use len() check in MediaFile.FullTitle() to prevent panic on empty slice Apply the same safety improvement as FullAlbumName() and Album.FullName() for consistency. Signed-off-by: Deluan * test: add tests for Album.FullName, MediaFile.FullTitle, and MediaFile.FullAlbumName Cover all cases: config enabled/disabled, tag present, tag absent, and empty tag slice. Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 2 ++ model/album.go | 10 ++++++++++ model/album_test.go | 19 +++++++++++++++++++ model/mediafile.go | 9 ++++++++- model/mediafile_test.go | 24 +++++++++++++++++++++++- server/subsonic/browsing.go | 2 +- server/subsonic/helpers.go | 13 +++++++------ 7 files changed, 70 insertions(+), 9 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 555d8f587..61448d315 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -155,6 +155,7 @@ type scannerOptions struct { type subsonicOptions struct { AppendSubtitle bool + AppendAlbumVersion bool ArtistParticipations bool DefaultReportRealPath bool EnableAverageRating bool @@ -689,6 +690,7 @@ func setViperDefaults() { viper.SetDefault("scanner.followsymlinks", true) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) + viper.SetDefault("subsonic.appendalbumversion", true) viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.enableaveragerating", true) diff --git a/model/album.go b/model/album.go index a8dcfe682..667f4695b 100644 --- a/model/album.go +++ b/model/album.go @@ -1,11 +1,14 @@ package model import ( + "fmt" "iter" "math" "sync" "time" + "github.com/navidrome/navidrome/conf" + "github.com/gohugoio/hashstructure" ) @@ -70,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } +func (a Album) FullName() string { + if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { + return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) + } + return a.Name +} + // Equals compares two Album structs, ignoring calculated fields func (a Album) Equals(other Album) bool { // Normalize float32 values to avoid false negatives diff --git a/model/album_test.go b/model/album_test.go index a45d16dd5..0f4c912cd 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -3,11 +3,30 @@ package model_test import ( "encoding/json" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("Album", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + DescribeTable("FullName", + func(enabled bool, tags Tags, expected string) { + conf.Server.Subsonic.AppendAlbumVersion = enabled + a := Album{Name: "Album", Tags: tags} + Expect(a.FullName()).To(Equal(expected)) + }, + Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"), + Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), + Entry("returns just name when tag is absent", true, Tags{}, "Album"), + Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + ) +}) + var _ = Describe("Albums", func() { var albums Albums diff --git a/model/mediafile.go b/model/mediafile.go index 831f006bf..103b02639 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -95,12 +95,19 @@ type MediaFile struct { } func (mf MediaFile) FullTitle() string { - if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil { + if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } +func (mf MediaFile) FullAlbumName() string { + if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { + return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) + } + return mf.Album +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 635a61d30..0b9191fe5 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -475,7 +475,29 @@ var _ = Describe("MediaFile", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableMediaFileCoverArt = true }) - Describe(".CoverArtId()", func() { + DescribeTable("FullTitle", + func(enabled bool, tags Tags, expected string) { + conf.Server.Subsonic.AppendSubtitle = enabled + mf := MediaFile{Title: "Song", Tags: tags} + Expect(mf.FullTitle()).To(Equal(expected)) + }, + Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"), + Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), + Entry("returns just title when tag is absent", true, Tags{}, "Song"), + Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), + ) + DescribeTable("FullAlbumName", + func(enabled bool, tags Tags, expected string) { + conf.Server.Subsonic.AppendAlbumVersion = enabled + mf := MediaFile{Album: "Album", Tags: tags} + Expect(mf.FullAlbumName()).To(Equal(expected)) + }, + Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"), + Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), + Entry("returns just album name when tag is absent", true, Tags{}, "Album"), + Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + ) + Describe("CoverArtId()", func() { It("returns its own id if it HasCoverArt", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 63939f6f4..5b9c4f3c9 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -443,7 +443,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) { dir := &responses.Directory{} dir.Id = album.ID - dir.Name = album.Name + dir.Name = album.FullName() dir.Parent = album.AlbumArtistID dir.PlayCount = album.PlayCount if album.PlayCount > 0 { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 107e23133..598346901 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -197,7 +197,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child } child.Parent = mf.AlbumID - child.Album = mf.Album + child.Album = mf.FullAlbumName() child.Year = int32(mf.Year) child.Artist = mf.Artist child.Genre = mf.Genre @@ -302,7 +302,7 @@ func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref { func fakePath(mf model.MediaFile) string { builder := strings.Builder{} - builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.Album))) + builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.FullAlbumName()))) if mf.DiscNumber != 0 { builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber)) } @@ -321,9 +321,10 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child := responses.Child{} child.Id = al.ID child.IsDir = true - child.Title = al.Name - child.Name = al.Name - child.Album = al.Name + fullName := al.FullName() + child.Title = fullName + child.Name = fullName + child.Album = fullName child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre @@ -405,7 +406,7 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle { func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir := responses.AlbumID3{} dir.Id = album.ID - dir.Name = album.Name + dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID dir.CoverArt = album.CoverArtID().String() From cdd3432788f04114a8ec6f6342e354a456b4a9bc Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 26 Feb 2026 16:19:37 -0500 Subject: [PATCH 029/596] refactor(http): rename HTTP client files and update struct names for consistency Signed-off-by: Deluan --- plugins/host/{httpclient.go => http.go} | 0 .../host/{httpclient_gen.go => http_gen.go} | 0 plugins/pdk/go/go.mod | 7 +++ ...{nd_host_httpclient.go => nd_host_http.go} | 10 ++-- ...ttpclient_stub.go => nd_host_http_stub.go} | 2 + plugins/pdk/python/host/nd_host_http.py | 59 +++++++++++++++++++ .../pdk/rust/nd-pdk-host/src/nd_host_http.rs | 18 +++--- 7 files changed, 83 insertions(+), 13 deletions(-) rename plugins/host/{httpclient.go => http.go} (100%) rename plugins/host/{httpclient_gen.go => http_gen.go} (100%) rename plugins/pdk/go/host/{nd_host_httpclient.go => nd_host_http.go} (90%) rename plugins/pdk/go/host/{nd_host_httpclient_stub.go => nd_host_http_stub.go} (94%) create mode 100644 plugins/pdk/python/host/nd_host_http.py diff --git a/plugins/host/httpclient.go b/plugins/host/http.go similarity index 100% rename from plugins/host/httpclient.go rename to plugins/host/http.go diff --git a/plugins/host/httpclient_gen.go b/plugins/host/http_gen.go similarity index 100% rename from plugins/host/httpclient_gen.go rename to plugins/host/http_gen.go diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod index 3916cd749..4d5fcddfc 100644 --- a/plugins/pdk/go/go.mod +++ b/plugins/pdk/go/go.mod @@ -6,3 +6,10 @@ require ( github.com/extism/go-pdk v1.1.3 github.com/stretchr/testify v1.11.1 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/pdk/go/host/nd_host_httpclient.go b/plugins/pdk/go/host/nd_host_http.go similarity index 90% rename from plugins/pdk/go/host/nd_host_httpclient.go rename to plugins/pdk/go/host/nd_host_http.go index 8bd960351..d77db4762 100644 --- a/plugins/pdk/go/host/nd_host_httpclient.go +++ b/plugins/pdk/go/host/nd_host_http.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +// HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { Method string `json:"method"` @@ -23,6 +24,7 @@ type HTTPRequest struct { TimeoutMs int32 `json:"timeoutMs"` } +// HTTPResponse represents the HTTPResponse data structure. // HTTPResponse represents the response from an outbound HTTP request. type HTTPResponse struct { StatusCode int32 `json:"statusCode"` @@ -35,11 +37,11 @@ type HTTPResponse struct { //go:wasmimport extism:host/user http_send func http_send(uint64) uint64 -type httpSendRequest struct { +type hTTPSendRequest struct { Request HTTPRequest `json:"request"` } -type httpSendResponse struct { +type hTTPSendResponse struct { Result *HTTPResponse `json:"result,omitempty"` Error string `json:"error,omitempty"` } @@ -55,7 +57,7 @@ type httpSendResponse struct { // Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { // Marshal request to JSON - req := httpSendRequest{ + req := hTTPSendRequest{ Request: request, } reqBytes, err := json.Marshal(req) @@ -73,7 +75,7 @@ func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { responseBytes := responseMem.ReadBytes() // Parse the response - var response httpSendResponse + var response hTTPSendResponse if err := json.Unmarshal(responseBytes, &response); err != nil { return nil, err } diff --git a/plugins/pdk/go/host/nd_host_httpclient_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go similarity index 94% rename from plugins/pdk/go/host/nd_host_httpclient_stub.go rename to plugins/pdk/go/host/nd_host_http_stub.go index 053069391..b5d1eee75 100644 --- a/plugins/pdk/go/host/nd_host_httpclient_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -10,6 +10,7 @@ package host import "github.com/stretchr/testify/mock" +// HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { Method string `json:"method"` @@ -19,6 +20,7 @@ type HTTPRequest struct { TimeoutMs int32 `json:"timeoutMs"` } +// HTTPResponse represents the HTTPResponse data structure. // HTTPResponse represents the response from an outbound HTTP request. type HTTPResponse struct { StatusCode int32 `json:"statusCode"` diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py new file mode 100644 index 000000000..8f196f985 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_http.py @@ -0,0 +1,59 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the HTTP host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "http_send") +def _http_send(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def http_send(request: Any) -> Any: + """Send executes an HTTP request and returns the response. + +Parameters: + - request: The HTTP request to execute, including method, URL, headers, body, and timeout + +Returns the HTTP response with status code, headers, and body. +Network errors, timeouts, and permission failures are returned as Go errors. +Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. + + Args: + request: Any parameter. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "request": request, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _http_send(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", None) diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index c73241c80..b8aaad1e0 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; /// HTTPRequest represents an outbound HTTP request from a plugin. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct HttpRequest { +pub struct HTTPRequest { pub method: String, pub url: String, #[serde(default)] @@ -23,7 +23,7 @@ pub struct HttpRequest { /// HTTPResponse represents the response from an outbound HTTP request. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct HttpResponse { +pub struct HTTPResponse { pub status_code: i32, #[serde(default)] pub headers: std::collections::HashMap, @@ -34,14 +34,14 @@ pub struct HttpResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct HTTPSendRequest { - request: HttpRequest, + request: HTTPRequest, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct HTTPSendResponse { #[serde(default)] - result: Option, + result: Option, #[serde(default)] error: Option, } @@ -52,23 +52,23 @@ extern "ExtismHost" { } /// Send executes an HTTP request and returns the response. -/// +/// /// Parameters: /// - request: The HTTP request to execute, including method, URL, headers, body, and timeout -/// +/// /// Returns the HTTP response with status code, headers, and body. -/// Network errors, timeouts, and permission failures are returned as errors. +/// Network errors, timeouts, and permission failures are returned as Go errors. /// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. /// /// # Arguments -/// * `request` - HttpRequest parameter. +/// * `request` - HTTPRequest parameter. /// /// # Returns /// The result value. /// /// # Errors /// Returns an error if the host function call fails. -pub fn send(request: HttpRequest) -> Result, Error> { +pub fn send(request: HTTPRequest) -> Result, Error> { let response = unsafe { http_send(Json(HTTPSendRequest { request: request, From 582d1b3cd90a0f913976a706bc9ea5e64d411b76 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 26 Feb 2026 16:30:50 -0500 Subject: [PATCH 030/596] refactor(plugins): validate scheduler capability at load time Move scheduler capability check from runtime (when callback fires) to load-time validation in ValidateWithCapabilities. This ensures plugins declaring the scheduler permission must export the nd_scheduler_callback function, failing fast with a clear error instead of silently skipping callbacks at runtime. --- plugins/host_scheduler.go | 6 ------ plugins/manifest.go | 8 ++++++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go index e7c97c271..131f56521 100644 --- a/plugins/host_scheduler.go +++ b/plugins/host_scheduler.go @@ -188,12 +188,6 @@ func (s *schedulerServiceImpl) invokeCallback(ctx context.Context, scheduleID st return } - // Check if plugin has the scheduler capability - if !hasCapability(instance.capabilities, CapabilityScheduler) { - log.Warn(ctx, "Plugin does not have scheduler capability", "plugin", s.pluginName, "scheduleID", scheduleID) - return - } - // Prepare callback input input := capabilities.SchedulerCallbackRequest{ ScheduleID: scheduleID, diff --git a/plugins/manifest.go b/plugins/manifest.go index f401a7f69..3ca2657cd 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -64,6 +64,14 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest") } } + + // Scheduler permission requires SchedulerCallback capability + if m.Permissions != nil && m.Permissions.Scheduler != nil { + if !hasCapability(capabilities, CapabilityScheduler) { + return fmt.Errorf("'scheduler' permission requires plugin to export '%s' function", FuncSchedulerCallback) + } + } + return nil } From bd8032b3274a02fef7dbafb51c8ea6b5970b3931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 27 Feb 2026 19:00:19 -0500 Subject: [PATCH 031/596] fix(plugins): add base64 handling for []byte and remove raw=true (#5121) * fix(plugins): add base64 handling for []byte and remove raw=true Go's json.Marshal automatically base64-encodes []byte fields, but Rust's serde_json serializes Vec as a JSON array and Python's json.dumps raises TypeError on bytes. This fixes both directions of plugin communication by adding proper base64 encoding/decoding in generated client code. For Rust templates (client and capability): adds a base64_bytes serde helper module with #[serde(with = "base64_bytes")] on all Vec fields, and adds base64 as a dependency. For Python templates: wraps bytes params with base64.b64encode() and responses with base64.b64decode(). Also removes the raw=true binary framing protocol from all templates, the parser, and the Method type. The raw mechanism added complexity that is no longer needed once []byte works properly over JSON. * fix(plugins): update production code and tests for base64 migration Remove raw=true annotation from SubsonicAPI.CallRaw, delete all raw test fixtures, remove raw-related test cases from parser, generator, and integration tests, and add new test cases validating base64 handling for Rust and Python templates. * fix(plugins): update golden files and regenerate production code Update golden test fixtures for codec and comprehensive services to include base64 handling for []byte fields. Regenerate all production PDK code (Go, Rust, Python) and host wrappers to use standard JSON with base64-encoded byte fields instead of binary framing protocol. * refactor: remove base64 helper duplication from rust template Signed-off-by: Deluan * fix(plugins): add base64 dependency to capabilities' Cargo.toml Signed-off-by: Deluan --------- Signed-off-by: Deluan --- plugins/cmd/ndpgen/integration_test.go | 3 - plugins/cmd/ndpgen/internal/generator.go | 18 ++ plugins/cmd/ndpgen/internal/generator_test.go | 266 +++++------------- plugins/cmd/ndpgen/internal/parser.go | 8 - plugins/cmd/ndpgen/internal/parser_test.go | 113 -------- .../internal/templates/base64_bytes.rs.tmpl | 25 ++ .../internal/templates/capability.rs.tmpl | 4 + .../ndpgen/internal/templates/client.go.tmpl | 27 +- .../ndpgen/internal/templates/client.py.tmpl | 45 ++- .../ndpgen/internal/templates/client.rs.tmpl | 88 +----- .../ndpgen/internal/templates/host.go.tmpl | 50 +--- plugins/cmd/ndpgen/internal/types.go | 57 +++- .../ndpgen/testdata/codec_client_expected.py | 5 +- .../ndpgen/testdata/codec_client_expected.rs | 25 ++ .../testdata/comprehensive_client_expected.py | 5 +- .../testdata/comprehensive_client_expected.rs | 25 ++ .../testdata/raw_client_expected.go.txt | 66 ----- .../ndpgen/testdata/raw_client_expected.py | 63 ----- .../ndpgen/testdata/raw_client_expected.rs | 73 ----- .../cmd/ndpgen/testdata/raw_service.go.txt | 10 - plugins/host/subsonicapi.go | 6 +- plugins/host/subsonicapi_gen.go | 44 +-- plugins/pdk/go/host/nd_host_subsonicapi.go | 37 ++- .../pdk/go/host/nd_host_subsonicapi_stub.go | 4 +- plugins/pdk/python/host/nd_host_cache.py | 5 +- plugins/pdk/python/host/nd_host_http.py | 1 + plugins/pdk/python/host/nd_host_kvstore.py | 5 +- .../pdk/python/host/nd_host_subsonicapi.py | 42 +-- plugins/pdk/python/host/nd_host_websocket.py | 3 +- .../pdk/rust/nd-pdk-capabilities/Cargo.toml | 1 + plugins/pdk/rust/nd-pdk-host/Cargo.toml | 1 + .../pdk/rust/nd-pdk-host/src/nd_host_cache.rs | 25 ++ .../pdk/rust/nd-pdk-host/src/nd_host_http.rs | 25 ++ .../rust/nd-pdk-host/src/nd_host_kvstore.rs | 25 ++ .../nd-pdk-host/src/nd_host_subsonicapi.rs | 90 +++--- .../rust/nd-pdk-host/src/nd_host_websocket.rs | 24 ++ 36 files changed, 460 insertions(+), 854 deletions(-) create mode 100644 plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl delete mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt delete mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.rs delete mode 100644 plugins/cmd/ndpgen/testdata/raw_service.go.txt diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index 13ebe14a4..db500c1fc 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -282,9 +282,6 @@ type ServiceB interface { Entry("option pattern (value, exists bool)", "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), - - Entry("raw=true binary response", - "raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"), ) It("generates compilable client code for comprehensive service", func() { diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 69e232565..705cd4d36 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -256,6 +256,15 @@ func GenerateClientRust(svc Service) ([]byte, error) { return nil, fmt.Errorf("parsing template: %w", err) } + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + data := templateData{ Service: svc, } @@ -622,6 +631,15 @@ func GenerateCapabilityRust(cap Capability) ([]byte, error) { return nil, fmt.Errorf("parsing template: %w", err) } + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + data := capabilityTemplateData{ Package: cap.Name, Capability: cap, diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index ed15f174b..34c2c2886 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -264,96 +264,6 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) - It("should generate binary framing for raw=true methods", func() { - svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", - Methods: []Method{ - { - Name: "GetStream", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should include encoding/binary import for raw methods - Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) - - // Should NOT generate a response type for raw methods - Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct")) - - // Should generate request type (request is still JSON) - Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct")) - - // Should build binary frame [0x00][4-byte CT len][CT][data] - Expect(codeStr).To(ContainSubstring("frame[0] = 0x00")) - Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32")) - - // Should have writeRawError helper - Expect(codeStr).To(ContainSubstring("streamWriteRawError")) - - // Should use writeRawError instead of writeError for raw methods - Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack")) - }) - - It("should generate both writeError and writeRawError for mixed services", func() { - svc := Service{ - Name: "API", - Permission: "api", - Interface: "APIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - { - Name: "CallRaw", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should have both helpers - Expect(codeStr).To(ContainSubstring("apiWriteResponse")) - Expect(codeStr).To(ContainSubstring("apiWriteError")) - Expect(codeStr).To(ContainSubstring("apiWriteRawError")) - - // Should generate response type for non-raw method only - Expect(codeStr).To(ContainSubstring("type APICallResponse struct")) - Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct")) - }) - It("should always include json import for JSON protocol", func() { // All services use JSON protocol, so json import is always needed svc := Service{ @@ -717,49 +627,7 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) }) - It("should generate binary frame parsing for raw methods", func() { - svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", - Methods: []Method{ - { - Name: "GetStream", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - Doc: "GetStream returns raw binary stream data.", - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should import Tuple and struct for raw methods - Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple")) - Expect(codeStr).To(ContainSubstring("import struct")) - - // Should return Tuple[str, bytes] - Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:")) - - // Should parse binary frame instead of JSON - Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()")) - Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) - Expect(codeStr).To(ContainSubstring("struct.unpack")) - Expect(codeStr).To(ContainSubstring("return content_type, data")) - - // Should NOT use json.loads for response - Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))")) - }) - - It("should not import Tuple or struct for non-raw services", func() { + It("should not import base64 for non-byte services", func() { svc := Service{ Name: "Test", Permission: "test", @@ -779,8 +647,37 @@ var _ = Describe("Generator", func() { codeStr := string(code) - Expect(codeStr).NotTo(ContainSubstring("Tuple")) - Expect(codeStr).NotTo(ContainSubstring("import struct")) + Expect(codeStr).NotTo(ContainSubstring("import base64")) + }) + + It("should generate base64 encoding/decoding for byte fields", func() { + svc := Service{ + Name: "Codec", + Permission: "codec", + Interface: "CodecService", + Methods: []Method{ + { + Name: "Encode", + HasError: true, + Params: []Param{NewParam("data", "[]byte")}, + Returns: []Param{NewParam("result", "[]byte")}, + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should import base64 + Expect(codeStr).To(ContainSubstring("import base64")) + + // Should base64-encode byte params in request + Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`)) + + // Should base64-decode byte returns in response + Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`)) }) }) @@ -939,46 +836,6 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk")) }) - It("should include encoding/binary import for raw methods", func() { - svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", - Methods: []Method{ - { - Name: "GetStream", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - }, - }, - } - - code, err := GenerateClientGo(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should include encoding/binary for raw binary frame parsing - Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) - - // Should NOT generate response type struct for raw methods - Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct")) - - // Should still generate request type - Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct")) - - // Should parse binary frame - Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01")) - Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32")) - - // Should return (string, []byte, error) - Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)")) - }) }) Describe("GenerateClientGoStub", func() { @@ -1748,22 +1605,17 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("Option")) }) - It("should generate raw extern C import and binary frame parsing for raw methods", func() { + It("should generate base64 serde for Vec fields", func() { svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", + Name: "Codec", + Permission: "codec", + Interface: "CodecService", Methods: []Method{ { - Name: "GetStream", + Name: "Encode", HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - Doc: "GetStream returns raw binary stream data.", + Params: []Param{NewParam("data", "[]byte")}, + Returns: []Param{NewParam("result", "[]byte")}, }, }, } @@ -1773,24 +1625,36 @@ var _ = Describe("Rust Generation", func() { codeStr := string(code) - // Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost" - Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`)) - Expect(codeStr).To(ContainSubstring(`extern "C"`)) - Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64")) + // Should generate base64_bytes serde module + Expect(codeStr).To(ContainSubstring("mod base64_bytes")) + Expect(codeStr).To(ContainSubstring("use base64::Engine as _")) - // Should NOT generate response type for raw methods - Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse")) + // Should add serde(with = "base64_bytes") on Vec fields + Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) + }) - // Should generate request type (request is still JSON) - Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest")) + It("should not generate base64 module when no byte fields", func() { + svc := Service{ + Name: "Test", + Permission: "test", + Interface: "TestService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + }, + } - // Should return Result<(String, Vec), Error> - Expect(codeStr).To(ContainSubstring("Result<(String, Vec), Error>")) + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) - // Should parse binary frame - Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) - Expect(codeStr).To(ContainSubstring("u32::from_be_bytes")) - Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy")) + codeStr := string(code) + + Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes")) + Expect(codeStr).NotTo(ContainSubstring("use base64")) }) }) }) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index c2d571779..4cb28f8d4 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -761,7 +761,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri m := Method{ Name: name, ExportName: annotation["name"], - Raw: annotation["raw"] == "true", Doc: doc, } @@ -800,13 +799,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri } } - // Validate raw=true methods: must return exactly (string, []byte, error) - if m.Raw { - if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" { - return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name) - } - } - return m, nil } diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f2bdbeded..f43578397 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -122,119 +122,6 @@ type TestService interface { Expect(services[0].Methods[0].Name).To(Equal("Exported")) }) - It("should parse raw=true annotation", func() { - src := `package host - -import "context" - -//nd:hostservice name=Stream permission=stream -type StreamService interface { - //nd:hostfunc raw=true - GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - m := services[0].Methods[0] - Expect(m.Name).To(Equal("GetStream")) - Expect(m.Raw).To(BeTrue()) - Expect(m.HasError).To(BeTrue()) - Expect(m.Returns).To(HaveLen(2)) - Expect(m.Returns[0].Name).To(Equal("contentType")) - Expect(m.Returns[0].Type).To(Equal("string")) - Expect(m.Returns[1].Name).To(Equal("data")) - Expect(m.Returns[1].Type).To(Equal("[]byte")) - }) - - It("should set Raw=false when raw annotation is absent", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Call(ctx context.Context, uri string) (response string, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services[0].Methods[0].Raw).To(BeFalse()) - }) - - It("should reject raw=true with invalid return signature", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc raw=true - BadRaw(ctx context.Context, uri string) (result string, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - _, err = ParseDirectory(tmpDir) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("raw=true")) - Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)")) - }) - - It("should reject raw=true without error return", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc raw=true - BadRaw(ctx context.Context, uri string) (contentType string, data []byte) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - _, err = ParseDirectory(tmpDir) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("raw=true")) - }) - - It("should parse mixed raw and non-raw methods", func() { - src := `package host - -import "context" - -//nd:hostservice name=API permission=api -type APIService interface { - //nd:hostfunc - Call(ctx context.Context, uri string) (responseJSON string, err error) - - //nd:hostfunc raw=true - CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - Expect(services[0].Methods).To(HaveLen(2)) - Expect(services[0].Methods[0].Raw).To(BeFalse()) - Expect(services[0].Methods[1].Raw).To(BeTrue()) - Expect(services[0].HasRawMethods()).To(BeTrue()) - }) - It("should handle custom export name", func() { src := `package host diff --git a/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl new file mode 100644 index 000000000..929aa8e3e --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl @@ -0,0 +1,25 @@ +{{define "base64_bytes_module"}} +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} +{{- end}} \ No newline at end of file diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 597a17338..790ed93e4 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; {{- end}} +{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} // Helper functions for skip_serializing_if with numeric types #[allow(dead_code)] @@ -70,6 +71,9 @@ pub struct {{.Name}} { #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] {{- else}} #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] {{- end}} pub {{rustFieldName .Name}}: {{fieldRustType .}}, {{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index 971ae394a..a6ee04446 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -8,9 +8,6 @@ package {{.Package}} import ( -{{- if .Service.HasRawMethods}} - "encoding/binary" -{{- end}} "encoding/json" {{- if .Service.HasErrors}} "errors" @@ -52,7 +49,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if and (not .IsErrorOnly) (not .Raw)}} +{{- if not .IsErrorOnly}} type {{responseType .}} struct { {{- range .Returns}} @@ -98,27 +95,7 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{ // Read the response from memory responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() -{{- if .Raw}} - - // Parse binary-framed response - if len(responseBytes) == 0 { - return "", nil, errors.New("empty response from host") - } - if responseBytes[0] == 0x01 { // error - return "", nil, errors.New(string(responseBytes[1:])) - } - if responseBytes[0] != 0x00 { - return "", nil, errors.New("unknown response status") - } - if len(responseBytes) < 5 { - return "", nil, errors.New("malformed raw response: incomplete header") - } - ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) - if uint32(len(responseBytes)) < 5+ctLen { - return "", nil, errors.New("malformed raw response: content-type overflow") - } - return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil -{{- else if .IsErrorOnly}} +{{- if .IsErrorOnly}} // Parse error-only response var response struct { diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl index 84bff4abe..7ccaa6106 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl @@ -8,12 +8,12 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}} +from typing import Any import extism import json -{{- if .Service.HasRawMethods}} -import struct +{{- if .Service.HasByteFields}} +import base64 {{- end}} @@ -32,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int: {{- end}} {{- /* Generate dataclasses for multi-value returns */ -}} {{range .Service.Methods}} -{{- if and .NeedsResultClass (not .Raw)}} +{{- if .NeedsResultClass}} @dataclass @@ -47,7 +47,7 @@ class {{pythonResultType .}}: {{range .Service.Methods}} -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: +def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -56,11 +56,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{.PythonName}}: {{.PythonType}} parameter. {{- end}} {{- end}} -{{- if .Raw}} - - Returns: - Tuple of (content_type, data) with the raw binary response. -{{- else if .HasReturns}} +{{- if .HasReturns}} Returns: {{- if .NeedsResultClass}} @@ -76,7 +72,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{- if .HasParams}} request = { {{- range .Params}} +{{- if .IsByteSlice}} + "{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"), +{{- else}} "{{.JSONName}}": {{.PythonName}}, +{{- end}} {{- end}} } request_bytes = json.dumps(request).encode("utf-8") @@ -86,24 +86,6 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam request_mem = extism.memory.alloc(request_bytes) response_offset = _{{exportName .}}(request_mem.offset) response_mem = extism.memory.find(response_offset) -{{- if .Raw}} - response_bytes = response_mem.bytes() - - if len(response_bytes) == 0: - raise HostFunctionError("empty response from host") - if response_bytes[0] == 0x01: - raise HostFunctionError(response_bytes[1:].decode("utf-8")) - if response_bytes[0] != 0x00: - raise HostFunctionError("unknown response status") - if len(response_bytes) < 5: - raise HostFunctionError("malformed raw response: incomplete header") - ct_len = struct.unpack(">I", response_bytes[1:5])[0] - if len(response_bytes) < 5 + ct_len: - raise HostFunctionError("malformed raw response: content-type overflow") - content_type = response_bytes[5:5 + ct_len].decode("utf-8") - data = response_bytes[5 + ct_len:] - return content_type, data -{{- else}} response = json.loads(extism.memory.string(response_mem)) {{if .HasError}} if response.get("error"): @@ -112,10 +94,17 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{- if .NeedsResultClass}} return {{pythonResultType .}}( {{- range .Returns}} +{{- if .IsByteSlice}} + {{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")), +{{- else}} {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}), +{{- end}} {{- end}} ) {{- else if .HasReturns}} +{{- if (index .Returns 0).IsByteSlice}} + return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", "")) +{{- else}} return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) {{- end}} {{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index 2fb368bf1..f8b786849 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -5,6 +5,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} {{- /* Generate struct definitions */ -}} {{- range .Service.Structs}} {{if .Doc}} @@ -16,6 +17,9 @@ pub struct {{.Name}} { {{- range .Fields}} {{- if .NeedsDefault}} #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] {{- end}} pub {{.RustName}}: {{fieldRustType .}}, {{- end}} @@ -29,17 +33,22 @@ pub struct {{.Name}} { #[serde(rename_all = "camelCase")] struct {{requestType .}} { {{- range .Params}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} {{.RustName}}: {{rustType .}}, {{- end}} } {{- end}} -{{- if not .Raw}} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct {{responseType .}} { {{- range .Returns}} #[serde(default)] +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} {{.RustName}}: {{rustType .}}, {{- end}} {{- if .HasError}} @@ -48,92 +57,16 @@ struct {{responseType .}} { {{- end}} } {{- end}} -{{- end}} #[host_fn] extern "ExtismHost" { {{- range .Service.Methods}} -{{- if not .Raw}} fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>; {{- end}} -{{- end}} } -{{- /* Declare raw extern "C" imports for raw methods */ -}} -{{- range .Service.Methods}} -{{- if .Raw}} - -#[link(wasm_import_module = "extism:host/user")] -extern "C" { - fn {{exportName .}}(offset: u64) -> u64; -} -{{- end}} -{{- end}} {{- /* Generate wrapper functions */ -}} {{range .Service.Methods}} -{{- if .Raw}} - -{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} -{{- if .HasParams}} -/// -/// # Arguments -{{- range .Params}} -/// * `{{.RustName}}` - {{rustType .}} parameter. -{{- end}} -{{- end}} -/// -/// # Returns -/// A tuple of (content_type, data) with the raw binary response. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec), Error> { -{{- if .HasParams}} - let req = {{requestType .}} { -{{- range .Params}} - {{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}}, -{{- end}} - }; - let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; -{{- else}} - let input_bytes = b"{}".to_vec(); -{{- end}} - let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; - - let response_offset = unsafe { {{exportName .}}(input_mem.offset()) }; - - let response_mem = Memory::find(response_offset) - .ok_or_else(|| Error::msg("empty response from host"))?; - let response_bytes = response_mem.to_vec(); - - if response_bytes.is_empty() { - return Err(Error::msg("empty response from host")); - } - if response_bytes[0] == 0x01 { - let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); - return Err(Error::msg(msg)); - } - if response_bytes[0] != 0x00 { - return Err(Error::msg("unknown response status")); - } - if response_bytes.len() < 5 { - return Err(Error::msg("malformed raw response: incomplete header")); - } - let ct_len = u32::from_be_bytes([ - response_bytes[1], - response_bytes[2], - response_bytes[3], - response_bytes[4], - ]) as usize; - if ct_len > response_bytes.len() - 5 { - return Err(Error::msg("malformed raw response: content-type overflow")); - } - let ct_end = 5 + ct_len; - let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); - let data = response_bytes[ct_end..].to_vec(); - Ok((content_type, data)) -} -{{- else}} {{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -209,4 +142,3 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName } {{- end}} {{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index 12dd20475..083f7577e 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -4,9 +4,6 @@ package {{.Package}} import ( "context" -{{- if .Service.HasRawMethods}} - "encoding/binary" -{{- end}} "encoding/json" extism "github.com/extism/go-sdk" @@ -23,7 +20,6 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if not .Raw}} // {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}. type {{responseType .}} struct { @@ -34,7 +30,6 @@ type {{responseType .}} struct { Error string `json:"error,omitempty"` {{- end}} } -{{- end}} {{end}} // Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions. @@ -56,48 +51,18 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) if err != nil { -{{- if .Raw}} - {{$.Service.Name | lower}}WriteRawError(p, stack, err) -{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) -{{- end}} return } var req {{requestType .}} if err := json.Unmarshal(reqBytes, &req); err != nil { -{{- if .Raw}} - {{$.Service.Name | lower}}WriteRawError(p, stack, err) -{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) -{{- end}} return } {{- end}} // Call the service method -{{- if .Raw}} - {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) - if svcErr != nil { - {{$.Service.Name | lower}}WriteRawError(p, stack, svcErr) - return - } - - // Write binary-framed response to plugin memory: - // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] - ctBytes := []byte({{lower (index .Returns 0).Name}}) - frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}})) - frame[0] = 0x00 // success - binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) - copy(frame[5:5+len(ctBytes)], ctBytes) - copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}}) - - respPtr, err := p.WriteBytes(frame) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -{{- else if .HasReturns}} +{{- if .HasReturns}} {{- if .HasError}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) if svcErr != nil { @@ -162,16 +127,3 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64 respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } -{{- if .Service.HasRawMethods}} - -// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory. -// Format: [0x01][UTF-8 error message] -func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { - errMsg := []byte(err.Error()) - frame := make([]byte, 1+len(errMsg)) - frame[0] = 0x01 // error - copy(frame[1:], errMsg) - respPtr, _ := p.WriteBytes(frame) - stack[0] = respPtr -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 29cf2316a..6132dfbc4 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -173,16 +173,6 @@ func (s Service) HasErrors() bool { return false } -// HasRawMethods returns true if any method in the service uses raw binary framing. -func (s Service) HasRawMethods() bool { - for _, m := range s.Methods { - if m.Raw { - return true - } - } - return false -} - // Method represents a host function method within a service. type Method struct { Name string // Go method name (e.g., "Call") @@ -191,7 +181,6 @@ type Method struct { Returns []Param // Return values (excluding error) HasError bool // Whether the method returns an error Doc string // Documentation comment for the method - Raw bool // If true, response uses binary framing instead of JSON } // FunctionName returns the Extism host function export name. @@ -343,6 +332,52 @@ type Param struct { JSONName string // JSON field name (camelCase) } +// IsByteSlice returns true if the parameter type is []byte. +func (p Param) IsByteSlice() bool { + return p.Type == "[]byte" +} + +// IsByteSlice returns true if the field type is []byte. +func (f FieldDef) IsByteSlice() bool { + return f.Type == "[]byte" +} + +// HasByteFields returns true if any method params, returns, or struct fields use []byte. +func (s Service) HasByteFields() bool { + for _, m := range s.Methods { + for _, p := range m.Params { + if p.IsByteSlice() { + return true + } + } + for _, r := range m.Returns { + if r.IsByteSlice() { + return true + } + } + } + for _, st := range s.Structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + +// HasByteFields returns true if any capability struct fields use []byte. +func (c Capability) HasByteFields() bool { + for _, st := range c.Structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + // NewParam creates a Param with auto-generated JSON name. func NewParam(name, typ string) Param { return Param{ diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.py b/plugins/cmd/ndpgen/testdata/codec_client_expected.py index e1eb92501..5142ffd0e 100644 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.py +++ b/plugins/cmd/ndpgen/testdata/codec_client_expected.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -38,7 +39,7 @@ def codec_encode(data: bytes) -> bytes: HostFunctionError: If the host function returns an error. """ request = { - "data": data, + "data": base64.b64encode(data).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) @@ -49,4 +50,4 @@ def codec_encode(data: bytes) -> bytes: if response.get("error"): raise HostFunctionError(response["error"]) - return response.get("result", b"") + return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.rs b/plugins/cmd/ndpgen/testdata/codec_client_expected.rs index ff61294d0..3e229ea8a 100644 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/codec_client_expected.rs @@ -5,10 +5,34 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct CodecEncodeRequest { + #[serde(with = "base64_bytes")] data: Vec, } @@ -16,6 +40,7 @@ struct CodecEncodeRequest { #[serde(rename_all = "camelCase")] struct CodecEncodeResponse { #[serde(default)] + #[serde(with = "base64_bytes")] result: Vec, #[serde(default)] error: Option, diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py index 0fdbfb0f2..93370ddcf 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -327,7 +328,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes: HostFunctionError: If the host function returns an error. """ request = { - "data": data, + "data": base64.b64encode(data).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) @@ -338,4 +339,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes: if response.get("error"): raise HostFunctionError(response["error"]) - return response.get("result", b"") + return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs index efcc1b8ef..08dae2901 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs @@ -5,6 +5,29 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -144,6 +167,7 @@ struct ComprehensiveMultipleReturnsResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ComprehensiveByteSliceRequest { + #[serde(with = "base64_bytes")] data: Vec, } @@ -151,6 +175,7 @@ struct ComprehensiveByteSliceRequest { #[serde(rename_all = "camelCase")] struct ComprehensiveByteSliceResponse { #[serde(default)] + #[serde(with = "base64_bytes")] result: Vec, #[serde(default)] error: Option, diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt deleted file mode 100644 index 22d387041..000000000 --- a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Stream host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndpdk - -import ( - "encoding/binary" - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// stream_getstream is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user stream_getstream -func stream_getstream(uint64) uint64 - -type streamGetStreamRequest struct { - Uri string `json:"uri"` -} - -// StreamGetStream calls the stream_getstream host function. -// GetStream returns raw binary stream data with content type. -func StreamGetStream(uri string) (string, []byte, error) { - // Marshal request to JSON - req := streamGetStreamRequest{ - Uri: uri, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := stream_getstream(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse binary-framed response - if len(responseBytes) == 0 { - return "", nil, errors.New("empty response from host") - } - if responseBytes[0] == 0x01 { // error - return "", nil, errors.New(string(responseBytes[1:])) - } - if responseBytes[0] != 0x00 { - return "", nil, errors.New("unknown response status") - } - if len(responseBytes) < 5 { - return "", nil, errors.New("malformed raw response: incomplete header") - } - ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) - if uint32(len(responseBytes)) < 5+ctLen { - return "", nil, errors.New("malformed raw response: content-type overflow") - } - return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil -} diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.py b/plugins/cmd/ndpgen/testdata/raw_client_expected.py deleted file mode 100644 index 45af2b6c6..000000000 --- a/plugins/cmd/ndpgen/testdata/raw_client_expected.py +++ /dev/null @@ -1,63 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Stream host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any, Tuple - -import extism -import json -import struct - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "stream_getstream") -def _stream_getstream(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def stream_get_stream(uri: str) -> Tuple[str, bytes]: - """GetStream returns raw binary stream data with content type. - - Args: - uri: str parameter. - - Returns: - Tuple of (content_type, data) with the raw binary response. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _stream_getstream(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response_bytes = response_mem.bytes() - - if len(response_bytes) == 0: - raise HostFunctionError("empty response from host") - if response_bytes[0] == 0x01: - raise HostFunctionError(response_bytes[1:].decode("utf-8")) - if response_bytes[0] != 0x00: - raise HostFunctionError("unknown response status") - if len(response_bytes) < 5: - raise HostFunctionError("malformed raw response: incomplete header") - ct_len = struct.unpack(">I", response_bytes[1:5])[0] - if len(response_bytes) < 5 + ct_len: - raise HostFunctionError("malformed raw response: content-type overflow") - content_type = response_bytes[5:5 + ct_len].decode("utf-8") - data = response_bytes[5 + ct_len:] - return content_type, data diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.rs b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs deleted file mode 100644 index 6de18be8e..000000000 --- a/plugins/cmd/ndpgen/testdata/raw_client_expected.rs +++ /dev/null @@ -1,73 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Stream host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct StreamGetStreamRequest { - uri: String, -} - -#[host_fn] -extern "ExtismHost" { -} - -#[link(wasm_import_module = "extism:host/user")] -extern "C" { - fn stream_getstream(offset: u64) -> u64; -} - -/// GetStream returns raw binary stream data with content type. -/// -/// # Arguments -/// * `uri` - String parameter. -/// -/// # Returns -/// A tuple of (content_type, data) with the raw binary response. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_stream(uri: &str) -> Result<(String, Vec), Error> { - let req = StreamGetStreamRequest { - uri: uri.to_owned(), - }; - let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; - let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; - - let response_offset = unsafe { stream_getstream(input_mem.offset()) }; - - let response_mem = Memory::find(response_offset) - .ok_or_else(|| Error::msg("empty response from host"))?; - let response_bytes = response_mem.to_vec(); - - if response_bytes.is_empty() { - return Err(Error::msg("empty response from host")); - } - if response_bytes[0] == 0x01 { - let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); - return Err(Error::msg(msg)); - } - if response_bytes[0] != 0x00 { - return Err(Error::msg("unknown response status")); - } - if response_bytes.len() < 5 { - return Err(Error::msg("malformed raw response: incomplete header")); - } - let ct_len = u32::from_be_bytes([ - response_bytes[1], - response_bytes[2], - response_bytes[3], - response_bytes[4], - ]) as usize; - if ct_len > response_bytes.len() - 5 { - return Err(Error::msg("malformed raw response: content-type overflow")); - } - let ct_end = 5 + ct_len; - let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); - let data = response_bytes[ct_end..].to_vec(); - Ok((content_type, data)) -} diff --git a/plugins/cmd/ndpgen/testdata/raw_service.go.txt b/plugins/cmd/ndpgen/testdata/raw_service.go.txt deleted file mode 100644 index c08332f5d..000000000 --- a/plugins/cmd/ndpgen/testdata/raw_service.go.txt +++ /dev/null @@ -1,10 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Stream permission=stream -type StreamService interface { - // GetStream returns raw binary stream data with content type. - //nd:hostfunc raw=true - GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) -} diff --git a/plugins/host/subsonicapi.go b/plugins/host/subsonicapi.go index 117f8abff..32de75e77 100644 --- a/plugins/host/subsonicapi.go +++ b/plugins/host/subsonicapi.go @@ -17,8 +17,8 @@ type SubsonicAPIService interface { Call(ctx context.Context, uri string) (responseJSON string, err error) // CallRaw executes a Subsonic API request and returns the raw binary response. - // Optimized for binary endpoints like getCoverArt and stream that return - // non-JSON data. The response is returned as raw bytes without JSON encoding overhead. - //nd:hostfunc raw=true + // Designed for binary endpoints like getCoverArt and stream that return + // non-JSON data. The data is base64-encoded over JSON on the wire. + //nd:hostfunc CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) } diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go index 438c51c95..52474030e 100644 --- a/plugins/host/subsonicapi_gen.go +++ b/plugins/host/subsonicapi_gen.go @@ -4,7 +4,6 @@ package host import ( "context" - "encoding/binary" "encoding/json" extism "github.com/extism/go-sdk" @@ -26,6 +25,13 @@ type SubsonicAPICallRawRequest struct { Uri string `json:"uri"` } +// SubsonicAPICallRawResponse is the response type for SubsonicAPI.CallRaw. +type SubsonicAPICallRawResponse struct { + ContentType string `json:"contentType,omitempty"` + Data []byte `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + // RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions. // The returned host functions should be added to the plugin's configuration. func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction { @@ -76,37 +82,28 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) if err != nil { - subsonicapiWriteRawError(p, stack, err) + subsonicapiWriteError(p, stack, err) return } var req SubsonicAPICallRawRequest if err := json.Unmarshal(reqBytes, &req); err != nil { - subsonicapiWriteRawError(p, stack, err) + subsonicapiWriteError(p, stack, err) return } // Call the service method contenttype, data, svcErr := service.CallRaw(ctx, req.Uri) if svcErr != nil { - subsonicapiWriteRawError(p, stack, svcErr) + subsonicapiWriteError(p, stack, svcErr) return } - // Write binary-framed response to plugin memory: - // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] - ctBytes := []byte(contenttype) - frame := make([]byte, 1+4+len(ctBytes)+len(data)) - frame[0] = 0x00 // success - binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) - copy(frame[5:5+len(ctBytes)], ctBytes) - copy(frame[5+len(ctBytes):], data) - - respPtr, err := p.WriteBytes(frame) - if err != nil { - stack[0] = 0 - return + // Write JSON response to plugin memory + resp := SubsonicAPICallRawResponse{ + ContentType: contenttype, + Data: data, } - stack[0] = respPtr + subsonicapiWriteResponse(p, stack, resp) }, []extism.ValueType{extism.ValueTypePTR}, []extism.ValueType{extism.ValueTypePTR}, @@ -137,14 +134,3 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } - -// subsonicapiWriteRawError writes a binary-framed error response to plugin memory. -// Format: [0x01][UTF-8 error message] -func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { - errMsg := []byte(err.Error()) - frame := make([]byte, 1+len(errMsg)) - frame[0] = 0x01 // error - copy(frame[1:], errMsg) - respPtr, _ := p.WriteBytes(frame) - stack[0] = respPtr -} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi.go b/plugins/pdk/go/host/nd_host_subsonicapi.go index 9bb4f4b15..e6e56ce6b 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi.go @@ -8,7 +8,6 @@ package host import ( - "encoding/binary" "encoding/json" "errors" @@ -38,6 +37,12 @@ type subsonicAPICallRawRequest struct { Uri string `json:"uri"` } +type subsonicAPICallRawResponse struct { + ContentType string `json:"contentType,omitempty"` + Data []byte `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + // SubsonicAPICall calls the subsonicapi_call host function. // Call executes a Subsonic API request and returns the JSON response. // @@ -78,8 +83,8 @@ func SubsonicAPICall(uri string) (string, error) { // SubsonicAPICallRaw calls the subsonicapi_callraw host function. // CallRaw executes a Subsonic API request and returns the raw binary response. -// Optimized for binary endpoints like getCoverArt and stream that return -// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +// Designed for binary endpoints like getCoverArt and stream that return +// non-JSON data. The data is base64-encoded over JSON on the wire. func SubsonicAPICallRaw(uri string) (string, []byte, error) { // Marshal request to JSON req := subsonicAPICallRawRequest{ @@ -99,22 +104,16 @@ func SubsonicAPICallRaw(uri string) (string, []byte, error) { responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() - // Parse binary-framed response - if len(responseBytes) == 0 { - return "", nil, errors.New("empty response from host") + // Parse the response + var response subsonicAPICallRawResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return "", nil, err } - if responseBytes[0] == 0x01 { // error - return "", nil, errors.New(string(responseBytes[1:])) + + // Convert Error field to Go error + if response.Error != "" { + return "", nil, errors.New(response.Error) } - if responseBytes[0] != 0x00 { - return "", nil, errors.New("unknown response status") - } - if len(responseBytes) < 5 { - return "", nil, errors.New("malformed raw response: incomplete header") - } - ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) - if uint32(len(responseBytes)) < 5+ctLen { - return "", nil, errors.New("malformed raw response: content-type overflow") - } - return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil + + return response.ContentType, response.Data, nil } diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 95dd41558..2fdaf2403 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -42,8 +42,8 @@ func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { // SubsonicAPICallRaw delegates to the mock instance. // CallRaw executes a Subsonic API request and returns the raw binary response. -// Optimized for binary endpoints like getCoverArt and stream that return -// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +// Designed for binary endpoints like getCoverArt and stream that return +// non-JSON data. The data is base64-encoded over JSON on the wire. func SubsonicAPICallRaw(uri string) (string, []byte, error) { return SubsonicAPIMock.CallRaw(uri) } diff --git a/plugins/pdk/python/host/nd_host_cache.py b/plugins/pdk/python/host/nd_host_cache.py index c22f95f6f..b24e983cc 100644 --- a/plugins/pdk/python/host/nd_host_cache.py +++ b/plugins/pdk/python/host/nd_host_cache.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -337,7 +338,7 @@ Returns an error if the operation fails. """ request = { "key": key, - "value": value, + "value": base64.b64encode(value).decode("ascii"), "ttlSeconds": ttl_seconds, } request_bytes = json.dumps(request).encode("utf-8") @@ -382,7 +383,7 @@ or the stored value is not a byte slice, exists will be false. raise HostFunctionError(response["error"]) return CacheGetBytesResult( - value=response.get("value", b""), + value=base64.b64decode(response.get("value", "")), exists=response.get("exists", False), ) diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py index 8f196f985..a806c8456 100644 --- a/plugins/pdk/python/host/nd_host_http.py +++ b/plugins/pdk/python/host/nd_host_http.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py index 5485d2fb5..3c3e61f53 100644 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ b/plugins/pdk/python/host/nd_host_kvstore.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -80,7 +81,7 @@ Returns an error if the storage limit would be exceeded or the operation fails. """ request = { "key": key, - "value": value, + "value": base64.b64encode(value).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) @@ -123,7 +124,7 @@ Returns the value and whether the key exists. raise HostFunctionError(response["error"]) return KVStoreGetResult( - value=response.get("value", b""), + value=base64.b64decode(response.get("value", "")), exists=response.get("exists", False), ) diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py index 4da8da77f..cf35bc043 100644 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ b/plugins/pdk/python/host/nd_host_subsonicapi.py @@ -8,11 +8,11 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any, Tuple +from typing import Any import extism import json -import struct +import base64 class HostFunctionError(Exception): @@ -32,6 +32,13 @@ def _subsonicapi_callraw(offset: int) -> int: ... +@dataclass +class SubsonicAPICallRawResult: + """Result type for subsonicapi_call_raw.""" + content_type: str + data: bytes + + def subsonicapi_call(uri: str) -> str: """Call executes a Subsonic API request and returns the JSON response. @@ -62,16 +69,16 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. return response.get("responseJson", "") -def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]: +def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult: """CallRaw executes a Subsonic API request and returns the raw binary response. -Optimized for binary endpoints like getCoverArt and stream that return -non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +Designed for binary endpoints like getCoverArt and stream that return +non-JSON data. The data is base64-encoded over JSON on the wire. Args: uri: str parameter. Returns: - Tuple of (content_type, data) with the raw binary response. + SubsonicAPICallRawResult containing content_type, data,. Raises: HostFunctionError: If the host function returns an error. @@ -83,19 +90,12 @@ non-JSON data. The response is returned as raw bytes without JSON encoding overh request_mem = extism.memory.alloc(request_bytes) response_offset = _subsonicapi_callraw(request_mem.offset) response_mem = extism.memory.find(response_offset) - response_bytes = response_mem.bytes() + response = json.loads(extism.memory.string(response_mem)) - if len(response_bytes) == 0: - raise HostFunctionError("empty response from host") - if response_bytes[0] == 0x01: - raise HostFunctionError(response_bytes[1:].decode("utf-8")) - if response_bytes[0] != 0x00: - raise HostFunctionError("unknown response status") - if len(response_bytes) < 5: - raise HostFunctionError("malformed raw response: incomplete header") - ct_len = struct.unpack(">I", response_bytes[1:5])[0] - if len(response_bytes) < 5 + ct_len: - raise HostFunctionError("malformed raw response: content-type overflow") - content_type = response_bytes[5:5 + ct_len].decode("utf-8") - data = response_bytes[5 + ct_len:] - return content_type, data + if response.get("error"): + raise HostFunctionError(response["error"]) + + return SubsonicAPICallRawResult( + content_type=response.get("contentType", ""), + data=base64.b64decode(response.get("data", "")), + ) diff --git a/plugins/pdk/python/host/nd_host_websocket.py b/plugins/pdk/python/host/nd_host_websocket.py index b62ee792c..4e882914c 100644 --- a/plugins/pdk/python/host/nd_host_websocket.py +++ b/plugins/pdk/python/host/nd_host_websocket.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -134,7 +135,7 @@ Returns an error if the connection is not found or if sending fails. """ request = { "connectionId": connection_id, - "data": data, + "data": base64.b64encode(data).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml index 98a91da1f..443f19da5 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" crate-type = ["rlib"] [dependencies] +base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml index 4cb828697..519096110 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] +base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs index 1f3d69295..267654136 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs @@ -5,6 +5,29 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -106,6 +129,7 @@ struct CacheGetFloatResponse { #[serde(rename_all = "camelCase")] struct CacheSetBytesRequest { key: String, + #[serde(with = "base64_bytes")] value: Vec, ttl_seconds: i64, } @@ -127,6 +151,7 @@ struct CacheGetBytesRequest { #[serde(rename_all = "camelCase")] struct CacheGetBytesResponse { #[serde(default)] + #[serde(with = "base64_bytes")] value: Vec, #[serde(default)] exists: bool, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index b8aaad1e0..d3bb2d326 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -5,6 +5,29 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} /// HTTPRequest represents an outbound HTTP request from a plugin. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -15,6 +38,7 @@ pub struct HTTPRequest { #[serde(default)] pub headers: std::collections::HashMap, #[serde(default)] + #[serde(with = "base64_bytes")] pub body: Vec, #[serde(default)] pub timeout_ms: i32, @@ -28,6 +52,7 @@ pub struct HTTPResponse { #[serde(default)] pub headers: std::collections::HashMap, #[serde(default)] + #[serde(with = "base64_bytes")] pub body: Vec, } diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs index 5048f369c..20fe18c6f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs @@ -5,11 +5,35 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct KVStoreSetRequest { key: String, + #[serde(with = "base64_bytes")] value: Vec, } @@ -30,6 +54,7 @@ struct KVStoreGetRequest { #[serde(rename_all = "camelCase")] struct KVStoreGetResponse { #[serde(default)] + #[serde(with = "base64_bytes")] value: Vec, #[serde(default)] exists: bool, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs index 2c9e6545f..56ba1066e 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs @@ -5,6 +5,29 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -27,14 +50,22 @@ struct SubsonicAPICallRawRequest { uri: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAPICallRawResponse { + #[serde(default)] + content_type: String, + #[serde(default)] + #[serde(with = "base64_bytes")] + data: Vec, + #[serde(default)] + error: Option, +} + #[host_fn] extern "ExtismHost" { fn subsonicapi_call(input: Json) -> Json; -} - -#[link(wasm_import_module = "extism:host/user")] -extern "C" { - fn subsonicapi_callraw(offset: u64) -> u64; + fn subsonicapi_callraw(input: Json) -> Json; } /// Call executes a Subsonic API request and returns the JSON response. @@ -65,54 +96,27 @@ pub fn call(uri: &str) -> Result { } /// CallRaw executes a Subsonic API request and returns the raw binary response. -/// Optimized for binary endpoints like getCoverArt and stream that return -/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +/// Designed for binary endpoints like getCoverArt and stream that return +/// non-JSON data. The data is base64-encoded over JSON on the wire. /// /// # Arguments /// * `uri` - String parameter. /// /// # Returns -/// A tuple of (content_type, data) with the raw binary response. +/// A tuple of (content_type, data). /// /// # Errors /// Returns an error if the host function call fails. pub fn call_raw(uri: &str) -> Result<(String, Vec), Error> { - let req = SubsonicAPICallRawRequest { - uri: uri.to_owned(), + let response = unsafe { + subsonicapi_callraw(Json(SubsonicAPICallRawRequest { + uri: uri.to_owned(), + }))? }; - let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; - let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; - let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) }; + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } - let response_mem = Memory::find(response_offset) - .ok_or_else(|| Error::msg("empty response from host"))?; - let response_bytes = response_mem.to_vec(); - - if response_bytes.is_empty() { - return Err(Error::msg("empty response from host")); - } - if response_bytes[0] == 0x01 { - let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); - return Err(Error::msg(msg)); - } - if response_bytes[0] != 0x00 { - return Err(Error::msg("unknown response status")); - } - if response_bytes.len() < 5 { - return Err(Error::msg("malformed raw response: incomplete header")); - } - let ct_len = u32::from_be_bytes([ - response_bytes[1], - response_bytes[2], - response_bytes[3], - response_bytes[4], - ]) as usize; - if ct_len > response_bytes.len() - 5 { - return Err(Error::msg("malformed raw response: content-type overflow")); - } - let ct_end = 5 + ct_len; - let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); - let data = response_bytes[ct_end..].to_vec(); - Ok((content_type, data)) + Ok((response.0.content_type, response.0.data)) } diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs index 58a399028..05ceb5407 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs @@ -5,6 +5,29 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -41,6 +64,7 @@ struct WebSocketSendTextResponse { #[serde(rename_all = "camelCase")] struct WebSocketSendBinaryRequest { connection_id: String, + #[serde(with = "base64_bytes")] data: Vec, } From d134de106189b888f7d57f63e311f0b50f5be12b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 28 Feb 2026 10:55:19 -0500 Subject: [PATCH 032/596] feat(server): add 'has_rating' filter to artist and mediafile repositories Signed-off-by: Deluan --- persistence/artist_repository.go | 1 + persistence/mediafile_repository.go | 1 + 2 files changed, 2 insertions(+) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 07824e21f..7f3d61540 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -134,6 +134,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi "id": idFilter(r.tableName), "name": fullTextFilter(r.tableName, "mbz_artist_id"), "starred": annotationBoolFilter("starred"), + "has_rating": annotationBoolFilter("rating"), "role": roleFilter, "missing": booleanFilter, "library_id": artistLibraryIdFilter, diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 394ca5b70..9034fa8f8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -98,6 +98,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "id": idFilter("media_file"), "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": annotationBoolFilter("starred"), + "has_rating": annotationBoolFilter("rating"), "genre_id": tagIDFilter, "missing": booleanFilter, "artists_id": artistFilter, From d9a215e1e3184ffd0ad85b2d4ddec250d661ab4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 28 Feb 2026 10:59:13 -0500 Subject: [PATCH 033/596] feat(plugins): allow mounting library directories as read-write (#5122) * feat(plugins): mount library directories as read-only by default Add an AllowWriteAccess boolean to the plugin model, defaulting to false. When off, library directories are mounted with the extism "ro:" prefix (read-only). Admins can explicitly grant write access via a new toggle in the Library Permission card. * test: add tests to buildAllowedPaths Signed-off-by: Deluan * chore: improve allowed paths logging for library access Signed-off-by: Deluan --------- Signed-off-by: Deluan --- ...28020813_add_plugin_allow_write_access.sql | 5 ++ model/plugin.go | 27 ++++---- persistence/plugin_repository.go | 32 +++++----- plugins/manager.go | 3 +- plugins/manager_loader.go | 63 +++++++++++------- plugins/manager_loader_test.go | 64 +++++++++++++++++++ resources/i18n/pt-br.json | 4 +- server/nativeapi/native_api.go | 2 +- server/nativeapi/plugin.go | 21 +++--- tests/mock_plugin_manager.go | 22 ++++--- ui/src/i18n/en.json | 4 +- ui/src/plugin/LibraryPermissionCard.jsx | 30 +++++++++ ui/src/plugin/PluginShow.jsx | 27 +++++++- 13 files changed, 229 insertions(+), 75 deletions(-) create mode 100644 db/migrations/20260228020813_add_plugin_allow_write_access.sql diff --git a/db/migrations/20260228020813_add_plugin_allow_write_access.sql b/db/migrations/20260228020813_add_plugin_allow_write_access.sql new file mode 100644 index 000000000..e17d874a5 --- /dev/null +++ b/db/migrations/20260228020813_add_plugin_allow_write_access.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false; + +-- +goose Down +ALTER TABLE plugin DROP COLUMN allow_write_access; diff --git a/model/plugin.go b/model/plugin.go index d23103995..f4bad6783 100644 --- a/model/plugin.go +++ b/model/plugin.go @@ -3,19 +3,20 @@ package model import "time" type Plugin struct { - ID string `structs:"id" json:"id"` - Path string `structs:"path" json:"path"` - Manifest string `structs:"manifest" json:"manifest"` - Config string `structs:"config" json:"config,omitempty"` - Users string `structs:"users" json:"users,omitempty"` - AllUsers bool `structs:"all_users" json:"allUsers,omitempty"` - Libraries string `structs:"libraries" json:"libraries,omitempty"` - AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"` - Enabled bool `structs:"enabled" json:"enabled"` - LastError string `structs:"last_error" json:"lastError,omitempty"` - SHA256 string `structs:"sha256" json:"sha256"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ID string `structs:"id" json:"id"` + Path string `structs:"path" json:"path"` + Manifest string `structs:"manifest" json:"manifest"` + Config string `structs:"config" json:"config,omitempty"` + Users string `structs:"users" json:"users,omitempty"` + AllUsers bool `structs:"all_users" json:"allUsers,omitempty"` + Libraries string `structs:"libraries" json:"libraries,omitempty"` + AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"` + AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"` + Enabled bool `structs:"enabled" json:"enabled"` + LastError string `structs:"last_error" json:"lastError,omitempty"` + SHA256 string `structs:"sha256" json:"sha256"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` } type Plugins []Plugin diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 4a98f148b..466abb40b 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -79,8 +79,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error { // Upsert using INSERT ... ON CONFLICT for atomic operation _, err := r.db.NewQuery(` - INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at) - VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) + INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at) + VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) ON CONFLICT(id) DO UPDATE SET path = excluded.path, manifest = excluded.manifest, @@ -89,24 +89,26 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error { all_users = excluded.all_users, libraries = excluded.libraries, all_libraries = excluded.all_libraries, + allow_write_access = excluded.allow_write_access, enabled = excluded.enabled, last_error = excluded.last_error, sha256 = excluded.sha256, updated_at = excluded.updated_at `).Bind(dbx.Params{ - "id": plugin.ID, - "path": plugin.Path, - "manifest": plugin.Manifest, - "config": plugin.Config, - "users": plugin.Users, - "all_users": plugin.AllUsers, - "libraries": plugin.Libraries, - "all_libraries": plugin.AllLibraries, - "enabled": plugin.Enabled, - "last_error": plugin.LastError, - "sha256": plugin.SHA256, - "created_at": time.Now(), - "updated_at": plugin.UpdatedAt, + "id": plugin.ID, + "path": plugin.Path, + "manifest": plugin.Manifest, + "config": plugin.Config, + "users": plugin.Users, + "all_users": plugin.AllUsers, + "libraries": plugin.Libraries, + "all_libraries": plugin.AllLibraries, + "allow_write_access": plugin.AllowWriteAccess, + "enabled": plugin.Enabled, + "last_error": plugin.LastError, + "sha256": plugin.SHA256, + "created_at": time.Now(), + "updated_at": plugin.UpdatedAt, }).Execute() return err } diff --git a/plugins/manager.go b/plugins/manager.go index d8d4f28ef..f148a706c 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -428,10 +428,11 @@ func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, a // If the plugin is enabled, it will be reloaded with the new settings. // If the plugin requires library permission and no libraries are configured (and allLibraries is false), // the plugin will be automatically disabled. -func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error { +func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error { return m.updatePluginSettings(ctx, id, func(p *model.Plugin) { p.Libraries = librariesJSON p.AllLibraries = allLibraries + p.AllowWriteAccess = allowWriteAccess }) } diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index c6355911f..688c4519c 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -226,6 +226,8 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { // loadPluginWithConfig loads a plugin with configuration from DB. // The p.Path should point to an .ndp package file. func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { + ctx := log.NewContext(m.ctx, "plugin", p.ID) + if m.stopped.Load() { return fmt.Errorf("manager is stopped") } @@ -283,27 +285,13 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { // Configure filesystem access for library permission if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem { - adminCtx := adminContext(m.ctx) + adminCtx := adminContext(ctx) libraries, err := m.ds.Library(adminCtx).GetAll() if err != nil { return fmt.Errorf("failed to get libraries for filesystem access: %w", err) } - // Build a set of allowed library IDs for fast lookup - allowedLibrarySet := make(map[int]struct{}, len(allowedLibraries)) - for _, id := range allowedLibraries { - allowedLibrarySet[id] = struct{}{} - } - - allowedPaths := make(map[string]string) - for _, lib := range libraries { - // Only mount if allLibraries is true or library is in the allowed list - if p.AllLibraries { - allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID)) - } else if _, ok := allowedLibrarySet[lib.ID]; ok { - allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID)) - } - } + allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) pluginManifest.AllowedPaths = allowedPaths } @@ -339,7 +327,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { // Enable experimental threads if requested in manifest if pkg.Manifest.HasExperimentalThreads() { runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) - log.Debug(m.ctx, "Enabling experimental threads support", "plugin", p.ID) + log.Debug(ctx, "Enabling experimental threads support") } extismConfig := extism.PluginConfig{ @@ -347,24 +335,24 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { RuntimeConfig: runtimeConfig, EnableHttpResponseHeaders: true, } - compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions) + compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions) if err != nil { return fmt.Errorf("compiling plugin: %w", err) } // Create instance to detect capabilities - instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{}) + instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{}) if err != nil { - compiled.Close(m.ctx) + compiled.Close(ctx) return fmt.Errorf("creating instance: %w", err) } instance.SetLogger(extismLogger(p.ID)) capabilities := detectCapabilities(instance) - instance.Close(m.ctx) + instance.Close(ctx) // Validate manifest against detected capabilities if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil { - compiled.Close(m.ctx) + compiled.Close(ctx) return fmt.Errorf("manifest validation: %w", err) } @@ -383,7 +371,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { m.mu.Unlock() // Call plugin init function - callPluginInit(m.ctx, m.plugins[p.ID]) + callPluginInit(ctx, m.plugins[p.ID]) return nil } @@ -414,3 +402,32 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { } return pluginConfig, nil } + +// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access. +// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only. +// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included. +func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string { + allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) + for _, id := range allowedLibraryIDs { + allowedLibrarySet[id] = struct{}{} + } + allowedPaths := make(map[string]string) + for _, lib := range libraries { + _, allowed := allowedLibrarySet[lib.ID] + if allLibraries || allowed { + mountPoint := toPluginMountPoint(int32(lib.ID)) + hostPath := lib.Path + if !allowWriteAccess { + hostPath = "ro:" + hostPath + } + allowedPaths[hostPath] = mountPoint + log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath) + } + } + if allowWriteAccess { + log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) + } else { + log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) + } + return allowedPaths +} diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 64bc5e810..3a00b07b7 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -3,6 +3,7 @@ package plugins import ( + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -58,3 +59,66 @@ var _ = Describe("parsePluginConfig", func() { Expect(result).ToNot(BeNil()) }) }) + +var _ = Describe("buildAllowedPaths", func() { + var libraries model.Libraries + + BeforeEach(func() { + libraries = model.Libraries{ + {ID: 1, Path: "/music/library1"}, + {ID: 2, Path: "/music/library2"}, + {ID: 3, Path: "/music/library3"}, + } + }) + + Context("read-only (default)", func() { + It("mounts all libraries with ro: prefix when allLibraries is true", func() { + result := buildAllowedPaths(nil, libraries, nil, true, false) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) + Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2")) + Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) + }) + + It("mounts only selected libraries with ro: prefix", func() { + result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false) + Expect(result).To(HaveLen(2)) + Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) + Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) + Expect(result).ToNot(HaveKey("ro:/music/library2")) + }) + }) + + Context("read-write (allowWriteAccess=true)", func() { + It("mounts all libraries without ro: prefix when allLibraries is true", func() { + result := buildAllowedPaths(nil, libraries, nil, true, true) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1")) + Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) + Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3")) + }) + + It("mounts only selected libraries without ro: prefix", func() { + result := buildAllowedPaths(nil, libraries, []int{2}, false, true) + Expect(result).To(HaveLen(1)) + Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) + }) + }) + + Context("edge cases", func() { + It("returns empty map when no libraries match", func() { + result := buildAllowedPaths(nil, libraries, []int{99}, false, false) + Expect(result).To(BeEmpty()) + }) + + It("returns empty map when libraries list is empty", func() { + result := buildAllowedPaths(nil, nil, []int{1}, false, false) + Expect(result).To(BeEmpty()) + }) + + It("returns empty map when allLibraries is false and no IDs provided", func() { + result := buildAllowedPaths(nil, libraries, nil, false, false) + Expect(result).To(BeEmpty()) + }) + }) +}) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index a844dccdb..1fc72bf30 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -353,7 +353,8 @@ "allUsers": "Permitir todos os usuários", "selectedUsers": "Usuários selecionados", "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Bibliotecas selecionadas" + "selectedLibraries": "Bibliotecas selecionadas", + "allowWriteAccess": "Permitir acesso de escrita" }, "sections": { "status": "Status", @@ -396,6 +397,7 @@ "allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.", "noLibraries": "Nenhuma biblioteca selecionada", "librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.", + "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", "requiredHosts": "Hosts necessários", "configValidationError": "Falha na validação da configuração:", "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido." diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 27b85a605..062cbf706 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -29,7 +29,7 @@ type PluginManager interface { ValidatePluginConfig(ctx context.Context, id, configJSON string) error UpdatePluginConfig(ctx context.Context, id, configJSON string) error UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error - UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error + UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error RescanPlugins(ctx context.Context) error UnloadDisabledPlugins(ctx context.Context) } diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index e733edc4b..a7d261681 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -56,12 +56,13 @@ func pluginsEnabledMiddleware(next http.Handler) http.Handler { // PluginUpdateRequest represents the fields that can be updated via the API type PluginUpdateRequest struct { - Enabled *bool `json:"enabled,omitempty"` - Config *string `json:"config,omitempty"` - Users *string `json:"users,omitempty"` - AllUsers *bool `json:"allUsers,omitempty"` - Libraries *string `json:"libraries,omitempty"` - AllLibraries *bool `json:"allLibraries,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config *string `json:"config,omitempty"` + Users *string `json:"users,omitempty"` + AllUsers *bool `json:"allUsers,omitempty"` + Libraries *string `json:"libraries,omitempty"` + AllLibraries *bool `json:"allLibraries,omitempty"` + AllowWriteAccess *bool `json:"allowWriteAccess,omitempty"` } func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { @@ -109,7 +110,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { } // Handle libraries permission update (if provided) - if req.Libraries != nil || req.AllLibraries != nil { + if req.Libraries != nil || req.AllLibraries != nil || req.AllowWriteAccess != nil { if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil { log.Error(ctx, "Error updating plugin libraries", err) return @@ -245,6 +246,7 @@ func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo mode librariesJSON := plugin.Libraries allLibraries := plugin.AllLibraries + allowWriteAccess := plugin.AllowWriteAccess if req.Libraries != nil { if *req.Libraries != "" && !isValidJSON(*req.Libraries) { @@ -256,8 +258,11 @@ func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo mode if req.AllLibraries != nil { allLibraries = *req.AllLibraries } + if req.AllowWriteAccess != nil { + allowWriteAccess = *req.AllowWriteAccess + } - if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries); err != nil { + if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries, allowWriteAccess); err != nil { log.Error(ctx, "Error updating plugin libraries", "id", id, err) http.Error(w, "Error updating plugin libraries: "+err.Error(), http.StatusInternalServerError) return err diff --git a/tests/mock_plugin_manager.go b/tests/mock_plugin_manager.go index 9691f7a38..05375f31c 100644 --- a/tests/mock_plugin_manager.go +++ b/tests/mock_plugin_manager.go @@ -18,7 +18,7 @@ type MockPluginManager struct { // UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError. UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error // UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError. - UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error + UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error // RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError. RescanPluginsFn func(ctx context.Context) error @@ -48,9 +48,10 @@ type MockPluginManager struct { AllUsers bool } UpdatePluginLibrariesCalls []struct { - ID string - LibrariesJSON string - AllLibraries bool + ID string + LibrariesJSON string + AllLibraries bool + AllowWriteAccess bool } RescanPluginsCalls int } @@ -105,14 +106,15 @@ func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON return m.UsersError } -func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error { +func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error { m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct { - ID string - LibrariesJSON string - AllLibraries bool - }{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries}) + ID string + LibrariesJSON string + AllLibraries bool + AllowWriteAccess bool + }{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries, AllowWriteAccess: allowWriteAccess}) if m.UpdatePluginLibrariesFn != nil { - return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries) + return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries, allowWriteAccess) } return m.LibrariesError } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 678abaabd..224b1c437 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -355,7 +355,8 @@ "allUsers": "Allow all users", "selectedUsers": "Selected users", "allLibraries": "Allow all libraries", - "selectedLibraries": "Selected libraries" + "selectedLibraries": "Selected libraries", + "allowWriteAccess": "Allow write access" }, "sections": { "status": "Status", @@ -400,6 +401,7 @@ "allLibrariesHelp": "When enabled, the plugin will have access to all libraries, including those created in the future.", "noLibraries": "No libraries selected", "librariesRequired": "This plugin requires access to library information. Select which libraries the plugin can access, or enable 'Allow all libraries'.", + "allowWriteAccessHelp": "When enabled, the plugin can modify files in the library directories. By default, plugins have read-only access.", "requiredHosts": "Required hosts" }, "placeholders": { diff --git a/ui/src/plugin/LibraryPermissionCard.jsx b/ui/src/plugin/LibraryPermissionCard.jsx index 885ac010b..d3c237279 100644 --- a/ui/src/plugin/LibraryPermissionCard.jsx +++ b/ui/src/plugin/LibraryPermissionCard.jsx @@ -23,8 +23,10 @@ export const LibraryPermissionCard = ({ classes, selectedLibraries, allLibraries, + allowWriteAccess, onSelectedLibrariesChange, onAllLibrariesChange, + onAllowWriteAccessChange, }) => { const translate = useTranslate() @@ -58,9 +60,17 @@ export const LibraryPermissionCard = ({ [onAllLibrariesChange], ) + const handleAllowWriteAccessToggle = React.useCallback( + (event) => { + onAllowWriteAccessChange(event.target.checked) + }, + [onAllowWriteAccessChange], + ) + // Get permission reason from manifest const libraryPermission = manifest?.permissions?.library const reason = libraryPermission?.reason + const hasFilesystem = libraryPermission?.filesystem === true // Check if permission is required but not configured const isConfigurationRequired = @@ -107,6 +117,24 @@ export const LibraryPermissionCard = ({ + {hasFilesystem && ( + + + } + label={translate('resources.plugin.fields.allowWriteAccess')} + /> + + {translate('resources.plugin.messages.allowWriteAccessHelp')} + + + )} + {!allLibraries && ( @@ -166,6 +194,8 @@ LibraryPermissionCard.propTypes = { classes: PropTypes.object.isRequired, selectedLibraries: PropTypes.array.isRequired, allLibraries: PropTypes.bool.isRequired, + allowWriteAccess: PropTypes.bool.isRequired, onSelectedLibrariesChange: PropTypes.func.isRequired, onAllLibrariesChange: PropTypes.func.isRequired, + onAllowWriteAccessChange: PropTypes.func.isRequired, } diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 38e858af4..caea44a75 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -48,8 +48,11 @@ const PluginShowLayout = () => { // Libraries permission state const [selectedLibraries, setSelectedLibraries] = useState([]) const [allLibraries, setAllLibraries] = useState(false) + const [allowWriteAccess, setAllowWriteAccess] = useState(false) const [lastRecordLibraries, setLastRecordLibraries] = useState(null) const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null) + const [lastRecordAllowWriteAccess, setLastRecordAllowWriteAccess] = + useState(null) // Parse JSON config to object const jsonToObject = useCallback((jsonString) => { @@ -99,10 +102,12 @@ const PluginShowLayout = () => { if (record && !isDirty) { const recordLibraries = record.libraries || '' const recordAllLibraries = record.allLibraries || false + const recordAllowWriteAccess = record.allowWriteAccess || false if ( recordLibraries !== lastRecordLibraries || - recordAllLibraries !== lastRecordAllLibraries + recordAllLibraries !== lastRecordAllLibraries || + recordAllowWriteAccess !== lastRecordAllowWriteAccess ) { try { setSelectedLibraries( @@ -112,11 +117,19 @@ const PluginShowLayout = () => { setSelectedLibraries([]) } setAllLibraries(recordAllLibraries) + setAllowWriteAccess(recordAllowWriteAccess) setLastRecordLibraries(recordLibraries) setLastRecordAllLibraries(recordAllLibraries) + setLastRecordAllowWriteAccess(recordAllowWriteAccess) } } - }, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty]) + }, [ + record, + lastRecordLibraries, + lastRecordAllLibraries, + lastRecordAllowWriteAccess, + isDirty, + ]) const handleConfigDataChange = useCallback( (newData, errors) => { @@ -152,6 +165,11 @@ const PluginShowLayout = () => { setIsDirty(true) }, []) + const handleAllowWriteAccessChange = useCallback((newAllowWriteAccess) => { + setAllowWriteAccess(newAllowWriteAccess) + setIsDirty(true) + }, []) + const [updatePlugin, { loading }] = useUpdate( 'plugin', record?.id, @@ -167,6 +185,7 @@ const PluginShowLayout = () => { setLastRecordAllUsers(null) setLastRecordLibraries(null) setLastRecordAllLibraries(null) + setLastRecordAllowWriteAccess(null) notify('resources.plugin.notifications.updated', 'info') }, onFailure: (err) => { @@ -199,6 +218,7 @@ const PluginShowLayout = () => { if (parsedManifest?.permissions?.library) { data.libraries = JSON.stringify(selectedLibraries) data.allLibraries = allLibraries + data.allowWriteAccess = allowWriteAccess } updatePlugin('plugin', record.id, data, record) @@ -210,6 +230,7 @@ const PluginShowLayout = () => { allUsers, selectedLibraries, allLibraries, + allowWriteAccess, ]) // Parse manifest @@ -294,8 +315,10 @@ const PluginShowLayout = () => { classes={classes} selectedLibraries={selectedLibraries} allLibraries={allLibraries} + allowWriteAccess={allowWriteAccess} onSelectedLibrariesChange={handleSelectedLibrariesChange} onAllLibrariesChange={handleAllLibrariesChange} + onAllowWriteAccessChange={handleAllowWriteAccessChange} /> From 2471bb9cf60227704e37d13c3b78c66fadf2f99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 28 Feb 2026 23:12:17 -0500 Subject: [PATCH 034/596] feat(plugins): add TTL support, batch operations, and hardening to kvstore (#5127) * feat(plugins): add expires_at column to kvstore schema * feat(plugins): filter expired keys in kvstore Get, Has, List * feat(plugins): add periodic cleanup of expired kvstore keys * feat(plugins): add SetWithTTL, DeleteByPrefix, and GetMany to kvstore Add three new methods to the KVStore host service: - SetWithTTL: store key-value pairs with automatic expiration - DeleteByPrefix: remove all keys matching a prefix in one operation - GetMany: retrieve multiple values in a single call All methods include comprehensive unit tests covering edge cases, expiration behavior, size tracking, and LIKE-special characters. * feat(plugins): regenerate code and update test plugin for new kvstore methods Regenerate host function wrappers and PDK bindings for Go, Python, and Rust. Update the test-kvstore plugin to exercise SetWithTTL, DeleteByPrefix, and GetMany. * feat(plugins): add integration tests for new kvstore methods Add WASM integration tests for SetWithTTL, DeleteByPrefix, and GetMany operations through the plugin boundary, verifying end-to-end behavior including TTL expiration, prefix deletion, and batch retrieval. * fix(plugins): address lint issues in kvstore implementation Handle tx.Rollback error return and suppress gosec false positive for parameterized SQL query construction in GetMany. * fix(plugins): Set clears expires_at when overwriting a TTL'd key Previously, calling Set() on a key that was stored with SetWithTTL() would leave the expires_at value intact, causing the key to silently expire even though Set implies permanent storage. Also excludes expired keys from currentSize calculation at startup. * refactor(plugins): simplify kvstore by removing in-memory size cache Replaced the in-memory currentSize cache (atomic.Int64), periodic cleanup timer, and mutex with direct database queries for storage accounting. This eliminates race conditions and cache drift issues at negligible performance cost for plugin-sized datasets. Also unified Set and SetWithTTL into a shared setValue method, simplified DeleteByPrefix to use RowsAffected instead of a transaction, and added an index on expires_at for efficient expiration filtering. * feat(plugins): add generic SQLite migration helper and refactor kvstore schema Add a reusable migrateDB helper that tracks schema versions via SQLite's PRAGMA user_version and applies pending migrations transactionally. Replace the ad-hoc createKVStoreSchema function in kvstore with a declarative migrations slice, making it easy to add future schema changes. Remove the now-redundant schema migration test since migrateDB has its own test suite and every kvstore test exercises the migrations implicitly. Signed-off-by: Deluan * fix(plugins): harden kvstore with explicit NULL handling, prefix validation, and cleanup timeout - Use sql.NullString for expires_at to explicitly send NULL instead of relying on datetime('now', '') returning NULL by accident - Reject empty prefix in DeleteByPrefix to prevent accidental data wipe - Add 5s timeout context to cleanupExpired on Close - Replace time.Sleep in unit tests with pre-expired timestamps Signed-off-by: Deluan * refactor(plugins): use batch processing in GetMany Process keys in chunks of 200 using slice.CollectChunks to avoid hitting SQLite's SQLITE_MAX_VARIABLE_NUMBER limit with large key sets. * feat(plugins): add periodic cleanup goroutine for expired kvstore keys Use the manager's context to control a background goroutine that purges expired keys every hour, stopping naturally on shutdown when the context is cancelled. --------- Signed-off-by: Deluan --- plugins/host/kvstore.go | 41 +- plugins/host/kvstore_gen.go | 160 ++++++- plugins/host_kvstore.go | 260 ++++++++--- plugins/host_kvstore_test.go | 435 +++++++++++++++++- plugins/manager_loader.go | 2 +- plugins/migrate.go | 47 ++ plugins/migrate_test.go | 99 ++++ plugins/pdk/go/host/nd_host_kvstore.go | 208 ++++++++- plugins/pdk/go/host/nd_host_kvstore_stub.go | 77 +++- plugins/pdk/python/host/nd_host_kvstore.py | 138 +++++- .../rust/nd-pdk-host/src/nd_host_kvstore.rs | 167 ++++++- plugins/testdata/test-kvstore/main.go | 52 ++- 12 files changed, 1528 insertions(+), 158 deletions(-) create mode 100644 plugins/migrate.go create mode 100644 plugins/migrate_test.go diff --git a/plugins/host/kvstore.go b/plugins/host/kvstore.go index 4d9dafd20..aa2597f27 100644 --- a/plugins/host/kvstore.go +++ b/plugins/host/kvstore.go @@ -23,6 +23,20 @@ type KVStoreService interface { //nd:hostfunc Set(ctx context.Context, key string, value []byte) error + // SetWithTTL stores a byte value with the given key and a time-to-live. + // + // After ttlSeconds, the key is treated as non-existent and will be + // cleaned up lazily. ttlSeconds must be greater than 0. + // + // Parameters: + // - key: The storage key (max 256 bytes, UTF-8) + // - value: The byte slice to store + // - ttlSeconds: Time-to-live in seconds (must be > 0) + // + // Returns an error if the storage limit would be exceeded or the operation fails. + //nd:hostfunc + SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error + // Get retrieves a byte value from storage. // // Parameters: @@ -32,14 +46,15 @@ type KVStoreService interface { //nd:hostfunc Get(ctx context.Context, key string) (value []byte, exists bool, err error) - // Delete removes a value from storage. + // GetMany retrieves multiple values in a single call. // // Parameters: - // - key: The storage key + // - keys: The storage keys to retrieve // - // Returns an error if the operation fails. Does not return an error if the key doesn't exist. + // Returns a map of key to value for keys that exist and have not expired. + // Missing or expired keys are omitted from the result. //nd:hostfunc - Delete(ctx context.Context, key string) error + GetMany(ctx context.Context, keys []string) (values map[string][]byte, err error) // Has checks if a key exists in storage. // @@ -59,6 +74,24 @@ type KVStoreService interface { //nd:hostfunc List(ctx context.Context, prefix string) (keys []string, err error) + // Delete removes a value from storage. + // + // Parameters: + // - key: The storage key + // + // Returns an error if the operation fails. Does not return an error if the key doesn't exist. + //nd:hostfunc + Delete(ctx context.Context, key string) error + + // DeleteByPrefix removes all keys matching the given prefix. + // + // Parameters: + // - prefix: Key prefix to match (must not be empty) + // + // Returns the number of keys deleted. Includes expired keys. + //nd:hostfunc + DeleteByPrefix(ctx context.Context, prefix string) (deletedCount int64, err error) + // GetStorageUsed returns the total storage used by this plugin in bytes. //nd:hostfunc GetStorageUsed(ctx context.Context) (bytes int64, err error) diff --git a/plugins/host/kvstore_gen.go b/plugins/host/kvstore_gen.go index 2ad24959d..44ee3b131 100644 --- a/plugins/host/kvstore_gen.go +++ b/plugins/host/kvstore_gen.go @@ -20,6 +20,18 @@ type KVStoreSetResponse struct { Error string `json:"error,omitempty"` } +// KVStoreSetWithTTLRequest is the request type for KVStore.SetWithTTL. +type KVStoreSetWithTTLRequest struct { + Key string `json:"key"` + Value []byte `json:"value"` + TtlSeconds int64 `json:"ttlSeconds"` +} + +// KVStoreSetWithTTLResponse is the response type for KVStore.SetWithTTL. +type KVStoreSetWithTTLResponse struct { + Error string `json:"error,omitempty"` +} + // KVStoreGetRequest is the request type for KVStore.Get. type KVStoreGetRequest struct { Key string `json:"key"` @@ -32,14 +44,15 @@ type KVStoreGetResponse struct { Error string `json:"error,omitempty"` } -// KVStoreDeleteRequest is the request type for KVStore.Delete. -type KVStoreDeleteRequest struct { - Key string `json:"key"` +// KVStoreGetManyRequest is the request type for KVStore.GetMany. +type KVStoreGetManyRequest struct { + Keys []string `json:"keys"` } -// KVStoreDeleteResponse is the response type for KVStore.Delete. -type KVStoreDeleteResponse struct { - Error string `json:"error,omitempty"` +// KVStoreGetManyResponse is the response type for KVStore.GetMany. +type KVStoreGetManyResponse struct { + Values map[string][]byte `json:"values,omitempty"` + Error string `json:"error,omitempty"` } // KVStoreHasRequest is the request type for KVStore.Has. @@ -64,6 +77,27 @@ type KVStoreListResponse struct { Error string `json:"error,omitempty"` } +// KVStoreDeleteRequest is the request type for KVStore.Delete. +type KVStoreDeleteRequest struct { + Key string `json:"key"` +} + +// KVStoreDeleteResponse is the response type for KVStore.Delete. +type KVStoreDeleteResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreDeleteByPrefixRequest is the request type for KVStore.DeleteByPrefix. +type KVStoreDeleteByPrefixRequest struct { + Prefix string `json:"prefix"` +} + +// KVStoreDeleteByPrefixResponse is the response type for KVStore.DeleteByPrefix. +type KVStoreDeleteByPrefixResponse struct { + DeletedCount int64 `json:"deletedCount,omitempty"` + Error string `json:"error,omitempty"` +} + // KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed. type KVStoreGetStorageUsedResponse struct { Bytes int64 `json:"bytes,omitempty"` @@ -75,10 +109,13 @@ type KVStoreGetStorageUsedResponse struct { func RegisterKVStoreHostFunctions(service KVStoreService) []extism.HostFunction { return []extism.HostFunction{ newKVStoreSetHostFunction(service), + newKVStoreSetWithTTLHostFunction(service), newKVStoreGetHostFunction(service), - newKVStoreDeleteHostFunction(service), + newKVStoreGetManyHostFunction(service), newKVStoreHasHostFunction(service), newKVStoreListHostFunction(service), + newKVStoreDeleteHostFunction(service), + newKVStoreDeleteByPrefixHostFunction(service), newKVStoreGetStorageUsedHostFunction(service), } } @@ -114,6 +151,37 @@ func newKVStoreSetHostFunction(service KVStoreService) extism.HostFunction { ) } +func newKVStoreSetWithTTLHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_setwithttl", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreSetWithTTLRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + if svcErr := service.SetWithTTL(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreSetWithTTLResponse{} + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction { return extism.NewHostFunctionWithStack( "kvstore_get", @@ -149,9 +217,9 @@ func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction { ) } -func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction { +func newKVStoreGetManyHostFunction(service KVStoreService) extism.HostFunction { return extism.NewHostFunctionWithStack( - "kvstore_delete", + "kvstore_getmany", func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) @@ -159,20 +227,23 @@ func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction { kvstoreWriteError(p, stack, err) return } - var req KVStoreDeleteRequest + var req KVStoreGetManyRequest if err := json.Unmarshal(reqBytes, &req); err != nil { kvstoreWriteError(p, stack, err) return } // Call the service method - if svcErr := service.Delete(ctx, req.Key); svcErr != nil { + values, svcErr := service.GetMany(ctx, req.Keys) + if svcErr != nil { kvstoreWriteError(p, stack, svcErr) return } // Write JSON response to plugin memory - resp := KVStoreDeleteResponse{} + resp := KVStoreGetManyResponse{ + Values: values, + } kvstoreWriteResponse(p, stack, resp) }, []extism.ValueType{extism.ValueTypePTR}, @@ -248,6 +319,71 @@ func newKVStoreListHostFunction(service KVStoreService) extism.HostFunction { ) } +func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_delete", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreDeleteRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + if svcErr := service.Delete(ctx, req.Key); svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreDeleteResponse{} + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newKVStoreDeleteByPrefixHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_deletebyprefix", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreDeleteByPrefixRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + deletedcount, svcErr := service.DeleteByPrefix(ctx, req.Prefix) + if svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreDeleteByPrefixResponse{ + DeletedCount: deletedcount, + } + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + func newKVStoreGetStorageUsedHostFunction(service KVStoreService) extism.HostFunction { return extism.NewHostFunctionWithStack( "kvstore_getstorageused", diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index 53d4da922..9aa37f7e5 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -7,14 +7,16 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" - "sync/atomic" + "time" "github.com/dustin/go-humanize" _ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/utils/slice" ) const ( @@ -22,17 +24,22 @@ const ( maxKeyLength = 256 // Max key length in bytes ) +// notExpiredFilter is the SQL condition to exclude expired keys. +const notExpiredFilter = "(expires_at IS NULL OR expires_at > datetime('now'))" + +const cleanupInterval = 1 * time.Hour + // kvstoreServiceImpl implements the host.KVStoreService interface. // Each plugin gets its own SQLite database for isolation. type kvstoreServiceImpl struct { - pluginName string - db *sql.DB - maxSize int64 - currentSize atomic.Int64 // cached total size, updated on Set/Delete + pluginName string + db *sql.DB + maxSize int64 } // newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database. -func newKVStoreService(pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) { +// The provided context controls the lifetime of the background cleanup goroutine. +func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) { // Parse max size from permission, default to 1MB maxSize := int64(defaultMaxKVStoreSize) if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" { @@ -59,46 +66,69 @@ func newKVStoreService(pluginName string, perm *KVStorePermission) (*kvstoreServ db.SetMaxOpenConns(3) db.SetMaxIdleConns(1) - // Create schema + // Apply schema migrations if err := createKVStoreSchema(db); err != nil { db.Close() - return nil, fmt.Errorf("creating kvstore schema: %w", err) + return nil, fmt.Errorf("migrating kvstore schema: %w", err) } - // Load current storage size from database - var currentSize int64 - if err := db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM kvstore`).Scan(¤tSize); err != nil { - db.Close() - return nil, fmt.Errorf("loading storage size: %w", err) - } - - log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)), "currentSize", humanize.Bytes(uint64(currentSize))) + log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize))) svc := &kvstoreServiceImpl{ pluginName: pluginName, db: db, maxSize: maxSize, } - svc.currentSize.Store(currentSize) + go svc.cleanupLoop(ctx) return svc, nil } +// createKVStoreSchema applies schema migrations to the kvstore database. +// New migrations must be appended at the end of the slice. func createKVStoreSchema(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS kvstore ( + return migrateDB(db, []string{ + `CREATE TABLE IF NOT EXISTS kvstore ( key TEXT PRIMARY KEY NOT NULL, value BLOB NOT NULL, size INTEGER NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - return err + )`, + `ALTER TABLE kvstore ADD COLUMN expires_at DATETIME DEFAULT NULL`, + `CREATE INDEX idx_kvstore_expires_at ON kvstore(expires_at)`, + }) } -// Set stores a byte value with the given key. -func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error { - // Validate key +// storageUsed returns the current total storage used by non-expired keys. +func (s *kvstoreServiceImpl) storageUsed(ctx context.Context) (int64, error) { + var used int64 + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size), 0) FROM kvstore WHERE `+notExpiredFilter).Scan(&used) + if err != nil { + return 0, fmt.Errorf("calculating storage used: %w", err) + } + return used, nil +} + +// checkStorageLimit verifies that adding delta bytes would not exceed the storage limit. +func (s *kvstoreServiceImpl) checkStorageLimit(ctx context.Context, delta int64) error { + if delta <= 0 { + return nil + } + used, err := s.storageUsed(ctx) + if err != nil { + return err + } + newTotal := used + delta + if newTotal > s.maxSize { + return fmt.Errorf("storage limit exceeded: would use %s of %s allowed", + humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize))) + } + return nil +} + +// setValue is the shared implementation for Set and SetWithTTL. +// A ttlSeconds of 0 means no expiration. +func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []byte, ttlSeconds int64) error { if len(key) == 0 { return fmt.Errorf("key cannot be empty") } @@ -108,46 +138,59 @@ func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) newValueSize := int64(len(value)) - // Get current size of this key (if it exists) to calculate delta + // Get current size of this key (if it exists and not expired) to calculate delta var oldSize int64 - err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ?`, key).Scan(&oldSize) + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&oldSize) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("checking existing key: %w", err) } - // Check size limits using cached total - delta := newValueSize - oldSize - newTotal := s.currentSize.Load() + delta - if newTotal > s.maxSize { - return fmt.Errorf("storage limit exceeded: would use %s of %s allowed", - humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize))) + if err := s.checkStorageLimit(ctx, newValueSize-oldSize); err != nil { + return err + } + + // Compute expires_at: sql.NullString{Valid:false} sends NULL (no expiration), + // otherwise we send a concrete timestamp. + var expiresAt sql.NullString + if ttlSeconds > 0 { + expiresAt = sql.NullString{String: fmt.Sprintf("+%d seconds", ttlSeconds), Valid: true} } - // Upsert the value _, err = s.db.ExecContext(ctx, ` - INSERT INTO kvstore (key, value, size, created_at, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT(key) DO UPDATE SET - value = excluded.value, + INSERT INTO kvstore (key, value, size, created_at, updated_at, expires_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, datetime('now', ?)) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, size = excluded.size, - updated_at = CURRENT_TIMESTAMP - `, key, value, newValueSize) + updated_at = CURRENT_TIMESTAMP, + expires_at = excluded.expires_at + `, key, value, newValueSize, expiresAt) if err != nil { return fmt.Errorf("storing value: %w", err) } - // Update cached size - s.currentSize.Add(delta) - - log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize) + log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize, "ttlSeconds", ttlSeconds) return nil } +// Set stores a byte value with the given key. +func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error { + return s.setValue(ctx, key, value, 0) +} + +// SetWithTTL stores a byte value with the given key and a time-to-live. +func (s *kvstoreServiceImpl) SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error { + if ttlSeconds <= 0 { + return fmt.Errorf("ttlSeconds must be greater than 0") + } + return s.setValue(ctx, key, value, ttlSeconds) +} + // Get retrieves a byte value from storage. func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) { var value []byte - err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ?`, key).Scan(&value) - if err == sql.ErrNoRows { + err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { return nil, false, nil } if err != nil { @@ -160,25 +203,11 @@ func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, // Delete removes a value from storage. func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error { - // Get size of the key being deleted to update cache - var oldSize int64 - err := s.db.QueryRowContext(ctx, `SELECT size FROM kvstore WHERE key = ?`, key).Scan(&oldSize) - if errors.Is(err, sql.ErrNoRows) { - // Key doesn't exist, nothing to delete - return nil - } - if err != nil { - return fmt.Errorf("checking key size: %w", err) - } - - _, err = s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key) + _, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key) if err != nil { return fmt.Errorf("deleting value: %w", err) } - // Update cached size - s.currentSize.Add(-oldSize) - log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key) return nil } @@ -186,7 +215,7 @@ func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error { // Has checks if a key exists in storage. func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) { var count int - err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ?`, key).Scan(&count) + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&count) if err != nil { return false, fmt.Errorf("checking key: %w", err) } @@ -200,12 +229,12 @@ func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string, var err error if prefix == "" { - rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore ORDER BY key`) + rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE `+notExpiredFilter+` ORDER BY key`) } else { // Escape special LIKE characters in prefix escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%") escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_") - rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' ORDER BY key`, escapedPrefix+"%") + rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' AND `+notExpiredFilter+` ORDER BY key`, escapedPrefix+"%") } if err != nil { return nil, fmt.Errorf("listing keys: %w", err) @@ -231,16 +260,113 @@ func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string, // GetStorageUsed returns the total storage used by this plugin in bytes. func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) { - used := s.currentSize.Load() + used, err := s.storageUsed(ctx) + if err != nil { + return 0, err + } log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used) return used, nil } -// Close closes the SQLite database connection. -// This is called when the plugin is unloaded. +// DeleteByPrefix removes all keys matching the given prefix. +func (s *kvstoreServiceImpl) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) { + if prefix == "" { + return 0, fmt.Errorf("prefix cannot be empty") + } + + escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%") + escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_") + result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key LIKE ? ESCAPE '\'`, escapedPrefix+"%") + if err != nil { + return 0, fmt.Errorf("deleting keys: %w", err) + } + + count, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("getting deleted count: %w", err) + } + + log.Trace(ctx, "KVStore.DeleteByPrefix", "plugin", s.pluginName, "prefix", prefix, "deletedCount", count) + return count, nil +} + +// GetMany retrieves multiple values in a single call, processing keys in batches. +func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) { + if len(keys) == 0 { + return map[string][]byte{}, nil + } + + const batchSize = 200 + result := make(map[string][]byte) + for chunk := range slice.CollectChunks(slices.Values(keys), batchSize) { + placeholders := make([]string, len(chunk)) + args := make([]any, len(chunk)) + for i, key := range chunk { + placeholders[i] = "?" + args[i] = key + } + + query := `SELECT key, value FROM kvstore WHERE key IN (` + strings.Join(placeholders, ",") + `) AND ` + notExpiredFilter //nolint:gosec // placeholders are always "?" + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("querying values: %w", err) + } + + for rows.Next() { + var key string + var value []byte + if err := rows.Scan(&key, &value); err != nil { + rows.Close() + return nil, fmt.Errorf("scanning value: %w", err) + } + result[key] = value + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("iterating values: %w", err) + } + rows.Close() + } + + log.Trace(ctx, "KVStore.GetMany", "plugin", s.pluginName, "requested", len(keys), "found", len(result)) + return result, nil +} + +// cleanupLoop periodically removes expired keys from the database. +// It stops when the provided context is cancelled. +func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.cleanupExpired(ctx) + } + } +} + +// cleanupExpired removes all expired keys from the database to reclaim disk space. +func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) { + result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')`) + if err != nil { + log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err) + return + } + if count, err := result.RowsAffected(); err == nil && count > 0 { + log.Debug("KVStore cleanup completed", "plugin", s.pluginName, "deletedKeys", count) + } +} + +// Close runs a final cleanup and closes the SQLite database connection. +// The cleanup goroutine is stopped by the context passed to newKVStoreService. func (s *kvstoreServiceImpl) Close() error { if s.db != nil { log.Debug("Closing plugin kvstore", "plugin", s.pluginName) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s.cleanupExpired(ctx) return s.db.Close() } return nil diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 3e2cbd01a..b900a659a 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -37,7 +38,7 @@ var _ = Describe("KVStoreService", func() { // Create service with 1KB limit for testing maxSize := "1KB" - service, err = newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) Expect(err).ToNot(HaveOccurred()) }) @@ -253,7 +254,7 @@ var _ = Describe("KVStoreService", func() { Expect(service.Close()).To(Succeed()) maxSize := "1KB" - service2, err := newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -302,7 +303,7 @@ var _ = Describe("KVStoreService", func() { Describe("Plugin Isolation", func() { It("isolates data between plugins", func() { - service2, err := newKVStoreService("other_plugin", &KVStorePermission{}) + service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -321,7 +322,7 @@ var _ = Describe("KVStoreService", func() { }) It("creates separate database files per plugin", func() { - service2, err := newKVStoreService("other_plugin", &KVStorePermission{}) + service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -343,6 +344,309 @@ var _ = Describe("KVStoreService", func() { Expect(err).To(HaveOccurred()) }) }) + + Describe("TTL Expiration", func() { + It("Get returns not-exists for expired keys", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('expired_key', 'old', 3, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + value, exists, err := service.Get(ctx, "expired_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(BeNil()) + }) + It("Has returns false for expired keys", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('expired_has', 'old', 3, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + exists, err := service.Has(ctx, "expired_has") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + It("List excludes expired keys", func() { + Expect(service.Set(ctx, "live:1", []byte("alive"))).To(Succeed()) + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('live:expired', 'dead', 4, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + keys, err := service.List(ctx, "live:") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(HaveLen(1)) + Expect(keys).To(ContainElement("live:1")) + }) + It("Get returns value for non-expired keys with TTL", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('future_key', 'still alive', 11, datetime('now', '+3600 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + value, exists, err := service.Get(ctx, "future_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal([]byte("still alive"))) + }) + It("Set clears expires_at from a key previously set with TTL", func() { + // Insert a key with a TTL that has already expired + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('ttl_then_set', 'temp', 4, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + // Overwrite with Set (no TTL) — should become permanent + err = service.Set(ctx, "ttl_then_set", []byte("permanent")) + Expect(err).ToNot(HaveOccurred()) + + // Should exist because Set cleared expires_at + value, exists, err := service.Get(ctx, "ttl_then_set") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal([]byte("permanent"))) + + // Verify expires_at is actually NULL + var expiresAt *string + Expect(service.db.QueryRow(`SELECT expires_at FROM kvstore WHERE key = 'ttl_then_set'`).Scan(&expiresAt)).To(Succeed()) + Expect(expiresAt).To(BeNil()) + }) + It("expired keys are not counted in storage used", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('expired_key', '12345', 5, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + // Expired keys should not be counted + used, err := service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(0))) + }) + It("cleanup removes expired rows from disk", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('cleanup_me', '12345', 5, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + // Row exists in DB but is logically expired + var count int + Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed()) + Expect(count).To(Equal(1)) + + service.cleanupExpired(ctx) + + // Row should be physically deleted + Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed()) + Expect(count).To(Equal(0)) + }) + }) + + Describe("SetWithTTL", func() { + It("stores value that is retrievable before expiry", func() { + err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.Get(ctx, "ttl_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal([]byte("ttl_value"))) + }) + + It("value is not retrievable after expiry", func() { + // Insert a key with an already-expired TTL + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('short_ttl', 'gone_soon', 9, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + _, exists, err := service.Get(ctx, "short_ttl") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("rejects ttlSeconds <= 0", func() { + err := service.SetWithTTL(ctx, "bad_ttl", []byte("value"), 0) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0")) + + err = service.SetWithTTL(ctx, "bad_ttl", []byte("value"), -5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0")) + }) + + It("validates key same as Set", func() { + err := service.SetWithTTL(ctx, "", []byte("value"), 60) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key cannot be empty")) + }) + + It("enforces size limits same as Set", func() { + bigValue := make([]byte, 2048) + err := service.SetWithTTL(ctx, "big_ttl", bigValue, 60) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("storage limit exceeded")) + }) + + It("overwrites existing key and updates TTL", func() { + // Insert a key with an already-expired TTL + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('overwrite_ttl', 'first', 5, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + // Overwrite with a long TTL — should be retrievable + err = service.SetWithTTL(ctx, "overwrite_ttl", []byte("second"), 3600) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.Get(ctx, "overwrite_ttl") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal([]byte("second"))) + }) + + It("tracks storage correctly", func() { + err := service.SetWithTTL(ctx, "sized_ttl", []byte("12345"), 3600) + Expect(err).ToNot(HaveOccurred()) + + used, err := service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(5))) + }) + }) + + Describe("DeleteByPrefix", func() { + BeforeEach(func() { + Expect(service.Set(ctx, "cache:user:1", []byte("Alice"))).To(Succeed()) + Expect(service.Set(ctx, "cache:user:2", []byte("Bob"))).To(Succeed()) + Expect(service.Set(ctx, "cache:item:1", []byte("Widget"))).To(Succeed()) + Expect(service.Set(ctx, "data:important", []byte("keep"))).To(Succeed()) + }) + + It("deletes all keys with the given prefix", func() { + deleted, err := service.DeleteByPrefix(ctx, "cache:user:") + Expect(err).ToNot(HaveOccurred()) + Expect(deleted).To(Equal(int64(2))) + + keys, err := service.List(ctx, "") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(HaveLen(2)) + Expect(keys).To(ContainElements("cache:item:1", "data:important")) + }) + + It("rejects empty prefix", func() { + _, err := service.DeleteByPrefix(ctx, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("prefix cannot be empty")) + }) + + It("returns 0 when no keys match", func() { + deleted, err := service.DeleteByPrefix(ctx, "nonexistent:") + Expect(err).ToNot(HaveOccurred()) + Expect(deleted).To(Equal(int64(0))) + }) + + It("updates storage size correctly", func() { + usedBefore, _ := service.GetStorageUsed(ctx) + Expect(usedBefore).To(BeNumerically(">", 0)) + + _, err := service.DeleteByPrefix(ctx, "cache:") + Expect(err).ToNot(HaveOccurred()) + + usedAfter, _ := service.GetStorageUsed(ctx) + Expect(usedAfter).To(Equal(int64(4))) + }) + + It("handles special LIKE characters in prefix", func() { + Expect(service.Set(ctx, "test%special", []byte("v1"))).To(Succeed()) + Expect(service.Set(ctx, "test_special", []byte("v2"))).To(Succeed()) + Expect(service.Set(ctx, "testXspecial", []byte("v3"))).To(Succeed()) + + deleted, err := service.DeleteByPrefix(ctx, "test%") + Expect(err).ToNot(HaveOccurred()) + Expect(deleted).To(Equal(int64(1))) + + exists, _ := service.Has(ctx, "test_special") + Expect(exists).To(BeTrue()) + exists, _ = service.Has(ctx, "testXspecial") + Expect(exists).To(BeTrue()) + }) + + It("also deletes expired keys matching prefix", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('cache:expired', 'old', 3, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + deleted, err := service.DeleteByPrefix(ctx, "cache:") + Expect(err).ToNot(HaveOccurred()) + Expect(deleted).To(Equal(int64(4))) + }) + }) + + Describe("GetMany", func() { + BeforeEach(func() { + Expect(service.Set(ctx, "key1", []byte("value1"))).To(Succeed()) + Expect(service.Set(ctx, "key2", []byte("value2"))).To(Succeed()) + Expect(service.Set(ctx, "key3", []byte("value3"))).To(Succeed()) + }) + + It("retrieves multiple values at once", func() { + values, err := service.GetMany(ctx, []string{"key1", "key2", "key3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(values).To(HaveLen(3)) + Expect(values["key1"]).To(Equal([]byte("value1"))) + Expect(values["key2"]).To(Equal([]byte("value2"))) + Expect(values["key3"]).To(Equal([]byte("value3"))) + }) + + It("omits missing keys from result", func() { + values, err := service.GetMany(ctx, []string{"key1", "missing", "key3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(values).To(HaveLen(2)) + Expect(values["key1"]).To(Equal([]byte("value1"))) + Expect(values["key3"]).To(Equal([]byte("value3"))) + _, hasMissing := values["missing"] + Expect(hasMissing).To(BeFalse()) + }) + + It("returns empty map for empty keys slice", func() { + values, err := service.GetMany(ctx, []string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(values).To(BeEmpty()) + }) + + It("returns empty map for nil keys slice", func() { + values, err := service.GetMany(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(values).To(BeEmpty()) + }) + + It("excludes expired keys", func() { + _, err := service.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('expired_many', 'old', 3, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + values, err := service.GetMany(ctx, []string{"key1", "expired_many"}) + Expect(err).ToNot(HaveOccurred()) + Expect(values).To(HaveLen(1)) + Expect(values["key1"]).To(Equal([]byte("value1"))) + }) + + It("handles all keys missing", func() { + values, err := service.GetMany(ctx, []string{"nope1", "nope2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(values).To(BeEmpty()) + }) + }) }) var _ = Describe("KVStoreService Integration", Ordered, func() { @@ -416,17 +720,21 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { Describe("KVStore Operations via Plugin", func() { type testKVStoreInput struct { - Operation string `json:"operation"` - Key string `json:"key"` - Value []byte `json:"value,omitempty"` - Prefix string `json:"prefix,omitempty"` + Operation string `json:"operation"` + Key string `json:"key"` + Value []byte `json:"value,omitempty"` + Prefix string `json:"prefix,omitempty"` + TTLSeconds int64 `json:"ttl_seconds,omitempty"` + Keys []string `json:"keys,omitempty"` } type testKVStoreOutput struct { - Value []byte `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Keys []string `json:"keys,omitempty"` - StorageUsed int64 `json:"storage_used,omitempty"` - Error *string `json:"error,omitempty"` + Value []byte `json:"value,omitempty"` + Values map[string][]byte `json:"values,omitempty"` + Exists bool `json:"exists,omitempty"` + Keys []string `json:"keys,omitempty"` + StorageUsed int64 `json:"storage_used,omitempty"` + DeletedCount int64 `json:"deleted_count,omitempty"` + Error *string `json:"error,omitempty"` } callTestKVStore := func(ctx context.Context, input testKVStoreInput) (*testKVStoreOutput, error) { @@ -594,6 +902,107 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { Expect(output.Exists).To(BeTrue()) Expect(output.Value).To(Equal(binaryData)) }) + + It("should set value with TTL and expire it", func() { + ctx := GinkgoT().Context() + + // Set value with 1 second TTL + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set_with_ttl", + Key: "ttl_key", + Value: []byte("temporary"), + TTLSeconds: 1, + }) + Expect(err).ToNot(HaveOccurred()) + + // Immediately should exist + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "get", + Key: "ttl_key", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.Value).To(Equal([]byte("temporary"))) + + // Wait for expiration + time.Sleep(2 * time.Second) + + // Should no longer exist + output, err = callTestKVStore(ctx, testKVStoreInput{ + Operation: "get", + Key: "ttl_key", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + + It("should delete keys by prefix", func() { + ctx := GinkgoT().Context() + + // Set multiple keys with shared prefix + for _, key := range []string{"del_prefix:a", "del_prefix:b", "keep:c"} { + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set", + Key: key, + Value: []byte("value"), + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Delete by prefix + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "delete_by_prefix", + Prefix: "del_prefix:", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.DeletedCount).To(Equal(int64(2))) + + // Verify remaining key + getOutput, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "has", + Key: "keep:c", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(getOutput.Exists).To(BeTrue()) + + // Verify deleted keys are gone + getOutput, err = callTestKVStore(ctx, testKVStoreInput{ + Operation: "has", + Key: "del_prefix:a", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(getOutput.Exists).To(BeFalse()) + }) + + It("should get many values at once", func() { + ctx := GinkgoT().Context() + + // Set multiple keys + for _, kv := range []struct{ k, v string }{ + {"many:1", "val1"}, + {"many:2", "val2"}, + {"many:3", "val3"}, + } { + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set", + Key: kv.k, + Value: []byte(kv.v), + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Get many, including a missing key + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "get_many", + Keys: []string{"many:1", "many:3", "many:missing"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Values).To(HaveLen(2)) + Expect(output.Values["many:1"]).To(Equal([]byte("val1"))) + Expect(output.Values["many:3"]).To(Equal([]byte("val3"))) + _, hasMissing := output.Values["many:missing"] + Expect(hasMissing).To(BeFalse()) + }) }) Describe("Database Isolation", func() { diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 688c4519c..610dbd028 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -103,7 +103,7 @@ var hostServices = []hostServiceEntry{ hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Kvstore - service, err := newKVStoreService(ctx.pluginName, perm) + service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm) if err != nil { log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err) return nil, nil diff --git a/plugins/migrate.go b/plugins/migrate.go new file mode 100644 index 000000000..332e34838 --- /dev/null +++ b/plugins/migrate.go @@ -0,0 +1,47 @@ +package plugins + +import ( + "database/sql" + "fmt" +) + +// migrateDB applies schema migrations to a SQLite database. +// +// Each entry in migrations is a single SQL statement. The current schema version +// is tracked using SQLite's built-in PRAGMA user_version. Only statements after +// the current version are executed, within a single transaction. +func migrateDB(db *sql.DB, migrations []string) error { + var version int + if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { + return fmt.Errorf("reading schema version: %w", err) + } + + if version >= len(migrations) { + return nil + } + + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("starting migration transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + for i := version; i < len(migrations); i++ { + if _, err := tx.Exec(migrations[i]); err != nil { + return fmt.Errorf("migration %d failed: %w", i+1, err) + } + } + + // PRAGMA statements cannot be executed inside a transaction in some SQLite + // drivers, but with mattn/go-sqlite3 this works. We set it inside the tx + // so that a failed commit leaves the version unchanged. + if _, err := tx.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, len(migrations))); err != nil { + return fmt.Errorf("updating schema version: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing migrations: %w", err) + } + + return nil +} diff --git a/plugins/migrate_test.go b/plugins/migrate_test.go new file mode 100644 index 000000000..17ed43c5c --- /dev/null +++ b/plugins/migrate_test.go @@ -0,0 +1,99 @@ +//go:build !windows + +package plugins + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("migrateDB", func() { + var db *sql.DB + + BeforeEach(func() { + var err error + db, err = sql.Open("sqlite3", ":memory:") + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + if db != nil { + db.Close() + } + }) + + getUserVersion := func() int { + var version int + Expect(db.QueryRow(`PRAGMA user_version`).Scan(&version)).To(Succeed()) + return version + } + + It("applies all migrations on a fresh database", func() { + migrations := []string{ + `CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, + `ALTER TABLE test ADD COLUMN email TEXT`, + } + + Expect(migrateDB(db, migrations)).To(Succeed()) + Expect(getUserVersion()).To(Equal(2)) + + // Verify schema + _, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`) + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips already applied migrations", func() { + migrations1 := []string{ + `CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, + } + Expect(migrateDB(db, migrations1)).To(Succeed()) + Expect(getUserVersion()).To(Equal(1)) + + // Add a new migration + migrations2 := []string{ + `CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, + `ALTER TABLE test ADD COLUMN email TEXT`, + } + Expect(migrateDB(db, migrations2)).To(Succeed()) + Expect(getUserVersion()).To(Equal(2)) + + // Verify the new column exists + _, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`) + Expect(err).ToNot(HaveOccurred()) + }) + + It("is a no-op when all migrations are applied", func() { + migrations := []string{ + `CREATE TABLE test (id INTEGER PRIMARY KEY)`, + } + Expect(migrateDB(db, migrations)).To(Succeed()) + Expect(migrateDB(db, migrations)).To(Succeed()) + Expect(getUserVersion()).To(Equal(1)) + }) + + It("is a no-op with empty migrations slice", func() { + Expect(migrateDB(db, nil)).To(Succeed()) + Expect(getUserVersion()).To(Equal(0)) + }) + + It("rolls back on failure", func() { + migrations := []string{ + `CREATE TABLE test (id INTEGER PRIMARY KEY)`, + `INVALID SQL STATEMENT`, + } + + err := migrateDB(db, migrations) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("migration 2 failed")) + + // Version should remain 0 (rolled back) + Expect(getUserVersion()).To(Equal(0)) + + // Table should not exist (rolled back) + _, err = db.Exec(`INSERT INTO test (id) VALUES (1)`) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/plugins/pdk/go/host/nd_host_kvstore.go b/plugins/pdk/go/host/nd_host_kvstore.go index 92ac9d772..15e1e366a 100644 --- a/plugins/pdk/go/host/nd_host_kvstore.go +++ b/plugins/pdk/go/host/nd_host_kvstore.go @@ -19,15 +19,20 @@ import ( //go:wasmimport extism:host/user kvstore_set func kvstore_set(uint64) uint64 +// kvstore_setwithttl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_setwithttl +func kvstore_setwithttl(uint64) uint64 + // kvstore_get is the host function provided by Navidrome. // //go:wasmimport extism:host/user kvstore_get func kvstore_get(uint64) uint64 -// kvstore_delete is the host function provided by Navidrome. +// kvstore_getmany is the host function provided by Navidrome. // -//go:wasmimport extism:host/user kvstore_delete -func kvstore_delete(uint64) uint64 +//go:wasmimport extism:host/user kvstore_getmany +func kvstore_getmany(uint64) uint64 // kvstore_has is the host function provided by Navidrome. // @@ -39,6 +44,16 @@ func kvstore_has(uint64) uint64 //go:wasmimport extism:host/user kvstore_list func kvstore_list(uint64) uint64 +// kvstore_delete is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_delete +func kvstore_delete(uint64) uint64 + +// kvstore_deletebyprefix is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_deletebyprefix +func kvstore_deletebyprefix(uint64) uint64 + // kvstore_getstorageused is the host function provided by Navidrome. // //go:wasmimport extism:host/user kvstore_getstorageused @@ -49,6 +64,12 @@ type kVStoreSetRequest struct { Value []byte `json:"value"` } +type kVStoreSetWithTTLRequest struct { + Key string `json:"key"` + Value []byte `json:"value"` + TtlSeconds int64 `json:"ttlSeconds"` +} + type kVStoreGetRequest struct { Key string `json:"key"` } @@ -59,8 +80,13 @@ type kVStoreGetResponse struct { Error string `json:"error,omitempty"` } -type kVStoreDeleteRequest struct { - Key string `json:"key"` +type kVStoreGetManyRequest struct { + Keys []string `json:"keys"` +} + +type kVStoreGetManyResponse struct { + Values map[string][]byte `json:"values,omitempty"` + Error string `json:"error,omitempty"` } type kVStoreHasRequest struct { @@ -81,6 +107,19 @@ type kVStoreListResponse struct { Error string `json:"error,omitempty"` } +type kVStoreDeleteRequest struct { + Key string `json:"key"` +} + +type kVStoreDeleteByPrefixRequest struct { + Prefix string `json:"prefix"` +} + +type kVStoreDeleteByPrefixResponse struct { + DeletedCount int64 `json:"deletedCount,omitempty"` + Error string `json:"error,omitempty"` +} + type kVStoreGetStorageUsedResponse struct { Bytes int64 `json:"bytes,omitempty"` Error string `json:"error,omitempty"` @@ -127,6 +166,52 @@ func KVStoreSet(key string, value []byte) error { return nil } +// KVStoreSetWithTTL calls the kvstore_setwithttl host function. +// SetWithTTL stores a byte value with the given key and a time-to-live. +// +// After ttlSeconds, the key is treated as non-existent and will be +// cleaned up lazily. ttlSeconds must be greater than 0. +// +// Parameters: +// - key: The storage key (max 256 bytes, UTF-8) +// - value: The byte slice to store +// - ttlSeconds: Time-to-live in seconds (must be > 0) +// +// Returns an error if the storage limit would be exceeded or the operation fails. +func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { + // Marshal request to JSON + req := kVStoreSetWithTTLRequest{ + Key: key, + Value: value, + TtlSeconds: ttlSeconds, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_setwithttl(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse error-only response + var response struct { + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(responseBytes, &response); err != nil { + return err + } + if response.Error != "" { + return errors.New(response.Error) + } + return nil +} + // KVStoreGet calls the kvstore_get host function. // Get retrieves a byte value from storage. // @@ -167,43 +252,45 @@ func KVStoreGet(key string) ([]byte, bool, error) { return response.Value, response.Exists, nil } -// KVStoreDelete calls the kvstore_delete host function. -// Delete removes a value from storage. +// KVStoreGetMany calls the kvstore_getmany host function. +// GetMany retrieves multiple values in a single call. // // Parameters: -// - key: The storage key +// - keys: The storage keys to retrieve // -// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -func KVStoreDelete(key string) error { +// Returns a map of key to value for keys that exist and have not expired. +// Missing or expired keys are omitted from the result. +func KVStoreGetMany(keys []string) (map[string][]byte, error) { // Marshal request to JSON - req := kVStoreDeleteRequest{ - Key: key, + req := kVStoreGetManyRequest{ + Keys: keys, } reqBytes, err := json.Marshal(req) if err != nil { - return err + return nil, err } reqMem := pdk.AllocateBytes(reqBytes) defer reqMem.Free() // Call the host function - responsePtr := kvstore_delete(reqMem.Offset()) + responsePtr := kvstore_getmany(reqMem.Offset()) // Read the response from memory responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } + // Parse the response + var response kVStoreGetManyResponse if err := json.Unmarshal(responseBytes, &response); err != nil { - return err + return nil, err } + + // Convert Error field to Go error if response.Error != "" { - return errors.New(response.Error) + return nil, errors.New(response.Error) } - return nil + + return response.Values, nil } // KVStoreHas calls the kvstore_has host function. @@ -286,6 +373,85 @@ func KVStoreList(prefix string) ([]string, error) { return response.Keys, nil } +// KVStoreDelete calls the kvstore_delete host function. +// Delete removes a value from storage. +// +// Parameters: +// - key: The storage key +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func KVStoreDelete(key string) error { + // Marshal request to JSON + req := kVStoreDeleteRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_delete(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse error-only response + var response struct { + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(responseBytes, &response); err != nil { + return err + } + if response.Error != "" { + return errors.New(response.Error) + } + return nil +} + +// KVStoreDeleteByPrefix calls the kvstore_deletebyprefix host function. +// DeleteByPrefix removes all keys matching the given prefix. +// +// Parameters: +// - prefix: Key prefix to match (must not be empty) +// +// Returns the number of keys deleted. Includes expired keys. +func KVStoreDeleteByPrefix(prefix string) (int64, error) { + // Marshal request to JSON + req := kVStoreDeleteByPrefixRequest{ + Prefix: prefix, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_deletebyprefix(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response kVStoreDeleteByPrefixResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.DeletedCount, nil +} + // KVStoreGetStorageUsed calls the kvstore_getstorageused host function. // GetStorageUsed returns the total storage used by this plugin in bytes. func KVStoreGetStorageUsed() (int64, error) { diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index 1b3ff1e8d..83b55d3a8 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -37,6 +37,28 @@ func KVStoreSet(key string, value []byte) error { return KVStoreMock.Set(key, value) } +// SetWithTTL is the mock method for KVStoreSetWithTTL. +func (m *mockKVStoreService) SetWithTTL(key string, value []byte, ttlSeconds int64) error { + args := m.Called(key, value, ttlSeconds) + return args.Error(0) +} + +// KVStoreSetWithTTL delegates to the mock instance. +// SetWithTTL stores a byte value with the given key and a time-to-live. +// +// After ttlSeconds, the key is treated as non-existent and will be +// cleaned up lazily. ttlSeconds must be greater than 0. +// +// Parameters: +// - key: The storage key (max 256 bytes, UTF-8) +// - value: The byte slice to store +// - ttlSeconds: Time-to-live in seconds (must be > 0) +// +// Returns an error if the storage limit would be exceeded or the operation fails. +func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { + return KVStoreMock.SetWithTTL(key, value, ttlSeconds) +} + // Get is the mock method for KVStoreGet. func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) { args := m.Called(key) @@ -54,21 +76,22 @@ func KVStoreGet(key string) ([]byte, bool, error) { return KVStoreMock.Get(key) } -// Delete is the mock method for KVStoreDelete. -func (m *mockKVStoreService) Delete(key string) error { - args := m.Called(key) - return args.Error(0) +// GetMany is the mock method for KVStoreGetMany. +func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) { + args := m.Called(keys) + return args.Get(0).(map[string][]byte), args.Error(1) } -// KVStoreDelete delegates to the mock instance. -// Delete removes a value from storage. +// KVStoreGetMany delegates to the mock instance. +// GetMany retrieves multiple values in a single call. // // Parameters: -// - key: The storage key +// - keys: The storage keys to retrieve // -// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -func KVStoreDelete(key string) error { - return KVStoreMock.Delete(key) +// Returns a map of key to value for keys that exist and have not expired. +// Missing or expired keys are omitted from the result. +func KVStoreGetMany(keys []string) (map[string][]byte, error) { + return KVStoreMock.GetMany(keys) } // Has is the mock method for KVStoreHas. @@ -105,6 +128,40 @@ func KVStoreList(prefix string) ([]string, error) { return KVStoreMock.List(prefix) } +// Delete is the mock method for KVStoreDelete. +func (m *mockKVStoreService) Delete(key string) error { + args := m.Called(key) + return args.Error(0) +} + +// KVStoreDelete delegates to the mock instance. +// Delete removes a value from storage. +// +// Parameters: +// - key: The storage key +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func KVStoreDelete(key string) error { + return KVStoreMock.Delete(key) +} + +// DeleteByPrefix is the mock method for KVStoreDeleteByPrefix. +func (m *mockKVStoreService) DeleteByPrefix(prefix string) (int64, error) { + args := m.Called(prefix) + return args.Get(0).(int64), args.Error(1) +} + +// KVStoreDeleteByPrefix delegates to the mock instance. +// DeleteByPrefix removes all keys matching the given prefix. +// +// Parameters: +// - prefix: Key prefix to match (must not be empty) +// +// Returns the number of keys deleted. Includes expired keys. +func KVStoreDeleteByPrefix(prefix string) (int64, error) { + return KVStoreMock.DeleteByPrefix(prefix) +} + // GetStorageUsed is the mock method for KVStoreGetStorageUsed. func (m *mockKVStoreService) GetStorageUsed() (int64, error) { args := m.Called() diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py index 3c3e61f53..33eaffc52 100644 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ b/plugins/pdk/python/host/nd_host_kvstore.py @@ -26,14 +26,20 @@ def _kvstore_set(offset: int) -> int: ... +@extism.import_fn("extism:host/user", "kvstore_setwithttl") +def _kvstore_setwithttl(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + @extism.import_fn("extism:host/user", "kvstore_get") def _kvstore_get(offset: int) -> int: """Raw host function - do not call directly.""" ... -@extism.import_fn("extism:host/user", "kvstore_delete") -def _kvstore_delete(offset: int) -> int: +@extism.import_fn("extism:host/user", "kvstore_getmany") +def _kvstore_getmany(offset: int) -> int: """Raw host function - do not call directly.""" ... @@ -50,6 +56,18 @@ def _kvstore_list(offset: int) -> int: ... +@extism.import_fn("extism:host/user", "kvstore_delete") +def _kvstore_delete(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "kvstore_deletebyprefix") +def _kvstore_deletebyprefix(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + @extism.import_fn("extism:host/user", "kvstore_getstorageused") def _kvstore_getstorageused(offset: int) -> int: """Raw host function - do not call directly.""" @@ -94,6 +112,43 @@ Returns an error if the storage limit would be exceeded or the operation fails. +def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None: + """SetWithTTL stores a byte value with the given key and a time-to-live. + +After ttlSeconds, the key is treated as non-existent and will be +cleaned up lazily. ttlSeconds must be greater than 0. + +Parameters: + - key: The storage key (max 256 bytes, UTF-8) + - value: The byte slice to store + - ttlSeconds: Time-to-live in seconds (must be > 0) + +Returns an error if the storage limit would be exceeded or the operation fails. + + Args: + key: str parameter. + value: bytes parameter. + ttl_seconds: int parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + "value": base64.b64encode(value).decode("ascii"), + "ttlSeconds": ttl_seconds, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_setwithttl(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + + def kvstore_get(key: str) -> KVStoreGetResult: """Get retrieves a byte value from storage. @@ -129,32 +184,37 @@ Returns the value and whether the key exists. ) -def kvstore_delete(key: str) -> None: - """Delete removes a value from storage. +def kvstore_get_many(keys: Any) -> Any: + """GetMany retrieves multiple values in a single call. Parameters: - - key: The storage key + - keys: The storage keys to retrieve -Returns an error if the operation fails. Does not return an error if the key doesn't exist. +Returns a map of key to value for keys that exist and have not expired. +Missing or expired keys are omitted from the result. Args: - key: str parameter. + keys: Any parameter. + + Returns: + Any: The result value. Raises: HostFunctionError: If the host function returns an error. """ request = { - "key": key, + "keys": keys, } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_delete(request_mem.offset) + response_offset = _kvstore_getmany(request_mem.offset) response_mem = extism.memory.find(response_offset) response = json.loads(extism.memory.string(response_mem)) if response.get("error"): raise HostFunctionError(response["error"]) + return response.get("values", None) def kvstore_has(key: str) -> bool: @@ -221,6 +281,66 @@ Returns a slice of matching keys. return response.get("keys", None) +def kvstore_delete(key: str) -> None: + """Delete removes a value from storage. + +Parameters: + - key: The storage key + +Returns an error if the operation fails. Does not return an error if the key doesn't exist. + + Args: + key: str parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_delete(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + + +def kvstore_delete_by_prefix(prefix: str) -> int: + """DeleteByPrefix removes all keys matching the given prefix. + +Parameters: + - prefix: Key prefix to match (must not be empty) + +Returns the number of keys deleted. Includes expired keys. + + Args: + prefix: str parameter. + + Returns: + int: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "prefix": prefix, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_deletebyprefix(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("deletedCount", 0) + + def kvstore_get_storage_used() -> int: """GetStorageUsed returns the total storage used by this plugin in bytes. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs index 20fe18c6f..a85e72895 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs @@ -44,6 +44,22 @@ struct KVStoreSetResponse { error: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreSetWithTTLRequest { + key: String, + #[serde(with = "base64_bytes")] + value: Vec, + ttl_seconds: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreSetWithTTLResponse { + #[serde(default)] + error: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct KVStoreGetRequest { @@ -64,13 +80,15 @@ struct KVStoreGetResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct KVStoreDeleteRequest { - key: String, +struct KVStoreGetManyRequest { + keys: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -struct KVStoreDeleteResponse { +struct KVStoreGetManyResponse { + #[serde(default)] + values: std::collections::HashMap>, #[serde(default)] error: Option, } @@ -105,6 +123,34 @@ struct KVStoreListResponse { error: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreDeleteRequest { + key: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreDeleteResponse { + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreDeleteByPrefixRequest { + prefix: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KVStoreDeleteByPrefixResponse { + #[serde(default)] + deleted_count: i64, + #[serde(default)] + error: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct KVStoreGetStorageUsedResponse { @@ -117,10 +163,13 @@ struct KVStoreGetStorageUsedResponse { #[host_fn] extern "ExtismHost" { fn kvstore_set(input: Json) -> Json; + fn kvstore_setwithttl(input: Json) -> Json; fn kvstore_get(input: Json) -> Json; - fn kvstore_delete(input: Json) -> Json; + fn kvstore_getmany(input: Json) -> Json; fn kvstore_has(input: Json) -> Json; fn kvstore_list(input: Json) -> Json; + fn kvstore_delete(input: Json) -> Json; + fn kvstore_deletebyprefix(input: Json) -> Json; fn kvstore_getstorageused(input: Json) -> Json; } @@ -153,6 +202,41 @@ pub fn set(key: &str, value: Vec) -> Result<(), Error> { Ok(()) } +/// SetWithTTL stores a byte value with the given key and a time-to-live. +/// +/// After ttlSeconds, the key is treated as non-existent and will be +/// cleaned up lazily. ttlSeconds must be greater than 0. +/// +/// Parameters: +/// - key: The storage key (max 256 bytes, UTF-8) +/// - value: The byte slice to store +/// - ttlSeconds: Time-to-live in seconds (must be > 0) +/// +/// Returns an error if the storage limit would be exceeded or the operation fails. +/// +/// # Arguments +/// * `key` - String parameter. +/// * `value` - Vec parameter. +/// * `ttl_seconds` - i64 parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn set_with_ttl(key: &str, value: Vec, ttl_seconds: i64) -> Result<(), Error> { + let response = unsafe { + kvstore_setwithttl(Json(KVStoreSetWithTTLRequest { + key: key.to_owned(), + value: value, + ttl_seconds: ttl_seconds, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(()) +} + /// Get retrieves a byte value from storage. /// /// Parameters: @@ -186,22 +270,26 @@ pub fn get(key: &str) -> Result>, Error> { } } -/// Delete removes a value from storage. +/// GetMany retrieves multiple values in a single call. /// /// Parameters: -/// - key: The storage key +/// - keys: The storage keys to retrieve /// -/// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +/// Returns a map of key to value for keys that exist and have not expired. +/// Missing or expired keys are omitted from the result. /// /// # Arguments -/// * `key` - String parameter. +/// * `keys` - Vec parameter. +/// +/// # Returns +/// The values value. /// /// # Errors /// Returns an error if the host function call fails. -pub fn delete(key: &str) -> Result<(), Error> { +pub fn get_many(keys: Vec) -> Result>, Error> { let response = unsafe { - kvstore_delete(Json(KVStoreDeleteRequest { - key: key.to_owned(), + kvstore_getmany(Json(KVStoreGetManyRequest { + keys: keys, }))? }; @@ -209,7 +297,7 @@ pub fn delete(key: &str) -> Result<(), Error> { return Err(Error::msg(err)); } - Ok(()) + Ok(response.0.values) } /// Has checks if a key exists in storage. @@ -270,6 +358,61 @@ pub fn list(prefix: &str) -> Result, Error> { Ok(response.0.keys) } +/// Delete removes a value from storage. +/// +/// Parameters: +/// - key: The storage key +/// +/// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +/// +/// # Arguments +/// * `key` - String parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn delete(key: &str) -> Result<(), Error> { + let response = unsafe { + kvstore_delete(Json(KVStoreDeleteRequest { + key: key.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(()) +} + +/// DeleteByPrefix removes all keys matching the given prefix. +/// +/// Parameters: +/// - prefix: Key prefix to match (must not be empty) +/// +/// Returns the number of keys deleted. Includes expired keys. +/// +/// # Arguments +/// * `prefix` - String parameter. +/// +/// # Returns +/// The deleted_count value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn delete_by_prefix(prefix: &str) -> Result { + let response = unsafe { + kvstore_deletebyprefix(Json(KVStoreDeleteByPrefixRequest { + prefix: prefix.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.deleted_count) +} + /// GetStorageUsed returns the total storage used by this plugin in bytes. /// /// # Returns diff --git a/plugins/testdata/test-kvstore/main.go b/plugins/testdata/test-kvstore/main.go index 9c289df01..7fba14abe 100644 --- a/plugins/testdata/test-kvstore/main.go +++ b/plugins/testdata/test-kvstore/main.go @@ -9,19 +9,23 @@ import ( // TestKVStoreInput is the input for nd_test_kvstore callback. type TestKVStoreInput struct { - Operation string `json:"operation"` // "set", "get", "delete", "has", "list", "get_storage_used" - Key string `json:"key"` // Storage key - Value []byte `json:"value"` // For set operations - Prefix string `json:"prefix"` // For list operation + Operation string `json:"operation"` // "set", "get", "delete", "has", "list", "get_storage_used", "set_with_ttl", "delete_by_prefix", "get_many" + Key string `json:"key"` // Storage key + Value []byte `json:"value"` // For set operations + Prefix string `json:"prefix"` // For list/delete_by_prefix operations + TTLSeconds int64 `json:"ttl_seconds,omitempty"` // For set_with_ttl + Keys []string `json:"keys,omitempty"` // For get_many } // TestKVStoreOutput is the output from nd_test_kvstore callback. type TestKVStoreOutput struct { - Value []byte `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Keys []string `json:"keys,omitempty"` - StorageUsed int64 `json:"storage_used,omitempty"` - Error *string `json:"error,omitempty"` + Value []byte `json:"value,omitempty"` + Values map[string][]byte `json:"values,omitempty"` + Exists bool `json:"exists,omitempty"` + Keys []string `json:"keys,omitempty"` + StorageUsed int64 `json:"storage_used,omitempty"` + DeletedCount int64 `json:"deleted_count,omitempty"` + Error *string `json:"error,omitempty"` } // nd_test_kvstore is the test callback that tests the kvstore host functions. @@ -96,6 +100,36 @@ func ndTestKVStore() int32 { pdk.OutputJSON(TestKVStoreOutput{StorageUsed: bytesUsed}) return 0 + case "set_with_ttl": + err := host.KVStoreSetWithTTL(input.Key, input.Value, input.TTLSeconds) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{}) + return 0 + + case "delete_by_prefix": + deletedCount, err := host.KVStoreDeleteByPrefix(input.Prefix) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{DeletedCount: deletedCount}) + return 0 + + case "get_many": + values, err := host.KVStoreGetMany(input.Keys) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{Values: values}) + return 0 + default: errStr := "unknown operation: " + input.Operation pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) From 3476be01f71b36a17d122964a0275647a87e30ee Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 1 Mar 2026 10:50:24 -0500 Subject: [PATCH 035/596] fix(scanner): handle nil mainCtx in Watcher to prevent panic Signed-off-by: Deluan --- scanner/watcher.go | 6 ++++++ scanner/watcher_test.go | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/scanner/watcher.go b/scanner/watcher.go index 3efebaacc..101e3793a 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -145,6 +145,12 @@ func (w *watcher) Watch(ctx context.Context, lib *model.Library) error { w.mu.Lock() defer w.mu.Unlock() + // If Run() hasn't been called yet, mainCtx will be nil - skip watching + if w.mainCtx == nil { + log.Debug(ctx, "Watcher not started yet, skipping watch for library", "libraryID", lib.ID, "name", lib.Name) + return nil + } + // Stop existing watcher if any if existingInstance, exists := w.libraryWatchers[lib.ID]; exists { log.Debug(ctx, "Stopping existing watcher before starting new one", "libraryID", lib.ID, "name", lib.Name) diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 01bfb2491..7a431d5a0 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -54,6 +54,15 @@ var _ = Describe("Watcher", func() { } }) + Describe("Watch before Run", func() { + It("returns nil and does not panic when mainCtx is nil", func() { + w.mainCtx = nil + err := w.Watch(ctx, lib) + Expect(err).ToNot(HaveOccurred()) + Expect(w.libraryWatchers).To(BeEmpty()) + }) + }) + Describe("Target Collection and Deduplication", func() { BeforeEach(func() { // Start watcher in background From 4e34d3ac1fa69f0875aabd4e7780974882ef1ed6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 1 Mar 2026 10:50:37 -0500 Subject: [PATCH 036/596] feat(ui): conditionally display 'path' field in LibraryList for desktop view Signed-off-by: Deluan --- ui/src/library/LibraryList.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx index aa1294882..35d627cbb 100644 --- a/ui/src/library/LibraryList.jsx +++ b/ui/src/library/LibraryList.jsx @@ -21,6 +21,7 @@ const LibraryFilter = (props) => ( const LibraryList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) useResourceRefresh('library') return ( @@ -40,7 +41,7 @@ const LibraryList = (props) => { ) : ( - + {isDesktop && } From d004f99f8f2ab3f753e3847bdf28548f3a5a3753 Mon Sep 17 00:00:00 2001 From: adrbn <128328324+adrbn@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:07:18 +0100 Subject: [PATCH 037/596] feat(playlist): add custom playlist cover art upload (#5110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(playlist): add custom playlist cover art upload - #406 Allow users to upload, view, and remove custom cover images for playlists. Custom images take priority over the auto-generated tiled artwork. Backend: - Add `image_path` column to playlist table (migration with proper rollback) - Add `SetImage`/`RemoveImage` methods to playlist service - Add `POST/DELETE /api/playlist/{id}/image` endpoints - Prioritize custom image in artwork reader pipeline - Clean up image files on playlist deletion - Use glob-based cleanup to prevent orphaned files across format changes - Reject uploads with undetermined image type (400) Frontend: - Hover overlay on playlist cover with upload (camera) and remove (trash) buttons - Lightbox for full-size cover art viewing - Cover art thumbnails in the playlist list view - Loading/error states and i18n strings Closes #406 Co-Authored-By: Claude Opus 4.6 Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor: rename playlist image path migration file Signed-off-by: Deluan * fix(playlist): address review feedback for cover art upload - #406 - Use httpClient instead of raw fetch for image upload/remove - Revert glob cleanup to simple imagePath check - Add log.Error before all error HTTP responses - Add backend tests for SetImage and RemoveImage Co-Authored-By: Claude Opus 4.6 Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor(playlist): use Playlist.ArtworkPath() for image storage Migrate all playlist image path handling to use the new Playlist.ArtworkPath() method as the single source of truth. The DB now stores only the filename (e.g. "pls-1.jpg") instead of a relative path, and images are stored under {DataFolder}/artwork/playlist/ instead of {DataFolder}/playlist_images/. The artwork root directory is created at startup alongside DataFolder and CacheFolder. This also removes the conf dependency from reader_playlist.go since path resolution is now fully encapsulated in the model. Signed-off-by: Deluan * refactor(playlist): streamline artwork image selection logic Signed-off-by: Deluan * refactor: move translation keys, add pt-BR translations Signed-off-by: Deluan * refactor(playlist): rename image_path to image_file Rename the playlist cover art column and field from image_path/ImagePath to image_file/ImageFile across the migration, model, service, tests, and UI. The new name more accurately describes what the field stores (a filename, not a path) and aligns with the existing ImageFiles/IsImageFile naming conventions in the codebase. --------- Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> Signed-off-by: Deluan Co-authored-by: Claude Opus 4.6 Co-authored-by: Deluan Quintão --- conf/configuration.go | 6 + consts/consts.go | 1 + core/artwork/reader_playlist.go | 19 ++- core/playlists/playlists.go | 72 +++++++++- core/playlists/playlists_test.go | 120 ++++++++++++++++ .../20260228172956_add_playlist_image_file.go | 22 +++ model/playlist.go | 11 ++ resources/i18n/pt-br.json | 8 +- server/nativeapi/native_api.go | 2 + server/nativeapi/playlists.go | 105 ++++++++++++++ ui/src/i18n/en.json | 8 +- ui/src/playlist/PlaylistDetails.jsx | 136 +++++++++++++++++- ui/src/playlist/PlaylistList.jsx | 27 ++++ 13 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 db/migrations/20260228172956_add_playlist_image_file.go diff --git a/conf/configuration.go b/conf/configuration.go index 61448d315..3e3b42355 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -303,6 +303,12 @@ func Load(noConfigDump bool) { os.Exit(1) } + err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err) + os.Exit(1) + } + if Server.Plugins.Enabled { if Server.Plugins.Folder == "" { Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") diff --git a/consts/consts.go b/consts/consts.go index ebde9d1d9..295abe8a9 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -65,6 +65,7 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" + ArtworkFolder = "artwork" PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index a9f289ad8..09bfe221b 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -8,6 +8,7 @@ import ( "image/draw" "image/png" "io" + "os" "time" "github.com/disintegration/imaging" @@ -43,11 +44,25 @@ func (a *playlistArtworkReader) LastUpdated() time.Time { } func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - ff := []sourceFunc{ + return selectImageReader(ctx, a.artID, + a.fromPlaylistImage(), a.fromGeneratedTiledCover(ctx), fromAlbumPlaceholder(), + ) +} + +func (a *playlistArtworkReader) fromPlaylistImage() sourceFunc { + return func() (io.ReadCloser, string, error) { + absPath := a.pl.ArtworkPath() + if absPath == "" { + return nil, "", nil + } + f, err := os.Open(absPath) + if err != nil { + return nil, "", err + } + return f, absPath, nil } - return selectImageReader(ctx, a.artID, ff...) } func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 5a9a908ee..7e881b730 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -2,7 +2,9 @@ package playlists import ( "context" + "fmt" "io" + "os" "path/filepath" "strconv" "strings" @@ -10,6 +12,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) @@ -34,6 +37,10 @@ type Playlists interface { RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error + // Cover art + SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error + RemoveImage(ctx context.Context, playlistID string) error + // Import ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) @@ -118,9 +125,18 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, } func (s *playlists) Delete(ctx context.Context, id string) error { - if _, err := s.checkWritable(ctx, id); err != nil { + pls, err := s.checkWritable(ctx, id) + if err != nil { return err } + + // Clean up custom cover image file if one exists + if path := pls.ArtworkPath(); path != "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err) + } + } + return s.ds.Playlist(ctx).Delete(id) } @@ -263,3 +279,57 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) }) } + +// --- Cover art operations --- + +func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error { + pls, err := s.checkWritable(ctx, playlistID) + if err != nil { + return err + } + + filename := playlistID + ext + oldPath := pls.ArtworkPath() + pls.ImageFile = filename + absPath := pls.ArtworkPath() + + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return fmt.Errorf("creating playlist images directory: %w", err) + } + + // Remove old image if it exists + if oldPath != "" { + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err) + } + } + + // Save new image + f, err := os.Create(absPath) + if err != nil { + return fmt.Errorf("creating playlist image file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, reader); err != nil { + return fmt.Errorf("writing playlist image file: %w", err) + } + + return s.ds.Playlist(ctx).Put(pls) +} + +func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { + pls, err := s.checkWritable(ctx, playlistID) + if err != nil { + return err + } + + if path := pls.ArtworkPath(); path != "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove playlist image", "path", path, err) + } + } + + pls.ImageFile = "" + return s.ds.Playlist(ctx).Put(pls) +} diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index a4c309d77..4d42bbeb9 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -2,7 +2,12 @@ package playlists_test import ( "context" + "os" + "path/filepath" + "strings" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -294,4 +299,119 @@ var _ = Describe("Playlists", func() { Expect(err).To(MatchError(model.ErrNotAuthorized)) }) }) + + Describe("SetImage", func() { + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + } + ps = playlists.NewPlaylists(ds) + }) + + It("saves image file and updates ImageFile", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + reader := strings.NewReader("fake image data") + err := ps.SetImage(ctx, "pls-1", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + + Expect(mockPlsRepo.Last.ImageFile).To(Equal("pls-1.jpg")) + absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") + data, err := os.ReadFile(absPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + }) + + It("removes old image when replacing", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + + // Upload first image + err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png") + Expect(err).ToNot(HaveOccurred()) + oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.png") + Expect(oldPath).To(BeAnExistingFile()) + + // Upload replacement image + err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(oldPath).ToNot(BeAnExistingFile()) + newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") + Expect(newPath).To(BeAnExistingFile()) + }) + + It("allows admin to set image on any playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) + err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg") + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies non-owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg") + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("returns error when playlist not found", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg") + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + + Describe("RemoveImage", func() { + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + + // Create a real image file on disk + imgDir := filepath.Join(tmpDir, "artwork", "playlist") + Expect(os.MkdirAll(imgDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed()) + + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", ImageFile: "pls-1.jpg"}, + "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + } + ps = playlists.NewPlaylists(ds) + }) + + It("removes file and clears ImageFile", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.RemoveImage(ctx, "pls-1") + Expect(err).ToNot(HaveOccurred()) + + Expect(mockPlsRepo.Last.ImageFile).To(BeEmpty()) + absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") + Expect(absPath).ToNot(BeAnExistingFile()) + }) + + It("succeeds even if playlist has no image", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.RemoveImage(ctx, "pls-empty") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.ImageFile).To(BeEmpty()) + }) + + It("denies non-owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + err := ps.RemoveImage(ctx, "pls-1") + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("returns error when playlist not found", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.RemoveImage(ctx, "nonexistent") + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) }) diff --git a/db/migrations/20260228172956_add_playlist_image_file.go b/db/migrations/20260228172956_add_playlist_image_file.go new file mode 100644 index 000000000..da2177aba --- /dev/null +++ b/db/migrations/20260228172956_add_playlist_image_file.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile) +} + +func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`) + return err +} + +func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`) + return err +} diff --git a/model/playlist.go b/model/playlist.go index a87019ed5..b6a52dcf2 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,10 +1,13 @@ package model import ( + "path/filepath" "slices" "strconv" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" ) @@ -21,6 +24,7 @@ type Playlist struct { Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` Path string `structs:"path" json:"path"` Sync bool `structs:"sync" json:"sync"` + ImageFile string `structs:"image_file" json:"imageFile"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` @@ -106,6 +110,13 @@ func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } +func (pls Playlist) ArtworkPath() string { + if pls.ImageFile == "" { + return "" + } + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.ImageFile) +} + type Playlists []Playlist type PlaylistRepository interface { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 1fc72bf30..6c9e154d1 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -218,9 +218,15 @@ "saveQueue": "Salvar fila em nova Playlist", "searchOrCreate": "Buscar playlists ou criar nova...", "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção" + "removeFromSelection": "Remover da seleção", + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa" }, "message": { + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa", "duplicate_song": "Adicionar músicas duplicadas", "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", "noPlaylistsFound": "Nenhuma playlist encontrada", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 062cbf706..3191991eb 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -140,6 +140,8 @@ func (api *Router) addPlaylistRoute(r chi.Router) { r.Get("/", rest.Get(constructor)) r.Put("/", rest.Put(constructor)) r.Delete("/", rest.Delete(constructor)) + r.Post("/image", uploadPlaylistImage(api.playlists)) + r.Delete("/image", deletePlaylistImage(api.playlists)) }) }) } diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 60e8024bd..c7230a209 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -5,7 +5,13 @@ import ( "encoding/json" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" "net/http" + "path/filepath" "strconv" "strings" @@ -15,6 +21,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" + _ "golang.org/x/image/webp" ) type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc @@ -224,3 +231,101 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { _, _ = w.Write(data) //nolint:gosec } } + +const maxImageSize = 10 << 20 // 10MB + +func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + playlistId, _ := p.String(":id") + + if err := r.ParseMultipartForm(maxImageSize); err != nil { + log.Error(ctx, "Error parsing multipart form", err) + http.Error(w, "file too large or invalid form", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("image") + if err != nil { + log.Error(ctx, "Error reading uploaded file", err) + http.Error(w, "missing image file", http.StatusBadRequest) + return + } + defer file.Close() + + // Validate the uploaded file is a valid image + _, format, err := image.DecodeConfig(file) + if err != nil { + log.Error(ctx, "Uploaded file is not a valid image", err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + + // Reset reader after DecodeConfig consumed some bytes + if seeker, ok := file.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + log.Error(ctx, "Error seeking file", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + // Determine file extension from decoded format or original filename + ext := "." + format + if ext == "." { + ext = strings.ToLower(filepath.Ext(header.Filename)) + } + if ext == "" || ext == "." { + log.Error(ctx, "Could not determine image type", "playlistId", playlistId, "filename", header.Filename) + http.Error(w, "could not determine image type", http.StatusBadRequest) + return + } + + err = pls.SetImage(ctx, playlistId, file, ext) + if errors.Is(err, model.ErrNotAuthorized) { + log.Error(ctx, "Not authorized to upload playlist image", "playlistId", playlistId, err) + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Playlist not found for image upload", "playlistId", playlistId, err) + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + log.Error(ctx, "Error saving playlist image", "playlistId", playlistId, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec + } +} + +func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + playlistId, _ := p.String(":id") + + err := pls.RemoveImage(ctx, playlistId) + if errors.Is(err, model.ErrNotAuthorized) { + log.Error(ctx, "Not authorized to remove playlist image", "playlistId", playlistId, err) + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Playlist not found for image removal", "playlistId", playlistId, err) + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + log.Error(ctx, "Error removing playlist image", "playlistId", playlistId, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec + } +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 224b1c437..7c4b8ff09 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -218,9 +218,15 @@ "makePrivate": "Make Private", "searchOrCreate": "Search playlists or type to create new...", "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection" + "removeFromSelection": "Remove from selection", + "uploadCover": "Upload Cover", + "removeCover": "Remove Cover" }, "message": { + "coverUploaded": "Cover art updated", + "coverRemoved": "Cover art removed", + "coverUploadError": "Error uploading cover art", + "coverRemoveError": "Error removing cover art", "duplicate_song": "Add duplicated songs", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", "noPlaylistsFound": "No playlists found", diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index acccb15f7..4c242dd2a 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -2,16 +2,27 @@ import { Card, CardContent, CardMedia, + IconButton, + Tooltip, Typography, useMediaQuery, } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' -import { useTranslate } from 'react-admin' -import { useCallback, useState, useEffect } from 'react' +import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' +import DeleteIcon from '@material-ui/icons/Delete' +import { useTranslate, useNotify, useRefresh } from 'react-admin' +import { useCallback, useRef, useState, useEffect } from 'react' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' -import { CollapsibleComment, DurationField, SizeField } from '../common' +import { + CollapsibleComment, + DurationField, + SizeField, + isWritable, +} from '../common' import subsonic from '../subsonic' +import { REST_URL } from '../consts' +import { httpClient } from '../dataProvider' const useStyles = makeStyles( (theme) => ({ @@ -55,6 +66,7 @@ const useStyles = makeStyles( display: 'flex', alignItems: 'center', justifyContent: 'center', + position: 'relative', }, cover: { objectFit: 'contain', @@ -68,6 +80,31 @@ const useStyles = makeStyles( coverLoading: { opacity: 0.5, }, + coverOverlay: { + position: 'absolute', + bottom: 0, + right: 0, + display: 'flex', + gap: '2px', + padding: '2px', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: '4px 0 0 0', + opacity: 0, + transition: 'opacity 0.2s ease-in-out', + '$coverParent:hover &': { + opacity: 1, + }, + }, + overlayButton: { + color: '#fff', + padding: '4px', + '&:hover': { + backgroundColor: 'rgba(255,255,255,0.2)', + }, + }, + overlayIcon: { + fontSize: '1.2rem', + }, title: { overflow: 'hidden', textOverflow: 'ellipsis', @@ -86,14 +123,18 @@ const useStyles = makeStyles( const PlaylistDetails = (props) => { const { record = {} } = props const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const [isLightboxOpen, setLightboxOpen] = useState(false) const [imageLoading, setImageLoading] = useState(false) const [imageError, setImageError] = useState(false) + const fileInputRef = useRef(null) const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) + const canEdit = isWritable(record.ownerId) // Reset image state when playlist changes useEffect(() => { @@ -119,6 +160,60 @@ const PlaylistDetails = (props) => { const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + const handleUploadClick = useCallback( + (e) => { + e.stopPropagation() + if (fileInputRef.current) { + fileInputRef.current.click() + } + }, + [fileInputRef], + ) + + const handleFileChange = useCallback( + async (e) => { + const file = e.target.files[0] + if (!file || !record.id) return + + const formData = new FormData() + formData.append('image', file) + + try { + await httpClient(`${REST_URL}/playlist/${record.id}/image`, { + method: 'POST', + headers: new Headers({}), + body: formData, + }) + notify('resources.playlist.message.coverUploaded', 'success') + refresh() + } catch (err) { + notify('resources.playlist.message.coverUploadError', 'warning') + } + + // Reset file input so the same file can be re-selected + e.target.value = '' + }, + [record.id, notify, refresh], + ) + + const handleRemoveCover = useCallback( + async (e) => { + e.stopPropagation() + if (!record.id) return + + try { + await httpClient(`${REST_URL}/playlist/${record.id}/image`, { + method: 'DELETE', + }) + notify('resources.playlist.message.coverRemoved', 'success') + refresh() + } catch (err) { + notify('resources.playlist.message.coverRemoveError', 'warning') + } + }, + [record.id, notify, refresh], + ) + return (
@@ -138,6 +233,41 @@ const PlaylistDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> + {canEdit && ( +
+ + + + + + {record.imageFile && ( + + + + + + )} + +
+ )}
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index eae9d863f..4ec2d5ca1 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -16,6 +16,7 @@ import { usePermissions, } from 'react-admin' import Switch from '@material-ui/core/Switch' +import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { @@ -28,11 +29,17 @@ import { } from '../common' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' +import subsonic from '../subsonic' const useStyles = makeStyles((theme) => ({ button: { color: theme.palette.type === 'dark' ? 'white' : undefined, }, + coverArt: { + width: '40px', + height: '40px', + borderRadius: '4px', + }, })) const PlaylistFilter = (props) => { @@ -119,6 +126,25 @@ const ToggleAutoImport = ({ resource, source }) => { ) : null } +const CoverArtField = () => { + const classes = useStyles() + const record = useRecordContext() + if (!record) return null + return ( + + ) +} + +CoverArtField.defaultProps = { + label: '', + sortable: false, +} + const PlaylistListBulkActions = (props) => { const classes = useStyles() return ( @@ -176,6 +202,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && } > isWritable(r?.ownerId)}> + {columns} From 27a83547f71f643e1c5bd087028ac0b68e522610 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 2 Mar 2026 08:56:56 -0500 Subject: [PATCH 038/596] fix(plugins): clear plugin errors on startup to allow retrying Plugins that entered an error state (e.g., incompatible with the Navidrome version) would remain in that state across restarts, blocking the user from retrying. This adds a ClearErrors method to PluginRepository that resets the last_error field on all plugins, and calls it during plugin manager startup before syncing and loading. Signed-off-by: Deluan --- model/plugin.go | 1 + persistence/plugin_repository.go | 8 ++++++++ persistence/plugin_repository_test.go | 24 ++++++++++++++++++++++++ plugins/manager.go | 6 ++++++ tests/mock_plugin_repo.go | 14 ++++++++++++++ 5 files changed, 53 insertions(+) diff --git a/model/plugin.go b/model/plugin.go index f4bad6783..18d66e305 100644 --- a/model/plugin.go +++ b/model/plugin.go @@ -23,6 +23,7 @@ type Plugins []Plugin type PluginRepository interface { ResourceRepository + ClearErrors() error CountAll(options ...QueryOptions) (int64, error) Delete(id string) error Get(id string) (*Plugin, error) diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 466abb40b..35c32de91 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -31,6 +31,14 @@ func (r *pluginRepository) isPermitted() bool { return user.IsAdmin } +func (r *pluginRepository) ClearErrors() error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + _, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute() + return err +} + func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) { if !r.isPermitted() { return 0, rest.ErrPermissionDenied diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go index ee158a31c..dc68b0892 100644 --- a/persistence/plugin_repository_test.go +++ b/persistence/plugin_repository_test.go @@ -175,6 +175,30 @@ var _ = Describe("PluginRepository", func() { Expect(err.Error()).To(ContainSubstring("ID cannot be empty")) }) }) + + Describe("ClearErrors", func() { + It("clears last_error on all plugins with errors", func() { + _ = repo.Put(&model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(&model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"}) + _ = repo.Put(&model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"}) + + err := repo.ClearErrors() + Expect(err).To(BeNil()) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + for _, p := range all { + Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID) + } + }) + + It("succeeds when no plugins have errors", func() { + _ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) + + err := repo.ClearErrors() + Expect(err).To(BeNil()) + }) + }) }) Describe("Regular User", func() { diff --git a/plugins/manager.go b/plugins/manager.go index f148a706c..bf6bab6e8 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -146,6 +146,12 @@ func (m *Manager) Start(ctx context.Context) error { log.Info(ctx, "Starting plugin manager", "folder", folder) + // Clear previous error states so plugins can be retried on restart + adminCtx := adminContext(ctx) + if err := m.ds.Plugin(adminCtx).ClearErrors(); err != nil { + log.Error(ctx, "Error clearing plugin errors", err) + } + // Sync plugins folder with DB if err := m.syncPlugins(ctx, folder); err != nil { log.Error(ctx, "Error syncing plugins with DB", err) diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go index dd08f6dec..5d22c26aa 100644 --- a/tests/mock_plugin_repo.go +++ b/tests/mock_plugin_repo.go @@ -29,6 +29,20 @@ func (m *MockPluginRepo) SetError(err bool) { m.Err = err } +func (m *MockPluginRepo) ClearErrors() error { + if m.Err { + return errors.New("unexpected error") + } + for i := range m.All { + m.All[i].LastError = "" + } + for k, p := range m.Data { + p.LastError = "" + m.Data[k] = p + } + return nil +} + func (m *MockPluginRepo) SetData(plugins model.Plugins) { m.Data = make(map[string]*model.Plugin, len(plugins)) m.All = plugins From c4fd8e31251a458450efad2a1e157c160da9bbc3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 2 Mar 2026 11:20:25 -0500 Subject: [PATCH 039/596] fix(plugins): resolve kvstore TTL flaky test due to second-boundary race Changed the TTL expiration check from strict greater-than to greater-or-equal in the notExpiredFilter SQL condition. SQLite's datetime has second-level precision, so a 1-second TTL set late in a second could appear expired immediately when read at the next second boundary (e.g. expires_at of T+1 fails the check 'T+1 > T+1'). Updated the cleanup query consistently to use strict less-than, so rows are only deleted after their expiration second has fully passed. --- plugins/host_kvstore.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index 9aa37f7e5..248e43c4d 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -25,7 +25,7 @@ const ( ) // notExpiredFilter is the SQL condition to exclude expired keys. -const notExpiredFilter = "(expires_at IS NULL OR expires_at > datetime('now'))" +const notExpiredFilter = "(expires_at IS NULL OR expires_at >= datetime('now'))" const cleanupInterval = 1 * time.Hour @@ -349,7 +349,7 @@ func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) { // cleanupExpired removes all expired keys from the database to reclaim disk space. func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) { - result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')`) + result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`) if err != nil { log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err) return From acd69f6a4fbeb68a0bf86ae65a393531bb4d7d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 2 Mar 2026 11:39:59 -0500 Subject: [PATCH 040/596] feat(playlist): support #EXTALBUMARTURL directive and sidecar images (#5131) * feat(playlist): add migration for playlist image field rename and external URL * refactor(playlist): rename ImageFile to UploadedImage and ArtworkPath to UploadedImagePath Rename playlist model fields and methods for clarity in preparation for adding external image URL and sidecar image support. Add the new ExternalImageURL field to the Playlist model. * feat(playlist): parse #EXTALBUMARTURL directive in M3U imports * feat(playlist): always sync ExternalImageURL on re-scan, preserve UploadedImage * feat(artwork): add sidecar image discovery and cache invalidation for playlists Add playlist sidecar image support to the artwork reader fallback chain. A sidecar image (e.g., MyPlaylist.jpg next to MyPlaylist.m3u) is discovered via case-insensitive base name matching using model.IsImageFile(). Cache invalidation uses max(playlist.UpdatedAt, imageFile.ModTime()) to bust stale artwork when sidecar or ExternalImageURL local files change. * feat(artwork): add external image URL source to playlist artwork reader Add fromPlaylistExternalImage source function that resolves playlist cover art from ExternalImageURL, supporting both HTTP(S) URLs (via the existing fromURL helper) and local file paths (via os.Open). Insert it in the Reader() fallback chain between sidecar and tiled cover. * refactor(artwork): simplify playlist artwork source functions Extract shared fromLocalFile helper, use url.Parse for scheme check, and collapse sidecar directory scan conditions. * test(artwork): remove redundant fromPlaylistSidecar tests These tests duplicated scenarios already covered by findPlaylistSidecarPath tests combined with fromLocalFile (tested via fromPlaylistExternalImage). After refactoring fromPlaylistSidecar to a one-liner composing those two functions, the wrapper tests add no value. * fix(playlist): address security review comments from PR #5131: - Use url.PathUnescape instead of url.QueryUnescape for file:// URLs so that '+' in filenames is preserved (not decoded as space). - Validate all local image paths (file://, absolute, relative) against known library boundaries via libraryMatcher, rejecting paths outside any configured library. - Harden #EXTALBUMARTURL against path traversal and SSRF by adding EnableM3UExternalAlbumArt config flag (default false, also disabled by EnableExternalServices=false) to gate HTTP(S) URL storage at parse time and fetching at read time (defense in depth). - Log a warning when os.ReadDir fails in findPlaylistSidecarPath for diagnosability. - Extract resolveLocalPath helper to simplify resolveImageURL. Signed-off-by: Deluan * feat(playlist): implement human-friendly filename generation for uploaded playlist cover images Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 3 + core/artwork/artwork_internal_test.go | 107 ++++++++ core/artwork/reader_playlist.go | 90 ++++++- core/playlists/import.go | 1 + core/playlists/import_test.go | 229 +++++++++++++++++- core/playlists/parse_m3u.go | 57 ++++- core/playlists/playlists.go | 14 +- core/playlists/playlists_test.go | 18 +- ...0302021413_rename_playlist_image_fields.go | 30 +++ model/playlist.go | 52 ++-- model/playlist_test.go | 22 ++ tests/fixtures/playlists/pls-with-art-url.m3u | 5 + ui/src/playlist/PlaylistDetails.jsx | 2 +- utils/files.go | 17 ++ utils/files_test.go | 43 ++++ 15 files changed, 647 insertions(+), 43 deletions(-) create mode 100644 db/migrations/20260302021413_rename_playlist_image_fields.go create mode 100644 tests/fixtures/playlists/pls-with-art-url.m3u diff --git a/conf/configuration.go b/conf/configuration.go index 3e3b42355..0d32815ee 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -46,6 +46,7 @@ type configOptions struct { EnableTranscodingCancellation bool EnableDownloads bool EnableExternalServices bool + EnableM3UExternalAlbumArt bool EnableInsightsCollector bool EnableMediaFileCoverArt bool TranscodingCacheSize string @@ -474,6 +475,7 @@ func parseIniFileConfiguration() { func disableExternalServices() { log.Info("All external integrations are DISABLED!") Server.EnableInsightsCollector = false + Server.EnableM3UExternalAlbumArt = false Server.LastFM.Enabled = false Server.Spotify.ID = "" Server.Deezer.Enabled = false @@ -638,6 +640,7 @@ func setViperDefaults() { viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) + viper.SetDefault("enablem3uexternalalbumart", false) viper.SetDefault("enablemediafilecoverart", true) viper.SetDefault("autotranscodedownload", false) viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index c18caf737..e2ea7adb0 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -235,6 +235,113 @@ var _ = Describe("Artwork", func() { }) }) }) + Describe("playlistArtworkReader", func() { + Describe("findPlaylistSidecarPath", func() { + It("discovers sidecar image next to playlist file", func() { + tmpDir := GinkgoT().TempDir() + plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") + imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") + Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) + Expect(result).To(Equal(imgPath)) + }) + + It("returns empty string when no sidecar image exists", func() { + tmpDir := GinkgoT().TempDir() + plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") + Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + + result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) + Expect(result).To(BeEmpty()) + }) + + It("returns empty string when playlist has no path", func() { + result := findPlaylistSidecarPath(GinkgoT().Context(), "") + Expect(result).To(BeEmpty()) + }) + + It("finds sidecar with different case base name", func() { + tmpDir := GinkgoT().TempDir() + plsPath := filepath.Join(tmpDir, "myplaylist.m3u") + imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") + Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) + Expect(result).To(Equal(imgPath)) + }) + }) + + Describe("fromPlaylistExternalImage", func() { + It("opens local path from ExternalImageURL", func() { + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "cover.jpg") + Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed()) + + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: imgPath}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + data, _ := io.ReadAll(r) + Expect(string(data)).To(Equal("external image data")) + r.Close() + }) + + It("returns nil when ExternalImageURL is empty", func() { + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: ""}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + + It("returns error when local file does not exist", func() { + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"}, + } + r, _, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).To(HaveOccurred()) + Expect(r).To(BeNil()) + }) + + It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + + It("still opens local path when EnableM3UExternalAlbumArt is false", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "cover.jpg") + Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed()) + + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: imgPath}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + }) + Describe("resizedArtworkReader", func() { BeforeEach(func() { folderRepo.result = []model.Folder{{ diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index 09bfe221b..91d47b0b0 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -8,10 +8,14 @@ import ( "image/draw" "image/png" "io" + "net/url" "os" + "path/filepath" + "strings" "time" "github.com/disintegration/imaging" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -36,6 +40,24 @@ func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model } a.cacheKey.artID = artID a.cacheKey.lastUpdate = pl.UpdatedAt + + // Check sidecar and ExternalImageURL local file ModTimes for cache invalidation. + // If either is newer than the playlist's UpdatedAt, use that instead so the + // cache is busted when a user replaces a sidecar image or local file reference. + for _, path := range []string{ + findPlaylistSidecarPath(ctx, pl.Path), + pl.ExternalImageURL, + } { + if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + continue + } + if info, err := os.Stat(path); err == nil { + if info.ModTime().After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = info.ModTime() + } + } + } + return a, nil } @@ -45,26 +67,82 @@ func (a *playlistArtworkReader) LastUpdated() time.Time { func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { return selectImageReader(ctx, a.artID, - a.fromPlaylistImage(), + a.fromPlaylistUploadedImage(), + a.fromPlaylistSidecar(ctx), + a.fromPlaylistExternalImage(ctx), a.fromGeneratedTiledCover(ctx), fromAlbumPlaceholder(), ) } -func (a *playlistArtworkReader) fromPlaylistImage() sourceFunc { +func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc { + return fromLocalFile(a.pl.UploadedImagePath()) +} + +func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc { + return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path)) +} + +func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc { return func() (io.ReadCloser, string, error) { - absPath := a.pl.ArtworkPath() - if absPath == "" { + imgURL := a.pl.ExternalImageURL + if imgURL == "" { return nil, "", nil } - f, err := os.Open(absPath) + parsed, err := url.Parse(imgURL) if err != nil { return nil, "", err } - return f, absPath, nil + if parsed.Scheme == "http" || parsed.Scheme == "https" { + if !conf.Server.EnableM3UExternalAlbumArt { + return nil, "", nil + } + return fromURL(ctx, parsed) + } + return fromLocalFile(imgURL)() } } +// fromLocalFile returns a sourceFunc that opens the given local path. +// Returns (nil, "", nil) if path is empty — signalling "not found, try next source". +func fromLocalFile(path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar +// image file with the same base name (case-insensitive). Returns empty string if +// no matching image is found or if plsPath is empty. +func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { + if plsPath == "" { + return "" + } + dir := filepath.Dir(plsPath) + base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) + + entries, err := os.ReadDir(dir) + if err != nil { + log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err) + return "" + } + for _, entry := range entries { + name := entry.Name() + nameBase := strings.TrimSuffix(name, filepath.Ext(name)) + if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { + return filepath.Join(dir, name) + } + } + return "" +} + func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { return func() (io.ReadCloser, string, error) { tiles, err := a.loadTiles(ctx) diff --git a/core/playlists/import.go b/core/playlists/import.go index 40e230527..4462554c7 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -106,6 +106,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) newPls.Comment = pls.Comment newPls.OwnerID = pls.OwnerID newPls.Public = pls.Public + newPls.UploadedImage = pls.UploadedImage // Preserve manual upload newPls.EvaluatedAt = &time.Time{} } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index a42c3f3eb..5312df95d 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -2,7 +2,9 @@ package playlists_test import ( "context" + "fmt" "os" + "path/filepath" "strconv" "strings" "time" @@ -39,6 +41,7 @@ var _ = Describe("Playlists - Import", func() { Describe("ImportFile", func() { var folder *model.Folder BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) ps = playlists.NewPlaylists(ds) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() @@ -93,6 +96,213 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Tracks).To(HaveLen(1)) Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) }) + + It("parses #EXTALBUMARTURL with HTTP URL", func() { + conf.Server.EnableM3UExternalAlbumArt = true + + pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) + Expect(pls.Tracks).To(HaveLen(2)) + }) + + It("parses #EXTALBUMARTURL with absolute local path", func() { + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "cover.jpg") + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath) + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(imgPath)) + }) + + It("parses #EXTALBUMARTURL with relative local path", func() { + tmpDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed()) + + m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg"))) + }) + + It("parses #EXTALBUMARTURL with file:// URL", func() { + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "my cover.jpg") + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20")) + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(imgPath)) + }) + + It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() { + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "A+B.jpg") + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath) + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(imgPath)) + }) + + It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() { + tmpDir := GinkgoT().TempDir() + + m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + + It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() { + tmpDir := GinkgoT().TempDir() + + m3u := "#EXTALBUMARTURL:file:///etc/passwd\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + + It("rejects #EXTALBUMARTURL with relative path escaping library", func() { + tmpDir := GinkgoT().TempDir() + + m3u := "#EXTALBUMARTURL:../../etc/passwd\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + + It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + tmpDir := GinkgoT().TempDir() + m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + + It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() { + conf.Server.EnableM3UExternalAlbumArt = true + + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: plsFile, + Sync: true, + UploadedImage: "existing-id.jpg", + ExternalImageURL: "https://example.com/old-cover.jpg", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.UploadedImage).To(Equal("existing-id.jpg")) + Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg")) + }) + + It("clears ExternalImageURL on re-scan when directive is removed", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds) + + m3u := "test.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: plsFile, + Sync: true, + ExternalImageURL: "https://example.com/old-cover.jpg", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) }) Describe("NSP", func() { @@ -125,7 +335,6 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Public).To(BeFalse()) }) It("uses server default when public field is absent", func() { - DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultPlaylistPublicVisibility = true pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") @@ -495,6 +704,24 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3")) }) + It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() { + conf.Server.EnableM3UExternalAlbumArt = true + + repo.data = []string{"tests/test.mp3"} + m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n" + pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) + }) + + It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() { + repo.data = []string{"tests/test.mp3"} + m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n" + pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation, // so we need exact matching for non-ASCII characters. It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() { diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 4e79d15c6..97ed7df6f 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -34,13 +35,17 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m pls.Name = line[len("#PLAYLIST:"):] continue } + if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { + pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher) + continue + } // Skip empty lines and extended info if line == "" || strings.HasPrefix(line, "#") { continue } if after, ok := strings.CutPrefix(line, "file://"); ok { line = after - line, _ = url.QueryUnescape(line) + line, _ = url.PathUnescape(line) } if !model.IsAudioFile(line) { continue @@ -267,3 +272,53 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l return results, nil } + +// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string. +// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). +// Local paths (file://, absolute, or relative) are resolved to an absolute path +// and validated against known library boundaries via matcher. +func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + // HTTP(S) URLs — store as-is, but only if external album art is enabled + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + if !conf.Server.EnableM3UExternalAlbumArt { + return "" + } + return value + } + + // Resolve to local absolute path + localPath, ok := resolveLocalPath(value, folder) + if !ok { + return "" + } + + // Validate path is within a known library + if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 { + return "" + } + return localPath +} + +// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path. +// Returns ("", false) if the path cannot be resolved. +func resolveLocalPath(value string, folder *model.Folder) (string, bool) { + if after, ok := strings.CutPrefix(value, "file://"); ok { + decoded, err := url.PathUnescape(after) + if err != nil { + return "", false + } + return filepath.Clean(decoded), true + } + if filepath.IsAbs(value) { + return filepath.Clean(value), true + } + if folder == nil { + return "", false + } + return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true +} diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 7e881b730..0649b16a9 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -131,7 +131,7 @@ func (s *playlists) Delete(ctx context.Context, id string) error { } // Clean up custom cover image file if one exists - if path := pls.ArtworkPath(); path != "" { + if path := pls.UploadedImagePath(); path != "" { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err) } @@ -288,10 +288,10 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R return err } - filename := playlistID + ext - oldPath := pls.ArtworkPath() - pls.ImageFile = filename - absPath := pls.ArtworkPath() + filename := pls.ImageFilename(ext) + oldPath := pls.UploadedImagePath() + pls.UploadedImage = filename + absPath := pls.UploadedImagePath() if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { return fmt.Errorf("creating playlist images directory: %w", err) @@ -324,12 +324,12 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { return err } - if path := pls.ArtworkPath(); path != "" { + if path := pls.UploadedImagePath(); path != "" { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { log.Warn(ctx, "Failed to remove playlist image", "path", path, err) } } - pls.ImageFile = "" + pls.UploadedImage = "" return s.ds.Playlist(ctx).Put(pls) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 4d42bbeb9..ec73c329a 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -315,14 +315,14 @@ var _ = Describe("Playlists", func() { ps = playlists.NewPlaylists(ds) }) - It("saves image file and updates ImageFile", func() { + It("saves image file and updates UploadedImage", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) reader := strings.NewReader("fake image data") err := ps.SetImage(ctx, "pls-1", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.ImageFile).To(Equal("pls-1.jpg")) - absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") + Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg")) + absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg") data, err := os.ReadFile(absPath) Expect(err).ToNot(HaveOccurred()) Expect(string(data)).To(Equal("fake image data")) @@ -334,14 +334,14 @@ var _ = Describe("Playlists", func() { // Upload first image err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png") Expect(err).ToNot(HaveOccurred()) - oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.png") + oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png") Expect(oldPath).To(BeAnExistingFile()) // Upload replacement image err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg") Expect(err).ToNot(HaveOccurred()) Expect(oldPath).ToNot(BeAnExistingFile()) - newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") + newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg") Expect(newPath).To(BeAnExistingFile()) }) @@ -378,19 +378,19 @@ var _ = Describe("Playlists", func() { Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed()) mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", ImageFile: "pls-1.jpg"}, + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"}, "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } ps = playlists.NewPlaylists(ds) }) - It("removes file and clears ImageFile", func() { + It("removes file and clears UploadedImage", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-1") Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.ImageFile).To(BeEmpty()) + Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") Expect(absPath).ToNot(BeAnExistingFile()) }) @@ -399,7 +399,7 @@ var _ = Describe("Playlists", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-empty") Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.ImageFile).To(BeEmpty()) + Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) It("denies non-owner", func() { diff --git a/db/migrations/20260302021413_rename_playlist_image_fields.go b/db/migrations/20260302021413_rename_playlist_image_fields.go new file mode 100644 index 000000000..1e9754637 --- /dev/null +++ b/db/migrations/20260302021413_rename_playlist_image_fields.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRenamePlaylistImageFields, downRenamePlaylistImageFields) +} + +func upRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN image_file TO uploaded_image;`) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN external_image_url VARCHAR(255) DEFAULT '';`) + return err +} + +func downRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN external_image_url;`) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN uploaded_image TO image_file;`) + return err +} diff --git a/model/playlist.go b/model/playlist.go index b6a52dcf2..5c9052eb7 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -9,24 +9,26 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/utils" ) type Playlist struct { - ID string `structs:"id" json:"id"` - Name string `structs:"name" json:"name"` - Comment string `structs:"comment" json:"comment"` - Duration float32 `structs:"duration" json:"duration"` - Size int64 `structs:"size" json:"size"` - SongCount int `structs:"song_count" json:"songCount"` - OwnerName string `structs:"-" json:"ownerName"` - OwnerID string `structs:"owner_id" json:"ownerId"` - Public bool `structs:"public" json:"public"` - Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` - Path string `structs:"path" json:"path"` - Sync bool `structs:"sync" json:"sync"` - ImageFile string `structs:"image_file" json:"imageFile"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ID string `structs:"id" json:"id"` + Name string `structs:"name" json:"name"` + Comment string `structs:"comment" json:"comment"` + Duration float32 `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + SongCount int `structs:"song_count" json:"songCount"` + OwnerName string `structs:"-" json:"ownerName"` + OwnerID string `structs:"owner_id" json:"ownerId"` + Public bool `structs:"public" json:"public"` + Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` + Path string `structs:"path" json:"path"` + Sync bool `structs:"sync" json:"sync"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage"` + ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` @@ -106,15 +108,29 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { pls.refreshStats() } +// ImageFilename returns a human-friendly filename for an uploaded playlist cover image. +// Format: _, falling back to if the name cleans to empty. +func (pls Playlist) ImageFilename(ext string) string { + clean := utils.CleanFileName(pls.Name) + if clean == "" { + return pls.ID + ext + } + return pls.ID + "_" + clean + ext +} + func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } -func (pls Playlist) ArtworkPath() string { - if pls.ImageFile == "" { +// UploadedImagePath returns the absolute filesystem path for a manually uploaded +// playlist cover image. Returns empty string if no image has been uploaded. +// This does NOT cover sidecar images or external URLs — those are resolved +// by the artwork reader's fallback chain. +func (pls Playlist) UploadedImagePath() string { + if pls.UploadedImage == "" { return "" } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.ImageFile) + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage) } type Playlists []Playlist diff --git a/model/playlist_test.go b/model/playlist_test.go index a54cecd53..98dd4e978 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -7,6 +7,28 @@ import ( ) var _ = Describe("Playlist", func() { + Describe("ImageFilename", func() { + It("returns ID_cleanname.ext for a normal name", func() { + pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"} + Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg")) + }) + + It("falls back to ID.ext when name cleans to empty", func() { + pls := model.Playlist{ID: "abc123", Name: "!!!"} + Expect(pls.ImageFilename(".png")).To(Equal("abc123.png")) + }) + + It("falls back to ID.ext for empty name", func() { + pls := model.Playlist{ID: "abc123", Name: ""} + Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg")) + }) + + It("handles names with special characters", func() { + pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"} + Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp")) + }) + }) + Describe("ToM3U8()", func() { var pls model.Playlist BeforeEach(func() { diff --git a/tests/fixtures/playlists/pls-with-art-url.m3u b/tests/fixtures/playlists/pls-with-art-url.m3u new file mode 100644 index 000000000..9dbf180f8 --- /dev/null +++ b/tests/fixtures/playlists/pls-with-art-url.m3u @@ -0,0 +1,5 @@ +#EXTM3U +#PLAYLIST:Playlist With Art +#EXTALBUMARTURL:https://example.com/cover.jpg +test.mp3 +test.ogg diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 4c242dd2a..b24446cb9 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -246,7 +246,7 @@ const PlaylistDetails = (props) => { - {record.imageFile && ( + {record.uploadedImage && ( diff --git a/utils/files.go b/utils/files.go index 9bdc262c5..2fce307ea 100644 --- a/utils/files.go +++ b/utils/files.go @@ -4,11 +4,14 @@ import ( "os" "path" "path/filepath" + "regexp" "strings" "github.com/navidrome/navidrome/model/id" ) +var cleanFileNameRe = regexp.MustCompile(`[^a-z0-9_-]`) + func TempFileName(prefix, suffix string) string { return filepath.Join(os.TempDir(), prefix+id.NewRandom()+suffix) } @@ -18,6 +21,20 @@ func BaseName(filePath string) string { return strings.TrimSuffix(p, path.Ext(p)) } +// CleanFileName produces a filesystem-safe, human-readable version of a name. +// It lowercases, replaces spaces with underscores, strips non-alphanumeric +// characters (except underscore and hyphen), and truncates to 50 characters. +func CleanFileName(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = strings.ReplaceAll(s, " ", "_") + s = cleanFileNameRe.ReplaceAllString(s, "") + if len(s) > 50 { + s = s[:50] + } + s = strings.TrimRight(s, "_-") + return s +} + // FileExists checks if a file or directory exists func FileExists(path string) bool { _, err := os.Stat(path) diff --git a/utils/files_test.go b/utils/files_test.go index dcb28aafb..72fc4f96f 100644 --- a/utils/files_test.go +++ b/utils/files_test.go @@ -99,6 +99,49 @@ var _ = Describe("BaseName", func() { }) }) +var _ = Describe("CleanFileName", func() { + It("lowercases and replaces spaces with underscores", func() { + Expect(utils.CleanFileName("My Cool Playlist")).To(Equal("my_cool_playlist")) + }) + + It("strips special characters", func() { + Expect(utils.CleanFileName("Rock & Roll! (2024)")).To(Equal("rock__roll_2024")) + }) + + It("handles unicode characters", func() { + Expect(utils.CleanFileName("Música Favorita")).To(Equal("msica_favorita")) + }) + + It("preserves hyphens", func() { + Expect(utils.CleanFileName("lo-fi beats")).To(Equal("lo-fi_beats")) + }) + + It("returns empty string for empty input", func() { + Expect(utils.CleanFileName("")).To(BeEmpty()) + }) + + It("returns empty string for whitespace-only input", func() { + Expect(utils.CleanFileName(" ")).To(BeEmpty()) + }) + + It("returns empty string when all characters are stripped", func() { + Expect(utils.CleanFileName("!!!@@@###")).To(BeEmpty()) + }) + + It("truncates to 50 characters", func() { + long := strings.Repeat("abcdefghij", 10) // 100 chars + result := utils.CleanFileName(long) + Expect(len(result)).To(Equal(50)) + }) + + It("trims trailing underscores and hyphens after truncation", func() { + // 49 a's + space + "b" = after clean: 49 a's + "_b" = 51 chars, truncated to 50 = 49 a's + "_" + name := strings.Repeat("a", 49) + " b" + result := utils.CleanFileName(name) + Expect(result).To(Equal(strings.Repeat("a", 49))) + }) +}) + var _ = Describe("FileExists", func() { var tempFile *os.File var tempDir string From 3d86d44fd9fd504e0ec6ee257fddd532accb390a Mon Sep 17 00:00:00 2001 From: Lokke Date: Mon, 2 Mar 2026 17:51:32 +0100 Subject: [PATCH 041/596] feat(server): add averageRating to smart playlists (#5092) --- model/criteria/fields.go | 1 + 1 file changed, 1 insertion(+) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 4dfc50f11..ed73de9f9 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -60,6 +60,7 @@ var fieldMap = map[string]*mappedField{ "daterated": {field: "annotation.rated_at"}, "playcount": {field: "COALESCE(annotation.play_count, 0)"}, "rating": {field: "COALESCE(annotation.rating, 0)"}, + "averagerating": {field: "media_file.average_rating", numeric: true}, "albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation}, "albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation}, "albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation}, From 82f9f88c0f31762ac1a7e68f6356495db69f6c46 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 2 Mar 2026 13:15:31 -0500 Subject: [PATCH 042/596] refactor(auth): replace untyped JWT claims with typed Claims struct Introduced a typed Claims struct in core/auth to replace the raw map[string]any approach used for JWT claims throughout the codebase. This provides compile-time safety and better readability when creating, validating, and extracting JWT tokens. Also upgraded lestrrat-go/jwx from v2 to v3 and go-chi/jwtauth to v5.4.0, adapting all callers to the new API where token accessor methods now return tuples instead of bare values. Updated all affected handlers, middleware, and tests. Signed-off-by: Deluan --- adapters/deezer/client_auth.go | 6 +- adapters/deezer/client_auth_test.go | 5 +- core/auth/auth.go | 58 +++++++---------- core/auth/auth_test.go | 15 ++--- core/auth/claims.go | 94 +++++++++++++++++++++++++++ core/auth/claims_test.go | 99 +++++++++++++++++++++++++++++ core/publicurl/publicurl.go | 2 +- go.mod | 14 ++-- go.sum | 28 ++++---- plugins/host_artwork_test.go | 7 +- server/auth.go | 12 ++-- server/public/handle_images.go | 19 ++---- server/public/handle_images_test.go | 12 +--- server/public/handle_shares.go | 10 ++- server/public/handle_streams.go | 26 +++----- server/subsonic/middlewares.go | 2 +- 16 files changed, 284 insertions(+), 125 deletions(-) create mode 100644 core/auth/claims.go create mode 100644 core/auth/claims_test.go diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go index d0924b768..eb664c00b 100644 --- a/adapters/deezer/client_auth.go +++ b/adapters/deezer/client_auth.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/log" ) @@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) { } // Calculate TTL with a 1-minute buffer for clock skew and network delays - expiresAt := token.Expiration() - if expiresAt.IsZero() { + expiresAt, ok := token.Expiration() + if !ok || expiresAt.IsZero() { return "", errors.New("deezer: JWT token has no expiration time") } diff --git a/adapters/deezer/client_auth_test.go b/adapters/deezer/client_auth_test.go index 59add7097..005a84e1a 100644 --- a/adapters/deezer/client_auth_test.go +++ b/adapters/deezer/client_auth_test.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -179,7 +179,8 @@ var _ = Describe("JWT Authentication", func() { Expect(err).To(BeNil()) // Verify token has no expiration - Expect(testToken.Expiration().IsZero()).To(BeTrue()) + _, hasExp := testToken.Expiration() + Expect(hasExp).To(BeFalse()) testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) Expect(err).To(BeNil()) diff --git a/core/auth/auth.go b/core/auth/auth.go index e03820a17..f7ab3ac1b 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,12 +4,11 @@ import ( "cmp" "context" "crypto/sha256" - "maps" "sync" "time" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -46,38 +45,30 @@ func Init(ds model.DataStore) { }) } -func createBaseClaims() map[string]any { - tokenClaims := map[string]any{} - tokenClaims[jwt.IssuerKey] = consts.JWTIssuer - return tokenClaims -} - -func CreatePublicToken(claims map[string]any) (string, error) { - tokenClaims := createBaseClaims() - maps.Copy(tokenClaims, claims) - _, token, err := TokenAuth.Encode(tokenClaims) - +func CreatePublicToken(claims Claims) (string, error) { + claims.Issuer = consts.JWTIssuer + _, token, err := TokenAuth.Encode(claims.ToMap()) return token, err } -func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) { - tokenClaims := createBaseClaims() +func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { + claims.Issuer = consts.JWTIssuer if !exp.IsZero() { - tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix() + claims.ExpiresAt = exp } - maps.Copy(tokenClaims, claims) - _, token, err := TokenAuth.Encode(tokenClaims) - + _, token, err := TokenAuth.Encode(claims.ToMap()) return token, err } func CreateToken(u *model.User) (string, error) { - claims := createBaseClaims() - claims[jwt.SubjectKey] = u.UserName - claims[jwt.IssuedAtKey] = time.Now().UTC().Unix() - claims["uid"] = u.ID - claims["adm"] = u.IsAdmin - token, _, err := TokenAuth.Encode(claims) + claims := Claims{ + Issuer: consts.JWTIssuer, + Subject: u.UserName, + IssuedAt: time.Now(), + UserID: u.ID, + IsAdmin: u.IsAdmin, + } + token, _, err := TokenAuth.Encode(claims.ToMap()) if err != nil { return "", err } @@ -86,23 +77,18 @@ func CreateToken(u *model.User) (string, error) { } func TouchToken(token jwt.Token) (string, error) { - claims, err := token.AsMap(context.Background()) - if err != nil { - return "", err - } - - claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix() - _, newToken, err := TokenAuth.Encode(claims) - + claims := ClaimsFromToken(token). + WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) + _, newToken, err := TokenAuth.Encode(claims.ToMap()) return newToken, err } -func Validate(tokenStr string) (map[string]any, error) { +func Validate(tokenStr string) (Claims, error) { token, err := jwtauth.VerifyToken(TokenAuth, tokenStr) if err != nil { - return nil, err + return Claims{}, err } - return token.AsMap(context.Background()) + return ClaimsFromToken(token), nil } func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 38f6820f5..761dd205c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -54,7 +54,7 @@ var _ = Describe("Auth", func() { decodedClaims, err := auth.Validate(tokenStr) Expect(err).NotTo(HaveOccurred()) - Expect(decodedClaims["iss"]).To(Equal("issuer")) + Expect(decodedClaims.Issuer).To(Equal("issuer")) }) It("returns ErrExpired if the `exp` field is in the past", func() { @@ -82,11 +82,11 @@ var _ = Describe("Auth", func() { claims, err := auth.Validate(tokenStr) Expect(err).NotTo(HaveOccurred()) - Expect(claims["iss"]).To(Equal(consts.JWTIssuer)) - Expect(claims["sub"]).To(Equal("johndoe")) - Expect(claims["uid"]).To(Equal("123")) - Expect(claims["adm"]).To(Equal(true)) - Expect(claims["exp"]).To(BeTemporally(">", time.Now())) + Expect(claims.Issuer).To(Equal(consts.JWTIssuer)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.UserID).To(Equal("123")) + Expect(claims.IsAdmin).To(Equal(true)) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) }) }) @@ -104,8 +104,7 @@ var _ = Describe("Auth", func() { decodedClaims, err := auth.Validate(touched) Expect(err).NotTo(HaveOccurred()) - exp := decodedClaims["exp"].(time.Time) - Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay)) + Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go new file mode 100644 index 000000000..ca496ae9a --- /dev/null +++ b/core/auth/claims.go @@ -0,0 +1,94 @@ +package auth + +import ( + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// Claims represents the typed JWT claims used throughout Navidrome, +// replacing the untyped map[string]any approach. +type Claims struct { + // Standard JWT claims + Issuer string + Subject string // username for session tokens + IssuedAt time.Time + ExpiresAt time.Time + + // Custom claims + UserID string // "uid" + IsAdmin bool // "adm" + ID string // "id" - artwork/mediafile ID + Format string // "f" - audio format + BitRate int // "b" - audio bitrate +} + +// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). +// Only non-zero fields are included. +func (c Claims) ToMap() map[string]any { + m := make(map[string]any) + if c.Issuer != "" { + m[jwt.IssuerKey] = c.Issuer + } + if c.Subject != "" { + m[jwt.SubjectKey] = c.Subject + } + if !c.IssuedAt.IsZero() { + m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() + } + if !c.ExpiresAt.IsZero() { + m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix() + } + if c.UserID != "" { + m["uid"] = c.UserID + } + if c.IsAdmin { + m["adm"] = c.IsAdmin + } + if c.ID != "" { + m["id"] = c.ID + } + if c.Format != "" { + m["f"] = c.Format + } + if c.BitRate != 0 { + m["b"] = c.BitRate + } + return m +} + +func (c Claims) WithExpiresAt(t time.Time) Claims { + c.ExpiresAt = t + return c +} + +// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get(). +func ClaimsFromToken(token jwt.Token) Claims { + var c Claims + c.Issuer, _ = token.Issuer() + c.Subject, _ = token.Subject() + c.IssuedAt, _ = token.IssuedAt() + c.ExpiresAt, _ = token.Expiration() + + var uid string + if err := token.Get("uid", &uid); err == nil { + c.UserID = uid + } + var adm bool + if err := token.Get("adm", &adm); err == nil { + c.IsAdmin = adm + } + var id string + if err := token.Get("id", &id); err == nil { + c.ID = id + } + var f string + if err := token.Get("f", &f); err == nil { + c.Format = f + } + var b int + if err := token.Get("b", &b); err == nil { + c.BitRate = b + } + return c +} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go new file mode 100644 index 000000000..cf6b07263 --- /dev/null +++ b/core/auth/claims_test.go @@ -0,0 +1,99 @@ +package auth_test + +import ( + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Claims", func() { + Describe("ToMap", func() { + It("includes only non-zero fields", func() { + c := auth.Claims{ + Issuer: "ND", + Subject: "johndoe", + UserID: "123", + IsAdmin: true, + } + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("iss", "ND")) + Expect(m).To(HaveKeyWithValue("sub", "johndoe")) + Expect(m).To(HaveKeyWithValue("uid", "123")) + Expect(m).To(HaveKeyWithValue("adm", true)) + Expect(m).NotTo(HaveKey("exp")) + Expect(m).NotTo(HaveKey("iat")) + Expect(m).NotTo(HaveKey("id")) + Expect(m).NotTo(HaveKey("f")) + Expect(m).NotTo(HaveKey("b")) + }) + + It("includes expiration and issued-at when set", func() { + now := time.Now() + c := auth.Claims{ + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + } + m := c.ToMap() + Expect(m).To(HaveKey("iat")) + Expect(m).To(HaveKey("exp")) + }) + + It("includes custom claims for public tokens", func() { + c := auth.Claims{ + ID: "al-123", + Format: "mp3", + BitRate: 192, + } + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("id", "al-123")) + Expect(m).To(HaveKeyWithValue("f", "mp3")) + Expect(m).To(HaveKeyWithValue("b", 192)) + }) + }) + + Describe("ClaimsFromToken", func() { + It("round-trips session claims through encode/decode", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + now := time.Now().Truncate(time.Second) + original := auth.Claims{ + Issuer: "ND", + Subject: "johndoe", + UserID: "123", + IsAdmin: true, + } + m := original.ToMap() + m["iat"] = now.UTC().Unix() + token, _, err := tokenAuth.Encode(m) + Expect(err).NotTo(HaveOccurred()) + + c := auth.ClaimsFromToken(token) + Expect(c.Issuer).To(Equal("ND")) + Expect(c.Subject).To(Equal("johndoe")) + Expect(c.UserID).To(Equal("123")) + Expect(c.IsAdmin).To(BeTrue()) + Expect(c.IssuedAt.UTC()).To(Equal(now.UTC())) + }) + + It("round-trips public token claims through encode/decode", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + original := auth.Claims{ + Issuer: "ND", + ID: "al-456", + Format: "opus", + BitRate: 128, + } + token, _, err := tokenAuth.Encode(original.ToMap()) + Expect(err).NotTo(HaveOccurred()) + + c := auth.ClaimsFromToken(token) + Expect(c.Issuer).To(Equal("ND")) + Expect(c.ID).To(Equal("al-456")) + Expect(c.Format).To(Equal("opus")) + Expect(c.BitRate).To(Equal(128)) + }) + }) + +}) diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index ff6f4221e..c1b8e01c4 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -18,7 +18,7 @@ import ( // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { - token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) + token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { diff --git a/go.mod b/go.mod index c52bb211d..1f6fa30a6 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 - github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc @@ -43,7 +43,7 @@ require ( github.com/kardianos/service v1.2.4 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/lestrrat-go/jwx/v3 v3.0.13 github.com/maruel/natural v1.3.0 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/mattn/go-sqlite3 v1.14.34 @@ -98,7 +98,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect + github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -109,10 +109,11 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -134,6 +135,7 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index bbfd51e11..26e4d3925 100644 --- a/go.sum +++ b/go.sum @@ -83,8 +83,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= -github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= -github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= +github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= -github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -163,16 +163,18 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= +github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= @@ -296,6 +298,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 5e3c54a80..b97e2684d 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -229,11 +229,10 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID { token, err := auth.TokenAuth.Decode(tokenPart) Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") - claims, err := token.AsMap(context.Background()) - Expect(err).ToNot(HaveOccurred(), "Failed to get claims from token") + c := auth.ClaimsFromToken(token) - id, ok := claims["id"].(string) - Expect(ok).To(BeTrue(), "Token should contain 'id' claim") + id := c.ID + Expect(id).ToNot(BeEmpty(), "Token should contain 'id' claim") artID, err := model.ParseArtworkID(id) Expect(err).ToNot(HaveOccurred(), "Failed to parse artwork ID from token") diff --git a/server/auth.go b/server/auth.go index 86e63722b..a7edaab0a 100644 --- a/server/auth.go +++ b/server/auth.go @@ -185,12 +185,16 @@ func tokenFromHeader(r *http.Request) string { } func UsernameFromToken(r *http.Request) string { - token, claims, err := jwtauth.FromContext(r.Context()) - if err != nil || claims["sub"] == nil || token == nil { + token, _, err := jwtauth.FromContext(r.Context()) + if err != nil || token == nil { return "" } - log.Trace(r, "Found username in JWT token", "username", token.Subject()) - return token.Subject() + sub, _ := token.Subject() + if sub == "" { + return "" + } + log.Trace(r, "Found username in JWT token", "username", sub) + return sub } func UsernameFromExtAuthHeader(r *http.Request) string { diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 5b1194cc9..f4985dea5 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -7,7 +7,6 @@ import ( "net/http" "time" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" @@ -76,22 +75,14 @@ func decodeArtworkID(tokenString string) (model.ArtworkID, error) { if token == nil { return model.ArtworkID{}, errors.New("unauthorized") } - err = jwt.Validate(token, jwt.WithRequiredClaim("id")) - if err != nil { - return model.ArtworkID{}, err + c := auth.ClaimsFromToken(token) + if c.ID == "" { + return model.ArtworkID{}, errors.New("required claim \"id\" not found") } - claims, err := token.AsMap(context.Background()) - if err != nil { - return model.ArtworkID{}, err - } - id, ok := claims["id"].(string) - if !ok { - return model.ArtworkID{}, errors.New("invalid id type") - } - artID, err := model.ParseArtworkID(id) + artID, err := model.ParseArtworkID(c.ID) if err == nil { return artID, nil } // Try to default to mediafile artworkId (if used with a mediafileShare token) - return model.ParseArtworkID("mf-" + id) + return model.ParseArtworkID("mf-" + c.ID) } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 0995f4f61..6895241f6 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -3,7 +3,6 @@ package public import ( "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -15,18 +14,11 @@ var _ = Describe("decodeArtworkID", func() { It("fails to decode an invalid token", func() { _, err := decodeArtworkID("xx-123") - Expect(err).To(MatchError("invalid JWT")) - }) - - It("defaults to kind mediafile for empty artwork ID", func() { - token, _ := auth.CreatePublicToken(map[string]any{"id": ""}) - id, err := decodeArtworkID(token) - Expect(err).ToNot(HaveOccurred()) - Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) + Expect(err).To(HaveOccurred()) }) It("fails to decode a token without an id", func() { - token, _ := auth.CreatePublicToken(map[string]any{}) + token, _ := auth.CreatePublicToken(auth.Claims{}) _, err := decodeArtworkID(token) Expect(err).To(HaveOccurred()) }) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 36764dece..15e63d4db 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,12 +97,10 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { } func encodeMediafileShare(s model.Share, id string) string { - claims := map[string]any{"id": id} - if s.Format != "" { - claims["f"] = s.Format - } - if s.MaxBitRate != 0 { - claims["b"] = s.MaxBitRate + claims := auth.Claims{ + ID: id, + Format: s.Format, + BitRate: s.MaxBitRate, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index cf120f0b5..d6819974b 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -1,13 +1,11 @@ package public import ( - "context" "errors" "io" "net/http" "strconv" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/req" @@ -85,21 +83,13 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if token == nil { return shareTrackInfo{}, errors.New("unauthorized") } - err = jwt.Validate(token, jwt.WithRequiredClaim("id")) - if err != nil { - return shareTrackInfo{}, err + c := auth.ClaimsFromToken(token) + if c.ID == "" { + return shareTrackInfo{}, errors.New("required claim \"id\" not found") } - claims, err := token.AsMap(context.Background()) - if err != nil { - return shareTrackInfo{}, err - } - id, ok := claims["id"].(string) - if !ok { - return shareTrackInfo{}, errors.New("invalid id type") - } - resp := shareTrackInfo{} - resp.id = id - resp.format, _ = claims["f"].(string) - resp.bitrate, _ = claims["b"].(int) - return resp, nil + return shareTrackInfo{ + id: c.ID, + format: c.Format, + bitrate: c.BitRate, + }, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index d984bac42..7698a3c7b 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -159,7 +159,7 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && claims["sub"] == user.UserName + valid = err == nil && claims.Subject == user.UserName case pass != "": if strings.HasPrefix(pass, "enc:") { if dec, err := hex.DecodeString(pass[4:]); err == nil { From 30df004d4d8916a3d11c14a188c03585d0f28233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 2 Mar 2026 16:18:30 -0500 Subject: [PATCH 043/596] test(plugins): speed up integration tests (~45% improvement) (#5137) * test(plugins): speed up integration tests with shared wazero cache Reduce plugin test suite runtime from ~22s to ~12s by: - Creating a shared wazero compilation cache directory in TestPlugins() and setting conf.Server.CacheFolder globally so all test Manager instances reuse compiled WASM binaries from disk cache - Moving 6 createTestManager* calls from inside It blocks to BeforeAll blocks in scrobbler_adapter_test.go and manager_call_test.go - Replacing time.Sleep(2s) in KVStore TTL test with Eventually polling - Reducing WebSocket callback sleeps from 100ms to 10ms Signed-off-by: Deluan * test(plugins): enhance websocket tests by storing server messages for verification Signed-off-by: Deluan --------- Signed-off-by: Deluan --- plugins/host_artwork_test.go | 1 - plugins/host_cache_test.go | 1 - plugins/host_config_test.go | 1 - plugins/host_kvstore_test.go | 20 ++-- plugins/host_library_test.go | 2 - plugins/host_scheduler_test.go | 1 - plugins/host_subsonicapi_test.go | 1 - plugins/host_users_test.go | 1 - plugins/host_websocket_test.go | 52 ++++------- plugins/manager_call_test.go | 84 ++++++++++------- plugins/plugins_suite_test.go | 15 ++- plugins/scrobbler_adapter_test.go | 116 +++++++++++++++--------- plugins/testdata/test-websocket/main.go | 5 +- 13 files changed, 165 insertions(+), 135 deletions(-) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index b97e2684d..151a0d03c 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -49,7 +49,6 @@ var _ = Describe("ArtworkService", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Initialize auth (required for token generation) ds := &tests.MockDataStore{MockedProperty: &tests.MockedPropertyRepo{}} diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index ec225c1c2..0f55bcfda 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -345,7 +345,6 @@ var _ = Describe("CacheService Integration", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index 5ad5af198..bd3368a67 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -59,7 +59,6 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Setup mock DataStore mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index b900a659a..4928825ef 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -677,7 +677,6 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") conf.Server.DataFolder = tmpDir // Setup mock DataStore with pre-enabled plugin @@ -924,16 +923,15 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { Expect(output.Exists).To(BeTrue()) Expect(output.Value).To(Equal([]byte("temporary"))) - // Wait for expiration - time.Sleep(2 * time.Second) - - // Should no longer exist - output, err = callTestKVStore(ctx, testKVStoreInput{ - Operation: "get", - Key: "ttl_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) + // Poll until the key expires (1s TTL) + Eventually(func(g Gomega) { + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "get", + Key: "ttl_key", + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(output.Exists).To(BeFalse()) + }).WithTimeout(3 * time.Second).WithPolling(200 * time.Millisecond).Should(Succeed()) }) It("should delete keys by prefix", func() { diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 413fc81c8..d92abfe30 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -264,7 +264,6 @@ var _ = Describe("LibraryService", Ordered, func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Create mock &tests.MockLibraryRepo{} mockLibRepo := &tests.MockLibraryRepo{} @@ -360,7 +359,6 @@ var _ = Describe("LibraryService Integration", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Setup mock DataStore with pre-enabled plugin and library mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index 51311f1c4..334d9b738 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -53,7 +53,6 @@ var _ = Describe("SchedulerService", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Create mock scheduler and timer registry mockSched = newMockScheduler() diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index b0589fa12..607f3a64b 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -46,7 +46,6 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Setup mock router and data store router = &fakeSubsonicRouter{} diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 2071a9320..1c0de7d03 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -486,7 +486,6 @@ func setupTestUsersConfig(tmpDir string) { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") } // testUsersInput represents input for test-users plugin calls diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 7a0439129..e8cb9f8fd 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -14,7 +14,6 @@ import ( "path/filepath" "strings" "sync" - "time" "github.com/gorilla/websocket" "github.com/navidrome/navidrome/conf" @@ -54,7 +53,6 @@ var _ = Describe("WebSocketService", Ordered, func() { conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() @@ -295,10 +293,12 @@ var _ = Describe("WebSocketService", Ordered, func() { Describe("Plugin Callbacks", func() { var wsServer *httptest.Server var serverConn *websocket.Conn + var serverMessages []string var serverMu sync.Mutex BeforeEach(func() { serverConn = nil + serverMessages = nil upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, @@ -312,12 +312,15 @@ var _ = Describe("WebSocketService", Ordered, func() { serverConn = conn serverMu.Unlock() - // Keep connection open + // Read and store messages for { - _, _, err := conn.ReadMessage() + _, msg, err := conn.ReadMessage() if err != nil { break } + serverMu.Lock() + serverMessages = append(serverMessages, string(msg)) + serverMu.Unlock() } })) @@ -336,36 +339,10 @@ var _ = Describe("WebSocketService", Ordered, func() { } }) - It("should invoke OnTextMessage callback when receiving text", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "text-cb-conn") - Expect(err).ToNot(HaveOccurred()) - - // Wait for server to have the connection - Eventually(func() *websocket.Conn { - serverMu.Lock() - defer serverMu.Unlock() - return serverConn - }).ShouldNot(BeNil()) - - // Send message from server to plugin - serverMu.Lock() - err = serverConn.WriteMessage(websocket.TextMessage, []byte("test message")) - serverMu.Unlock() - Expect(err).ToNot(HaveOccurred()) - - // The plugin should have received the callback - // We can verify by checking the plugin's stored messages via vars - // For now we just verify no errors occurred - time.Sleep(100 * time.Millisecond) - _ = connID - }) - It("should invoke OnBinaryMessage callback when receiving binary", func() { ctx := GinkgoT().Context() wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "binary-cb-conn") + _, err := testService.Connect(ctx, wsURL, nil, "binary-cb-conn") Expect(err).ToNot(HaveOccurred()) // Wait for server to have the connection @@ -382,9 +359,13 @@ var _ = Describe("WebSocketService", Ordered, func() { serverMu.Unlock() Expect(err).ToNot(HaveOccurred()) - // Give time for callback to execute - time.Sleep(100 * time.Millisecond) - _ = connID + // Plugin echoes binary data back as text prefixed with "binary_echo:" + expectedEcho := "binary_echo:" + base64.StdEncoding.EncodeToString(binaryData) + Eventually(func() []string { + serverMu.Lock() + defer serverMu.Unlock() + return serverMessages + }).Should(ContainElement(expectedEcho)) }) It("should invoke OnClose callback when server closes connection", func() { @@ -466,7 +447,7 @@ var _ = Describe("WebSocketService", Ordered, func() { It("should allow plugin to send messages via host function", func() { ctx := GinkgoT().Context() wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "host-send-conn") + _, err := testService.Connect(ctx, wsURL, nil, "host-send-conn") Expect(err).ToNot(HaveOccurred()) // Wait for server to have the connection @@ -488,7 +469,6 @@ var _ = Describe("WebSocketService", Ordered, func() { defer serverMu.Unlock() return serverMessages }).Should(ContainElement("echo:echo")) - _ = connID }) It("should allow plugin to close connection via host function", func() { diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 8865e1266..3e64f1cee 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -81,48 +81,66 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() { Expect(calls[0].elapsed).To(BeNumerically(">=", 0)) }) - It("records metrics for failed plugin calls (error returned)", func() { - // Create a manager with error config to force plugin errors - errorRecorder := &mockMetricsRecorder{} - errorManager, _ := createTestManagerWithPluginsAndMetrics( - map[string]map[string]string{ - "test-metadata-agent": {"error": "simulated error"}, - }, - errorRecorder, - "test-metadata-agent"+PackageExtension, + Context("with error config", Ordered, func() { + var ( + errorRecorder *mockMetricsRecorder + errorAgent agents.Interface ) - errorAgent, ok := errorManager.LoadMediaAgent("test-metadata-agent") - Expect(ok).To(BeTrue()) + BeforeAll(func() { + errorRecorder = &mockMetricsRecorder{} + errorManager, _ := createTestManagerWithPluginsAndMetrics( + map[string]map[string]string{ + "test-metadata-agent": {"error": "simulated error"}, + }, + errorRecorder, + "test-metadata-agent"+PackageExtension, + ) - retriever := errorAgent.(agents.ArtistBiographyRetriever) - _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).To(HaveOccurred()) + var ok bool + errorAgent, ok = errorManager.LoadMediaAgent("test-metadata-agent") + Expect(ok).To(BeTrue()) + }) - calls := errorRecorder.getCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].plugin).To(Equal("test-metadata-agent")) - Expect(calls[0].method).To(Equal(FuncGetArtistBiography)) - Expect(calls[0].ok).To(BeFalse()) + It("records metrics for failed plugin calls (error returned)", func() { + retriever := errorAgent.(agents.ArtistBiographyRetriever) + _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + Expect(err).To(HaveOccurred()) + + calls := errorRecorder.getCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].plugin).To(Equal("test-metadata-agent")) + Expect(calls[0].method).To(Equal(FuncGetArtistBiography)) + Expect(calls[0].ok).To(BeFalse()) + }) }) - It("does not record metrics for not-implemented functions", func() { - // Use partial metadata agent that doesn't implement GetArtistMBID - partialRecorder := &mockMetricsRecorder{} - partialManager, _ := createTestManagerWithPluginsAndMetrics( - nil, - partialRecorder, - "partial-metadata-agent"+PackageExtension, + Context("with partial metadata agent", Ordered, func() { + var ( + partialRecorder *mockMetricsRecorder + partialAgent agents.Interface ) - partialAgent, ok := partialManager.LoadMediaAgent("partial-metadata-agent") - Expect(ok).To(BeTrue()) + BeforeAll(func() { + partialRecorder = &mockMetricsRecorder{} + partialManager, _ := createTestManagerWithPluginsAndMetrics( + nil, + partialRecorder, + "partial-metadata-agent"+PackageExtension, + ) - retriever := partialAgent.(agents.ArtistMBIDRetriever) - _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") - Expect(err).To(MatchError(errNotImplemented)) + var ok bool + partialAgent, ok = partialManager.LoadMediaAgent("partial-metadata-agent") + Expect(ok).To(BeTrue()) + }) - calls := partialRecorder.getCalls() - Expect(calls).To(HaveLen(0)) + It("does not record metrics for not-implemented functions", func() { + retriever := partialAgent.(agents.ArtistMBIDRetriever) + _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") + Expect(err).To(MatchError(errNotImplemented)) + + calls := partialRecorder.getCalls() + Expect(calls).To(HaveLen(0)) + }) }) }) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 3b5e610d6..1799ba3ce 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -36,6 +36,20 @@ var ( func TestPlugins(t *testing.T) { tests.Init(t, false) buildTestPlugins(t, testDataDir) + + // Create a shared wazero compilation cache directory. + // All test managers will point CacheFolder here so that WASM compilation + // is done once per binary and then reused from disk cache. + sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*") + if err != nil { + t.Fatalf("Failed to create shared cache dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sharedCacheDir) }) + + // Set CacheFolder globally so all tests (including those using + // configtest.SetupConfig) inherit it without needing to set it manually. + conf.Server.CacheFolder = sharedCacheDir + log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Plugins Suite") @@ -114,7 +128,6 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s conf.Server.Plugins.Enabled = true conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") // Setup mock DataStore with pre-enabled plugins mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 05fc11757..ab8dc6f88 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -58,16 +58,23 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { Expect(result).To(BeTrue()) }) - It("returns false when plugin is configured to not authorize", func() { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"authorized": "false"}, - }, "test-scrobbler"+PackageExtension) + Context("when plugin is configured to not authorize", Ordered, func() { + var notAuthScrobbler scrobbler.Scrobbler - sc, ok := manager.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"authorized": "false"}, + }, "test-scrobbler"+PackageExtension) - result := sc.IsAuthorized(ctxWithUser(), "user-1") - Expect(result).To(BeFalse()) + var ok bool + notAuthScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns false", func() { + result := notAuthScrobbler.IsAuthorized(ctxWithUser(), "user-1") + Expect(result).To(BeFalse()) + }) }) }) @@ -127,18 +134,25 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) }) - It("returns error when plugin returns error", func() { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, - }, "test-scrobbler"+PackageExtension) + Context("when plugin returns error", Ordered, func() { + var retryScrobbler scrobbler.Scrobbler - sc, ok := manager.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, + }, "test-scrobbler"+PackageExtension) - track := &model.MediaFile{ID: "track-1", Title: "Test Song"} - err := sc.NowPlaying(ctxWithUser(), "user-1", track, 30) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + var ok bool + retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrRetryLater", func() { + track := &model.MediaFile{ID: "track-1", Title: "Test Song"} + err := retryScrobbler.NowPlaying(ctxWithUser(), "user-1", track, 30) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) }) }) @@ -166,38 +180,52 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) }) - It("returns error when plugin returns not_authorized error", func() { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"error": "user not linked", "error_type": "scrobbler(not_authorized)"}, - }, "test-scrobbler"+PackageExtension) + Context("when plugin returns not_authorized error", Ordered, func() { + var notAuthScrobbler scrobbler.Scrobbler - sc, ok := manager.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "user not linked", "error_type": "scrobbler(not_authorized)"}, + }, "test-scrobbler"+PackageExtension) - scrobble := scrobbler.Scrobble{ - MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, - TimeStamp: time.Now(), - } - err := sc.Scrobble(ctxWithUser(), "user-1", scrobble) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + var ok bool + notAuthScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrNotAuthorized", func() { + scrobble := scrobbler.Scrobble{ + MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, + TimeStamp: time.Now(), + } + err := notAuthScrobbler.Scrobble(ctxWithUser(), "user-1", scrobble) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) }) - It("returns error when plugin returns unrecoverable error", func() { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"error": "track rejected", "error_type": "scrobbler(unrecoverable)"}, - }, "test-scrobbler"+PackageExtension) + Context("when plugin returns unrecoverable error", Ordered, func() { + var unrecoverableScrobbler scrobbler.Scrobbler - sc, ok := manager.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "track rejected", "error_type": "scrobbler(unrecoverable)"}, + }, "test-scrobbler"+PackageExtension) - scrobble := scrobbler.Scrobble{ - MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, - TimeStamp: time.Now(), - } - err := sc.Scrobble(ctxWithUser(), "user-1", scrobble) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) + var ok bool + unrecoverableScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrUnrecoverable", func() { + scrobble := scrobbler.Scrobble{ + MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, + TimeStamp: time.Now(), + } + err := unrecoverableScrobbler.Scrobble(ctxWithUser(), "user-1", scrobble) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) + }) }) }) diff --git a/plugins/testdata/test-websocket/main.go b/plugins/testdata/test-websocket/main.go index 7e2683e4d..b85eaa358 100644 --- a/plugins/testdata/test-websocket/main.go +++ b/plugins/testdata/test-websocket/main.go @@ -45,10 +45,11 @@ func (t *testWebSocket) OnTextMessage(input websocket.OnTextMessageRequest) erro } // OnBinaryMessage is called when a binary message is received. +// Echoes the data back as a text message prefixed with "binary_echo:" so tests +// can observe the callback fired. func (t *testWebSocket) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - // Store received binary data for test verification storeReceivedMessage("binary:" + input.Data) - return nil + return host.WebSocketSendText(input.ConnectionID, "binary_echo:"+input.Data) } // OnError is called when an error occurs on a WebSocket connection. From 6fd044fb09f0ba9e8148ac0d519b78f477a9b653 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 2 Mar 2026 16:38:00 -0500 Subject: [PATCH 044/596] feat(plugins): change websockets Data field type to []byte for binary support Signed-off-by: Deluan --- plugins/capabilities/websocket_callback.go | 2 +- plugins/capabilities/websocket_callback.yaml | 1 + plugins/cmd/ndpgen/internal/xtp_schema.go | 8 +++- .../cmd/ndpgen/internal/xtp_schema_test.go | 39 +++++++++++++++++++ plugins/host_websocket.go | 3 +- plugins/host_websocket_test.go | 24 ++++++------ plugins/pdk/go/websocket/websocket.go | 2 +- plugins/pdk/go/websocket/websocket_stub.go | 2 +- .../rust/nd-pdk-capabilities/src/websocket.rs | 26 ++++++++++++- plugins/testdata/test-websocket/main.go | 9 +++-- 10 files changed, 94 insertions(+), 22 deletions(-) diff --git a/plugins/capabilities/websocket_callback.go b/plugins/capabilities/websocket_callback.go index 07db029f0..ddfc0fc95 100644 --- a/plugins/capabilities/websocket_callback.go +++ b/plugins/capabilities/websocket_callback.go @@ -38,7 +38,7 @@ type OnBinaryMessageRequest struct { // ConnectionID is the unique identifier for the WebSocket connection that received the message. ConnectionID string `json:"connectionId"` // Data is the binary data received from the WebSocket, encoded as base64. - Data string `json:"data"` + Data []byte `json:"data"` } // OnErrorRequest is the request provided when an error occurs on a WebSocket connection. diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml index 401c77bb4..6cd0cff9f 100644 --- a/plugins/capabilities/websocket_callback.yaml +++ b/plugins/capabilities/websocket_callback.yaml @@ -30,6 +30,7 @@ components: description: ConnectionID is the unique identifier for the WebSocket connection that received the message. data: type: string + format: byte description: Data is the binary data received from the WebSocket, encoded as base64. required: - connectionId diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index db30262cc..cc2a7d0e0 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -246,6 +246,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { return prop } + // Handle primitive types (including []byte which maps to string/byte, not array) + if isPrimitiveGoType(goType) { + prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType) + return prop + } + // Handle slice types if strings.HasPrefix(goType, "[]") { elemType := goType[2:] @@ -259,7 +265,7 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { return prop } - // Handle primitive types + // Handle remaining types prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType) return prop } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 5e8a132f2..2e28a75d8 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -303,6 +303,45 @@ var _ = Describe("XTP Schema Generation", func() { }) }) + Context("capability with []byte field", func() { + It("should map []byte to string with byte format, not array", func() { + capability := Capability{ + Name: "byte_test", + SourceFile: "byte_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Data", Type: "[]byte", JSONTag: "data"}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + Expect(ValidateXTPSchema(schema)).To(Succeed()) + + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + data := props["data"].(map[string]any) + Expect(data["type"]).To(Equal("string")) + Expect(data["format"]).To(Equal("byte")) + Expect(data).NotTo(HaveKey("items")) + }) + }) + Context("capability with nullable ref", func() { It("should mark pointer to enum as nullable with $ref", func() { capability := Capability{ diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 84b28dd35..74238a422 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -2,7 +2,6 @@ package plugins import ( "context" - "encoding/base64" "errors" "fmt" "maps" @@ -355,7 +354,7 @@ func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connecti func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) { invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{ ConnectionID: connectionID, - Data: base64.StdEncoding.EncodeToString(data), + Data: data, }, "binary message", connectionID) } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index e8cb9f8fd..83fca9898 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -5,7 +5,7 @@ package plugins import ( "context" "crypto/sha256" - "encoding/base64" + "encoding/hex" "maps" "net/http" @@ -294,11 +294,13 @@ var _ = Describe("WebSocketService", Ordered, func() { var wsServer *httptest.Server var serverConn *websocket.Conn var serverMessages []string + var serverBinaryMessages [][]byte var serverMu sync.Mutex BeforeEach(func() { serverConn = nil serverMessages = nil + serverBinaryMessages = nil upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, @@ -314,12 +316,16 @@ var _ = Describe("WebSocketService", Ordered, func() { // Read and store messages for { - _, msg, err := conn.ReadMessage() + msgType, msg, err := conn.ReadMessage() if err != nil { break } serverMu.Lock() - serverMessages = append(serverMessages, string(msg)) + if msgType == websocket.BinaryMessage { + serverBinaryMessages = append(serverBinaryMessages, msg) + } else { + serverMessages = append(serverMessages, string(msg)) + } serverMu.Unlock() } })) @@ -359,13 +365,12 @@ var _ = Describe("WebSocketService", Ordered, func() { serverMu.Unlock() Expect(err).ToNot(HaveOccurred()) - // Plugin echoes binary data back as text prefixed with "binary_echo:" - expectedEcho := "binary_echo:" + base64.StdEncoding.EncodeToString(binaryData) - Eventually(func() []string { + // Plugin echoes binary data back as a binary message + Eventually(func() [][]byte { serverMu.Lock() defer serverMu.Unlock() - return serverMessages - }).Should(ContainElement(expectedEcho)) + return serverBinaryMessages + }).Should(ContainElement(binaryData)) }) It("should invoke OnClose callback when server closes connection", func() { @@ -609,6 +614,3 @@ func findWebSocketService(m *Manager, pluginName string) *webSocketServiceImpl { } return nil } - -// Ensure base64 import is used -var _ = base64.StdEncoding diff --git a/plugins/pdk/go/websocket/websocket.go b/plugins/pdk/go/websocket/websocket.go index 0ad2cb549..47a53b7b3 100644 --- a/plugins/pdk/go/websocket/websocket.go +++ b/plugins/pdk/go/websocket/websocket.go @@ -16,7 +16,7 @@ type OnBinaryMessageRequest struct { // ConnectionID is the unique identifier for the WebSocket connection that received the message. ConnectionID string `json:"connectionId"` // Data is the binary data received from the WebSocket, encoded as base64. - Data string `json:"data"` + Data []byte `json:"data"` } // OnCloseRequest is the request provided when a WebSocket connection is closed. diff --git a/plugins/pdk/go/websocket/websocket_stub.go b/plugins/pdk/go/websocket/websocket_stub.go index 1c808d92f..214118a89 100644 --- a/plugins/pdk/go/websocket/websocket_stub.go +++ b/plugins/pdk/go/websocket/websocket_stub.go @@ -13,7 +13,7 @@ type OnBinaryMessageRequest struct { // ConnectionID is the unique identifier for the WebSocket connection that received the message. ConnectionID string `json:"connectionId"` // Data is the binary data received from the WebSocket, encoded as base64. - Data string `json:"data"` + Data []byte `json:"data"` } // OnCloseRequest is the request provided when a WebSocket connection is closed. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs index b077110d3..672233e4b 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs @@ -4,6 +4,29 @@ // It is intended for use in Navidrome plugins built with extism-pdk. use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} // Helper functions for skip_serializing_if with numeric types #[allow(dead_code)] @@ -27,7 +50,8 @@ pub struct OnBinaryMessageRequest { pub connection_id: String, /// Data is the binary data received from the WebSocket, encoded as base64. #[serde(default)] - pub data: String, + #[serde(with = "base64_bytes")] + pub data: Vec, } /// OnCloseRequest is the request provided when a WebSocket connection is closed. #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/plugins/testdata/test-websocket/main.go b/plugins/testdata/test-websocket/main.go index b85eaa358..270f36a11 100644 --- a/plugins/testdata/test-websocket/main.go +++ b/plugins/testdata/test-websocket/main.go @@ -3,6 +3,7 @@ package main import ( + "encoding/base64" "errors" "github.com/navidrome/navidrome/plugins/pdk/go/host" @@ -45,11 +46,11 @@ func (t *testWebSocket) OnTextMessage(input websocket.OnTextMessageRequest) erro } // OnBinaryMessage is called when a binary message is received. -// Echoes the data back as a text message prefixed with "binary_echo:" so tests -// can observe the callback fired. +// Echoes the data back as a binary message so tests can observe the callback fired. func (t *testWebSocket) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - storeReceivedMessage("binary:" + input.Data) - return host.WebSocketSendText(input.ConnectionID, "binary_echo:"+input.Data) + encoded := base64.StdEncoding.EncodeToString(input.Data) + storeReceivedMessage("binary:" + encoded) + return host.WebSocketSendBinary(input.ConnectionID, input.Data) } // OnError is called when an error occurs on a WebSocket connection. From 435fb0b0768b5059992605bb0c0eb2c4812cdbda Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 2 Mar 2026 16:59:05 -0500 Subject: [PATCH 045/596] feat(server): add EnableCoverArtUpload config option Allow administrators to disable playlist cover art upload/removal for non-admin users via the new EnableCoverArtUpload config option (default: true). - Guard uploadPlaylistImage and deletePlaylistImage endpoints (403 for non-admin when disabled) - Set CoverArtRole in Subsonic GetUser/GetUsers responses based on config and admin status - Pass config to frontend and conditionally hide upload/remove UI controls - Admins always retain upload capability regardless of setting --- conf/configuration.go | 2 + server/nativeapi/playlists.go | 12 +++ server/nativeapi/playlists_test.go | 126 +++++++++++++++++++++------- server/serve_index.go | 1 + server/subsonic/users.go | 1 + server/subsonic/users_test.go | 16 ++++ ui/src/config.js | 1 + ui/src/playlist/PlaylistDetails.jsx | 5 +- 8 files changed, 131 insertions(+), 33 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 0d32815ee..b6f65183a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -76,6 +76,7 @@ type configOptions struct { EnableFavourites bool EnableStarRating bool EnableUserEditing bool + EnableCoverArtUpload bool EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -668,6 +669,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) + viper.SetDefault("enablecoverartupload", true) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index c7230a209..797654a3b 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -17,9 +17,11 @@ import ( "github.com/deluan/rest" "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/req" _ "golang.org/x/image/webp" ) @@ -237,6 +239,11 @@ const maxImageSize = 10 << 20 // 10MB func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + user, _ := request.UserFrom(ctx) + if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { + http.Error(w, "cover art upload is disabled", http.StatusForbidden) + return + } p := req.Params(r) playlistId, _ := p.String(":id") @@ -306,6 +313,11 @@ func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + user, _ := request.UserFrom(ctx) + if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { + http.Error(w, "cover art upload is disabled", http.StatusForbidden) + return + } p := req.Params(r) playlistId, _ := p.String(":id") diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 961d10b68..7f0cd7de1 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -3,6 +3,7 @@ package nativeapi import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "time" @@ -14,50 +15,56 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "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 -} +var _ = Describe("Playlist Image Endpoints", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) -func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) { - return int64(len(m.tracks)), nil -} + DescribeTable("uploadPlaylistImage guard", + func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableCoverArtUpload = enableCoverArtUpload + handler := uploadPlaylistImage(&mockPlaylistsService{}) -func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) { - return m.tracks, nil -} + req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) + req = req.WithContext(ctx) -func (m *mockPlaylistTrackRepo) EntityName() string { - return "playlist_track" -} + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + Expect(w.Code).To(Equal(expectedStatus)) + }, + Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest), + Entry("enabled, admin passes guard", true, true, http.StatusBadRequest), + Entry("disabled, admin passes guard", false, true, http.StatusBadRequest), + Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), + ) -func (m *mockPlaylistTrackRepo) NewInstance() any { - return &model.PlaylistTrack{} -} + DescribeTable("deletePlaylistImage guard", + func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableCoverArtUpload = enableCoverArtUpload + handler := deletePlaylistImage(&mockPlaylistsService{}) -func (m *mockPlaylistTrackRepo) Read(id string) (any, error) { - for _, t := range m.tracks { - if t.ID == id { - return &t, nil - } - } - return nil, rest.ErrNotFound -} + req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) + req = req.WithContext(ctx) -type mockPlaylistsService struct { - playlists.Playlists - tracksRepo rest.Repository -} - -func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository { - return m.tracksRepo -} + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + Expect(w.Code).To(Equal(expectedStatus)) + }, + Entry("enabled, regular user passes guard", true, false, http.StatusNotFound), + Entry("enabled, admin passes guard", true, true, http.StatusNotFound), + Entry("disabled, admin passes guard", false, true, http.StatusNotFound), + Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), + ) +}) var _ = Describe("Playlist Tracks Endpoint", func() { var ( @@ -174,3 +181,58 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) }) }) + +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 +} + +type mockPlaylistsService struct { + playlists.Playlists + tracksRepo rest.Repository + removeImageFn func(ctx context.Context, id string) error + setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error +} + +func (m *mockPlaylistsService) RemoveImage(ctx context.Context, id string) error { + if m.removeImageFn != nil { + return m.removeImageFn(ctx, id) + } + return model.ErrNotFound +} + +func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader io.Reader, ext string) error { + if m.setImageFn != nil { + return m.setImageFn(ctx, id, reader, ext) + } + return model.ErrNotFound +} + +func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository { + return m.tracksRepo +} diff --git a/server/serve_index.go b/server/serve_index.go index 92ef47e23..6b0c890a6 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -61,6 +61,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, "enableUserEditing": conf.Server.EnableUserEditing, + "enableCoverArtUpload": conf.Server.EnableCoverArtUpload, "enableSharing": conf.Server.EnableSharing, "shareURL": conf.Server.ShareURL, "defaultDownloadableShare": conf.Server.DefaultDownloadableShare, diff --git a/server/subsonic/users.go b/server/subsonic/users.go index aeac6992b..4f6dccaac 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -22,6 +22,7 @@ func buildUserResponse(user model.User) responses.User { ScrobblingEnabled: true, DownloadRole: conf.Server.EnableDownloads, ShareRole: conf.Server.EnableSharing, + CoverArtRole: conf.Server.EnableCoverArtUpload || user.IsAdmin, Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }), } diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go index 95e16590d..1fd5dce71 100644 --- a/server/subsonic/users_test.go +++ b/server/subsonic/users_test.go @@ -63,6 +63,7 @@ var _ = Describe("Users", func() { Expect(userResponse.User.ScrobblingEnabled).To(BeTrue()) Expect(userResponse.User.DownloadRole).To(BeTrue()) Expect(userResponse.User.ShareRole).To(BeTrue()) + Expect(userResponse.User.CoverArtRole).To(BeTrue()) Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20))) // Verify GetUsers response structure @@ -81,6 +82,7 @@ var _ = Describe("Users", func() { Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled)) Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole)) Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole)) + Expect(singleUser.CoverArtRole).To(Equal(userFromList.CoverArtRole)) Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole)) Expect(singleUser.Folder).To(Equal(userFromList.Folder)) }) @@ -102,6 +104,20 @@ var _ = Describe("Users", func() { Entry("jukebox enabled, admin-only, admin user", true, true, true, true), ) + DescribeTable("CoverArt role permissions", + func(enableCoverArtUpload, isAdmin, expectedCoverArtRole bool) { + conf.Server.EnableCoverArtUpload = enableCoverArtUpload + testUser.IsAdmin = isAdmin + + response := buildUserResponse(testUser) + Expect(response.CoverArtRole).To(Equal(expectedCoverArtRole)) + }, + Entry("enabled, regular user", true, false, true), + Entry("enabled, admin user", true, true, true), + Entry("disabled, regular user", false, false, false), + Entry("disabled, admin user", false, true, true), + ) + Describe("Folder list population", func() { It("should populate Folder field with user's accessible library IDs", func() { testUser.Libraries = model.Libraries{ diff --git a/ui/src/config.js b/ui/src/config.js index 5acf10b69..0672a58f4 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -22,6 +22,7 @@ const defaultConfig = { defaultUIVolume: 100, uiSearchDebounceMs: 200, enableUserEditing: true, + enableCoverArtUpload: true, enableSharing: true, shareURL: '', defaultDownloadableShare: true, diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index b24446cb9..eefed83b4 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -20,6 +20,7 @@ import { SizeField, isWritable, } from '../common' +import config from '../config' import subsonic from '../subsonic' import { REST_URL } from '../consts' import { httpClient } from '../dataProvider' @@ -134,7 +135,9 @@ const PlaylistDetails = (props) => { const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - const canEdit = isWritable(record.ownerId) + const canEdit = + isWritable(record.ownerId) && + (config.enableCoverArtUpload || localStorage.getItem('role') === 'admin') // Reset image state when playlist changes useEffect(() => { From 157c917ca5bfa268e9247e611830b0ea49c3886d Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 2 Mar 2026 17:01:12 -0500 Subject: [PATCH 046/596] chore(deps): update golang.org/x/net to v0.51.0 Signed-off-by: Deluan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1f6fa30a6..7f7e90a7f 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 golang.org/x/image v0.36.0 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.41.0 golang.org/x/term v0.40.0 diff --git a/go.sum b/go.sum index 26e4d3925..224b33e90 100644 --- a/go.sum +++ b/go.sum @@ -348,8 +348,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 692f0f99f6184ad108edde85818787181572a255 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:48:26 -0500 Subject: [PATCH 047/596] chore(deps): bump actions/upload-artifact in /.github/workflows (#5134) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 5fcc9526b..c2e51f47c 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -235,7 +235,7 @@ jobs: CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} - name: Upload Binaries - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: navidrome-${{ env.PLATFORM }} path: ./output @@ -266,7 +266,7 @@ jobs: touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' with: name: digests-${{ env.PLATFORM }} @@ -393,7 +393,7 @@ jobs: du -h binaries/msi/*.msi - name: Upload MSI files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: navidrome-windows-installers path: binaries/msi/*.msi @@ -437,7 +437,7 @@ jobs: rm ./dist/*.tar.gz ./dist/*.zip - name: Upload all-packages artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: packages path: dist/navidrome_0* @@ -466,7 +466,7 @@ jobs: path: ./dist - name: Upload all-packages artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: navidrome_linux_${{ matrix.item }} path: dist/navidrome_0*_linux_${{ matrix.item }} From c8857668549173684622953ad75a998be8981314 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:48:36 -0500 Subject: [PATCH 048/596] chore(deps): bump actions/download-artifact in /.github/workflows (#5133) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c2e51f47c..3a24d27b1 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -288,7 +288,7 @@ jobs: - uses: actions/checkout@v6 - name: Download digests - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: /tmp/digests pattern: digests-* @@ -322,7 +322,7 @@ jobs: - uses: actions/checkout@v6 - name: Download digests - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: /tmp/digests pattern: digests-* @@ -374,7 +374,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: ./binaries pattern: navidrome-windows* @@ -411,7 +411,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: path: ./binaries pattern: navidrome-* @@ -460,7 +460,7 @@ jobs: item: ${{ fromJson(needs.release.outputs.package_list) }} steps: - name: Download all-packages artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: packages path: ./dist From ed4c0ef432d6084806e3e179c8bda2b964fb131a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 3 Mar 2026 07:58:14 -0500 Subject: [PATCH 049/596] fix(scanner): add nil guards to cursor wrapping (#5139) * fix(persistence): add nil guards to cursor wrapping in folder and mediafile repos Prevent SIGSEGV panic when queryWithStableResults yields a zero-value struct on the rows.Err() path (e.g., "database is locked" during concurrent scanning). Extract cursor wrapping into wrapFolderCursor and wrapMediaFileCursor with nil checks matching the existing pattern in album_repository.go. Fixes #5138 * fix(persistence): wrap original cursor error in nil guard messages Use %w to preserve the underlying error (e.g., "database is locked") so callers can use errors.Is/As for root cause analysis. Tests now verify the original error is accessible via errors.Is. * fix(persistence): add nil guards and error wrapping in album, folder, and mediafile cursor functions Signed-off-by: Deluan --------- Signed-off-by: Deluan --- persistence/album_repository.go | 9 +++-- persistence/album_repository_test.go | 41 +++++++++++++++++++++++ persistence/folder_repository.go | 11 ++++++- persistence/folder_repository_test.go | 41 +++++++++++++++++++++++ persistence/mediafile_repository.go | 23 +++++++------ persistence/mediafile_repository_test.go | 42 ++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 15 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 077e33d17..35bda877c 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "iter" "maps" "slices" "strings" @@ -302,17 +303,21 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) if err != nil { return nil, err } + return wrapAlbumCursor(cursor), nil +} + +func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { return func(yield func(model.Album, error) bool) { for a, err := range cursor { if a.Album == nil { - yield(model.Album{}, fmt.Errorf("unexpected nil album: %v", a)) + yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) return } if !yield(*a.Album, err) || err != nil { return } } - }, nil + } } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 9fbc6b974..66b6eba9f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "errors" "fmt" "time" @@ -743,6 +744,46 @@ var _ = Describe("AlbumRepository", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) }) + + Describe("wrapAlbumCursor", func() { + It("does not panic when the cursor yields a dbAlbum with nil Album", func() { + // Simulate what queryWithStableResults does on the rows.Err() path: + // it yields a zero-value dbAlbum (where Album is nil) with an error. + dbErr := fmt.Errorf("database is locked") + cursor := func(yield func(dbAlbum, error) bool) { + var empty dbAlbum // Album pointer is nil + yield(empty, dbErr) + } + + // wrapAlbumCursor should handle the nil Album without panicking + wrappedCursor := wrapAlbumCursor(cursor) + var gotErr error + Expect(func() { + for _, err := range wrappedCursor { + gotErr = err + } + }).ToNot(Panic()) + Expect(gotErr).To(HaveOccurred()) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) + Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") + }) + + It("yields albums from a valid cursor", func() { + album := &model.Album{ID: "a1", Name: "Test"} + cursor := func(yield func(dbAlbum, error) bool) { + yield(dbAlbum{Album: album}, nil) + } + + wrappedCursor := wrapAlbumCursor(cursor) + var albums []model.Album + for a, err := range wrappedCursor { + Expect(err).ToNot(HaveOccurred()) + albums = append(albums, a) + } + Expect(albums).To(HaveLen(1)) + Expect(albums[0].ID).To(Equal("a1")) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index a4e203467..f7bb6a4fe 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "iter" "maps" "os" "path/filepath" @@ -218,13 +219,21 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) if err != nil { return nil, err } + return wrapFolderCursor(cursor), nil +} + +func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return func(yield func(model.Folder, error) bool) { for f, err := range cursor { + if f.Folder == nil { + yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) + return + } if !yield(*f.Folder, err) || err != nil { return } } - }, nil + } } func (r folderRepository) purgeEmpty(libraryIDs ...int) error { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 6c24741c9..7b6a0f764 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "errors" "fmt" "github.com/navidrome/navidrome/log" @@ -210,4 +211,44 @@ var _ = Describe("FolderRepository", func() { }) }) }) + + Describe("wrapFolderCursor", func() { + It("does not panic when the cursor yields a dbFolder with nil Folder", func() { + // Simulate what queryWithStableResults does on the rows.Err() path: + // it yields a zero-value dbFolder (where Folder is nil) with an error. + dbErr := fmt.Errorf("database is locked") + cursor := func(yield func(dbFolder, error) bool) { + var empty dbFolder // Folder pointer is nil + yield(empty, dbErr) + } + + // wrapFolderCursor should handle the nil Folder without panicking + wrappedCursor := wrapFolderCursor(cursor) + var gotErr error + Expect(func() { + for _, err := range wrappedCursor { + gotErr = err + } + }).ToNot(Panic()) + Expect(gotErr).To(HaveOccurred()) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) + Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") + }) + + It("yields folders from a valid cursor", func() { + folder := &model.Folder{ID: "f1", Name: "Test"} + cursor := func(yield func(dbFolder, error) bool) { + yield(dbFolder{Folder: folder}, nil) + } + + wrappedCursor := wrapFolderCursor(cursor) + var folders []model.Folder + for f, err := range wrappedCursor { + Expect(err).ToNot(HaveOccurred()) + folders = append(folders, f) + } + Expect(folders).To(HaveLen(1)) + Expect(folders[0].ID).To(Equal("f1")) + }) + }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 9034fa8f8..43736e317 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "fmt" + "iter" "slices" "strconv" "strings" @@ -231,17 +232,7 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me if err != nil { return nil, err } - return func(yield func(model.MediaFile, error) bool) { - for m, err := range cursor { - if m.MediaFile == nil { - yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile: %v", m)) - return - } - if !yield(*m.MediaFile, err) || err != nil { - return - } - } - }, nil + return wrapMediaFileCursor(cursor), nil } // FindByPaths finds media files by their paths. @@ -371,13 +362,21 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC if err != nil { return nil, err } + return wrapMediaFileCursor(cursor), nil +} + +func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { return func(yield func(model.MediaFile, error) bool) { for m, err := range cursor { + if m.MediaFile == nil { + yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) + return + } if !yield(*m.MediaFile, err) || err != nil { return } } - }, nil + } } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 7a04d79e4..5a866379f 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -2,6 +2,8 @@ package persistence import ( "context" + "errors" + "fmt" "time" "github.com/Masterminds/squirrel" @@ -711,4 +713,44 @@ var _ = Describe("MediaRepository", func() { Expect(results).To(BeEmpty()) }) }) + + Describe("wrapMediaFileCursor", func() { + It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() { + // Simulate what queryWithStableResults does on the rows.Err() path: + // it yields a zero-value dbMediaFile (where MediaFile is nil) with an error. + dbErr := fmt.Errorf("database is locked") + cursor := func(yield func(dbMediaFile, error) bool) { + var empty dbMediaFile // MediaFile pointer is nil + yield(empty, dbErr) + } + + // wrapMediaFileCursor should handle the nil MediaFile without panicking + wrappedCursor := wrapMediaFileCursor(cursor) + var gotErr error + Expect(func() { + for _, err := range wrappedCursor { + gotErr = err + } + }).ToNot(Panic()) + Expect(gotErr).To(HaveOccurred()) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) + Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") + }) + + It("yields mediafiles from a valid cursor", func() { + mf := &model.MediaFile{ID: "mf1", Title: "Test"} + cursor := func(yield func(dbMediaFile, error) bool) { + yield(dbMediaFile{MediaFile: mf}, nil) + } + + wrappedCursor := wrapMediaFileCursor(cursor) + var mediafiles []model.MediaFile + for m, err := range wrappedCursor { + Expect(err).ToNot(HaveOccurred()) + mediafiles = append(mediafiles, m) + } + Expect(mediafiles).To(HaveLen(1)) + Expect(mediafiles[0].ID).To(Equal("mf1")) + }) + }) }) From 24ba655dc322050c3d78b15e09f2eb8d9325b17d Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 3 Mar 2026 08:14:54 -0500 Subject: [PATCH 050/596] refactor: simplify error handling in updateParticipants and toModels methods Signed-off-by: Deluan --- persistence/album_repository.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 35bda877c..7207bf5a2 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -203,12 +203,11 @@ func (r *albumRepository) Put(al *model.Album) error { } al.ID = id if len(al.Participants) > 0 { - err = r.updateParticipants(al.ID, al.Participants) - if err != nil { + if err = r.updateParticipants(al.ID, al.Participants); err != nil { return err } } - return err + return nil } // TODO Move external metadata to a separated table @@ -242,7 +241,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e if err != nil { return nil, err } - return res.toModels(), err + return res.toModels(), nil } func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { From 668869b6c7737ea443cd2479ad200ea4a23f1792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 3 Mar 2026 13:48:49 -0500 Subject: [PATCH 051/596] feat(plugins): add TaskQueue host service for persistent background task queues (#5116) * feat(plugins): define TaskQueue host service interface Add the TaskQueueService interface with CreateQueue, Enqueue, GetTaskStatus, and CancelTask methods plus QueueConfig struct. * feat(plugins): define TaskWorker capability for task execution callbacks * feat(plugins): add taskqueue permission to manifest schema Add TaskQueuePermission with maxConcurrency option. * feat(plugins): implement TaskQueue service with SQLite persistence and workers Per-plugin SQLite database with queues and tasks tables. Worker goroutines dequeue tasks and invoke nd_task_execute callback. Exponential backoff retries, rate limiting via delayMs, automatic cleanup of terminal tasks. * feat(plugins): require TaskWorker capability for taskqueue permission * feat(plugins): register TaskQueue host service in manager * feat(plugins): add test-taskqueue plugin for integration testing * feat(plugins): add integration tests for TaskQueue host service * docs: document TaskQueue module for persistent task queues Signed-off-by: Deluan * fix(plugins): harden TaskQueue host service with validation and safety improvements Add input validation (queue name length, payload size limits), extract status string constants to eliminate raw SQL literals, make CreateQueue idempotent via upsert for crash recovery, fix RetentionMs default check for negative values, cap exponential backoff at 1 hour to prevent overflow, and replace manual mutex-based delay enforcement with rate.Limiter from golang.org/x/time/rate for correct concurrent worker serialization. * refactor(plugins): remove capability check for TaskWorker in TaskQueue host service Signed-off-by: Deluan * fix(plugins): use context-aware database execution in TaskQueue host service Signed-off-by: Deluan * refactor(plugins): streamline task queue configuration and error handling Signed-off-by: Deluan * feat(plugins): increase maxConcurrency for task queue and handle budget exhaustion Signed-off-by: Deluan * refactor(plugins): simplify goroutine management in task queue service Signed-off-by: Deluan * feat(plugins): update TaskWorker interface to return status messages and refactor task queue service Signed-off-by: Deluan * feat(plugins): add ClearQueue function to remove pending tasks from a specified queue Signed-off-by: Deluan * refactor(plugins): use migrateDB for task queue schema and fix constant name collision Replaced the raw db.Exec call in createTaskQueueSchema with migrateDB, matching the pattern used by createKVStoreSchema. This enables version-tracked schema migrations via SQLite's PRAGMA user_version, allowing future schema changes to be appended incrementally. Also renamed cleanupInterval to taskCleanupInterval to resolve a redeclaration conflict with host_kvstore.go. * regenerate PDKs Signed-off-by: Deluan --------- Signed-off-by: Deluan --- plugins/capabilities/taskworker.go | 27 + plugins/capabilities/taskworker.yaml | 38 + plugins/host/task.go | 73 + plugins/host/task_gen.go | 266 ++++ plugins/host_taskqueue.go | 595 ++++++++ plugins/host_taskqueue_test.go | 1221 +++++++++++++++++ plugins/manager_loader.go | 17 + plugins/manifest-schema.json | 20 + plugins/manifest.go | 7 + plugins/manifest_gen.go | 33 + plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_task.go | 277 ++++ plugins/pdk/go/host/nd_host_task_stub.go | 105 ++ plugins/pdk/go/taskworker/taskworker.go | 79 ++ plugins/pdk/go/taskworker/taskworker_stub.go | 41 + plugins/pdk/python/host/nd_host_task.py | 188 +++ .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../nd-pdk-capabilities/src/taskworker.rs | 102 ++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../pdk/rust/nd-pdk-host/src/nd_host_task.rs | 258 ++++ plugins/testdata/test-taskqueue/go.mod | 16 + plugins/testdata/test-taskqueue/go.sum | 14 + plugins/testdata/test-taskqueue/main.go | 114 ++ plugins/testdata/test-taskqueue/manifest.json | 12 + 24 files changed, 3513 insertions(+) create mode 100644 plugins/capabilities/taskworker.go create mode 100644 plugins/capabilities/taskworker.yaml create mode 100644 plugins/host/task.go create mode 100644 plugins/host/task_gen.go create mode 100644 plugins/host_taskqueue.go create mode 100644 plugins/host_taskqueue_test.go create mode 100644 plugins/pdk/go/host/nd_host_task.go create mode 100644 plugins/pdk/go/host/nd_host_task_stub.go create mode 100644 plugins/pdk/go/taskworker/taskworker.go create mode 100644 plugins/pdk/go/taskworker/taskworker_stub.go create mode 100644 plugins/pdk/python/host/nd_host_task.py create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs create mode 100644 plugins/testdata/test-taskqueue/go.mod create mode 100644 plugins/testdata/test-taskqueue/go.sum create mode 100644 plugins/testdata/test-taskqueue/main.go create mode 100644 plugins/testdata/test-taskqueue/manifest.json diff --git a/plugins/capabilities/taskworker.go b/plugins/capabilities/taskworker.go new file mode 100644 index 000000000..c53d50174 --- /dev/null +++ b/plugins/capabilities/taskworker.go @@ -0,0 +1,27 @@ +package capabilities + +// TaskWorker provides task execution handling. +// This capability allows plugins to receive callbacks when their queued tasks +// are ready to execute. Plugins that use the taskqueue host service must +// implement this capability. +// +//nd:capability name=taskworker +type TaskWorker interface { + // OnTaskExecute is called when a queued task is ready to run. + // The returned string is a status/result message stored in the tasks table. + // Return an error to trigger retry (if retries are configured). + //nd:export name=nd_task_execute + OnTaskExecute(TaskExecuteRequest) (string, error) +} + +// TaskExecuteRequest is the request provided when a task is ready to execute. +type TaskExecuteRequest struct { + // QueueName is the name of the queue this task belongs to. + QueueName string `json:"queueName"` + // TaskID is the unique identifier for this task. + TaskID string `json:"taskId"` + // Payload is the opaque data provided when the task was enqueued. + Payload []byte `json:"payload"` + // Attempt is the current attempt number (1-based: first attempt = 1). + Attempt int32 `json:"attempt"` +} diff --git a/plugins/capabilities/taskworker.yaml b/plugins/capabilities/taskworker.yaml new file mode 100644 index 000000000..f10fd0794 --- /dev/null +++ b/plugins/capabilities/taskworker.yaml @@ -0,0 +1,38 @@ +version: v1-draft +exports: + nd_task_execute: + description: |- + OnTaskExecute is called when a queued task is ready to run. + The returned string is a status/result message stored in the tasks table. + Return an error to trigger retry (if retries are configured). + input: + $ref: '#/components/schemas/TaskExecuteRequest' + contentType: application/json + output: + type: string + contentType: application/json +components: + schemas: + TaskExecuteRequest: + description: TaskExecuteRequest is the request provided when a task is ready to execute. + properties: + queueName: + type: string + description: QueueName is the name of the queue this task belongs to. + taskId: + type: string + description: TaskID is the unique identifier for this task. + payload: + type: array + description: Payload is the opaque data provided when the task was enqueued. + items: + type: object + attempt: + type: integer + format: int32 + description: 'Attempt is the current attempt number (1-based: first attempt = 1).' + required: + - queueName + - taskId + - payload + - attempt diff --git a/plugins/host/task.go b/plugins/host/task.go new file mode 100644 index 000000000..dcaf7197e --- /dev/null +++ b/plugins/host/task.go @@ -0,0 +1,73 @@ +package host + +import "context" + +// TaskInfo holds the current state of a task. +type TaskInfo struct { + // Status is the current task status: "pending", "running", + // "completed", "failed", or "cancelled". + Status string `json:"status"` + // Message is the status/result message returned by the plugin callback. + Message string `json:"message"` + // Attempt is the current or last attempt number (1-based). + Attempt int32 `json:"attempt"` +} + +// QueueConfig holds configuration for a task queue. +type QueueConfig struct { + // Concurrency is the max number of parallel workers. Default: 1. + // Capped by the plugin's manifest maxConcurrency. + Concurrency int32 `json:"concurrency"` + + // MaxRetries is the number of times to retry a failed task. Default: 0. + MaxRetries int32 `json:"maxRetries"` + + // BackoffMs is the initial backoff between retries in milliseconds. + // Doubles each retry (exponential: backoffMs * 2^(attempt-1)). Default: 1000. + BackoffMs int64 `json:"backoffMs"` + + // DelayMs is the minimum delay between starting consecutive tasks + // in milliseconds. Useful for rate limiting. Default: 0. + DelayMs int64 `json:"delayMs"` + + // RetentionMs is how long completed/failed/cancelled tasks are kept + // in milliseconds. Default: 3600000 (1h). Min: 60000 (1m). Max: 604800000 (1w). + RetentionMs int64 `json:"retentionMs"` +} + +// TaskService provides persistent task queues for plugins. +// +// This service allows plugins to create named queues with configurable concurrency, +// retry policies, and rate limiting. Tasks are persisted to SQLite and survive +// server restarts. When a task is ready to execute, the host calls the plugin's +// nd_task_execute callback function. +// +//nd:hostservice name=Task permission=taskqueue +type TaskService interface { + // CreateQueue creates a named task queue with the given configuration. + // Zero-value fields in config use sensible defaults. + // If a queue with the same name already exists, returns an error. + // On startup, this also recovers any stale "running" tasks from a previous crash. + //nd:hostfunc + CreateQueue(ctx context.Context, name string, config QueueConfig) error + + // Enqueue adds a task to the named queue. Returns the task ID. + // payload is opaque bytes passed back to the plugin on execution. + //nd:hostfunc + Enqueue(ctx context.Context, queueName string, payload []byte) (string, error) + + // Get returns the current state of a task including its status, + // message, and attempt count. + //nd:hostfunc + Get(ctx context.Context, taskID string) (*TaskInfo, error) + + // Cancel cancels a pending task. Returns error if already + // running, completed, or failed. + //nd:hostfunc + Cancel(ctx context.Context, taskID string) error + + // ClearQueue removes all pending tasks from the named queue. + // Running tasks are not affected. Returns the number of tasks removed. + //nd:hostfunc + ClearQueue(ctx context.Context, queueName string) (int64, error) +} diff --git a/plugins/host/task_gen.go b/plugins/host/task_gen.go new file mode 100644 index 000000000..e4864bb5b --- /dev/null +++ b/plugins/host/task_gen.go @@ -0,0 +1,266 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// TaskCreateQueueRequest is the request type for Task.CreateQueue. +type TaskCreateQueueRequest struct { + Name string `json:"name"` + Config QueueConfig `json:"config"` +} + +// TaskCreateQueueResponse is the response type for Task.CreateQueue. +type TaskCreateQueueResponse struct { + Error string `json:"error,omitempty"` +} + +// TaskEnqueueRequest is the request type for Task.Enqueue. +type TaskEnqueueRequest struct { + QueueName string `json:"queueName"` + Payload []byte `json:"payload"` +} + +// TaskEnqueueResponse is the response type for Task.Enqueue. +type TaskEnqueueResponse struct { + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// TaskGetRequest is the request type for Task.Get. +type TaskGetRequest struct { + TaskID string `json:"taskId"` +} + +// TaskGetResponse is the response type for Task.Get. +type TaskGetResponse struct { + Result *TaskInfo `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// TaskCancelRequest is the request type for Task.Cancel. +type TaskCancelRequest struct { + TaskID string `json:"taskId"` +} + +// TaskCancelResponse is the response type for Task.Cancel. +type TaskCancelResponse struct { + Error string `json:"error,omitempty"` +} + +// TaskClearQueueRequest is the request type for Task.ClearQueue. +type TaskClearQueueRequest struct { + QueueName string `json:"queueName"` +} + +// TaskClearQueueResponse is the response type for Task.ClearQueue. +type TaskClearQueueResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterTaskHostFunctions registers Task service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterTaskHostFunctions(service TaskService) []extism.HostFunction { + return []extism.HostFunction{ + newTaskCreateQueueHostFunction(service), + newTaskEnqueueHostFunction(service), + newTaskGetHostFunction(service), + newTaskCancelHostFunction(service), + newTaskClearQueueHostFunction(service), + } +} + +func newTaskCreateQueueHostFunction(service TaskService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "task_createqueue", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + taskWriteError(p, stack, err) + return + } + var req TaskCreateQueueRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + taskWriteError(p, stack, err) + return + } + + // Call the service method + if svcErr := service.CreateQueue(ctx, req.Name, req.Config); svcErr != nil { + taskWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := TaskCreateQueueResponse{} + taskWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newTaskEnqueueHostFunction(service TaskService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "task_enqueue", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + taskWriteError(p, stack, err) + return + } + var req TaskEnqueueRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + taskWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.Enqueue(ctx, req.QueueName, req.Payload) + if svcErr != nil { + taskWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := TaskEnqueueResponse{ + Result: result, + } + taskWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newTaskGetHostFunction(service TaskService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "task_get", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + taskWriteError(p, stack, err) + return + } + var req TaskGetRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + taskWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.Get(ctx, req.TaskID) + if svcErr != nil { + taskWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := TaskGetResponse{ + Result: result, + } + taskWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newTaskCancelHostFunction(service TaskService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "task_cancel", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + taskWriteError(p, stack, err) + return + } + var req TaskCancelRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + taskWriteError(p, stack, err) + return + } + + // Call the service method + if svcErr := service.Cancel(ctx, req.TaskID); svcErr != nil { + taskWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := TaskCancelResponse{} + taskWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newTaskClearQueueHostFunction(service TaskService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "task_clearqueue", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + taskWriteError(p, stack, err) + return + } + var req TaskClearQueueRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + taskWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.ClearQueue(ctx, req.QueueName) + if svcErr != nil { + taskWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := TaskClearQueueResponse{ + Result: result, + } + taskWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// taskWriteResponse writes a JSON response to plugin memory. +func taskWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + taskWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// taskWriteError writes an error response to plugin memory. +func taskWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go new file mode 100644 index 000000000..283bc9635 --- /dev/null +++ b/plugins/host_taskqueue.go @@ -0,0 +1,595 @@ +package plugins + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/host" + "golang.org/x/time/rate" +) + +const ( + defaultConcurrency int32 = 1 + defaultBackoffMs int64 = 1000 + defaultRetentionMs int64 = 3_600_000 // 1 hour + minRetentionMs int64 = 60_000 // 1 minute + maxRetentionMs int64 = 604_800_000 // 1 week + maxQueueNameLength = 128 + maxPayloadSize = 1 * 1024 * 1024 // 1MB + maxBackoffMs int64 = 3_600_000 // 1 hour + taskCleanupInterval = 5 * time.Minute + pollInterval = 5 * time.Second + shutdownTimeout = 10 * time.Second + + taskStatusPending = "pending" + taskStatusRunning = "running" + taskStatusCompleted = "completed" + taskStatusFailed = "failed" + taskStatusCancelled = "cancelled" +) + +// CapabilityTaskWorker indicates the plugin can receive task execution callbacks. +const CapabilityTaskWorker Capability = "TaskWorker" + +const FuncTaskWorkerCallback = "nd_task_execute" + +func init() { + registerCapability(CapabilityTaskWorker, FuncTaskWorkerCallback) +} + +type queueState struct { + config host.QueueConfig + signal chan struct{} + limiter *rate.Limiter +} + +// notifyWorkers sends a non-blocking signal to wake up queue workers. +func (qs *queueState) notifyWorkers() { + select { + case qs.signal <- struct{}{}: + default: + } +} + +// taskQueueServiceImpl implements host.TaskQueueService with SQLite persistence +// and background worker goroutines for task execution. +type taskQueueServiceImpl struct { + pluginName string + manager *Manager + maxConcurrency int32 + db *sql.DB + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + queues map[string]*queueState + + // For testing: override how callbacks are invoked + invokeCallbackFn func(ctx context.Context, queueName, taskID string, payload []byte, attempt int32) (string, error) +} + +// newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. +func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { + dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("creating plugin data directory: %w", err) + } + + dbPath := filepath.Join(dataDir, "taskqueue.db") + db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off") + if err != nil { + return nil, fmt.Errorf("opening taskqueue database: %w", err) + } + + db.SetMaxOpenConns(3) + db.SetMaxIdleConns(1) + + if err := createTaskQueueSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("creating taskqueue schema: %w", err) + } + + ctx, cancel := context.WithCancel(manager.ctx) + + s := &taskQueueServiceImpl{ + pluginName: pluginName, + manager: manager, + maxConcurrency: maxConcurrency, + db: db, + ctx: ctx, + cancel: cancel, + queues: make(map[string]*queueState), + } + s.invokeCallbackFn = s.defaultInvokeCallback + + s.wg.Go(s.cleanupLoop) + + log.Debug("Initialized plugin taskqueue", "plugin", pluginName, "path", dbPath, "maxConcurrency", maxConcurrency) + return s, nil +} + +// createTaskQueueSchema applies schema migrations to the taskqueue database. +// New migrations must be appended at the end of the slice. +func createTaskQueueSchema(db *sql.DB) error { + return migrateDB(db, []string{ + `CREATE TABLE IF NOT EXISTS queues ( + name TEXT PRIMARY KEY, + concurrency INTEGER NOT NULL DEFAULT 1, + max_retries INTEGER NOT NULL DEFAULT 0, + backoff_ms INTEGER NOT NULL DEFAULT 1000, + delay_ms INTEGER NOT NULL DEFAULT 0, + retention_ms INTEGER NOT NULL DEFAULT 3600000 + )`, + `CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + queue_name TEXT NOT NULL REFERENCES queues(name), + payload BLOB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL, + next_run_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + message TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_tasks_dequeue ON tasks(queue_name, status, next_run_at)`, + }) +} + +// applyConfigDefaults fills zero-value config fields with sensible defaults +// and clamps values to valid ranges, logging warnings for clamped values. +func (s *taskQueueServiceImpl) applyConfigDefaults(ctx context.Context, name string, config *host.QueueConfig) { + if config.Concurrency <= 0 { + config.Concurrency = defaultConcurrency + } + if config.BackoffMs <= 0 { + config.BackoffMs = defaultBackoffMs + } + if config.RetentionMs <= 0 { + config.RetentionMs = defaultRetentionMs + } + + if config.RetentionMs < minRetentionMs { + log.Warn(ctx, "TaskQueue retention clamped to minimum", "plugin", s.pluginName, "queue", name, + "requested", config.RetentionMs, "min", minRetentionMs) + config.RetentionMs = minRetentionMs + } + if config.RetentionMs > maxRetentionMs { + log.Warn(ctx, "TaskQueue retention clamped to maximum", "plugin", s.pluginName, "queue", name, + "requested", config.RetentionMs, "max", maxRetentionMs) + config.RetentionMs = maxRetentionMs + } +} + +// clampConcurrency reduces config.Concurrency if it exceeds the remaining budget. +// Returns an error when the concurrency budget is fully exhausted. +// Must be called with s.mu held. +func (s *taskQueueServiceImpl) clampConcurrency(ctx context.Context, name string, config *host.QueueConfig) error { + var allocated int32 + for _, qs := range s.queues { + allocated += qs.config.Concurrency + } + available := s.maxConcurrency - allocated + if available <= 0 { + log.Warn(ctx, "TaskQueue concurrency budget exhausted", "plugin", s.pluginName, "queue", name, + "allocated", allocated, "maxConcurrency", s.maxConcurrency) + return fmt.Errorf("concurrency budget exhausted (%d/%d allocated)", allocated, s.maxConcurrency) + } + if config.Concurrency > available { + log.Warn(ctx, "TaskQueue concurrency clamped", "plugin", s.pluginName, "queue", name, + "requested", config.Concurrency, "available", available, "maxConcurrency", s.maxConcurrency) + config.Concurrency = available + } + return nil +} + +func (s *taskQueueServiceImpl) CreateQueue(ctx context.Context, name string, config host.QueueConfig) error { + if len(name) == 0 { + return fmt.Errorf("queue name cannot be empty") + } + if len(name) > maxQueueNameLength { + return fmt.Errorf("queue name exceeds maximum length of %d bytes", maxQueueNameLength) + } + + s.applyConfigDefaults(ctx, name, &config) + + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.clampConcurrency(ctx, name, &config); err != nil { + return err + } + + if _, exists := s.queues[name]; exists { + return fmt.Errorf("queue %q already exists", name) + } + + // Upsert into queues table (idempotent across restarts) + _, err := s.db.ExecContext(ctx, ` + INSERT INTO queues (name, concurrency, max_retries, backoff_ms, delay_ms, retention_ms) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + concurrency = excluded.concurrency, + max_retries = excluded.max_retries, + backoff_ms = excluded.backoff_ms, + delay_ms = excluded.delay_ms, + retention_ms = excluded.retention_ms + `, name, config.Concurrency, config.MaxRetries, config.BackoffMs, config.DelayMs, config.RetentionMs) + if err != nil { + return fmt.Errorf("creating queue: %w", err) + } + + // Reset stale running tasks from previous crash + now := time.Now().UnixMilli() + _, err = s.db.ExecContext(ctx, ` + UPDATE tasks SET status = ?, updated_at = ? WHERE queue_name = ? AND status = ? + `, taskStatusPending, now, name, taskStatusRunning) + if err != nil { + return fmt.Errorf("resetting stale tasks: %w", err) + } + + qs := &queueState{ + config: config, + signal: make(chan struct{}, 1), + } + if config.DelayMs > 0 { + // Rate limit dispatches to enforce delay between tasks. + // Burst of 1 allows one immediate dispatch, then enforces the delay interval. + qs.limiter = rate.NewLimiter(rate.Every(time.Duration(config.DelayMs)*time.Millisecond), 1) + } + s.queues[name] = qs + + for i := int32(0); i < config.Concurrency; i++ { + s.wg.Go(func() { s.worker(name, qs) }) + } + + log.Debug(ctx, "Created task queue", "plugin", s.pluginName, "queue", name, + "concurrency", config.Concurrency, "maxRetries", config.MaxRetries, + "backoffMs", config.BackoffMs, "delayMs", config.DelayMs, "retentionMs", config.RetentionMs) + return nil +} + +func (s *taskQueueServiceImpl) Enqueue(ctx context.Context, queueName string, payload []byte) (string, error) { + s.mu.Lock() + qs, exists := s.queues[queueName] + s.mu.Unlock() + + if !exists { + return "", fmt.Errorf("queue %q does not exist", queueName) + } + if len(payload) > maxPayloadSize { + return "", fmt.Errorf("payload size %d exceeds maximum of %d bytes", len(payload), maxPayloadSize) + } + + taskID := id.NewRandom() + now := time.Now().UnixMilli() + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO tasks (id, queue_name, payload, status, attempt, max_retries, next_run_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?) + `, taskID, queueName, payload, taskStatusPending, qs.config.MaxRetries, now, now, now) + if err != nil { + return "", fmt.Errorf("enqueuing task: %w", err) + } + + qs.notifyWorkers() + log.Trace(ctx, "Enqueued task", "plugin", s.pluginName, "queue", queueName, "taskID", taskID) + return taskID, nil +} + +// Get returns the current state of a task. +func (s *taskQueueServiceImpl) Get(ctx context.Context, taskID string) (*host.TaskInfo, error) { + var info host.TaskInfo + err := s.db.QueryRowContext(ctx, `SELECT status, message, attempt FROM tasks WHERE id = ?`, taskID). + Scan(&info.Status, &info.Message, &info.Attempt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("task %q not found", taskID) + } + if err != nil { + return nil, fmt.Errorf("getting task info: %w", err) + } + return &info, nil +} + +// Cancel cancels a pending task. +func (s *taskQueueServiceImpl) Cancel(ctx context.Context, taskID string) error { + now := time.Now().UnixMilli() + result, err := s.db.ExecContext(ctx, ` + UPDATE tasks SET status = ?, updated_at = ? WHERE id = ? AND status = ? + `, taskStatusCancelled, now, taskID, taskStatusPending) + if err != nil { + return fmt.Errorf("cancelling task: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking cancel result: %w", err) + } + + if rowsAffected == 0 { + // Check if task exists at all + var status string + err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, taskID).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("task %q not found", taskID) + } + if err != nil { + return fmt.Errorf("checking task existence: %w", err) + } + return fmt.Errorf("task %q cannot be cancelled (status: %s)", taskID, status) + } + + log.Trace(ctx, "Cancelled task", "plugin", s.pluginName, "taskID", taskID) + return nil +} + +// ClearQueue removes all pending tasks from the named queue. +// Running tasks are not affected. Returns the number of tasks removed. +func (s *taskQueueServiceImpl) ClearQueue(ctx context.Context, queueName string) (int64, error) { + s.mu.Lock() + _, exists := s.queues[queueName] + s.mu.Unlock() + + if !exists { + return 0, fmt.Errorf("queue %q does not exist", queueName) + } + + now := time.Now().UnixMilli() + result, err := s.db.ExecContext(ctx, ` + UPDATE tasks SET status = ?, updated_at = ? WHERE queue_name = ? AND status = ? + `, taskStatusCancelled, now, queueName, taskStatusPending) + if err != nil { + return 0, fmt.Errorf("clearing queue: %w", err) + } + + cleared, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("checking clear result: %w", err) + } + + if cleared > 0 { + log.Debug(ctx, "Cleared pending tasks from queue", "plugin", s.pluginName, "queue", queueName, "cleared", cleared) + } + return cleared, nil +} + +// worker is the main loop for a single worker goroutine. +func (s *taskQueueServiceImpl) worker(queueName string, qs *queueState) { + // Process any existing pending tasks immediately on startup + s.drainQueue(queueName, qs) + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-qs.signal: + s.drainQueue(queueName, qs) + case <-ticker.C: + s.drainQueue(queueName, qs) + } + } +} + +func (s *taskQueueServiceImpl) drainQueue(queueName string, qs *queueState) { + for s.ctx.Err() == nil && s.processTask(queueName, qs) { + } +} + +// processTask dequeues and processes a single task. Returns true if a task was processed. +func (s *taskQueueServiceImpl) processTask(queueName string, qs *queueState) bool { + now := time.Now().UnixMilli() + + // Atomically dequeue a task + var taskID string + var payload []byte + var attempt, maxRetries int32 + err := s.db.QueryRowContext(s.ctx, ` + UPDATE tasks SET status = ?, attempt = attempt + 1, updated_at = ? + WHERE id = ( + SELECT id FROM tasks + WHERE queue_name = ? AND status = ? AND next_run_at <= ? + ORDER BY next_run_at, created_at LIMIT 1 + ) + RETURNING id, payload, attempt, max_retries + `, taskStatusRunning, now, queueName, taskStatusPending, now).Scan(&taskID, &payload, &attempt, &maxRetries) + if errors.Is(err, sql.ErrNoRows) { + return false + } + if err != nil { + log.Error(s.ctx, "Failed to dequeue task", "plugin", s.pluginName, "queue", queueName, err) + return false + } + + // Enforce delay between task dispatches using a rate limiter. + // This is done after dequeue so that empty polls don't consume rate tokens. + if qs.limiter != nil { + if err := qs.limiter.Wait(s.ctx); err != nil { + // Context cancelled during wait — revert task to pending for recovery + s.revertTaskToPending(taskID) + return false + } + } + + // Invoke callback + log.Debug(s.ctx, "Executing task", "plugin", s.pluginName, "queue", queueName, "taskID", taskID, "attempt", attempt) + message, callbackErr := s.invokeCallbackFn(s.ctx, queueName, taskID, payload, attempt) + + // If context was cancelled (shutdown), revert task to pending for recovery + if s.ctx.Err() != nil { + s.revertTaskToPending(taskID) + return false + } + + if callbackErr == nil { + s.completeTask(queueName, taskID, message) + } else { + s.handleTaskFailure(queueName, taskID, attempt, maxRetries, qs, callbackErr, message) + } + return true +} + +func (s *taskQueueServiceImpl) completeTask(queueName, taskID, message string) { + now := time.Now().UnixMilli() + if _, err := s.db.ExecContext(s.ctx, `UPDATE tasks SET status = ?, message = ?, updated_at = ? WHERE id = ?`, taskStatusCompleted, message, now, taskID); err != nil { + log.Error(s.ctx, "Failed to mark task as completed", "plugin", s.pluginName, "taskID", taskID, err) + } + log.Debug(s.ctx, "Task completed", "plugin", s.pluginName, "queue", queueName, "taskID", taskID) +} + +func (s *taskQueueServiceImpl) handleTaskFailure(queueName, taskID string, attempt, maxRetries int32, qs *queueState, callbackErr error, message string) { + log.Warn(s.ctx, "Task execution failed", "plugin", s.pluginName, "queue", queueName, + "taskID", taskID, "attempt", attempt, "maxRetries", maxRetries, "err", callbackErr) + + // Use error message as fallback if no message was provided + if message == "" { + message = callbackErr.Error() + } + + now := time.Now().UnixMilli() + if attempt > maxRetries { + if _, err := s.db.ExecContext(s.ctx, `UPDATE tasks SET status = ?, message = ?, updated_at = ? WHERE id = ?`, taskStatusFailed, message, now, taskID); err != nil { + log.Error(s.ctx, "Failed to mark task as failed", "plugin", s.pluginName, "taskID", taskID, err) + } + log.Warn(s.ctx, "Task failed after all retries", "plugin", s.pluginName, "queue", queueName, "taskID", taskID) + return + } + + // Exponential backoff: backoffMs * 2^(attempt-1) + backoff := qs.config.BackoffMs << (attempt - 1) + if backoff <= 0 || backoff > maxBackoffMs { + backoff = maxBackoffMs + } + nextRunAt := now + backoff + if _, err := s.db.ExecContext(s.ctx, ` + UPDATE tasks SET status = ?, next_run_at = ?, updated_at = ? WHERE id = ? + `, taskStatusPending, nextRunAt, now, taskID); err != nil { + log.Error(s.ctx, "Failed to reschedule task for retry", "plugin", s.pluginName, "taskID", taskID, err) + } + + // Wake worker after backoff expires + time.AfterFunc(time.Duration(backoff)*time.Millisecond, func() { + qs.notifyWorkers() + }) +} + +// revertTaskToPending puts a running task back to pending status and decrements the attempt +// counter (used during shutdown to ensure the interrupted attempt doesn't count). +func (s *taskQueueServiceImpl) revertTaskToPending(taskID string) { + now := time.Now().UnixMilli() + _, err := s.db.Exec(`UPDATE tasks SET status = ?, attempt = MAX(attempt - 1, 0), updated_at = ? WHERE id = ? AND status = ?`, taskStatusPending, now, taskID, taskStatusRunning) + if err != nil { + log.Error("Failed to revert task to pending", "plugin", s.pluginName, "taskID", taskID, err) + } +} + +// defaultInvokeCallback calls the plugin's nd_task_execute function. +func (s *taskQueueServiceImpl) defaultInvokeCallback(ctx context.Context, queueName, taskID string, payload []byte, attempt int32) (string, error) { + s.manager.mu.RLock() + p, ok := s.manager.plugins[s.pluginName] + s.manager.mu.RUnlock() + + if !ok { + return "", fmt.Errorf("plugin %s not loaded", s.pluginName) + } + + input := capabilities.TaskExecuteRequest{ + QueueName: queueName, + TaskID: taskID, + Payload: payload, + Attempt: attempt, + } + + message, err := callPluginFunction[capabilities.TaskExecuteRequest, string](ctx, p, FuncTaskWorkerCallback, input) + if err != nil { + return "", err + } + return message, nil +} + +// cleanupLoop periodically removes terminal tasks past their retention period. +func (s *taskQueueServiceImpl) cleanupLoop() { + ticker := time.NewTicker(taskCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.runCleanup() + } + } +} + +// runCleanup deletes terminal tasks past their retention period. +func (s *taskQueueServiceImpl) runCleanup() { + s.mu.Lock() + queues := make(map[string]*queueState, len(s.queues)) + for k, v := range s.queues { + queues[k] = v + } + s.mu.Unlock() + + now := time.Now().UnixMilli() + for name, qs := range queues { + result, err := s.db.ExecContext(s.ctx, ` + DELETE FROM tasks WHERE queue_name = ? AND status IN (?, ?, ?) AND updated_at + ? < ? + `, name, taskStatusCompleted, taskStatusFailed, taskStatusCancelled, qs.config.RetentionMs, now) + if err != nil { + log.Error(s.ctx, "Failed to cleanup tasks", "plugin", s.pluginName, "queue", name, err) + continue + } + if deleted, _ := result.RowsAffected(); deleted > 0 { + log.Debug(s.ctx, "Cleaned up terminal tasks", "plugin", s.pluginName, "queue", name, "deleted", deleted) + } + } +} + +// Close shuts down the task queue service, stopping all workers and closing the database. +func (s *taskQueueServiceImpl) Close() error { + // Cancel context to signal all goroutines + s.cancel() + + // Wait for goroutines with timeout + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(shutdownTimeout): + log.Warn("TaskQueue shutdown timed out", "plugin", s.pluginName) + } + + // Mark running tasks as pending for recovery on next startup + if s.db != nil { + now := time.Now().UnixMilli() + if _, err := s.db.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE status = ?`, taskStatusPending, now, taskStatusRunning); err != nil { + log.Error("Failed to reset running tasks on shutdown", "plugin", s.pluginName, err) + } + log.Debug("Closing plugin taskqueue", "plugin", s.pluginName) + return s.db.Close() + } + return nil +} + +// Compile-time verification +var _ host.TaskService = (*taskQueueServiceImpl)(nil) +var _ io.Closer = (*taskQueueServiceImpl)(nil) diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go new file mode 100644 index 000000000..c3ab8d119 --- /dev/null +++ b/plugins/host_taskqueue_test.go @@ -0,0 +1,1221 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TaskQueueService", func() { + var tmpDir string + var service *taskQueueServiceImpl + var ctx context.Context + var manager *Manager + + BeforeEach(func() { + ctx = GinkgoT().Context() + var err error + tmpDir, err = os.MkdirTemp("", "taskqueue-test-*") + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = tmpDir + + // Create a mock manager with context + managerCtx, cancel := context.WithCancel(ctx) + manager = &Manager{ + plugins: make(map[string]*plugin), + ctx: managerCtx, + } + DeferCleanup(cancel) + + service, err = newTaskQueueService("test_plugin", manager, 5) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + if service != nil { + service.Close() + } + os.RemoveAll(tmpDir) + }) + + Describe("CreateQueue", func() { + It("creates a queue successfully", func() { + err := service.CreateQueue(ctx, "my-queue", host.QueueConfig{ + Concurrency: 2, + MaxRetries: 3, + BackoffMs: 2000, + RetentionMs: 7200000, + }) + Expect(err).ToNot(HaveOccurred()) + + service.mu.Lock() + qs, exists := service.queues["my-queue"] + service.mu.Unlock() + Expect(exists).To(BeTrue()) + Expect(qs.config.Concurrency).To(Equal(int32(2))) + Expect(qs.config.MaxRetries).To(Equal(int32(3))) + Expect(qs.config.BackoffMs).To(Equal(int64(2000))) + Expect(qs.config.RetentionMs).To(Equal(int64(7200000))) + }) + + It("returns error for duplicate queue name", func() { + err := service.CreateQueue(ctx, "dup-queue", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + err = service.CreateQueue(ctx, "dup-queue", host.QueueConfig{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("already exists")) + }) + }) + + Describe("CreateQueue name validation", func() { + It("rejects empty queue name", func() { + err := service.CreateQueue(ctx, "", host.QueueConfig{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("queue name cannot be empty")) + }) + + It("rejects over-length queue name", func() { + longName := strings.Repeat("a", maxQueueNameLength+1) + err := service.CreateQueue(ctx, longName, host.QueueConfig{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("exceeds maximum length")) + }) + + It("accepts queue name at maximum length", func() { + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", nil + } + exactName := strings.Repeat("a", maxQueueNameLength) + err := service.CreateQueue(ctx, exactName, host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("CreateQueue defaults", func() { + It("applies defaults for zero-value config", func() { + err := service.CreateQueue(ctx, "defaults-queue", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + service.mu.Lock() + qs := service.queues["defaults-queue"] + service.mu.Unlock() + Expect(qs.config.Concurrency).To(Equal(defaultConcurrency)) + Expect(qs.config.BackoffMs).To(Equal(defaultBackoffMs)) + Expect(qs.config.RetentionMs).To(Equal(defaultRetentionMs)) + }) + }) + + Describe("CreateQueue defaults with negative values", func() { + It("applies default RetentionMs for negative value", func() { + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", nil + } + err := service.CreateQueue(ctx, "neg-retention", host.QueueConfig{ + RetentionMs: -500, + }) + Expect(err).ToNot(HaveOccurred()) + + service.mu.Lock() + qs := service.queues["neg-retention"] + service.mu.Unlock() + Expect(qs.config.RetentionMs).To(Equal(defaultRetentionMs)) + }) + }) + + Describe("CreateQueue clamping", func() { + It("clamps concurrency exceeding maxConcurrency", func() { + // maxConcurrency is 5; request 10 + err := service.CreateQueue(ctx, "clamped-queue", host.QueueConfig{ + Concurrency: 10, + }) + Expect(err).ToNot(HaveOccurred()) + + service.mu.Lock() + qs := service.queues["clamped-queue"] + service.mu.Unlock() + Expect(qs.config.Concurrency).To(Equal(int32(5))) + }) + + It("returns error when concurrency budget is exhausted", func() { + // maxConcurrency is 5; create a queue that uses all 5 + err := service.CreateQueue(ctx, "full-budget", host.QueueConfig{ + Concurrency: 5, + }) + Expect(err).ToNot(HaveOccurred()) + + // Next queue should fail — no budget remaining + err = service.CreateQueue(ctx, "over-budget", host.QueueConfig{ + Concurrency: 1, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("concurrency budget exhausted")) + }) + + It("clamps retention below minimum", func() { + err := service.CreateQueue(ctx, "low-retention", host.QueueConfig{ + RetentionMs: 100, // below minRetentionMs + }) + Expect(err).ToNot(HaveOccurred()) + + service.mu.Lock() + qs := service.queues["low-retention"] + service.mu.Unlock() + Expect(qs.config.RetentionMs).To(Equal(minRetentionMs)) + }) + + It("clamps retention above maximum", func() { + err := service.CreateQueue(ctx, "high-retention", host.QueueConfig{ + RetentionMs: 999_999_999_999, // above maxRetentionMs + }) + Expect(err).ToNot(HaveOccurred()) + + service.mu.Lock() + qs := service.queues["high-retention"] + service.mu.Unlock() + Expect(qs.config.RetentionMs).To(Equal(maxRetentionMs)) + }) + }) + + Describe("Enqueue", func() { + BeforeEach(func() { + // Use a no-op callback to prevent actual execution attempts + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", nil + } + err := service.CreateQueue(ctx, "enqueue-test", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("enqueues a task and returns task ID", func() { + taskID, err := service.Enqueue(ctx, "enqueue-test", []byte("payload")) + Expect(err).ToNot(HaveOccurred()) + Expect(taskID).ToNot(BeEmpty()) + }) + + It("returns error for non-existent queue", func() { + _, err := service.Enqueue(ctx, "no-such-queue", []byte("payload")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + + It("rejects payload exceeding maximum size", func() { + bigPayload := make([]byte, maxPayloadSize+1) + _, err := service.Enqueue(ctx, "enqueue-test", bigPayload) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("exceeds maximum")) + }) + + It("accepts payload at maximum size", func() { + exactPayload := make([]byte, maxPayloadSize) + taskID, err := service.Enqueue(ctx, "enqueue-test", exactPayload) + Expect(err).ToNot(HaveOccurred()) + Expect(taskID).ToNot(BeEmpty()) + }) + }) + + Describe("GetTaskStatus", func() { + BeforeEach(func() { + // Use a callback that blocks until context is cancelled so tasks stay pending + service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { + <-ctx.Done() + return "", ctx.Err() + } + }) + + It("returns pending for a new task", func() { + err := service.CreateQueue(ctx, "status-test", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "status-test", []byte("data")) + Expect(err).ToNot(HaveOccurred()) + + // The task may get picked up quickly; check initial status + // Since the callback blocks, it should be either pending or running + info, err := service.Get(ctx, taskID) + Expect(err).ToNot(HaveOccurred()) + Expect(info).ToNot(BeNil()) + Expect(info.Status).To(BeElementOf("pending", "running")) + }) + + It("returns error for unknown task ID", func() { + _, err := service.Get(ctx, "nonexistent-id") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + }) + + Describe("CancelTask", func() { + BeforeEach(func() { + // Block callback so tasks stay in pending/running + service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { + <-ctx.Done() + return "", ctx.Err() + } + }) + + It("cancels a pending task", func() { + // Block the callback so the first task occupies the worker + started := make(chan struct{}) + service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { + close(started) + <-ctx.Done() + return "", ctx.Err() + } + + err := service.CreateQueue(ctx, "cancel-test", host.QueueConfig{ + Concurrency: 1, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue a blocker task to occupy the single worker + _, err = service.Enqueue(ctx, "cancel-test", []byte("blocker")) + Expect(err).ToNot(HaveOccurred()) + + // Wait for the blocker task to start running + Eventually(started).WithTimeout(5 * time.Second).Should(BeClosed()) + + // Enqueue a second task — it stays pending since the worker is busy + taskID, err := service.Enqueue(ctx, "cancel-test", []byte("cancel-me")) + Expect(err).ToNot(HaveOccurred()) + + err = service.Cancel(ctx, taskID) + Expect(err).ToNot(HaveOccurred()) + + info, err := service.Get(ctx, taskID) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Status).To(Equal("cancelled")) + }) + + It("returns error for unknown task ID", func() { + err := service.Cancel(ctx, "nonexistent-id") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("returns error for non-pending task", func() { + // Create a queue where tasks complete immediately + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", nil + } + err := service.CreateQueue(ctx, "completed-test", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "completed-test", []byte("data")) + Expect(err).ToNot(HaveOccurred()) + + // Wait for task to complete + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + + // Try to cancel completed task + err = service.Cancel(ctx, taskID) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot be cancelled")) + }) + }) + + Describe("ClearQueue", func() { + It("clears all pending tasks from a queue", func() { + // Block the callback so the first task occupies the worker + started := make(chan struct{}) + service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { + close(started) + <-ctx.Done() + return "", ctx.Err() + } + + err := service.CreateQueue(ctx, "clear-test", host.QueueConfig{ + Concurrency: 1, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue a blocker task to occupy the single worker + _, err = service.Enqueue(ctx, "clear-test", []byte("blocker")) + Expect(err).ToNot(HaveOccurred()) + + // Wait for the blocker task to start running + Eventually(started).WithTimeout(5 * time.Second).Should(BeClosed()) + + // Enqueue several more tasks — they stay pending since the worker is busy + var pendingIDs []string + for i := 0; i < 3; i++ { + taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i))) + Expect(err).ToNot(HaveOccurred()) + pendingIDs = append(pendingIDs, taskID) + } + + // Clear the queue + cleared, err := service.ClearQueue(ctx, "clear-test") + Expect(err).ToNot(HaveOccurred()) + Expect(cleared).To(Equal(int64(3))) + + // Verify all pending tasks are now cancelled + for _, id := range pendingIDs { + info, err := service.Get(ctx, id) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Status).To(Equal("cancelled")) + } + }) + + It("returns zero when queue has no pending tasks", func() { + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", nil + } + err := service.CreateQueue(ctx, "empty-clear", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + cleared, err := service.ClearQueue(ctx, "empty-clear") + Expect(err).ToNot(HaveOccurred()) + Expect(cleared).To(Equal(int64(0))) + }) + + It("returns error for non-existent queue", func() { + _, err := service.ClearQueue(ctx, "no-such-queue") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + + It("does not affect running tasks", func() { + // Block the callback so tasks stay running + started := make(chan struct{}, 1) + service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { + select { + case started <- struct{}{}: + default: + } + <-ctx.Done() + return "", ctx.Err() + } + + err := service.CreateQueue(ctx, "clear-running", host.QueueConfig{ + Concurrency: 1, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue a task that will start running + runningID, err := service.Enqueue(ctx, "clear-running", []byte("running-task")) + Expect(err).ToNot(HaveOccurred()) + + // Wait for it to start running + Eventually(started).WithTimeout(5 * time.Second).Should(Receive()) + + // Clear the queue — should not affect the running task + cleared, err := service.ClearQueue(ctx, "clear-running") + Expect(err).ToNot(HaveOccurred()) + Expect(cleared).To(Equal(int64(0))) + + // Verify the running task is still running + info, err := service.Get(ctx, runningID) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Status).To(Equal("running")) + }) + }) + + Describe("Worker execution", func() { + It("invokes callback and completes task", func() { + var callCount atomic.Int32 + var receivedQueueName, receivedTaskID string + var receivedPayload []byte + var receivedAttempt int32 + + service.invokeCallbackFn = func(_ context.Context, queueName, taskID string, payload []byte, attempt int32) (string, error) { + callCount.Add(1) + receivedQueueName = queueName + receivedTaskID = taskID + receivedPayload = payload + receivedAttempt = attempt + return "", nil + } + + err := service.CreateQueue(ctx, "worker-test", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "worker-test", []byte("test-payload")) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + + Expect(callCount.Load()).To(Equal(int32(1))) + Expect(receivedQueueName).To(Equal("worker-test")) + Expect(receivedTaskID).To(Equal(taskID)) + Expect(receivedPayload).To(Equal([]byte("test-payload"))) + Expect(receivedAttempt).To(Equal(int32(1))) + }) + }) + + Describe("Message storage", func() { + It("stores message on successful completion", func() { + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "task completed successfully", nil + } + + err := service.CreateQueue(ctx, "msg-success", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "msg-success", []byte("data")) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + + info, err := service.Get(ctx, taskID) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Message).To(Equal("task completed successfully")) + Expect(info.Attempt).To(Equal(int32(1))) + }) + + It("stores error message on failure", func() { + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", fmt.Errorf("something went wrong") + } + + err := service.CreateQueue(ctx, "msg-fail", host.QueueConfig{ + MaxRetries: 0, + }) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "msg-fail", []byte("data")) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed")) + + info, err := service.Get(ctx, taskID) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Message).To(Equal("something went wrong")) + }) + + It("uses explicit message over error message on failure", func() { + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "partial progress made", fmt.Errorf("timeout exceeded") + } + + err := service.CreateQueue(ctx, "msg-fail-with-msg", host.QueueConfig{ + MaxRetries: 0, + }) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "msg-fail-with-msg", []byte("data")) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed")) + + info, err := service.Get(ctx, taskID) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Message).To(Equal("partial progress made")) + }) + }) + + Describe("Retry on failure", func() { + It("retries and eventually fails after exhausting retries", func() { + var callCount atomic.Int32 + + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + callCount.Add(1) + return "", fmt.Errorf("task failed") + } + + err := service.CreateQueue(ctx, "retry-test", host.QueueConfig{ + MaxRetries: 2, + BackoffMs: 10, // Very short for testing + }) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "retry-test", []byte("retry-payload")) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed")) + + // 1 initial attempt + 2 retries = 3 total calls + Expect(callCount.Load()).To(Equal(int32(3))) + }) + }) + + Describe("Retry then succeed", func() { + It("retries and succeeds on second attempt", func() { + var callCount atomic.Int32 + + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, attempt int32) (string, error) { + callCount.Add(1) + if attempt == 1 { + return "", fmt.Errorf("temporary error") + } + return "success", nil + } + + err := service.CreateQueue(ctx, "retry-succeed", host.QueueConfig{ + MaxRetries: 1, + BackoffMs: 10, // Very short for testing + }) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "retry-succeed", []byte("data")) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + + Expect(callCount.Load()).To(Equal(int32(2))) + }) + }) + + Describe("Backoff overflow cap", func() { + It("caps backoff at maxRetentionMs to prevent overflow", func() { + var callCount atomic.Int32 + + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + callCount.Add(1) + return "", fmt.Errorf("always fail") + } + + err := service.CreateQueue(ctx, "backoff-overflow", host.QueueConfig{ + MaxRetries: 3, + BackoffMs: 1_000_000_000, // Very large backoff to trigger overflow on exponentiation + }) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "backoff-overflow", []byte("overflow-test")) + Expect(err).ToNot(HaveOccurred()) + + // Wait for first attempt to fail + Eventually(func() int32 { + return callCount.Load() + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(BeNumerically(">=", int32(1))) + + // Check next_run_at is positive and reasonable (capped at maxRetentionMs from now) + var nextRunAt int64 + err = service.db.QueryRow(`SELECT next_run_at FROM tasks WHERE id = ?`, taskID).Scan(&nextRunAt) + Expect(err).ToNot(HaveOccurred()) + + now := time.Now().UnixMilli() + Expect(nextRunAt).To(BeNumerically(">", int64(0)), "next_run_at should be positive") + Expect(nextRunAt).To(BeNumerically("<=", now+maxBackoffMs+1000), "next_run_at should be at most maxBackoffMs from now") + }) + }) + + Describe("Delay enforcement with concurrent workers", func() { + It("enforces delay between dispatches even with multiple workers", func() { + var mu sync.Mutex + var dispatchTimes []time.Time + + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + mu.Lock() + dispatchTimes = append(dispatchTimes, time.Now()) + mu.Unlock() + return "", nil + } + + err := service.CreateQueue(ctx, "delay-concurrent", host.QueueConfig{ + Concurrency: 3, + DelayMs: 200, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue 5 tasks + for i := 0; i < 5; i++ { + _, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i))) + Expect(err).ToNot(HaveOccurred()) + } + + // Wait for all tasks to complete + Eventually(func() int { + mu.Lock() + defer mu.Unlock() + return len(dispatchTimes) + }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal(5)) + + // Sort dispatch times and verify gaps + mu.Lock() + sort.Slice(dispatchTimes, func(i, j int) bool { + return dispatchTimes[i].Before(dispatchTimes[j]) + }) + times := make([]time.Time, len(dispatchTimes)) + copy(times, dispatchTimes) + mu.Unlock() + + // Consecutive dispatches should have at least ~160ms gap (80% of 200ms) + for i := 1; i < len(times); i++ { + gap := times[i].Sub(times[i-1]) + Expect(gap).To(BeNumerically(">=", 160*time.Millisecond), + fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap)) + } + }) + }) + + Describe("Shutdown recovery", func() { + It("resets stale running tasks on CreateQueue", func() { + // Create a first service and queue, enqueue a task + service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { + <-ctx.Done() + return "", ctx.Err() + } + err := service.CreateQueue(ctx, "recovery-queue", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + taskID, err := service.Enqueue(ctx, "recovery-queue", []byte("stale-task")) + Expect(err).ToNot(HaveOccurred()) + + // Wait for the task to start running + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("running")) + + // Close the service (simulates crash - tasks left in running state) + service.Close() + + // Create a new service pointing to the same DB + managerCtx2, cancel2 := context.WithCancel(ctx) + DeferCleanup(cancel2) + manager2 := &Manager{ + plugins: make(map[string]*plugin), + ctx: managerCtx2, + } + + service, err = newTaskQueueService("test_plugin", manager2, 5) + Expect(err).ToNot(HaveOccurred()) + + // Override callback to succeed + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { + return "", nil + } + + // Re-create the queue - the upsert handles the existing row from the old service + err = service.CreateQueue(ctx, "recovery-queue", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + // The stale running task should now be reset to pending and eventually completed + Eventually(func() string { + info, err := service.Get(ctx, taskID) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + }) + }) + + Describe("Close", func() { + It("prevents subsequent operations after close", func() { + err := service.CreateQueue(ctx, "close-test", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + service.Close() + + // After close, operations should fail + _, err = service.Enqueue(ctx, "close-test", []byte("data")) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Plugin isolation", func() { + It("uses separate databases for different plugins", func() { + managerCtx2, cancel2 := context.WithCancel(ctx) + DeferCleanup(cancel2) + manager2 := &Manager{ + plugins: make(map[string]*plugin), + ctx: managerCtx2, + } + + service2, err := newTaskQueueService("other_plugin", manager2, 5) + Expect(err).ToNot(HaveOccurred()) + defer service2.Close() + + // Check that separate database files exist + _, err = os.Stat(filepath.Join(tmpDir, "plugins", "test_plugin", "taskqueue.db")) + Expect(err).ToNot(HaveOccurred()) + _, err = os.Stat(filepath.Join(tmpDir, "plugins", "other_plugin", "taskqueue.db")) + Expect(err).ToNot(HaveOccurred()) + + // Both services should be able to create queues with the same name independently + service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { return "", nil } + service2.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { return "", nil } + + err = service.CreateQueue(ctx, "shared-name", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + err = service2.CreateQueue(ctx, "shared-name", host.QueueConfig{}) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue to each and verify they work independently + taskID1, err := service.Enqueue(ctx, "shared-name", []byte("plugin1")) + Expect(err).ToNot(HaveOccurred()) + taskID2, err := service2.Enqueue(ctx, "shared-name", []byte("plugin2")) + Expect(err).ToNot(HaveOccurred()) + + Expect(taskID1).ToNot(Equal(taskID2)) + + // Both should complete + Eventually(func() string { + info, err := service.Get(ctx, taskID1) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + + Eventually(func() string { + info, err := service2.Get(ctx, taskID2) + if err != nil || info == nil { + return "" + } + return info.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + }) + }) +}) + +var _ = Describe("TaskQueueService Integration", Ordered, func() { + var manager *Manager + var tmpDir string + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "taskqueue-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the test-taskqueue plugin + srcPath := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-taskqueue"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + conf.Server.DataFolder = tmpDir + + // Setup mock DataStore with pre-enabled plugin + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-taskqueue", + Path: destPath, + SHA256: hashHex, + Enabled: true, + }}) + dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + metrics: noopMetricsRecorder{}, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + // Helper types for calling the test plugin + type testQueueConfig struct { + Concurrency int32 `json:"concurrency,omitempty"` + MaxRetries int32 `json:"maxRetries,omitempty"` + BackoffMs int64 `json:"backoffMs,omitempty"` + DelayMs int64 `json:"delayMs,omitempty"` + RetentionMs int64 `json:"retentionMs,omitempty"` + } + + type testTaskQueueInput struct { + Operation string `json:"operation"` + QueueName string `json:"queueName,omitempty"` + Config *testQueueConfig `json:"config,omitempty"` + Payload []byte `json:"payload,omitempty"` + TaskID string `json:"taskId,omitempty"` + } + + type testTaskQueueOutput struct { + TaskID string `json:"taskId,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Attempt int32 `json:"attempt,omitempty"` + Cleared int64 `json:"cleared,omitempty"` + Error *string `json:"error,omitempty"` + } + + callTestTaskQueue := func(ctx context.Context, input testTaskQueueInput) (*testTaskQueueOutput, error) { + manager.mu.RLock() + p := manager.plugins["test-taskqueue"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_taskqueue", inputBytes) + if err != nil { + return nil, err + } + + var output testTaskQueueOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + Describe("Plugin Loading", func() { + It("should load plugin with taskqueue permission and TaskWorker capability", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-taskqueue"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Taskqueue).ToNot(BeNil()) + Expect(p.manifest.Permissions.Taskqueue.MaxConcurrency).To(Equal(10)) + Expect(p.capabilities).To(ContainElement(CapabilityTaskWorker)) + }) + }) + + Describe("Create Queue", func() { + It("should create a queue without error", func() { + ctx := GinkgoT().Context() + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-create", + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return error for duplicate queue name", func() { + ctx := GinkgoT().Context() + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-dup", + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-dup", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("already exists")) + }) + }) + + Describe("Enqueue and Task Completion", func() { + It("should enqueue a task and complete successfully", func() { + ctx := GinkgoT().Context() + + // Create queue + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-complete", + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue task with payload "hello" + output, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "enqueue", + QueueName: "test-complete", + Payload: []byte("hello"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.TaskID).ToNot(BeEmpty()) + + taskID := output.TaskID + + // Poll until completed + Eventually(func() string { + out, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "get_task_status", + TaskID: taskID, + }) + if err != nil { + return "error" + } + return out.Status + }).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("completed")) + }) + }) + + Describe("Enqueue with Failure, No Retries", func() { + It("should fail when payload is 'fail' and maxRetries is 0", func() { + ctx := GinkgoT().Context() + + // Create queue with no retries + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-fail-no-retry", + Config: &testQueueConfig{ + MaxRetries: 0, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue task that will fail + output, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "enqueue", + QueueName: "test-fail-no-retry", + Payload: []byte("fail"), + }) + Expect(err).ToNot(HaveOccurred()) + + taskID := output.TaskID + + // Poll until failed + Eventually(func() string { + out, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "get_task_status", + TaskID: taskID, + }) + if err != nil { + return "error" + } + return out.Status + }).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("failed")) + }) + }) + + Describe("Enqueue with Retry Then Success", func() { + It("should retry and eventually succeed with 'fail-then-succeed' payload", func() { + ctx := GinkgoT().Context() + + // Create queue with retries and short backoff + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-retry-succeed", + Config: &testQueueConfig{ + MaxRetries: 2, + BackoffMs: 100, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue task that fails on attempt < 2, then succeeds + output, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "enqueue", + QueueName: "test-retry-succeed", + Payload: []byte("fail-then-succeed"), + }) + Expect(err).ToNot(HaveOccurred()) + + taskID := output.TaskID + + // Poll until completed + Eventually(func() string { + out, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "get_task_status", + TaskID: taskID, + }) + if err != nil { + return "error" + } + return out.Status + }).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("completed")) + }) + }) + + Describe("Cancel Pending Task", func() { + It("should cancel a pending task", func() { + ctx := GinkgoT().Context() + + // Create queue with concurrency=1 and a large delay between dispatches. + // The first task completes immediately (burst token), the second is dequeued + // but blocks on the rate limiter. Tasks 3+ remain in 'pending' and can be cancelled. + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-cancel", + Config: &testQueueConfig{ + Concurrency: 1, + DelayMs: 60000, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue several tasks - the first will complete immediately, + // the second will be dequeued but block on the rate limiter (status=running), + // the rest will stay pending. + var taskIDs []string + for i := 0; i < 5; i++ { + output, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "enqueue", + QueueName: "test-cancel", + Payload: []byte("hello"), + }) + Expect(err).ToNot(HaveOccurred()) + taskIDs = append(taskIDs, output.TaskID) + } + + // Wait for the first task to complete (it has no delay) + Eventually(func() string { + out, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "get_task_status", + TaskID: taskIDs[0], + }) + if err != nil { + return "error" + } + return out.Status + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) + + // Give the worker a moment to dequeue the second task (which will + // block on the delay) so tasks 3+ stay in 'pending' + time.Sleep(100 * time.Millisecond) + + // Cancel the last task - it should still be pending + lastTaskID := taskIDs[len(taskIDs)-1] + _, err = callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "cancel_task", + TaskID: lastTaskID, + }) + Expect(err).ToNot(HaveOccurred()) + + // Verify status is cancelled + statusOut, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "get_task_status", + TaskID: lastTaskID, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(statusOut.Status).To(Equal("cancelled")) + }) + }) + + Describe("Enqueue to Non-Existent Queue", func() { + It("should return error when enqueueing to a queue that does not exist", func() { + ctx := GinkgoT().Context() + + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "enqueue", + QueueName: "nonexistent-queue", + Payload: []byte("payload"), + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + }) + + Describe("Clear Queue", func() { + It("should clear pending tasks and return the count", func() { + ctx := GinkgoT().Context() + + // Create queue with large delay so tasks stay pending after the first completes + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "create_queue", + QueueName: "test-clear", + Config: &testQueueConfig{ + Concurrency: 1, + DelayMs: 60000, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Enqueue several tasks + for i := 0; i < 4; i++ { + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "enqueue", + QueueName: "test-clear", + Payload: []byte(fmt.Sprintf("task-%d", i)), + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Wait for the first task to complete (burst token) + time.Sleep(200 * time.Millisecond) + + // Clear the queue — should cancel remaining pending tasks + output, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "clear_queue", + QueueName: "test-clear", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Cleared).To(BeNumerically(">=", int64(1))) + }) + + It("should return error for non-existent queue", func() { + ctx := GinkgoT().Context() + + _, err := callTestTaskQueue(ctx, testTaskQueueInput{ + Operation: "clear_queue", + QueueName: "nonexistent-clear", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 610dbd028..59f48453f 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -128,6 +128,23 @@ var hostServices = []hostServiceEntry{ return host.RegisterHTTPHostFunctions(service), nil }, }, + { + name: "Task", + hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + perm := ctx.permissions.Taskqueue + maxConcurrency := int32(1) + if perm.MaxConcurrency > 0 { + maxConcurrency = int32(perm.MaxConcurrency) + } + service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency) + if err != nil { + log.Error("Failed to create Task service", "plugin", ctx.pluginName, err) + return nil, nil + } + return host.RegisterTaskHostFunctions(service), service + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 4e64ca6ea..8daf88ccf 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -110,6 +110,9 @@ }, "users": { "$ref": "#/$defs/UsersPermission" + }, + "taskqueue": { + "$ref": "#/$defs/TaskQueuePermission" } } }, @@ -224,6 +227,23 @@ } } }, + "TaskQueuePermission": { + "type": "object", + "description": "Task queue permissions for background task processing", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why task queue access is needed" + }, + "maxConcurrency": { + "type": "integer", + "description": "Maximum total concurrent workers across all queues. Default: 1", + "minimum": 1, + "default": 1 + } + } + }, "UsersPermission": { "type": "object", "description": "Users service permissions for accessing user information", diff --git a/plugins/manifest.go b/plugins/manifest.go index 3ca2657cd..375e73e7f 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -72,6 +72,13 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { } } + // Task (taskqueue) permission requires TaskWorker capability + if m.Permissions != nil && m.Permissions.Taskqueue != nil { + if !hasCapability(capabilities, CapabilityTaskWorker) { + return fmt.Errorf("'taskqueue' permission requires plugin to export '%s' function", FuncTaskWorkerCallback) + } + } + return nil } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 27c3c0677..a565ed3d1 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -181,6 +181,9 @@ type Permissions struct { // Subsonicapi corresponds to the JSON schema field "subsonicapi". Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` + // Taskqueue corresponds to the JSON schema field "taskqueue". + Taskqueue *TaskQueuePermission `json:"taskqueue,omitempty" yaml:"taskqueue,omitempty" mapstructure:"taskqueue,omitempty"` + // Users corresponds to the JSON schema field "users". Users *UsersPermission `json:"users,omitempty" yaml:"users,omitempty" mapstructure:"users,omitempty"` @@ -200,6 +203,36 @@ type SubsonicAPIPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Task queue permissions for background task processing +type TaskQueuePermission struct { + // Maximum total concurrent workers across all queues. Default: 1 + MaxConcurrency int `json:"maxConcurrency,omitempty" yaml:"maxConcurrency,omitempty" mapstructure:"maxConcurrency,omitempty"` + + // Explanation for why task queue access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + type Plain TaskQueuePermission + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["maxConcurrency"]; !ok || v == nil { + plain.MaxConcurrency = 1.0 + } + if 1 > plain.MaxConcurrency { + return fmt.Errorf("field %s: must be >= %v", "maxConcurrency", 1) + } + *j = TaskQueuePermission(plain) + return nil +} + // Enable experimental WebAssembly threads support type ThreadsFeature struct { // Explanation for why threads support is needed diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index b801db44b..5781a04c1 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Scheduler: provides task scheduling capabilities for plugins. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. + - Task: provides persistent task queues for plugins. - Users: provides access to user information for plugins. - WebSocket: provides WebSocket communication capabilities for plugins. diff --git a/plugins/pdk/go/host/nd_host_task.go b/plugins/pdk/go/host/nd_host_task.go new file mode 100644 index 000000000..92a41c5bc --- /dev/null +++ b/plugins/pdk/go/host/nd_host_task.go @@ -0,0 +1,277 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Task host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// QueueConfig represents the QueueConfig data structure. +// QueueConfig holds configuration for a task queue. +type QueueConfig struct { + Concurrency int32 `json:"concurrency"` + MaxRetries int32 `json:"maxRetries"` + BackoffMs int64 `json:"backoffMs"` + DelayMs int64 `json:"delayMs"` + RetentionMs int64 `json:"retentionMs"` +} + +// TaskInfo represents the TaskInfo data structure. +// TaskInfo holds the current state of a task. +type TaskInfo struct { + Status string `json:"status"` + Message string `json:"message"` + Attempt int32 `json:"attempt"` +} + +// task_createqueue is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user task_createqueue +func task_createqueue(uint64) uint64 + +// task_enqueue is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user task_enqueue +func task_enqueue(uint64) uint64 + +// task_get is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user task_get +func task_get(uint64) uint64 + +// task_cancel is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user task_cancel +func task_cancel(uint64) uint64 + +// task_clearqueue is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user task_clearqueue +func task_clearqueue(uint64) uint64 + +type taskCreateQueueRequest struct { + Name string `json:"name"` + Config QueueConfig `json:"config"` +} + +type taskEnqueueRequest struct { + QueueName string `json:"queueName"` + Payload []byte `json:"payload"` +} + +type taskEnqueueResponse struct { + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type taskGetRequest struct { + TaskID string `json:"taskId"` +} + +type taskGetResponse struct { + Result *TaskInfo `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type taskCancelRequest struct { + TaskID string `json:"taskId"` +} + +type taskClearQueueRequest struct { + QueueName string `json:"queueName"` +} + +type taskClearQueueResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// TaskCreateQueue calls the task_createqueue host function. +// CreateQueue creates a named task queue with the given configuration. +// Zero-value fields in config use sensible defaults. +// If a queue with the same name already exists, returns an error. +// On startup, this also recovers any stale "running" tasks from a previous crash. +func TaskCreateQueue(name string, config QueueConfig) error { + // Marshal request to JSON + req := taskCreateQueueRequest{ + Name: name, + Config: config, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := task_createqueue(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse error-only response + var response struct { + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(responseBytes, &response); err != nil { + return err + } + if response.Error != "" { + return errors.New(response.Error) + } + return nil +} + +// TaskEnqueue calls the task_enqueue host function. +// Enqueue adds a task to the named queue. Returns the task ID. +// payload is opaque bytes passed back to the plugin on execution. +func TaskEnqueue(queueName string, payload []byte) (string, error) { + // Marshal request to JSON + req := taskEnqueueRequest{ + QueueName: queueName, + Payload: payload, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := task_enqueue(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response taskEnqueueResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return "", err + } + + // Convert Error field to Go error + if response.Error != "" { + return "", errors.New(response.Error) + } + + return response.Result, nil +} + +// TaskGet calls the task_get host function. +// Get returns the current state of a task including its status, +// message, and attempt count. +func TaskGet(taskID string) (*TaskInfo, error) { + // Marshal request to JSON + req := taskGetRequest{ + TaskID: taskID, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := task_get(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response taskGetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// TaskCancel calls the task_cancel host function. +// Cancel cancels a pending task. Returns error if already +// running, completed, or failed. +func TaskCancel(taskID string) error { + // Marshal request to JSON + req := taskCancelRequest{ + TaskID: taskID, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := task_cancel(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse error-only response + var response struct { + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(responseBytes, &response); err != nil { + return err + } + if response.Error != "" { + return errors.New(response.Error) + } + return nil +} + +// TaskClearQueue calls the task_clearqueue host function. +// ClearQueue removes all pending tasks from the named queue. +// Running tasks are not affected. Returns the number of tasks removed. +func TaskClearQueue(queueName string) (int64, error) { + // Marshal request to JSON + req := taskClearQueueRequest{ + QueueName: queueName, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := task_clearqueue(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response taskClearQueueResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go new file mode 100644 index 000000000..4dde0e234 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -0,0 +1,105 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import "github.com/stretchr/testify/mock" + +// QueueConfig represents the QueueConfig data structure. +// QueueConfig holds configuration for a task queue. +type QueueConfig struct { + Concurrency int32 `json:"concurrency"` + MaxRetries int32 `json:"maxRetries"` + BackoffMs int64 `json:"backoffMs"` + DelayMs int64 `json:"delayMs"` + RetentionMs int64 `json:"retentionMs"` +} + +// TaskInfo represents the TaskInfo data structure. +// TaskInfo holds the current state of a task. +type TaskInfo struct { + Status string `json:"status"` + Message string `json:"message"` + Attempt int32 `json:"attempt"` +} + +// mockTaskService is the mock implementation for testing. +type mockTaskService struct { + mock.Mock +} + +// TaskMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.TaskMock.On("MethodName", args...).Return(values...) +var TaskMock = &mockTaskService{} + +// CreateQueue is the mock method for TaskCreateQueue. +func (m *mockTaskService) CreateQueue(name string, config QueueConfig) error { + args := m.Called(name, config) + return args.Error(0) +} + +// TaskCreateQueue delegates to the mock instance. +// CreateQueue creates a named task queue with the given configuration. +// Zero-value fields in config use sensible defaults. +// If a queue with the same name already exists, returns an error. +// On startup, this also recovers any stale "running" tasks from a previous crash. +func TaskCreateQueue(name string, config QueueConfig) error { + return TaskMock.CreateQueue(name, config) +} + +// Enqueue is the mock method for TaskEnqueue. +func (m *mockTaskService) Enqueue(queueName string, payload []byte) (string, error) { + args := m.Called(queueName, payload) + return args.String(0), args.Error(1) +} + +// TaskEnqueue delegates to the mock instance. +// Enqueue adds a task to the named queue. Returns the task ID. +// payload is opaque bytes passed back to the plugin on execution. +func TaskEnqueue(queueName string, payload []byte) (string, error) { + return TaskMock.Enqueue(queueName, payload) +} + +// Get is the mock method for TaskGet. +func (m *mockTaskService) Get(taskID string) (*TaskInfo, error) { + args := m.Called(taskID) + return args.Get(0).(*TaskInfo), args.Error(1) +} + +// TaskGet delegates to the mock instance. +// Get returns the current state of a task including its status, +// message, and attempt count. +func TaskGet(taskID string) (*TaskInfo, error) { + return TaskMock.Get(taskID) +} + +// Cancel is the mock method for TaskCancel. +func (m *mockTaskService) Cancel(taskID string) error { + args := m.Called(taskID) + return args.Error(0) +} + +// TaskCancel delegates to the mock instance. +// Cancel cancels a pending task. Returns error if already +// running, completed, or failed. +func TaskCancel(taskID string) error { + return TaskMock.Cancel(taskID) +} + +// ClearQueue is the mock method for TaskClearQueue. +func (m *mockTaskService) ClearQueue(queueName string) (int64, error) { + args := m.Called(queueName) + return args.Get(0).(int64), args.Error(1) +} + +// TaskClearQueue delegates to the mock instance. +// ClearQueue removes all pending tasks from the named queue. +// Running tasks are not affected. Returns the number of tasks removed. +func TaskClearQueue(queueName string) (int64, error) { + return TaskMock.ClearQueue(queueName) +} diff --git a/plugins/pdk/go/taskworker/taskworker.go b/plugins/pdk/go/taskworker/taskworker.go new file mode 100644 index 000000000..5d09a3209 --- /dev/null +++ b/plugins/pdk/go/taskworker/taskworker.go @@ -0,0 +1,79 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the TaskWorker capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package taskworker + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// TaskExecuteRequest is the request provided when a task is ready to execute. +type TaskExecuteRequest struct { + // QueueName is the name of the queue this task belongs to. + QueueName string `json:"queueName"` + // TaskID is the unique identifier for this task. + TaskID string `json:"taskId"` + // Payload is the opaque data provided when the task was enqueued. + Payload []byte `json:"payload"` + // Attempt is the current attempt number (1-based: first attempt = 1). + Attempt int32 `json:"attempt"` +} + +// TaskWorker is the marker interface for taskworker plugins. +// Implement one or more of the provider interfaces below. +// TaskWorker provides task execution handling. +// This capability allows plugins to receive callbacks when their queued tasks +// are ready to execute. Plugins that use the taskqueue host service must +// implement this capability. +type TaskWorker interface{} + +// TaskExecuteProvider provides the OnTaskExecute function. +type TaskExecuteProvider interface { + OnTaskExecute(TaskExecuteRequest) (string, error) +} // Internal implementation holders +var ( + taskExecuteImpl func(TaskExecuteRequest) (string, error) +) + +// Register registers a taskworker implementation. +// The implementation is checked for optional provider interfaces. +func Register(impl TaskWorker) { + if p, ok := impl.(TaskExecuteProvider); ok { + taskExecuteImpl = p.OnTaskExecute + } +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_task_execute +func _NdTaskExecute() int32 { + if taskExecuteImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input TaskExecuteRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := taskExecuteImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/taskworker/taskworker_stub.go b/plugins/pdk/go/taskworker/taskworker_stub.go new file mode 100644 index 000000000..e45054e8e --- /dev/null +++ b/plugins/pdk/go/taskworker/taskworker_stub.go @@ -0,0 +1,41 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package taskworker + +// TaskExecuteRequest is the request provided when a task is ready to execute. +type TaskExecuteRequest struct { + // QueueName is the name of the queue this task belongs to. + QueueName string `json:"queueName"` + // TaskID is the unique identifier for this task. + TaskID string `json:"taskId"` + // Payload is the opaque data provided when the task was enqueued. + Payload []byte `json:"payload"` + // Attempt is the current attempt number (1-based: first attempt = 1). + Attempt int32 `json:"attempt"` +} + +// TaskWorker is the marker interface for taskworker plugins. +// Implement one or more of the provider interfaces below. +// TaskWorker provides task execution handling. +// This capability allows plugins to receive callbacks when their queued tasks +// are ready to execute. Plugins that use the taskqueue host service must +// implement this capability. +type TaskWorker interface{} + +// TaskExecuteProvider provides the OnTaskExecute function. +type TaskExecuteProvider interface { + OnTaskExecute(TaskExecuteRequest) (string, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ TaskWorker) {} diff --git a/plugins/pdk/python/host/nd_host_task.py b/plugins/pdk/python/host/nd_host_task.py new file mode 100644 index 000000000..5d6e7474c --- /dev/null +++ b/plugins/pdk/python/host/nd_host_task.py @@ -0,0 +1,188 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the Task host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json +import base64 + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "task_createqueue") +def _task_createqueue(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "task_enqueue") +def _task_enqueue(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "task_get") +def _task_get(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "task_cancel") +def _task_cancel(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "task_clearqueue") +def _task_clearqueue(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def task_create_queue(name: str, config: Any) -> None: + """CreateQueue creates a named task queue with the given configuration. +Zero-value fields in config use sensible defaults. +If a queue with the same name already exists, returns an error. +On startup, this also recovers any stale "running" tasks from a previous crash. + + Args: + name: str parameter. + config: Any parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "name": name, + "config": config, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _task_createqueue(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + + +def task_enqueue(queue_name: str, payload: bytes) -> str: + """Enqueue adds a task to the named queue. Returns the task ID. +payload is opaque bytes passed back to the plugin on execution. + + Args: + queue_name: str parameter. + payload: bytes parameter. + + Returns: + str: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "queueName": queue_name, + "payload": base64.b64encode(payload).decode("ascii"), + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _task_enqueue(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", "") + + +def task_get(task_id: str) -> Any: + """Get returns the current state of a task including its status, +message, and attempt count. + + Args: + task_id: str parameter. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "taskId": task_id, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _task_get(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", None) + + +def task_cancel(task_id: str) -> None: + """Cancel cancels a pending task. Returns error if already +running, completed, or failed. + + Args: + task_id: str parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "taskId": task_id, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _task_cancel(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + + +def task_clear_queue(queue_name: str) -> int: + """ClearQueue removes all pending tasks from the named queue. +Running tasks are not affected. Returns the number of tasks removed. + + Args: + queue_name: str parameter. + + Returns: + int: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "queueName": queue_name, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _task_clearqueue(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", 0) diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 0f0daf80f..06c2c5c0d 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -9,4 +9,5 @@ pub mod lifecycle; pub mod metadata; pub mod scheduler; pub mod scrobbler; +pub mod taskworker; pub mod websocket; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs new file mode 100644 index 000000000..e8aa106a2 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs @@ -0,0 +1,102 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the TaskWorker capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// TaskExecuteRequest is the request provided when a task is ready to execute. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskExecuteRequest { + /// QueueName is the name of the queue this task belongs to. + #[serde(default)] + pub queue_name: String, + /// TaskID is the unique identifier for this task. + #[serde(default)] + pub task_id: String, + /// Payload is the opaque data provided when the task was enqueued. + #[serde(default)] + #[serde(with = "base64_bytes")] + pub payload: Vec, + /// Attempt is the current attempt number (1-based: first attempt = 1). + #[serde(default)] + pub attempt: i32, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// TaskExecuteProvider provides the OnTaskExecute function. +pub trait TaskExecuteProvider { + fn on_task_execute(&self, req: TaskExecuteRequest) -> Result; +} + +/// Register the on_task_execute export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_taskworker_task_execute { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_task_execute( + req: extism_pdk::Json<$crate::taskworker::TaskExecuteRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::taskworker::TaskExecuteProvider::on_task_execute(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 52a3a86cd..3a31bc489 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`scheduler`] - provides task scheduling capabilities for plugins. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. +//! - [`task`] - provides persistent task queues for plugins. //! - [`users`] - provides access to user information for plugins. //! - [`websocket`] - provides WebSocket communication capabilities for plugins. @@ -99,6 +100,13 @@ pub mod subsonicapi { pub use super::nd_host_subsonicapi::*; } +#[doc(hidden)] +mod nd_host_task; +/// provides persistent task queues for plugins. +pub mod task { + pub use super::nd_host_task::*; +} + #[doc(hidden)] mod nd_host_users; /// provides access to user information for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs new file mode 100644 index 000000000..4f43e165c --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs @@ -0,0 +1,258 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Task host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +mod base64_bytes { + use serde::{self, Deserialize, Deserializer, Serializer}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + BASE64.decode(&s).map_err(serde::de::Error::custom) + } +} + +/// QueueConfig holds configuration for a task queue. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueueConfig { + pub concurrency: i32, + pub max_retries: i32, + pub backoff_ms: i64, + pub delay_ms: i64, + pub retention_ms: i64, +} + +/// TaskInfo holds the current state of a task. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskInfo { + pub status: String, + pub message: String, + pub attempt: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TaskCreateQueueRequest { + name: String, + config: QueueConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskCreateQueueResponse { + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TaskEnqueueRequest { + queue_name: String, + #[serde(with = "base64_bytes")] + payload: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskEnqueueResponse { + #[serde(default)] + result: String, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TaskGetRequest { + task_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskGetResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TaskCancelRequest { + task_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskCancelResponse { + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TaskClearQueueRequest { + queue_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskClearQueueResponse { + #[serde(default)] + result: i64, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn task_createqueue(input: Json) -> Json; + fn task_enqueue(input: Json) -> Json; + fn task_get(input: Json) -> Json; + fn task_cancel(input: Json) -> Json; + fn task_clearqueue(input: Json) -> Json; +} + +/// CreateQueue creates a named task queue with the given configuration. +/// Zero-value fields in config use sensible defaults. +/// If a queue with the same name already exists, returns an error. +/// On startup, this also recovers any stale "running" tasks from a previous crash. +/// +/// # Arguments +/// * `name` - String parameter. +/// * `config` - QueueConfig parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn create_queue(name: &str, config: QueueConfig) -> Result<(), Error> { + let response = unsafe { + task_createqueue(Json(TaskCreateQueueRequest { + name: name.to_owned(), + config: config, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(()) +} + +/// Enqueue adds a task to the named queue. Returns the task ID. +/// payload is opaque bytes passed back to the plugin on execution. +/// +/// # Arguments +/// * `queue_name` - String parameter. +/// * `payload` - Vec parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn enqueue(queue_name: &str, payload: Vec) -> Result { + let response = unsafe { + task_enqueue(Json(TaskEnqueueRequest { + queue_name: queue_name.to_owned(), + payload: payload, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// Get returns the current state of a task including its status, +/// message, and attempt count. +/// +/// # Arguments +/// * `task_id` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get(task_id: &str) -> Result, Error> { + let response = unsafe { + task_get(Json(TaskGetRequest { + task_id: task_id.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// Cancel cancels a pending task. Returns error if already +/// running, completed, or failed. +/// +/// # Arguments +/// * `task_id` - String parameter. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn cancel(task_id: &str) -> Result<(), Error> { + let response = unsafe { + task_cancel(Json(TaskCancelRequest { + task_id: task_id.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(()) +} + +/// ClearQueue removes all pending tasks from the named queue. +/// Running tasks are not affected. Returns the number of tasks removed. +/// +/// # Arguments +/// * `queue_name` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn clear_queue(queue_name: &str) -> Result { + let response = unsafe { + task_clearqueue(Json(TaskClearQueueRequest { + queue_name: queue_name.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-taskqueue/go.mod b/plugins/testdata/test-taskqueue/go.mod new file mode 100644 index 000000000..37f857e5a --- /dev/null +++ b/plugins/testdata/test-taskqueue/go.mod @@ -0,0 +1,16 @@ +module test-taskqueue + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-taskqueue/go.sum b/plugins/testdata/test-taskqueue/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-taskqueue/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-taskqueue/main.go b/plugins/testdata/test-taskqueue/main.go new file mode 100644 index 000000000..9b734ed67 --- /dev/null +++ b/plugins/testdata/test-taskqueue/main.go @@ -0,0 +1,114 @@ +// Test TaskQueue plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-taskqueue.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "fmt" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/taskworker" +) + +func init() { + taskworker.Register(&handler{}) +} + +type handler struct{} + +func (h *handler) OnTaskExecute(req taskworker.TaskExecuteRequest) (string, error) { + payload := string(req.Payload) + if payload == "fail" { + return "", fmt.Errorf("task failed as instructed") + } + if payload == "fail-then-succeed" && req.Attempt < 2 { + return "", fmt.Errorf("transient failure") + } + return "completed successfully", nil +} + +// Test helper types +type TestInput struct { + Operation string `json:"operation"` + QueueName string `json:"queueName,omitempty"` + Config *host.QueueConfig `json:"config,omitempty"` + Payload []byte `json:"payload,omitempty"` + TaskID string `json:"taskId,omitempty"` +} + +type TestOutput struct { + TaskID string `json:"taskId,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Attempt int32 `json:"attempt,omitempty"` + Cleared int64 `json:"cleared,omitempty"` + Error *string `json:"error,omitempty"` +} + +//go:wasmexport nd_test_taskqueue +func ndTestTaskQueue() int32 { + var input TestInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + + switch input.Operation { + case "create_queue": + config := host.QueueConfig{} + if input.Config != nil { + config = *input.Config + } + err := host.TaskCreateQueue(input.QueueName, config) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestOutput{}) + + case "enqueue": + taskID, err := host.TaskEnqueue(input.QueueName, input.Payload) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestOutput{TaskID: taskID}) + + case "get_task_status": + info, err := host.TaskGet(input.TaskID) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestOutput{Status: info.Status, Message: info.Message, Attempt: info.Attempt}) + + case "cancel_task": + err := host.TaskCancel(input.TaskID) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestOutput{}) + + case "clear_queue": + cleared, err := host.TaskClearQueue(input.QueueName) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestOutput{Cleared: cleared}) + + default: + errStr := "unknown operation: " + input.Operation + pdk.OutputJSON(TestOutput{Error: &errStr}) + } + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-taskqueue/manifest.json b/plugins/testdata/test-taskqueue/manifest.json new file mode 100644 index 000000000..3cd3b0f0b --- /dev/null +++ b/plugins/testdata/test-taskqueue/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "Test TaskQueue Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for TaskQueue integration testing", + "permissions": { + "taskqueue": { + "reason": "For testing task queue operations", + "maxConcurrency": 10 + } + } +} From eeb1bd5f41e17bfa54724704a19049fcaf87ed9a Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 3 Mar 2026 13:54:43 -0500 Subject: [PATCH 052/596] fix(plugins): update payload type to string with byte format for task data Signed-off-by: Deluan --- plugins/capabilities/taskworker.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/capabilities/taskworker.yaml b/plugins/capabilities/taskworker.yaml index f10fd0794..7aa7126e0 100644 --- a/plugins/capabilities/taskworker.yaml +++ b/plugins/capabilities/taskworker.yaml @@ -23,10 +23,9 @@ components: type: string description: TaskID is the unique identifier for this task. payload: - type: array + type: string + format: byte description: Payload is the opaque data provided when the task was enqueued. - items: - type: object attempt: type: integer format: int32 From f03ca44a8ec4abc06086e84c95ecf7212854d0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 3 Mar 2026 15:48:39 -0500 Subject: [PATCH 053/596] feat(plugins): add lyrics provider plugin capability (#5126) * feat(plugins): add lyrics provider plugin capability Refactor the lyrics system from a static function to an interface-based service that supports WASM plugin providers. Plugins listed in the LyricsPriority config (alongside "embedded" and file extensions) are now resolved through the plugin system. Includes capability definition, Go/Rust PDK, adapter, Wire integration, and tests for plugin fallback behavior. * test(plugins): add lyrics capability integration test with test plugin * fix(plugins): default lyrics language to 'xxx' when plugin omits it Per the OpenSubsonic spec, the server must return 'und' or 'xxx' when the lyrics language is unknown. The lyrics plugin adapter was passing an empty string through when a plugin didn't provide a language value. This defaults the language to 'xxx', consistent with all other callers of model.ToLyrics() in the codebase. * refactor(plugins): rename lyrics import to improve clarity Signed-off-by: Deluan * refactor(lyrics): update TrackInfo description for clarity Signed-off-by: Deluan * fix(lyrics): enhance lyrics plugin handling and case sensitivity Signed-off-by: Deluan * fix(plugins): update payload type to string with byte format for task data Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/wire_gen.go | 8 +- cmd/wire_injectors.go | 2 + core/lyrics/lyrics.go | 34 +++- core/lyrics/lyrics_test.go | 108 ++++++++++++- core/lyrics/sources.go | 24 +++ core/wire_providers.go | 2 + plugins/capabilities/lyrics.go | 26 +++ plugins/capabilities/lyrics.yaml | 115 ++++++++++++++ plugins/capabilities/scrobbler.go | 2 +- plugins/capabilities/scrobbler.yaml | 2 +- plugins/lyrics_adapter.go | 59 +++++++ plugins/lyrics_adapter_test.go | 99 ++++++++++++ plugins/manager.go | 17 ++ plugins/pdk/go/lyrics/lyrics.go | 118 ++++++++++++++ plugins/pdk/go/lyrics/lyrics_stub.go | 82 ++++++++++ plugins/pdk/go/scrobbler/scrobbler.go | 2 +- plugins/pdk/go/scrobbler/scrobbler_stub.go | 2 +- .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../rust/nd-pdk-capabilities/src/lyrics.rs | 148 ++++++++++++++++++ .../rust/nd-pdk-capabilities/src/scrobbler.rs | 2 +- plugins/testdata/test-lyrics/go.mod | 16 ++ plugins/testdata/test-lyrics/go.sum | 14 ++ plugins/testdata/test-lyrics/main.go | 42 +++++ plugins/testdata/test-lyrics/manifest.json | 6 + server/e2e/e2e_suite_test.go | 2 + server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 5 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval.go | 5 +- server/subsonic/media_retrieval_test.go | 3 +- server/subsonic/opensubsonic_test.go | 2 +- server/subsonic/playlists_test.go | 4 +- server/subsonic/searching_test.go | 2 +- 33 files changed, 930 insertions(+), 28 deletions(-) create mode 100644 plugins/capabilities/lyrics.go create mode 100644 plugins/capabilities/lyrics.yaml create mode 100644 plugins/lyrics_adapter.go create mode 100644 plugins/lyrics_adapter_test.go create mode 100644 plugins/pdk/go/lyrics/lyrics.go create mode 100644 plugins/pdk/go/lyrics/lyrics_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs create mode 100644 plugins/testdata/test-lyrics/go.mod create mode 100644 plugins/testdata/test-lyrics/go.sum create mode 100644 plugins/testdata/test-lyrics/main.go create mode 100644 plugins/testdata/test-lyrics/manifest.json diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 204d90ba8..e8df9a386 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -1,6 +1,6 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo" +//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5" //go:build !wireinject // +build !wireinject @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -103,7 +104,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics) + lyricsLyrics := lyrics.NewLyrics(manager) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics) return router } @@ -207,7 +209,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 56206feb6..d87a8d6d3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" @@ -44,6 +45,7 @@ var allProviders = wire.NewSet( plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 858a3ffd8..758053042 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -9,23 +9,45 @@ import ( "github.com/navidrome/navidrome/model" ) -func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { +// Lyrics can fetch lyrics for a media file. +type Lyrics interface { + GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) +} + +// PluginLoader discovers and loads lyrics provider plugins. +type PluginLoader interface { + LoadLyricsProvider(name string) (Lyrics, bool) +} + +type lyricsService struct { + pluginLoader PluginLoader +} + +// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin +// system is available. +func NewLyrics(pluginLoader PluginLoader) Lyrics { + return &lyricsService{pluginLoader: pluginLoader} +} + +// GetLyrics returns lyrics for the given media file, trying sources in the +// order specified by conf.Server.LyricsPriority. +func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { var lyricsList model.LyricList var err error - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") { + for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) switch { - case pattern == "embedded": + case strings.EqualFold(pattern, "embedded"): lyricsList, err = fromEmbedded(ctx, mf) case strings.HasPrefix(pattern, "."): - lyricsList, err = fromExternalFile(ctx, mf, pattern) + lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) default: - log.Error(ctx, "Invalid lyric pattern", "pattern", pattern) + lyricsList, err = l.fromPlugin(ctx, mf, pattern) } if err != nil { - log.Error(ctx, "error parsing lyrics", "source", pattern, err) + log.Error(ctx, "error getting lyrics", "source", pattern, err) } if len(lyricsList) > 0 { diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index f4197ccf6..2e495a714 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -3,6 +3,7 @@ package lyrics_test import ( "context" "encoding/json" + "fmt" "os" "github.com/navidrome/navidrome/conf" @@ -72,7 +73,8 @@ var _ = Describe("sources", func() { DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { conf.Server.LyricsPriority = priority - list, err := lyrics.GetLyrics(ctx, &mf) + svc := lyrics.NewLyrics(nil) + list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(expected)) }, @@ -107,7 +109,8 @@ var _ = Describe("sources", func() { It("should fallback to embedded if an error happens when parsing file", func() { conf.Server.LyricsPriority = ".mp3,embedded" - list, err := lyrics.GetLyrics(ctx, &mf) + svc := lyrics.NewLyrics(nil) + list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) }) @@ -115,10 +118,109 @@ var _ = Describe("sources", func() { It("should return nothing if error happens when trying to parse file", func() { conf.Server.LyricsPriority = ".mp3" - list, err := lyrics.GetLyrics(ctx, &mf) + svc := lyrics.NewLyrics(nil) + list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(BeEmpty()) }) }) }) + + Context("plugin sources", func() { + var mockLoader *mockPluginLoader + + BeforeEach(func() { + mockLoader = &mockPluginLoader{} + }) + + It("should return lyrics from a plugin", func() { + conf.Server.LyricsPriority = "test-lyrics-plugin" + mockLoader.lyrics = unsyncedLyrics + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(unsyncedLyrics)) + }) + + It("should try plugin after embedded returns nothing", func() { + conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" + mf.Lyrics = "" // No embedded lyrics + mockLoader.lyrics = unsyncedLyrics + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(unsyncedLyrics)) + }) + + It("should skip plugin if embedded has lyrics", func() { + conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" + mockLoader.lyrics = unsyncedLyrics + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(embeddedLyrics)) // embedded wins + }) + + It("should skip unknown plugin names gracefully", func() { + conf.Server.LyricsPriority = "nonexistent-plugin,embedded" + mockLoader.notFound = true + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded + }) + + It("should preserve plugin name case from config", func() { + conf.Server.LyricsPriority = "MyLyricsPlugin" + mockLoader.pluginName = "MyLyricsPlugin" + mockLoader.lyrics = unsyncedLyrics + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(unsyncedLyrics)) + }) + + It("should handle plugin error gracefully", func() { + conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" + mockLoader.err = fmt.Errorf("plugin error") + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded + }) + }) }) + +type mockPluginLoader struct { + lyrics model.LyricList + err error + notFound bool + pluginName string // expected plugin name (exact match, like real manager) +} + +func (m *mockPluginLoader) PluginNames(_ string) []string { + if m.notFound { + return nil + } + return []string{"test-lyrics-plugin"} +} + +func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + if m.notFound { + return nil, false + } + // If pluginName is set, require exact match (like the real plugin manager) + if m.pluginName != "" && name != m.pluginName { + return nil, false + } + return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true +} + +type mockLyricsProvider struct { + lyrics model.LyricList + err error +} + +func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) { + return m.lyrics, m.err +} diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 857dc2eef..82a10ca41 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( return model.LyricList{*lyrics}, nil } + +// fromPlugin attempts to load lyrics from a plugin with the given name. +func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) { + if l.pluginLoader == nil { + log.Debug(ctx, "Invalid lyric source", "source", pluginName) + return nil, nil + } + + provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName) + if !ok { + log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName) + return nil, nil + } + + lyricsList, err := provider.GetLyrics(ctx, mf) + if err != nil { + return nil, err + } + + if len(lyricsList) > 0 { + log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList)) + } + return lyricsList, nil +} diff --git a/core/wire_providers.go b/core/wire_providers.go index 503feb789..f9b472015 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -28,4 +29,5 @@ var Set = wire.NewSet( scrobbler.GetPlayTracker, playback.GetInstance, metrics.GetInstance, + lyrics.NewLyrics, ) diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go new file mode 100644 index 000000000..6f6d19177 --- /dev/null +++ b/plugins/capabilities/lyrics.go @@ -0,0 +1,26 @@ +package capabilities + +// Lyrics provides lyrics for a given track from external sources. +// +//nd:capability name=lyrics required=true +type Lyrics interface { + //nd:export name=nd_lyrics_get_lyrics + GetLyrics(GetLyricsRequest) (GetLyricsResponse, error) +} + +// GetLyricsRequest contains the track information for lyrics lookup. +type GetLyricsRequest struct { + Track TrackInfo `json:"track"` +} + +// GetLyricsResponse contains the lyrics returned by the plugin. +type GetLyricsResponse struct { + Lyrics []LyricsText `json:"lyrics"` +} + +// LyricsText represents a single set of lyrics in raw text format. +// Text can be plain text or LRC format — Navidrome will parse it. +type LyricsText struct { + Lang string `json:"lang,omitempty"` + Text string `json:"text"` +} diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml new file mode 100644 index 000000000..e4f88476c --- /dev/null +++ b/plugins/capabilities/lyrics.yaml @@ -0,0 +1,115 @@ +version: v1-draft +exports: + nd_lyrics_get_lyrics: + input: + $ref: '#/components/schemas/GetLyricsRequest' + contentType: application/json + output: + $ref: '#/components/schemas/GetLyricsResponse' + contentType: application/json +components: + schemas: + ArtistRef: + description: ArtistRef is a reference to an artist with name and optional MBID. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name + GetLyricsRequest: + description: GetLyricsRequest contains the track information for lyrics lookup. + properties: + track: + $ref: '#/components/schemas/TrackInfo' + required: + - track + GetLyricsResponse: + description: GetLyricsResponse contains the lyrics returned by the plugin. + properties: + lyrics: + type: array + items: + $ref: '#/components/schemas/LyricsText' + required: + - lyrics + LyricsText: + description: |- + LyricsText represents a single set of lyrics in raw text format. + Text can be plain text or LRC format — Navidrome will parse it. + properties: + lang: + type: string + text: + type: string + required: + - text + TrackInfo: + description: TrackInfo contains track metadata. + properties: + id: + type: string + description: ID is the internal Navidrome track ID. + title: + type: string + description: Title is the track title. + album: + type: string + description: Album is the album name. + artist: + type: string + description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). + albumArtist: + type: string + description: AlbumArtist is the formatted album artist name for display. + artists: + type: array + description: Artists is the list of track artists. + items: + $ref: '#/components/schemas/ArtistRef' + albumArtists: + type: array + description: AlbumArtists is the list of album artists. + items: + $ref: '#/components/schemas/ArtistRef' + duration: + type: number + format: float + description: Duration is the track duration in seconds. + trackNumber: + type: integer + format: int32 + description: TrackNumber is the track number on the album. + discNumber: + type: integer + format: int32 + description: DiscNumber is the disc number. + mbzRecordingId: + type: string + description: MBZRecordingID is the MusicBrainz recording ID. + mbzAlbumId: + type: string + description: MBZAlbumID is the MusicBrainz album/release ID. + mbzReleaseGroupId: + type: string + description: MBZReleaseGroupID is the MusicBrainz release group ID. + mbzReleaseTrackId: + type: string + description: MBZReleaseTrackID is the MusicBrainz release track ID. + required: + - id + - title + - album + - artist + - albumArtist + - artists + - albumArtists + - duration + - trackNumber + - discNumber diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 300652cb3..8091efe50 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -38,7 +38,7 @@ type ArtistRef struct { MBID string `json:"mbid,omitempty"` } -// TrackInfo contains track metadata for scrobbling. +// TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. ID string `json:"id"` diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index d8f47c951..5de351a5f 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -77,7 +77,7 @@ components: - track - timestamp TrackInfo: - description: TrackInfo contains track metadata for scrobbling. + description: TrackInfo contains track metadata. properties: id: type: string diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go new file mode 100644 index 000000000..aa9930664 --- /dev/null +++ b/plugins/lyrics_adapter.go @@ -0,0 +1,59 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const CapabilityLyrics Capability = "Lyrics" + +const ( + FuncLyricsGetLyrics = "nd_lyrics_get_lyrics" +) + +func init() { + registerCapability( + CapabilityLyrics, + FuncLyricsGetLyrics, + ) +} + +// LyricsPlugin adapts a WASM plugin with the Lyrics capability. +type LyricsPlugin struct { + name string + plugin *plugin +} + +// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses +// using model.ToLyrics. +func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { + req := capabilities.GetLyricsRequest{ + Track: mediaFileToTrackInfo(mf), + } + resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse]( + ctx, l.plugin, FuncLyricsGetLyrics, req, + ) + if err != nil { + return nil, err + } + + var result model.LyricList + for _, lt := range resp.Lyrics { + lang := lt.Lang + if lang == "" { + lang = "xxx" + } + parsed, err := model.ToLyrics(lang, lt.Text) + if err != nil { + log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) + continue + } + if parsed != nil && !parsed.IsEmpty() { + result = append(result, *parsed) + } + } + return result, nil +} diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go new file mode 100644 index 000000000..a1a6c1809 --- /dev/null +++ b/plugins/lyrics_adapter_test.go @@ -0,0 +1,99 @@ +//go:build !windows + +package plugins + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LyricsPlugin", Ordered, func() { + var ( + lyricsManager *Manager + provider *LyricsPlugin + ) + + BeforeAll(func() { + lyricsManager, _ = createTestManagerWithPlugins(nil, + "test-lyrics"+PackageExtension, + "test-metadata-agent"+PackageExtension, + ) + + p, ok := lyricsManager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + provider = p.(*LyricsPlugin) + }) + + Describe("LoadLyricsProvider", func() { + It("returns a lyrics provider for a plugin with Lyrics capability", func() { + Expect(provider).ToNot(BeNil()) + }) + + It("returns false for a plugin without Lyrics capability", func() { + _, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent") + Expect(ok).To(BeFalse()) + }) + + It("returns false for non-existent plugin", func() { + _, ok := lyricsManager.LoadLyricsProvider("non-existent") + Expect(ok).To(BeFalse()) + }) + }) + + Describe("GetLyrics", func() { + It("successfully returns lyrics from the plugin", func() { + track := &model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Artist: "Test Artist", + } + + result, err := provider.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Line).ToNot(BeEmpty()) + Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song")) + }) + + It("defaults language to 'xxx' when plugin does not provide one", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"no_lang": "true"}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Lang).To(Equal("xxx")) + }) + + It("returns error when plugin returns error", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"error": "service unavailable"}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song"} + _, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("PluginNames", func() { + It("returns plugin names with Lyrics capability", func() { + names := lyricsManager.PluginNames("Lyrics") + Expect(names).To(ContainElement("test-lyrics")) + }) + + It("does not return metadata agent plugins for Lyrics capability", func() { + names := lyricsManager.PluginNames("Lyrics") + Expect(names).ToNot(ContainElement("test-metadata-agent")) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go index bf6bab6e8..0c7c91ed8 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -16,6 +16,7 @@ import ( extism "github.com/extism/go-sdk" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -282,6 +283,22 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { }, true } +// LoadLyricsProvider loads and returns a lyrics provider plugin by name. +func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + m.mu.RLock() + plugin, ok := m.plugins[name] + m.mu.RUnlock() + + if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) { + return nil, false + } + + return &LyricsPlugin{ + name: plugin.name, + plugin: plugin, + }, true +} + // PluginInfo contains basic information about a plugin for metrics/insights. type PluginInfo struct { Name string diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go new file mode 100644 index 000000000..4f5aa6302 --- /dev/null +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -0,0 +1,118 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the Lyrics capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package lyrics + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// ArtistRef is a reference to an artist with name and optional MBID. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + +// GetLyricsRequest contains the track information for lyrics lookup. +type GetLyricsRequest struct { + Track TrackInfo `json:"track"` +} + +// GetLyricsResponse contains the lyrics returned by the plugin. +type GetLyricsResponse struct { + Lyrics []LyricsText `json:"lyrics"` +} + +// LyricsText represents a single set of lyrics in raw text format. +// Text can be plain text or LRC format — Navidrome will parse it. +type LyricsText struct { + Lang string `json:"lang,omitempty"` + Text string `json:"text"` +} + +// TrackInfo contains track metadata. +type TrackInfo struct { + // ID is the internal Navidrome track ID. + ID string `json:"id"` + // Title is the track title. + Title string `json:"title"` + // Album is the album name. + Album string `json:"album"` + // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). + Artist string `json:"artist"` + // AlbumArtist is the formatted album artist name for display. + AlbumArtist string `json:"albumArtist"` + // Artists is the list of track artists. + Artists []ArtistRef `json:"artists"` + // AlbumArtists is the list of album artists. + AlbumArtists []ArtistRef `json:"albumArtists"` + // Duration is the track duration in seconds. + Duration float32 `json:"duration"` + // TrackNumber is the track number on the album. + TrackNumber int32 `json:"trackNumber"` + // DiscNumber is the disc number. + DiscNumber int32 `json:"discNumber"` + // MBZRecordingID is the MusicBrainz recording ID. + MBZRecordingID string `json:"mbzRecordingId,omitempty"` + // MBZAlbumID is the MusicBrainz album/release ID. + MBZAlbumID string `json:"mbzAlbumId,omitempty"` + // MBZReleaseGroupID is the MusicBrainz release group ID. + MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + // MBZReleaseTrackID is the MusicBrainz release track ID. + MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` +} + +// Lyrics requires all methods to be implemented. +// Lyrics provides lyrics for a given track from external sources. +type Lyrics interface { + // GetLyrics + GetLyrics(GetLyricsRequest) (GetLyricsResponse, error) +} // Internal implementation holders +var ( + lyricsImpl func(GetLyricsRequest) (GetLyricsResponse, error) +) + +// Register registers a lyrics implementation. +// All methods are required. +func Register(impl Lyrics) { + lyricsImpl = impl.GetLyrics +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_lyrics_get_lyrics +func _NdLyricsGetLyrics() int32 { + if lyricsImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetLyricsRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := lyricsImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go new file mode 100644 index 000000000..1fdf184e5 --- /dev/null +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -0,0 +1,82 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package lyrics + +// ArtistRef is a reference to an artist with name and optional MBID. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + +// GetLyricsRequest contains the track information for lyrics lookup. +type GetLyricsRequest struct { + Track TrackInfo `json:"track"` +} + +// GetLyricsResponse contains the lyrics returned by the plugin. +type GetLyricsResponse struct { + Lyrics []LyricsText `json:"lyrics"` +} + +// LyricsText represents a single set of lyrics in raw text format. +// Text can be plain text or LRC format — Navidrome will parse it. +type LyricsText struct { + Lang string `json:"lang,omitempty"` + Text string `json:"text"` +} + +// TrackInfo contains track metadata. +type TrackInfo struct { + // ID is the internal Navidrome track ID. + ID string `json:"id"` + // Title is the track title. + Title string `json:"title"` + // Album is the album name. + Album string `json:"album"` + // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). + Artist string `json:"artist"` + // AlbumArtist is the formatted album artist name for display. + AlbumArtist string `json:"albumArtist"` + // Artists is the list of track artists. + Artists []ArtistRef `json:"artists"` + // AlbumArtists is the list of album artists. + AlbumArtists []ArtistRef `json:"albumArtists"` + // Duration is the track duration in seconds. + Duration float32 `json:"duration"` + // TrackNumber is the track number on the album. + TrackNumber int32 `json:"trackNumber"` + // DiscNumber is the disc number. + DiscNumber int32 `json:"discNumber"` + // MBZRecordingID is the MusicBrainz recording ID. + MBZRecordingID string `json:"mbzRecordingId,omitempty"` + // MBZAlbumID is the MusicBrainz album/release ID. + MBZAlbumID string `json:"mbzAlbumId,omitempty"` + // MBZReleaseGroupID is the MusicBrainz release group ID. + MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + // MBZReleaseTrackID is the MusicBrainz release track ID. + MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` +} + +// Lyrics requires all methods to be implemented. +// Lyrics provides lyrics for a given track from external sources. +type Lyrics interface { + // GetLyrics + GetLyrics(GetLyricsRequest) (GetLyricsResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ Lyrics) {} diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 258b1b4c1..c694f59d8 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -62,7 +62,7 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } -// TrackInfo contains track metadata for scrobbling. +// TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. ID string `json:"id"` diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index f2fc584ad..6d4afd818 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -59,7 +59,7 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } -// TrackInfo contains track metadata for scrobbling. +// TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. ID string `json:"id"` diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 06c2c5c0d..85375b525 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -6,6 +6,7 @@ //! for implementing Navidrome plugin capabilities in Rust. pub mod lifecycle; +pub mod lyrics; pub mod metadata; pub mod scheduler; pub mod scrobbler; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs new file mode 100644 index 000000000..16882abae --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -0,0 +1,148 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the Lyrics capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// ArtistRef is a reference to an artist with name and optional MBID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// ID is the internal Navidrome artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// GetLyricsRequest contains the track information for lyrics lookup. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetLyricsRequest { + #[serde(default)] + pub track: TrackInfo, +} +/// GetLyricsResponse contains the lyrics returned by the plugin. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetLyricsResponse { + #[serde(default)] + pub lyrics: Vec, +} +/// LyricsText represents a single set of lyrics in raw text format. +/// Text can be plain text or LRC format — Navidrome will parse it. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LyricsText { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub lang: String, + #[serde(default)] + pub text: String, +} +/// TrackInfo contains track metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackInfo { + /// ID is the internal Navidrome track ID. + #[serde(default)] + pub id: String, + /// Title is the track title. + #[serde(default)] + pub title: String, + /// Album is the album name. + #[serde(default)] + pub album: String, + /// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). + #[serde(default)] + pub artist: String, + /// AlbumArtist is the formatted album artist name for display. + #[serde(default)] + pub album_artist: String, + /// Artists is the list of track artists. + #[serde(default)] + pub artists: Vec, + /// AlbumArtists is the list of album artists. + #[serde(default)] + pub album_artists: Vec, + /// Duration is the track duration in seconds. + #[serde(default)] + pub duration: f32, + /// TrackNumber is the track number on the album. + #[serde(default)] + pub track_number: i32, + /// DiscNumber is the disc number. + #[serde(default)] + pub disc_number: i32, + /// MBZRecordingID is the MusicBrainz recording ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_recording_id: String, + /// MBZAlbumID is the MusicBrainz album/release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_id: String, + /// MBZReleaseGroupID is the MusicBrainz release group ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_group_id: String, + /// MBZReleaseTrackID is the MusicBrainz release track ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_track_id: String, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// Lyrics requires all methods to be implemented. +/// Lyrics provides lyrics for a given track from external sources. +pub trait Lyrics { + /// GetLyrics + fn get_lyrics(&self, req: GetLyricsRequest) -> Result; +} + +/// Register all exports for the Lyrics capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_lyrics { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_lyrics_get_lyrics( + req: extism_pdk::Json<$crate::lyrics::GetLyricsRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 9dbedd040..2572712d1 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -76,7 +76,7 @@ pub struct ScrobbleRequest { #[serde(default)] pub timestamp: i64, } -/// TrackInfo contains track metadata for scrobbling. +/// TrackInfo contains track metadata. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrackInfo { diff --git a/plugins/testdata/test-lyrics/go.mod b/plugins/testdata/test-lyrics/go.mod new file mode 100644 index 000000000..fbbb23fc0 --- /dev/null +++ b/plugins/testdata/test-lyrics/go.mod @@ -0,0 +1,16 @@ +module test-lyrics + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-lyrics/go.sum b/plugins/testdata/test-lyrics/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-lyrics/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go new file mode 100644 index 000000000..0e485ceba --- /dev/null +++ b/plugins/testdata/test-lyrics/main.go @@ -0,0 +1,42 @@ +// Test lyrics plugin for Navidrome plugin system integration tests. +package main + +import ( + "fmt" + + "github.com/navidrome/navidrome/plugins/pdk/go/lyrics" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +func init() { + lyrics.Register(&testLyrics{}) +} + +type testLyrics struct{} + +func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { + // Check for configured error + errMsg, hasErr := pdk.GetConfig("error") + if hasErr && errMsg != "" { + return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) + } + + // Check if we should omit language (to test default language handling) + noLang, hasNoLang := pdk.GetConfig("no_lang") + lang := "eng" + if hasNoLang && noLang == "true" { + lang = "" + } + + // Return test lyrics based on track info + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{ + { + Lang: lang, + Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist, + }, + }, + }, nil +} + +func main() {} diff --git a/plugins/testdata/test-lyrics/manifest.json b/plugins/testdata/test-lyrics/manifest.json new file mode 100644 index 000000000..a61299e92 --- /dev/null +++ b/plugins/testdata/test-lyrics/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Test Lyrics", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test lyrics plugin for integration testing" +} diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 0e0ca606a..484a91cc7 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -396,6 +397,7 @@ func setupTestDB() { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), ) } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 63c2614cd..aac2d63da 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index d0d9bb169..8674a2946 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" + lyricssvc "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" @@ -48,12 +49,13 @@ type Router struct { share core.Share playback playback.PlaybackServer metrics metrics.Metrics + lyrics lyricssvc.Lyrics } func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, - metrics metrics.Metrics, + metrics metrics.Metrics, lyrics lyricssvc.Lyrics, ) *Router { r := &Router{ ds: ds, @@ -69,6 +71,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame share: share, playback: playback, metrics: metrics, + lyrics: lyrics, } r.Handler = r.routes() return r diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 6f09f5349..57809fbb6 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index c16779e3a..54fcb5e3a 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" @@ -109,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { return response, nil } - structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0]) + structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0]) if err != nil { return nil, err } @@ -142,7 +141,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } - structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile) + structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile) if err != nil { return nil, err } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 351b4e591..1a638f066 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" @@ -33,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil)) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 58dca682c..c02b262b9 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ) BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index d99e244b0..86c17b39c 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 7f7de381a..d4b7e9702 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) From 11e4aaed1ba3a1cc6420facb888cbbcf4589b445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 4 Mar 2026 22:42:49 -0500 Subject: [PATCH 054/596] feat(server): add percentage-based limits to smart playlists (#5144) * feat(playlists): add percentage-based limits to smart playlists Add a new `limitPercent` JSON field to Criteria that allows smart playlist limits to be expressed as a percentage of matching tracks rather than a fixed number. For example, a playlist matching 450 songs with a 10% limit returns 45 songs, scaling dynamically as the library grows. When `limitPercent` is set, refreshSmartPlaylist runs a COUNT query first to determine the total matching tracks, then resolves the percentage to an absolute LIMIT before executing the main query. The fixed `limit` field takes precedence when both are set. Values are clamped to [0, 100] during JSON unmarshaling. No database migration is needed since rules are stored as a JSON string. * fix(criteria): validate percentage limit range in IsPercentageLimit method Signed-off-by: Deluan * fix(criteria): ensure idempotency of ToSql method for expressions Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/playlists/parse_nsp_test.go | 15 +++ model/criteria/criteria.go | 91 ++++++++++++++---- model/criteria/criteria_test.go | 142 +++++++++++++++++++++++++++++ model/criteria/fields.go | 25 +++-- model/criteria/operators_test.go | 15 +++ persistence/playlist_repository.go | 54 ++++++++--- 6 files changed, 296 insertions(+), 46 deletions(-) diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go index 0a7b2727e..516a5355d 100644 --- a/core/playlists/parse_nsp_test.go +++ b/core/playlists/parse_nsp_test.go @@ -122,6 +122,21 @@ var _ = Describe("parseNSP", func() { Expect(pls.Name).To(Equal("Original")) }) + It("parses limitPercent from NSP", func() { + nsp := `{ + "all": [{"is": {"loved": true}}], + "sort": "playCount", + "order": "desc", + "limitPercent": 25 + }` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Rules).ToNot(BeNil()) + Expect(pls.Rules.LimitPercent).To(Equal(25)) + Expect(pls.Rules.Limit).To(Equal(0)) + }) + It("parses criteria with multiple rules", func() { nsp := `{ "all": [ diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index bc3fe801c..278acf34c 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -15,10 +15,38 @@ type Expression = squirrel.Sqlizer type Criteria struct { Expression - Sort string - Order string - Limit int - Offset int + Sort string + Order string + Limit int + LimitPercent int + Offset int +} + +// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is +// set it takes precedence. Otherwise, if LimitPercent is set, the limit is +// computed as a percentage of totalCount (minimum 1 when totalCount > 0). +// Returns 0 when no limit applies. +func (c Criteria) EffectiveLimit(totalCount int64) int { + if c.Limit > 0 { + return c.Limit + } + if c.LimitPercent > 0 && c.LimitPercent <= 100 { + if totalCount <= 0 { + return 0 + } + result := int(totalCount) * c.LimitPercent / 100 + if result < 1 { + return 1 + } + return result + } + return 0 +} + +// IsPercentageLimit returns true when the criteria uses a valid percentage-based +// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it). +func (c Criteria) IsPercentageLimit() bool { + return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100 } func (c Criteria) OrderBy() string { @@ -95,6 +123,16 @@ func (c Criteria) ToSql() (sql string, args []any, err error) { return c.Expression.ToSql() } +// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression, +// excluding any JOINs required solely for sorting. This is useful for COUNT +// queries where sort order is irrelevant. +func (c Criteria) ExpressionJoins() JoinType { + if c.Expression == nil { + return JoinNone + } + return extractJoinTypes(c.Expression) +} + // RequiredJoins inspects the expression tree and Sort field to determine which // additional JOINs are needed when evaluating this criteria. func (c Criteria) RequiredJoins() JoinType { @@ -128,17 +166,19 @@ func (c Criteria) ChildPlaylistIds() []string { func (c Criteria) MarshalJSON() ([]byte, error) { aux := struct { - All []Expression `json:"all,omitempty"` - Any []Expression `json:"any,omitempty"` - Sort string `json:"sort,omitempty"` - Order string `json:"order,omitempty"` - Limit int `json:"limit,omitempty"` - Offset int `json:"offset,omitempty"` + All []Expression `json:"all,omitempty"` + Any []Expression `json:"any,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Limit int `json:"limit,omitempty"` + LimitPercent int `json:"limitPercent,omitempty"` + Offset int `json:"offset,omitempty"` }{ - Sort: c.Sort, - Order: c.Order, - Limit: c.Limit, - Offset: c.Offset, + Sort: c.Sort, + Order: c.Order, + Limit: c.Limit, + LimitPercent: c.LimitPercent, + Offset: c.Offset, } switch rules := c.Expression.(type) { case Any: @@ -153,12 +193,13 @@ func (c Criteria) MarshalJSON() ([]byte, error) { func (c *Criteria) UnmarshalJSON(data []byte) error { var aux struct { - All unmarshalConjunctionType `json:"all"` - Any unmarshalConjunctionType `json:"any"` - Sort string `json:"sort"` - Order string `json:"order"` - Limit int `json:"limit"` - Offset int `json:"offset"` + All unmarshalConjunctionType `json:"all"` + Any unmarshalConjunctionType `json:"any"` + Sort string `json:"sort"` + Order string `json:"order"` + Limit int `json:"limit"` + LimitPercent int `json:"limitPercent"` + Offset int `json:"offset"` } if err := json.Unmarshal(data, &aux); err != nil { return err @@ -174,5 +215,15 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { c.Order = aux.Order c.Limit = aux.Limit c.Offset = aux.Offset + + // Clamp LimitPercent to [0, 100] + if aux.LimitPercent < 0 { + log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent) + aux.LimitPercent = 0 + } else if aux.LimitPercent > 100 { + log.Warn("limitPercent value out of range, clamping to 100", "value", aux.LimitPercent) + aux.LimitPercent = 100 + } + c.LimitPercent = aux.LimitPercent return nil } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 9a4da360c..a76b3fc1f 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -181,6 +181,28 @@ var _ = Describe("Criteria", func() { }) }) + Describe("ExpressionJoins", func() { + It("excludes sort-only joins", func() { + c := Criteria{ + Expression: All{ + Contains{"title": "love"}, + }, + Sort: "albumRating", + } + gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone)) + gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) + }) + + It("includes expression-based joins", func() { + c := Criteria{ + Expression: All{ + Gt{"albumRating": 3}, + }, + } + gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) + }) + }) + Describe("RequiredJoins", func() { It("returns JoinNone when no annotation fields are used", func() { c := Criteria{ @@ -263,6 +285,126 @@ var _ = Describe("Criteria", func() { }) }) + Describe("LimitPercent", func() { + Describe("JSON round-trip", func() { + It("marshals and unmarshals limitPercent", func() { + goObj := Criteria{ + Expression: All{Contains{"title": "love"}}, + Sort: "title", + Order: "asc", + LimitPercent: 10, + } + j, err := json.Marshal(goObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`)) + gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`)) + + var newObj Criteria + err = json.Unmarshal(j, &newObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10)) + gomega.Expect(newObj.Limit).To(gomega.Equal(0)) + }) + + It("does not include limitPercent when zero", func() { + goObj := Criteria{ + Expression: All{Contains{"title": "love"}}, + Limit: 50, + } + j, err := json.Marshal(goObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`)) + gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`)) + }) + + It("backward compatible: JSON with only limit still works", func() { + jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}` + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(c.Limit).To(gomega.Equal(20)) + gomega.Expect(c.LimitPercent).To(gomega.Equal(0)) + }) + }) + + Describe("UnmarshalJSON clamping", func() { + It("clamps values above 100 to 100", func() { + jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}` + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(c.LimitPercent).To(gomega.Equal(100)) + }) + + It("clamps negative values to 0", func() { + jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}` + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(c.LimitPercent).To(gomega.Equal(0)) + }) + }) + + Describe("EffectiveLimit", func() { + It("returns fixed limit when Limit is set", func() { + c := Criteria{Limit: 50, LimitPercent: 10} + gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50)) + }) + + It("returns percentage-based limit", func() { + c := Criteria{LimitPercent: 10} + gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45)) + }) + + It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() { + c := Criteria{LimitPercent: 1} + gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1)) + }) + + It("returns 0 when totalCount is 0", func() { + c := Criteria{LimitPercent: 10} + gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0)) + }) + + It("returns 0 when no limit is set", func() { + c := Criteria{} + gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0)) + }) + + It("returns full count for 100%", func() { + c := Criteria{LimitPercent: 100} + gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450)) + }) + + It("returns 1 for 1% of 50 items", func() { + c := Criteria{LimitPercent: 1} + gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1)) + }) + }) + + Describe("IsPercentageLimit", func() { + It("returns true when LimitPercent is set and Limit is 0", func() { + c := Criteria{LimitPercent: 10} + gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue()) + }) + + It("returns false when Limit is set", func() { + c := Criteria{Limit: 50, LimitPercent: 10} + gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) + }) + + It("returns false when neither is set", func() { + c := Criteria{} + gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) + }) + + It("returns false when LimitPercent is out of range", func() { + c := Criteria{LimitPercent: 150} + gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) + }) + }) + }) + Context("with child playlists", func() { var ( topLevelInPlaylistID string diff --git a/model/criteria/fields.go b/model/criteria/fields.go index ed73de9f9..b9d91f087 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -122,27 +122,24 @@ func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel. log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr)) } - // Extract into a generic map + // Extract the field name and value, then build a new map keyed by "value" + // for the inner condition. The original map is left untouched so that + // ToSql can be called multiple times without corruption. var k string - m := make(map[string]any, rv.Len()) + var v any for _, key := range rv.MapKeys() { - // Save the key to build the expression, and use the provided keyName as the key k = key.String() - m["value"] = rv.MapIndex(key).Interface() + v = rv.MapIndex(key).Interface() break // only one key is expected (and supported) } - // Clear the original map - for _, key := range rv.MapKeys() { - rv.SetMapIndex(key, reflect.Value{}) - } + // Create a new map-based expression with "value" as the key, matching the + // column name inside json_tree subqueries. + newMap := reflect.MakeMap(rv.Type()) + newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v)) + newExpr := newMap.Interface().(squirrel.Sqlizer) - // Write the updated map back into the original variable - for key, val := range m { - rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val)) - } - - return exprFunc(k, expr, negate) + return exprFunc(k, newExpr, negate) } // mapTagExpr maps a normal field expression to a tag expression. diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index f0681af6b..5f756f97d 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -178,6 +178,21 @@ var _ = Describe("Operators", func() { }) }) + DescribeTable("ToSql idempotency", + func(expr Expression) { + sql1, args1, err1 := expr.ToSql() + sql2, args2, err2 := expr.ToSql() + + gomega.Expect(err1).ToNot(gomega.HaveOccurred()) + gomega.Expect(err2).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql2).To(gomega.Equal(sql1)) + gomega.Expect(args2).To(gomega.Equal(args1)) + }, + Entry("tag expression", Is{"genre": "Rock"}), + Entry("role expression", Contains{"artist": "Beatles"}), + Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}), + ) + DescribeTable("JSON Marshaling", func(op Expression, jsonString string) { obj := And{op} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 11b9cd8b4..8d1bbe0f8 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -248,22 +248,36 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Conditionally join album/artist annotation tables only when referenced by criteria or sort requiredJoins := rules.RequiredJoins() - if requiredJoins.Has(criteria.JoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", usr.ID) - } - if requiredJoins.Has(criteria.JoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", usr.ID) - } + sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) // Only include media files from libraries the user has access to sq = r.applyLibraryFilter(sq, "media_file") + // Resolve percentage-based limit to an absolute number before applying criteria + if rules.IsPercentageLimit() { + // Use only expression-based joins for the COUNT query (sort joins are unnecessary) + exprJoins := rules.ExpressionJoins() + countSq := Select("count(*) as count").From("media_file"). + LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", usr.ID) + countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) + countSq = r.applyLibraryFilter(countSq, "media_file") + countSq = countSq.Where(rules) + + var res struct{ Count int64 } + err = r.queryOne(countSq, &res) + if err != nil { + log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + return false + } + resolvedLimit := rules.EffectiveLimit(res.Count) + log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) + rules.Limit = resolvedLimit + rules.LimitPercent = 0 + } + // Apply the criteria rules sq = r.addCriteria(sq, rules) insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) @@ -296,6 +310,22 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return true } +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder { + if joins.Has(criteria.JoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.Has(criteria.JoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } + return sq +} + func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { sql = sql.Where(c) if c.Limit > 0 { From 627266ec82070bba1ce54ab19330b26a0d59bb7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:01:53 -0500 Subject: [PATCH 055/596] chore(deps): bump immutable from 4.3.7 to 4.3.8 in /ui (#5145) Bumps [immutable](https://github.com/immutable-js/immutable-js) from 4.3.7 to 4.3.8. - [Release notes](https://github.com/immutable-js/immutable-js/releases) - [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/immutable-js/immutable-js/compare/v4.3.7...v4.3.8) --- updated-dependencies: - dependency-name: immutable dependency-version: 4.3.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index b4efed744..bd8a2d2ed 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -7183,9 +7183,9 @@ } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "license": "MIT", "optional": true }, From 12f28b9d97b563c238a059cdd8e09259be1283e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:06:12 -0500 Subject: [PATCH 056/596] chore(deps): bump dompurify in /ui (#5147) Bumps [dompurify](https://github.com/cure53/DOMPurify) to 3.3.2 and updates ancestor dependency . These dependencies need to be updated together. Updates `dompurify` from 3.3.1 to 3.3.2 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.3.1...3.3.2) Updates `dompurify` from 2.5.8 to 2.5.9 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.3.1...3.3.2) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.3.2 dependency-type: direct:production - dependency-name: dompurify dependency-version: 2.5.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 17 ++++++++++------- ui/package.json | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index bd8a2d2ed..04d6fe07c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,7 +18,7 @@ "clsx": "^2.1.1", "connected-react-router": "^6.9.3", "deepmerge": "^4.3.1", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "history": "^4.10.1", "inflection": "^3.0.2", "jwt-decode": "^4.0.0", @@ -5503,10 +5503,13 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -9682,9 +9685,9 @@ "license": "MIT" }, "node_modules/ra-ui-materialui/node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/ra-ui-materialui/node_modules/inflection": { diff --git a/ui/package.json b/ui/package.json index 54062c1ac..5b4deb773 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,7 +27,7 @@ "clsx": "^2.1.1", "connected-react-router": "^6.9.3", "deepmerge": "^4.3.1", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "history": "^4.10.1", "inflection": "^3.0.2", "jwt-decode": "^4.0.0", From 1ce561cc8ead1dba3dd68c6e0eb03a2ca789b91c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 5 Mar 2026 19:53:55 -0500 Subject: [PATCH 057/596] refactor(server): remove legacy embedded coverart logic Signed-off-by: Deluan --- conf/configuration.go | 1 - core/artwork/sources.go | 54 ----------------------------------------- go.mod | 10 ++------ go.sum | 2 -- 4 files changed, 2 insertions(+), 65 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index b6f65183a..c46879d42 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -132,7 +132,6 @@ type configOptions struct { DevExternalScanner bool DevScannerThreads uint DevSelectiveWatcher bool - DevLegacyEmbedImage bool DevInsightsInitialDelay time.Duration DevEnablePlayerInsights bool DevEnablePluginsInsights bool diff --git a/core/artwork/sources.go b/core/artwork/sources.go index b1b9b5454..0628461e0 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -15,8 +15,6 @@ import ( "strings" "time" - "github.com/dhowden/tag" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" @@ -86,58 +84,6 @@ var picTypeRegexes = []*regexp.Regexp{ } func fromTag(ctx context.Context, path string) sourceFunc { - if conf.Server.DevLegacyEmbedImage { - return fromTagLegacy(ctx, path) - } - return fromTagGoTaglib(ctx, path) -} - -func fromTagLegacy(ctx context.Context, path string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - defer f.Close() - - m, err := tag.ReadFrom(f) - if err != nil { - return nil, "", err - } - - types := m.PictureTypes() - if len(types) == 0 { - return nil, "", fmt.Errorf("no embedded image found in %s", path) - } - - var picture *tag.Picture - for _, regex := range picTypeRegexes { - for _, t := range types { - if regex.MatchString(t) { - log.Trace(ctx, "Found embedded image", "type", t, "path", path) - picture = m.Pictures(t) - break - } - } - if picture != nil { - break - } - } - if picture == nil { - log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path) - picture = m.Picture() - } - if picture == nil { - return nil, "", fmt.Errorf("could not load embedded image from %s", path) - } - return io.NopCloser(bytes.NewReader(picture.Data)), path, nil - } -} - -func fromTagGoTaglib(ctx context.Context, path string) sourceFunc { return func() (io.ReadCloser, string, error) { if path == "" { return nil, "", nil diff --git a/go.mod b/go.mod index 7f7e90a7f..abcadab73 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,8 @@ module github.com/navidrome/navidrome go 1.25.0 -replace ( - // Fork to fix https://github.com/navidrome/navidrome/issues/3254 - 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-20260225021432-1699562530f1 -) +// Fork to implement raw tags support +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 require ( github.com/Masterminds/squirrel v1.5.4 @@ -19,7 +14,6 @@ require ( github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 - github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 github.com/disintegration/imaging v1.6.2 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 diff --git a/go.sum b/go.sum index 224b33e90..e46f1c2e9 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,6 @@ github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcH 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= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= -github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA= -github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= From d2db41691e20df4b49e74a676e4d8273f52cdf50 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 5 Mar 2026 20:47:35 -0500 Subject: [PATCH 058/596] fix(ui): conditionally render sync toggle based on screen size Signed-off-by: Deluan --- ui/src/playlist/PlaylistList.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 4ec2d5ca1..67c456f27 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -181,7 +181,9 @@ const PlaylistList = (props) => { ), comment: , - sync: , + sync: !isXsmall && ( + + ), }), [isDesktop, isXsmall], ) From f102036dc6a4df88ea9e4ef53437f65f2bb7d01d Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 5 Mar 2026 20:56:16 -0500 Subject: [PATCH 059/596] fix(server): clear server-managed fields in savePlaylist to prevent injection via REST API Signed-off-by: Deluan --- core/playlists/rest_adapter.go | 8 ++++++- core/playlists/rest_adapter_test.go | 34 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3865d97e9..c9cd4c136 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -58,10 +58,16 @@ func (s *playlists) TracksRepository(ctx context.Context, playlistId string, ref } // savePlaylist creates a new playlist, assigning the owner from context. +// Only Name, Comment, Public, and Rules are user-settable via the REST API. func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) { usr, _ := request.UserFrom(ctx) pls.OwnerID = usr.ID - pls.ID = "" // Force new creation + pls.ID = "" // Force new creation + pls.Path = "" // Server-managed (M3U file path) + pls.Sync = false // Server-managed (M3U sync flag) + pls.UploadedImage = "" // Managed by image upload endpoint + pls.ExternalImageURL = "" // Managed by M3U import / plugins only + pls.EvaluatedAt = nil // Server-managed err := s.ds.Playlist(ctx).Put(pls) if err != nil { return "", err diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index b65095957..29db2fc33 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -2,10 +2,12 @@ package playlists_test import ( "context" + "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -56,6 +58,38 @@ var _ = Describe("REST Adapter", func() { Expect(err).ToNot(HaveOccurred()) Expect(pls.ID).ToNot(Equal("should-be-cleared")) }) + + It("clears server-managed fields to prevent injection via REST API", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + now := time.Now() + pls := &model.Playlist{ + Name: "Legit Playlist", + Comment: "A comment", + Public: true, + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}, + Path: "/some/path/playlist.m3u", + Sync: true, + UploadedImage: "injected-image-path", + ExternalImageURL: "http://evil.example.com/ssrf", + EvaluatedAt: &now, + } + _, err := repo.Save(pls) + Expect(err).ToNot(HaveOccurred()) + + saved := mockPlsRepo.Last + // User-settable fields are preserved + Expect(saved.Name).To(Equal("Legit Playlist")) + Expect(saved.Comment).To(Equal("A comment")) + Expect(saved.Public).To(BeTrue()) + Expect(saved.Rules).ToNot(BeNil()) + // Server-managed fields are cleared + Expect(saved.Path).To(BeEmpty()) + Expect(saved.Sync).To(BeFalse()) + Expect(saved.UploadedImage).To(BeEmpty()) + Expect(saved.ExternalImageURL).To(BeEmpty()) + Expect(saved.EvaluatedAt).To(BeNil()) + }) }) Describe("Update", func() { From 3cd5d16b0ae615d155d9a1320ee44a977e8bbde3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 6 Mar 2026 19:23:47 -0500 Subject: [PATCH 060/596] chore: upgrade golangci-lint to 2.11 and fix lint issues Signed-off-by: Deluan --- .golangci.yml | 5 +++++ Makefile | 2 +- plugins/host_taskqueue.go | 2 +- scanner/watcher.go | 2 +- server/nativeapi/playlists.go | 2 +- server/subsonic/middlewares.go | 2 ++ utils/pl/pipelines.go | 4 ++-- 7 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 1937c2f77..b6c632dee 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -40,6 +40,11 @@ linters: enable: - nilness exclusions: + rules: + - linters: + - gosec + path: _test\.go + text: "G703" generated: lax presets: - comments diff --git a/Makefile b/Makefile index f7b7b1b05..559a34c3c 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ DOCKER_TAG ?= deluan/navidrome:develop # Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib CROSS_TAGLIB_VERSION ?= 2.2.0-1 -GOLANGCI_LINT_VERSION ?= v2.10.0 +GOLANGCI_LINT_VERSION ?= v2.11.1 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index 283bc9635..9f2ed85f6 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -101,7 +101,7 @@ func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int return nil, fmt.Errorf("creating taskqueue schema: %w", err) } - ctx, cancel := context.WithCancel(manager.ctx) + ctx, cancel := context.WithCancel(manager.ctx) //nolint:gosec // cancel is stored in struct and called in Close() s := &taskQueueServiceImpl{ pluginName: pluginName, diff --git a/scanner/watcher.go b/scanner/watcher.go index 101e3793a..62fcc9341 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -158,7 +158,7 @@ func (w *watcher) Watch(ctx context.Context, lib *model.Library) error { } // Start new watcher - watcherCtx, cancel := context.WithCancel(w.mainCtx) + watcherCtx, cancel := context.WithCancel(w.mainCtx) //nolint:gosec // cancel is stored in instance and called on shutdown instance := &libraryWatcherInstance{ library: lib, cancel: cancel, diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 797654a3b..118528f68 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -247,7 +247,7 @@ func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { p := req.Params(r) playlistId, _ := p.String(":id") - if err := r.ParseMultipartForm(maxImageSize); err != nil { + if err := r.ParseMultipartForm(maxImageSize); err != nil { //nolint:gosec // size is limited by maxImageSize parameter log.Error(ctx, "Error parsing multipart form", err) http.Error(w, "file too large or invalid form", http.StatusBadRequest) return diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 7698a3c7b..2d8b1fd94 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -31,9 +31,11 @@ import ( func postFormToQueryParams(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB err := r.ParseForm() if err != nil { sendError(w, r, newError(responses.ErrorGeneric, err.Error())) + return } var parts []string for key, values := range r.Form { diff --git a/utils/pl/pipelines.go b/utils/pl/pipelines.go index 981b86880..df4ee030c 100644 --- a/utils/pl/pipelines.go +++ b/utils/pl/pipelines.go @@ -29,7 +29,7 @@ func Stage[In any, Out any]( limit := int64(maxWorkers) sem1 := semaphore.NewWeighted(limit) - go func() { + go func() { //nolint:gosec // intentional context.Background() below to wait for workers after ctx cancellation defer close(outputChannel) defer close(errorChannel) @@ -58,7 +58,7 @@ func Stage[In any, Out any]( // By using context.Background() here we are assuming the fn will stop when the context // is canceled. This is required so we can wait for the workers to finish and avoid closing // the outputChannel before they are done. - if err := sem1.Acquire(context.Background(), limit); err != nil { + if err := sem1.Acquire(context.Background(), limit); err != nil { //nolint:gosec // intentional: must wait for workers after ctx cancellation log.Error(ctx, "Failed waiting for workers", err) } }() From e1b34129998114220e9236e5877beb075d2dd51a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 7 Mar 2026 12:00:09 -0500 Subject: [PATCH 061/596] fix(scanner): update gotaglib version to reflect actual dependency version Signed-off-by: Deluan --- adapters/gotaglib/gotaglib.go | 13 ++++++++++++- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index f434d1c71..7b827e880 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -44,7 +44,18 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) { } func (e extractor) Version() string { - return "2.2 WASM" + bi, ok := debug.ReadBuildInfo() + if ok { + for _, dep := range bi.Deps { + if dep.Path == "go.senan.xyz/taglib" { + if dep.Replace != nil { + return dep.Replace.Version + } + return dep.Version + } + } + } + return "unknown" } func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { diff --git a/go.mod b/go.mod index abcadab73..538b06f89 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.25.0 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index e46f1c2e9..8f13089a3 100644 --- a/go.sum +++ b/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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 h1:seWJmkPAb+M1ysRNGzTGS7FfdrUe9wQTHhB9p2fxDWg= -github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 h1:RpRSTEsAdLHx3Ci0d3M5wtpjcBZiKzhnGfnNAxGXrAE= +github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7/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= From ae1e0ddb11a3e18660762f6ec7c096732fa77904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 8 Mar 2026 23:57:49 -0400 Subject: [PATCH 062/596] feat(subsonic): implement OpenSubsonic Transcoding extension (#4990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(subsonic): implement transcode decision logic and codec handling for media files Signed-off-by: Deluan * fix(subsonic): update codec limitation structure and decision logic for improved clarity Signed-off-by: Deluan * fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic Signed-off-by: Deluan * refactor(transcoding): simplify container alias handling in matchesContainer function Signed-off-by: Deluan * fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests Signed-off-by: Deluan * feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic Signed-off-by: Deluan * refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability Signed-off-by: Deluan * refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks Signed-off-by: Deluan * refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity Signed-off-by: Deluan * refactor(transcoding): enhance logging for transcode decision process and client info conversion Signed-off-by: Deluan * refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity Signed-off-by: Deluan * refactor(transcoding): enhance transcoding config lookup logic for audio codecs Signed-off-by: Deluan * refactor(transcoding): enhance transcoding options with sample rate support and improve command handling Signed-off-by: Deluan * refactor(transcoding): add bit depth support for audio transcoding and enhance related logic Signed-off-by: Deluan * refactor(transcoding): enhance AAC command handling and support for audio channels in streaming Signed-off-by: Deluan * refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping Signed-off-by: Deluan * refactor(transcoding): update default command handling and add codec support for transcoding Signed-off-by: Deluan * fix: implement noopDecider for transcoding decision handling in tests Signed-off-by: Deluan * fix: address review findings for OpenSubsonic transcoding PR Fix multiple issues identified during code review of the transcoding extension: add missing return after error in shared stream handler preventing nil pointer panic, replace dead r.Body nil check with MaxBytesReader size limit, distinguish not-found from other DB errors, fix bpsToKbps integer truncation with rounding, add "pcm" to isLosslessFormat for consistency with model.IsLossless(), add sampleRate/bitDepth/channels to streaming log, fix outdated test comment, and add tests for conversion functions and GetTranscodeStream parameter passing. * feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters Signed-off-by: Deluan * fix: small issues Updated mock AAC transcoding command to use the new default (ipod with fragmented MP4) matching the migration, ensuring tests exercise the same buildDynamicArgs code path as production. Improved archiver test mock to match on the whole StreamRequest struct instead of decomposing fields, making it resilient to future field additions. Added named constants for JWT claim keys in the transcode token and wrapped ParseTranscodeParams errors with ErrTokenInvalid for consistency. Documented the IsLossless BitDepth fallback heuristic as temporary until Codec column is populated. Signed-off-by: Deluan * fix(transcoding): adapt transcode claims to struct-based auth.Claims Updated transcode token handling to use the struct-based auth.Claims introduced on master, replacing the previous map[string]any approach. Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay, UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in ClaimsFromToken for numeric claims that lose their Go type during JWT string serialization. Also added the missing lyrics parameter to all subsonic.New() calls in test files. * feat(model): add ProbeData field and UpdateProbeData repository method Add probe_data TEXT column to media_file for caching ffprobe results. Add UpdateProbeData to MediaFileRepository interface and implementations. Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints. * feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract codec, profile, bitrate, sample rate, bit depth, and channels. Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth. Normalize "unknown" profile to empty string. All parseProbeOutput tests use real ffprobe JSON from actual files. * feat(transcoding): integrate ffprobe into transcode decisions Add ensureProbed to probe media files on first transcode decision, caching results in probe_data. Build SourceStream from probe data with fallback to tag-based metadata. Refactor decision logic to pass StreamDetails instead of MediaFile, enabling codec profile limitations (e.g., audioProfile) to use probe data. Add normalizeProbeCodec to map ffprobe codec names (dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm). NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated. * feat(transcoding): add DevEnableMediaFileProbe config flag Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe- based media file probing as a safety fallback. When disabled, the decider uses tag-based metadata from the scanner instead. * test(transcode): add ensureProbed unit tests Test probing when ProbeData is empty, skipping when already set, error propagation from ffprobe, and DevEnableMediaFileProbe flag. * refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream Move ffprobe arguments to a probeAudioStreamCmd constant, following the same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to only probe the first audio stream, avoiding unnecessary parsing of video and artwork streams. Derive the ffprobe binary path safely using filepath.Dir/Base instead of replacing within the full path string. * refactor(transcode): decouple transcode token claims from auth.Claims Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt, Channels, SampleRate, BitDepth) from auth.Claims, which is shared with session and share tokens. Transcode tokens are signed parameter-passing tokens, not authentication tokens, so coupling them to auth created misleading dependencies. The transcode package now owns its own JWT claim serialization via Decision.toClaimsMap() and paramsFromToken(), using generic auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight tokens remain compatible. Signed-off-by: Deluan * refactor(transcode): simplify code after review Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite checkIntLimitation as a one-liner delegating to applyIntLimitation. Return probe result from ensureProbed to avoid redundant JSON round-trip. Extract toResponseStreamDetails helper and mediaTypeSong constant in the API layer, and use transcode.ProtocolHTTP constant instead of hardcoded string. Signed-off-by: Deluan * fix(ffmpeg): enhance bit_rate parsing logic for audio streams Signed-off-by: Deluan * fix(transcode): improve code review findings across transcode implementation - Fix parseProbeData to return nil on JSON unmarshal failure instead of a zero-valued struct, preventing silent degradation of source stream details - Use probe-resolved codec for lossless detection in buildSourceStream instead of the potentially stale scanner data - Remove MediaFile.IsLossless() (dead code) and consolidate lossless detection in isLosslessFormat(), using codec name only — bit depth is not reliable since lossy codecs like ADPCM report non-zero values - Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack) - Guard bpsToKbps against negative input values - Fix misleading comment in buildTemplateArgs about conditional injection - Avoid leaking internal error details in Subsonic API responses - Add missing test for ErrNotFound branch in GetTranscodeDecision - Add TODO for hardcoded protocol in toResponseStreamDetails * refactor(transcode): streamline transcoding command lookup and format resolution Signed-off-by: Deluan * feat(transcode): implement server-side transcoding override for player formats Signed-off-by: Deluan * fix(transcode): honor bit depth and channel constraints in transcoding selection selectTranscodingOptions only checked sample rate when deciding whether same-format transcoding was needed, ignoring requested bit depth and channel reductions. This caused the streamer to return raw audio when the transcode decision requested downmix or bit-depth conversion. * refactor(transcode): unify streaming decision engine via MakeDecision Move transcoding decision-making out of mediaStreamer and into the subsonic Stream/Download handlers, using transcode.Decider.MakeDecision as the single decision engine. This eliminates selectTranscodingOptions and the mismatch between decision and streaming code paths (decision used LookupTranscodeCommand with built-in fallbacks, while streaming used FindByFormat which only checked the DB). - Add DecisionOptions with SkipProbe to MakeDecision so the legacy streaming path never calls ffprobe - Add buildLegacyClientInfo to translate legacy stream params (format, maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo - Add resolveStreamRequest on the subsonic Router to resolve legacy params into a fully specified StreamRequest via MakeDecision - Simplify DoStream to a dumb executor that receives pre-resolved params - Remove selectTranscodingOptions entirely Signed-off-by: Deluan * refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest Moved MediaStreamer, Stream, TranscodingCache and related types from core/media_streamer.go into core/transcode/, eliminating the duplicate StreamRequest type. The transcode.StreamRequest now carries all fields (ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and ResolveStream returns a fully-populated value, removing manual field copying at every call site. Also moved buildLegacyClientInfo into the transcode package alongside ResolveStream, and unexported ParseTranscodeParams since it was only used internally by ValidateTranscodeParams. * refactor(transcode): rename Decider methods and unexport Params type Rename ResolveStream → ResolveRequest and ValidateTranscodeParams → ResolveRequestFromToken for clarity and consistency. The new ResolveRequestFromToken returns a StreamRequest directly (instead of the intermediate Params type), eliminating manual Params→StreamRequest conversion in callers. Unexport Params to params since it is now only used internally for JWT token parsing. * test(transcode): remove redundant tests and use constants Remove tests that duplicate coverage from integration-level tests (toClaimsMap, paramsFromToken round-trips, applyServerOverride direct call, duplicate 410 handler test). Replace raw "http" strings with ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into DescribeTable. * refactor(transcode): split oversized files into focused modules Split transcode.go and transcode_test.go into focused files by concern: - decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe) - token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken) - legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest) - codec_test.go: isLosslessFormat and normalizeProbeCodec tests - token_test.go: token round-trip and ResolveRequestFromToken tests Moved the Decider interface from types.go to decider.go to keep it near its implementation, and cleaned up types.go to contain only pure type definitions and constants. No public API changes. * refactor(transcode): reorder parameters in applyServerOverride function Signed-off-by: Deluan * test(e2e): add NewTestStream function and implement spyStreamer for testing Signed-off-by: Deluan --------- Signed-off-by: Deluan --- adapters/gotaglib/gotaglib.go | 1 + cmd/wire_gen.go | 12 +- conf/configuration.go | 2 + consts/consts.go | 8 +- core/archiver.go | 7 +- core/archiver_test.go | 17 +- core/auth/auth.go | 13 + core/auth/claims.go | 8 +- core/ffmpeg/ffmpeg.go | 276 ++++- core/ffmpeg/ffmpeg_test.go | 498 +++++++- core/media_streamer_Internal_test.go | 162 --- core/transcode/aliases.go | 87 ++ core/transcode/codec.go | 77 ++ core/transcode/codec_test.go | 69 ++ core/transcode/decider.go | 425 +++++++ core/transcode/decider_test.go | 1087 +++++++++++++++++ core/transcode/legacy_client.go | 85 ++ core/transcode/legacy_client_test.go | 84 ++ core/transcode/limitations.go | 171 +++ core/{ => transcode}/media_streamer.go | 144 +-- core/{ => transcode}/media_streamer_test.go | 27 +- core/transcode/token.go | 155 +++ core/transcode/token_test.go | 272 +++++ core/transcode/transcode_suite_test.go | 17 + core/transcode/types.go | 134 ++ core/wire_providers.go | 6 +- ...75815_add_codec_and_update_transcodings.go | 73 ++ model/mediafile.go | 60 + model/mediafile_test.go | 54 +- model/metadata/map_mediafile.go | 1 + model/metadata/metadata.go | 1 + persistence/mediafile_repository.go | 5 + server/e2e/e2e_suite_test.go | 65 +- server/e2e/subsonic_media_retrieval_test.go | 124 ++ server/public/handle_streams.go | 6 +- server/public/public.go | 5 +- server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 65 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 5 +- server/subsonic/playlists_test.go | 4 +- server/subsonic/responses/responses.go | 24 + server/subsonic/searching_test.go | 2 +- server/subsonic/stream.go | 15 +- server/subsonic/transcode.go | 381 ++++++ server/subsonic/transcode_test.go | 406 ++++++ tests/mock_ffmpeg.go | 18 +- tests/mock_mediafile_repo.go | 11 + tests/mock_transcoding_repo.go | 4 + 51 files changed, 4828 insertions(+), 352 deletions(-) delete mode 100644 core/media_streamer_Internal_test.go create mode 100644 core/transcode/aliases.go create mode 100644 core/transcode/codec.go create mode 100644 core/transcode/codec_test.go create mode 100644 core/transcode/decider.go create mode 100644 core/transcode/decider_test.go create mode 100644 core/transcode/legacy_client.go create mode 100644 core/transcode/legacy_client_test.go create mode 100644 core/transcode/limitations.go rename core/{ => transcode}/media_streamer.go (56%) rename core/{ => transcode}/media_streamer_test.go (69%) create mode 100644 core/transcode/token.go create mode 100644 core/transcode/token_test.go create mode 100644 core/transcode/transcode_suite_test.go create mode 100644 core/transcode/types.go create mode 100644 db/migrations/20260307175815_add_codec_and_update_transcodings.go create mode 100644 server/subsonic/transcode.go create mode 100644 server/subsonic/transcode_test.go diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 7b827e880..9b71cb462 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -77,6 +77,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { Channels: int(props.Channels), SampleRate: int(props.SampleRate), BitDepth: int(props.BitsPerSample), + Codec: props.Codec, } // Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index e8df9a386..a7a0769d3 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -94,8 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := core.GetTranscodingCache() - mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodingCache := transcode.GetTranscodingCache() + mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) @@ -105,7 +106,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics) + decider := transcode.NewDecider(dataStore, fFmpeg) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, decider) return router } @@ -120,8 +122,8 @@ func CreatePublicRouter() *public.Router { agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := core.GetTranscodingCache() - mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodingCache := transcode.GetTranscodingCache() + mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) diff --git a/conf/configuration.go b/conf/configuration.go index c46879d42..da549ce26 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -139,6 +139,7 @@ type configOptions struct { DevExternalArtistFetchMultiplier float64 DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool + DevEnableMediaFileProbe bool } type scannerOptions struct { @@ -763,6 +764,7 @@ func setViperDefaults() { viper.SetDefault("devexternalartistfetchmultiplier", 1.5) viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) + viper.SetDefault("devenablemediafileprobe", true) } func init() { diff --git a/consts/consts.go b/consts/consts.go index 295abe8a9..2a5fdd94a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -153,7 +153,13 @@ var ( Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + }, + { + Name: "flac audio", + TargetFormat: "flac", + DefaultBitRate: 0, + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/archiver.go b/core/archiver.go index 63459816e..88b2d5b0e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -22,13 +23,13 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver { +func NewArchiver(ms transcode.MediaStreamer, ds model.DataStore, shares Share) Archiver { return &archiver{ds: ds, ms: ms, shares: shares} } type archiver struct { ds model.DataStore - ms MediaStreamer + ms transcode.MediaStreamer shares Share } @@ -176,7 +177,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med var r io.ReadCloser if format != "raw" && format != "" { - r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0) + r, err = a.ms.DoStream(ctx, &mf, transcode.StreamRequest{Format: format, BitRate: bitrate}) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index 37c4ef9ab..bfce641c9 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -9,6 +9,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -44,7 +45,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) @@ -73,7 +74,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) @@ -104,7 +105,7 @@ var _ = Describe("Archiver", func() { } sh.On("Load", mock.Anything, "1").Return(share, nil) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipShare(context.Background(), "1", out) @@ -136,7 +137,7 @@ var _ = Describe("Archiver", func() { plRepo := &mockPlaylistRepository{} plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) ds.On("Playlist", mock.Anything).Return(plRepo) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) @@ -214,15 +215,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, type mockMediaStreamer struct { mock.Mock - core.MediaStreamer + transcode.MediaStreamer } -func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) { - args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset) +func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { + args := m.Called(ctx, mf, req) if args.Error(1) != nil { return nil, args.Error(1) } - return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil + return &transcode.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } type mockShare struct { diff --git a/core/auth/auth.go b/core/auth/auth.go index f7ab3ac1b..a75111b35 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -120,6 +120,19 @@ func createNewSecret(ctx context.Context, ds model.DataStore) string { return secret } +// EncodeToken creates a signed JWT from an arbitrary claims map. +// It sets the issuer claim automatically. +func EncodeToken(claims map[string]any) (string, error) { + claims[jwt.IssuerKey] = consts.JWTIssuer + _, token, err := TokenAuth.Encode(claims) + return token, err +} + +// DecodeAndVerifyToken verifies a JWT string and returns the parsed token. +func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) { + return jwtauth.VerifyToken(TokenAuth, tokenStr) +} + func getEncKey() []byte { key := cmp.Or( conf.Server.PasswordEncryptionKey, diff --git a/core/auth/claims.go b/core/auth/claims.go index ca496ae9a..c0e4dea7f 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -86,9 +86,11 @@ func ClaimsFromToken(token jwt.Token) Claims { if err := token.Get("f", &f); err == nil { c.Format = f } - var b int - if err := token.Get("b", &b); err == nil { - c.BitRate = b + if err := token.Get("b", &c.BitRate); err != nil { + var bf float64 + if err := token.Get("b", &bf); err == nil { + c.BitRate = int(bf) + } } return c } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d134077ce..7202d02dd 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -2,23 +2,49 @@ package ffmpeg import ( "context" + "encoding/json" "errors" "fmt" "io" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" ) +// TranscodeOptions contains all parameters for a transcoding operation. +type TranscodeOptions struct { + Command string // DB command template (used to detect custom vs default) + Format string // Target format (mp3, opus, aac, flac) + FilePath string + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 + Offset int // seconds +} + +// AudioProbeResult contains authoritative audio stream properties from ffprobe. +type AudioProbeResult struct { + Codec string `json:"codec"` + Profile string `json:"profile,omitempty"` + BitRate int `json:"bitRate"` + SampleRate int `json:"sampleRate"` + BitDepth int `json:"bitDepth"` + Channels int `json:"channels"` +} + type FFmpeg interface { - Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) + Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) Probe(ctx context.Context, files []string) (string, error) + ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool Version() string @@ -29,21 +55,26 @@ func New() FFmpeg { } const ( - extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" - probeCmd = "ffmpeg %s -f ffmetadata" + extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" + probeCmd = "ffmpeg %s -f ffmetadata" + probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} -func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) { +func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err } - // First make sure the file exists - if err := fileExists(path); err != nil { + if err := fileExists(opts.FilePath); err != nil { return nil, err } - args := createFFmpegCommand(command, path, maxBitRate, offset) + var args []string + if isDefaultCommand(opts.Format, opts.Command) { + args = buildDynamicArgs(opts) + } else { + args = buildTemplateArgs(opts) + } return e.start(ctx, args) } @@ -51,7 +82,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, if _, err := ffmpegCmd(); err != nil { return nil, err } - // First make sure the file exists if err := fileExists(path); err != nil { return nil, err } @@ -81,6 +111,91 @@ func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) { return string(output), nil } +func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) { + if _, err := ffmpegCmd(); err != nil { + return nil, err + } + if err := fileExists(filePath); err != nil { + return nil, err + } + args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) + log.Trace(ctx, "Executing ffprobe command", "args", args) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) + } + return parseProbeOutput(output) +} + +type probeOutput struct { + Streams []probeStream `json:"streams"` + Format probeFormat `json:"format"` +} + +type probeFormat struct { + BitRate string `json:"bit_rate"` +} + +type probeStream struct { + CodecName string `json:"codec_name"` + CodecType string `json:"codec_type"` + Profile string `json:"profile"` + SampleRate string `json:"sample_rate"` + BitRate string `json:"bit_rate"` + Channels int `json:"channels"` + BitsPerSample int `json:"bits_per_sample"` + BitsPerRawSample string `json:"bits_per_raw_sample"` +} + +func parseProbeOutput(data []byte) (*AudioProbeResult, error) { + var output probeOutput + if err := json.Unmarshal(data, &output); err != nil { + return nil, fmt.Errorf("parsing ffprobe output: %w", err) + } + + for _, s := range output.Streams { + if s.CodecType != "audio" { + continue + } + bitDepth := s.BitsPerSample + if bitDepth == 0 && s.BitsPerRawSample != "" { + bitDepth, _ = strconv.Atoi(s.BitsPerRawSample) + } + result := &AudioProbeResult{ + Codec: s.CodecName, + Channels: s.Channels, + BitDepth: bitDepth, + } + + // Profile: "unknown" → empty + if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") { + result.Profile = s.Profile + } + + // Sample rate: string → int + if s.SampleRate != "" { + result.SampleRate, _ = strconv.Atoi(s.SampleRate) + } + + // Bit rate: bps string → kbps int + if s.BitRate != "" { + bps, _ := strconv.Atoi(s.BitRate) + result.BitRate = bps / 1000 + } + + // Fallback to format-level bit_rate (needed for FLAC, Opus, etc.) + if result.BitRate == 0 && output.Format.BitRate != "" { + bps, _ := strconv.Atoi(output.Format.BitRate) + result.BitRate = bps / 1000 + } + + return result, nil + } + + return nil, fmt.Errorf("no audio stream found in ffprobe output") +} + func (e *ffmpeg) CmdPath() (string, error) { return ffmpegCmd() } @@ -156,6 +271,141 @@ func (j *ffCmd) wait() { _ = j.out.Close() } +// formatCodecMap maps target format to ffmpeg codec flag. +var formatCodecMap = map[string]string{ + "mp3": "libmp3lame", + "opus": "libopus", + "aac": "aac", + "flac": "flac", +} + +// formatOutputMap maps target format to ffmpeg output format flag (-f). +var formatOutputMap = map[string]string{ + "mp3": "mp3", + "opus": "opus", + "aac": "ipod", + "flac": "flac", +} + +// defaultCommands is used to detect whether a user has customized their transcoding command. +var defaultCommands = func() map[string]string { + m := make(map[string]string, len(consts.DefaultTranscodings)) + for _, t := range consts.DefaultTranscodings { + m[t.TargetFormat] = t.Command + } + return m +}() + +// isDefaultCommand returns true if the command matches the known default for this format. +func isDefaultCommand(format, command string) bool { + return defaultCommands[format] == command +} + +// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats, +// including all transcoding parameters (bitrate, sample rate, channels). +func buildDynamicArgs(opts TranscodeOptions) []string { + cmdPath, _ := ffmpegCmd() + args := []string{cmdPath, "-i", opts.FilePath} + + if opts.Offset > 0 { + args = append(args, "-ss", strconv.Itoa(opts.Offset)) + } + + args = append(args, "-map", "0:a:0") + + if codec, ok := formatCodecMap[opts.Format]; ok { + args = append(args, "-c:a", codec) + } + + if opts.BitRate > 0 { + args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k") + } + if opts.SampleRate > 0 { + args = append(args, "-ar", strconv.Itoa(opts.SampleRate)) + } + if opts.Channels > 0 { + args = append(args, "-ac", strconv.Itoa(opts.Channels)) + } + // Only pass -sample_fmt for lossless output formats where bit depth matters. + // Lossy codecs (mp3, aac, opus) handle sample format conversion internally, + // and passing interleaved formats like "s16" causes silent failures. + if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { + args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) + } + + args = append(args, "-v", "0") + + if outputFmt, ok := formatOutputMap[opts.Format]; ok { + args = append(args, "-f", outputFmt) + } + + // For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming + if opts.Format == "aac" { + args = append(args, "-movflags", "frag_keyframe+empty_moov") + } + + args = append(args, "-") + return args +} + +// buildTemplateArgs handles user-customized command templates, with dynamic injection +// of sample rate, channels, and bit depth when requested by the transcode decision. +// Note: these flags are injected unconditionally when non-zero, even if the template +// already includes them. FFmpeg uses the last occurrence of duplicate flags. +func buildTemplateArgs(opts TranscodeOptions) []string { + args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset) + + // Dynamically inject -ar, -ac, and -sample_fmt before the output target + if opts.SampleRate > 0 { + args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate)) + } + if opts.Channels > 0 { + args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels)) + } + if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { + args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) + } + return args +} + +// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output). +func injectBeforeOutput(args []string, flag, value string) []string { + if len(args) > 0 && args[len(args)-1] == "-" { + result := make([]string, 0, len(args)+2) + result = append(result, args[:len(args)-1]...) + result = append(result, flag, value, "-") + return result + } + return append(args, flag, value) +} + +// isLosslessOutputFormat returns true if the format is a lossless audio format +// where preserving bit depth via -sample_fmt is meaningful. +// Note: this covers only formats ffmpeg can produce as output. For the full set of +// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat. +func isLosslessOutputFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff": + return true + } + return false +} + +// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string. +// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format +// (ffmpeg packs 24-bit samples into 32-bit containers). +func bitDepthToSampleFmt(bitDepth int) string { + switch bitDepth { + case 16: + return "s16" + case 32: + return "s32" + default: + // 24-bit and other depths: use s32 (the next valid container size) + return "s32" + } +} + // Path will always be an absolute path func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string @@ -196,10 +446,20 @@ func fixCmd(cmd string) []string { if s == "ffmpeg" || s == "ffmpeg.exe" { split[i] = cmdPath } + if s == "ffprobe" || s == "ffprobe.exe" { + split[i] = ffprobePath(cmdPath) + } } return split } +// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path. +func ffprobePath(ffmpegCmd string) string { + dir := filepath.Dir(ffmpegCmd) + base := filepath.Base(ffmpegCmd) + return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1)) +} + func ffmpegCmd() (string, error) { ffOnce.Do(func() { if conf.Server.FFmpegPath != "" { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index debe0b51e..eebeefe35 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,19 +2,27 @@ package ffmpeg import ( "context" + "os" + "path/filepath" "runtime" sync "sync" "testing" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestFFmpeg(t *testing.T) { - tests.Init(t, false) + // Inline test init to avoid import cycle with tests package + //nolint:dogsled + _, file, _, _ := runtime.Caller(0) + appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) + confPath := filepath.Join(appPath, "tests", "navidrome-test.toml") + _ = os.Chdir(appPath) + conf.LoadFromFile(confPath) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "FFmpeg Suite") @@ -70,6 +78,473 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("isDefaultCommand", func() { + It("returns true for known default mp3 command", func() { + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + }) + It("returns true for known default opus command", func() { + Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + }) + It("returns true for known default aac command", func() { + Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue()) + }) + It("returns true for known default flac command", func() { + Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + }) + It("returns false for a custom command", func() { + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) + }) + It("returns false for unknown format", func() { + Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse()) + }) + }) + + Describe("buildDynamicArgs", func() { + It("builds mp3 args with bitrate, samplerate, and channels", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 256, + SampleRate: 48000, + Channels: 2, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "libmp3lame", + "-b:a", "256k", + "-ar", "48000", + "-ac", "2", + "-v", "0", + "-f", "mp3", + "-", + })) + }) + + It("builds flac args without bitrate", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + SampleRate: 48000, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-map", "0:a:0", + "-c:a", "flac", + "-ar", "48000", + "-v", "0", + "-f", "flac", + "-", + })) + }) + + It("builds opus args with bitrate only", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "opus", + FilePath: "/music/file.flac", + BitRate: 128, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "libopus", + "-b:a", "128k", + "-v", "0", + "-f", "opus", + "-", + })) + }) + + It("includes offset when specified", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.mp3", + BitRate: 192, + Offset: 30, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.mp3", + "-ss", "30", + "-map", "0:a:0", + "-c:a", "libmp3lame", + "-b:a", "192k", + "-v", "0", + "-f", "mp3", + "-", + })) + }) + + It("builds aac args with fragmented MP4 container", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "aac", + FilePath: "/music/file.flac", + BitRate: 256, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "aac", + "-b:a", "256k", + "-v", "0", + "-f", "ipod", + "-movflags", "frag_keyframe+empty_moov", + "-", + })) + }) + + It("builds flac args with bit depth", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 24, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-map", "0:a:0", + "-c:a", "flac", + "-sample_fmt", "s32", + "-v", "0", + "-f", "flac", + "-", + })) + }) + + It("omits -sample_fmt when bit depth is 0", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.flac", + BitDepth: 0, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + It("omits -sample_fmt when bit depth is too low (DSD)", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 1, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16", + func(format string, bitRate int) { + args := buildDynamicArgs(TranscodeOptions{ + Format: format, + FilePath: "/music/file.flac", + BitRate: bitRate, + BitDepth: 16, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }, + Entry("mp3", "mp3", 256), + Entry("aac", "aac", 256), + Entry("opus", "opus", 128), + ) + }) + + Describe("bitDepthToSampleFmt", func() { + It("converts 16-bit", func() { + Expect(bitDepthToSampleFmt(16)).To(Equal("s16")) + }) + It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() { + Expect(bitDepthToSampleFmt(24)).To(Equal("s32")) + }) + It("converts 32-bit", func() { + Expect(bitDepthToSampleFmt(32)).To(Equal("s32")) + }) + }) + + Describe("buildTemplateArgs", func() { + It("injects -ar and -ac into custom template", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + SampleRate: 44100, + Channels: 2, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-ar", "44100", "-ac", "2", + "-", + })) + }) + + It("injects only -ar when channels is 0", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + SampleRate: 48000, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-ar", "48000", + "-", + })) + }) + + It("does not inject anything when sample rate and channels are 0", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-", + })) + }) + + It("injects -sample_fmt for lossless output format with bit depth", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 24, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-v", "0", "-c:a", "flac", "-f", "flac", + "-sample_fmt", "s32", + "-", + })) + }) + + It("does not inject -sample_fmt for lossy output format even with bit depth", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 192, + BitDepth: 16, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-", + })) + }) + }) + + Describe("injectBeforeOutput", func() { + It("inserts flag before trailing dash", func() { + args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000") + Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"})) + }) + + It("appends when no trailing dash", func() { + args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000") + Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"})) + }) + }) + + Describe("parseProbeOutput", func() { + It("parses MP3 with embedded artwork (real ffprobe output)", func() { + // Real: MP3 file with mjpeg artwork stream after audio + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` + + `"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` + + `"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("mp3")) + Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps + Expect(result.BitDepth).To(Equal(0)) // lossy codec + }) + + It("parses AAC-LC in m4a container (real ffprobe output)", func() { + // Real: AAC LC file with profile and artwork + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` + + `"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` + + `"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("aac")) + Expect(result.Profile).To(Equal("LC")) + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps + }) + + It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() { + // Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` + + `{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` + + `"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` + + `"sample_rate":"48000","channels":2,"channel_layout":"stereo",` + + `"bits_per_sample":0,"bit_rate":"55999"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("aac")) + Expect(result.Profile).To(Equal("HE-AACv2")) + Expect(result.SampleRate).To(Equal(48000)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps + }) + + It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() { + // Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample. + // Stream-level bit_rate is absent; format-level bit_rate is used as fallback. + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` + + `"format":{"bit_rate":"906900"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("flac")) + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample + Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps + Expect(result.Profile).To(BeEmpty()) // no profile field in real output + }) + + It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() { + // Real: Opus stream-level bit_rate is absent; format-level is used as fallback. + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":0}],` + + `"format":{"bit_rate":"128000"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("opus")) + Expect(result.SampleRate).To(Equal(48000)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps + Expect(result.BitDepth).To(Equal(0)) + }) + + It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() { + // Real: WAV uses bits_per_sample directly + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` + + `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` + + `"bits_per_sample":16,"bit_rate":"1411200"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("pcm_s16le")) + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitDepth).To(Equal(16)) + Expect(result.BitRate).To(Equal(1411)) + }) + + It("parses ALAC in m4a container (real ffprobe output)", func() { + // Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` + + `"bits_per_raw_sample":"16"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("alac")) + Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps + }) + + It("skips video-only streams", func() { + data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + _, err := parseProbeOutput(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no audio stream")) + }) + + It("returns error for empty streams array", func() { + data := []byte(`{"streams":[]}`) + _, err := parseProbeOutput(data) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid JSON", func() { + data := []byte(`not json`) + _, err := parseProbeOutput(data) + Expect(err).To(HaveOccurred()) + }) + + It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() { + // Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` + + `"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` + + `"format":{"bit_rate":"18432000"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("flac")) + Expect(result.SampleRate).To(Equal(192000)) + Expect(result.BitDepth).To(Equal(24)) + Expect(result.Channels).To(Equal(8)) + Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps + }) + + It("parses DSD/DSF file (real ffprobe output)", func() { + // Real: Yes - Owner of a Lonely Heart, DSD64 DSF + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"dsd_lsbf_planar",` + + `"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` + + `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("dsd_lsbf_planar")) + Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample + Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps + }) + + It("prefers stream-level bit_rate over format-level when both are present", func() { + // ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` + + `"sample_rate":"44100","channels":2,"bits_per_sample":0,` + + `"bit_rate":"1011003","bits_per_raw_sample":"16"}],` + + `"format":{"bit_rate":"1050000"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050) + }) + + It("returns BitRate 0 when neither stream nor format has bit_rate", func() { + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` + + `"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` + + `"format":{}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.BitRate).To(Equal(0)) + }) + + It("clears 'unknown' profile to empty string", func() { + data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` + + `"codec_type":"audio","profile":"unknown","sample_rate":"44100",` + + `"channels":2,"bits_per_sample":0}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Profile).To(BeEmpty()) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -93,7 +568,12 @@ var _ = Describe("ffmpeg", func() { command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -" // The input file is not used here, but we need to provide a valid path to the Transcode function - stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0) + stream, err := ff.Transcode(ctx, TranscodeOptions{ + Command: command, + Format: "mp3", + FilePath: "tests/fixtures/test.mp3", + BitRate: 128, + }) Expect(err).ToNot(HaveOccurred()) defer stream.Close() @@ -115,7 +595,12 @@ var _ = Describe("ffmpeg", func() { cancel() // Cancel immediately // This should fail immediately - _, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0) + _, err := ff.Transcode(ctx, TranscodeOptions{ + Command: "ffmpeg -i %s -f mp3 -", + Format: "mp3", + FilePath: "tests/fixtures/test.mp3", + BitRate: 128, + }) Expect(err).To(MatchError(context.Canceled)) }) }) @@ -142,7 +627,10 @@ var _ = Describe("ffmpeg", func() { defer cancel() // Start a process that will run for a while - stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0) + stream, err := ff.Transcode(ctx, TranscodeOptions{ + Command: longRunningCmd, + FilePath: "tests/fixtures/test.mp3", + }) Expect(err).ToNot(HaveOccurred()) defer stream.Close() diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go deleted file mode 100644 index 44fbf701c..000000000 --- a/core/media_streamer_Internal_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package core - -import ( - "context" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("MediaStreamer", func() { - var ds model.DataStore - ctx := log.NewContext(context.Background()) - - BeforeEach(func() { - ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - }) - - Context("selectTranscodingOptions", func() { - mf := &model.MediaFile{} - Context("player is not configured", func() { - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) - Expect(format).To(Equal("raw")) - }) - It("returns raw if a transcoder does not exists", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0) - Expect(format).To(Equal("raw")) - }) - It("returns the requested format if a transcoder exists", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() { - mf.Suffix = "mp3" - mf.BitRate = 112 - format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128) - Expect(format).To(Equal("raw")) - }) - It("returns the requested format if requested BitRate is lower than original", func() { - mf.Suffix = "mp3" - mf.BitRate = 320 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(192)) - }) - It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() { - mf.Suffix = "mp3" - mf.BitRate = 320 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(320)) - }) - Context("Downsampling", func() { - BeforeEach(func() { - conf.Server.DefaultDownsamplingFormat = "opus" - mf.Suffix = "FLAC" - mf.BitRate = 960 - }) - It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() { - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128) - Expect(format).To(Equal("opus")) - Expect(bitRate).To(Equal(128)) - }) - It("returns raw if maxBitrate is equal or greater than original", func() { - // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(0)) - }) - }) - }) - - Context("player has format configured", func() { - BeforeEach(func() { - t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} - ctx = request.WithTranscoding(ctx, t) - }) - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) - Expect(format).To(Equal("raw")) - }) - It("returns configured format/bitrate as default", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(96)) - }) - It("returns requested format", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns requested bitrate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(80)) - }) - It("returns raw if selected bitrate and format is the same as original", func() { - mf.Suffix = "mp3" - mf.BitRate = 192 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(0)) - }) - }) - - Context("player has maxBitRate configured", func() { - BeforeEach(func() { - t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} - p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192} - ctx = request.WithTranscoding(ctx, t) - ctx = request.WithPlayer(ctx, p) - }) - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) - Expect(format).To(Equal("raw")) - }) - It("returns configured format/bitrate as default", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(192)) - }) - It("returns requested format", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns requested bitrate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(160)) - }) - }) - }) -}) diff --git a/core/transcode/aliases.go b/core/transcode/aliases.go new file mode 100644 index 000000000..67a641511 --- /dev/null +++ b/core/transcode/aliases.go @@ -0,0 +1,87 @@ +package transcode + +import ( + "slices" + "strings" +) + +// containerAliasGroups maps each container alias to a canonical group name. +var containerAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts", "m4a", "mp4", "m4b", "m4p"}, + {"mpeg", "mp3", "mp2"}, + {"ogg", "oga"}, + {"aif", "aiff"}, + {"asf", "wma"}, + {"mpc", "mpp"}, + {"wv"}, + } + m := make(map[string]string) + for _, g := range groups { + canonical := g[0] + for _, name := range g { + m[name] = canonical + } + } + return m +}() + +// codecAliasGroups maps each codec alias to a canonical group name. +// Codecs within the same group are considered equivalent. +var codecAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts"}, + {"ac3", "ac-3"}, + {"eac3", "e-ac3", "e-ac-3", "eac-3"}, + {"mpc7", "musepack7"}, + {"mpc8", "musepack8"}, + {"wma1", "wmav1"}, + {"wma2", "wmav2"}, + {"wmalossless", "wma9lossless"}, + {"wmapro", "wma9pro"}, + {"shn", "shorten"}, + {"mp4als", "als"}, + } + m := make(map[string]string) + for _, g := range groups { + for _, name := range g { + m[name] = g[0] // canonical = first entry + } + } + return m +}() + +// matchesWithAliases checks if a value matches any entry in candidates, +// consulting the alias map for equivalent names. +func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool { + value = strings.ToLower(value) + canonical := aliases[value] + for _, c := range candidates { + c = strings.ToLower(c) + if c == value { + return true + } + if canonical != "" && aliases[c] == canonical { + return true + } + } + return false +} + +// matchesContainer checks if a file suffix matches any of the container names, +// including common aliases. +func matchesContainer(suffix string, containers []string) bool { + return matchesWithAliases(suffix, containers, containerAliasGroups) +} + +// matchesCodec checks if a codec matches any of the codec names, +// including common aliases. +func matchesCodec(codec string, codecs []string) bool { + return matchesWithAliases(codec, codecs, codecAliasGroups) +} + +func containsIgnoreCase(slice []string, s string) bool { + return slices.ContainsFunc(slice, func(item string) bool { + return strings.EqualFold(item, s) + }) +} diff --git a/core/transcode/codec.go b/core/transcode/codec.go new file mode 100644 index 000000000..aa276d43f --- /dev/null +++ b/core/transcode/codec.go @@ -0,0 +1,77 @@ +package transcode + +import "strings" + +// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal +// codec names used throughout Navidrome (matching inferCodecFromSuffix output). +// Most ffprobe names match directly; this handles the exceptions. +func normalizeProbeCodec(codec string) string { + c := strings.ToLower(codec) + // DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf + if strings.HasPrefix(c, "dsd") { + return "dsd" + } + // PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc. + if strings.HasPrefix(c, "pcm_") { + return "pcm" + } + return c +} + +// isLosslessFormat returns true if the format is a known lossless audio codec/format. +// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM) +// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal. +// +// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats +// ffmpeg can produce as output (a smaller set). +func isLosslessFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm": + return true + } + return false +} + +// normalizeSourceSampleRate adjusts the source sample rate for codecs that store +// it differently than PCM. Currently handles DSD (÷8): +// DSD64=2822400→352800, DSD128=5644800→705600, etc. +// For other codecs, returns the rate unchanged. +func normalizeSourceSampleRate(sampleRate int, codec string) int { + if strings.EqualFold(codec, "dsd") && sampleRate > 0 { + return sampleRate / 8 + } + return sampleRate +} + +// normalizeSourceBitDepth adjusts the source bit depth for codecs that use +// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is +// what ffmpeg produces). For other codecs, returns the depth unchanged. +func normalizeSourceBitDepth(bitDepth int, codec string) int { + if strings.EqualFold(codec, "dsd") && bitDepth == 1 { + return 24 + } + return bitDepth +} + +// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs +// that always resample regardless of input (e.g., Opus always outputs 48000Hz). +// Returns 0 if the codec has no fixed output rate. +func codecFixedOutputSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "opus": + return 48000 + } + return 0 +} + +// codecMaxSampleRate returns the hard maximum output sample rate for a codec. +// Returns 0 if the codec has no hard limit. +func codecMaxSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "mp3": + return 48000 + case "aac": + return 96000 + } + return 0 +} diff --git a/core/transcode/codec_test.go b/core/transcode/codec_test.go new file mode 100644 index 000000000..6d3fbd78c --- /dev/null +++ b/core/transcode/codec_test.go @@ -0,0 +1,69 @@ +package transcode + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Codec", func() { + Describe("isLosslessFormat", func() { + It("returns true for known lossless codecs", func() { + Expect(isLosslessFormat("flac")).To(BeTrue()) + Expect(isLosslessFormat("alac")).To(BeTrue()) + Expect(isLosslessFormat("pcm")).To(BeTrue()) + Expect(isLosslessFormat("wav")).To(BeTrue()) + Expect(isLosslessFormat("dsd")).To(BeTrue()) + Expect(isLosslessFormat("ape")).To(BeTrue()) + Expect(isLosslessFormat("wv")).To(BeTrue()) + Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack + }) + + It("returns false for lossy codecs", func() { + Expect(isLosslessFormat("mp3")).To(BeFalse()) + Expect(isLosslessFormat("aac")).To(BeFalse()) + Expect(isLosslessFormat("opus")).To(BeFalse()) + Expect(isLosslessFormat("vorbis")).To(BeFalse()) + }) + + It("returns false for unknown codecs", func() { + Expect(isLosslessFormat("unknown_codec")).To(BeFalse()) + }) + + It("is case-insensitive", func() { + Expect(isLosslessFormat("FLAC")).To(BeTrue()) + Expect(isLosslessFormat("Alac")).To(BeTrue()) + }) + }) + + Describe("normalizeProbeCodec", func() { + It("passes through common codec names unchanged", func() { + Expect(normalizeProbeCodec("mp3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("aac")).To(Equal("aac")) + Expect(normalizeProbeCodec("flac")).To(Equal("flac")) + Expect(normalizeProbeCodec("opus")).To(Equal("opus")) + Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis")) + Expect(normalizeProbeCodec("alac")).To(Equal("alac")) + Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2")) + }) + + It("normalizes DSD variants to dsd", func() { + Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd")) + }) + + It("normalizes PCM variants to pcm", func() { + Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm")) + }) + + It("lowercases input", func() { + Expect(normalizeProbeCodec("MP3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("AAC")).To(Equal("aac")) + Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) + }) + }) +}) diff --git a/core/transcode/decider.go b/core/transcode/decider.go new file mode 100644 index 000000000..55b451fd6 --- /dev/null +++ b/core/transcode/decider.go @@ -0,0 +1,425 @@ +package transcode + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +const defaultBitrate = 256 // kbps + +// Decider is the core service interface for making transcoding decisions +type Decider interface { + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) + CreateTranscodeParams(decision *Decision) (string, error) + ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) + ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest +} + +func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider { + return &deciderService{ + ds: ds, + ff: ff, + } +} + +type deciderService struct { + ds model.DataStore + ff ffmpeg.FFmpeg +} + +func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) { + decision := &Decision{ + MediaID: mf.ID, + SourceUpdatedAt: mf.UpdatedAt, + } + + var probe *ffmpeg.AudioProbeResult + if !opts.SkipProbe { + var err error + probe, err = s.ensureProbed(ctx, mf) + if err != nil { + return nil, err + } + } + + // Build source stream details (uses probe data if available) + decision.SourceStream = buildSourceStream(mf, probe) + src := &decision.SourceStream + + // Check for server-side player transcoding override + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } + + log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, + "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, + "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) + + // Check global bitrate constraint first. + if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { + log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play", + "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) + decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported") + // Skip direct play profiles entirely — global constraint fails + } else { + // Try direct play profiles, collecting reasons for each failure + for _, profile := range clientInfo.DirectPlayProfiles { + if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" { + decision.CanDirectPlay = true + decision.TranscodeReasons = nil // Clear any previously collected reasons + break + } else { + decision.TranscodeReasons = append(decision.TranscodeReasons, reason) + } + } + } + + // If direct play is possible, we're done + if decision.CanDirectPlay { + log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec) + return decision, nil + } + + // Try transcoding profiles (in order of preference) + for _, profile := range clientInfo.TranscodingProfiles { + if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil { + decision.CanTranscode = true + decision.TargetFormat = transcodeFormat + decision.TargetBitrate = ts.Bitrate + decision.TargetChannels = ts.Channels + decision.TargetSampleRate = ts.SampleRate + decision.TargetBitDepth = ts.BitDepth + decision.TranscodeStream = ts + break + } + } + + if decision.CanTranscode { + log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID, + "targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate, + "targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons) + } + + // If neither direct play nor transcode is possible + if !decision.CanDirectPlay && !decision.CanTranscode { + decision.ErrorReason = "no compatible playback profile found" + log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID, + "container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons) + } + + return decision, nil +} + +func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) StreamDetails { + sd := StreamDetails{ + Container: mf.Suffix, + Duration: mf.Duration, + Size: mf.Size, + } + + // Use pre-parsed probe result, or fall back to parsing stored probe data + if probe == nil { + probe, _ = parseProbeData(mf.ProbeData) + } + + // Use probe data if available for authoritative values + if probe != nil { + sd.Codec = normalizeProbeCodec(probe.Codec) + sd.Profile = probe.Profile + sd.Bitrate = probe.BitRate + sd.SampleRate = probe.SampleRate + sd.BitDepth = probe.BitDepth + sd.Channels = probe.Channels + } else { + sd.Codec = mf.AudioCodec() + sd.Bitrate = mf.BitRate + sd.SampleRate = mf.SampleRate + sd.BitDepth = mf.BitDepth + sd.Channels = mf.Channels + } + sd.IsLossless = isLosslessFormat(sd.Codec) + + return sd +} + +// applyServerOverride replaces the client-provided profiles with synthetic ones +// matching the server-forced transcoding format and bitrate. +func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo { + maxBitRate := trc.DefaultBitRate + if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + maxBitRate = player.MaxBitRate + } + + log.Debug(ctx, "Applying server-side transcoding override", + "targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate, + "client", original.Name) + + return &ClientInfo{ + Name: original.Name, + Platform: original.Platform, + MaxAudioBitrate: maxBitRate, + MaxTranscodingAudioBitrate: maxBitRate, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP}, + }, + } +} + +func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { + if data == "" { + return nil, nil + } + var result ffmpeg.AudioProbeResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + return nil, err + } + return &result, nil +} + +// checkDirectPlayProfile returns "" if the profile matches (direct play OK), +// or a typed reason string if it doesn't match. +func (s *deciderService) checkDirectPlayProfile(src *StreamDetails, profile *DirectPlayProfile, clientInfo *ClientInfo) string { + // Check protocol (only http for now) + if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) { + return "protocol not supported" + } + + // Check container + if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) { + return "container not supported" + } + + // Check codec + if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) { + return "audio codec not supported" + } + + // Check channels + if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { + return "audio channels not supported" + } + + // Check codec-specific limitations + for _, codecProfile := range clientInfo.CodecProfiles { + if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) { + if reason := checkLimitations(src, codecProfile.Limitations); reason != "" { + return reason + } + } + } + + return "" +} + +// computeTranscodedStream attempts to build a valid transcoded stream for the given profile. +// Returns the stream details and the internal transcoding format (which may differ from the +// response container when a codec fallback occurs, e.g., "mp4"→"aac"). +// Returns nil, "" if the profile cannot produce a valid output. +func (s *deciderService) computeTranscodedStream(ctx context.Context, src *StreamDetails, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) { + // Check protocol (only http for now) + if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) { + log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol) + return nil, "" + } + + responseContainer, targetFormat := resolveTargetFormat(profile) + if targetFormat == "" { + return nil, "" + } + + // Verify we have a transcoding command available (DB custom or built-in default) + if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" { + log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat) + return nil, "" + } + + targetIsLossless := isLosslessFormat(targetFormat) + + // Reject lossy to lossless conversion + if !src.IsLossless && targetIsLossless { + log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) + return nil, "" + } + + ts := &StreamDetails{ + Container: responseContainer, + Codec: strings.ToLower(profile.AudioCodec), + SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), + Channels: src.Channels, + BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), + IsLossless: targetIsLossless, + } + if ts.Codec == "" { + ts.Codec = targetFormat + } + + // Apply codec-intrinsic sample rate adjustments before codec profile limitations + if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 { + ts.SampleRate = fixedRate + } + if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate { + ts.SampleRate = maxRate + } + + // Determine target bitrate (all in kbps) + if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok { + return nil, "" + } + + // Apply MaxAudioChannels from the transcoding profile + if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { + ts.Channels = profile.MaxAudioChannels + } + + // Apply codec profile limitations to the TARGET codec + if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { + return nil, "" + } + + return ts, targetFormat +} + +// LookupTranscodeCommand returns the ffmpeg command for the given format. +// It checks the DB first (for user-customized commands), then falls back to +// the built-in default command. Returns "" if the format is unknown. +func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string { + t, err := ds.Transcoding(ctx).FindByFormat(format) + if err == nil && t.Command != "" { + return t.Command + } + // Fall back to built-in defaults + for _, dt := range consts.DefaultTranscodings { + if dt.TargetFormat == format { + return dt.Command + } + } + return "" +} + +// resolveTargetFormat determines the response container and internal target format +// from the profile's Container and AudioCodec fields. When an AudioCodec is specified +// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac"). +func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) { + responseContainer = strings.ToLower(profile.Container) + targetFormat = responseContainer + + // Prefer the audioCodec as targetFormat when provided (handles container-to-codec + // mapping like "mp4" → "aac", "ogg" → "opus"). + if profile.AudioCodec != "" { + targetFormat = strings.ToLower(profile.AudioCodec) + } + + // If neither container nor audioCodec is set, we can't resolve a format. + if targetFormat == "" { + return "", "" + } + + // When no container was specified, use the targetFormat as container too. + if responseContainer == "" { + responseContainer = targetFormat + } + + return responseContainer, targetFormat +} + +// computeBitrate determines the target bitrate for the transcoded stream. +// Returns false if the profile should be rejected. +func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { + if src.IsLossless { + if !targetIsLossless { + if clientInfo.MaxTranscodingAudioBitrate > 0 { + ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate + } else { + ts.Bitrate = defaultBitrate + } + } else { + if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { + log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit", + "targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) + return false + } + } + } else { + ts.Bitrate = src.Bitrate + } + + // Apply maxAudioBitrate as final cap + if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate { + ts.Bitrate = clientInfo.MaxAudioBitrate + } + return true +} + +// applyCodecLimitations applies codec profile limitations to the transcoded stream. +// Returns false if the profile should be rejected. +func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { + targetCodec := ts.Codec + for _, codecProfile := range clientInfo.CodecProfiles { + if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) { + continue + } + if !matchesCodec(targetCodec, []string{codecProfile.Name}) { + continue + } + for _, lim := range codecProfile.Limitations { + result := applyLimitation(sourceBitrate, &lim, ts) + if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted { + log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target", + "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name) + return false + } + if result == adjustCannotFit { + log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied", + "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name, + "comparison", lim.Comparison, "values", lim.Values) + return false + } + } + } + return true +} + +// ensureProbed runs ffprobe if probe data is missing, persists it, and returns +// the parsed result. Returns (nil, nil) when probing is skipped or data already exists +// (in which case the caller should parse mf.ProbeData). +func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) { + if mf.ProbeData != "" { + return nil, nil + } + if !conf.Server.DevEnableMediaFileProbe { + return nil, nil + } + + result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath()) + if err != nil { + return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err) + } + + data, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err) + } + mf.ProbeData = string(data) + + if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil { + log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err) + // Don't fail the decision — we have the data in memory + } + + log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec, + "profile", result.Profile, "bitRate", result.BitRate, + "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels) + return result, nil +} diff --git a/core/transcode/decider_test.go b/core/transcode/decider_test.go new file mode 100644 index 000000000..e5ad2f621 --- /dev/null +++ b/core/transcode/decider_test.go @@ -0,0 +1,1087 @@ +package transcode + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// withProbe pre-populates ProbeData on a MediaFile from its own fields, +// so ensureProbed short-circuits and tests don't need mock ffprobe results. +func withProbe(mf *model.MediaFile) *model.MediaFile { + probe := ffmpeg.AudioProbeResult{ + Codec: mf.AudioCodec(), + BitRate: mf.BitRate, + SampleRate: mf.SampleRate, + BitDepth: mf.BitDepth, + Channels: mf.Channels, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + return mf +} + +var _ = Describe("Decider", func() { + var ( + ds *tests.MockDataStore + ff *tests.MockFFmpeg + svc Decider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff = tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewDecider(ds, ff) + }) + + Describe("MakeDecision", func() { + Context("Direct Play", func() { + It("allows direct play when profile matches", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.TranscodeReasons).To(BeEmpty()) + }) + + It("rejects direct play when container doesn't match", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + }) + + It("rejects direct play when codec doesn't match", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) + }) + + It("rejects direct play when channels exceed limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) + }) + + It("handles container aliases (aac -> m4a)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles container aliases (mp4 -> m4a)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles codec aliases (adts -> aac)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("allows when protocol list is empty (any protocol)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("allows when both container and codec lists are empty (wildcard)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{}, AudioCodecs: []string{}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + }) + + Context("MaxAudioBitrate constraint", func() { + It("revokes direct play when bitrate exceeds maxAudioBitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2}) + ci := &ClientInfo{ + MaxAudioBitrate: 500, // kbps + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) + }) + }) + + Context("Transcoding", func() { + It("selects transcoding when direct play isn't possible", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, // kbps + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(256)) // kbps + Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + }) + + It("rejects lossy to lossless transcoding", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("uses default bitrate when client doesn't specify", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) // 256 kbps + }) + + It("preserves lossy bitrate when under max", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, // kbps + TranscodingProfiles: []Profile{ + {Container: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps + }) + + It("rejects format with no transcoding command available", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "wav", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("applies maxAudioBitrate as final cap on transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) + ci := &ClientInfo{ + MaxAudioBitrate: 96, // kbps + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate + }) + + It("selects first valid transcoding profile in order", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("opus")) + }) + }) + + Context("Lossless to lossless transcoding", func() { + It("allows lossless to lossless when samplerate needs downsampling", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) + ci := &ClientInfo{ + MaxAudioBitrate: 1000, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + }) + + It("sets IsLossless=true on transcoded stream when target is lossless", func() { + // Transcoding to mp3 (lossy) should result in IsLossless=false. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy + }) + }) + + Context("No compatible profile", func() { + It("returns error when nothing matches", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) + ci := &ClientInfo{} + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.ErrorReason).To(Equal("no compatible playback profile found")) + }) + }) + + Context("Codec limitations on direct play", func() { + It("rejects direct play when codec limitation fails (required)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) + }) + + It("allows direct play when optional limitation fails", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles Equals comparison with multiple values", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("rejects when Equals comparison doesn't match any value", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + + It("rejects direct play when audioProfile limitation fails (required)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "aac", + Limitations: []Limitation{ + {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true}, + }, + }, + }, + } + // Source profile is empty (not yet populated from scanner), so Equals("LC") fails + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported")) + }) + + It("allows direct play when audioProfile limitation is optional", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "aac", + Limitations: []Limitation{ + {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("rejects direct play due to samplerate limitation", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported")) + }) + }) + + Context("Codec limitations on transcoded output", func() { + It("applies bitrate limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + MaxAudioBitrate: 96, // force transcode + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Bitrate).To(Equal(96)) + }) + + It("applies channel limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Channels).To(Equal(2)) + }) + + It("applies samplerate limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + + It("applies bitdepth limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + Expect(decision.TargetBitDepth).To(Equal(16)) + }) + + It("preserves source bit depth when no limitation applies", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + }) + + Context("DSD sample rate conversion", func() { + It("converts DSD sample rate to PCM-equivalent in decision", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("converts DSD sample rate for FLAC target without codec limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("flac")) + // DSD64 2822400 / 8 = 352800, FLAC has no hard max + Expect(decision.TranscodeStream.SampleRate).To(Equal(352800)) + Expect(decision.TargetSampleRate).To(Equal(352800)) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("applies codec profile limit to DSD-converted FLAC sample rate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("applies audioBitdepth limitation to DSD-converted bit depth", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + Expect(decision.TargetBitDepth).To(Equal(16)) + }) + }) + + Context("Probe-based lossless detection", func() { + It("uses probe codec name for lossless detection", func() { + // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv" + mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + probe := ffmpeg.AudioProbeResult{ + Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + MaxTranscodingAudioBitrate: 256, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.IsLossless).To(BeTrue()) + Expect(decision.SourceStream.Codec).To(Equal("wavpack")) + // Lossless source transcoding to MP3 should use MaxTranscodingAudioBitrate + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Bitrate).To(Equal(256)) + }) + + It("detects lossy from probe codec name", func() { + mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2, SampleRate: 48000} + probe := ffmpeg.AudioProbeResult{ + Codec: "vorbis", BitRate: 192, SampleRate: 48000, BitDepth: 0, Channels: 2, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.IsLossless).To(BeFalse()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + }) + + Context("Opus fixed sample rate", func() { + It("sets Opus output to 48000Hz regardless of input", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 128, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("opus")) + // Opus always outputs 48000Hz + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + }) + + It("sets Opus output to 48000Hz even for 96kHz input", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 128, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + }) + + Context("Container vs format separation", func() { + It("preserves mp4 container when falling back to aac format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // TargetFormat is the internal format used for transcoding ("aac") + Expect(decision.TargetFormat).To(Equal("aac")) + // Container in the response preserves what the client asked ("mp4") + Expect(decision.TranscodeStream.Container).To(Equal("mp4")) + Expect(decision.TranscodeStream.Codec).To(Equal("aac")) + }) + + It("uses container as format when container matches transcoding config", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TranscodeStream.Container).To(Equal("mp3")) + }) + }) + + Context("MP3 max sample rate", func() { + It("caps sample rate at 48000 for MP3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + + It("preserves sample rate at 44100 for MP3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(44100)) + }) + }) + + Context("AAC max sample rate", func() { + It("caps sample rate at 96000 for AAC", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(96000)) + }) + }) + + Context("Typed transcode reasons from multiple profiles", func() { + It("collects reasons from each failed direct play profile", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + {Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(HaveLen(3)) + Expect(decision.TranscodeReasons[0]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[1]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[2]).To(Equal("container not supported")) + }) + }) + + Context("Source stream details", func() { + It("populates source stream correctly with kbps bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.Container).To(Equal("flac")) + Expect(decision.SourceStream.Codec).To(Equal("flac")) + Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps + Expect(decision.SourceStream.SampleRate).To(Equal(96000)) + Expect(decision.SourceStream.BitDepth).To(Equal(24)) + Expect(decision.SourceStream.Channels).To(Equal(2)) + }) + }) + + Context("Server-side player transcoding override", func() { + It("forces transcoding when override targets a different format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + // Set server override in context + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("allows direct play when source matches forced format and bitrate is within cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("transcodes when source bitrate exceeds the forced cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("uses player MaxBitRate over transcoding DefaultBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(320)) + }) + + It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + // With no cap, lossless→lossy uses defaultBitrate (256) + Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) + }) + + It("does not apply override when no transcoding is in context", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + // No override in context — client profiles used as-is + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + }) + }) + + Describe("ensureProbed", func() { + var mockMFRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + mockMFRepo = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile = mockMFRepo + }) + + It("calls ffprobe and populates ProbeData when empty", func() { + mf := &model.MediaFile{ID: "probe-1", Suffix: "mp3", BitRate: 320, Channels: 2} + mockMFRepo.SetData(model.MediaFiles{*mf}) + + ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{ + Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2, + } + + svc := NewDecider(ds, ff).(*deciderService) + probe, err := svc.ensureProbed(ctx, mf) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.ProbeData).ToNot(BeEmpty()) + Expect(probe).ToNot(BeNil()) + Expect(probe.Codec).To(Equal("mp3")) + Expect(probe.BitRate).To(Equal(320)) + Expect(probe.SampleRate).To(Equal(44100)) + Expect(probe.Channels).To(Equal(2)) + + // Verify persisted to DB + stored := mockMFRepo.Data["probe-1"] + Expect(stored.ProbeData).To(Equal(mf.ProbeData)) + }) + + It("skips ffprobe when ProbeData is already set", func() { + mf := withProbe(&model.MediaFile{ID: "probe-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + + // Set error on mock — if ffprobe were called, this would fail + ff.Error = fmt.Errorf("should not be called") + + svc := NewDecider(ds, ff).(*deciderService) + probe, err := svc.ensureProbed(ctx, mf) + Expect(err).ToNot(HaveOccurred()) + Expect(probe).To(BeNil()) + }) + + It("returns error when ffprobe fails", func() { + mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"} + ff.Error = fmt.Errorf("ffprobe not found") + + svc := NewDecider(ds, ff).(*deciderService) + _, err := svc.ensureProbed(ctx, mf) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("probing media file")) + Expect(mf.ProbeData).To(BeEmpty()) + }) + + It("skips ffprobe when DevEnableMediaFileProbe is false", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevEnableMediaFileProbe = false + + mf := &model.MediaFile{ID: "probe-4", Suffix: "mp3"} + // Set a result — if ffprobe were called, ProbeData would be populated + ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"} + + svc := NewDecider(ds, ff).(*deciderService) + probe, err := svc.ensureProbed(ctx, mf) + Expect(err).ToNot(HaveOccurred()) + Expect(probe).To(BeNil()) + Expect(mf.ProbeData).To(BeEmpty()) + }) + }) + +}) diff --git a/core/transcode/legacy_client.go b/core/transcode/legacy_client.go new file mode 100644 index 000000000..83190ec92 --- /dev/null +++ b/core/transcode/legacy_client.go @@ -0,0 +1,85 @@ +package transcode + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// buildLegacyClientInfo translates legacy Subsonic stream/download parameters +// into a ClientInfo for use with MakeDecision. +// It does NOT read request.TranscodingFrom(ctx) — that is handled by +// MakeDecision's applyServerOverride. +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { + ci := &ClientInfo{Name: "legacy"} + + // Determine target format for transcoding + var targetFormat string + switch { + case reqFormat != "": + targetFormat = reqFormat + case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + targetFormat = conf.Server.DefaultDownsamplingFormat + } + + if targetFormat != "" { + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + } + ci.TranscodingProfiles = []Profile{ + {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, + } + if reqBitRate > 0 { + ci.MaxAudioBitrate = reqBitRate + ci.MaxTranscodingAudioBitrate = reqBitRate + } + } else { + // No transcoding requested — direct play everything + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Protocols: []string{ProtocolHTTP}}, + } + } + + return ci +} + +// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters +// into a fully specified StreamRequest. +func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest { + var req StreamRequest + req.ID = mf.ID + req.Offset = offset + + if reqFormat == "raw" { + req.Format = "raw" + return req + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req + } + + if decision.CanDirectPlay { + req.Format = "raw" + return req + } + + if decision.CanTranscode { + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + return req + } + + // No compatible profile — fallback to raw + req.Format = "raw" + return req +} diff --git a/core/transcode/legacy_client_test.go b/core/transcode/legacy_client_test.go new file mode 100644 index 000000000..9628764f4 --- /dev/null +++ b/core/transcode/legacy_client_test.go @@ -0,0 +1,84 @@ +package transcode + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("buildLegacyClientInfo", func() { + var mf *model.MediaFile + + BeforeEach(func() { + mf = &model.MediaFile{Suffix: "flac", BitRate: 960} + }) + + It("sets transcoding profile for explicit format without bitrate", func() { + ci := buildLegacyClientInfo(mf, "mp3", 0) + + Expect(ci.Name).To(Equal("legacy")) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) + Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP)) + Expect(ci.MaxAudioBitrate).To(BeZero()) + Expect(ci.MaxTranscodingAudioBitrate).To(BeZero()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()})) + Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP})) + }) + + It("sets transcoding profile and bitrate for explicit format with bitrate", func() { + ci := buildLegacyClientInfo(mf, "mp3", 192) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + }) + + It("returns direct play profile when no format and no bitrate", func() { + ci := buildLegacyClientInfo(mf, "", 0) + + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty()) + Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP})) + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.MaxAudioBitrate).To(BeZero()) + }) + + It("uses default downsampling format for bitrate-only downsampling", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 128) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP)) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()})) + }) + + It("returns direct play when bitrate >= source bitrate", func() { + ci := buildLegacyClientInfo(mf, "", 960) + + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty()) + Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP})) + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.MaxAudioBitrate).To(BeZero()) + }) +}) diff --git a/core/transcode/limitations.go b/core/transcode/limitations.go new file mode 100644 index 000000000..aefc87d97 --- /dev/null +++ b/core/transcode/limitations.go @@ -0,0 +1,171 @@ +package transcode + +import ( + "strconv" + "strings" +) + +// adjustResult represents the outcome of applying a limitation to a transcoded stream value +type adjustResult int + +const ( + adjustNone adjustResult = iota // Value already satisfies the limitation + adjustAdjusted // Value was changed to fit the limitation + adjustCannotFit // Cannot satisfy the limitation (reject this profile) +) + +// checkLimitations checks codec profile limitations against source stream details. +// Returns "" if all limitations pass, or a typed reason string for the first failure. +func checkLimitations(src *StreamDetails, limitations []Limitation) string { + for _, lim := range limitations { + var ok bool + var reason string + + switch lim.Name { + case LimitationAudioChannels: + ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values) + reason = "audio channels not supported" + case LimitationAudioSamplerate: + ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values) + reason = "audio samplerate not supported" + case LimitationAudioBitrate: + ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values) + reason = "audio bitrate not supported" + case LimitationAudioBitdepth: + ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values) + reason = "audio bitdepth not supported" + case LimitationAudioProfile: + ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values) + reason = "audio profile not supported" + default: + continue + } + + if !ok && lim.Required { + return reason + } + } + return "" +} + +// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. +// Returns the adjustment result. +func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { + switch lim.Name { + case LimitationAudioChannels: + return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) + case LimitationAudioBitrate: + current := ts.Bitrate + if current == 0 { + current = sourceBitrate + } + return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v }) + case LimitationAudioSamplerate: + return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v }) + case LimitationAudioBitdepth: + if ts.BitDepth > 0 { + return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v }) + } + case LimitationAudioProfile: + // TODO: implement when audio profile data is available + } + return adjustNone +} + +// applyIntLimitation applies a limitation comparison to a value. +// If the value needs adjusting, calls the setter and returns the result. +func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult { + if len(values) == 0 { + return adjustNone + } + + switch comparison { + case ComparisonLessThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current <= limit { + return adjustNone + } + setter(limit) + return adjustAdjusted + case ComparisonGreaterThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current >= limit { + return adjustNone + } + // Cannot upscale + return adjustCannotFit + case ComparisonEquals: + // Check if current value matches any allowed value + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustNone + } + } + // Find the closest allowed value below current (don't upscale) + var closest int + found := false + for _, v := range values { + if limit, ok := parseInt(v); ok && limit < current { + if !found || limit > closest { + closest = limit + found = true + } + } + } + if found { + setter(closest) + return adjustAdjusted + } + return adjustCannotFit + case ComparisonNotEquals: + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustCannotFit + } + } + return adjustNone + } + + return adjustNone +} + +func checkIntLimitation(value int, comparison string, values []string) bool { + return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone +} + +// checkStringLimitation checks a string value against a limitation. +// Only Equals and NotEquals comparisons are meaningful for strings. +// LessThanEqual/GreaterThanEqual are not applicable and always pass. +func checkStringLimitation(value string, comparison string, values []string) bool { + switch comparison { + case ComparisonEquals: + for _, v := range values { + if strings.EqualFold(value, v) { + return true + } + } + return false + case ComparisonNotEquals: + for _, v := range values { + if strings.EqualFold(value, v) { + return false + } + } + return true + } + return true +} + +func parseInt(s string) (int, bool) { + v, err := strconv.Atoi(s) + if err != nil || v < 0 { + return 0, false + } + return v, true +} diff --git a/core/media_streamer.go b/core/transcode/media_streamer.go similarity index 56% rename from core/media_streamer.go rename to core/transcode/media_streamer.go index c741ed476..88fb61e2d 100644 --- a/core/media_streamer.go +++ b/core/transcode/media_streamer.go @@ -1,4 +1,4 @@ -package core +package transcode import ( "context" @@ -6,6 +6,7 @@ import ( "io" "mime" "os" + "strings" "sync" "time" @@ -19,8 +20,8 @@ import ( ) type MediaStreamer interface { - NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) + NewStream(ctx context.Context, req StreamRequest) (*Stream, error) + DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) } type TranscodingCache cache.FileCache @@ -36,44 +37,53 @@ type mediaStreamer struct { } type streamJob struct { - ms *mediaStreamer - mf *model.MediaFile - filePath string - format string - bitRate int - offset int + ms *mediaStreamer + mf *model.MediaFile + filePath string + format string + bitRate int + sampleRate int + bitDepth int + channels int + offset int } func (j *streamJob) Key() string { - return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset) + return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) } -func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { - mf, err := ms.ds.MediaFile(ctx).Get(id) +func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) { + mf, err := ms.ds.MediaFile(ctx).Get(req.ID) if err != nil { return nil, err } - return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset) + return ms.DoStream(ctx, mf, req) } -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) { var format string var bitRate int var cached bool defer func() { log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached, - "bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", + "bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels, + "user", userName(ctx), "transcoding", format != "raw", "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) }() - format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate) + format = req.Format + bitRate = req.BitRate + if format == "" || format == "raw" { + format = "raw" + bitRate = 0 + } s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} filePath := mf.AbsolutePath() if format == "raw" { log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath, - "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "selectedBitrate", bitRate, "selectedFormat", format) f, err := os.Open(filePath) @@ -87,12 +97,15 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF } job := &streamJob{ - ms: ms, - mf: mf, - filePath: filePath, - format: format, - bitRate: bitRate, - offset: reqOffset, + ms: ms, + mf: mf, + filePath: filePath, + format: format, + bitRate: bitRate, + sampleRate: req.SampleRate, + bitDepth: req.BitDepth, + channels: req.Channels, + offset: req.Offset, } r, err := ms.cache.Get(ctx, job) if err != nil { @@ -105,7 +118,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF s.Seeker = r.Seeker log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath, - "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable()) @@ -130,56 +143,15 @@ func (s *Stream) EstimatedContentLength() int { return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) } -// TODO This function deserves some love (refactoring) -func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) { - format = "raw" - if reqFormat == "raw" { - return format, 0 +// NewTestStream creates a Stream for testing purposes. +func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream { + return &Stream{ + ctx: context.Background(), + mf: mf, + format: format, + bitRate: bitRate, + ReadCloser: io.NopCloser(strings.NewReader("")), } - if reqFormat == mf.Suffix && reqBitRate == 0 { - bitRate = mf.BitRate - return format, bitRate - } - trc, hasDefault := request.TranscodingFrom(ctx) - var cFormat string - var cBitRate int - if reqFormat != "" { - cFormat = reqFormat - } else { - if hasDefault { - cFormat = trc.TargetFormat - cBitRate = trc.DefaultBitRate - if p, ok := request.PlayerFrom(ctx); ok { - cBitRate = p.MaxBitRate - } - } else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" { - // If no format is specified and no transcoding associated to the player, but a bitrate is specified, - // and there is no transcoding set for the player, we use the default downsampling format. - // But only if the requested bitRate is lower than the original bitRate. - log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat) - cFormat = conf.Server.DefaultDownsamplingFormat - } - } - if reqBitRate > 0 { - cBitRate = reqBitRate - } - if cBitRate == 0 && cFormat == "" { - return format, bitRate - } - t, err := ds.Transcoding(ctx).FindByFormat(cFormat) - if err == nil { - format = t.TargetFormat - if cBitRate != 0 { - bitRate = cBitRate - } else { - bitRate = t.DefaultBitRate - } - } - if format == mf.Suffix && bitRate >= mf.BitRate { - format = "raw" - bitRate = 0 - } - return format, bitRate } var ( @@ -199,9 +171,9 @@ func NewTranscodingCache() TranscodingCache { consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { job := arg.(*streamJob) - t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format) - if err != nil { - log.Error(ctx, "Error loading transcoding command", "format", job.format, err) + command := LookupTranscodeCommand(ctx, job.ms.ds, job.format) + if command == "" { + log.Error(ctx, "No transcoding command available", "format", job.format) return nil, os.ErrInvalid } @@ -217,7 +189,16 @@ func NewTranscodingCache() TranscodingCache { transcodingCtx = request.AddValues(context.Background(), ctx) } - out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset) + out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{ + Command: command, + Format: job.format, + FilePath: job.filePath, + BitRate: job.bitRate, + SampleRate: job.sampleRate, + BitDepth: job.bitDepth, + Channels: job.channels, + Offset: job.offset, + }) if err != nil { log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid @@ -225,3 +206,12 @@ func NewTranscodingCache() TranscodingCache { return out, nil }) } + +// userName extracts the username from the context for logging purposes. +func userName(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); !ok { + return "UNKNOWN" + } else { + return user.UserName + } +} diff --git a/core/media_streamer_test.go b/core/transcode/media_streamer_test.go similarity index 69% rename from core/media_streamer_test.go rename to core/transcode/media_streamer_test.go index f5175495b..f49dcb8d8 100644 --- a/core/media_streamer_test.go +++ b/core/transcode/media_streamer_test.go @@ -1,4 +1,4 @@ -package core_test +package transcode_test import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -16,7 +16,7 @@ import ( ) var _ = Describe("MediaStreamer", func() { - var streamer core.MediaStreamer + var streamer transcode.MediaStreamer var ds model.DataStore ffmpeg := tests.NewMockFFmpeg("fake data") ctx := log.NewContext(context.TODO()) @@ -29,9 +29,9 @@ var _ = Describe("MediaStreamer", func() { ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) - testCache := core.NewTranscodingCache() + testCache := transcode.NewTranscodingCache() Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) - streamer = core.NewMediaStreamer(ds, ffmpeg, testCache) + streamer = transcode.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { _ = os.RemoveAll(conf.Server.CacheFolder) @@ -39,34 +39,29 @@ var _ = Describe("MediaStreamer", func() { Context("NewStream", func() { It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, "123", "raw", 0, 0) + s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "raw"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) - It("returns a seekable stream if maxBitRate is 0", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Seekable()).To(BeTrue()) - }) - It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0) + It("returns a seekable stream if no format is specified (direct play)", func() { + s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0) + s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 64}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) It("returns a seekable stream if the file is complete in the cache", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0) + s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) _, _ = io.ReadAll(s) _ = s.Close() Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0) + s, err = streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/token.go b/core/transcode/token.go new file mode 100644 index 000000000..e110320d0 --- /dev/null +++ b/core/transcode/token.go @@ -0,0 +1,155 @@ +package transcode + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const tokenTTL = 12 * time.Hour + +// params contains the parameters extracted from a transcode token. +// TargetBitrate is in kilobits per second (kbps). +type params struct { + MediaID string + DirectPlay bool + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int + TargetBitDepth int + SourceUpdatedAt time.Time +} + +// toClaimsMap converts a Decision into a JWT claims map for token encoding. +// Only non-zero transcode fields are included. +func (d *Decision) toClaimsMap() map[string]any { + m := map[string]any{ + "mid": d.MediaID, + "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), + jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(), + } + if d.CanDirectPlay { + m["dp"] = true + } + if d.CanTranscode && d.TargetFormat != "" { + m["f"] = d.TargetFormat + if d.TargetBitrate != 0 { + m["b"] = d.TargetBitrate + } + if d.TargetChannels != 0 { + m["ch"] = d.TargetChannels + } + if d.TargetSampleRate != 0 { + m["sr"] = d.TargetSampleRate + } + if d.TargetBitDepth != 0 { + m["bd"] = d.TargetBitDepth + } + } + return m +} + +// paramsFromToken extracts and validates Params from a parsed JWT token. +// Returns an error if required claims (media ID, source timestamp) are missing. +func paramsFromToken(token jwt.Token) (*params, error) { + var p params + var mid string + if err := token.Get("mid", &mid); err == nil { + p.MediaID = mid + } + if p.MediaID == "" { + return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid) + } + + var dp bool + if err := token.Get("dp", &dp); err == nil { + p.DirectPlay = dp + } + + ua := getIntClaim(token, "ua") + if ua != 0 { + p.SourceUpdatedAt = time.Unix(int64(ua), 0) + } + if p.SourceUpdatedAt.IsZero() { + return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid) + } + + var f string + if err := token.Get("f", &f); err == nil { + p.TargetFormat = f + } + p.TargetBitrate = getIntClaim(token, "b") + p.TargetChannels = getIntClaim(token, "ch") + p.TargetSampleRate = getIntClaim(token, "sr") + p.TargetBitDepth = getIntClaim(token, "bd") + return &p, nil +} + +// getIntClaim extracts an int claim from a JWT token, handling the case where +// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). +func getIntClaim(token jwt.Token, key string) int { + var v int + if err := token.Get(key, &v); err == nil { + return v + } + var v64 int64 + if err := token.Get(key, &v64); err == nil { + return int(v64) + } + var f float64 + if err := token.Get(key, &f); err == nil { + return int(f) + } + return 0 +} + +func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { + return auth.EncodeToken(decision.toClaimsMap()) +} + +func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return nil, err + } + return paramsFromToken(token) +} + +func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) { + p, err := s.parseTranscodeParams(token) + if err != nil { + return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) + } + if p.MediaID != mediaID { + return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID) + } + mf, err := s.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return StreamRequest{}, nil, ErrMediaNotFound + } + return StreamRequest{}, nil, err + } + if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { + log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, + "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) + return StreamRequest{}, nil, ErrTokenStale + } + + req := StreamRequest{ID: mediaID, Offset: offset} + if !p.DirectPlay && p.TargetFormat != "" { + req.Format = p.TargetFormat + req.BitRate = p.TargetBitrate + req.SampleRate = p.TargetSampleRate + req.BitDepth = p.TargetBitDepth + req.Channels = p.TargetChannels + } + return req, mf, nil +} diff --git a/core/transcode/token_test.go b/core/transcode/token_test.go new file mode 100644 index 000000000..b9b74c8fc --- /dev/null +++ b/core/transcode/token_test.go @@ -0,0 +1,272 @@ +package transcode + +import ( + "context" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Token", func() { + var ( + ds *tests.MockDataStore + ff *tests.MockFFmpeg + svc Decider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff = tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewDecider(ds, ff) + }) + + Describe("Token round-trip", func() { + var ( + sourceTime time.Time + impl *deciderService + ) + + BeforeEach(func() { + sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + impl = svc.(*deciderService) + }) + + It("creates and parses a direct play token", func() { + decision := &Decision{ + MediaID: "media-123", + CanDirectPlay: true, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + Expect(token).ToNot(BeEmpty()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-123")) + Expect(params.DirectPlay).To(BeTrue()) + Expect(params.TargetFormat).To(BeEmpty()) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) + }) + + It("creates and parses a transcode token with kbps bitrate", func() { + decision := &Decision{ + MediaID: "media-456", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, // kbps + TargetChannels: 2, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-456")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("mp3")) + Expect(params.TargetBitrate).To(Equal(256)) // kbps + Expect(params.TargetChannels).To(Equal(2)) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) + }) + + It("creates and parses a transcode token with sample rate", func() { + decision := &Decision{ + MediaID: "media-789", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetSampleRate: 48000, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-789")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("flac")) + Expect(params.TargetSampleRate).To(Equal(48000)) + Expect(params.TargetChannels).To(Equal(2)) + }) + + It("creates and parses a transcode token with bit depth", func() { + decision := &Decision{ + MediaID: "media-bd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetBitDepth: 24, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-bd")) + Expect(params.TargetBitDepth).To(Equal(24)) + }) + + It("omits bit depth from token when 0", func() { + decision := &Decision{ + MediaID: "media-nobd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetBitDepth: 0, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetBitDepth).To(Equal(0)) + }) + + It("omits sample rate from token when 0", func() { + decision := &Decision{ + MediaID: "media-100", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetSampleRate: 0, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetSampleRate).To(Equal(0)) + }) + + It("truncates SourceUpdatedAt to seconds", func() { + timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) + decision := &Decision{ + MediaID: "media-trunc", + CanDirectPlay: true, + SourceUpdatedAt: timeWithNanos, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix())) + }) + + It("rejects an invalid token", func() { + _, err := impl.parseTranscodeParams("invalid-token") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ResolveRequestFromToken", func() { + var ( + mockMFRepo *tests.MockMediaFileRepo + sourceTime time.Time + ) + + BeforeEach(func() { + sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + mockMFRepo = &tests.MockMediaFileRepo{} + ds.MockedMediaFile = mockMFRepo + }) + + createTokenForMedia := func(mediaID string, updatedAt time.Time) string { + decision := &Decision{ + MediaID: mediaID, + CanDirectPlay: true, + SourceUpdatedAt: updatedAt, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + return token + } + + It("returns stream request and media file for valid token", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", UpdatedAt: sourceTime}, + }) + token := createTokenForMedia("song-1", sourceTime) + + req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(req.ID).To(Equal("song-1")) + Expect(req.Format).To(BeEmpty()) // direct play has no target format + Expect(mf.ID).To(Equal("song-1")) + }) + + It("returns ErrTokenInvalid for invalid token", func() { + _, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0) + Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) + }) + + It("returns ErrTokenInvalid when mediaID does not match token", func() { + token := createTokenForMedia("song-1", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0) + Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) + }) + + It("returns ErrMediaNotFound when media file does not exist", func() { + token := createTokenForMedia("gone-id", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0) + Expect(err).To(MatchError(ErrMediaNotFound)) + }) + + It("returns ErrTokenStale when media file has changed", func() { + newTime := sourceTime.Add(1 * time.Hour) + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", UpdatedAt: newTime}, + }) + token := createTokenForMedia("song-1", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + Expect(err).To(MatchError(ErrTokenStale)) + }) + }) + + Describe("paramsFromToken", func() { + It("returns error when media ID is missing", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)}) + Expect(err).NotTo(HaveOccurred()) + + _, err = paramsFromToken(token) + Expect(err).To(MatchError(ContainSubstring("missing media ID"))) + }) + + It("returns error when source timestamp is missing", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"}) + Expect(err).NotTo(HaveOccurred()) + + _, err = paramsFromToken(token) + Expect(err).To(MatchError(ContainSubstring("missing source timestamp"))) + }) + }) +}) diff --git a/core/transcode/transcode_suite_test.go b/core/transcode/transcode_suite_test.go new file mode 100644 index 000000000..e35471b05 --- /dev/null +++ b/core/transcode/transcode_suite_test.go @@ -0,0 +1,17 @@ +package transcode + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTranscode(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Transcode Suite") +} diff --git a/core/transcode/types.go b/core/transcode/types.go new file mode 100644 index 000000000..d7a63fbc4 --- /dev/null +++ b/core/transcode/types.go @@ -0,0 +1,134 @@ +package transcode + +import ( + "errors" + "time" +) + +var ( + ErrTokenInvalid = errors.New("invalid or expired transcode token") + ErrMediaNotFound = errors.New("media file not found") + ErrTokenStale = errors.New("transcode token is stale: media file has changed") +) + +// DecisionOptions controls optional behavior of MakeDecision. +type DecisionOptions struct { + // SkipProbe prevents MakeDecision from running ffprobe on the media file. + // When true, source stream details are derived from tag metadata only. + SkipProbe bool +} + +// StreamRequest contains the resolved parameters for creating a media stream. +type StreamRequest struct { + ID string + Format string + BitRate int // kbps + SampleRate int + BitDepth int + Channels int + Offset int // seconds +} + +// ClientInfo represents client playback capabilities. +// All bitrate values are in kilobits per second (kbps) +type ClientInfo struct { + Name string + Platform string + MaxAudioBitrate int + MaxTranscodingAudioBitrate int + DirectPlayProfiles []DirectPlayProfile + TranscodingProfiles []Profile + CodecProfiles []CodecProfile +} + +// DirectPlayProfile describes a format the client can play directly +type DirectPlayProfile struct { + Containers []string + AudioCodecs []string + Protocols []string + MaxAudioChannels int +} + +// Profile describes a transcoding target the client supports +type Profile struct { + Container string + AudioCodec string + Protocol string + MaxAudioChannels int +} + +// CodecProfile describes codec-specific limitations +type CodecProfile struct { + Type string + Name string + Limitations []Limitation +} + +// Limitation describes a specific codec limitation +type Limitation struct { + Name string + Comparison string + Values []string + Required bool +} + +// Protocol values (OpenSubsonic spec enum) +const ( + ProtocolHTTP = "http" + ProtocolHLS = "hls" +) + +// Comparison operators (OpenSubsonic spec enum) +const ( + ComparisonEquals = "Equals" + ComparisonNotEquals = "NotEquals" + ComparisonLessThanEqual = "LessThanEqual" + ComparisonGreaterThanEqual = "GreaterThanEqual" +) + +// Limitation names (OpenSubsonic spec enum) +const ( + LimitationAudioChannels = "audioChannels" + LimitationAudioBitrate = "audioBitrate" + LimitationAudioProfile = "audioProfile" + LimitationAudioSamplerate = "audioSamplerate" + LimitationAudioBitdepth = "audioBitdepth" +) + +// Codec profile types (OpenSubsonic spec enum) +const ( + CodecProfileTypeAudio = "AudioCodec" +) + +// Decision represents the internal decision result. +// All bitrate values are in kilobits per second (kbps). +type Decision struct { + MediaID string + CanDirectPlay bool + CanTranscode bool + TranscodeReasons []string + ErrorReason string + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int + TargetBitDepth int + SourceStream StreamDetails + SourceUpdatedAt time.Time + TranscodeStream *StreamDetails +} + +// StreamDetails describes audio stream properties. +// Bitrate is in kilobits per second (kbps). +type StreamDetails struct { + Container string + Codec string + Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data. + Bitrate int + SampleRate int + BitDepth int + Channels int + Duration float32 + Size int64 + IsLossless bool +} diff --git a/core/wire_providers.go b/core/wire_providers.go index f9b472015..20b5eb9a5 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,11 +10,12 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/transcode" ) var Set = wire.NewSet( - NewMediaStreamer, - GetTranscodingCache, + transcode.NewMediaStreamer, + transcode.GetTranscodingCache, NewArchiver, NewPlayers, NewShare, @@ -22,6 +23,7 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + transcode.NewDecider, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go new file mode 100644 index 000000000..4e8b1b7f5 --- /dev/null +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -0,0 +1,73 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/navidrome/navidrome/model/id" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) +} + +func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { + // Add codec column to media_file. + _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + if err != nil { + return err + } + _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + if err != nil { + return err + } + + // Update old AAC default (adts) to new default (ipod with fragmented MP4). + // Only affects users who still have the unmodified old default command. + _, err = tx.Exec( + `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + ) + if err != nil { + return err + } + + // Add FLAC transcoding for existing installations that were seeded before FLAC was added. + var count int + err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + if err != nil { + return err + } + if count == 0 { + _, err = tx.Exec( + "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", + id.NewRandom(), "flac audio", "flac", 0, + "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + ) + if err != nil { + return err + } + } + + // Add probe_data column for caching ffprobe results. + _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + if err != nil { + return err + } + return nil +} + +func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + if err != nil { + return err + } + _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`) + return err +} diff --git a/model/mediafile.go b/model/mediafile.go index 103b02639..20532bfb9 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -56,6 +56,8 @@ type MediaFile struct { SampleRate int `structs:"sample_rate" json:"sampleRate"` BitDepth int `structs:"bit_depth" json:"bitDepth"` Channels int `structs:"channels" json:"channels"` + Codec string `structs:"codec" json:"codec"` + ProbeData string `structs:"probe_data" json:"-" hash:"ignore"` Genre string `structs:"genre" json:"genre"` Genres Genres `structs:"-" json:"genres,omitempty"` SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` @@ -168,6 +170,63 @@ func (mf MediaFile) AbsolutePath() string { return filepath.Join(mf.LibraryPath, mf.Path) } +// AudioCodec returns the audio codec for this file. +// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties. +func (mf MediaFile) AudioCodec() string { + // If we have a stored codec from scanning, normalize and return it + if mf.Codec != "" { + return strings.ToLower(mf.Codec) + } + // Fallback: infer from Suffix + BitDepth + return mf.inferCodecFromSuffix() +} + +// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty. +func (mf MediaFile) inferCodecFromSuffix() string { + switch strings.ToLower(mf.Suffix) { + case "mp3", "mpga": + return "mp3" + case "mp2": + return "mp2" + case "ogg", "oga": + return "vorbis" + case "opus": + return "opus" + case "mpc": + return "mpc" + case "wma": + return "wma" + case "flac": + return "flac" + case "wav": + return "pcm" + case "aif", "aiff", "aifc": + return "pcm" + case "ape": + return "ape" + case "wv", "wvp": + return "wv" + case "tta": + return "tta" + case "tak": + return "tak" + case "shn": + return "shn" + case "dsf", "dff": + return "dsd" + case "m4a": + // AAC if BitDepth==0, ALAC if BitDepth>0 + if mf.BitDepth > 0 { + return "alac" + } + return "aac" + case "m4b", "m4p", "m4r": + return "aac" + default: + return "" + } +} + type MediaFiles []MediaFile // ToAlbum creates an Album object based on the attributes of this MediaFiles collection. @@ -363,6 +422,7 @@ type MediaFileRepository interface { CountBySuffix(options ...QueryOptions) (map[string]int64, error) Exists(id string) (bool, error) Put(m *MediaFile) error + UpdateProbeData(id string, data string) error Get(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 0b9191fe5..207d3c155 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -497,7 +497,7 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), ) - Describe("CoverArtId()", func() { + Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() @@ -518,6 +518,58 @@ var _ = Describe("MediaFile", func() { Expect(id.ID).To(Equal(mf.AlbumID)) }) }) + + Describe("AudioCodec", func() { + It("returns normalized stored codec when available", func() { + mf := MediaFile{Codec: "AAC", Suffix: "m4a"} + Expect(mf.AudioCodec()).To(Equal("aac")) + }) + + It("returns stored codec lowercased", func() { + mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} + Expect(mf.AudioCodec()).To(Equal("alac")) + }) + + DescribeTable("infers codec from suffix when Codec field is empty", + func(suffix string, bitDepth int, expected string) { + mf := MediaFile{Suffix: suffix, BitDepth: bitDepth} + Expect(mf.AudioCodec()).To(Equal(expected)) + }, + Entry("mp3", "mp3", 0, "mp3"), + Entry("mpga", "mpga", 0, "mp3"), + Entry("mp2", "mp2", 0, "mp2"), + Entry("ogg", "ogg", 0, "vorbis"), + Entry("oga", "oga", 0, "vorbis"), + Entry("opus", "opus", 0, "opus"), + Entry("mpc", "mpc", 0, "mpc"), + Entry("wma", "wma", 0, "wma"), + Entry("flac", "flac", 0, "flac"), + Entry("wav", "wav", 0, "pcm"), + Entry("aif", "aif", 0, "pcm"), + Entry("aiff", "aiff", 0, "pcm"), + Entry("aifc", "aifc", 0, "pcm"), + Entry("ape", "ape", 0, "ape"), + Entry("wv", "wv", 0, "wv"), + Entry("wvp", "wvp", 0, "wv"), + Entry("tta", "tta", 0, "tta"), + Entry("tak", "tak", 0, "tak"), + Entry("shn", "shn", 0, "shn"), + Entry("dsf", "dsf", 0, "dsd"), + Entry("dff", "dff", 0, "dsd"), + Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"), + Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"), + Entry("m4b", "m4b", 0, "aac"), + Entry("m4p", "m4p", 0, "aac"), + Entry("m4r", "m4r", 0, "aac"), + Entry("unknown suffix", "xyz", 0, ""), + ) + + It("prefers stored codec over suffix inference", func() { + mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0} + Expect(mf.AudioCodec()).To(Equal("alac")) + }) + }) + }) func t(v string) time.Time { diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index c64e8c724..824cad7c2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.SampleRate = md.AudioProperties().SampleRate mf.BitDepth = md.AudioProperties().BitDepth mf.Channels = md.AudioProperties().Channels + mf.Codec = md.AudioProperties().Codec mf.Path = md.FilePath() mf.Suffix = md.Suffix() mf.Size = md.Size() diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 954505c98..48928f989 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -35,6 +35,7 @@ type AudioProperties struct { BitDepth int SampleRate int Channels int + Codec string } type Date string diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 43736e317..264778ea0 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -163,6 +163,11 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error { return r.updateParticipants(m.ID, m.Participants) } +func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { + _, err := r.executeSQL(Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) + return err +} + func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id") diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 484a91cc7..02b66f4b7 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -3,6 +3,7 @@ package e2e import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -19,12 +20,14 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -69,6 +72,7 @@ var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router + spy *spyStreamer lib model.Library // Snapshot paths for fast DB restore @@ -224,17 +228,50 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// noopStreamer implements core.MediaStreamer -type noopStreamer struct{} +// spyStreamer captures the StreamRequest passed to DoStream for test assertions, +// then returns a minimal fake Stream so the handler completes without error. +type spyStreamer struct { + LastRequest transcode.StreamRequest + LastMediaFile *model.MediaFile +} -func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) { +func (s *spyStreamer) NewStream(ctx context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { return nil, model.ErrNotFound } -func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) { - return nil, model.ErrNotFound +func (s *spyStreamer) DoStream(_ context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { + s.LastRequest = req + s.LastMediaFile = mf + format := req.Format + if format == "" || format == "raw" { + format = mf.Suffix + } + return transcode.NewTestStream(mf, format, req.BitRate), nil } +// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. +type noopFFmpeg struct{} + +func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { + return "", nil +} + +func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } +func (n noopFFmpeg) IsAvailable() bool { return false } +func (n noopFFmpeg) Version() string { return "noop" } + // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -298,11 +335,12 @@ func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ core.MediaStreamer = noopStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} + _ artwork.Artwork = noopArtwork{} + _ transcode.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -380,13 +418,15 @@ func setupTestDB() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) - // Create the Subsonic Router with real DS + noop stubs + // Create the Subsonic Router with real DS, spy streamer, and real Decider + spy = &spyStreamer{} + decider := transcode.NewDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, - noopStreamer{}, + spy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, @@ -398,6 +438,7 @@ func setupTestDB() { playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(nil), + decider, ) } diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go index c36713dbb..465082acb 100644 --- a/server/e2e/subsonic_media_retrieval_test.go +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -3,6 +3,9 @@ package e2e import ( "net/http" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -14,21 +17,142 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { }) Describe("Stream", func() { + var trackID string + + BeforeAll(func() { + // All test tracks are mp3 at 320kbps + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + trackID = songs[0].ID + }) + It("returns error when id parameter is missing", func() { resp := doReq("stream") Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) }) + + It("streams raw when no format or bitrate specified", func() { + w := doRawReq("stream", "id", trackID) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("streams raw when format=raw", func() { + w := doRawReq("stream", "id", trackID, "format", "raw") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("transcodes to different format with bitrate", func() { + w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("downsamples when only maxBitRate is specified (lower than source)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + w := doRawReq("stream", "id", trackID, "maxBitRate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("streams raw when maxBitRate is higher than source", func() { + w := doRawReq("stream", "id", trackID, "maxBitRate", "999") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("streams raw when format matches source and no bitrate reduction", func() { + w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "320") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("transcodes when same format but lower bitrate", func() { + w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("falls back to raw for unknown format", func() { + w := doRawReq("stream", "id", trackID, "format", "xyz") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("passes timeOffset through", func() { + w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128", "timeOffset", "30") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.Offset).To(Equal(30)) + }) }) Describe("Download", func() { + var trackID string + + BeforeAll(func() { + // All test tracks are mp3 at 320kbps + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + trackID = songs[0].ID + }) + It("returns error when id parameter is missing", func() { resp := doReq("download") Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) }) + + It("downloads raw when no format specified and AutoTranscodeDownload is false", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = true + conf.Server.AutoTranscodeDownload = false + + w := doRawReq("download", "id", trackID) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("downloads with explicit format and bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = true + + w := doRawReq("download", "id", trackID, "format", "opus", "bitrate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("returns error when downloads are disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = false + + resp := doReq("download", "id", trackID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) }) Describe("GetCoverArt", func() { diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index d6819974b..a147a2ac8 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/req" ) @@ -22,10 +23,13 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0) + stream, err := pub.streamer.NewStream(ctx, transcode.StreamRequest{ + ID: info.id, Format: info.format, BitRate: info.bitrate, + }) if err != nil { log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) + return } // Make sure the stream will be closed at the end, to avoid leakage diff --git a/server/public/public.go b/server/public/public.go index ebccb01d2..7d8a4e007 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/publicurl" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -20,14 +21,14 @@ import ( type Router struct { http.Handler artwork artwork.Artwork - streamer core.MediaStreamer + streamer transcode.MediaStreamer archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, share core.Share, archiver core.Archiver) *Router { +func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.MediaStreamer, share core.Share, archiver core.Archiver) *Router { p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index aac2d63da..ae4ef9bb9 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 8674a2946..6f355d161 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -36,42 +37,44 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, type Router struct { http.Handler - ds model.DataStore - artwork artwork.Artwork - streamer core.MediaStreamer - archiver core.Archiver - players core.Players - provider external.Provider - playlists playlistsvc.Playlists - scanner model.Scanner - broker events.Broker - scrobbler scrobbler.PlayTracker - share core.Share - playback playback.PlaybackServer - metrics metrics.Metrics - lyrics lyricssvc.Lyrics + ds model.DataStore + artwork artwork.Artwork + streamer transcode.MediaStreamer + archiver core.Archiver + players core.Players + provider external.Provider + playlists playlistsvc.Playlists + scanner model.Scanner + broker events.Broker + scrobbler scrobbler.PlayTracker + share core.Share + playback playback.PlaybackServer + metrics metrics.Metrics + lyrics lyricssvc.Lyrics + transcodeDecision transcode.Decider } -func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, +func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, - metrics metrics.Metrics, lyrics lyricssvc.Lyrics, + metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision transcode.Decider, ) *Router { r := &Router{ - ds: ds, - artwork: artwork, - streamer: streamer, - archiver: archiver, - players: players, - provider: provider, - playlists: playlists, - scanner: scanner, - broker: broker, - scrobbler: scrobbler, - share: share, - playback: playback, - metrics: metrics, - lyrics: lyrics, + ds: ds, + artwork: artwork, + streamer: streamer, + archiver: archiver, + players: players, + provider: provider, + playlists: playlists, + scanner: scanner, + broker: broker, + scrobbler: scrobbler, + share: share, + playback: playback, + metrics: metrics, + lyrics: lyrics, + transcodeDecision: transcodeDecision, } r.Handler = r.routes() return r @@ -176,6 +179,8 @@ func (api *Router) routes() http.Handler { h(r, "getLyricsBySongId", api.GetLyricsBySongId) hr(r, "stream", api.Stream) hr(r, "download", api.Download) + hr(r, "getTranscodeDecision", api.GetTranscodeDecision) + hr(r, "getTranscodeStream", api.GetTranscodeStream) }) r.Group(func(r chi.Router) { // configure request throttling diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 57809fbb6..fc767b0ff 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 1a638f066..7f64fb47f 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil)) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index a364651c5..353cf1077 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -13,6 +13,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, + {Name: "transcoding", Versions: []int32{1}}, } return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index c02b262b9..92d1c3e84 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ) BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) @@ -35,11 +35,12 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(4), + HaveLen(5), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 86c17b39c..41701b4de 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 0fdbf1be6..be59e5851 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -61,6 +61,7 @@ type Subsonic struct { OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` + TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` } const ( @@ -617,3 +618,26 @@ func marshalJSONArray[T any](v []T) ([]byte, error) { } return json.Marshal(v) } + +// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension) +type TranscodeDecision struct { + CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"` + CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"` + TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"` + ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"` + TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"` + SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"` + TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"` +} + +// StreamDetails describes audio stream properties for transcoding decisions +type StreamDetails struct { + Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"` + Container string `xml:"container,attr,omitempty" json:"container,omitempty"` + Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"` + AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"` + AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"` + AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"` + AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"` + AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"` +} diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index d4b7e9702..ab40a726f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index d0cbe2086..753e408c1 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -17,7 +17,7 @@ import ( "github.com/navidrome/navidrome/utils/req" ) -func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *core.Stream, id string) { +func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *transcode.Stream, id string) { if stream.Seekable() { http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) } else { @@ -60,7 +60,13 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su format, _ := p.String("format") timeOffset := p.IntOr("timeOffset", 0) - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err + } + + streamReq := api.transcodeDecision.ResolveRequest(ctx, mf, format, maxBitRate, timeOffset) + stream, err := api.streamer.DoStream(ctx, mf, streamReq) if err != nil { return nil, err } @@ -129,7 +135,8 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0) + streamReq := api.transcodeDecision.ResolveRequest(ctx, v, format, maxBitRate, 0) + stream, err := api.streamer.DoStream(ctx, v, streamReq) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go new file mode 100644 index 000000000..ffc4cfcd7 --- /dev/null +++ b/server/subsonic/transcode.go @@ -0,0 +1,381 @@ +package subsonic + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + + "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +// API-layer request structs for JSON unmarshaling (decoupled from core structs) + +// clientInfoRequest represents client playback capabilities from the request body +type clientInfoRequest struct { + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"` + MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"` + DirectPlayProfiles []directPlayProfileRequest `json:"directPlayProfiles,omitempty"` + TranscodingProfiles []transcodingProfileRequest `json:"transcodingProfiles,omitempty"` + CodecProfiles []codecProfileRequest `json:"codecProfiles,omitempty"` +} + +// directPlayProfileRequest describes a format the client can play directly +type directPlayProfileRequest struct { + Containers []string `json:"containers,omitempty"` + AudioCodecs []string `json:"audioCodecs,omitempty"` + Protocols []string `json:"protocols,omitempty"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +// transcodingProfileRequest describes a transcoding target the client supports +type transcodingProfileRequest struct { + Container string `json:"container,omitempty"` + AudioCodec string `json:"audioCodec,omitempty"` + Protocol string `json:"protocol,omitempty"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +// codecProfileRequest describes codec-specific limitations +type codecProfileRequest struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Limitations []limitationRequest `json:"limitations,omitempty"` +} + +// limitationRequest describes a specific codec limitation +type limitationRequest struct { + Name string `json:"name,omitempty"` + Comparison string `json:"comparison,omitempty"` + Values []string `json:"values,omitempty"` + Required bool `json:"required,omitempty"` +} + +// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct. +// The OpenSubsonic spec uses bps for bitrate values; core uses kbps. +func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { + ci := &transcode.ClientInfo{ + Name: r.Name, + Platform: r.Platform, + MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate), + MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate), + } + + for _, dp := range r.DirectPlayProfiles { + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{ + Containers: dp.Containers, + AudioCodecs: dp.AudioCodecs, + Protocols: dp.Protocols, + MaxAudioChannels: dp.MaxAudioChannels, + }) + } + + for _, tp := range r.TranscodingProfiles { + ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{ + Container: tp.Container, + AudioCodec: tp.AudioCodec, + Protocol: tp.Protocol, + MaxAudioChannels: tp.MaxAudioChannels, + }) + } + + for _, cp := range r.CodecProfiles { + coreCP := transcode.CodecProfile{ + Type: cp.Type, + Name: cp.Name, + } + for _, lim := range cp.Limitations { + coreLim := transcode.Limitation{ + Name: lim.Name, + Comparison: lim.Comparison, + Values: lim.Values, + Required: lim.Required, + } + // Convert audioBitrate limitation values from bps to kbps + if lim.Name == transcode.LimitationAudioBitrate { + coreLim.Values = convertBitrateValues(lim.Values) + } + coreCP.Limitations = append(coreCP.Limitations, coreLim) + } + ci.CodecProfiles = append(ci.CodecProfiles, coreCP) + } + + return ci +} + +// bpsToKbps converts bits per second to kilobits per second (rounded). +func bpsToKbps(bps int) int { + if bps < 0 { + return 0 + } + return (bps + 500) / 1000 +} + +// kbpsToBps converts kilobits per second to bits per second. +func kbpsToBps(kbps int) int { + return kbps * 1000 +} + +// convertBitrateValues converts a slice of bps string values to kbps string values. +func convertBitrateValues(bpsValues []string) []string { + result := make([]string, len(bpsValues)) + for i, v := range bpsValues { + n, err := strconv.Atoi(v) + if err == nil { + result[i] = strconv.Itoa(bpsToKbps(n)) + } else { + result[i] = v // preserve unparseable values as-is + } + } + return result +} + +// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec. +func (r *clientInfoRequest) validate() error { + for _, dp := range r.DirectPlayProfiles { + for _, p := range dp.Protocols { + if !isValidProtocol(p) { + return fmt.Errorf("invalid protocol: %s", p) + } + } + } + for _, tp := range r.TranscodingProfiles { + if tp.Protocol != "" && !isValidProtocol(tp.Protocol) { + return fmt.Errorf("invalid protocol: %s", tp.Protocol) + } + } + for _, cp := range r.CodecProfiles { + if !isValidCodecProfileType(cp.Type) { + return fmt.Errorf("invalid codec profile type: %s", cp.Type) + } + for _, lim := range cp.Limitations { + if !isValidLimitationName(lim.Name) { + return fmt.Errorf("invalid limitation name: %s", lim.Name) + } + if !isValidComparison(lim.Comparison) { + return fmt.Errorf("invalid comparison: %s", lim.Comparison) + } + } + } + return nil +} + +// Only support songs for now +var validMediaTypes = []string{ + "song", +} + +func isValidMediaType(mediaType string) bool { + return slices.Contains(validMediaTypes, mediaType) +} + +var validProtocols = []string{ + transcode.ProtocolHTTP, + transcode.ProtocolHLS, +} + +func isValidProtocol(p string) bool { + return slices.Contains(validProtocols, p) +} + +var validCodecProfileTypes = []string{ + transcode.CodecProfileTypeAudio, +} + +func isValidCodecProfileType(t string) bool { + return slices.Contains(validCodecProfileTypes, t) +} + +var validLimitationNames = []string{ + transcode.LimitationAudioChannels, + transcode.LimitationAudioBitrate, + transcode.LimitationAudioProfile, + transcode.LimitationAudioSamplerate, + transcode.LimitationAudioBitdepth, +} + +func isValidLimitationName(n string) bool { + return slices.Contains(validLimitationNames, n) +} + +var validComparisons = []string{ + transcode.ComparisonEquals, + transcode.ComparisonNotEquals, + transcode.ComparisonLessThanEqual, + transcode.ComparisonGreaterThanEqual, +} + +func isValidComparison(c string) bool { + return slices.Contains(validComparisons, c) +} + +// toResponseStreamDetails converts a core StreamDetails to the API response type. +func toResponseStreamDetails(sd *transcode.StreamDetails) *responses.StreamDetails { + return &responses.StreamDetails{ + Protocol: transcode.ProtocolHTTP, // TODO: derive from decision when HLS support is added + Container: sd.Container, + Codec: sd.Codec, + AudioBitrate: int32(kbpsToBps(sd.Bitrate)), + AudioProfile: sd.Profile, + AudioSamplerate: int32(sd.SampleRate), + AudioBitdepth: int32(sd.BitDepth), + AudioChannels: int32(sd.Channels), + } +} + +// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. +// It receives client capabilities and returns a decision on whether to direct play or transcode. +func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return nil, nil + } + + ctx := r.Context() + p := req.Params(r) + + mediaID, err := p.String("mediaId") + if err != nil { + return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId") + } + + mediaType, err := p.String("mediaType") + if err != nil { + return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType") + } + + if !isValidMediaType(mediaType) { + return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType) + } + + // Parse and validate ClientInfo from request body (required per OpenSubsonic spec) + var clientInfoReq clientInfoRequest + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit + if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil { + return nil, newError(responses.ErrorGeneric, "invalid JSON request body") + } + if err := clientInfoReq.validate(); err != nil { + return nil, newError(responses.ErrorGeneric, "%v", err) + } + clientInfo := clientInfoReq.toCoreClientInfo() + + // Get media file + mf, err := api.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID) + } + log.Error(ctx, "Error retrieving media file", "mediaID", mediaID, err) + return nil, newError(responses.ErrorGeneric, "error retrieving media file") + } + + // Make the decision + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{}) + if err != nil { + log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) + return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") + } + + // Only create a token when there is a valid playback path + var transcodeParams string + if decision.CanDirectPlay || decision.CanTranscode { + transcodeParams, err = api.transcodeDecision.CreateTranscodeParams(decision) + if err != nil { + log.Error(ctx, "Failed to create transcode token", "mediaID", mediaID, err) + return nil, newError(responses.ErrorGeneric, "failed to create transcode token") + } + } + + // Build response (convert kbps from core to bps for the API) + response := newResponse() + response.TranscodeDecision = &responses.TranscodeDecision{ + CanDirectPlay: decision.CanDirectPlay, + CanTranscode: decision.CanTranscode, + TranscodeReasons: decision.TranscodeReasons, + ErrorReason: decision.ErrorReason, + TranscodeParams: transcodeParams, + SourceStream: toResponseStreamDetails(&decision.SourceStream), + } + + if decision.TranscodeStream != nil { + response.TranscodeDecision.TranscodeStream = toResponseStreamDetails(decision.TranscodeStream) + } + + return response, nil +} + +// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. +// It streams media using the decision encoded in the transcodeParams JWT token. +// All errors are returned as proper HTTP status codes (not Subsonic error responses). +func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + p := req.Params(r) + + mediaID, err := p.String("mediaId") + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + mediaType, err := p.String("mediaType") + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + transcodeParamsToken, err := p.String("transcodeParams") + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + if !isValidMediaType(mediaType) { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + // Validate the token and resolve streaming parameters + streamReq, mf, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mediaID, p.IntOr("offset", 0)) + if err != nil { + switch { + case errors.Is(err, transcode.ErrMediaNotFound): + http.Error(w, "Not Found", http.StatusNotFound) + case errors.Is(err, transcode.ErrTokenInvalid), errors.Is(err, transcode.ErrTokenStale): + http.Error(w, "Gone", http.StatusGone) + default: + log.Error(ctx, "Error validating transcode params", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return nil, nil + } + + // Create stream (use DoStream to avoid duplicate DB fetch) + stream, err := api.streamer.DoStream(ctx, mf, streamReq) + if err != nil { + log.Error(ctx, "Error creating stream", "mediaID", mediaID, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return nil, nil + } + + // Make sure the stream will be closed at the end + defer func() { + if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err) + } + }() + + w.Header().Set("X-Content-Type-Options", "nosniff") + + api.serveStream(ctx, w, r, stream, mediaID) + + return nil, nil +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go new file mode 100644 index 000000000..717eeb1f5 --- /dev/null +++ b/server/subsonic/transcode_test.go @@ -0,0 +1,406 @@ +package subsonic + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Transcode endpoints", func() { + var ( + router *Router + ds *tests.MockDataStore + mockTD *mockTranscodeDecision + w *httptest.ResponseRecorder + mockMFRepo *tests.MockMediaFileRepo + ) + + BeforeEach(func() { + mockMFRepo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} + mockTD = &mockTranscodeDecision{} + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + w = httptest.NewRecorder() + }) + + Describe("GetTranscodeDecision", func() { + It("returns 405 for non-POST requests", func() { + r := newGetRequest("mediaId=123", "mediaType=song") + resp, err := router.GetTranscodeDecision(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + Expect(w.Header().Get("Allow")).To(Equal("POST")) + }) + + It("returns error when mediaId is missing", func() { + r := newJSONPostRequest("mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newJSONPostRequest("mediaId=123", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for unsupported mediaType", func() { + r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not yet supported")) + }) + + It("returns ErrorDataNotFound when media file does not exist", func() { + // mockMFRepo has no data set, so Get() returns model.ErrNotFound + r := newJSONPostRequest("mediaId=nonexistent&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("media file not found")) + }) + + It("returns error when media file retrieval fails", func() { + mockMFRepo.SetError(true) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error retrieving media file")) + }) + + It("returns error when body is empty", func() { + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when body contains invalid JSON", func() { + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid protocol in direct play profile", func() { + body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid protocol")) + }) + + It("returns error for invalid comparison operator", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid comparison")) + }) + + It("returns error for invalid limitation name", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid limitation name")) + }) + + It("returns error for invalid codec profile type", func() { + body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid codec profile type")) + }) + + It("rejects wrong-case protocol", func() { + body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid protocol")) + }) + + It("rejects wrong-case codec profile type", func() { + body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid codec profile type")) + }) + + It("rejects wrong-case comparison operator", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid comparison")) + }) + + It("rejects wrong-case limitation name", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid limitation name")) + }) + + It("returns a valid decision response", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &transcode.Decision{ + MediaID: "song-1", + CanDirectPlay: true, + SourceStream: transcode.StreamDetails{ + Container: "mp3", Codec: "mp3", Bitrate: 320, + SampleRate: 44100, Channels: 2, + }, + } + mockTD.token = "test-jwt-token" + + body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + resp, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token")) + Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http")) + Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3")) + Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000))) + }) + + It("includes transcode stream when transcoding", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, + }) + mockTD.decision = &transcode.Decision{ + MediaID: "song-2", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TranscodeReasons: []string{"container not supported"}, + SourceStream: transcode.StreamDetails{ + Container: "flac", Codec: "flac", Bitrate: 1000, + SampleRate: 96000, BitDepth: 24, Channels: 2, + }, + TranscodeStream: &transcode.StreamDetails{ + Container: "mp3", Codec: "mp3", Bitrate: 256, + SampleRate: 96000, Channels: 2, + }, + } + mockTD.token = "transcode-token" + + r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}") + resp, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported")) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + }) + }) + + Describe("GetTranscodeStream", func() { + It("returns 400 when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "transcodeParams=abc") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when transcodeParams is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 410 for invalid or mismatched token", func() { + mockTD.resolveErr = transcode.ErrTokenInvalid + r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 404 when media file not found", func() { + mockTD.resolveErr = transcode.ErrMediaNotFound + r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when media file has changed (stale token)", func() { + mockTD.resolveErr = transcode.ErrTokenStale + r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("builds correct StreamRequest for direct play", func() { + fakeStreamer := &fakeMediaStreamer{} + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + mockTD.resolvedReq = transcode.StreamRequest{ID: "song-1"} + mockTD.resolvedMF = &model.MediaFile{ID: "song-1"} + + r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token") + _, _ = router.GetTranscodeStream(w, r) + + Expect(fakeStreamer.captured).ToNot(BeNil()) + Expect(fakeStreamer.captured.ID).To(Equal("song-1")) + Expect(fakeStreamer.captured.Format).To(BeEmpty()) + Expect(fakeStreamer.captured.BitRate).To(BeZero()) + Expect(fakeStreamer.captured.SampleRate).To(BeZero()) + Expect(fakeStreamer.captured.BitDepth).To(BeZero()) + Expect(fakeStreamer.captured.Channels).To(BeZero()) + }) + + It("builds correct StreamRequest for transcoding", func() { + fakeStreamer := &fakeMediaStreamer{} + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + mockTD.resolvedReq = transcode.StreamRequest{ + ID: "song-2", + Format: "mp3", + BitRate: 256, + SampleRate: 44100, + BitDepth: 16, + Channels: 2, + } + mockTD.resolvedMF = &model.MediaFile{ID: "song-2"} + + r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10") + _, _ = router.GetTranscodeStream(w, r) + + Expect(fakeStreamer.captured).ToNot(BeNil()) + Expect(fakeStreamer.captured.ID).To(Equal("song-2")) + Expect(fakeStreamer.captured.Format).To(Equal("mp3")) + Expect(fakeStreamer.captured.BitRate).To(Equal(256)) + Expect(fakeStreamer.captured.SampleRate).To(Equal(44100)) + Expect(fakeStreamer.captured.BitDepth).To(Equal(16)) + Expect(fakeStreamer.captured.Channels).To(Equal(2)) + Expect(fakeStreamer.captured.Offset).To(Equal(10)) + }) + }) + + Describe("bpsToKbps", func() { + It("converts standard bitrates", func() { + Expect(bpsToKbps(128000)).To(Equal(128)) + Expect(bpsToKbps(320000)).To(Equal(320)) + Expect(bpsToKbps(256000)).To(Equal(256)) + }) + It("returns 0 for 0", func() { + Expect(bpsToKbps(0)).To(Equal(0)) + }) + It("rounds instead of truncating", func() { + Expect(bpsToKbps(999)).To(Equal(1)) + Expect(bpsToKbps(500)).To(Equal(1)) + Expect(bpsToKbps(499)).To(Equal(0)) + }) + It("returns 0 for negative values", func() { + Expect(bpsToKbps(-1)).To(Equal(0)) + Expect(bpsToKbps(-1000)).To(Equal(0)) + Expect(bpsToKbps(-1000000)).To(Equal(0)) + }) + }) + + Describe("kbpsToBps", func() { + It("converts standard bitrates", func() { + Expect(kbpsToBps(128)).To(Equal(128000)) + Expect(kbpsToBps(320)).To(Equal(320000)) + }) + It("returns 0 for 0", func() { + Expect(kbpsToBps(0)).To(Equal(0)) + }) + }) + + Describe("convertBitrateValues", func() { + It("converts valid bps strings to kbps", func() { + Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"})) + }) + It("preserves unparseable values", func() { + Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"})) + }) + It("handles empty slice", func() { + Expect(convertBitrateValues([]string{})).To(Equal([]string{})) + }) + }) +}) + +// newJSONPostRequest creates an HTTP POST request with JSON body and query params +func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { + r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody)) + r.Header.Set("Content-Type", "application/json") + return r +} + +// mockTranscodeDecision is a test double for transcode.Decider +type mockTranscodeDecision struct { + decision *transcode.Decision + token string + tokenErr error + resolvedReq transcode.StreamRequest + resolvedMF *model.MediaFile + resolveErr error +} + +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo, _ transcode.DecisionOptions) (*transcode.Decision, error) { + if m.decision != nil { + return m.decision, nil + } + return &transcode.Decision{}, nil +} + +func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest { + return transcode.StreamRequest{Format: "raw"} +} + +func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) { + return m.token, m.tokenErr +} + +func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ string, offset int) (transcode.StreamRequest, *model.MediaFile, error) { + if m.resolveErr != nil { + return transcode.StreamRequest{}, nil, m.resolveErr + } + req := m.resolvedReq + req.Offset = offset + return req, m.resolvedMF, nil +} + +// fakeMediaStreamer captures the StreamRequest and returns a sentinel error, +// allowing tests to verify parameter passing without constructing a real Stream. +var errStreamCaptured = errors.New("stream request captured") + +type fakeMediaStreamer struct { + captured *transcode.StreamRequest +} + +func (f *fakeMediaStreamer) NewStream(_ context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { + f.captured = &req + return nil, errStreamCaptured +} + +func (f *fakeMediaStreamer) DoStream(_ context.Context, _ *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { + f.captured = &req + return nil, errStreamCaptured +} diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index a792ae9d3..a35defeae 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "sync/atomic" + + "github.com/navidrome/navidrome/core/ffmpeg" ) func NewMockFFmpeg(data string) *MockFFmpeg { @@ -14,16 +16,17 @@ func NewMockFFmpeg(data string) *MockFFmpeg { type MockFFmpeg struct { io.Reader - lock sync.Mutex - closed atomic.Bool - Error error + lock sync.Mutex + closed atomic.Bool + Error error + ProbeAudioResult *ffmpeg.AudioProbeResult } func (ff *MockFFmpeg) IsAvailable() bool { return true } -func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) { +func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) { if ff.Error != nil { return nil, ff.Error } @@ -43,6 +46,13 @@ func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { } return "", nil } +func (ff *MockFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + if ff.Error != nil { + return nil, ff.Error + } + return ff.ProbeAudioResult, nil +} + func (ff *MockFFmpeg) CmdPath() (string, error) { if ff.Error != nil { return "", ff.Error diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 1d4527c88..01eacae30 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -109,6 +109,17 @@ func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { return nil } +func (m *MockMediaFileRepo) UpdateProbeData(id string, data string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[id]; ok { + d.ProbeData = data + return nil + } + return model.ErrNotFound +} + func (m *MockMediaFileRepo) Delete(id string) error { if m.Err { return errors.New("error") diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 12db0d7be..796e84111 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -18,6 +18,10 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil + case "flac": + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + case "aac": + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound } From 928741ef253645f61c81a7cccdaeb2e1eb18adad Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 08:06:06 -0400 Subject: [PATCH 063/596] fix(db): recreate probe_data column as NOT NULL with empty string default The probe_data column was added with DEFAULT NULL in migration 20260307175815, which causes sql.Scan errors when reading into Go string fields. This migration drops and recreates the column with DEFAULT '' NOT NULL to prevent NULL scan errors. --- .../20260309120007_fix_probe_data_null.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 db/migrations/20260309120007_fix_probe_data_null.go diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go new file mode 100644 index 000000000..a7e7366ed --- /dev/null +++ b/db/migrations/20260309120007_fix_probe_data_null.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull) +} + +func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error { + // Recreate probe_data column as NOT NULL with empty string default. + // The previous migration created it with DEFAULT NULL, which causes + // scan errors when reading into Go string fields. + _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) + return err +} + +func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error { + return nil +} From 7c5aa1fafaae0b4cc3af7fe1958ad9e1cc9bf943 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 09:43:55 -0400 Subject: [PATCH 064/596] test(e2e): add transcode endpoint e2e tests and clean up test helpers Add comprehensive e2e tests for getTranscodeDecision and getTranscodeStream endpoints covering direct play, transcoding, error handling, and round-trip token validation. Refactor buildPostReq to reuse buildReq for auth params, remove unused WAV/AAC test tracks, and consolidate duplicate test assertions. --- core/storage/storagetest/fake_storage.go | 3 + server/e2e/e2e_suite_test.go | 57 +++ server/e2e/subsonic_album_lists_test.go | 24 +- server/e2e/subsonic_browsing_test.go | 2 +- server/e2e/subsonic_multilibrary_test.go | 2 +- server/e2e/subsonic_searching_test.go | 6 +- server/e2e/subsonic_transcode_test.go | 464 +++++++++++++++++++++++ 7 files changed, 542 insertions(+), 16 deletions(-) create mode 100644 server/e2e/subsonic_transcode_test.go diff --git a/core/storage/storagetest/fake_storage.go b/core/storage/storagetest/fake_storage.go index 79ed3193d..1b0d1a6c1 100644 --- a/core/storage/storagetest/fake_storage.go +++ b/core/storage/storagetest/fake_storage.go @@ -284,6 +284,9 @@ func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) { p.AudioProperties.BitDepth = getInt("bitdepth") p.AudioProperties.SampleRate = getInt("samplerate") p.AudioProperties.Channels = getInt("channels") + if codec, ok := data["codec"].(string); ok { + p.AudioProperties.Codec = codec + } for k, v := range data { p.Tags[k] = []string{fmt.Sprintf("%v", v)} } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 02b66f4b7..e55130ffd 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -1,6 +1,7 @@ package e2e import ( + "bytes" "context" "encoding/json" "errors" @@ -55,6 +56,7 @@ type _t = map[string]any var template = storagetest.Template var track = storagetest.Track +var file = storagetest.File // MusicBrainz ID constants for test data (valid UUID v4 values) const ( @@ -122,6 +124,9 @@ func buildTestFS() storagetest.FakeFS { popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"}) + // Template for diverse-format transcode test tracks + tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), @@ -140,6 +145,33 @@ func buildTestFS() storagetest.FakeFS { "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), // CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests) "CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")), + + // Diverse audio format tracks for transcode e2e tests + "Test/Transcode Formats/01 - TC FLAC Standard.flac": file(tcBase, _t{ + "title": "TC FLAC Standard", "track": 1, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(240), + }), + "Test/Transcode Formats/02 - TC FLAC HiRes.flac": file(tcBase, _t{ + "title": "TC FLAC HiRes", "track": 2, "suffix": "flac", + "bitrate": 3000, "samplerate": 96000, "bitdepth": 24, "channels": 2, "duration": int64(180), + }), + "Test/Transcode Formats/03 - TC ALAC Track.m4a": file(tcBase, _t{ + "title": "TC ALAC Track", "track": 3, "suffix": "m4a", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(200), + }), + "Test/Transcode Formats/04 - TC DSD Track.dsf": file(tcBase, _t{ + "title": "TC DSD Track", "track": 4, "suffix": "dsf", + "bitrate": 5645, "samplerate": 2822400, "bitdepth": 1, "channels": 2, "duration": int64(300), + }), + "Test/Transcode Formats/05 - TC Opus Track.opus": file(tcBase, _t{ + "title": "TC Opus Track", "track": 5, "suffix": "opus", + "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(210), + }), + "Test/Transcode Formats/06 - TC MKA Opus.mka": file(tcBase, _t{ + "title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus", + "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220), + }), + // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -207,6 +239,30 @@ func buildReq(user model.User, endpoint string, params ...string) *http.Request return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil) } +// buildPostReq creates a POST request with a JSON body and Subsonic auth params in the query string. +func buildPostReq(user model.User, endpoint string, body string, params ...string) *http.Request { + getReq := buildReq(user, endpoint, params...) + r := httptest.NewRequest("POST", getReq.URL.RequestURI(), bytes.NewReader([]byte(body))) + r.Header.Set("Content-Type", "application/json") + return r +} + +// doPostReq makes a POST round-trip as admin and returns the parsed Subsonic response. +func doPostReq(endpoint string, body string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildPostReq(adminUser, endpoint, body, params...) + router.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doRawPostReq makes a POST round-trip as admin and returns the raw recorder. +func doRawPostReq(endpoint string, body string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildPostReq(adminUser, endpoint, body, params...) + router.ServeHTTP(w, r) + return w +} + // parseJSONResponse parses the JSON response body into a Subsonic response struct. func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { Expect(w.Code).To(Equal(http.StatusOK)) @@ -411,6 +467,7 @@ func setupTestDB() { }) conf.Server.MusicFolder = "fake:///music" conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) restoreDB() diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index d3d24a7c5..d41d17dbc 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(6)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,14 +27,15 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(6)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop + Expect(albums).To(HaveLen(7)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) Expect(albums[5].Title).To(Equal("Pop")) + Expect(albums[6].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -42,22 +43,23 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(6)) + Expect(albums).To(HaveLen(7)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various, then CJK: シートベルツ + // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) Expect(albums[3].Artist).To(Equal("Miles Davis")) - Expect(albums[4].Artist).To(Equal("Various")) - Expect(albums[5].Artist).To(Equal("シートベルツ")) + Expect(albums[4].Artist).To(Equal("Test Artist")) + Expect(albums[5].Artist).To(Equal("Various")) + Expect(albums[6].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(6)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) }) It("type=byGenre filters by genre parameter", func() { @@ -188,7 +190,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(6)) + Expect(albums).To(HaveLen(7)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -199,7 +201,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(6)) + Expect(resp.AlbumList2.Album).To(HaveLen(7)) }) }) @@ -244,7 +246,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.RandomSongs).ToNot(BeNil()) Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) - Expect(resp.RandomSongs.Songs).To(HaveLen(7)) + Expect(resp.RandomSongs.Songs).To(HaveLen(10)) }) It("respects size parameter", func() { diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go index 5a2da8737..55aeb8e9e 100644 --- a/server/e2e/subsonic_browsing_test.go +++ b/server/e2e/subsonic_browsing_test.go @@ -288,7 +288,7 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Genres).ToNot(BeNil()) - Expect(resp.Genres.Genre).To(HaveLen(3)) + Expect(resp.Genres.Genre).To(HaveLen(4)) }) It("includes correct genre names", func() { diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index aa4a0c626..f59187d00 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -141,7 +141,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(6)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index bfcbbc8ee..7f6aaf57a 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(5)) - Expect(resp.SearchResult3.Album).To(HaveLen(6)) - Expect(resp.SearchResult3.Song).To(HaveLen(7)) + Expect(resp.SearchResult3.Artist).To(HaveLen(6)) + Expect(resp.SearchResult3.Album).To(HaveLen(7)) + Expect(resp.SearchResult3.Song).To(HaveLen(13)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go new file mode 100644 index 000000000..16a884bf3 --- /dev/null +++ b/server/e2e/subsonic_transcode_test.go @@ -0,0 +1,464 @@ +package e2e + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Client profile JSON bodies for getTranscodeDecision requests. +// All bitrate values are in bps (per OpenSubsonic spec). +const ( + // mp3OnlyClient can direct-play mp3 and transcode to mp3 + mp3OnlyClient = `{ + "name": "test-mp3-only", + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} + ] + }` + + // flacAndMp3Client can direct-play flac and mp3, transcode to mp3 + flacAndMp3Client = `{ + "name": "test-flac-mp3", + "directPlayProfiles": [ + {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}, + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} + ] + }` + + // universalClient can direct-play most formats + universalClient = `{ + "name": "test-universal", + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}, + {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}, + {"containers": ["m4a"], "audioCodecs": ["alac", "aac"], "protocols": ["http"]}, + {"containers": ["opus", "ogg"], "audioCodecs": ["opus"], "protocols": ["http"]}, + {"containers": ["wav"], "audioCodecs": ["pcm"], "protocols": ["http"]}, + {"containers": ["dsf"], "audioCodecs": ["dsd"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} + ] + }` + + // bitrateCapClient has maxAudioBitrate set to 320000 bps (320 kbps) + bitrateCapClient = `{ + "name": "test-bitrate-cap", + "maxAudioBitrate": 320000, + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}, + {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} + ] + }` + + // opusTranscodeClient can direct-play mp3, transcode to opus + opusTranscodeClient = `{ + "name": "test-opus-transcode", + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "opus", "audioCodec": "opus", "protocol": "http"} + ] + }` + + // flacOnlyClient can direct-play flac, transcode to flac (no mp3 support at all) + flacOnlyClient = `{ + "name": "test-flac-only", + "directPlayProfiles": [ + {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "flac", "audioCodec": "flac", "protocol": "http"} + ] + }` + + // maxTranscodeBitrateClient has maxTranscodingAudioBitrate set + maxTranscodeBitrateClient = `{ + "name": "test-max-transcode-bitrate", + "maxTranscodingAudioBitrate": 192000, + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} + ] + }` + + // dsdToFlacClient can direct-play mp3, transcode to flac + dsdToFlacClient = `{ + "name": "test-dsd-to-flac", + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} + ], + "transcodingProfiles": [ + {"container": "flac", "audioCodec": "flac", "protocol": "http"} + ] + }` +) + +var _ = Describe("Transcode Endpoints", Ordered, func() { + // Track IDs resolved in BeforeAll + var ( + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps) + alacTrackID string // TC ALAC Track (m4a, alac) + dsdTrackID string // TC DSD Track (dsf, dsd) + opusTrackID string // TC Opus Track (opus, 128kbps) + mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag) + ) + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + byTitle := map[string]string{} + for _, s := range songs { + byTitle[s.Title] = s.ID + } + ensureGetTrackID := func(title string) string { + id := byTitle[title] + Expect(id).ToNot(BeEmpty()) + return id + } + mp3TrackID = ensureGetTrackID("Come Together") + flacTrackID = ensureGetTrackID("TC FLAC Standard") + flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes") + alacTrackID = ensureGetTrackID("TC ALAC Track") + dsdTrackID = ensureGetTrackID("TC DSD Track") + opusTrackID = ensureGetTrackID("TC Opus Track") + mkaOpusTrackID = ensureGetTrackID("TC MKA Opus") + }) + + Describe("getTranscodeDecision", func() { + Describe("error cases", func() { + It("returns 405 for GET request", func() { + w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song") + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + }) + + It("returns error when mediaId is missing", func() { + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) + }) + + It("returns error when mediaType is missing", func() { + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) + }) + + It("returns error for unsupported mediaType", func() { + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "video") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns error for invalid JSON body", func() { + resp := doPostReq("getTranscodeDecision", "{invalid-json", "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns error for empty JSON body", func() { + w := doRawPostReq("getTranscodeDecision", "", "mediaId", mp3TrackID, "mediaType", "song") + Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 with error status + resp := parseJSONResponse(w) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for non-existent media ID", func() { + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", "non-existent-id", "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound)) + }) + + It("returns error for invalid protocol in body", func() { + invalidBody := `{ + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["invalid-protocol"]} + ] + }` + resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns error for invalid comparison operator in body", func() { + invalidBody := `{ + "directPlayProfiles": [ + {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} + ], + "codecProfiles": [{ + "type": "AudioCodec", "name": "mp3", + "limitations": [{"name": "audioBitrate", "comparison": "InvalidOp", "values": ["320000"]}] + }] + }` + resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("direct play decisions", func() { + It("allows MP3 direct play when client supports mp3", func() { + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).To(BeNil()) + Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty()) + }) + + It("allows FLAC direct play when client supports flac", func() { + resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + }) + + It("allows ALAC direct play via m4a container + alac codec matching", func() { + resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", alacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + }) + + It("allows Opus direct play when client supports opus", func() { + resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", opusTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + }) + + It("denies direct play when container mismatches", func() { + // mp3OnlyClient cannot play FLAC container + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + }) + + It("denies direct play when codec mismatches", func() { + // MKA container with opus codec — client only supports mp3 + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mkaOpusTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + }) + + It("denies direct play when maxAudioBitrate exceeded", func() { + // bitrateCapClient caps at 320kbps, FLAC is 900kbps + resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + }) + }) + + Describe("transcode decisions", func() { + It("transcodes FLAC to MP3 when client only supports MP3", func() { + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3")) + Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty()) + }) + + It("transcodes FLAC hi-res to Opus with correct sample rate", func() { + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacHiResTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + // Opus always outputs 48000 Hz + Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(48000))) + }) + + It("transcodes DSD to FLAC with normalized sample rate and bit depth", func() { + resp := doPostReq("getTranscodeDecision", dsdToFlacClient, "mediaId", dsdTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("flac")) + // DSD sample rate normalized: 2822400 / 8 = 352800 + Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(352800))) + // DSD 1-bit → 24-bit PCM + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitdepth).To(Equal(int32(24))) + }) + + It("refuses lossy to lossless transcoding: MP3 to FLAC", func() { + // flacOnlyClient can't direct-play mp3, and lossy→lossless transcode is rejected + resp := doPostReq("getTranscodeDecision", flacOnlyClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + // MP3 is lossy, FLAC is lossless — should not allow transcoding + Expect(resp.TranscodeDecision.CanTranscode).To(BeFalse()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + Expect(resp.TranscodeDecision.TranscodeParams).To(BeEmpty()) + }) + + It("caps transcode bitrate via maxTranscodingAudioBitrate", func() { + resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) + }) + }) + + Describe("response structure", func() { + It("has correct sourceStream details", func() { + resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + src := resp.TranscodeDecision.SourceStream + Expect(src).ToNot(BeNil()) + Expect(src.Container).To(Equal("flac")) + Expect(src.Codec).To(Equal("flac")) + // AudioBitrate is in bps: 900 kbps * 1000 = 900000 bps + Expect(src.AudioBitrate).To(Equal(int32(900000))) + Expect(src.AudioSamplerate).To(Equal(int32(44100))) + Expect(src.AudioChannels).To(Equal(int32(2))) + Expect(src.Protocol).To(Equal("http")) + }) + + It("reports audioBitrate in bps (kbps * 1000)", func() { + resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + src := resp.TranscodeDecision.SourceStream + Expect(src).ToNot(BeNil()) + // MP3 is 320 kbps → 320000 bps + Expect(src.AudioBitrate).To(Equal(int32(320000))) + }) + }) + }) + + Describe("getTranscodeStream", func() { + Describe("error cases", func() { + It("returns 400 when mediaId is missing", func() { + w := doRawReq("getTranscodeStream", "mediaType", "song", "transcodeParams", "some-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when mediaType is missing", func() { + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "transcodeParams", "some-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when transcodeParams is missing", func() { + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 for unsupported mediaType", func() { + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "video", "transcodeParams", "some-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 410 for malformed token", func() { + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", "invalid-token") + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 410 for stale token (media file updated after token issued)", func() { + // Get a valid decision token + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Save original UpdatedAt and restore after test + mf, err := ds.MediaFile(ctx).Get(mp3TrackID) + Expect(err).ToNot(HaveOccurred()) + originalUpdatedAt := mf.UpdatedAt + + // Update the media file's UpdatedAt to simulate a change after token issuance + mf.UpdatedAt = time.Now().Add(time.Hour) + Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) + + // Attempt to stream with the now-stale token + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusGone)) + + // Restore original UpdatedAt + mf.UpdatedAt = originalUpdatedAt + Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) + }) + }) + + Describe("round-trip: decision then stream", func() { + It("streams direct play for MP3", func() { + // Get decision + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Stream using the token + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusOK)) + // Direct play: format should be "raw" or empty + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams transcoded FLAC to MP3", func() { + // Get decision + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Stream using the token + w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + }) + + It("passes offset through to stream request", func() { + // Get decision + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Stream with offset + w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", + "transcodeParams", token, "offset", "30") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Offset).To(Equal(30)) + }) + }) + }) +}) From a25306f2c14f97e9f4e178bfb47f4532d03571ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Mar 2026 10:52:13 -0400 Subject: [PATCH 065/596] fix(artwork): search parent folders for album cover art in multi-disc layouts (#5157) * fix(artwork): search parent folders for album cover art in multi-disc layouts When albums have tracks in subdirectories (e.g., CD1/, CD2/), Navidrome only searched those subdirectories for cover images. This meant cover art placed in the album's root folder (e.g., "Artist/Album/cover.jpg") was not found. Now loadAlbumFoldersPaths also queries parent folders of the album's media folders, so cover art in the album root is discovered. * fix(artwork): simplify parent folder detection for album cover art lookup Signed-off-by: Deluan * fix(album): propagate non-ErrNotFound errors from parent folder lookup Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/artwork/reader_album.go | 42 +++++++ core/artwork/reader_album_test.go | 177 +++++++++++++++++++++++++++++ core/artwork/reader_artist_test.go | 18 ++- 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 9fc9262cb..36b2fff05 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "crypto/md5" + "errors" "fmt" "io" "path/filepath" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -103,6 +105,28 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo if err != nil { return nil, nil, nil, err } + + folderIDSet := make(map[string]bool, len(folderIDs)) + for _, id := range folderIDs { + folderIDSet[id] = true + } + + // For multi-disc albums (2+ folders), check if all folders share a common parent + // that is not already included. This finds cover art in the album root folder + // (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/"). + // We skip single-folder albums to avoid pulling images from the artist folder. + if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { + parentFolder, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + } else if err != nil { + return nil, nil, nil, err + } + if parentFolder != nil { + folders = append(folders, *parentFolder) + } + } + var paths []string var imgFiles []string var updatedAt time.Time @@ -125,6 +149,24 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +// commonParentFolder returns the shared parent folder ID when all folders have the +// same parent and that parent is not already in folderIDSet. Returns "" otherwise. +func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { + if len(folders) < 2 { + return "" + } + parentID := folders[0].ParentID + if parentID == "" || folderIDSet[parentID] { + return "" + } + for _, f := range folders[1:] { + if f.ParentID != parentID { + return "" + } + } + return parentID +} + // compareImageFiles compares two image file paths for sorting. // It extracts the base filename (without extension) and compares case-insensitively. // This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index fd5f8a2be..a8a0eae3e 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "path/filepath" "time" @@ -116,5 +117,181 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) }) + + It("includes images from parent folder for multi-disc albums", func() { + // Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/ + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "CD1", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + { + ID: "folder2", + Path: "Artist/Album", + Name: "CD2", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "parentFolder", + Path: "Artist", + Name: "Album", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg", "back.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(2)) + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg"))) + Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + }) + + It("does not query parent when parent ID is already in album folders", func() { + // When the parent folder is already one of the album's folders, skip it + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "folder2", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "", + Name: "Artist", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + // Get should not have been called (parent already in folder set) + Expect(repo.getCallCount).To(Equal(0)) + }) + + It("does not query parent when folders have different parents", func() { + // When album folders span different parents, don't search any parent + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist1/Album", + Name: "part1", + ParentID: "parentA", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "Artist2/Album", + Name: "part2", + ParentID: "parentB", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg"))) + // Get should not have been called (different parents) + Expect(repo.getCallCount).To(Equal(0)) + }) + + It("does not query parent for single-folder albums", func() { + // A single-folder album's parent is typically the artist folder, + // which should not be searched for cover art + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + // Get should not have been called (single folder, no parent lookup) + Expect(repo.getCallCount).To(Equal(0)) + }) + + It("propagates non-ErrNotFound errors from parent folder lookup", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "CD1", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "Artist/Album", + Name: "CD2", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.getErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("continues gracefully when parent folder is not found", func() { + // Parent folder may have been deleted; should log a warning and continue + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "CD1", + ParentID: "missingParent", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "Artist/Album", + Name: "CD2", + ParentID: "missingParent", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + // parentResult is nil, so Get will return ErrNotFound + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg"))) + Expect(repo.getCallCount).To(Equal(1)) + }) }) }) diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index e6a0168f8..4aa71c9ca 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -417,14 +417,28 @@ var _ = Describe("artistArtworkReader", func() { type fakeFolderRepo struct { model.FolderRepository - result []model.Folder - err error + result []model.Folder + parentResult *model.Folder + getErr error + getCallCount int + err error } func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, f.err } +func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { + f.getCallCount++ + if f.getErr != nil { + return nil, f.getErr + } + if f.parentResult != nil { + return f.parentResult, nil + } + return nil, model.ErrNotFound +} + type fakeDataStore struct { model.DataStore folderRepo *fakeFolderRepo From 957130ca386bb88f9a5c23785cd07629851cfb0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Mar 2026 11:06:31 -0400 Subject: [PATCH 066/596] feat(ui): integrate transcode decision into web player (#5155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add browser audio profile detection for transcoding Detect browser codec capabilities via canPlayType() to build a client profile for the getTranscodeDecision API. Only codecs returning "probably" are treated as supported for conservative compatibility. * feat(ui): add transcode decision service with caching and pre-fetch Standalone service that fetches getTranscodeDecision results, caches them with an 11-hour TTL (1h buffer before 12h token expiry), and supports bulk pre-fetching for upcoming queue items. Includes invalidateAll() for handling stale tokens and getCachedDecision() for synchronous cache reads. * feat(ui): add fetch helper for getTranscodeDecision endpoint POST-based Subsonic API call that sends the browser's codec profile and returns the transcode decision including the JWT transcodeParams token for subsequent streaming. * feat(ui): wire transcode decision service singleton Module index that creates the service singleton with the real fetch function and re-exports the browser profile detector. * feat(ui): add Redux transcoding reducer for browser profile state Store the detected browser codec profile in Redux so it's available globally. The profile is set once at startup and used by the decision service when calling getTranscodeDecision. * feat(ui): integrate transcode decision into player musicSrc Replace static stream URLs with lazy musicSrc functions that fetch a transcode decision before playback. Falls back to the old stream endpoint if the decision fetch fails or if no browser profile is set. * feat(ui): detect browser profile and pre-fetch transcode decisions Run codec detection once when the Player mounts, storing the profile in both the decision service and Redux. Pre-fetch decisions for the next 3 songs when the queue or play position changes. * feat(ui): handle stale tokens and replace audio preload with decision pre-fetch On audio playback error, invalidate all cached transcode decisions and pre-fetch fresh decisions for upcoming songs. Replace the old Audio element preload with decision pre-fetching to warm the cache for instant playback transitions. * feat(ui): show transcode format in QualityInfo chip When transcode decision data is available, QualityInfo now shows "FLAC → OPUS 128" instead of just the source format. The new props are optional, so existing usages in song lists, album songs, playlists, and shares are unaffected. * feat(ui): display transcode status in player quality badge AudioTitle now reads the cached transcode decision for the current track and passes it to QualityInfo, showing "FLAC → OPUS 128" when transcoding or the normal format when direct playing. * chore(ui): format and lint transcode decision integration * refactor(ui): use JWT exp claim for decision cache expiry Replace the hardcoded 11-hour TTL with actual token expiration decoded from the JWT's exp claim. Each cache entry is now validated against its own token's lifetime, adapting automatically to server configuration changes. Tokens without an exp claim are treated as expired and re-fetched immediately. * fix(ui): resolve transcode URLs eagerly on browser refresh Instead of setting musicSrc to a function on queue refresh (which breaks the player's identity matching and can't survive JSON serialization), resolve transcode decisions for the current and next few tracks before dispatching, passing string URLs to the reducer. Also simplifies code: extract makeMusicSrc helper, add resolveStreamUrl to decisionService, use httpClient instead of raw fetch, and remove barrel file test. * chore(ui): fix prettier formatting in Player.jsx * fix(ui): use ref to avoid stale closure in mount-only transcode effect Split the mount effect into profile detection + URL resolution, using a ref for playerState so the effect correctly reads the latest queue without needing playerState in the dependency array (which would cause it to re-run on every queue/position change). * fix(ui): address code review feedback on transcode integration - Use jwt-decode for JWT parsing instead of manual atob (handles base64url) - Guard resolveStreamUrl to fall back to direct stream when decision is null - Fix savedPlayIndex -1 bug in PLAYER_REFRESH_QUEUE (findIndex returns -1) * docs: improve comments on JWT exp claim decoding in decision service Signed-off-by: Deluan --------- Signed-off-by: Deluan --- ui/src/App.jsx | 2 + ui/src/actions/player.js | 12 + ui/src/audioplayer/AudioTitle.jsx | 10 + ui/src/audioplayer/Player.jsx | 93 +++++++- ui/src/common/QualityInfo.jsx | 26 ++- ui/src/common/QualityInfo.test.jsx | 26 +++ ui/src/reducers/index.js | 1 + ui/src/reducers/playerReducer.js | 28 ++- ui/src/reducers/playerReducer.test.js | 52 +++++ ui/src/reducers/transcodingReducer.js | 14 ++ ui/src/reducers/transcodingReducer.test.js | 23 ++ ui/src/transcode/browserProfile.js | 40 ++++ ui/src/transcode/browserProfile.test.js | 76 ++++++ ui/src/transcode/decisionService.js | 111 +++++++++ ui/src/transcode/decisionService.test.js | 256 +++++++++++++++++++++ ui/src/transcode/fetchDecision.js | 23 ++ ui/src/transcode/fetchDecision.test.js | 92 ++++++++ ui/src/transcode/index.js | 5 + 18 files changed, 883 insertions(+), 7 deletions(-) create mode 100644 ui/src/reducers/playerReducer.test.js create mode 100644 ui/src/reducers/transcodingReducer.js create mode 100644 ui/src/reducers/transcodingReducer.test.js create mode 100644 ui/src/transcode/browserProfile.js create mode 100644 ui/src/transcode/browserProfile.test.js create mode 100644 ui/src/transcode/decisionService.js create mode 100644 ui/src/transcode/decisionService.test.js create mode 100644 ui/src/transcode/fetchDecision.js create mode 100644 ui/src/transcode/fetchDecision.test.js create mode 100644 ui/src/transcode/index.js diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 2dbe72421..35eaee3eb 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -33,6 +33,7 @@ import { replayGainReducer, downloadMenuDialogReducer, shareDialogReducer, + transcodingReducer, } from './reducers' import createAdminStore from './store/createAdminStore' import { i18nProvider } from './i18n' @@ -72,6 +73,7 @@ const adminStore = createAdminStore({ activity: activityReducer, settings: settingsReducer, replayGain: replayGainReducer, + transcoding: transcodingReducer, }, }) diff --git a/ui/src/actions/player.js b/ui/src/actions/player.js index acef2e9b2..9056abeb6 100644 --- a/ui/src/actions/player.js +++ b/ui/src/actions/player.js @@ -7,6 +7,8 @@ export const PLAYER_PLAY_TRACKS = 'PLAYER_PLAY_TRACKS' export const PLAYER_CURRENT = 'PLAYER_CURRENT' export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME' export const PLAYER_SET_MODE = 'PLAYER_SET_MODE' +export const TRANSCODING_SET_PROFILE = 'TRANSCODING_SET_PROFILE' +export const PLAYER_REFRESH_QUEUE = 'PLAYER_REFRESH_QUEUE' export const setTrack = (data) => ({ type: PLAYER_SET_TRACK, @@ -102,3 +104,13 @@ export const setPlayMode = (mode) => ({ type: PLAYER_SET_MODE, data: { mode }, }) + +export const setTranscodingProfile = (profile) => ({ + type: TRANSCODING_SET_PROFILE, + data: profile, +}) + +export const refreshQueue = (resolvedUrls) => ({ + type: PLAYER_REFRESH_QUEUE, + data: resolvedUrls, +}) diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index 093bb53fb..df37edfbb 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -3,6 +3,7 @@ import { useMediaQuery } from '@material-ui/core' import { Link } from 'react-router-dom' import clsx from 'clsx' import { QualityInfo } from '../common' +import { decisionService } from '../transcode' import useStyle from './styles' import { useDrag } from 'react-dnd' import { DraggableTypes } from '../consts' @@ -35,6 +36,14 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { rgTrackPeak: song.rgTrackPeak, } + const decision = decisionService.getCachedDecision(audioInfo.trackId) + const transcodeProps = decision + ? { + transcodeStream: decision.transcodeStream || null, + isDirectPlay: decision.canDirectPlay, + } + : {} + const subtitle = song.tags?.['subtitle'] const title = song.title + (subtitle ? ` (${subtitle})` : '') @@ -53,6 +62,7 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { record={qi} className={classes.qualityInfo} {...gainInfo} + {...transcodeProps} /> )} diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 7d086172b..eba3b82d7 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -19,7 +19,9 @@ import AudioTitle from './AudioTitle' import { clearQueue, currentPlaying, + refreshQueue, setPlayMode, + setTranscodingProfile, setVolume, syncQueue, } from '../actions' @@ -30,6 +32,7 @@ import locale from './locale' import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' import { calculateGain } from '../utils/calculateReplayGain' +import { detectBrowserProfile, decisionService } from '../transcode' const Player = () => { const theme = useCurrentTheme() @@ -49,6 +52,61 @@ const Player = () => { ) const { authenticated } = useAuthState() + + // Keep a ref to playerState so the mount effect can read the latest value + // without re-triggering on every queue/position change + const playerStateRef = useRef(playerState) + playerStateRef.current = playerState + + // Detect browser codec profile and eagerly resolve transcode URLs for the + // persisted queue once on mount (e.g. after a browser refresh) + useEffect(() => { + const profile = detectBrowserProfile() + decisionService.setProfile(profile) + dispatch(setTranscodingProfile(profile)) + + const state = playerStateRef.current + const currentIdx = state.savedPlayIndex || 0 + const trackIds = state.queue + .slice(currentIdx, currentIdx + 4) + .filter((item) => !item.isRadio && item.trackId) + .map((item) => item.trackId) + + if (trackIds.length === 0) { + dispatch(refreshQueue()) + return + } + + Promise.allSettled( + trackIds.map((id) => + decisionService.resolveStreamUrl(id).then((url) => [id, url]), + ), + ).then((results) => { + const resolvedUrls = {} + results.forEach((r) => { + if (r.status === 'fulfilled') { + resolvedUrls[r.value[0]] = r.value[1] + } + }) + dispatch(refreshQueue(resolvedUrls)) + }) + }, [dispatch]) + + // Pre-fetch transcode decisions for next 2-3 songs when queue or position changes + useEffect(() => { + if (!playerState.queue.length) return + + const currentIdx = playerState.savedPlayIndex || 0 + const nextSongIds = playerState.queue + .slice(currentIdx + 1, currentIdx + 4) + .filter((item) => !item.isRadio) + .map((item) => item.trackId) + + if (nextSongIds.length > 0) { + decisionService.prefetchDecisions(nextSongIds) + } + }, [playerState.queue, playerState.savedPlayIndex]) + const visible = authenticated && playerState.queue.length > 0 const isRadio = playerState.current?.isRadio || false const classes = useStyle({ @@ -151,7 +209,9 @@ const Player = () => { ...defaultOptions, audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, - autoPlay: playerState.clear || playerState.playIndex === 0, + autoPlay: + playerState.autoPlay !== false && + (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, extendsContent: ( @@ -190,9 +250,9 @@ const Player = () => { if (!preloaded) { const next = nextSong() - if (next != null) { - const audio = new Audio() - audio.src = next.musicSrc + if (next != null && !next.isRadio) { + // Trigger decision pre-fetch (this also warms the cache) + decisionService.prefetchDecisions([next.trackId]) } setPreload(true) return @@ -284,6 +344,28 @@ const Player = () => { } }, []) + const onAudioError = useCallback( + (error, currentPlayId, audioLists, audioInfo) => { + // Invalidate all cached decisions — token may be stale + decisionService.invalidateAll() + + // Pre-fetch decisions for upcoming songs with fresh tokens + const currentIdx = playerState.queue.findIndex( + (item) => item.uuid === currentPlayId, + ) + if (currentIdx >= 0) { + const nextSongIds = playerState.queue + .slice(currentIdx + 1, currentIdx + 4) + .filter((item) => !item.isRadio) + .map((item) => item.trackId) + if (nextSongIds.length > 0) { + decisionService.prefetchDecisions(nextSongIds) + } + } + }, + [playerState.queue], + ) + const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { dispatch(clearQueue()) @@ -320,6 +402,7 @@ const Player = () => { onPlayModeChange={(mode) => dispatch(setPlayMode(mode))} onAudioEnded={onAudioEnded} onCoverClick={onCoverClick} + onAudioError={onAudioError} onBeforeDestroy={onBeforeDestroy} getAudioInstance={setAudioInstance} /> diff --git a/ui/src/common/QualityInfo.jsx b/ui/src/common/QualityInfo.jsx index 171f5e0f0..57a8251a4 100644 --- a/ui/src/common/QualityInfo.jsx +++ b/ui/src/common/QualityInfo.jsx @@ -20,7 +20,15 @@ const useStyle = makeStyles( }, ) -export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => { +export const QualityInfo = ({ + record, + size, + gainMode, + preAmp, + className, + transcodeStream, + isDirectPlay, +}) => { const classes = useStyle() let { suffix, bitRate, rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak } = record @@ -34,6 +42,20 @@ export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => { } } + // Show transcode target when transcoding (not direct play) + if (transcodeStream && !isDirectPlay) { + const targetCodec = (transcodeStream.codec || '').toUpperCase() + const targetBitrate = transcodeStream.audioBitrate + ? Math.round(transcodeStream.audioBitrate / 1000) + : 0 + let targetInfo = targetCodec + if (targetBitrate > 0) { + targetInfo += ' ' + targetBitrate + } + const sourceSuffix = suffix || placeholder + info = `${sourceSuffix} → ${targetInfo}` + } + const extra = useMemo(() => { if (gainMode !== 'none') { const gainValue = calculateGain( @@ -63,6 +85,8 @@ QualityInfo.propTypes = { size: PropTypes.string, className: PropTypes.string, gainMode: PropTypes.string, + transcodeStream: PropTypes.object, + isDirectPlay: PropTypes.bool, } QualityInfo.defaultProps = { diff --git a/ui/src/common/QualityInfo.test.jsx b/ui/src/common/QualityInfo.test.jsx index ae1874715..174ee8a85 100644 --- a/ui/src/common/QualityInfo.test.jsx +++ b/ui/src/common/QualityInfo.test.jsx @@ -77,4 +77,30 @@ describe('', () => { ) expect(screen.getByText('FLAC (0.00 dB)')).toBeInTheDocument() }) + + it('shows transcode arrow when transcodeStream is provided', () => { + const info = { suffix: 'FLAC', bitRate: 1008 } + const transcodeStream = { codec: 'opus', audioBitrate: 128000 } + render() + expect(screen.getByText('FLAC → OPUS 128')).toBeInTheDocument() + }) + + it('shows transcode with lossy source including bitrate', () => { + const info = { suffix: 'FLAC', bitRate: 1008 } + const transcodeStream = { codec: 'mp3', audioBitrate: 320000 } + render() + expect(screen.getByText('FLAC → MP3 320')).toBeInTheDocument() + }) + + it('does not show arrow when isDirectPlay is true', () => { + const info = { suffix: 'MP3', bitRate: 320 } + render() + expect(screen.getByText('MP3 320')).toBeInTheDocument() + }) + + it('behaves normally when no transcode props are passed', () => { + const info = { suffix: 'MP3', bitRate: 320 } + render() + expect(screen.getByText('MP3 320')).toBeInTheDocument() + }) }) diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 3db0b1dff..64a0049b7 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -6,3 +6,4 @@ export * from './albumView' export * from './activityReducer' export * from './settingsReducer' export * from './replayGainReducer' +export * from './transcodingReducer' diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 0392736e5..b7086e6c8 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid' import subsonic from '../subsonic' +import { decisionService } from '../transcode' import { PLAYER_ADD_TRACKS, PLAYER_CLEAR_QUEUE, @@ -10,6 +11,7 @@ import { PLAYER_SET_VOLUME, PLAYER_SYNC_QUEUE, PLAYER_SET_MODE, + PLAYER_REFRESH_QUEUE, } from '../actions' import config from '../config' @@ -30,6 +32,14 @@ const pad = (value) => { } } +const makeMusicSrc = (trackId) => + decisionService.getProfile() + ? () => + decisionService + .resolveStreamUrl(trackId) + .catch(() => subsonic.streamUrl(trackId)) + : subsonic.streamUrl(trackId) + const mapToAudioLists = (item) => { // If item comes from a playlist, trackId is mediaFileId const trackId = item.mediaFileId || item.id @@ -76,7 +86,7 @@ const mapToAudioLists = (item) => { lyric: lyricText, singer: item.artist, duration: item.duration, - musicSrc: subsonic.streamUrl(trackId), + musicSrc: makeMusicSrc(trackId), cover: subsonic.getCoverArtUrl( { id: trackId, @@ -210,6 +220,22 @@ export const playerReducer = (previousState = initialState, payload) => { return reduceCurrent(previousState, payload) case PLAYER_SET_MODE: return reduceMode(previousState, payload) + case PLAYER_REFRESH_QUEUE: { + const resolvedUrls = payload.data || {} + return { + ...previousState, + queue: previousState.queue.map((item) => ({ + ...item, + musicSrc: item.isRadio + ? item.musicSrc + : resolvedUrls[item.trackId] || subsonic.streamUrl(item.trackId), + })), + clear: true, + autoPlay: false, + playIndex: + previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0, + } + } default: return previousState } diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js new file mode 100644 index 000000000..9e3b03b1e --- /dev/null +++ b/ui/src/reducers/playerReducer.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { playerReducer } from './playerReducer' +import { PLAYER_REFRESH_QUEUE } from '../actions' + +describe('playerReducer', () => { + describe('PLAYER_REFRESH_QUEUE', () => { + it('clamps negative savedPlayIndex to 0', () => { + const state = { + queue: [ + { trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }, + { trackId: 'song-2', musicSrc: 'old-url', uuid: 'b' }, + ], + savedPlayIndex: -1, + current: {}, + clear: false, + volume: 1, + } + const action = { type: PLAYER_REFRESH_QUEUE, data: {} } + const result = playerReducer(state, action) + expect(result.playIndex).toBe(0) + }) + + it('preserves valid savedPlayIndex', () => { + const state = { + queue: [ + { trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }, + { trackId: 'song-2', musicSrc: 'old-url', uuid: 'b' }, + ], + savedPlayIndex: 1, + current: {}, + clear: false, + volume: 1, + } + const action = { type: PLAYER_REFRESH_QUEUE, data: {} } + const result = playerReducer(state, action) + expect(result.playIndex).toBe(1) + }) + + it('uses savedPlayIndex of 0 correctly', () => { + const state = { + queue: [{ trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }], + savedPlayIndex: 0, + current: {}, + clear: false, + volume: 1, + } + const action = { type: PLAYER_REFRESH_QUEUE, data: {} } + const result = playerReducer(state, action) + expect(result.playIndex).toBe(0) + }) + }) +}) diff --git a/ui/src/reducers/transcodingReducer.js b/ui/src/reducers/transcodingReducer.js new file mode 100644 index 000000000..db7a3708a --- /dev/null +++ b/ui/src/reducers/transcodingReducer.js @@ -0,0 +1,14 @@ +import { TRANSCODING_SET_PROFILE } from '../actions' + +const initialState = { + browserProfile: null, +} + +export const transcodingReducer = (state = initialState, { type, data }) => { + switch (type) { + case TRANSCODING_SET_PROFILE: + return { ...state, browserProfile: data } + default: + return state + } +} diff --git a/ui/src/reducers/transcodingReducer.test.js b/ui/src/reducers/transcodingReducer.test.js new file mode 100644 index 000000000..eb3e7a490 --- /dev/null +++ b/ui/src/reducers/transcodingReducer.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { transcodingReducer } from './transcodingReducer' +import { TRANSCODING_SET_PROFILE } from '../actions' + +describe('transcodingReducer', () => { + const initialState = { browserProfile: null } + + it('returns initial state', () => { + expect(transcodingReducer(undefined, {})).toEqual(initialState) + }) + + it('handles TRANSCODING_SET_PROFILE', () => { + const profile = { + name: 'NavidromeUI', + directPlayProfiles: [{ containers: ['mp3'] }], + } + const state = transcodingReducer(initialState, { + type: TRANSCODING_SET_PROFILE, + data: profile, + }) + expect(state.browserProfile).toEqual(profile) + }) +}) diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js new file mode 100644 index 000000000..4ee114e45 --- /dev/null +++ b/ui/src/transcode/browserProfile.js @@ -0,0 +1,40 @@ +// Each entry: { codec name for the server, container, MIME to probe } +export const CODEC_PROBES = [ + { codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' }, + { codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' }, + { codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' }, + { codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' }, + { codec: 'flac', container: 'flac', mime: 'audio/flac' }, + { codec: 'wav', container: 'wav', mime: 'audio/wav' }, + { codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' }, +] + +// Default transcoding targets — ordered by preference. +// These are attempted if direct play is not possible. +const DEFAULT_TRANSCODING_PROFILES = [ + { container: 'ogg', audioCodec: 'opus', protocol: 'http' }, + { container: 'mp3', audioCodec: 'mp3', protocol: 'http' }, +] + +export function detectBrowserProfile() { + const audio = new Audio() + const directPlayProfiles = [] + + for (const { codec, container, mime } of CODEC_PROBES) { + if (audio.canPlayType(mime) === 'probably') { + directPlayProfiles.push({ + containers: [container], + audioCodecs: [codec], + protocols: ['http'], + }) + } + } + + return { + name: 'NavidromeUI', + platform: navigator.userAgent, + directPlayProfiles, + transcodingProfiles: DEFAULT_TRANSCODING_PROFILES, + codecProfiles: [], + } +} diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js new file mode 100644 index 000000000..9764a0046 --- /dev/null +++ b/ui/src/transcode/browserProfile.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { detectBrowserProfile, CODEC_PROBES } from './browserProfile' + +describe('detectBrowserProfile', () => { + let mockCanPlayType + + beforeEach(() => { + mockCanPlayType = vi.fn() + vi.stubGlobal( + 'Audio', + class { + canPlayType = mockCanPlayType + }, + ) + }) + + it('includes codecs that return "probably"', () => { + mockCanPlayType.mockImplementation((mime) => { + if (mime === 'audio/mpeg') return 'probably' + if (mime === 'audio/ogg; codecs="opus"') return 'probably' + return '' + }) + + const profile = detectBrowserProfile() + + expect(profile.name).toBe('NavidromeUI') + expect(profile.directPlayProfiles.length).toBe(2) + + const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs) + expect(codecs).toContain('mp3') + expect(codecs).toContain('opus') + }) + + it('excludes codecs that return "maybe"', () => { + mockCanPlayType.mockReturnValue('maybe') + + const profile = detectBrowserProfile() + expect(profile.directPlayProfiles).toEqual([]) + }) + + it('excludes codecs that return empty string', () => { + mockCanPlayType.mockReturnValue('') + + const profile = detectBrowserProfile() + expect(profile.directPlayProfiles).toEqual([]) + }) + + it('sets protocol to "http" for all direct play profiles', () => { + mockCanPlayType.mockReturnValue('probably') + + const profile = detectBrowserProfile() + profile.directPlayProfiles.forEach((p) => { + expect(p.protocols).toEqual(['http']) + }) + }) + + it('includes transcoding profiles for common formats', () => { + mockCanPlayType.mockReturnValue('') + + const profile = detectBrowserProfile() + expect(profile.transcodingProfiles.length).toBeGreaterThan(0) + expect(profile.transcodingProfiles[0].protocol).toBe('http') + }) + + it('sets codecProfiles to empty array', () => { + mockCanPlayType.mockReturnValue('probably') + + const profile = detectBrowserProfile() + expect(profile.codecProfiles).toEqual([]) + }) + + it('includes platform info', () => { + const profile = detectBrowserProfile() + expect(typeof profile.platform).toBe('string') + }) +}) diff --git a/ui/src/transcode/decisionService.js b/ui/src/transcode/decisionService.js new file mode 100644 index 000000000..9228cc882 --- /dev/null +++ b/ui/src/transcode/decisionService.js @@ -0,0 +1,111 @@ +import { jwtDecode } from 'jwt-decode' +import subsonic from '../subsonic' +import { baseUrl } from '../utils' + +// Decode the exp claim from a JWT token (no signature verification needed client-side). +// The JWT token is meant to be opaque to the client, we are only allowing ourselves to do +// this here because the UI is tightly integrated with the server; normally we would +// need to rely on the getTranscodeStream returning an error on stale tokens. +export function decodeJwtExp(token) { + try { + if (!token) return null + const payload = jwtDecode(token) + return typeof payload.exp === 'number' ? payload.exp : null + } catch { + return null + } +} + +export function createDecisionService(fetchFn) { + const cache = new Map() + let currentProfile = null + + function isFresh(entry) { + const exp = decodeJwtExp(entry.decision?.transcodeParams) + if (exp == null) return false + // exp is in seconds, Date.now() in milliseconds; 60s buffer avoids mid-request expiry + return Date.now() < (exp - 60) * 1000 + } + + function setProfile(profile) { + currentProfile = profile + } + + function getProfile() { + return currentProfile + } + + async function getDecision(songId, browserProfile) { + const profile = browserProfile || currentProfile + if (!profile) return null + + const cached = cache.get(songId) + if (cached && isFresh(cached)) { + return cached.decision + } + + const decision = await fetchFn(songId, profile) + cache.set(songId, { decision }) + return decision + } + + async function prefetchDecisions(songIds, browserProfile) { + const profile = browserProfile || currentProfile + if (!profile) return + + const uncached = songIds.filter((id) => { + const entry = cache.get(id) + return !entry || !isFresh(entry) + }) + + await Promise.allSettled( + uncached.map(async (id) => { + const decision = await fetchFn(id, profile) + cache.set(id, { decision }) + }), + ) + } + + function invalidateAll() { + cache.clear() + } + + function buildStreamUrl(songId, transcodeParams, offset) { + const params = { + mediaId: songId, + mediaType: 'song', + transcodeParams, + } + if (offset != null && offset > 0) { + params.offset = offset + } + return baseUrl(subsonic.url('getTranscodeStream', null, params)) + } + + async function resolveStreamUrl(songId) { + const decision = await getDecision(songId) + if (!decision?.transcodeParams) { + return baseUrl(subsonic.streamUrl(songId)) + } + return buildStreamUrl(songId, decision.transcodeParams) + } + + function getCachedDecision(songId) { + const entry = cache.get(songId) + if (entry && isFresh(entry)) { + return entry.decision + } + return null + } + + return { + getDecision, + getCachedDecision, + prefetchDecisions, + resolveStreamUrl, + invalidateAll, + buildStreamUrl, + setProfile, + getProfile, + } +} diff --git a/ui/src/transcode/decisionService.test.js b/ui/src/transcode/decisionService.test.js new file mode 100644 index 000000000..a2718ade9 --- /dev/null +++ b/ui/src/transcode/decisionService.test.js @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { createDecisionService, decodeJwtExp } from './decisionService' + +// Helper: create a fake JWT with a given exp (seconds since epoch) +function fakeJwt(expSeconds) { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const payload = btoa(JSON.stringify({ exp: expSeconds })) + return `${header}.${payload}.fake-signature` +} + +// Helper: create a fake JWT with no exp claim +function fakeJwtNoExp() { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const payload = btoa(JSON.stringify({ sub: 'test' })) + return `${header}.${payload}.fake-signature` +} + +describe('decodeJwtExp', () => { + it('extracts exp from a valid JWT', () => { + const exp = 1700000000 + expect(decodeJwtExp(fakeJwt(exp))).toBe(exp) + }) + + it('returns null for JWT without exp claim', () => { + expect(decodeJwtExp(fakeJwtNoExp())).toBeNull() + }) + + it('returns null for non-JWT string', () => { + expect(decodeJwtExp('not-a-jwt')).toBeNull() + }) + + it('returns null for empty string', () => { + expect(decodeJwtExp('')).toBeNull() + }) + + it('returns null for null/undefined', () => { + expect(decodeJwtExp(null)).toBeNull() + expect(decodeJwtExp(undefined)).toBeNull() + }) +}) + +describe('decisionService', () => { + let service + let mockFetchFn + + const fakeProfile = { + name: 'NavidromeUI', + platform: 'test', + directPlayProfiles: [], + transcodingProfiles: [], + codecProfiles: [], + } + + // Token that expires 1 hour from "now" (will be relative to fake timers) + function makeFakeDecision(expiresInMs = 3600 * 1000) { + const expSeconds = Math.floor((Date.now() + expiresInMs) / 1000) + return { + canDirectPlay: true, + canTranscode: false, + transcodeParams: fakeJwt(expSeconds), + sourceStream: { codec: 'mp3', container: 'mp3' }, + } + } + + beforeEach(() => { + localStorage.setItem('username', 'testuser') + localStorage.setItem('subsonic-token', 'testtoken') + localStorage.setItem('subsonic-salt', 'testsalt') + mockFetchFn = vi.fn().mockImplementation(() => { + return Promise.resolve(makeFakeDecision()) + }) + service = createDecisionService(mockFetchFn) + }) + + afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() + }) + + describe('getDecision', () => { + it('fetches and caches a decision', async () => { + const result = await service.getDecision('song-1', fakeProfile) + expect(result.canDirectPlay).toBe(true) + expect(mockFetchFn).toHaveBeenCalledTimes(1) + expect(mockFetchFn).toHaveBeenCalledWith('song-1', fakeProfile) + + // Second call uses cache + const result2 = await service.getDecision('song-1', fakeProfile) + expect(result2).toEqual(result) + expect(mockFetchFn).toHaveBeenCalledTimes(1) + }) + + it('re-fetches after token expires', async () => { + vi.useFakeTimers() + + // Token expires in 1 hour + mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000)) + await service.getDecision('song-1', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(1) + + // Advance past expiration + vi.advanceTimersByTime(3600 * 1000 + 1000) + await service.getDecision('song-1', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it('does not re-fetch before token expires', async () => { + vi.useFakeTimers() + + // Token expires in 1 hour + mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000)) + await service.getDecision('song-1', fakeProfile) + + // 30 minutes later — still fresh + vi.advanceTimersByTime(1800 * 1000) + await service.getDecision('song-1', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) + + it('re-fetches immediately when token has no exp claim', async () => { + const noExpDecision = { + canDirectPlay: true, + canTranscode: false, + transcodeParams: fakeJwtNoExp(), + sourceStream: { codec: 'mp3', container: 'mp3' }, + } + mockFetchFn.mockResolvedValue(noExpDecision) + await service.getDecision('song-1', fakeProfile) + + // Should re-fetch because token has no exp + await service.getDecision('song-1', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(2) + }) + + it('caches different songs independently', async () => { + await service.getDecision('song-1', fakeProfile) + await service.getDecision('song-2', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(2) + }) + }) + + describe('getCachedDecision', () => { + it('returns null when song is not cached', () => { + expect(service.getCachedDecision('song-1')).toBeNull() + }) + + it('returns cached decision after getDecision', async () => { + await service.getDecision('song-1', fakeProfile) + const cached = service.getCachedDecision('song-1') + expect(cached).not.toBeNull() + expect(cached.canDirectPlay).toBe(true) + }) + + it('returns null after cache is invalidated', async () => { + await service.getDecision('song-1', fakeProfile) + service.invalidateAll() + expect(service.getCachedDecision('song-1')).toBeNull() + }) + + it('returns null after token expires', async () => { + vi.useFakeTimers() + mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000)) + await service.getDecision('song-1', fakeProfile) + + vi.advanceTimersByTime(3600 * 1000 + 1000) + expect(service.getCachedDecision('song-1')).toBeNull() + vi.useRealTimers() + }) + }) + + describe('prefetchDecisions', () => { + it('fetches decisions for uncached songs', async () => { + await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(2) + }) + + it('skips already cached songs', async () => { + await service.getDecision('song-1', fakeProfile) + mockFetchFn.mockClear() + + await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(1) + expect(mockFetchFn).toHaveBeenCalledWith('song-2', fakeProfile) + }) + + it('silently ignores fetch errors', async () => { + mockFetchFn.mockRejectedValue(new Error('network error')) + await expect( + service.prefetchDecisions(['song-1'], fakeProfile), + ).resolves.not.toThrow() + }) + }) + + describe('invalidateAll', () => { + it('clears cache so next getDecision re-fetches', async () => { + await service.getDecision('song-1', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(1) + + service.invalidateAll() + + await service.getDecision('song-1', fakeProfile) + expect(mockFetchFn).toHaveBeenCalledTimes(2) + }) + }) + + describe('resolveStreamUrl', () => { + it('fetches decision and returns built URL', async () => { + service.setProfile(fakeProfile) + const url = await service.resolveStreamUrl('song-1') + expect(url).toContain('getTranscodeStream') + expect(url).toContain('mediaId=song-1') + expect(mockFetchFn).toHaveBeenCalledTimes(1) + }) + + it('falls back to stream URL when decision has no transcodeParams', async () => { + service.setProfile(fakeProfile) + mockFetchFn.mockResolvedValue({ + canDirectPlay: true, + canTranscode: false, + }) + const url = await service.resolveStreamUrl('song-1') + expect(url).toContain('stream') + expect(url).not.toContain('getTranscodeStream') + }) + + it('falls back to stream URL when decision is null', async () => { + service.setProfile(fakeProfile) + mockFetchFn.mockResolvedValue(null) + const url = await service.resolveStreamUrl('song-1') + expect(url).toContain('stream') + expect(url).not.toContain('getTranscodeStream') + }) + }) + + describe('buildStreamUrl', () => { + it('builds URL with required parameters', () => { + const url = service.buildStreamUrl('song-1', 'jwt-token-123') + expect(url).toContain('getTranscodeStream') + expect(url).toContain('mediaId=song-1') + expect(url).toContain('mediaType=song') + expect(url).toContain('transcodeParams=jwt-token-123') + }) + + it('includes offset when provided', () => { + const url = service.buildStreamUrl('song-1', 'jwt-token-123', 30) + expect(url).toContain('offset=30') + }) + + it('omits offset when not provided', () => { + const url = service.buildStreamUrl('song-1', 'jwt-token-123') + expect(url).not.toContain('offset') + }) + }) +}) diff --git a/ui/src/transcode/fetchDecision.js b/ui/src/transcode/fetchDecision.js new file mode 100644 index 000000000..1c794082b --- /dev/null +++ b/ui/src/transcode/fetchDecision.js @@ -0,0 +1,23 @@ +import subsonic from '../subsonic' +import { httpClient } from '../dataProvider' + +export async function fetchTranscodeDecision(songId, browserProfile) { + const fetchUrl = subsonic.url('getTranscodeDecision', null, { + mediaId: songId, + mediaType: 'song', + }) + + const { json } = await httpClient(fetchUrl, { + method: 'POST', + body: JSON.stringify(browserProfile), + }) + + const subsonicResponse = json['subsonic-response'] + + if (subsonicResponse.status !== 'ok') { + const err = subsonicResponse.error || {} + throw new Error(`getTranscodeDecision error: ${err.code} ${err.message}`) + } + + return subsonicResponse.transcodeDecision +} diff --git a/ui/src/transcode/fetchDecision.test.js b/ui/src/transcode/fetchDecision.test.js new file mode 100644 index 000000000..83f934f33 --- /dev/null +++ b/ui/src/transcode/fetchDecision.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' + +// Mock httpClient before importing module under test +vi.mock('../dataProvider', () => ({ + httpClient: vi.fn(), +})) + +import { fetchTranscodeDecision } from './fetchDecision' +import { httpClient } from '../dataProvider' + +describe('fetchTranscodeDecision', () => { + const fakeProfile = { + name: 'NavidromeUI', + platform: 'test', + directPlayProfiles: [ + { containers: ['mp3'], audioCodecs: ['mp3'], protocols: ['http'] }, + ], + transcodingProfiles: [], + codecProfiles: [], + } + + const fakeJson = { + 'subsonic-response': { + status: 'ok', + transcodeDecision: { + canDirectPlay: true, + canTranscode: false, + transcodeParams: 'jwt-token', + sourceStream: { codec: 'mp3' }, + }, + }, + } + + beforeEach(() => { + localStorage.setItem('username', 'testuser') + localStorage.setItem('subsonic-token', 'testtoken') + localStorage.setItem('subsonic-salt', 'testsalt') + + httpClient.mockResolvedValue({ json: fakeJson }) + }) + + afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() + }) + + it('makes a POST request to getTranscodeDecision with correct URL', async () => { + await fetchTranscodeDecision('song-1', fakeProfile) + + expect(httpClient).toHaveBeenCalledTimes(1) + const [url, options] = httpClient.mock.calls[0] + expect(url).toContain('getTranscodeDecision') + expect(url).toContain('mediaId=song-1') + expect(url).toContain('mediaType=song') + expect(options.method).toBe('POST') + }) + + it('sends the browser profile as JSON body', async () => { + await fetchTranscodeDecision('song-1', fakeProfile) + + const [, options] = httpClient.mock.calls[0] + expect(JSON.parse(options.body)).toEqual(fakeProfile) + }) + + it('returns the transcodeDecision from response', async () => { + const result = await fetchTranscodeDecision('song-1', fakeProfile) + expect(result).toEqual(fakeJson['subsonic-response'].transcodeDecision) + }) + + it('throws on HTTP error (httpClient rejects)', async () => { + httpClient.mockRejectedValue(new Error('Server Error')) + + await expect( + fetchTranscodeDecision('song-1', fakeProfile), + ).rejects.toThrow() + }) + + it('throws on Subsonic error response', async () => { + httpClient.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'failed', + error: { code: 70, message: 'not found' }, + }, + }, + }) + + await expect( + fetchTranscodeDecision('song-1', fakeProfile), + ).rejects.toThrow() + }) +}) diff --git a/ui/src/transcode/index.js b/ui/src/transcode/index.js new file mode 100644 index 000000000..aa0cb216c --- /dev/null +++ b/ui/src/transcode/index.js @@ -0,0 +1,5 @@ +import { createDecisionService } from './decisionService' +import { fetchTranscodeDecision } from './fetchDecision' +export { detectBrowserProfile } from './browserProfile' + +export const decisionService = createDecisionService(fetchTranscodeDecision) From 09e1cf6ae75d7f6e30cd7505acf474f373fbd623 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 11:22:43 -0400 Subject: [PATCH 067/596] chore(deps): update TagLib to 2.2.1 Signed-off-by: Deluan --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 559a34c3c..be3c221ba 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ 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.2.0-1 +CROSS_TAGLIB_VERSION ?= 2.2.1-1 GOLANGCI_LINT_VERSION ?= v2.11.1 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") From e08d4bef16586256e70640d2f1f6078f1bb5bdf3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 12:44:19 -0400 Subject: [PATCH 068/596] fix(ui): preserve pending track selection through queue sync and premature callbacks When clicking a song while another was playing, PLAYER_SYNC_QUEUE and PLAYER_CURRENT would fire before the music player switched tracks, wiping the playIndex set by PLAYER_PLAY_TRACKS. This caused the player to stay on the old track instead of switching to the clicked one. Now reduceSyncQueue and reduceCurrent preserve a pending playIndex until the music player confirms it actually reached the requested track. --- ui/src/reducers/playerReducer.js | 17 +++-- ui/src/reducers/playerReducer.test.js | 95 ++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index b7086e6c8..d3291633c 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -173,8 +173,11 @@ const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { return { ...state, queue: audioLists, - clear: false, - playIndex: undefined, + // Keep clear and playIndex alive so the music player can still + // pick up a pending track selection set by PLAYER_PLAY_TRACKS. + // They will be consumed by the next PLAYER_CURRENT dispatch. + clear: state.playIndex != null ? state.clear : false, + playIndex: state.playIndex != null ? state.playIndex : undefined, } } @@ -183,11 +186,17 @@ const reduceCurrent = (state, { data }) => { const savedPlayIndex = state.queue.findIndex( (item) => item.uuid === current.uuid, ) + // When a track selection is pending (playIndex is set), keep it alive + // until the music player confirms it actually switched to the requested + // track. Without this, a premature onAudioPlay callback for the + // still-playing old track would overwrite the pending selection. + const pending = state.playIndex != null && savedPlayIndex !== state.playIndex return { ...state, current, - playIndex: undefined, - savedPlayIndex, + playIndex: pending ? state.playIndex : undefined, + clear: pending ? state.clear : false, + savedPlayIndex: pending ? state.savedPlayIndex : savedPlayIndex, volume: data.volume, } } diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 9e3b03b1e..10e9512d7 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -1,8 +1,101 @@ import { describe, it, expect } from 'vitest' import { playerReducer } from './playerReducer' -import { PLAYER_REFRESH_QUEUE } from '../actions' +import { + PLAYER_SYNC_QUEUE, + PLAYER_CURRENT, + PLAYER_REFRESH_QUEUE, +} from '../actions' describe('playerReducer', () => { + describe('pending track selection survives SYNC_QUEUE and premature CURRENT', () => { + // Simulates the real sequence when clicking a new song while one is playing: + // 1. PLAYER_PLAY_TRACKS sets playIndex and clear + // 2. PLAYER_SYNC_QUEUE fires when music player syncs its internal queue + // 3. PLAYER_CURRENT fires for the OLD still-playing track + // 4. PLAYER_CURRENT fires for the NEW track (player switched) + const stateAfterPlayTracks = { + queue: [ + { trackId: 's1', uuid: 'aaa', name: 'Song 1' }, + { trackId: 's2', uuid: 'bbb', name: 'Song 2' }, + { trackId: 's3', uuid: 'ccc', name: 'Song 3' }, + ], + current: { uuid: 'ccc', name: 'Song 3' }, + playIndex: 0, // user clicked Song 1 + savedPlayIndex: 2, // Song 3 was playing + clear: true, + volume: 1, + } + + it('SYNC_QUEUE preserves pending playIndex and clear', () => { + const newQueue = [ + { trackId: 's1', uuid: 'xxx', name: 'Song 1' }, + { trackId: 's2', uuid: 'yyy', name: 'Song 2' }, + { trackId: 's3', uuid: 'zzz', name: 'Song 3' }, + ] + const action = { + type: PLAYER_SYNC_QUEUE, + data: { audioInfo: {}, audioLists: newQueue }, + } + const result = playerReducer(stateAfterPlayTracks, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + expect(result.queue).toBe(newQueue) + }) + + it('SYNC_QUEUE clears playIndex when no pending selection', () => { + const stateNoPending = { ...stateAfterPlayTracks, playIndex: undefined } + const action = { + type: PLAYER_SYNC_QUEUE, + data: { audioInfo: {}, audioLists: stateNoPending.queue }, + } + const result = playerReducer(stateNoPending, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + }) + + it('CURRENT for old track preserves pending playIndex', () => { + // After SYNC_QUEUE, queue has new UUIDs. The old track's UUID (zzz) + // is at index 2, but playIndex is 0. This is a premature callback. + const stateAfterSync = { + ...stateAfterPlayTracks, + queue: [ + { trackId: 's1', uuid: 'xxx', name: 'Song 1' }, + { trackId: 's2', uuid: 'yyy', name: 'Song 2' }, + { trackId: 's3', uuid: 'zzz', name: 'Song 3' }, + ], + } + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'zzz', name: 'Song 3', volume: 1 }, + } + const result = playerReducer(stateAfterSync, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + expect(result.savedPlayIndex).toBe(2) // preserved from before + }) + + it('CURRENT for correct track consumes pending playIndex', () => { + const stateAfterSync = { + ...stateAfterPlayTracks, + queue: [ + { trackId: 's1', uuid: 'xxx', name: 'Song 1' }, + { trackId: 's2', uuid: 'yyy', name: 'Song 2' }, + { trackId: 's3', uuid: 'zzz', name: 'Song 3' }, + ], + } + // Player switched to Song 1 (uuid 'xxx', index 0 == playIndex) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'xxx', name: 'Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterSync, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + expect(result.current.name).toBe('Song 1') + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { From d4b2499e1ee1cd6ca135af08758e9a96e0d7966d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Mar 2026 14:19:53 -0400 Subject: [PATCH 069/596] fix(server): return correct scanType in startScan response (#5159) * fix(api): return correct scanType in startScan response The startScan endpoint launches the scan in a goroutine and immediately calls GetScanStatus to build the response. Because the scanner hasn't had time to initialize and write its state to the database, the response contained stale data from the previous scan (e.g., scanType "quick" when fullScan=true was requested). Add a polling loop that waits briefly (up to 3s, polling every 50ms) for the scanner to report Scanning=true before returning the status. If the timeout expires, it falls back to the current behavior (no regression). Fixes #5158 * fix(api): use ticker/timer with context cancellation for scan polling Replace time.Sleep loop with proper ticker, timer, and ctx.Done() handling so the poll exits cleanly on timeout or client disconnect. * fix(api): handle fast scan completion in poll loop Add a channel to detect when the scan goroutine finishes before the poll loop observes Scanning=true, avoiding a 3s timeout on very fast scans. Use defer close to handle both success and error paths. --- server/subsonic/library_scanning.go | 32 +++++++++++++ server/subsonic/library_scanning_test.go | 60 ++++++++++++++++++++++++ tests/mock_scanner.go | 23 +++++++++ 3 files changed, 115 insertions(+) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index c9dd64968..bac27f821 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -80,7 +80,9 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { } } + fastScanCompleted := make(chan struct{}) go func() { + defer close(fastScanCompleted) start := time.Now() var err error @@ -99,5 +101,35 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start)) }() + // Wait briefly for the scanner to start and update its status, so the response + // reflects the current scan (not stale data from a previous scan). + const ( + pollInterval = 50 * time.Millisecond + pollTimeout = 3 * time.Second + ) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + timer := time.NewTimer(pollTimeout) + defer timer.Stop() + +loop: + for { + status, err := api.scanner.Status(ctx) + if err == nil && status.Scanning { + break + } + select { + case <-fastScanCompleted: + log.Info(ctx, "Fast scan completed", "user", loggedUser.UserName) + break loop + case <-timer.C: + log.Warn(ctx, "Timed out waiting for scanner to start; response may be stale") + break loop + case <-ctx.Done(): + return nil, newError(responses.ErrorGeneric, "Request cancelled while waiting for scanner to start") + case <-ticker.C: + } + } + return api.GetScanStatus(r) } diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index d8eba296b..c62c156bc 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -365,6 +365,66 @@ var _ = Describe("LibraryScanning", func() { Expect(targets[0].LibraryID).To(Equal(1)) Expect(targets[0].FolderPath).To(Equal("")) }) + + It("returns correct scanType in response when fullScan=false", func() { + // Setup mock to update status when scan starts (simulating the real scanner) + ms.SetScanStatusFunc(func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus { + scanType := "quick" + if fullScan { + scanType = "full" + } + return &model.ScannerStatus{ + Scanning: true, + ScanType: scanType, + } + }) + + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + r := httptest.NewRequest("GET", "/rest/startScan", nil) + r = r.WithContext(ctx) + + response, err := api.StartScan(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + Expect(response.ScanStatus).ToNot(BeNil()) + Expect(response.ScanStatus.Scanning).To(BeTrue()) + Expect(response.ScanStatus.ScanType).To(Equal("quick")) + }) + + It("returns correct scanType in response when fullScan=true", func() { + // Setup mock to update status when scan starts (simulating the real scanner) + ms.SetScanStatusFunc(func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus { + scanType := "quick" + if fullScan { + scanType = "full" + } + return &model.ScannerStatus{ + Scanning: true, + ScanType: scanType, + } + }) + + ctx := request.WithUser(context.Background(), model.User{ + ID: "admin-id", + IsAdmin: true, + }) + + r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil) + r = r.WithContext(ctx) + + response, err := api.StartScan(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + Expect(response.ScanStatus).ToNot(BeNil()) + Expect(response.ScanStatus.Scanning).To(BeTrue()) + Expect(response.ScanStatus.ScanType).To(Equal("full")) + }) }) Describe("GetScanStatus", func() { diff --git a/tests/mock_scanner.go b/tests/mock_scanner.go index 52396723f..495e8fe53 100644 --- a/tests/mock_scanner.go +++ b/tests/mock_scanner.go @@ -14,6 +14,7 @@ type MockScanner struct { scanFoldersCalls []ScanFoldersCall scanningStatus bool statusResponse *model.ScannerStatus + scanStatusFunc func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus } type ScanAllCall struct { @@ -38,6 +39,13 @@ func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan}) + // Simulate the scanner updating its status when the scan starts + if m.scanStatusFunc != nil { + m.statusResponse = m.scanStatusFunc(fullScan, nil) + } else { + m.scanningStatus = true + } + return nil, nil } @@ -54,6 +62,13 @@ func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []mo Targets: targetsCopy, }) + // Simulate the scanner updating its status when the scan starts + if m.scanStatusFunc != nil { + m.statusResponse = m.scanStatusFunc(fullScan, targetsCopy) + } else { + m.scanningStatus = true + } + return nil, nil } @@ -118,3 +133,11 @@ func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) { defer m.mu.Unlock() m.statusResponse = status } + +// SetScanStatusFunc sets a function that will be called when ScanAll/ScanFolders is invoked, +// simulating the scanner updating its status when the scan starts. +func (m *MockScanner) SetScanStatusFunc(fn func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus) { + m.mu.Lock() + defer m.mu.Unlock() + m.scanStatusFunc = fn +} From d7c3a50f86f96ee21da999604a3eb3c276e8d525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Mar 2026 16:47:34 -0400 Subject: [PATCH 070/596] fix: player MaxBitRate cap, format-aware defaults, browser profile filtering (#5165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transcode): apply player MaxBitRate cap and use format-aware default bitrates Add player MaxBitRate cap to the transcode decider so server-side player bitrate limits are respected when making OpenSubsonic transcode decisions. The player cap is applied only when it is more restrictive than the client's maxAudioBitrate (or when the client has no limit). Also replace the hardcoded 256 kbps default with a format-aware lookup that checks the DB first (for user-customized values), then built-in defaults, and finally falls back to 256 kbps. For lossless→lossy transcoding, prefer maxTranscodingAudioBitrate over maxAudioBitrate when available. * test(e2e): add tests for player MaxBitRate cap and format-aware default bitrates Add e2e tests covering: - Player MaxBitRate forcing transcode when source exceeds cap - Player MaxBitRate having no effect when source is under cap - Client limit winning when more restrictive than player MaxBitRate - Player MaxBitRate winning when more restrictive than client limit - Player MaxBitRate=0 having no effect - Format-aware defaults: mp3 (192kbps), opus (128kbps) instead of hardcoded 256 - maxAudioBitrate fallback for lossless→lossy when no maxTranscodingAudioBitrate - maxTranscodingAudioBitrate taking priority over maxAudioBitrate - Combined player + client limits flowing correctly through decision→stream * feat(transcode): update transcoding profiles to add flac, filter by supported codecs, and ensure mp3 fallback Signed-off-by: Deluan * fix(db): ensure all default transcodings exist on upgrade Older installations that were seeded before aac/flac were added to DefaultTranscodings may be missing these entries. The previous migration only added flac; this one ensures all default transcodings are present without touching user-customized entries. * test: remove duplication Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/transcode/decider.go | 28 ++- core/transcode/decider_test.go | 97 ++++++++- ...60309203355_ensure_default_transcodings.go | 41 ++++ server/e2e/subsonic_transcode_test.go | 188 ++++++++++++++++++ ui/src/transcode/browserProfile.js | 42 ++-- ui/src/transcode/browserProfile.test.js | 39 +++- 6 files changed, 413 insertions(+), 22 deletions(-) create mode 100644 db/migrations/20260309203355_ensure_default_transcodings.go diff --git a/core/transcode/decider.go b/core/transcode/decider.go index 55b451fd6..e870e9af7 100644 --- a/core/transcode/decider.go +++ b/core/transcode/decider.go @@ -14,7 +14,7 @@ import ( "github.com/navidrome/navidrome/model/request" ) -const defaultBitrate = 256 // kbps +const fallbackBitrate = 256 // kbps // Decider is the core service interface for making transcoding decisions type Decider interface { @@ -58,6 +58,13 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, // Check for server-side player transcoding override if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { + modified := *clientInfo + modified.MaxAudioBitrate = player.MaxBitRate + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } } log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, @@ -291,6 +298,21 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea return ts, targetFormat } +// lookupDefaultBitrate returns the default bitrate for the given format. +// It checks the DB first (for user-customized values), then falls back to +// the built-in defaults, and finally to fallbackBitrate. +func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int { + if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 { + return t.DefaultBitRate + } + for _, dt := range consts.DefaultTranscodings { + if dt.TargetFormat == format && dt.DefaultBitRate > 0 { + return dt.DefaultBitRate + } + } + return fallbackBitrate +} + // LookupTranscodeCommand returns the ffmpeg command for the given format. // It checks the DB first (for user-customized commands), then falls back to // the built-in default command. Returns "" if the format is unknown. @@ -341,8 +363,10 @@ func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, if !targetIsLossless { if clientInfo.MaxTranscodingAudioBitrate > 0 { ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate + } else if clientInfo.MaxAudioBitrate > 0 { + ts.Bitrate = clientInfo.MaxAudioBitrate } else { - ts.Bitrate = defaultBitrate + ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat) } } else { if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { diff --git a/core/transcode/decider_test.go b/core/transcode/decider_test.go index e5ad2f621..280060fbf 100644 --- a/core/transcode/decider_test.go +++ b/core/transcode/decider_test.go @@ -229,7 +229,7 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) // 256 kbps + Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo }) It("preserves lossy bitrate when under max", func() { @@ -993,8 +993,8 @@ var _ = Describe("Decider", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) - // With no cap, lossless→lossy uses defaultBitrate (256) - Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) + // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) + Expect(decision.TargetBitrate).To(Equal(160)) }) It("does not apply override when no transcoding is in context", func() { @@ -1012,6 +1012,97 @@ var _ = Describe("Decider", func() { }) }) + + Context("Player MaxBitRate cap", func() { + It("applies player MaxBitRate cap when client has no limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) + + decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + // Source bitrate 1000 > player cap 320, so direct play is not possible + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + // Lossless→lossy should use MaxAudioBitrate (320) as target, not format default + Expect(decision.TargetBitrate).To(Equal(320)) + }) + + It("uses client limit when it is more restrictive than player MaxBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + Name: "TestClient", + MaxAudioBitrate: 256, + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) + + decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins + Expect(decision.TargetBitrate).To(Equal(256)) + }) + + It("does not cap when player MaxBitRate is 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + } + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) + + decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + }) + + Context("Format-aware default bitrate", func() { + It("uses opus default bitrate from DB", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock + }) + + It("uses aac default bitrate from DB", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock + }) + + It("falls back to 256 for unknown format", func() { + bitrate := lookupDefaultBitrate(ctx, ds, "xyz") + Expect(bitrate).To(Equal(fallbackBitrate)) + }) + }) }) Describe("ensureProbed", func() { diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go new file mode 100644 index 000000000..ff3838222 --- /dev/null +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -0,0 +1,41 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/id" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings) +} + +func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { + // Older installations may be missing default transcodings that were added + // after the initial seeding (e.g., aac was added later than mp3/opus). + // Insert any missing defaults without touching user-customized entries. + for _, t := range consts.DefaultTranscodings { + var count int + err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ?", t.TargetFormat).Scan(&count) + if err != nil { + return err + } + if count == 0 { + _, err = tx.Exec( + "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", + id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command, + ) + if err != nil { + return err + } + } + } + return nil +} + +func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index 16a884bf3..64d788b30 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -146,6 +146,25 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) Describe("getTranscodeDecision", func() { + // setPlayerMaxBitRate ensures a player exists for the test-client and sets its MaxBitRate. + // It makes a dummy request to register the player, then updates it via the repository. + setPlayerMaxBitRate := func(maxBitRate int) { + doReq("ping") + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + Expect(err).ToNot(HaveOccurred()) + player.MaxBitRate = maxBitRate + Expect(ds.Player(ctx).Put(player)).To(Succeed()) + } + + AfterEach(func() { + // Reset player MaxBitRate to 0 after each test + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + if err == nil { + player.MaxBitRate = 0 + _ = ds.Player(ctx).Put(player) + } + }) + Describe("error cases", func() { It("returns 405 for GET request", func() { w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song") @@ -360,6 +379,175 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(src.AudioBitrate).To(Equal(int32(320000))) }) }) + + Describe("player MaxBitRate cap", func() { + It("forces transcode when source bitrate exceeds player MaxBitRate", func() { + setPlayerMaxBitRate(320) // 320 kbps cap + + // FLAC is 900kbps, client has no bitrate limit but player cap is 320 + resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + // Target bitrate should be capped at player's 320kbps = 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + }) + + It("does not affect direct play when source bitrate is under player MaxBitRate", func() { + setPlayerMaxBitRate(500) // 500 kbps cap + + // MP3 is 320kbps, under the 500kbps player cap → direct play + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + }) + + It("uses client limit when more restrictive than player MaxBitRate", func() { + setPlayerMaxBitRate(500) // 500 kbps player cap + + // Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500 + // FLAC is 900kbps → exceeds both limits → transcode + resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // Client limit (320kbps) is more restrictive → 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + }) + + It("uses player MaxBitRate when more restrictive than client limit", func() { + setPlayerMaxBitRate(192) // 192 kbps player cap + + // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 + // FLAC is 900kbps → transcode at 192kbps + resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // Player limit (192kbps) is more restrictive → 192000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) + }) + + It("has no effect when player MaxBitRate is 0", func() { + setPlayerMaxBitRate(0) // No player cap + + // FLAC with flac+mp3 client → direct play (no bitrate constraint) + resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + }) + }) + + Describe("format-aware default bitrate", func() { + It("uses mp3 format default (192kbps) for lossless-to-mp3 with no bitrate limits", func() { + // mp3OnlyClient has no maxAudioBitrate or maxTranscodingAudioBitrate + // FLAC → MP3 should use the mp3 default bitrate (192kbps), not hardcoded 256 + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + // mp3 default is 192kbps = 192000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) + }) + + It("uses opus format default (128kbps) for lossless-to-opus with no bitrate limits", func() { + // opusTranscodeClient has no maxAudioBitrate or maxTranscodingAudioBitrate + // FLAC → Opus should use the opus default bitrate (128kbps) + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + // opus default is 128kbps = 128000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) + }) + + It("uses maxAudioBitrate as fallback for lossless-to-lossy when no maxTranscodingAudioBitrate", func() { + // bitrateCapClient has maxAudioBitrate=320000 but no maxTranscodingAudioBitrate + // FLAC → MP3: maxAudioBitrate (320kbps) should be used as the target + resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // maxAudioBitrate is 320kbps = 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + }) + + It("prefers maxTranscodingAudioBitrate over maxAudioBitrate for lossless-to-lossy", func() { + // maxTranscodeBitrateClient has maxTranscodingAudioBitrate=192000 + // FLAC → MP3: should use 192kbps, not format default or maxAudioBitrate + resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // maxTranscodingAudioBitrate is 192kbps = 192000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) + }) + }) + + Describe("player MaxBitRate + client limits combined", func() { + It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() { + setPlayerMaxBitRate(320) + + // opusTranscodeClient has no client bitrate limits + // Player cap injects maxAudioBitrate=320 + // FLAC (900kbps) → exceeds 320 → transcode to opus + // Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320 + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + // maxAudioBitrate=320 used as fallback → 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + }) + + It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() { + setPlayerMaxBitRate(320) + + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate + // Player cap injects maxAudioBitrate=320 + // FLAC (900kbps) → exceeds 320 → transcode to mp3 + // Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority + resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // maxTranscodingAudioBitrate=192 is preferred → 192000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) + }) + + It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() { + setPlayerMaxBitRate(128) + + // Get decision: FLAC (900kbps) with player cap 128 → transcode + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Stream using the token + w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + }) }) Describe("getTranscodeStream", func() { diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js index 4ee114e45..5a4cde20f 100644 --- a/ui/src/transcode/browserProfile.js +++ b/ui/src/transcode/browserProfile.js @@ -9,32 +9,44 @@ export const CODEC_PROBES = [ { codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' }, ] -// Default transcoding targets — ordered by preference. -// These are attempted if direct play is not possible. -const DEFAULT_TRANSCODING_PROFILES = [ - { container: 'ogg', audioCodec: 'opus', protocol: 'http' }, - { container: 'mp3', audioCodec: 'mp3', protocol: 'http' }, -] +// Transcoding targets in preference order (lossless first, then lossy). +// Derived from CODEC_PROBES to avoid duplicating MIME strings. +// MP3 is always included as a universal fallback. +const TRANSCODE_CODECS = ['flac', 'opus', 'mp3'] + +function probeSupported(audio, probes) { + return probes.filter(({ mime }) => audio.canPlayType(mime) === 'probably') +} export function detectBrowserProfile() { const audio = new Audio() - const directPlayProfiles = [] - for (const { codec, container, mime } of CODEC_PROBES) { - if (audio.canPlayType(mime) === 'probably') { - directPlayProfiles.push({ - containers: [container], - audioCodecs: [codec], - protocols: ['http'], + const directPlayProfiles = probeSupported(audio, CODEC_PROBES).map( + ({ codec, container }) => ({ + containers: [container], + audioCodecs: [codec], + protocols: ['http'], + }), + ) + + // Build transcoding profiles from supported codecs, always keeping mp3 as fallback + const transcodingProfiles = TRANSCODE_CODECS.reduce((profiles, codec) => { + const probe = CODEC_PROBES.find((p) => p.codec === codec) + if (audio.canPlayType(probe.mime) === 'probably' || codec === 'mp3') { + profiles.push({ + container: probe.container, + audioCodec: codec, + protocol: 'http', }) } - } + return profiles + }, []) return { name: 'NavidromeUI', platform: navigator.userAgent, directPlayProfiles, - transcodingProfiles: DEFAULT_TRANSCODING_PROFILES, + transcodingProfiles, codecProfiles: [], } } diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js index 9764a0046..360ae7885 100644 --- a/ui/src/transcode/browserProfile.test.js +++ b/ui/src/transcode/browserProfile.test.js @@ -54,14 +54,49 @@ describe('detectBrowserProfile', () => { }) }) - it('includes transcoding profiles for common formats', () => { + it('filters transcoding profiles by canPlayType', () => { + mockCanPlayType.mockImplementation((mime) => { + if (mime === 'audio/mpeg') return 'probably' + if (mime === 'audio/ogg; codecs="opus"') return 'probably' + return '' + }) + + const profile = detectBrowserProfile() + const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) + expect(codecs).toEqual(['opus', 'mp3']) + expect(codecs).not.toContain('flac') + profile.transcodingProfiles.forEach((p) => { + expect(p.protocol).toBe('http') + }) + }) + + it('always includes mp3 fallback in transcoding profiles', () => { mockCanPlayType.mockReturnValue('') const profile = detectBrowserProfile() - expect(profile.transcodingProfiles.length).toBeGreaterThan(0) + expect(profile.transcodingProfiles.length).toBe(1) + expect(profile.transcodingProfiles[0].audioCodec).toBe('mp3') expect(profile.transcodingProfiles[0].protocol).toBe('http') }) + it('does not duplicate mp3 when canPlayType supports it', () => { + mockCanPlayType.mockReturnValue('probably') + + const profile = detectBrowserProfile() + const mp3Count = profile.transcodingProfiles.filter( + (p) => p.audioCodec === 'mp3', + ).length + expect(mp3Count).toBe(1) + }) + + it('preserves transcoding profile preference order', () => { + mockCanPlayType.mockReturnValue('probably') + + const profile = detectBrowserProfile() + const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) + expect(codecs).toEqual(['flac', 'opus', 'mp3']) + }) + it('sets codecProfiles to empty array', () => { mockCanPlayType.mockReturnValue('probably') From 94894fd511a02068a038bb112d6c162a63534a4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:19:03 -0400 Subject: [PATCH 071/596] chore(deps): bump docker/build-push-action in /.github/workflows (#5164) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 3a24d27b1..eb7523d4e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -221,7 +221,7 @@ jobs: hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Build Binaries - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -244,7 +244,7 @@ jobs: - name: Build and push image by digest id: push-image if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile From d76b49c6d1e2e262f65848ce5e81af0b5b36b805 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 17:18:38 -0400 Subject: [PATCH 072/596] chore(deps): update golang.org/x/sync, golang.org/x/sys, golang.org/x/time, and go.opentelemetry.io/proto/otlp to latest versions Signed-off-by: Deluan --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 538b06f89..d50f738b7 100644 --- a/go.mod +++ b/go.mod @@ -64,11 +64,11 @@ require ( go.uber.org/goleak v1.3.0 golang.org/x/image v0.36.0 golang.org/x/net v0.51.0 - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.41.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 - golang.org/x/time v0.14.0 + golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -131,7 +131,7 @@ require ( github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 8f13089a3..d92075234 100644 --- a/go.sum +++ b/go.sum @@ -305,8 +305,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -355,8 +355,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -372,8 +372,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= @@ -399,8 +399,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= From 844dffa2f1f45e8ac8ddd1b84420784c85bbea38 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 18:26:07 -0400 Subject: [PATCH 073/596] fix: add 'opus' to the container aliases for improved direct play detection Signed-off-by: Deluan --- core/transcode/aliases.go | 2 +- core/transcode/decider_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/core/transcode/aliases.go b/core/transcode/aliases.go index 67a641511..0c9bfcb44 100644 --- a/core/transcode/aliases.go +++ b/core/transcode/aliases.go @@ -10,7 +10,7 @@ var containerAliasGroups = func() map[string]string { groups := [][]string{ {"aac", "adts", "m4a", "mp4", "m4b", "m4p"}, {"mpeg", "mp3", "mp2"}, - {"ogg", "oga"}, + {"ogg", "oga", "opus"}, {"aif", "aiff"}, {"asf", "wma"}, {"mpc", "mpp"}, diff --git a/core/transcode/decider_test.go b/core/transcode/decider_test.go index 280060fbf..cc9b5fb60 100644 --- a/core/transcode/decider_test.go +++ b/core/transcode/decider_test.go @@ -129,6 +129,18 @@ var _ = Describe("Decider", func() { Expect(decision.CanDirectPlay).To(BeTrue()) }) + It("handles container aliases (opus -> ogg)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "opus", Codec: "Opus", BitRate: 165, Channels: 2, SampleRate: 48000}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + It("handles codec aliases (adts -> aac)", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) ci := &ClientInfo{ From 767744a3010591c398d8d684fbf06849a9cd8169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Mar 2026 22:22:58 -0400 Subject: [PATCH 074/596] refactor: rename core/transcode to core/stream, simplify MediaStreamer (#5166) * refactor: rename core/transcode directory to core/stream * refactor: update all imports from core/transcode to core/stream * refactor: rename exported symbols to fit core/stream package name * refactor: simplify MediaStreamer interface to single NewStream method Remove the two-method interface (NewStream + DoStream) in favor of a single NewStream(ctx, mf, req) method. Callers are now responsible for fetching the MediaFile before calling NewStream. This removes the implicit DB lookup from the streamer, making it a pure streaming concern. * refactor: update all callers from DoStream to NewStream * chore: update wire_gen.go and stale comment for core/stream rename * refactor: update wire command to handle GO_BUILD_TAGS correctly Signed-off-by: Deluan * fix: distinguish not-found from internal errors in public stream handler * refactor: remove unused ID field from stream.Request * refactor: simplify ResolveRequestFromToken to receive *model.MediaFile Move MediaFile fetching responsibility to callers, making the method focused on token validation and request resolution. Remove ErrMediaNotFound (no longer produced). Update GetTranscodeStream handler to fetch the media file before calling ResolveRequestFromToken. * refactor: extend tokenTTL from 12 to 48 hours Signed-off-by: Deluan --------- Signed-off-by: Deluan --- Makefile | 2 +- cmd/wire_gen.go | 14 +- core/archiver.go | 8 +- core/archiver_test.go | 16 +-- core/ffmpeg/ffmpeg.go | 2 +- core/{transcode => stream}/aliases.go | 2 +- core/{transcode => stream}/codec.go | 2 +- core/{transcode => stream}/codec_test.go | 2 +- core/{transcode => stream}/decider.go | 34 ++--- core/{transcode => stream}/decider_test.go | 134 +++++++++--------- core/{transcode => stream}/legacy_client.go | 11 +- .../legacy_client_test.go | 2 +- core/{transcode => stream}/limitations.go | 6 +- core/{transcode => stream}/media_streamer.go | 16 +-- .../media_streamer_test.go | 26 ++-- .../stream_suite_test.go} | 6 +- core/{transcode => stream}/token.go | 31 ++-- core/{transcode => stream}/token_test.go | 58 +++----- core/{transcode => stream}/types.go | 30 ++-- core/wire_providers.go | 8 +- server/e2e/e2e_suite_test.go | 28 ++-- server/public/handle_streams.go | 18 ++- server/public/public.go | 6 +- server/subsonic/api.go | 10 +- server/subsonic/stream.go | 8 +- server/subsonic/transcode.go | 72 ++++++---- server/subsonic/transcode_test.go | 63 ++++---- 27 files changed, 298 insertions(+), 317 deletions(-) rename core/{transcode => stream}/aliases.go (99%) rename core/{transcode => stream}/codec.go (99%) rename core/{transcode => stream}/codec_test.go (99%) rename core/{transcode => stream}/decider.go (93%) rename core/{transcode => stream}/decider_test.go (90%) rename core/{transcode => stream}/legacy_client.go (91%) rename core/{transcode => stream}/legacy_client_test.go (99%) rename core/{transcode => stream}/limitations.go (96%) rename core/{transcode => stream}/media_streamer.go (92%) rename core/{transcode => stream}/media_streamer_test.go (71%) rename core/{transcode/transcode_suite_test.go => stream/stream_suite_test.go} (74%) rename core/{transcode => stream}/token.go (79%) rename core/{transcode => stream}/token_test.go (84%) rename core/{transcode => stream}/types.go (78%) diff --git a/Makefile b/Makefile index be3c221ba..c9c88f506 100644 --- a/Makefile +++ b/Makefile @@ -109,7 +109,7 @@ format: ##@Development Format code .PHONY: format wire: check_go_env ##@Development Update Dependency Injection - go tool wire gen -tags=$(GO_BUILD_TAGS) ./... + go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./... .PHONY: wire gen: check_go_env ##@Development Run go generate for code generation diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index a7a0769d3..d045c12ec 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,7 +21,7 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -95,8 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := transcode.GetTranscodingCache() - mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) @@ -106,8 +106,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) - decider := transcode.NewDecider(dataStore, fFmpeg) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, decider) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider) return router } @@ -122,8 +122,8 @@ func CreatePublicRouter() *public.Router { agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := transcode.GetTranscodingCache() - mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) diff --git a/core/archiver.go b/core/archiver.go index 88b2d5b0e..8305c4f6c 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -23,13 +23,13 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms transcode.MediaStreamer, ds model.DataStore, shares Share) Archiver { +func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver { return &archiver{ds: ds, ms: ms, shares: shares} } type archiver struct { ds model.DataStore - ms transcode.MediaStreamer + ms stream.MediaStreamer shares Share } @@ -177,7 +177,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med var r io.ReadCloser if format != "raw" && format != "" { - r, err = a.ms.DoStream(ctx, &mf, transcode.StreamRequest{Format: format, BitRate: bitrate}) + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index bfce641c9..4f7aed278 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -9,7 +9,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -45,7 +45,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) @@ -74,7 +74,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) @@ -105,7 +105,7 @@ var _ = Describe("Archiver", func() { } sh.On("Load", mock.Anything, "1").Return(share, nil) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipShare(context.Background(), "1", out) @@ -137,7 +137,7 @@ var _ = Describe("Archiver", func() { plRepo := &mockPlaylistRepository{} plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) ds.On("Playlist", mock.Anything).Return(plRepo) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) @@ -215,15 +215,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, type mockMediaStreamer struct { mock.Mock - transcode.MediaStreamer + stream.MediaStreamer } -func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { +func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { args := m.Called(ctx, mf, req) if args.Error(1) != nil { return nil, args.Error(1) } - return &transcode.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil + return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } type mockShare struct { diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 7202d02dd..d0cf0755d 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -382,7 +382,7 @@ func injectBeforeOutput(args []string, flag, value string) []string { // isLosslessOutputFormat returns true if the format is a lossless audio format // where preserving bit depth via -sample_fmt is meaningful. // Note: this covers only formats ffmpeg can produce as output. For the full set of -// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat. +// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat. func isLosslessOutputFormat(format string) bool { switch strings.ToLower(format) { case "flac", "alac", "wav", "aiff": diff --git a/core/transcode/aliases.go b/core/stream/aliases.go similarity index 99% rename from core/transcode/aliases.go rename to core/stream/aliases.go index 0c9bfcb44..6a4c8386f 100644 --- a/core/transcode/aliases.go +++ b/core/stream/aliases.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "slices" diff --git a/core/transcode/codec.go b/core/stream/codec.go similarity index 99% rename from core/transcode/codec.go rename to core/stream/codec.go index aa276d43f..88d1ae45d 100644 --- a/core/transcode/codec.go +++ b/core/stream/codec.go @@ -1,4 +1,4 @@ -package transcode +package stream import "strings" diff --git a/core/transcode/codec_test.go b/core/stream/codec_test.go similarity index 99% rename from core/transcode/codec_test.go rename to core/stream/codec_test.go index 6d3fbd78c..4c76b3ecd 100644 --- a/core/transcode/codec_test.go +++ b/core/stream/codec_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( . "github.com/onsi/ginkgo/v2" diff --git a/core/transcode/decider.go b/core/stream/decider.go similarity index 93% rename from core/transcode/decider.go rename to core/stream/decider.go index e870e9af7..5cca0cb0f 100644 --- a/core/transcode/decider.go +++ b/core/stream/decider.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -16,15 +16,15 @@ import ( const fallbackBitrate = 256 // kbps -// Decider is the core service interface for making transcoding decisions -type Decider interface { - MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) - CreateTranscodeParams(decision *Decision) (string, error) - ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) - ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest +// TranscodeDecider is the core service interface for making transcoding decisions +type TranscodeDecider interface { + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) + CreateTranscodeParams(decision *TranscodeDecision) (string, error) + ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) + ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request } -func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider { +func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { return &deciderService{ ds: ds, ff: ff, @@ -36,8 +36,8 @@ type deciderService struct { ff ffmpeg.FFmpeg } -func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) { - decision := &Decision{ +func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) { + decision := &TranscodeDecision{ MediaID: mf.ID, SourceUpdatedAt: mf.UpdatedAt, } @@ -126,8 +126,8 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, return decision, nil } -func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) StreamDetails { - sd := StreamDetails{ +func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details { + sd := Details{ Container: mf.Suffix, Duration: mf.Duration, Size: mf.Size, @@ -197,7 +197,7 @@ func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { // checkDirectPlayProfile returns "" if the profile matches (direct play OK), // or a typed reason string if it doesn't match. -func (s *deciderService) checkDirectPlayProfile(src *StreamDetails, profile *DirectPlayProfile, clientInfo *ClientInfo) string { +func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string { // Check protocol (only http for now) if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) { return "protocol not supported" @@ -234,7 +234,7 @@ func (s *deciderService) checkDirectPlayProfile(src *StreamDetails, profile *Dir // Returns the stream details and the internal transcoding format (which may differ from the // response container when a codec fallback occurs, e.g., "mp4"→"aac"). // Returns nil, "" if the profile cannot produce a valid output. -func (s *deciderService) computeTranscodedStream(ctx context.Context, src *StreamDetails, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) { +func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) { // Check protocol (only http for now) if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) { log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol) @@ -260,7 +260,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea return nil, "" } - ts := &StreamDetails{ + ts := &Details{ Container: responseContainer, Codec: strings.ToLower(profile.AudioCodec), SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), @@ -358,7 +358,7 @@ func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat stri // computeBitrate determines the target bitrate for the transcoded stream. // Returns false if the profile should be rejected. -func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { +func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { if src.IsLossless { if !targetIsLossless { if clientInfo.MaxTranscodingAudioBitrate > 0 { @@ -388,7 +388,7 @@ func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, // applyCodecLimitations applies codec profile limitations to the transcoded stream. // Returns false if the profile should be rejected. -func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { +func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { targetCodec := ts.Codec for _, codecProfile := range clientInfo.CodecProfiles { if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) { diff --git a/core/transcode/decider_test.go b/core/stream/decider_test.go similarity index 90% rename from core/transcode/decider_test.go rename to core/stream/decider_test.go index cc9b5fb60..42ebd84f1 100644 --- a/core/transcode/decider_test.go +++ b/core/stream/decider_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -35,7 +35,7 @@ var _ = Describe("Decider", func() { var ( ds *tests.MockDataStore ff *tests.MockFFmpeg - svc Decider + svc TranscodeDecider ctx context.Context ) @@ -47,7 +47,7 @@ var _ = Describe("Decider", func() { } ff = tests.NewMockFFmpeg("") auth.Init(ds) - svc = NewDecider(ds, ff) + svc = NewTranscodeDecider(ds, ff) }) Describe("MakeDecision", func() { @@ -59,7 +59,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) Expect(decision.CanTranscode).To(BeFalse()) @@ -73,7 +73,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) @@ -86,7 +86,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) @@ -99,7 +99,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) @@ -112,7 +112,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -124,7 +124,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -136,7 +136,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -148,7 +148,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -160,7 +160,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -172,7 +172,7 @@ var _ = Describe("Decider", func() { {Containers: []string{}, AudioCodecs: []string{}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -190,7 +190,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -210,7 +210,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -226,7 +226,7 @@ var _ = Describe("Decider", func() { {Container: "flac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -238,7 +238,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo @@ -252,7 +252,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps @@ -265,7 +265,7 @@ var _ = Describe("Decider", func() { {Container: "wav", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -278,7 +278,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate @@ -296,7 +296,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("opus")) @@ -315,7 +315,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -330,7 +330,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy @@ -341,7 +341,7 @@ var _ = Describe("Decider", func() { It("returns error when nothing matches", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) ci := &ClientInfo{} - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeFalse()) @@ -366,7 +366,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) @@ -388,7 +388,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -409,7 +409,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -430,7 +430,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) }) @@ -452,7 +452,7 @@ var _ = Describe("Decider", func() { }, } // Source profile is empty (not yet populated from scanner), so Equals("LC") fails - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported")) @@ -474,7 +474,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -495,7 +495,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported")) @@ -520,7 +520,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.Bitrate).To(Equal(96)) @@ -543,7 +543,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.Channels).To(Equal(2)) @@ -566,7 +566,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -588,7 +588,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) @@ -602,7 +602,7 @@ var _ = Describe("Decider", func() { {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) @@ -626,7 +626,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -641,7 +641,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -660,7 +660,7 @@ var _ = Describe("Decider", func() { {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("flac")) @@ -688,7 +688,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 @@ -715,7 +715,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit @@ -740,7 +740,7 @@ var _ = Describe("Decider", func() { }, MaxTranscodingAudioBitrate: 256, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.IsLossless).To(BeTrue()) Expect(decision.SourceStream.Codec).To(Equal("wavpack")) @@ -762,7 +762,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.IsLossless).To(BeFalse()) Expect(decision.CanDirectPlay).To(BeTrue()) @@ -778,7 +778,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("opus")) @@ -795,7 +795,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -811,7 +811,7 @@ var _ = Describe("Decider", func() { {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // TargetFormat is the internal format used for transcoding ("aac") @@ -829,7 +829,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -846,7 +846,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -860,7 +860,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(44100)) @@ -876,7 +876,7 @@ var _ = Describe("Decider", func() { {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 @@ -897,7 +897,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(HaveLen(3)) @@ -915,7 +915,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.Container).To(Equal("flac")) Expect(decision.SourceStream.Codec).To(Equal("flac")) @@ -939,7 +939,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -957,7 +957,7 @@ var _ = Describe("Decider", func() { } overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) Expect(decision.CanTranscode).To(BeFalse()) @@ -970,7 +970,7 @@ var _ = Describe("Decider", func() { } overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -986,7 +986,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -1001,7 +1001,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -1018,7 +1018,7 @@ var _ = Describe("Decider", func() { }, } // No override in context — client profiles used as-is - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -1039,7 +1039,7 @@ var _ = Describe("Decider", func() { } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) // Source bitrate 1000 > player cap 320, so direct play is not possible Expect(decision.CanDirectPlay).To(BeFalse()) @@ -1060,7 +1060,7 @@ var _ = Describe("Decider", func() { } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins @@ -1077,7 +1077,7 @@ var _ = Describe("Decider", func() { } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -1091,7 +1091,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock @@ -1104,7 +1104,7 @@ var _ = Describe("Decider", func() { {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock @@ -1133,7 +1133,7 @@ var _ = Describe("Decider", func() { Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2, } - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) probe, err := svc.ensureProbed(ctx, mf) Expect(err).ToNot(HaveOccurred()) Expect(mf.ProbeData).ToNot(BeEmpty()) @@ -1154,7 +1154,7 @@ var _ = Describe("Decider", func() { // Set error on mock — if ffprobe were called, this would fail ff.Error = fmt.Errorf("should not be called") - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) probe, err := svc.ensureProbed(ctx, mf) Expect(err).ToNot(HaveOccurred()) Expect(probe).To(BeNil()) @@ -1164,7 +1164,7 @@ var _ = Describe("Decider", func() { mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"} ff.Error = fmt.Errorf("ffprobe not found") - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) _, err := svc.ensureProbed(ctx, mf) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("probing media file")) @@ -1179,7 +1179,7 @@ var _ = Describe("Decider", func() { // Set a result — if ffprobe were called, ProbeData would be populated ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"} - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) probe, err := svc.ensureProbed(ctx, mf) Expect(err).ToNot(HaveOccurred()) Expect(probe).To(BeNil()) diff --git a/core/transcode/legacy_client.go b/core/stream/legacy_client.go similarity index 91% rename from core/transcode/legacy_client.go rename to core/stream/legacy_client.go index 83190ec92..e813a3520 100644 --- a/core/transcode/legacy_client.go +++ b/core/stream/legacy_client.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -46,10 +46,9 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int } // ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters -// into a fully specified StreamRequest. -func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest { - var req StreamRequest - req.ID = mf.ID +// into a fully specified Request. +func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request { + var req Request req.Offset = offset if reqFormat == "raw" { @@ -58,7 +57,7 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) - decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true}) + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) if err != nil { log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) req.Format = "raw" diff --git a/core/transcode/legacy_client_test.go b/core/stream/legacy_client_test.go similarity index 99% rename from core/transcode/legacy_client_test.go rename to core/stream/legacy_client_test.go index 9628764f4..d163464a8 100644 --- a/core/transcode/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "github.com/navidrome/navidrome/conf" diff --git a/core/transcode/limitations.go b/core/stream/limitations.go similarity index 96% rename from core/transcode/limitations.go rename to core/stream/limitations.go index aefc87d97..ab70d6c07 100644 --- a/core/transcode/limitations.go +++ b/core/stream/limitations.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "strconv" @@ -16,7 +16,7 @@ const ( // checkLimitations checks codec profile limitations against source stream details. // Returns "" if all limitations pass, or a typed reason string for the first failure. -func checkLimitations(src *StreamDetails, limitations []Limitation) string { +func checkLimitations(src *Details, limitations []Limitation) string { for _, lim := range limitations { var ok bool var reason string @@ -50,7 +50,7 @@ func checkLimitations(src *StreamDetails, limitations []Limitation) string { // applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. // Returns the adjustment result. -func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { +func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult { switch lim.Name { case LimitationAudioChannels: return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) diff --git a/core/transcode/media_streamer.go b/core/stream/media_streamer.go similarity index 92% rename from core/transcode/media_streamer.go rename to core/stream/media_streamer.go index 88fb61e2d..062a13884 100644 --- a/core/transcode/media_streamer.go +++ b/core/stream/media_streamer.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -20,8 +20,7 @@ import ( ) type MediaStreamer interface { - NewStream(ctx context.Context, req StreamRequest) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) + NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) } type TranscodingCache cache.FileCache @@ -52,16 +51,7 @@ func (j *streamJob) Key() string { return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) } -func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) { - mf, err := ms.ds.MediaFile(ctx).Get(req.ID) - if err != nil { - return nil, err - } - - return ms.DoStream(ctx, mf, req) -} - -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) { +func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) { var format string var bitRate int var cached bool diff --git a/core/transcode/media_streamer_test.go b/core/stream/media_streamer_test.go similarity index 71% rename from core/transcode/media_streamer_test.go rename to core/stream/media_streamer_test.go index f49dcb8d8..1bc21e239 100644 --- a/core/transcode/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -1,4 +1,4 @@ -package transcode_test +package stream_test import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -16,7 +16,7 @@ import ( ) var _ = Describe("MediaStreamer", func() { - var streamer transcode.MediaStreamer + var streamer stream.MediaStreamer var ds model.DataStore ffmpeg := tests.NewMockFFmpeg("fake data") ctx := log.NewContext(context.TODO()) @@ -29,39 +29,45 @@ var _ = Describe("MediaStreamer", func() { ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) - testCache := transcode.NewTranscodingCache() + testCache := stream.NewTranscodingCache() Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) - streamer = transcode.NewMediaStreamer(ds, ffmpeg, testCache) + streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { _ = os.RemoveAll(conf.Server.CacheFolder) }) Context("NewStream", func() { + var mf *model.MediaFile + BeforeEach(func() { + var err error + mf, err = ds.MediaFile(ctx).Get("123") + Expect(err).ToNot(HaveOccurred()) + }) It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "raw"}) + s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if no format is specified (direct play)", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123"}) + s, err := streamer.NewStream(ctx, mf, stream.Request{}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 64}) + s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) It("returns a seekable stream if the file is complete in the cache", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) + s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) _, _ = io.ReadAll(s) _ = s.Close() Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - s, err = streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) + s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/transcode_suite_test.go b/core/stream/stream_suite_test.go similarity index 74% rename from core/transcode/transcode_suite_test.go rename to core/stream/stream_suite_test.go index e35471b05..36e9e7f43 100644 --- a/core/transcode/transcode_suite_test.go +++ b/core/stream/stream_suite_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestTranscode(t *testing.T) { +func TestStream(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "Transcode Suite") + RunSpecs(t, "Stream Suite") } diff --git a/core/transcode/token.go b/core/stream/token.go similarity index 79% rename from core/transcode/token.go rename to core/stream/token.go index e110320d0..24a154b54 100644 --- a/core/transcode/token.go +++ b/core/stream/token.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -12,7 +12,7 @@ import ( "github.com/navidrome/navidrome/model" ) -const tokenTTL = 12 * time.Hour +const tokenTTL = 48 * time.Hour // params contains the parameters extracted from a transcode token. // TargetBitrate is in kilobits per second (kbps). @@ -29,7 +29,7 @@ type params struct { // toClaimsMap converts a Decision into a JWT claims map for token encoding. // Only non-zero transcode fields are included. -func (d *Decision) toClaimsMap() map[string]any { +func (d *TranscodeDecision) toClaimsMap() map[string]any { m := map[string]any{ "mid": d.MediaID, "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), @@ -110,7 +110,7 @@ func getIntClaim(token jwt.Token, key string) int { return 0 } -func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { +func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) { return auth.EncodeToken(decision.toClaimsMap()) } @@ -122,28 +122,21 @@ func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) return paramsFromToken(token) } -func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) { +func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) { p, err := s.parseTranscodeParams(token) if err != nil { - return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) + return Request{}, errors.Join(ErrTokenInvalid, err) } - if p.MediaID != mediaID { - return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID) - } - mf, err := s.ds.MediaFile(ctx).Get(mediaID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return StreamRequest{}, nil, ErrMediaNotFound - } - return StreamRequest{}, nil, err + if p.MediaID != mf.ID { + return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID) } if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { - log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, + log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID, "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) - return StreamRequest{}, nil, ErrTokenStale + return Request{}, ErrTokenStale } - req := StreamRequest{ID: mediaID, Offset: offset} + req := Request{Offset: offset} if !p.DirectPlay && p.TargetFormat != "" { req.Format = p.TargetFormat req.BitRate = p.TargetBitrate @@ -151,5 +144,5 @@ func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token stri req.BitDepth = p.TargetBitDepth req.Channels = p.TargetChannels } - return req, mf, nil + return req, nil } diff --git a/core/transcode/token_test.go b/core/stream/token_test.go similarity index 84% rename from core/transcode/token_test.go rename to core/stream/token_test.go index b9b74c8fc..7409a7532 100644 --- a/core/transcode/token_test.go +++ b/core/stream/token_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -16,7 +16,7 @@ var _ = Describe("Token", func() { var ( ds *tests.MockDataStore ff *tests.MockFFmpeg - svc Decider + svc TranscodeDecider ctx context.Context ) @@ -28,7 +28,7 @@ var _ = Describe("Token", func() { } ff = tests.NewMockFFmpeg("") auth.Init(ds) - svc = NewDecider(ds, ff) + svc = NewTranscodeDecider(ds, ff) }) Describe("Token round-trip", func() { @@ -43,7 +43,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a direct play token", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-123", CanDirectPlay: true, SourceUpdatedAt: sourceTime, @@ -61,7 +61,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a transcode token with kbps bitrate", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-456", CanDirectPlay: false, CanTranscode: true, @@ -84,7 +84,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a transcode token with sample rate", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-789", CanDirectPlay: false, CanTranscode: true, @@ -107,7 +107,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a transcode token with bit depth", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-bd", CanDirectPlay: false, CanTranscode: true, @@ -127,7 +127,7 @@ var _ = Describe("Token", func() { }) It("omits bit depth from token when 0", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-nobd", CanDirectPlay: false, CanTranscode: true, @@ -145,7 +145,7 @@ var _ = Describe("Token", func() { }) It("omits sample rate from token when 0", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-100", CanDirectPlay: false, CanTranscode: true, @@ -164,7 +164,7 @@ var _ = Describe("Token", func() { It("truncates SourceUpdatedAt to seconds", func() { timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-trunc", CanDirectPlay: true, SourceUpdatedAt: timeWithNanos, @@ -184,19 +184,14 @@ var _ = Describe("Token", func() { }) Describe("ResolveRequestFromToken", func() { - var ( - mockMFRepo *tests.MockMediaFileRepo - sourceTime time.Time - ) + var sourceTime time.Time BeforeEach(func() { sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) - mockMFRepo = &tests.MockMediaFileRepo{} - ds.MockedMediaFile = mockMFRepo }) createTokenForMedia := func(mediaID string, updatedAt time.Time) string { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: mediaID, CanDirectPlay: true, SourceUpdatedAt: updatedAt, @@ -206,46 +201,35 @@ var _ = Describe("Token", func() { return token } - It("returns stream request and media file for valid token", func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", UpdatedAt: sourceTime}, - }) + It("returns stream request for valid token", func() { + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} token := createTokenForMedia("song-1", sourceTime) - req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).ToNot(HaveOccurred()) - Expect(req.ID).To(Equal("song-1")) Expect(req.Format).To(BeEmpty()) // direct play has no target format - Expect(mf.ID).To(Equal("song-1")) }) It("returns ErrTokenInvalid for invalid token", func() { - _, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0) + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} + _, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0) Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) }) It("returns ErrTokenInvalid when mediaID does not match token", func() { + mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime} token := createTokenForMedia("song-1", sourceTime) - _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0) + _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) }) - It("returns ErrMediaNotFound when media file does not exist", func() { - token := createTokenForMedia("gone-id", sourceTime) - - _, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0) - Expect(err).To(MatchError(ErrMediaNotFound)) - }) - It("returns ErrTokenStale when media file has changed", func() { newTime := sourceTime.Add(1 * time.Hour) - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", UpdatedAt: newTime}, - }) + mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime} token := createTokenForMedia("song-1", sourceTime) - _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ErrTokenStale)) }) }) diff --git a/core/transcode/types.go b/core/stream/types.go similarity index 78% rename from core/transcode/types.go rename to core/stream/types.go index d7a63fbc4..0cb4ac47d 100644 --- a/core/transcode/types.go +++ b/core/stream/types.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "errors" @@ -6,21 +6,19 @@ import ( ) var ( - ErrTokenInvalid = errors.New("invalid or expired transcode token") - ErrMediaNotFound = errors.New("media file not found") - ErrTokenStale = errors.New("transcode token is stale: media file has changed") + ErrTokenInvalid = errors.New("invalid or expired transcode token") + ErrTokenStale = errors.New("transcode token is stale: media file has changed") ) -// DecisionOptions controls optional behavior of MakeDecision. -type DecisionOptions struct { - // SkipProbe prevents MakeDecision from running ffprobe on the media file. +// TranscodeOptions controls optional behavior of MakeTranscodeDecision. +type TranscodeOptions struct { + // SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file. // When true, source stream details are derived from tag metadata only. SkipProbe bool } -// StreamRequest contains the resolved parameters for creating a media stream. -type StreamRequest struct { - ID string +// Request contains the resolved parameters for creating a media stream. +type Request struct { Format string BitRate int // kbps SampleRate int @@ -100,9 +98,9 @@ const ( CodecProfileTypeAudio = "AudioCodec" ) -// Decision represents the internal decision result. +// TranscodeDecision represents the internal decision result. // All bitrate values are in kilobits per second (kbps). -type Decision struct { +type TranscodeDecision struct { MediaID string CanDirectPlay bool CanTranscode bool @@ -113,14 +111,14 @@ type Decision struct { TargetChannels int TargetSampleRate int TargetBitDepth int - SourceStream StreamDetails + SourceStream Details SourceUpdatedAt time.Time - TranscodeStream *StreamDetails + TranscodeStream *Details } -// StreamDetails describes audio stream properties. +// Details describes audio stream properties. // Bitrate is in kilobits per second (kbps). -type StreamDetails struct { +type Details struct { Container string Codec string Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data. diff --git a/core/wire_providers.go b/core/wire_providers.go index 20b5eb9a5..153df7262 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,12 +10,12 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" ) var Set = wire.NewSet( - transcode.NewMediaStreamer, - transcode.GetTranscodingCache, + stream.NewMediaStreamer, + stream.GetTranscodingCache, NewArchiver, NewPlayers, NewShare, @@ -23,7 +23,7 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, - transcode.NewDecider, + stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index e55130ffd..be8a894d5 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -28,7 +28,7 @@ import ( "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -284,25 +284,21 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// spyStreamer captures the StreamRequest passed to DoStream for test assertions, +// spyStreamer captures the Request passed to NewStream for test assertions, // then returns a minimal fake Stream so the handler completes without error. type spyStreamer struct { - LastRequest transcode.StreamRequest + LastRequest stream.Request LastMediaFile *model.MediaFile } -func (s *spyStreamer) NewStream(ctx context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { - return nil, model.ErrNotFound -} - -func (s *spyStreamer) DoStream(_ context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { +func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { s.LastRequest = req s.LastMediaFile = mf format := req.Format if format == "" || format == "raw" { format = mf.Suffix } - return transcode.NewTestStream(mf, format, req.BitRate), nil + return stream.NewTestStream(mf, format, req.BitRate), nil } // noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. @@ -391,12 +387,12 @@ func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ transcode.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -477,7 +473,7 @@ func setupTestDB() { // Create the Subsonic Router with real DS, spy streamer, and real Decider spy = &spyStreamer{} - decider := transcode.NewDecider(ds, noopFFmpeg{}) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index a147a2ac8..6cdf8b44a 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,8 +7,9 @@ import ( "strconv" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" ) @@ -23,8 +24,19 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, transcode.StreamRequest{ - ID: info.id, Format: info.format, BitRate: info.bitrate, + mf, err := pub.ds.MediaFile(ctx).Get(info.id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + } else { + log.Error(ctx, "Error retrieving media file for shared stream", "id", info.id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + } + return + } + + stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{ + Format: info.format, BitRate: info.bitrate, }) if err != nil { log.Error(ctx, "Error starting shared stream", err) diff --git a/server/public/public.go b/server/public/public.go index 7d8a4e007..5e3407c19 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -21,14 +21,14 @@ import ( type Router struct { http.Handler artwork artwork.Artwork - streamer transcode.MediaStreamer + streamer stream.MediaStreamer archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.MediaStreamer, share core.Share, archiver core.Archiver) *Router { +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 6f355d161..e91c02aa4 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,7 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -39,7 +39,7 @@ type Router struct { http.Handler ds model.DataStore artwork artwork.Artwork - streamer transcode.MediaStreamer + streamer stream.MediaStreamer archiver core.Archiver players core.Players provider external.Provider @@ -51,13 +51,13 @@ type Router struct { playback playback.PlaybackServer metrics metrics.Metrics lyrics lyricssvc.Lyrics - transcodeDecision transcode.Decider + transcodeDecision stream.TranscodeDecider } -func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.MediaStreamer, archiver core.Archiver, +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, - metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision transcode.Decider, + metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, ) *Router { r := &Router{ ds: ds, diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 753e408c1..ebebb97f1 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -17,7 +17,7 @@ import ( "github.com/navidrome/navidrome/utils/req" ) -func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *transcode.Stream, id string) { +func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *stream.Stream, id string) { if stream.Seekable() { http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) } else { @@ -66,7 +66,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su } streamReq := api.transcodeDecision.ResolveRequest(ctx, mf, format, maxBitRate, timeOffset) - stream, err := api.streamer.DoStream(ctx, mf, streamReq) + stream, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { return nil, err } @@ -136,7 +136,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: streamReq := api.transcodeDecision.ResolveRequest(ctx, v, format, maxBitRate, 0) - stream, err := api.streamer.DoStream(ctx, v, streamReq) + stream, err := api.streamer.NewStream(ctx, v, streamReq) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index ffc4cfcd7..792504871 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,7 +8,7 @@ import ( "slices" "strconv" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -59,10 +59,10 @@ type limitationRequest struct { Required bool `json:"required,omitempty"` } -// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct. +// toCoreClientInfo converts the API request struct to the stream.ClientInfo struct. // The OpenSubsonic spec uses bps for bitrate values; core uses kbps. -func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { - ci := &transcode.ClientInfo{ +func (r *clientInfoRequest) toCoreClientInfo() *stream.ClientInfo { + ci := &stream.ClientInfo{ Name: r.Name, Platform: r.Platform, MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate), @@ -70,7 +70,7 @@ func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { } for _, dp := range r.DirectPlayProfiles { - ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{ + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, stream.DirectPlayProfile{ Containers: dp.Containers, AudioCodecs: dp.AudioCodecs, Protocols: dp.Protocols, @@ -79,7 +79,7 @@ func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { } for _, tp := range r.TranscodingProfiles { - ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{ + ci.TranscodingProfiles = append(ci.TranscodingProfiles, stream.Profile{ Container: tp.Container, AudioCodec: tp.AudioCodec, Protocol: tp.Protocol, @@ -88,19 +88,19 @@ func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { } for _, cp := range r.CodecProfiles { - coreCP := transcode.CodecProfile{ + coreCP := stream.CodecProfile{ Type: cp.Type, Name: cp.Name, } for _, lim := range cp.Limitations { - coreLim := transcode.Limitation{ + coreLim := stream.Limitation{ Name: lim.Name, Comparison: lim.Comparison, Values: lim.Values, Required: lim.Required, } // Convert audioBitrate limitation values from bps to kbps - if lim.Name == transcode.LimitationAudioBitrate { + if lim.Name == stream.LimitationAudioBitrate { coreLim.Values = convertBitrateValues(lim.Values) } coreCP.Limitations = append(coreCP.Limitations, coreLim) @@ -178,8 +178,8 @@ func isValidMediaType(mediaType string) bool { } var validProtocols = []string{ - transcode.ProtocolHTTP, - transcode.ProtocolHLS, + stream.ProtocolHTTP, + stream.ProtocolHLS, } func isValidProtocol(p string) bool { @@ -187,7 +187,7 @@ func isValidProtocol(p string) bool { } var validCodecProfileTypes = []string{ - transcode.CodecProfileTypeAudio, + stream.CodecProfileTypeAudio, } func isValidCodecProfileType(t string) bool { @@ -195,11 +195,11 @@ func isValidCodecProfileType(t string) bool { } var validLimitationNames = []string{ - transcode.LimitationAudioChannels, - transcode.LimitationAudioBitrate, - transcode.LimitationAudioProfile, - transcode.LimitationAudioSamplerate, - transcode.LimitationAudioBitdepth, + stream.LimitationAudioChannels, + stream.LimitationAudioBitrate, + stream.LimitationAudioProfile, + stream.LimitationAudioSamplerate, + stream.LimitationAudioBitdepth, } func isValidLimitationName(n string) bool { @@ -207,10 +207,10 @@ func isValidLimitationName(n string) bool { } var validComparisons = []string{ - transcode.ComparisonEquals, - transcode.ComparisonNotEquals, - transcode.ComparisonLessThanEqual, - transcode.ComparisonGreaterThanEqual, + stream.ComparisonEquals, + stream.ComparisonNotEquals, + stream.ComparisonLessThanEqual, + stream.ComparisonGreaterThanEqual, } func isValidComparison(c string) bool { @@ -218,9 +218,9 @@ func isValidComparison(c string) bool { } // toResponseStreamDetails converts a core StreamDetails to the API response type. -func toResponseStreamDetails(sd *transcode.StreamDetails) *responses.StreamDetails { +func toResponseStreamDetails(sd *stream.Details) *responses.StreamDetails { return &responses.StreamDetails{ - Protocol: transcode.ProtocolHTTP, // TODO: derive from decision when HLS support is added + Protocol: stream.ProtocolHTTP, // TODO: derive from decision when HLS support is added Container: sd.Container, Codec: sd.Codec, AudioBitrate: int32(kbpsToBps(sd.Bitrate)), @@ -232,7 +232,7 @@ func toResponseStreamDetails(sd *transcode.StreamDetails) *responses.StreamDetai } // GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. -// It receives client capabilities and returns a decision on whether to direct play or transcode. +// It receives client capabilities and returns a decision on whether to direct play or stream. func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") @@ -279,7 +279,7 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } // Make the decision - decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{}) + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") @@ -343,13 +343,23 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, nil } + // Fetch the media file + mf, err := api.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Not Found", http.StatusNotFound) + } else { + log.Error(ctx, "Error retrieving media file", "mediaID", mediaID, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return nil, nil + } + // Validate the token and resolve streaming parameters - streamReq, mf, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mediaID, p.IntOr("offset", 0)) + streamReq, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mf, p.IntOr("offset", 0)) if err != nil { switch { - case errors.Is(err, transcode.ErrMediaNotFound): - http.Error(w, "Not Found", http.StatusNotFound) - case errors.Is(err, transcode.ErrTokenInvalid), errors.Is(err, transcode.ErrTokenStale): + case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale): http.Error(w, "Gone", http.StatusGone) default: log.Error(ctx, "Error validating transcode params", err) @@ -358,8 +368,8 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, nil } - // Create stream (use DoStream to avoid duplicate DB fetch) - stream, err := api.streamer.DoStream(ctx, mf, streamReq) + // Create stream + stream, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { log.Error(ctx, "Error creating stream", "mediaID", mediaID, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 717eeb1f5..8db729c6d 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -7,7 +7,7 @@ import ( "net/http" "net/http/httptest" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -156,10 +156,10 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo.SetData(model.MediaFiles{ {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}, }) - mockTD.decision = &transcode.Decision{ + mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-1", CanDirectPlay: true, - SourceStream: transcode.StreamDetails{ + SourceStream: stream.Details{ Container: "mp3", Codec: "mp3", Bitrate: 320, SampleRate: 44100, Channels: 2, }, @@ -184,18 +184,18 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo.SetData(model.MediaFiles{ {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, }) - mockTD.decision = &transcode.Decision{ + mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-2", CanDirectPlay: false, CanTranscode: true, TargetFormat: "mp3", TargetBitrate: 256, TranscodeReasons: []string{"container not supported"}, - SourceStream: transcode.StreamDetails{ + SourceStream: stream.Details{ Container: "flac", Codec: "flac", Bitrate: 1000, SampleRate: 96000, BitDepth: 24, Channels: 2, }, - TranscodeStream: &transcode.StreamDetails{ + TranscodeStream: &stream.Details{ Container: "mp3", Codec: "mp3", Bitrate: 256, SampleRate: 96000, Channels: 2, }, @@ -231,7 +231,8 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 for invalid or mismatched token", func() { - mockTD.resolveErr = transcode.ErrTokenInvalid + mockMFRepo.SetData(model.MediaFiles{{ID: "123"}}) + mockTD.resolveErr = stream.ErrTokenInvalid r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -240,7 +241,7 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 404 when media file not found", func() { - mockTD.resolveErr = transcode.ErrMediaNotFound + // mockMFRepo has no data, so Get() returns ErrNotFound r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -249,7 +250,8 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 when media file has changed (stale token)", func() { - mockTD.resolveErr = transcode.ErrTokenStale + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) + mockTD.resolveErr = stream.ErrTokenStale r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -260,14 +262,13 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) - mockTD.resolvedReq = transcode.StreamRequest{ID: "song-1"} - mockTD.resolvedMF = &model.MediaFile{ID: "song-1"} + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) + mockTD.resolvedReq = stream.Request{} r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token") _, _ = router.GetTranscodeStream(w, r) Expect(fakeStreamer.captured).ToNot(BeNil()) - Expect(fakeStreamer.captured.ID).To(Equal("song-1")) Expect(fakeStreamer.captured.Format).To(BeEmpty()) Expect(fakeStreamer.captured.BitRate).To(BeZero()) Expect(fakeStreamer.captured.SampleRate).To(BeZero()) @@ -278,21 +279,19 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) - mockTD.resolvedReq = transcode.StreamRequest{ - ID: "song-2", + mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) + mockTD.resolvedReq = stream.Request{ Format: "mp3", BitRate: 256, SampleRate: 44100, BitDepth: 16, Channels: 2, } - mockTD.resolvedMF = &model.MediaFile{ID: "song-2"} r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10") _, _ = router.GetTranscodeStream(w, r) Expect(fakeStreamer.captured).ToNot(BeNil()) - Expect(fakeStreamer.captured.ID).To(Equal("song-2")) Expect(fakeStreamer.captured.Format).To(Equal("mp3")) Expect(fakeStreamer.captured.BitRate).To(Equal(256)) Expect(fakeStreamer.captured.SampleRate).To(Equal(44100)) @@ -353,38 +352,37 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { return r } -// mockTranscodeDecision is a test double for transcode.Decider +// mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { - decision *transcode.Decision + decision *stream.TranscodeDecision token string tokenErr error - resolvedReq transcode.StreamRequest - resolvedMF *model.MediaFile + resolvedReq stream.Request resolveErr error } -func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo, _ transcode.DecisionOptions) (*transcode.Decision, error) { +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { if m.decision != nil { return m.decision, nil } - return &transcode.Decision{}, nil + return &stream.TranscodeDecision{}, nil } -func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest { - return transcode.StreamRequest{Format: "raw"} +func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) stream.Request { + return stream.Request{Format: "raw"} } -func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) { +func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { return m.token, m.tokenErr } -func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ string, offset int) (transcode.StreamRequest, *model.MediaFile, error) { +func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ *model.MediaFile, offset int) (stream.Request, error) { if m.resolveErr != nil { - return transcode.StreamRequest{}, nil, m.resolveErr + return stream.Request{}, m.resolveErr } req := m.resolvedReq req.Offset = offset - return req, m.resolvedMF, nil + return req, nil } // fakeMediaStreamer captures the StreamRequest and returns a sentinel error, @@ -392,15 +390,10 @@ func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ str var errStreamCaptured = errors.New("stream request captured") type fakeMediaStreamer struct { - captured *transcode.StreamRequest + captured *stream.Request } -func (f *fakeMediaStreamer) NewStream(_ context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { - f.captured = &req - return nil, errStreamCaptured -} - -func (f *fakeMediaStreamer) DoStream(_ context.Context, _ *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { +func (f *fakeMediaStreamer) NewStream(_ context.Context, _ *model.MediaFile, req stream.Request) (*stream.Stream, error) { f.captured = &req return nil, errStreamCaptured } From 053a0fd6c0a63ec46ec41192df5992f95a115233 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 10 Mar 2026 17:08:35 -0400 Subject: [PATCH 075/596] fix: prevent raw file being returned when explicit transcode format is requested When a client requests transcoding with an explicit format (e.g., format=opus) but no maxBitRate, buildLegacyClientInfo was adding a direct play profile matching the source format. Since there was no bitrate constraint to block it, MakeDecision would match the source against the direct play profile and return the raw file instead of transcoding. This fix only adds the direct play profile when no explicit format was requested (bitrate-only downsampling) or when the requested format matches the source format (allowing direct play when no actual transcoding is needed). --- core/stream/legacy_client.go | 12 ++- core/stream/legacy_client_test.go | 20 ++++- server/e2e/subsonic_stream_test.go | 127 +++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 server/e2e/subsonic_stream_test.go diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index e813a3520..82d01f5de 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -2,6 +2,7 @@ package stream import ( "context" + "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -25,8 +26,15 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int } if targetFormat != "" { - ci.DirectPlayProfiles = []DirectPlayProfile{ - {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + // Add a direct play profile for the source format when no explicit + // format was requested (bitrate-only downsampling) or when the + // requested format matches the source. When the client explicitly + // requests a different format, direct play must not match the + // source — otherwise the source is returned untranscoded. + if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) { + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + } } ci.TranscodingProfiles = []Profile{ {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index d163464a8..3557a9314 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -25,10 +25,25 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP)) Expect(ci.MaxAudioBitrate).To(BeZero()) Expect(ci.MaxTranscodingAudioBitrate).To(BeZero()) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("does not add direct play profile when explicit format differs from source (no bitrate)", func() { + ci := buildLegacyClientInfo(mf, "opus", 0) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("adds direct play profile when explicit format matches source format", func() { + ci := buildLegacyClientInfo(mf, "flac", 0) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac")) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()})) - Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP})) }) It("sets transcoding profile and bitrate for explicit format with bitrate", func() { @@ -39,8 +54,7 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) Expect(ci.MaxAudioBitrate).To(Equal(192)) Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) }) It("returns direct play profile when no format and no bitrate", func() { diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go new file mode 100644 index 000000000..1ca99510b --- /dev/null +++ b/server/e2e/subsonic_stream_test.go @@ -0,0 +1,127 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("stream.view (legacy streaming)", Ordered, func() { + var ( + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + ) + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + byTitle := map[string]string{} + for _, s := range songs { + byTitle[s.Title] = s.ID + } + mp3TrackID = byTitle["Come Together"] + Expect(mp3TrackID).ToNot(BeEmpty()) + flacTrackID = byTitle["TC FLAC Standard"] + Expect(flacTrackID).ToNot(BeEmpty()) + }) + + Describe("raw / direct play", func() { + It("streams raw when no format or maxBitRate is specified", func() { + w := doRawReq("stream", "id", flacTrackID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams raw when format=raw is explicitly requested", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "raw") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams raw when maxBitRate is >= source bitrate", func() { + w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "1000") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams raw when format matches source and bitrate is not lower", func() { + w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "320") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + }) + + Describe("transcoding with explicit format", func() { + It("transcodes to mp3 when format=mp3 is requested", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "mp3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + // Should use the mp3 default bitrate (192kbps) + Expect(spy.LastRequest.BitRate).To(Equal(192)) + }) + + It("transcodes to opus when format=opus is requested (no maxBitRate)", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + // Should use the opus default bitrate (128kbps) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("transcodes to opus with specified maxBitRate", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus", "maxBitRate", "192") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(192)) + }) + + It("transcodes to mp3 with specified maxBitRate", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "maxBitRate", "128") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("transcodes MP3 to opus when format=opus is requested", func() { + w := doRawReq("stream", "id", mp3TrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + }) + + It("transcodes same format when maxBitRate is lower than source", func() { + w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "128") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + }) + + Describe("downsampling with maxBitRate only", func() { + It("transcodes using default downsampling format when maxBitRate < source bitrate", func() { + conf.Server.DefaultDownsamplingFormat = "opus" + w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "192") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(192)) + }) + + It("streams raw when maxBitRate >= source bitrate (no downsampling needed)", func() { + conf.Server.DefaultDownsamplingFormat = "opus" + w := doRawReq("stream", "id", mp3TrackID, "maxBitRate", "320") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + }) + + Describe("timeOffset", func() { + It("passes timeOffset to the stream request", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "timeOffset", "30") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Offset).To(Equal(30)) + }) + }) +}) From 75e5bc4e819b4d5ab053603b11fe4694ad53621e Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 10 Mar 2026 17:19:25 -0400 Subject: [PATCH 076/596] refactor: rename spy to streamerSpy in e2e tests for clarity Renamed the spy variable to streamerSpy across all e2e test files so that its purpose is immediately clear without needing to look up the declaration. --- server/e2e/e2e_suite_test.go | 16 ++++----- server/e2e/subsonic_media_retrieval_test.go | 32 ++++++++--------- server/e2e/subsonic_stream_test.go | 38 ++++++++++----------- server/e2e/subsonic_transcode_test.go | 10 +++--- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index be8a894d5..dc8b178d8 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -71,11 +71,11 @@ const ( // Shared test state var ( - ctx context.Context - ds *tests.MockDataStore - router *subsonic.Router - spy *spyStreamer - lib model.Library + ctx context.Context + ds *tests.MockDataStore + router *subsonic.Router + streamerSpy *spyStreamer + lib model.Library // Snapshot paths for fast DB restore dbFilePath string @@ -471,15 +471,15 @@ func setupTestDB() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) - // Create the Subsonic Router with real DS, spy streamer, and real Decider - spy = &spyStreamer{} + // Create the Subsonic Router with real DS, streamer spy, and real Decider + streamerSpy = &spyStreamer{} decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, - spy, + streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go index 465082acb..f51386672 100644 --- a/server/e2e/subsonic_media_retrieval_test.go +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -38,22 +38,22 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { w := doRawReq("stream", "id", trackID) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) It("streams raw when format=raw", func() { w := doRawReq("stream", "id", trackID, "format", "raw") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) It("transcodes to different format with bitrate", func() { w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) It("downsamples when only maxBitRate is specified (lower than source)", func() { @@ -63,45 +63,45 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { w := doRawReq("stream", "id", trackID, "maxBitRate", "128") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) It("streams raw when maxBitRate is higher than source", func() { w := doRawReq("stream", "id", trackID, "maxBitRate", "999") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) It("streams raw when format matches source and no bitrate reduction", func() { w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "320") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) It("transcodes when same format but lower bitrate", func() { w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "128") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("mp3")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) It("falls back to raw for unknown format", func() { w := doRawReq("stream", "id", trackID, "format", "xyz") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) It("passes timeOffset through", func() { w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128", "timeOffset", "30") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) - Expect(spy.LastRequest.Offset).To(Equal(30)) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) }) }) @@ -131,7 +131,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { w := doRawReq("download", "id", trackID) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) It("downloads with explicit format and bitrate", func() { @@ -141,8 +141,8 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { w := doRawReq("download", "id", trackID, "format", "opus", "bitrate", "128") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) It("returns error when downloads are disabled", func() { diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go index 1ca99510b..d144dc4eb 100644 --- a/server/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -33,25 +33,25 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { It("streams raw when no format or maxBitRate is specified", func() { w := doRawReq("stream", "id", flacTrackID) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) }) It("streams raw when format=raw is explicitly requested", func() { w := doRawReq("stream", "id", flacTrackID, "format", "raw") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) }) It("streams raw when maxBitRate is >= source bitrate", func() { w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "1000") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) }) It("streams raw when format matches source and bitrate is not lower", func() { w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "320") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) }) }) @@ -59,44 +59,44 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { It("transcodes to mp3 when format=mp3 is requested", func() { w := doRawReq("stream", "id", flacTrackID, "format", "mp3") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) // Should use the mp3 default bitrate (192kbps) - Expect(spy.LastRequest.BitRate).To(Equal(192)) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(192)) }) It("transcodes to opus when format=opus is requested (no maxBitRate)", func() { w := doRawReq("stream", "id", flacTrackID, "format", "opus") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) // Should use the opus default bitrate (128kbps) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) It("transcodes to opus with specified maxBitRate", func() { w := doRawReq("stream", "id", flacTrackID, "format", "opus", "maxBitRate", "192") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) - Expect(spy.LastRequest.BitRate).To(Equal(192)) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(192)) }) It("transcodes to mp3 with specified maxBitRate", func() { w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "maxBitRate", "128") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("mp3")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) It("transcodes MP3 to opus when format=opus is requested", func() { w := doRawReq("stream", "id", mp3TrackID, "format", "opus") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) }) It("transcodes same format when maxBitRate is lower than source", func() { w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "128") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("mp3")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) }) @@ -105,15 +105,15 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { conf.Server.DefaultDownsamplingFormat = "opus" w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "192") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("opus")) - Expect(spy.LastRequest.BitRate).To(Equal(192)) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(192)) }) It("streams raw when maxBitRate >= source bitrate (no downsampling needed)", func() { conf.Server.DefaultDownsamplingFormat = "opus" w := doRawReq("stream", "id", mp3TrackID, "maxBitRate", "320") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) }) }) @@ -121,7 +121,7 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { It("passes timeOffset to the stream request", func() { w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "timeOffset", "30") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Offset).To(Equal(30)) + Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) }) }) }) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index 64d788b30..a9a180dc8 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -544,8 +544,8 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // Stream using the token w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("mp3")) - Expect(spy.LastRequest.BitRate).To(Equal(128)) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) }) }) @@ -617,7 +617,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) Expect(w.Code).To(Equal(http.StatusOK)) // Direct play: format should be "raw" or empty - Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) }) It("streams transcoded FLAC to MP3", func() { @@ -631,7 +631,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // Stream using the token w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) }) It("passes offset through to stream request", func() { @@ -645,7 +645,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token, "offset", "30") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(spy.LastRequest.Offset).To(Equal(30)) + Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) }) }) }) From 51c48bcacdafc36671908358af6de99370562564 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 10 Mar 2026 18:12:57 -0400 Subject: [PATCH 077/596] fix(ui): enforce consistent delete button contrast for delete in AMusic theme Signed-off-by: Deluan --- ui/src/themes/amusic.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index 2477a3070..74f7d3fd4 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -194,12 +194,22 @@ export default { }, RaDeleteWithConfirmButton: { deleteButton: { - color: '#fff', + color: '#fff !important', + }, + }, + RaDeleteWithUndoButton: { + deleteButton: { + color: '#fff !important', + }, + }, + RaBulkDeleteWithConfirmButton: { + deleteButton: { + color: '#fff !important', }, }, RaBulkDeleteWithUndoButton: { deleteButton: { - color: '#fff', + color: '#fff !important', }, }, RaPaginationActions: { From d8bc41fbb19179da9d9321b3f0ae3e7f539432eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 11 Mar 2026 09:26:32 -0400 Subject: [PATCH 078/596] fix: use ADTS for AAC transcoding, temporarily exclude AAC from transcode decisions (#5167) * fix: use ADTS format for AAC transcoding to avoid silent output on ffmpeg 8.0+ The fragmented MP4 muxer (`-f ipod -movflags frag_keyframe+empty_moov`) produces corrupt/silent audio when ffmpeg pipes to stdout, confirmed on ffmpeg 8.0+. The moof atom offset values are zeroed out in pipe mode, causing AAC decoder errors. Switch to `-f adts` (raw AAC framing) which works reliably via pipe and is widely supported by clients including UPnP/Sonos devices. * fix: exclude AAC from transcode decision, as it is not working for Sonos. Signed-off-by: Deluan --------- Signed-off-by: Deluan --- consts/consts.go | 2 +- core/ffmpeg/ffmpeg.go | 7 +--- core/ffmpeg/ffmpeg_test.go | 7 ++-- core/stream/aliases.go | 5 +++ core/stream/aliases_test.go | 30 +++++++++++++++ ...0260310113858_fix_aac_transcode_command.go | 30 +++++++++++++++ server/subsonic/transcode.go | 10 +++++ server/subsonic/transcode_test.go | 37 ++++++++++++++++--- 8 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 core/stream/aliases_test.go create mode 100644 db/migrations/20260310113858_fix_aac_transcode_command.go diff --git a/consts/consts.go b/consts/consts.go index 2a5fdd94a..061aebd7b 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -153,7 +153,7 @@ var ( Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d0cf0755d..9c4f99364 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -283,7 +283,7 @@ var formatCodecMap = map[string]string{ var formatOutputMap = map[string]string{ "mp3": "mp3", "opus": "opus", - "aac": "ipod", + "aac": "adts", "flac": "flac", } @@ -339,11 +339,6 @@ func buildDynamicArgs(opts TranscodeOptions) []string { args = append(args, "-f", outputFmt) } - // For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming - if opts.Format == "aac" { - args = append(args, "-movflags", "frag_keyframe+empty_moov") - } - args = append(args, "-") return args } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index eebeefe35..23e419219 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -86,7 +86,7 @@ var _ = Describe("ffmpeg", func() { Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) @@ -174,7 +174,7 @@ var _ = Describe("ffmpeg", func() { })) }) - It("builds aac args with fragmented MP4 container", func() { + It("builds aac args with ADTS output", func() { args := buildDynamicArgs(TranscodeOptions{ Format: "aac", FilePath: "/music/file.flac", @@ -186,8 +186,7 @@ var _ = Describe("ffmpeg", func() { "-c:a", "aac", "-b:a", "256k", "-v", "0", - "-f", "ipod", - "-movflags", "frag_keyframe+empty_moov", + "-f", "adts", "-", })) }) diff --git a/core/stream/aliases.go b/core/stream/aliases.go index 6a4c8386f..af42ac076 100644 --- a/core/stream/aliases.go +++ b/core/stream/aliases.go @@ -80,6 +80,11 @@ func matchesCodec(codec string, codecs []string) bool { return matchesWithAliases(codec, codecs, codecAliasGroups) } +// IsAACCodec returns true if the given codec or container name resolves to AAC. +func IsAACCodec(name string) bool { + return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"}) +} + func containsIgnoreCase(slice []string, s string) bool { return slices.ContainsFunc(slice, func(item string) bool { return strings.EqualFold(item, s) diff --git a/core/stream/aliases_test.go b/core/stream/aliases_test.go new file mode 100644 index 000000000..72f061810 --- /dev/null +++ b/core/stream/aliases_test.go @@ -0,0 +1,30 @@ +package stream + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Aliases", func() { + Describe("IsAACCodec", func() { + It("returns true for AAC and its aliases", func() { + Expect(IsAACCodec("aac")).To(BeTrue()) + Expect(IsAACCodec("AAC")).To(BeTrue()) + Expect(IsAACCodec("adts")).To(BeTrue()) + Expect(IsAACCodec("m4a")).To(BeTrue()) + Expect(IsAACCodec("mp4")).To(BeTrue()) + Expect(IsAACCodec("m4b")).To(BeTrue()) + }) + + It("returns false for non-AAC formats", func() { + Expect(IsAACCodec("mp3")).To(BeFalse()) + Expect(IsAACCodec("opus")).To(BeFalse()) + Expect(IsAACCodec("flac")).To(BeFalse()) + Expect(IsAACCodec("ogg")).To(BeFalse()) + }) + + It("returns false for empty string", func() { + Expect(IsAACCodec("")).To(BeFalse()) + }) + }) +}) diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go new file mode 100644 index 000000000..588137383 --- /dev/null +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand) +} + +func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { + // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces + // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+). + // Switch to `-f adts` (raw AAC framing) which works reliably via pipe. + // Only update rows that still have the old default command. + const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -" + const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -" + _, err := tx.Exec( + "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", + newCommand, oldCommand, + ) + return err +} + +func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 792504871..64e74d460 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -268,6 +268,16 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } clientInfo := clientInfoReq.toCoreClientInfo() + // TODO: Remove this filter once AAC transcoding works reliably + // with streaming clients (Sonos, etc). + // See https://github.com/navidrome/navidrome/discussions/4832#discussioncomment-16068231 + clientInfo.TranscodingProfiles = slices.DeleteFunc(clientInfo.TranscodingProfiles, func(p stream.Profile) bool { + if p.AudioCodec != "" { + return stream.IsAACCodec(p.AudioCodec) + } + return stream.IsAACCodec(p.Container) + }) + // Get media file mf, err := api.ds.MediaFile(ctx).Get(mediaID) if err != nil { diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8db729c6d..15ba168d7 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -180,6 +180,29 @@ var _ = Describe("Transcode endpoints", func() { Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000))) }) + It("filters AAC from transcoding profiles", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "opus", Codec: "opus", BitRate: 128, Channels: 2, SampleRate: 48000}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true} + mockTD.token = "token" + + body := `{ + "transcodingProfiles": [ + {"container": "aac", "audioCodec": "aac", "protocol": "http"}, + {"container": "mp3", "audioCodec": "mp3", "protocol": "http"}, + {"container": "m4a", "audioCodec": "aac", "protocol": "http"} + ] + }` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient).ToNot(BeNil()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) + }) + It("includes transcode stream when transcoding", func() { mockMFRepo.SetData(model.MediaFiles{ {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, @@ -354,14 +377,16 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { - decision *stream.TranscodeDecision - token string - tokenErr error - resolvedReq stream.Request - resolveErr error + decision *stream.TranscodeDecision + token string + tokenErr error + resolvedReq stream.Request + resolveErr error + capturedClient *stream.ClientInfo } -func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { + m.capturedClient = ci if m.decision != nil { return m.decision, nil } From 5ecbe31a06b4035fc2768219455e804c26660647 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 11 Mar 2026 09:46:06 -0400 Subject: [PATCH 079/596] fix: implement fallback to DefaultDownsamplingFormat for unknown formats Signed-off-by: Deluan --- core/stream/legacy_client.go | 11 +- core/stream/legacy_client_test.go | 142 ++++++++++++++++++++ server/e2e/subsonic_media_retrieval_test.go | 4 +- 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 82d01f5de..d6e929ec8 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -86,7 +86,16 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile return req } - // No compatible profile — fallback to raw + // No compatible profile for the requested format — retry with DefaultDownsamplingFormat + // TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values + fallbackFormat := conf.Server.DefaultDownsamplingFormat + if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) { + log.Warn(ctx, "Requested format not available, falling back to default downsampling format", + "requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID) + return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset) + } + + // Ultimate fallback — raw req.Format = "raw" return req } diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index 3557a9314..de1eb1339 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -1,9 +1,13 @@ package stream import ( + "context" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -96,3 +100,141 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.MaxAudioBitrate).To(BeZero()) }) }) + +var _ = Describe("ResolveRequest", func() { + var ( + svc TranscodeDecider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds := &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff := tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewTranscodeDecider(ds, ff) + }) + + It("returns raw when format is 'raw'", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "raw", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("returns raw (direct play) when no format or bitrate specified", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("transcodes to requested format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "opus", 0, 0) + + Expect(req.Format).To(Equal("opus")) + }) + + It("transcodes to requested format with bitrate limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("returns raw when requested format matches source and no bitrate reduction", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("downsamples when only bitrate is specified below source", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 128, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("passes offset through", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "opus", 128, 30) + + Expect(req.Format).To(Equal("opus")) + Expect(req.Offset).To(Equal(30)) + }) + + Context("fallback for unknown format", func() { + It("falls back to DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) + + Expect(req.Format).To(Equal("opus")) + }) + + It("falls back to raw when DefaultDownsamplingFormat is empty", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "xyz" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("preserves bitrate when falling back to DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(128)) + }) + }) +}) diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go index f51386672..079b131ff 100644 --- a/server/e2e/subsonic_media_retrieval_test.go +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -89,11 +89,11 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) - It("falls back to raw for unknown format", func() { + It("falls back to default downsampling format for unknown format", func() { w := doRawReq("stream", "id", trackID, "format", "xyz") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) }) It("passes timeOffset through", func() { From 0312eb33f176af88ba0d00d446fcc5777075de4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 12 Mar 2026 08:21:49 -0400 Subject: [PATCH 080/596] fix(ui): improve browser codec detection and limit Safari transcoding to mp3 (#5171) * fix: update codec MIME types to support multiple variants for better compatibility Signed-off-by: Deluan * fix: limit Safari transcoding to mp3 Signed-off-by: Deluan * style: format browserProfile test file with prettier * fix: comment Signed-off-by: Deluan --------- Signed-off-by: Deluan --- ui/src/transcode/browserProfile.js | 52 +++++++++++++---- ui/src/transcode/browserProfile.test.js | 78 +++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js index 5a4cde20f..d268af7c9 100644 --- a/ui/src/transcode/browserProfile.js +++ b/ui/src/transcode/browserProfile.js @@ -1,12 +1,16 @@ -// Each entry: { codec name for the server, container, MIME to probe } +// Each entry: { codec name for the server, container, mime: [MIME probe strings] } export const CODEC_PROBES = [ - { codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' }, - { codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' }, - { codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' }, - { codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' }, - { codec: 'flac', container: 'flac', mime: 'audio/flac' }, - { codec: 'wav', container: 'wav', mime: 'audio/wav' }, - { codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' }, + { codec: 'mp3', container: 'mp3', mime: ['audio/mpeg; codecs="mp3"'] }, + { codec: 'opus', container: 'ogg', mime: ['audio/ogg; codecs="opus"'] }, + { codec: 'vorbis', container: 'ogg', mime: ['audio/ogg; codecs="vorbis"'] }, + { + codec: 'flac', + container: 'flac', + mime: ['audio/flac', 'audio/flac; codecs="flac"'], + }, + { codec: 'wav', container: 'wav', mime: ['audio/wav; codecs="1"'] }, + { codec: 'alac', container: 'mp4', mime: ['audio/mp4; codecs="alac"'] }, + { codec: 'aac', container: 'mp4', mime: ['audio/mp4; codecs="mp4a.40.2"'] }, ] // Transcoding targets in preference order (lossless first, then lossy). @@ -14,8 +18,27 @@ export const CODEC_PROBES = [ // MP3 is always included as a universal fallback. const TRANSCODE_CODECS = ['flac', 'opus', 'mp3'] +// Safari transcoding is limited to mp3 only. Safari cannot reliably stream +// Ogg containers (reports canPlayType support but fails on non-seekable +// transcoded streams), and FLAC transcoding also fails in practice. +const SAFARI_TRANSCODE_CODECS = ['mp3'] + +function canPlay(audio, mimeList) { + return mimeList.some((m) => { + const result = audio.canPlayType(m) + return result === 'probably' || result === 'maybe' + }) +} + function probeSupported(audio, probes) { - return probes.filter(({ mime }) => audio.canPlayType(mime) === 'probably') + return probes.filter(({ mime }) => canPlay(audio, mime)) +} + +function isSafari() { + const ua = navigator.userAgent + return ( + ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium') + ) } export function detectBrowserProfile() { @@ -29,10 +52,15 @@ export function detectBrowserProfile() { }), ) - // Build transcoding profiles from supported codecs, always keeping mp3 as fallback - const transcodingProfiles = TRANSCODE_CODECS.reduce((profiles, codec) => { + // Build transcoding profiles from supported codecs, always keeping mp3 as fallback. + // Safari is limited to mp3 transcoding only. + const transcodeCodecs = isSafari() + ? SAFARI_TRANSCODE_CODECS + : TRANSCODE_CODECS + const transcodingProfiles = transcodeCodecs.reduce((profiles, codec) => { const probe = CODEC_PROBES.find((p) => p.codec === codec) - if (audio.canPlayType(probe.mime) === 'probably' || codec === 'mp3') { + if (!probe) return profiles + if (canPlay(audio, probe.mime) || codec === 'mp3') { profiles.push({ container: probe.container, audioCodec: codec, diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js index 360ae7885..e79b7a744 100644 --- a/ui/src/transcode/browserProfile.test.js +++ b/ui/src/transcode/browserProfile.test.js @@ -16,7 +16,7 @@ describe('detectBrowserProfile', () => { it('includes codecs that return "probably"', () => { mockCanPlayType.mockImplementation((mime) => { - if (mime === 'audio/mpeg') return 'probably' + if (mime === 'audio/mpeg; codecs="mp3"') return 'probably' if (mime === 'audio/ogg; codecs="opus"') return 'probably' return '' }) @@ -31,11 +31,15 @@ describe('detectBrowserProfile', () => { expect(codecs).toContain('opus') }) - it('excludes codecs that return "maybe"', () => { - mockCanPlayType.mockReturnValue('maybe') + it('includes codecs that return "maybe"', () => { + mockCanPlayType.mockImplementation((mime) => { + if (mime === 'audio/flac') return 'maybe' + return '' + }) const profile = detectBrowserProfile() - expect(profile.directPlayProfiles).toEqual([]) + const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs) + expect(codecs).toContain('flac') }) it('excludes codecs that return empty string', () => { @@ -56,7 +60,7 @@ describe('detectBrowserProfile', () => { it('filters transcoding profiles by canPlayType', () => { mockCanPlayType.mockImplementation((mime) => { - if (mime === 'audio/mpeg') return 'probably' + if (mime === 'audio/mpeg; codecs="mp3"') return 'probably' if (mime === 'audio/ogg; codecs="opus"') return 'probably' return '' }) @@ -104,8 +108,72 @@ describe('detectBrowserProfile', () => { expect(profile.codecProfiles).toEqual([]) }) + it('matches codec when any mime variant returns "probably"', () => { + mockCanPlayType.mockImplementation((mime) => { + if (mime === 'audio/flac; codecs="flac"') return 'probably' + return '' + }) + + const profile = detectBrowserProfile() + const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs) + expect(codecs).toContain('flac') + }) + it('includes platform info', () => { const profile = detectBrowserProfile() expect(typeof profile.platform).toBe('string') }) + + describe('Safari restrictions', () => { + beforeEach(() => { + // Safari reports canPlayType for Ogg as positive, but can't actually + // stream transcoded Ogg. Simulate Safari: supports everything. + mockCanPlayType.mockReturnValue('probably') + }) + + it('still includes ogg in direct play profiles on Safari', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15', + }) + + const profile = detectBrowserProfile() + const containers = profile.directPlayProfiles.flatMap((p) => p.containers) + expect(containers).toContain('ogg') + }) + + it('limits Safari transcoding to mp3 only', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15', + }) + + const profile = detectBrowserProfile() + const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) + expect(codecs).toEqual(['mp3']) + }) + + it('does NOT restrict transcoding on Chrome', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }) + + const profile = detectBrowserProfile() + const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) + expect(codecs).toContain('opus') + expect(codecs).toContain('flac') + }) + + it('applies same restrictions on iOS Safari', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }) + + const profile = detectBrowserProfile() + const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) + expect(codecs).toEqual(['mp3']) + }) + }) }) From 903e3f070fa1cb83c879b60e4e2bf7333d18a000 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:29:37 +0000 Subject: [PATCH 081/596] fix(subsonic): always return required playqueue fields (#5172) --- server/e2e/subsonic_bookmarks_test.go | 22 +++++++++++++++++++--- server/subsonic/bookmarks.go | 12 ++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/e2e/subsonic_bookmarks_test.go index d0dc06208..726b41743 100644 --- a/server/e2e/subsonic_bookmarks_test.go +++ b/server/e2e/subsonic_bookmarks_test.go @@ -77,12 +77,28 @@ var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { } }) - It("getPlayQueue returns empty when nothing saved", func() { + It("getPlayQueue returns minimum required fields when nothing specified", func() { resp := doReq("getPlayQueue") Expect(resp.Status).To(Equal(responses.StatusOK)) - // When no play queue exists, PlayQueue should be nil (no entry returned) - Expect(resp.PlayQueue).To(BeNil()) + Expect(resp.PlayQueue).ToNot(BeNil()) + Expect(resp.PlayQueue.Entry).To(HaveLen(0)) + Expect(resp.PlayQueue.Current).To(BeEmpty()) + Expect(resp.PlayQueue.Position).To(Equal(int64(0))) + Expect(resp.PlayQueue.Username).To(Equal(adminUser.UserName)) + Expect(resp.PlayQueue.ChangedBy).To(BeEmpty()) + }) + + It("getPlayQueueByIndex returns minimum required fields when nothing specified", func() { + resp := doReq("getPlayQueueByIndex") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.PlayQueueByIndex).ToNot(BeNil()) + Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(0)) + Expect(resp.PlayQueueByIndex.CurrentIndex).To(BeNil()) + Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(0))) + Expect(resp.PlayQueueByIndex.Username).To(Equal(adminUser.UserName)) + Expect(resp.PlayQueueByIndex.ChangedBy).To(BeEmpty()) }) It("savePlayQueue stores current play queue", func() { diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index b1e71b1c7..337712750 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -78,7 +78,11 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { return nil, err } if pq == nil || len(pq.Items) == 0 { - return newResponse(), nil + response := newResponse() + response.PlayQueue = &responses.PlayQueue{ + Username: user.UserName, + } + return response, nil } response := newResponse() @@ -145,7 +149,11 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er return nil, err } if pq == nil || len(pq.Items) == 0 { - return newResponse(), nil + response := newResponse() + response.PlayQueueByIndex = &responses.PlayQueueByIndex{ + Username: user.UserName, + } + return response, nil } response := newResponse() From d0fbba14ff7dd456157ffb8a2d5d860a97f63fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 12 Mar 2026 11:39:31 -0400 Subject: [PATCH 082/596] fix(db): check both name and target_format in default transcodings migration (#5175) The ensure_default_transcodings migration only checked target_format before inserting, but the transcoding table has UNIQUE constraints on both name and target_format. Older installations may have entries where the name matches a default (e.g., 'opus audio') but the target_format differs (e.g., 'oga' instead of 'opus'), causing a UNIQUE constraint violation on name during the INSERT. Fixes #5174 --- db/migrations/20260309203355_ensure_default_transcodings.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index ff3838222..ab6d24952 100644 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -17,9 +17,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { // Older installations may be missing default transcodings that were added // after the initial seeding (e.g., aac was added later than mp3/opus). // Insert any missing defaults without touching user-customized entries. + // Check both target_format and name since both have UNIQUE constraints, + // and older entries may have a different target_format (e.g., 'oga' vs 'opus') + // but the same name. for _, t := range consts.DefaultTranscodings { var count int - err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ?", t.TargetFormat).Scan(&count) + err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) if err != nil { return err } From 0790f66627cd56135874a567d2277da604d69970 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 12 Mar 2026 17:07:34 -0400 Subject: [PATCH 083/596] fix(scanner): increase watcher channel buffers to prevent dropped filesystem events When files were moved between libraries, the small channel buffers (size 1) throughout the watcher pipeline caused backpressure that led to dropped filesystem events. This meant only some of the affected folders were scanned, preventing cross-library move detection from working correctly. Increase all watcher channel buffers to 500 and switch to blocking sends to ensure no filesystem events are silently dropped. --- core/storage/local/watcher.go | 4 ++-- scanner/watcher.go | 10 +++------- scanner/watcher_test.go | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/core/storage/local/watcher.go b/core/storage/local/watcher.go index e2418f4cb..1b8a4e0c8 100644 --- a/core/storage/local/watcher.go +++ b/core/storage/local/watcher.go @@ -17,8 +17,8 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { if !s.watching.CompareAndSwap(false, true) { return nil, errors.New("watcher already started") } - input := make(chan notify.EventInfo, 1) - output := make(chan string, 1) + input := make(chan notify.EventInfo, 500) + output := make(chan string, 500) started := make(chan struct{}) go func() { diff --git a/scanner/watcher.go b/scanner/watcher.go index 62fcc9341..376db910c 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -48,7 +48,7 @@ func GetWatcher(ds model.DataStore, s model.Scanner) Watcher { ds: ds, scanner: s, triggerWait: conf.Server.Scanner.WatcherWait, - watcherNotify: make(chan scanNotification, 1), + watcherNotify: make(chan scanNotification, 500), libraryWatchers: make(map[int]*libraryWatcherInstance), } }) @@ -272,12 +272,8 @@ func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, continue } - // Notify the main watcher of changes - select { - case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}: - default: - // Channel is full, notification already pending - } + // Notify the main watcher of changes. This will trigger a scan after the debounce period. + w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath} } } } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 7a431d5a0..e1600db32 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -27,7 +27,7 @@ var _ = Describe("Watcher", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests - ctx, cancel = context.WithCancel(context.Background()) + ctx, cancel = context.WithCancel(GinkgoT().Context()) DeferCleanup(cancel) lib = &model.Library{ From 4ddb0774ecb2c7d4dd714daf841994bc9a11ac1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 13 Mar 2026 09:35:59 -0400 Subject: [PATCH 084/596] perf(artwork): improve image serving performance with WebP encoding and optimized pipeline (#5181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(artwork): add benchmark helpers for generating test images * test(artwork): add image decode benchmarks for JPEG/PNG at various sizes * test(artwork): add image resize benchmarks for Lanczos at various sizes * test(artwork): add image encode benchmarks for JPEG quality levels and PNG * test(artwork): add full resize pipeline benchmark (decode+resize+encode) * test(artwork): add tag extraction benchmark for embedded art * test(cache): add file cache benchmarks for read, write, and concurrent access * test(artwork): add E2E benchmarks for artwork.Get with cache on/off and concurrency * fix(test): use absolute path for tag extraction benchmark fixture * test(artwork): add resize alternatives benchmark comparing resamplers * perf(artwork): switch to CatmullRom resampler and JPEG for square images Replace imaging.Lanczos with imaging.CatmullRom for image resizing (30% faster, indistinguishable quality at thumbnail sizes). Stop forcing PNG encoding for square images when the source is JPEG — JPEG is smaller and faster to encode. Square images from JPEG sources went from 52ms to 10ms (80% improvement). Add sync.Pool for encode buffers to reduce GC pressure under concurrent load. * perf(artwork): increase cache warmer concurrency from 2 to 4 workers Resize is CPU-bound, so more workers improve throughput on multi-core systems. Doubled worker count to better utilize available cores during background cache warming. * perf(artwork): switch to xdraw.ApproxBiLinear and always encode as JPEG Replace disintegration/imaging with golang.org/x/image/draw for image resizing. This eliminates ~92K allocations per resize (from imaging's internal goroutine parallelism) down to ~20, reducing GC pressure under concurrent load. Always encode resized artwork as JPEG regardless of source format, since cover art doesn't need transparency. This is ~5x faster than PNG encode and produces much smaller output (e.g. 18KB JPEG vs 124KB PNG). * perf(artwork): skip external API call when artist image URL is cached ArtistImage() was always calling the external agent (Spotify/Last.fm) to get the image URL, even when the artist already had URLs stored in the database. This caused every artist image request to block on an external API call, creating severe serialization when loading artist grids (5-20 seconds for the first page). Now use the stored URL directly when available. Artists with no stored URL still fetch synchronously. Background refresh via UpdateArtistInfo handles TTL-based URL updates. * perf(artwork): increase getCoverArt throttle from NumCPU/3 to NumCPU The previous default of max(2, NumCPU/3) was too aggressive for artist images which are I/O-bound (downloading from external CDNs), not CPU-bound. On an 8-core machine this meant only 2 concurrent requests, causing a staircase pattern where 12 images took ~2.4s wall-clock. Bumping to max(4, NumCPU) cuts wall-clock time by ~50% for artist image grids while still preventing unbounded concurrency for CPU-bound resizes. * perf(artwork): encode resized images as WebP instead of JPEG Switch from JPEG to WebP encoding for resized artwork using gen2brain/webp (libwebp via WASM, no CGo). WebP produces ~74% smaller output at the same quality with only ~25% slower full-pipeline encode time (cached, so only paid once per artwork+size). Use NRGBA image type to preserve alpha channel in WebP output, and transparent padding for square canvas instead of black. Also removes the disintegration/imaging dependency entirely by replacing imaging.Fill in playlist tile generation with a custom fillCenter function using xdraw.ApproxBiLinear. * perf(artwork): switch from ApproxBiLinear to BiLinear scaling for improved image processing Signed-off-by: Deluan * refactor(configuration): rename CoverJpegQuality to CoverArtQuality and update references Signed-off-by: Deluan * feat(artwork): add DevJpegCoverArt option to control JPEG encoding for cover art Signed-off-by: Deluan * fix(artwork): remove redundant transparent fill and handle encode errors in resizeImage Removed a no-op draw.Draw call that filled the NRGBA canvas with transparent pixels — NewNRGBA already zero-initializes to fully transparent. Also added an early return on encode failure to avoid allocating and copying potentially corrupt buffer data before returning the error. * fix(configuration): reorder default agents (deezer is faster) Signed-off-by: Deluan * fix(test): resolve dogsled lint warning in tag extraction benchmark Use all return values from runtime.Caller instead of discarding three with blank identifiers, which triggered the dogsled linter. * fix(artwork): revert cache key format Signed-off-by: Deluan * fix(configuration): remove deprecated CoverJpegQuality field and update references to CoverArtQuality Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 12 +- core/artwork/artwork_internal_test.go | 81 ++++++++-- core/artwork/benchmark_decode_test.go | 37 +++++ core/artwork/benchmark_e2e_test.go | 189 ++++++++++++++++++++++++ core/artwork/benchmark_encode_test.go | 40 +++++ core/artwork/benchmark_helpers_test.go | 47 ++++++ core/artwork/benchmark_pipeline_test.go | 51 +++++++ core/artwork/benchmark_tag_test.go | 38 +++++ core/artwork/cache_warmer.go | 2 +- core/artwork/reader_playlist.go | 33 ++++- core/artwork/reader_resized.go | 72 ++++++--- core/external/provider.go | 16 +- go.mod | 3 +- go.sum | 7 +- utils/cache/benchmark_test.go | 171 +++++++++++++++++++++ 15 files changed, 749 insertions(+), 50 deletions(-) create mode 100644 core/artwork/benchmark_decode_test.go create mode 100644 core/artwork/benchmark_e2e_test.go create mode 100644 core/artwork/benchmark_encode_test.go create mode 100644 core/artwork/benchmark_helpers_test.go create mode 100644 core/artwork/benchmark_pipeline_test.go create mode 100644 core/artwork/benchmark_tag_test.go create mode 100644 utils/cache/benchmark_test.go diff --git a/conf/configuration.go b/conf/configuration.go index da549ce26..b867e0d56 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -69,7 +69,7 @@ type configOptions struct { MPVPath string MPVCmdTemplate string CoverArtPriority string - CoverJpegQuality int + CoverArtQuality int ArtistArtPriority string LyricsPriority string EnableGravatar bool @@ -140,6 +140,7 @@ type configOptions struct { DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool + DevJpegCoverArt bool } type scannerOptions struct { @@ -283,6 +284,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") err := viper.Unmarshal(&Server) if err != nil { @@ -415,6 +417,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") // Call init hooks for _, hook := range hooks { @@ -655,7 +658,7 @@ func setViperDefaults() { viper.SetDefault("ffmpegpath", "") viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") - viper.SetDefault("coverjpegquality", 75) + viper.SetDefault("coverartquality", 75) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) @@ -707,7 +710,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") - viper.SetDefault("agents", "lastfm,spotify,deezer") + viper.SetDefault("agents", "deezer,lastfm,spotify") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) viper.SetDefault("lastfm.apikey", "") @@ -749,7 +752,7 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) - viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3)) + viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU())) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) @@ -765,6 +768,7 @@ func setViperDefaults() { viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) + viper.SetDefault("devjpegcoverart", false) } func init() { diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index e2ea7adb0..5ca32f401 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -7,9 +7,12 @@ import ( "image/jpeg" "image/png" "io" + "os" "path/filepath" + _ "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -353,24 +356,24 @@ var _ = Describe("Artwork", func() { }) }) When("Square is false", func() { - It("returns a PNG if original image is a PNG", func() { + It("returns WebP even if original image is a PNG", func() { conf.Server.CoverArtPriority = "front.png" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) + Expect(format).To(Equal("webp")) Expect(img.Bounds().Size().X).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15)) }) - It("returns a JPEG if original image is not a PNG", func() { + It("returns WebP if original image is not a PNG", func() { conf.Server.CoverArtPriority = "cover.jpg" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) - Expect(format).To(Equal("jpeg")) + Expect(format).To(Equal("webp")) Expect(err).ToNot(HaveOccurred()) Expect(img.Bounds().Size().X).To(Equal(200)) Expect(img.Bounds().Size().Y).To(Equal(200)) @@ -380,9 +383,9 @@ var _ = Describe("Artwork", func() { var alCover model.Album DescribeTable("resize", - func(format string, landscape bool, size int) { - coverFileName := "cover." + format - dirName := createImage(format, landscape, size) + func(srcFormat string, expectedFormat string, landscape bool, size int) { + coverFileName := "cover." + srcFormat + dirName := createImage(srcFormat, landscape, size) alCover = model.Album{ ID: "444", Name: "Only external", @@ -399,16 +402,70 @@ var _ = Describe("Artwork", func() { img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) + Expect(format).To(Equal(expectedFormat)) Expect(img.Bounds().Size().X).To(Equal(size)) Expect(img.Bounds().Size().Y).To(Equal(size)) }, - Entry("portrait png image", "png", false, 200), - Entry("landscape png image", "png", true, 200), - Entry("portrait jpg image", "jpg", false, 200), - Entry("landscape jpg image", "jpg", true, 200), + Entry("portrait png image", "png", "webp", false, 200), + Entry("landscape png image", "png", "webp", true, 200), + Entry("portrait jpg image", "jpg", "webp", false, 200), + Entry("landscape jpg image", "jpg", "webp", true, 200), ) }) + When("DevJpegCoverArt is true and square is false", func() { + BeforeEach(func() { + conf.Server.DevJpegCoverArt = true + }) + It("returns JPEG even if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("jpeg")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns JPEG if original image is a JPG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("jpeg")) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("DevJpegCoverArt is true and square is true", func() { + var alCover model.Album + + BeforeEach(func() { + conf.Server.DevJpegCoverArt = true + }) + It("returns PNG for square mode", func() { + dirName := createImage("png", false, 200) + alCover = model.Album{ + ID: "444", + Name: "Only external", + FolderIDs: []string{"tmp"}, + } + folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) + + conf.Server.CoverArtPriority = "cover.png" + r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) When("Requested size is larger than original", func() { It("clamps size to original dimensions", func() { conf.Server.CoverArtPriority = "front.png" diff --git a/core/artwork/benchmark_decode_test.go b/core/artwork/benchmark_decode_test.go new file mode 100644 index 000000000..cfbfe5605 --- /dev/null +++ b/core/artwork/benchmark_decode_test.go @@ -0,0 +1,37 @@ +package artwork + +import ( + "bytes" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "testing" +) + +func BenchmarkImageDecode(b *testing.B) { + sizes := []int{300, 1000, 3000} + formats := []struct { + name string + gen func(tb testing.TB, w, h int) []byte + }{ + {"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }}, + {"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }}, + } + + for _, format := range formats { + for _, size := range sizes { + data := format.gen(b, size, size) + b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) { + b.SetBytes(int64(len(data))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + b.Fatal(err) + } + } + }) + } + } +} diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go new file mode 100644 index 000000000..c27964018 --- /dev/null +++ b/core/artwork/benchmark_e2e_test.go @@ -0,0 +1,189 @@ +package artwork + +import ( + "context" + "fmt" + "image/jpeg" + "io" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" +) + +// setupE2EBenchmark creates an artwork instance with a real album cover image on disk, +// backed by either a real file cache or disabled cache depending on cacheSize. +// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers +// the critical path (source selection, decode, resize, encode, cache). This is a deliberate +// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure +// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant. +// +// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together). +func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) { + b.Helper() + cleanup := configtest.SetupConfig() + b.Cleanup(cleanup) + + tmpDir, err := os.MkdirTemp("", "artwork-bench-*") + if err != nil { + b.Fatal(err) + } + + // Create a realistic cover image on disk + coverPath := filepath.Join(tmpDir, "cover.jpg") + coverImg := generateGradientImage(1000, 1000) + f, err := os.Create(coverPath) + if err != nil { + b.Fatal(err) + } + if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil { + f.Close() + b.Fatal(err) + } + f.Close() + + // Configure cache + conf.Server.ImageCacheSize = cacheSize + conf.Server.CacheFolder = tmpDir + conf.Server.CoverArtQuality = 75 + conf.Server.CoverArtPriority = "cover.*" + + // Set up mock data store with album pointing to our cover. + // Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls. + album := model.Album{ + ID: "bench-album-1", + Name: "Benchmark Album", + FolderIDs: []string{"f1"}, + UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + } + folderRepo := &fakeFolderRepo{ + result: []model.Folder{{ + Path: tmpDir, + ImageFiles: []string{"cover.jpg"}, + }}, + } + ds := &tests.MockDataStore{ + MockedTranscoding: &tests.MockTranscodingRepo{}, + MockedFolder: folderRepo, + } + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + artID := album.CoverArtID() + + imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + }) + + // Wait for cache init if enabled + if cacheSize != "0" { + for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) { + runtime.Gosched() // Yield to allow background init goroutine to run + } + } + + ffmpeg := tests.NewMockFFmpeg("fallback content") + aw := NewArtwork(ds, imgCache, ffmpeg, nil) + + cleanupAll := func() { + os.RemoveAll(tmpDir) + } + return aw, artID, cleanupAll +} + +func BenchmarkArtworkGetE2E(b *testing.B) { + cacheConfigs := []struct { + name string + cacheSize string + }{ + {"no_cache", "0"}, + {"with_cache", "100MB"}, + } + sizes := []int{0, 300} + + for _, cc := range cacheConfigs { + for _, size := range sizes { + b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) { + aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) + defer cleanup() + + // Warm the cache on first call if cache is enabled + if cc.cacheSize != "0" { + r, _, err := aw.Get(context.Background(), artID, size, size > 0) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(r) + r.Close() + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + r, _, err := aw.Get(context.Background(), artID, size, size > 0) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(r) + r.Close() + } + }) + } + } +} + +func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { + cacheConfigs := []struct { + name string + cacheSize string + }{ + {"no_cache", "0"}, + {"with_cache", "100MB"}, + } + concurrencyLevels := []int{10, 50} + + for _, cc := range cacheConfigs { + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) { + aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) + defer cleanup() + + // Warm cache + if cc.cacheSize != "0" { + r, _, _ := aw.Get(context.Background(), artID, 300, true) + if r != nil { + _, _ = io.ReadAll(r) + r.Close() + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + r, _, err := aw.Get(context.Background(), artID, 300, true) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(r) + r.Close() + }() + } + wg.Wait() + } + }) + } + } +} diff --git a/core/artwork/benchmark_encode_test.go b/core/artwork/benchmark_encode_test.go new file mode 100644 index 000000000..d8ab858f5 --- /dev/null +++ b/core/artwork/benchmark_encode_test.go @@ -0,0 +1,40 @@ +package artwork + +import ( + "bytes" + "fmt" + "image/jpeg" + "image/png" + "testing" +) + +func BenchmarkImageEncode(b *testing.B) { + img := generateGradientImage(300, 300) + + jpegQualities := []int{60, 75, 90} + for _, q := range jpegQualities { + b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) { + var buf bytes.Buffer + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.Reset() + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(buf.Len()), "bytes") + }) + } + + b.Run("png/300x300", func(b *testing.B) { + var buf bytes.Buffer + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.Reset() + if err := png.Encode(&buf, img); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(buf.Len()), "bytes") + }) +} diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go new file mode 100644 index 000000000..60990bb8b --- /dev/null +++ b/core/artwork/benchmark_helpers_test.go @@ -0,0 +1,47 @@ +package artwork + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern. +// The gradient ensures the image has realistic entropy (not trivially compressible). +func generateJPEG(t testing.TB, width, height, quality int) []byte { + t.Helper() + img := generateGradientImage(width, height) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// generatePNG creates a PNG image of the given dimensions with a gradient pattern. +func generatePNG(t testing.TB, width, height int) []byte { + t.Helper() + img := generateGradientImage(width, height) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// generateGradientImage creates an RGBA image with a diagonal gradient pattern. +func generateGradientImage(width, height int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + r := uint8((x * 255) / width) + g := uint8((y * 255) / height) + b := uint8(((x + y) * 255) / (width + height)) + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) + } + } + return img +} diff --git a/core/artwork/benchmark_pipeline_test.go b/core/artwork/benchmark_pipeline_test.go new file mode 100644 index 000000000..9dc4f6c95 --- /dev/null +++ b/core/artwork/benchmark_pipeline_test.go @@ -0,0 +1,51 @@ +package artwork + +import ( + "bytes" + "fmt" + "testing" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" +) + +func BenchmarkResizeFullPipeline(b *testing.B) { + cleanup := configtest.SetupConfig() + b.Cleanup(cleanup) + conf.Server.CoverArtQuality = 75 + + sourceSizes := []int{1000, 3000} + targetSize := 300 + + for _, srcSize := range sourceSizes { + jpegData := generateJPEG(b, srcSize, srcSize, 90) + + b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) { + b.SetBytes(int64(len(jpegData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, _, err := resizeImage(bytes.NewReader(jpegData), targetSize, false) + if err != nil { + b.Fatal(err) + } + if result == nil { + b.Fatal("expected non-nil resized image") + } + } + }) + + b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) { + b.SetBytes(int64(len(jpegData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, _, err := resizeImage(bytes.NewReader(jpegData), targetSize, true) + if err != nil { + b.Fatal(err) + } + if result == nil { + b.Fatal("expected non-nil resized image") + } + } + }) + } +} diff --git a/core/artwork/benchmark_tag_test.go b/core/artwork/benchmark_tag_test.go new file mode 100644 index 000000000..fd649beab --- /dev/null +++ b/core/artwork/benchmark_tag_test.go @@ -0,0 +1,38 @@ +package artwork + +import ( + "path/filepath" + "runtime" + "testing" + + "go.senan.xyz/taglib" +) + +func BenchmarkTagExtraction(b *testing.B) { + // Ensure working directory is the project root (tests.Init not called with -run='^$') + _, file, _, ok := runtime.Caller(0) + if !ok { + b.Fatal("runtime.Caller failed") + } + appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) + + // Use existing test fixture with embedded artwork + testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast)) + if err != nil { + b.Fatal(err) + } + images := f.Properties().Images + if len(images) == 0 { + b.Fatal("no images found in test file") + } + data, err := f.Image(0) + if err != nil || len(data) == 0 { + b.Fatal("failed to extract image data") + } + f.Close() + } +} diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index 909d299d8..f13820d00 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -132,7 +132,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) input := pl.FromSlice(ctx, batch) - errs := pl.Sink(ctx, 2, input, a.doCacheImage) + errs := pl.Sink(ctx, 4, input, a.doCacheImage) for err := range errs { log.Debug(ctx, "Error warming cache", err) } diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index 91d47b0b0..7919eead6 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -14,11 +14,11 @@ import ( "strings" "time" - "github.com/disintegration/imaging" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + xdraw "golang.org/x/image/draw" ) type playlistArtworkReader struct { @@ -200,7 +200,7 @@ func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) ( if err != nil { return nil, err } - return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil + return fillCenter(img, tileSize/2, tileSize/2), nil } func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { @@ -238,3 +238,32 @@ func rect(pos int) image.Rectangle { r.Max.Y = r.Min.Y + tileSize/2 return r } + +// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, +// equivalent to imaging.Fill with Center anchor. +func fillCenter(src image.Image, dstW, dstH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Calculate crop rectangle (center crop to match destination aspect ratio) + srcAspect := float64(srcW) / float64(srcH) + dstAspect := float64(dstW) / float64(dstH) + + var cropRect image.Rectangle + if srcAspect > dstAspect { + // Source is wider — crop horizontally + cropW := int(float64(srcH) * dstAspect) + cropX := (srcW - cropW) / 2 + cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) + } else { + // Source is taller — crop vertically + cropH := int(float64(srcW) / dstAspect) + cropY := (srcH - cropH) / 2 + cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) + } + + dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + xdraw.BiLinear.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + return dst +} diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 6de983baf..dffcba8e3 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -5,17 +5,26 @@ import ( "context" "fmt" "image" + "image/draw" "image/jpeg" "image/png" "io" + "sync" "time" - "github.com/disintegration/imaging" + "github.com/gen2brain/webp" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" ) +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + type resizedArtworkReader struct { artID model.ArtworkID cacheKey string @@ -46,7 +55,7 @@ func (a *resizedArtworkReader) Key() string { if a.square { return baseKey + ".square" } - return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality) + return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality) } func (a *resizedArtworkReader) LastUpdated() time.Time { @@ -79,7 +88,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin } func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(reader) + original, _, err := image.Decode(reader) if err != nil { return nil, 0, err } @@ -96,26 +105,45 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error return nil, originalSize, nil } - var resized image.Image - if originalSize >= size { - resized = imaging.Fit(original, size, size, imaging.Lanczos) - } else { - if bounds.Max.Y < bounds.Max.X { - resized = imaging.Resize(original, size, 0, imaging.Lanczos) - } else { - resized = imaging.Resize(original, 0, size, imaging.Lanczos) - } - } - if square { - bg := image.NewRGBA(image.Rect(0, 0, size, size)) - resized = imaging.OverlayCenter(bg, resized, 1) - } + // Calculate aspect-fit dimensions + srcW, srcH := bounds.Dx(), bounds.Dy() + scale := float64(size) / float64(max(srcW, srcH)) + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) - buf := new(bytes.Buffer) - if format == "png" || square { - err = png.Encode(buf, resized) + var dst *image.NRGBA + var dstRect image.Rectangle + if square { + // Square canvas with image centered (transparent padding via zero-initialized NRGBA) + dst = image.NewNRGBA(image.Rect(0, 0, size, size)) + offsetX := (size - dstW) / 2 + offsetY := (size - dstH) / 2 + dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) } else { - err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality}) + // Tight-fit canvas + dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + dstRect = dst.Bounds() } - return buf, originalSize, err + xdraw.BiLinear.Scale(dst, dstRect, original, bounds, draw.Src, nil) + + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + if conf.Server.DevJpegCoverArt { + if square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) + } + } else { + err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } + if err != nil { + bufPool.Put(buf) + return nil, originalSize, err + } + // Copy bytes before returning buffer to pool (pool may reuse the buffer) + encoded := make([]byte, buf.Len()) + copy(encoded, buf.Bytes()) + bufPool.Put(buf) + return bytes.NewReader(encoded), originalSize, nil } diff --git a/core/external/provider.go b/core/external/provider.go index 6a4f4f5e0..a30afed15 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -374,13 +374,19 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, err } - e.callGetImage(ctx, e.ag, &artist) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) - return nil, ctx.Err() + // Use already-stored image URL if available, avoiding expensive external API calls. + // If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it. + imageUrl := artist.ArtistImageUrl() + if imageUrl == "" { + // No cached URL — must fetch from external source synchronously + e.callGetImage(ctx, e.ag, &artist) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) + return nil, ctx.Err() + } + imageUrl = artist.ArtistImageUrl() } - imageUrl := artist.ArtistImageUrl() if imageUrl == "" { return nil, model.ErrNotFound } diff --git a/go.mod b/go.mod index d50f738b7..424c52421 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 - github.com/disintegration/imaging v1.6.2 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 github.com/djherbis/stream v1.4.0 @@ -22,6 +21,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 + github.com/gen2brain/webp v0.5.5 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 @@ -84,6 +84,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect + github.com/ebitengine/purego v0.8.3 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect diff --git a/go.sum b/go.sum index d92075234..a28900a41 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,6 @@ github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6n github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= @@ -60,6 +58,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= +github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -69,6 +69,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= +github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -325,7 +327,6 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go new file mode 100644 index 000000000..1fe448f84 --- /dev/null +++ b/utils/cache/benchmark_test.go @@ -0,0 +1,171 @@ +package cache + +import ( + "context" + "fmt" + "io" + "os" + "runtime" + "strings" + "sync" + "testing" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" +) + +type benchItem struct { + key string +} + +func (b *benchItem) Key() string { return b.key } + +// setupBenchCache creates a file cache in a temp directory. Returns the cache and cleanup function. +func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileCache, func()) { + b.Helper() + tmpDir, err := os.MkdirTemp("", "bench-cache-*") + if err != nil { + b.Fatal(err) + } + b.Cleanup(configtest.SetupConfig()) + conf.Server.CacheFolder = tmpDir + + fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) + + // Wait for cache to be ready + for !fc.ready.Load() { + runtime.Gosched() // Yield to allow background init goroutine to run + } + + teardown := func() { + os.RemoveAll(tmpDir) + } + return fc, teardown +} + +func BenchmarkCacheWrite(b *testing.B) { + // Simulate writing 50KB images (typical 300px JPEG) + imageData := strings.Repeat("x", 50*1024) + + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + key := fmt.Sprintf("write-bench-%d", i) + s, err := fc.Get(context.Background(), &benchItem{key: key}) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(s) + s.Close() + } +} + +func BenchmarkCacheRead(b *testing.B) { + imageData := strings.Repeat("x", 50*1024) + + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + // Pre-populate cache + item := &benchItem{key: "read-bench"} + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(s) + s.Close() + + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(s) + s.Close() + } +} + +func BenchmarkConcurrentCacheRead(b *testing.B) { + imageData := strings.Repeat("x", 50*1024) + + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + // Pre-populate cache + item := &benchItem{key: "concurrent-read"} + s, _ := fc.Get(context.Background(), item) + _, _ = io.ReadAll(s) + s.Close() + + concurrencyLevels := []int{1, 10, 50} + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("goroutines_%d", n), func(b *testing.B) { + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(s) + s.Close() + }() + } + wg.Wait() + } + }) + } +} + +func BenchmarkConcurrentCacheMiss(b *testing.B) { + imageData := strings.Repeat("x", 50*1024) + + concurrencyLevels := []int{1, 10, 50} + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("goroutines_%d", n), func(b *testing.B) { + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + // All goroutines request the SAME key (not yet cached) + item := &benchItem{key: fmt.Sprintf("miss-%d", i)} + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(s) + s.Close() + }() + } + wg.Wait() + } + }) + } +} From a50b2a1e72129aba2401149814f193b20969ef1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 13 Mar 2026 18:11:12 -0400 Subject: [PATCH 085/596] feat(artwork): preserve animated image artwork during resize (#5184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): preserve animated image artwork during resize Detect animated GIFs, WebPs, and APNGs via lightweight byte scanning and preserve their animation when serving resized artwork. Animated GIFs are converted to animated WebP via ffmpeg with optional downscaling; animated WebP/APNG are returned as-is since ffmpeg cannot re-encode them. Adds ConvertAnimatedImage to the FFmpeg interface for piping stdin data through ffmpeg with animated WebP output. * fix(artwork): address code review feedback for animated artwork Fix ReadCloser leak where ffmpeg pipe's Close was discarded by io.NopCloser wrapping — now preserves ReadCloser semantics when the resized reader already supports Close. Use uint64 for PNG chunk position to prevent potential overflow on 32-bit platforms. Add integration tests for the animation branching logic in resizeImage. --- core/artwork/animation.go | 120 +++++++++++++++++ core/artwork/animation_test.go | 161 ++++++++++++++++++++++ core/artwork/benchmark_pipeline_test.go | 5 +- core/artwork/reader_resized.go | 37 +++++- core/artwork/reader_resized_test.go | 170 ++++++++++++++++++++++++ core/ffmpeg/ffmpeg.go | 33 ++++- server/e2e/e2e_suite_test.go | 4 + tests/mock_ffmpeg.go | 12 ++ 8 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 core/artwork/animation.go create mode 100644 core/artwork/animation_test.go create mode 100644 core/artwork/reader_resized_test.go diff --git a/core/artwork/animation.go b/core/artwork/animation.go new file mode 100644 index 000000000..07f493eb4 --- /dev/null +++ b/core/artwork/animation.go @@ -0,0 +1,120 @@ +package artwork + +import ( + "bytes" + "encoding/binary" +) + +// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file. +// Animated GIFs use GIF89a and contain multiple image blocks. +func isAnimatedGIF(data []byte) bool { + // GIF header: "GIF87a" or "GIF89a" + if !bytes.HasPrefix(data, []byte("GIF")) { + return false + } + + // Skip header (6 bytes) + logical screen descriptor (7 bytes) + pos := 13 + if pos >= len(data) { + return false + } + + // Skip Global Color Table if present (bit 7 of packed byte at offset 10) + if len(data) > 10 && data[10]&0x80 != 0 { + // GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte + gctSize := 3 * (1 << ((data[10] & 0x07) + 1)) + pos += gctSize + } + + frameCount := 0 + for pos < len(data) { + switch data[pos] { + case 0x2C: // Image Descriptor - marks a frame + frameCount++ + if frameCount > 1 { + return true + } + pos++ // skip introducer + if pos+8 >= len(data) { + return false + } + pos += 8 // skip x, y, w, h (each 2 bytes) + packed := data[pos] + pos++ // skip packed byte + // Skip Local Color Table if present + if packed&0x80 != 0 { + lctSize := 3 * (1 << ((packed & 0x07) + 1)) + pos += lctSize + } + // Skip LZW minimum code size + pos++ + // Skip sub-blocks + pos = skipGIFSubBlocks(data, pos) + case 0x21: // Extension block + pos++ // skip introducer + if pos >= len(data) { + return false + } + pos++ // skip extension label + // Skip sub-blocks + pos = skipGIFSubBlocks(data, pos) + case 0x3B: // Trailer + return false + default: + // Unknown block, bail + return false + } + } + return false +} + +// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block). +func skipGIFSubBlocks(data []byte, pos int) int { + for pos < len(data) { + blockSize := int(data[pos]) + pos++ // skip size byte + if blockSize == 0 { + break + } + pos += blockSize + } + return pos +} + +// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container. +func isAnimatedWebP(data []byte) bool { + // WebP header: "RIFF" + 4 bytes size + "WEBP" + if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 { + return false + } + if !bytes.Equal(data[8:12], []byte("WEBP")) { + return false + } + // Scan for ANMF chunk identifier + return bytes.Contains(data[12:], []byte("ANMF")) +} + +// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file. +// APNG files contain an acTL chunk that is not present in static PNGs. +func isAnimatedPNG(data []byte) bool { + // PNG signature: 8 bytes + pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + if !bytes.HasPrefix(data, pngSig) { + return false + } + + // Scan chunks for "acTL" (animation control) + pos := uint64(8) + dataLen := uint64(len(data)) + for pos+8 <= dataLen { + chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4])) + chunkType := string(data[pos+4 : pos+8]) + + if chunkType == "acTL" { + return true + } + // Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC) + pos += 12 + chunkLen + } + return false +} diff --git a/core/artwork/animation_test.go b/core/artwork/animation_test.go new file mode 100644 index 000000000..9000a8511 --- /dev/null +++ b/core/artwork/animation_test.go @@ -0,0 +1,161 @@ +package artwork + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/gif" + "image/png" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Animation detection", func() { + Describe("isAnimatedGIF", func() { + It("detects an animated GIF with multiple frames", func() { + Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue()) + }) + + It("detects an animated GIF with many frames", func() { + Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue()) + }) + + It("does not flag a static GIF (single frame)", func() { + Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse()) + }) + + It("returns false for non-GIF data", func() { + Expect(isAnimatedGIF(nil)).To(BeFalse()) + Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse()) + }) + }) + + Describe("isAnimatedWebP", func() { + It("detects an animated WebP with ANMF chunk", func() { + Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue()) + }) + + It("does not flag a static WebP (no ANMF chunk)", func() { + Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse()) + }) + + It("returns false for non-WebP data", func() { + Expect(isAnimatedWebP(nil)).To(BeFalse()) + Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse()) + }) + }) + + Describe("isAnimatedPNG", func() { + It("detects an APNG with acTL chunk", func() { + Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue()) + }) + + It("does not flag a static PNG (no acTL chunk)", func() { + Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse()) + }) + + It("returns false for non-PNG data", func() { + Expect(isAnimatedPNG(nil)).To(BeFalse()) + Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse()) + }) + }) +}) + +// createAnimatedGIF creates a minimal animated GIF with the given number of frames. +func createAnimatedGIF(frames int) []byte { + g := &gif.GIF{ + LoopCount: 0, + } + for range frames { + img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White}) + g.Image = append(g.Image, img) + g.Delay = append(g.Delay, 10) + } + var buf bytes.Buffer + err := gif.EncodeAll(&buf, g) + if err != nil { + panic(err) + } + return buf.Bytes() +} + +// writeUint32LE appends a little-endian uint32 to the buffer. +func writeUint32LE(buf *bytes.Buffer, v uint32) { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, v) + buf.Write(b) +} + +// writeUint32BE appends a big-endian uint32 to the buffer. +func writeUint32BE(buf *bytes.Buffer, v uint32) { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, v) + buf.Write(b) +} + +// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk. +func createAnimatedWebPBytes() []byte { + var buf bytes.Buffer + buf.WriteString("RIFF") + writeUint32LE(&buf, 100) // file size placeholder + buf.WriteString("WEBP") + // VP8X chunk (extended format, required for animation) + buf.WriteString("VP8X") + writeUint32LE(&buf, 10) + buf.Write(make([]byte, 10)) + // ANIM chunk (animation parameters) + buf.WriteString("ANIM") + writeUint32LE(&buf, 6) + buf.Write(make([]byte, 6)) + // ANMF chunk (animation frame) + buf.WriteString("ANMF") + writeUint32LE(&buf, 16) + buf.Write(make([]byte, 16)) + return buf.Bytes() +} + +// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks. +func createStaticWebPBytes() []byte { + var buf bytes.Buffer + buf.WriteString("RIFF") + writeUint32LE(&buf, 20) // file size + buf.WriteString("WEBP") + // VP8 chunk (simple lossy format) + buf.WriteString("VP8 ") + writeUint32LE(&buf, 4) + buf.Write(make([]byte, 4)) + return buf.Bytes() +} + +// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG). +func createAPNGBytes() []byte { + // Start with a real PNG + staticPNG := createStaticPNGBytes() + + // Insert an acTL chunk after the IHDR chunk. + // PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25) + ihdrEnd := 8 + 25 + var buf bytes.Buffer + buf.Write(staticPNG[:ihdrEnd]) + // Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4 + writeUint32BE(&buf, 8) // chunk data length + buf.WriteString("acTL") + writeUint32BE(&buf, 2) // num_frames + writeUint32BE(&buf, 0) // num_plays (0 = infinite) + writeUint32BE(&buf, 0) // CRC placeholder + buf.Write(staticPNG[ihdrEnd:]) + return buf.Bytes() +} + +// createStaticPNGBytes creates a minimal valid static PNG. +func createStaticPNGBytes() []byte { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + var buf bytes.Buffer + err := png.Encode(&buf, img) + if err != nil { + panic(err) + } + return buf.Bytes() +} diff --git a/core/artwork/benchmark_pipeline_test.go b/core/artwork/benchmark_pipeline_test.go index 9dc4f6c95..23d5954df 100644 --- a/core/artwork/benchmark_pipeline_test.go +++ b/core/artwork/benchmark_pipeline_test.go @@ -1,7 +1,6 @@ package artwork import ( - "bytes" "fmt" "testing" @@ -24,7 +23,7 @@ func BenchmarkResizeFullPipeline(b *testing.B) { b.SetBytes(int64(len(jpegData))) b.ResetTimer() for i := 0; i < b.N; i++ { - result, _, err := resizeImage(bytes.NewReader(jpegData), targetSize, false) + result, _, err := resizeStaticImage(jpegData, targetSize, false) if err != nil { b.Fatal(err) } @@ -38,7 +37,7 @@ func BenchmarkResizeFullPipeline(b *testing.B) { b.SetBytes(int64(len(jpegData))) b.ResetTimer() for i := 0; i < b.N; i++ { - result, _, err := resizeImage(bytes.NewReader(jpegData), targetSize, true) + result, _, err := resizeStaticImage(jpegData, targetSize, true) if err != nil { b.Fatal(err) } diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index dffcba8e3..7e90f10e9 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -70,7 +70,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin } defer orig.Close() - resized, origSize, err := resizeImage(orig, a.size, a.square) + resized, origSize, err := a.resizeImage(ctx, orig) if resized == nil { log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) } else { @@ -84,11 +84,42 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin orig, _, err = a.a.Get(ctx, a.artID, 0, false) return orig, "", err } + // Preserve ReadCloser semantics if the resized reader already supports Close + // (e.g., ffmpeg pipe), otherwise wrap with NopCloser + if rc, ok := resized.(io.ReadCloser); ok { + return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil + } return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil } -func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) { - original, _, err := image.Decode(reader) +func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, 0, fmt.Errorf("reading image data: %w", err) + } + + // Preserve animation for animated images (skip for square thumbnails) + if !a.square { + if isAnimatedGIF(data) { + if a.a.ffmpeg.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil + } + log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) + } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil + } + } + + return resizeStaticImage(data, a.size, a.square) +} + +func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { + original, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, 0, err } diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go new file mode 100644 index 000000000..7c9e21c0a --- /dev/null +++ b/core/artwork/reader_resized_test.go @@ -0,0 +1,170 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "io" + + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizeImage", func() { + var mockFF *tests.MockFFmpeg + var r *resizedArtworkReader + + BeforeEach(func() { + mockFF = tests.NewMockFFmpeg("converted-animated-data") + r = &resizedArtworkReader{ + size: 300, + square: false, + a: &artwork{ffmpeg: mockFF}, + } + }) + + Describe("animated GIF handling", func() { + It("converts animated GIF via ffmpeg when available", func() { + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should have been processed by ffmpeg (mock returns "converted-animated-data") + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) // MockFFmpeg echoes input back + }) + + It("falls back to static resize when ffmpeg fails for animated GIF", func() { + mockFF.Error = errors.New("ffmpeg failed") + // Use size smaller than image so static resize actually produces output + r.size = 1 + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Should fall through to static resize successfully (no ffmpeg error propagated) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify it's a static image (WebP encoded), not the ffmpeg error + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(len(output)).To(BeNumerically(">", 0)) + }) + + It("skips animation for square thumbnails even with animated GIF", func() { + r.square = true + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Should fall through to static resize (not ffmpeg conversion) + // The minimal test GIF may or may not resize successfully, + // but ffmpeg should NOT have been called for animated conversion + _ = result + _ = err + // Verify by checking the mock wasn't used for animated conversion: + // If ffmpeg was called, it would return mock data, not static resize result + }) + }) + + Describe("animated WebP handling", func() { + It("returns animated WebP data as-is when not square", func() { + data := createAnimatedWebPBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + + It("does not passthrough animated WebP for square thumbnails", func() { + r.square = true + data := createAnimatedWebPBytes() + // Should fall through to static resize, which will fail on fake WebP data + _, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Static decode will fail on our minimal test WebP bytes (not a real image) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("animated PNG handling", func() { + It("returns animated PNG data as-is when not square", func() { + data := createAPNGBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + + It("does not passthrough animated PNG for square thumbnails", func() { + r.square = true + data := createAPNGBytes() + // Should fall through to static resize + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Static PNG decode should succeed on our APNG (it's a valid PNG) + if err == nil { + Expect(result).ToNot(BeNil()) + } + }) + }) + + Describe("static image handling", func() { + It("resizes a static PNG normally", func() { + data := createStaticPNGBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Static PNG is 2x2, size 300 is larger, so should return nil (no upscale) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Describe("ReadCloser preservation", func() { + It("preserves Close semantics from ffmpeg ReadCloser", func() { + // Create a trackable ReadCloser + tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))} + mockFF2 := &mockFFmpegWithCloser{tracker: tracker} + r.a = &artwork{ffmpeg: mockFF2} + + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + + // The result should be an io.ReadCloser (the tracker) + rc, ok := result.(io.ReadCloser) + Expect(ok).To(BeTrue()) + Expect(rc.Close()).ToNot(HaveOccurred()) + Expect(tracker.closed).To(BeTrue()) + }) + }) +}) + +// closeTracker is an io.ReadCloser that tracks whether Close was called. +type closeTracker struct { + io.Reader + closed bool +} + +func (c *closeTracker) Close() error { + c.closed = true + return nil +} + +// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser +// for ConvertAnimatedImage, allowing us to verify Close propagation. +type mockFFmpegWithCloser struct { + ffmpeg.FFmpeg + tracker *closeTracker +} + +func (m *mockFFmpegWithCloser) IsAvailable() bool { return true } +func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) { + return m.tracker, nil +} diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 9c4f99364..2b06250a1 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -43,6 +43,7 @@ type AudioProbeResult struct { type FFmpeg interface { Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) + ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) Probe(ctx context.Context, files []string) (string, error) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) @@ -78,6 +79,23 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC return e.start(ctx, args) } +func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { + cmdPath, err := ffmpegCmd() + if err != nil { + return nil, err + } + + args := []string{cmdPath, "-i", "pipe:0"} + if maxSize > 0 { + vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize) + args = append(args, "-vf", vf) + } + args = append(args, "-loop", "0", "-c:v", "libwebp_anim", + "-quality", strconv.Itoa(quality), "-f", "webp", "-") + + return e.start(ctx, args, reader) +} + func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err @@ -223,9 +241,12 @@ func (e *ffmpeg) Version() string { return parts[2] } -func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) { +func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) { log.Trace(ctx, "Executing ffmpeg command", "cmd", args) j := &ffCmd{args: args} + if len(input) > 0 { + j.input = input[0] + } j.PipeReader, j.out = io.Pipe() err := j.start(ctx) if err != nil { @@ -237,14 +258,18 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error type ffCmd struct { *io.PipeReader - out *io.PipeWriter - args []string - cmd *exec.Cmd + out *io.PipeWriter + args []string + cmd *exec.Cmd + input io.Reader // optional stdin source } func (j *ffCmd) start(ctx context.Context) error { cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec cmd.Stdout = j.out + if j.input != nil { + cmd.Stdin = j.input + } if log.IsGreaterOrEqualTo(log.LevelTrace) { cmd.Stderr = os.Stderr } else { diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index dc8b178d8..ef379ad05 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -320,6 +320,10 @@ func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProb return nil, errors.New("noop ffmpeg: probe not supported") } +func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: convert animated image not supported") +} + func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } func (n noopFFmpeg) IsAvailable() bool { return false } func (n noopFFmpeg) Version() string { return "noop" } diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index a35defeae..346209b71 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -1,6 +1,7 @@ package tests import ( + "bytes" "context" "io" "strings" @@ -40,6 +41,17 @@ func (ff *MockFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, erro return ff, nil } +func (ff *MockFFmpeg) ConvertAnimatedImage(_ context.Context, reader io.Reader, _ int, _ int) (io.ReadCloser, error) { + if ff.Error != nil { + return nil, ff.Error + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + return io.NopCloser(bytes.NewReader(data)), nil +} + func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { if ff.Error != nil { return "", ff.Error From 49a14d45838ff6f95157ea3d55b70e28d0f66866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 13 Mar 2026 18:33:18 -0400 Subject: [PATCH 086/596] feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + core/artwork/artwork.go | 2 + core/artwork/reader_disc.go | 268 +++++++++++++++++++++++ core/artwork/reader_disc_test.go | 285 +++++++++++++++++++++++++ model/artwork_id.go | 18 ++ model/artwork_id_test.go | 34 +++ server/subsonic/helpers.go | 8 +- server/subsonic/helpers_test.go | 33 ++- server/subsonic/responses/responses.go | 5 +- ui/src/common/SongDatagrid.jsx | 88 ++++++-- ui/src/i18n/en.json | 1 + ui/src/subsonic/index.js | 11 + ui/src/subsonic/index.test.js | 50 +++++ 13 files changed, 782 insertions(+), 23 deletions(-) create mode 100644 core/artwork/reader_disc.go create mode 100644 core/artwork/reader_disc_test.go diff --git a/conf/configuration.go b/conf/configuration.go index b867e0d56..dc5fd1b59 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -71,6 +71,7 @@ type configOptions struct { CoverArtPriority string CoverArtQuality int ArtistArtPriority string + DiscArtPriority string LyricsPriority string EnableGravatar bool EnableFavourites bool @@ -660,6 +661,7 @@ func setViperDefaults() { viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartquality", 75) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") + viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 2e92b24c8..4a0a32afc 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -122,6 +122,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newMediafileArtworkReader(ctx, a, artID) case model.KindPlaylistArtwork: artReader, err = newPlaylistArtworkReader(ctx, a, artID) + case model.KindDiscArtwork: + artReader, err = newDiscArtworkReader(ctx, a, artID) default: return nil, ErrUnavailable } diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go new file mode 100644 index 000000000..7548f76d2 --- /dev/null +++ b/core/artwork/reader_disc.go @@ -0,0 +1,268 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type discArtworkReader struct { + cacheKey + a *artwork + album model.Album + discNumber int + imgFiles []string + discFolders map[string]bool + isMultiFolder bool + firstTrackPath string + updatedAt *time.Time +} + +func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) { + albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) + if err != nil { + return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) + } + + al, err := a.ds.Album(ctx).Get(albumID) + if err != nil { + return nil, err + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al) + if err != nil { + return nil, err + } + + // Query mediafiles for this album + disc to find folder associations and first track + mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Sort: "track_number", + Order: "ASC", + Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, + }) + if err != nil { + return nil, err + } + + // Build disc folder set and find first track + discFolders := make(map[string]bool) + var firstTrackPath string + allFolderIDs := make(map[string]bool) + for _, mf := range mfs { + allFolderIDs[mf.FolderID] = true + if firstTrackPath == "" { + firstTrackPath = mf.Path + } + } + + // Resolve folder IDs to absolute paths + if len(allFolderIDs) > 0 { + folderIDs := make([]string, 0, len(allFolderIDs)) + for id := range allFolderIDs { + folderIDs = append(folderIDs, id) + } + folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"folder.id": folderIDs}, + }) + if err != nil { + return nil, err + } + for _, f := range folders { + discFolders[f.AbsolutePath()] = true + } + } + + isMultiFolder := len(al.FolderIDs) > 1 + + r := &discArtworkReader{ + a: a, + album: *al, + discNumber: discNumber, + imgFiles: imgFiles, + discFolders: discFolders, + isMultiFolder: isMultiFolder, + firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath), + updatedAt: imagesUpdatedAt, + } + r.cacheKey.artID = artID + if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) { + r.cacheKey.lastUpdate = *r.updatedAt + } else { + r.cacheKey.lastUpdate = al.UpdatedAt + } + return r, nil +} + +func (d *discArtworkReader) Key() string { + hash := md5.Sum([]byte(conf.Server.DiscArtPriority)) + return fmt.Sprintf( + "%s.%x", + d.cacheKey.Key(), + hash, + ) +} + +func (d *discArtworkReader) LastUpdated() time.Time { + return d.album.UpdatedAt +} + +func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority) + // Fallback to album cover art + albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt) + ff = append(ff, fromAlbum(ctx, d.a, albumArtID)) + return selectImageReader(ctx, d.cacheKey.artID, ff...) +} + +func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { + var ff []sourceFunc + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath)) + case pattern == "external": + // Not supported for disc art, silently ignore + case pattern == "discsubtitle": + if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" { + ff = append(ff, d.fromDiscSubtitle(ctx, subtitle)) + } + case len(d.imgFiles) > 0: + ff = append(ff, d.fromExternalFile(ctx, pattern)) + } + } + return ff +} + +// fromDiscSubtitle returns a sourceFunc that matches image files whose stem +// (filename without extension) equals the disc subtitle (case-insensitive). +func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { + return func() (io.ReadCloser, string, error) { + for _, file := range d.imgFiles { + _, name := filepath.Split(file) + stem := strings.TrimSuffix(name, filepath.Ext(name)) + if !strings.EqualFold(stem, subtitle) { + continue + } + f, err := os.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil + } + return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle) + } +} + +// extractDiscNumber extracts a disc number from a filename based on a glob pattern. +// It finds the portion of the filename that the wildcard matched and parses leading +// digits as the disc number. Returns (0, false) if the pattern doesn't match or +// no leading digits are found in the wildcard portion. +func extractDiscNumber(pattern, filename string) (int, bool) { + filename = strings.ToLower(filename) + pattern = strings.ToLower(pattern) + + matched, err := filepath.Match(pattern, filename) + if err != nil || !matched { + return 0, false + } + + // Find the prefix before the first '*' in the pattern + starIdx := strings.IndexByte(pattern, '*') + if starIdx < 0 { + return 0, false + } + prefix := pattern[:starIdx] + + // Strip the prefix from the filename to get the wildcard-matched portion + if !strings.HasPrefix(filename, prefix) { + return 0, false + } + remainder := filename[len(prefix):] + + // Extract leading ASCII digits from the remainder + var digits []byte + for _, r := range remainder { + if r >= '0' && r <= '9' { + digits = append(digits, byte(r)) + } else { + break + } + } + + if len(digits) == 0 { + return 0, false + } + + num, err := strconv.Atoi(string(digits)) + if err != nil { + return 0, false + } + return num, true +} + +// fromExternalFile returns a sourceFunc that matches image files against a glob +// pattern with disc-number-aware filtering. +// +// Matching rules: +// - If a disc number can be extracted from the filename, the file matches only if +// the number equals the target disc number. +// - If no number is found and this is a multi-folder album, the file matches if +// it's in a folder containing tracks for this disc. +// - If no number is found and this is a single-folder album, the file is skipped +// (ambiguous). +func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + for _, file := range d.imgFiles { + _, name := filepath.Split(file) + match, err := filepath.Match(pattern, strings.ToLower(name)) + if err != nil { + log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) + continue + } + if !match { + continue + } + + // Try to extract disc number from filename + num, hasNum := extractDiscNumber(pattern, name) + if hasNum { + // File has a disc number — must match target disc + if num != d.discNumber { + continue + } + } else if d.isMultiFolder { + // No number, multi-folder: match by folder association + dir := filepath.Dir(file) + if !d.discFolders[dir] { + continue + } + } else { + // No number, single-folder: ambiguous, skip + continue + } + + f, err := os.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil + } + return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern) + } +} diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go new file mode 100644 index 000000000..f8193e24e --- /dev/null +++ b/core/artwork/reader_disc_test.go @@ -0,0 +1,285 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Disc Artwork Reader", func() { + Describe("extractDiscNumber", func() { + DescribeTable("extracts disc number from filename based on glob pattern", + func(pattern, filename string, expectedNum int, expectedOk bool) { + num, ok := extractDiscNumber(pattern, filename) + Expect(ok).To(Equal(expectedOk)) + if expectedOk { + Expect(num).To(Equal(expectedNum)) + } + }, + // Standard disc patterns + Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true), + Entry("disc2.png", "disc*.*", "disc2.png", 2, true), + Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true), + Entry("disc02.png", "disc*.*", "disc02.png", 2, true), + Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true), + + // CD patterns + Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true), + Entry("cd02.png", "cd*.*", "cd02.png", 2, true), + + // No number in filename + Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false), + Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false), + + // Extra text after number + Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true), + Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true), + + // Case insensitive (filename already lowered by caller) + Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true), + + // Pattern doesn't match + Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false), + + // Pattern with no wildcard before dot + Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true), + ) + }) + + Describe("fromExternalFile", func() { + var ( + ctx context.Context + tmpDir string + ) + + BeforeEach(func() { + ctx = context.Background() + tmpDir = GinkgoT().TempDir() + }) + + createFile := func(path string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) + Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) + return fullPath + } + + It("matches file with disc number in single-folder album", func() { + f1 := createFile("album/disc1.jpg") + f2 := createFile("album/disc2.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("skips file without number in single-folder album", func() { + f1 := createFile("album/disc.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, _, _ := sf() + Expect(r).To(BeNil()) + }) + + It("matches file without number in multi-folder album by folder", func() { + f1 := createFile("album/cd1/disc.jpg") + f2 := createFile("album/cd2/disc.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, + isMultiFolder: true, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("prefers disc number over folder when number is present", func() { + // disc2.jpg in cd1 folder should match disc 2, not disc 1 + f1 := createFile("album/cd1/disc2.jpg") + reader := &discArtworkReader{ + discNumber: 2, + imgFiles: []string{f1}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, + isMultiFolder: true, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("does not match disc2.jpg when looking for disc 1", func() { + f1 := createFile("album/disc2.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, _, _ := sf() + Expect(r).To(BeNil()) + }) + }) + + Describe("fromDiscSubtitle", func() { + var ( + ctx context.Context + tmpDir string + ) + + BeforeEach(func() { + ctx = context.Background() + tmpDir = GinkgoT().TempDir() + }) + + createFile := func(path string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) + Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) + return fullPath + } + + It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() { + f1 := createFile("album/The Blue Disc.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1}, + } + + sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("matches case-insensitively", func() { + f1 := createFile("album/bonus tracks.png") + reader := &discArtworkReader{ + discNumber: 2, + imgFiles: []string{f1}, + } + + sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("returns error when no matching file found", func() { + f1 := createFile("album/cover.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1}, + } + + sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") + _, _, err := sf() + Expect(err).To(HaveOccurred()) + }) + + It("matches first file when multiple extensions exist", func() { + f1 := createFile("album/The Blue Disc.jpg") + f2 := createFile("album/The Blue Disc.png") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + } + + sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + }) + + Describe("discArtworkReader", func() { + Describe("fromDiscArtPriority", func() { + var reader *discArtworkReader + + BeforeEach(func() { + reader = &discArtworkReader{ + discNumber: 2, + isMultiFolder: true, + discFolders: map[string]bool{"/music/album/cd2": true}, + imgFiles: []string{ + "/music/album/cd1/disc.jpg", + "/music/album/cd2/disc.jpg", + "/music/album/cd2/disc2.jpg", + }, + firstTrackPath: "/music/album/cd2/track1.flac", + } + }) + + It("returns source funcs for glob patterns", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") + Expect(ff).To(HaveLen(1)) + }) + + It("returns source funcs for embedded pattern", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded") + Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag + }) + + It("handles multiple comma-separated patterns", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded") + Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag + }) + + It("ignores 'external' pattern silently", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "external") + Expect(ff).To(HaveLen(0)) + }) + + It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() { + reader.imgFiles = nil + ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") + Expect(ff).To(HaveLen(0)) + }) + + It("returns source func for discsubtitle pattern", func() { + reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}} + ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") + Expect(ff).To(HaveLen(1)) + }) + + It("returns no source func for discsubtitle when disc has no subtitle", func() { + reader.album = model.Album{Discs: model.Discs{2: ""}} + ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") + Expect(ff).To(HaveLen(0)) + }) + }) + }) +}) diff --git a/model/artwork_id.go b/model/artwork_id.go index 36026dd03..8d935f427 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -22,6 +22,7 @@ var ( KindArtistArtwork = Kind{"ar", "artist"} KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} + KindDiscArtwork = Kind{"dc", "disc"} ) var artworkKindMap = map[string]Kind{ @@ -29,6 +30,7 @@ var artworkKindMap = map[string]Kind{ KindArtistArtwork.prefix: KindArtistArtwork, KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, + KindDiscArtwork.prefix: KindDiscArtwork, } type ArtworkID struct { @@ -91,6 +93,22 @@ func MustParseArtworkID(id string) ArtworkID { return artID } +func DiscArtworkID(albumID string, discNumber int) string { + return fmt.Sprintf("%s:%d", albumID, discNumber) +} + +func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 || parts[1] == "" { + return "", 0, errors.New("invalid disc artwork id") + } + num, err := strconv.Atoi(parts[1]) + if err != nil { + return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err) + } + return parts[0], num, nil +} + func artworkIDFromAlbum(al Album) ArtworkID { return ArtworkID{ Kind: KindAlbumArtwork, diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index 2f42217f9..b634e7cbc 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -28,6 +28,40 @@ var _ = Describe("ArtworkID", func() { Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) + Describe("ParseArtworkID - disc kind", func() { + It("parses a disc artwork ID with dc prefix", func() { + now := time.Now() + id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now) + parsedId, err := model.ParseArtworkID(id.String()) + Expect(err).ToNot(HaveOccurred()) + Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) + Expect(parsedId.ID).To(Equal("albumid123:2")) + Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) + }) + }) + + Describe("ParseDiscArtworkID", func() { + DescribeTable("parses composite disc artwork IDs", + func(id string, expectedAlbum string, expectedDisc int, expectErr bool) { + albumID, discNumber, err := model.ParseDiscArtworkID(id) + if expectErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + Expect(albumID).To(Equal(expectedAlbum)) + Expect(discNumber).To(Equal(expectedDisc)) + } + }, + Entry("valid id", "albumid123:2", "albumid123", 2, false), + Entry("disc number 1", "abc:1", "abc", 1, false), + Entry("large disc number", "abc:10", "abc", 10, false), + Entry("missing colon", "albumid123", "", 0, true), + Entry("missing disc number", "albumid123:", "", 0, true), + Entry("non-numeric disc", "albumid123:abc", "", 0, true), + Entry("empty string", "", "", 0, true), + ) + }) + Describe("ParseArtworkID()", func() { It("parses album artwork ids", func() { id, err := model.ParseArtworkID("al-1234") diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 598346901..e930aa630 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -392,7 +392,13 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle { } var discTitles []responses.DiscTitle for num, title := range a.Discs { - discTitles = append(discTitles, responses.DiscTitle{Disc: int32(num), Title: title}) + artID := model.NewArtworkID(model.KindDiscArtwork, + model.DiscArtworkID(a.ID, num), &a.UpdatedAt) + discTitles = append(discTitles, responses.DiscTitle{ + Disc: int32(num), + Title: title, + CoverArt: artID.String(), + }) } if len(discTitles) == 1 && discTitles[0].Title == "" { return nil diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 2099c8f69..d20ca89ad 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "net/http/httptest" + "time" "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/conf" @@ -103,27 +104,43 @@ var _ = Describe("helpers", func() { Expect(buildDiscSubtitles(album)).To(BeNil()) }) - It("should return the disc title for a single disc", func() { + It("should return the disc title with cover art for a single disc", func() { + updatedAt := time.Now().Truncate(time.Second) album := model.Album{ + ID: "album1", + UpdatedAt: updatedAt, Discs: map[int]string{ 1: "Special Edition", }, } - Expect(buildDiscSubtitles(album)).To(Equal([]responses.DiscTitle{{Disc: 1, Title: "Special Edition"}})) + result := buildDiscSubtitles(album) + Expect(result).To(HaveLen(1)) + Expect(result[0].Disc).To(Equal(int32(1))) + Expect(result[0].Title).To(Equal("Special Edition")) + expectedArtID := model.NewArtworkID(model.KindDiscArtwork, "album1:1", &updatedAt) + Expect(result[0].CoverArt).To(Equal(expectedArtID.String())) }) - It("should return correct disc titles when album has discs with valid disc numbers", func() { + It("should return correct disc titles with cover art when album has multiple discs", func() { + updatedAt := time.Now().Truncate(time.Second) album := model.Album{ + ID: "album1", + UpdatedAt: updatedAt, Discs: map[int]string{ 1: "Disc 1", 2: "Disc 2", }, } - expected := []responses.DiscTitle{ - {Disc: 1, Title: "Disc 1"}, - {Disc: 2, Title: "Disc 2"}, - } - Expect(buildDiscSubtitles(album)).To(Equal(expected)) + result := buildDiscSubtitles(album) + Expect(result).To(HaveLen(2)) + Expect(result[0].Disc).To(Equal(int32(1))) + Expect(result[0].Title).To(Equal("Disc 1")) + expectedArtID1 := model.NewArtworkID(model.KindDiscArtwork, "album1:1", &updatedAt) + Expect(result[0].CoverArt).To(Equal(expectedArtID1.String())) + Expect(result[1].Disc).To(Equal(int32(2))) + Expect(result[1].Title).To(Equal("Disc 2")) + expectedArtID2 := model.NewArtworkID(model.KindDiscArtwork, "album1:2", &updatedAt) + Expect(result[1].CoverArt).To(Equal(expectedArtID2.String())) }) }) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index be59e5851..c550ce61e 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -575,8 +575,9 @@ func (r ReplayGain) MarshalXML(e *xml.Encoder, start xml.StartElement) error { } type DiscTitle struct { - Disc int32 `xml:"disc,attr" json:"disc"` - Title string `xml:"title,attr" json:"title"` + Disc int32 `xml:"disc,attr" json:"disc"` + Title string `xml:"title,attr" json:"title"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` } type ItemDate struct { diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index 3586cf225..fbf2b9e31 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -1,4 +1,10 @@ -import React, { isValidElement, useMemo, useCallback, forwardRef } from 'react' +import React, { + isValidElement, + useMemo, + useCallback, + useState, + forwardRef, +} from 'react' import { useDispatch } from 'react-redux' import { Datagrid, @@ -17,7 +23,10 @@ import { makeStyles } from '@material-ui/core/styles' import AlbumIcon from '@material-ui/icons/Album' import clsx from 'clsx' import { useDrag } from 'react-dnd' +import Lightbox from 'react-image-lightbox' +import 'react-image-lightbox/style.css' import { playTracks } from '../actions' +import subsonic from '../subsonic' import { AlbumContextMenu } from '../common' import { DraggableTypes } from '../consts' import { formatFullDate } from '../utils' @@ -28,10 +37,20 @@ const useStyles = makeStyles({ overflow: 'hidden', textOverflow: 'ellipsis', verticalAlign: 'middle', + display: 'flex', + alignItems: 'center', }, discIcon: { - verticalAlign: 'text-top', - marginRight: '4px', + marginRight: '14px', + }, + discCoverArt: { + width: '48px', + height: '48px', + marginRight: '14px', + objectFit: 'cover', + borderRadius: '4px', + flexShrink: 0, + cursor: 'pointer', }, row: { cursor: 'pointer', @@ -61,19 +80,45 @@ const useStyles = makeStyles({ const DiscSubtitleRow = forwardRef( ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { + const translate = useTranslate() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) + const [imageError, setImageError] = useState(false) + const [isLightboxOpen, setLightboxOpen] = useState(false) const handlePlaySubset = (discNumber) => () => { onClick(discNumber) } - let subtitle = [] - if (record.discNumber > 0) { - subtitle.push(record.discNumber) - } - if (record.discSubtitle) { - subtitle.push(record.discSubtitle) - } + const coverArtUrl = subsonic.getDiscCoverArtUrl( + record.albumId, + record.discNumber, + record.updatedAt, + 96, + ) + + const fullImageUrl = subsonic.getDiscCoverArtUrl( + record.albumId, + record.discNumber, + record.updatedAt, + ) + + const handleOpenLightbox = useCallback( + (e) => { + if (!imageError) { + e.stopPropagation() + setLightboxOpen(true) + } + }, + [imageError], + ) + + const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + + const subtitle = record.discSubtitle + ? record.discSubtitle + : translate('resources.song.fields.disc', { + discNumber: record.discNumber, + }) return ( - - {subtitle.join(': ')} + {!imageError ? ( + setImageError(true)} + /> + ) : ( + + )} + {subtitle} + {isLightboxOpen && !imageError && ( + + )} { } } +const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => { + const options = { + ...(updatedAt && { _: updatedAt }), + ...(size && { size }), + } + return baseUrl( + url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options), + ) +} + const getArtistInfo = (id) => { return httpClient(url('getArtistInfo', id)) } @@ -129,6 +139,7 @@ export default { getScanStatus, getNowPlaying, getCoverArtUrl, + getDiscCoverArtUrl, getAvatarUrl, streamUrl, getAlbumInfo, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 1e0fbeaa6..2544e03f8 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -105,6 +105,56 @@ describe('getCoverArtUrl', () => { }) }) +describe('getDiscCoverArtUrl', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => { + const url = subsonic.getDiscCoverArtUrl( + 'album-123', + 2, + '2023-01-01T00:00:00Z', + 48, + ) + + expect(url).toContain('getCoverArt') + expect(url).toContain('id=dc-album-123%3A2') + expect(url).toContain('size=48') + expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') + }) + + it('should handle missing updatedAt', () => { + const url = subsonic.getDiscCoverArtUrl('album-123', 1, undefined, 48) + + expect(url).toContain('id=dc-album-123%3A1') + expect(url).toContain('size=48') + expect(url).not.toContain('_=') + }) + + it('should handle missing size', () => { + const url = subsonic.getDiscCoverArtUrl( + 'album-123', + 1, + '2023-01-01T00:00:00Z', + ) + + expect(url).toContain('id=dc-album-123%3A1') + expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') + expect(url).not.toContain('size=') + }) +}) + describe('getAvatarUrl', () => { beforeEach(() => { // Mock localStorage values required by subsonic From 55e10b9c778729154e3ba0aaa0a43b135270ae99 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Mar 2026 19:20:07 -0400 Subject: [PATCH 087/596] fix(playlist): update smart playlist rules during metadata update Signed-off-by: Deluan --- core/playlists/rest_adapter.go | 2 ++ core/playlists/rest_adapter_test.go | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index c9cd4c136..3fecda0d5 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -97,5 +97,7 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity if entity.OwnerID != "" { current.OwnerID = entity.OwnerID } + // Apply smart playlist rules update + current.Rules = entity.Rules return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 29db2fc33..70ca8a9e4 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -125,6 +125,22 @@ var _ = Describe("REST Adapter", func() { Expect(err).To(Equal(rest.ErrPermissionDenied)) }) + It("updates smart playlist rules", func() { + mockPlsRepo.Data["smart-1"] = &model.Playlist{ + ID: "smart-1", + Name: "Smart Playlist", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} + pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules} + err := repo.Update("smart-1", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + }) + It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) From d042fc138cc173dbb89263d03e5289bd2ecc5ee5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Mar 2026 21:06:26 -0400 Subject: [PATCH 088/596] refactor(nanoid): replace gonanoid with custom nanoid implementation for ID generation Signed-off-by: Deluan --- core/share.go | 4 +- go.mod | 1 - go.sum | 2 - model/id/id.go | 4 +- utils/nanoid/nanoid.go | 52 +++++++++++++++++++++++ utils/nanoid/nanoid_test.go | 85 +++++++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 utils/nanoid/nanoid.go create mode 100644 utils/nanoid/nanoid_test.go diff --git a/core/share.go b/core/share.go index fa43a95dd..a6d06a018 100644 --- a/core/share.go +++ b/core/share.go @@ -7,11 +7,11 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" - gonanoid "github.com/matoous/go-nanoid/v2" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) @@ -73,7 +73,7 @@ type shareRepositoryWrapper struct { func (r *shareRepositoryWrapper) newId() (string, error) { for { - id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) + id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) if err != nil { return "", err } diff --git a/go.mod b/go.mod index 424c52421..33333ca33 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,6 @@ require ( github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 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/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 diff --git a/go.sum b/go.sum index a28900a41..30ca76e86 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,6 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= -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= diff --git a/model/id/id.go b/model/id/id.go index 930875260..b54542898 100644 --- a/model/id/id.go +++ b/model/id/id.go @@ -6,12 +6,12 @@ import ( "math/big" "strings" - gonanoid "github.com/matoous/go-nanoid/v2" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/nanoid" ) func NewRandom() string { - id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22) + id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22) if err != nil { log.Error("Could not generate new ID", err) } diff --git a/utils/nanoid/nanoid.go b/utils/nanoid/nanoid.go new file mode 100644 index 000000000..17d32e72f --- /dev/null +++ b/utils/nanoid/nanoid.go @@ -0,0 +1,52 @@ +package nanoid + +import ( + "crypto/rand" + "errors" + "math" +) + +// Generate returns a cryptographically secure random string of `size` characters +// drawn from `alphabet`. It uses bitmask with rejection sampling to avoid modulo bias. +// The alphabet must be non-empty, contain at most 255 characters, and consist only of +// ASCII characters. Non-ASCII alphabets (e.g., multi-byte UTF-8) are not supported. +func Generate(alphabet string, size int) (string, error) { + if len(alphabet) == 0 || len(alphabet) > 255 { + return "", errors.New("alphabet must be non-empty and at most 255 characters") + } + if size <= 0 { + return "", errors.New("size must be a positive integer") + } + + mask := getMask(len(alphabet)) + step := int(math.Ceil(1.6 * float64(mask) * float64(size) / float64(len(alphabet)))) + + id := make([]byte, size) + bytes := make([]byte, step) + for j := 0; ; { + if _, err := rand.Read(bytes); err != nil { + return "", err + } + for i := range step { + idx := int(bytes[i]) & mask + if idx < len(alphabet) { + id[j] = alphabet[idx] + j++ + if j == size { + return string(id), nil + } + } + } + } +} + +// getMask returns the smallest bitmask >= alphabetSize-1. +func getMask(alphabetSize int) int { + for i := 1; i <= 8; i++ { + mask := (2 << uint(i)) - 1 + if mask >= alphabetSize-1 { + return mask + } + } + return 0 +} diff --git a/utils/nanoid/nanoid_test.go b/utils/nanoid/nanoid_test.go new file mode 100644 index 000000000..99d4e5715 --- /dev/null +++ b/utils/nanoid/nanoid_test.go @@ -0,0 +1,85 @@ +package nanoid_test + +import ( + "testing" + + "github.com/navidrome/navidrome/utils/nanoid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNanoid(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Nanoid Suite") +} + +var _ = Describe("Generate", func() { + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + It("generates a string of the requested length", func() { + id, err := nanoid.Generate(alphabet, 22) + Expect(err).ToNot(HaveOccurred()) + Expect(id).To(HaveLen(22)) + }) + + It("generates a short string of the requested length", func() { + id, err := nanoid.Generate(alphabet, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(id).To(HaveLen(10)) + }) + + It("only contains characters from the alphabet", func() { + id, err := nanoid.Generate(alphabet, 100) + Expect(err).ToNot(HaveOccurred()) + for _, c := range id { + Expect(alphabet).To(ContainSubstring(string(c))) + } + }) + + It("generates unique IDs", func() { + seen := make(map[string]bool) + for range 1000 { + id, err := nanoid.Generate(alphabet, 22) + Expect(err).ToNot(HaveOccurred()) + Expect(seen).ToNot(HaveKey(id)) + seen[id] = true + } + }) + + It("works with a single-character alphabet", func() { + id, err := nanoid.Generate("a", 5) + Expect(err).ToNot(HaveOccurred()) + Expect(id).To(Equal("aaaaa")) + }) + + It("works with a small alphabet", func() { + id, err := nanoid.Generate("ab", 10) + Expect(err).ToNot(HaveOccurred()) + Expect(id).To(HaveLen(10)) + for _, c := range id { + Expect(string(c)).To(BeElementOf("a", "b")) + } + }) + + It("returns error on empty alphabet", func() { + _, err := nanoid.Generate("", 10) + Expect(err).To(HaveOccurred()) + }) + + It("returns error on alphabet larger than 255 characters", func() { + bigAlphabet := make([]byte, 256) + for i := range bigAlphabet { + bigAlphabet[i] = byte(i) + } + _, err := nanoid.Generate(string(bigAlphabet), 10) + Expect(err).To(HaveOccurred()) + }) + + It("returns error on non-positive size", func() { + _, err := nanoid.Generate(alphabet, 0) + Expect(err).To(HaveOccurred()) + + _, err = nanoid.Generate(alphabet, -1) + Expect(err).To(HaveOccurred()) + }) +}) From 55331b5fd92c3534a1e75f7ebfb1dd537f7efb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 14 Mar 2026 00:07:21 -0400 Subject: [PATCH 089/596] fix(scanner): prevent duplicate tracks when multiple missing files match same target (#5183) In processMissingTracks, matched tracks were not removed from the candidate pool after being consumed by moveMatched. This allowed the same target track to be paired with multiple missing tracks, creating duplicate non-missing records with the same path. Track consumed matches in a usedMatched map so each target is used at most once. Fixes #5169 --- scanner/phase_2_missing_tracks.go | 12 +++++++++- scanner/phase_2_missing_tracks_test.go | 33 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index c47565036..8c258b833 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -114,6 +114,10 @@ func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] { func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) { hasMatches := false + // Track which matched entries have already been consumed, so each matched track + // is only used once. Without this, the same matched track could be paired with + // multiple missing tracks, creating duplicate records with the same path. + usedMatched := make(map[string]bool, len(in.matched)) for _, ms := range in.missing { var exactMatch model.MediaFile @@ -121,6 +125,9 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr // Identify exact and equivalent matches for _, mt := range in.matched { + if usedMatched[mt.ID] { + continue + } if ms.Equals(mt) { exactMatch = mt break // Prioritize exact match @@ -138,13 +145,14 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr log.Error(p.ctx, "Scanner: Error moving matched track", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name, err) return nil, err } + usedMatched[exactMatch.ID] = true p.totalMatched.Add(1) hasMatches = true continue } // If there is only one missing and one matched track, consider them equivalent (same PID) - if len(in.missing) == 1 && len(in.matched) == 1 { + if len(in.missing) == 1 && len(in.matched) == 1 && !usedMatched[in.matched[0].ID] { singleMatch := in.matched[0] log.Debug(p.ctx, "Scanner: Found track with same persistent ID in a new place", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name) err := p.moveMatched(singleMatch, ms) @@ -152,6 +160,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name, err) return nil, err } + usedMatched[singleMatch.ID] = true p.totalMatched.Add(1) hasMatches = true continue @@ -165,6 +174,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name, err) return nil, err } + usedMatched[equivalentMatch.ID] = true p.totalMatched.Add(1) hasMatches = true } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index fa6ef5724..d54ceee40 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -241,6 +241,39 @@ var _ = Describe("phaseMissingTracks", func() { Expect(movedTrack.Size).To(Equal(missingTrack.Size)) }) + It("should not match the same target to multiple missing tracks (prevents duplicate paths)", func() { + // Simulate a scenario where two missing tracks from different locations have the same + // base filename and match the same newly imported track via IsEquivalent. + // Without deduplication, both missing tracks would be "moved" to the same target, + // creating two non-missing records with the same path. + missingTrack1 := model.MediaFile{ID: "1", PID: "A", Path: "old_dir1/song.mp3", Title: "title1", Size: 100} + missingTrack2 := model.MediaFile{ID: "2", PID: "A", Path: "old_dir2/song.mp3", Title: "title1", Size: 100} + matchedTrack := model.MediaFile{ID: "3", PID: "A", Path: "new_dir/song.mp3", Title: "title1", Size: 200} + + _ = ds.MediaFile(ctx).Put(&missingTrack1) + _ = ds.MediaFile(ctx).Put(&missingTrack2) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + in := &missingTracks{ + missing: []model.MediaFile{missingTrack1, missingTrack2}, + matched: []model.MediaFile{matchedTrack}, + } + + _, err := phase.processMissingTracks(in) + Expect(err).ToNot(HaveOccurred()) + // Only one of the missing tracks should be matched + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + // The matched track should have been consumed by the first missing track + movedTrack, _ := ds.MediaFile(ctx).Get("1") + Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) + + // The second missing track should remain unchanged + unmatchedTrack, _ := ds.MediaFile(ctx).Get("2") + Expect(unmatchedTrack.Path).To(Equal(missingTrack2.Path)) + }) + It("should return an error when there's an error moving the matched track", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} From d79b8124674bef23283201a097b99fc29a195f52 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 09:59:51 -0400 Subject: [PATCH 090/596] refactor(natural): replace maruel/natural with custom natural sort implementation --- core/artwork/reader_album.go | 2 +- utils/natural/natural.go | 98 ++++++++++++++++++++++++++++ utils/natural/natural_test.go | 116 ++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 utils/natural/natural.go create mode 100644 utils/natural/natural_test.go diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 36b2fff05..98a2105eb 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -13,13 +13,13 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/maruel/natural" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/natural" ) type albumArtworkReader struct { diff --git a/utils/natural/natural.go b/utils/natural/natural.go new file mode 100644 index 000000000..fa0800e1d --- /dev/null +++ b/utils/natural/natural.go @@ -0,0 +1,98 @@ +// Package natural provides natural (alphanumeric) string comparison. +// When both strings have digit sequences at the same position, they are +// compared numerically (so "file2" < "file10"); otherwise bytes are +// compared one-by-one. No allocations are made. +package natural + +import "strings" + +// Compare returns a negative value if a < b, zero if a == b, +// or a positive value if a > b using natural sort ordering. +// +// When two numeric segments are numerically equal (e.g. "01" vs "1"), +// comparison continues with the remaining suffixes. If one or both +// strings end at the digit boundary, the raw strings are compared +// lexically, which makes leading zeros significant as a tie-breaker +// (e.g. "a01" < "a1", "a0" < "a00"). +func Compare(a, b string) int { + ia, ib := 0, 0 + for ia < len(a) && ib < len(b) { + ca, cb := a[ia], b[ib] + da, db := isDigit(ca), isDigit(cb) + + switch { + case da && db: + // Both are in digit sequences — compare numerically. + endA := ia + for endA < len(a) && isDigit(a[endA]) { + endA++ + } + endB := ib + for endB < len(b) && isDigit(b[endB]) { + endB++ + } + + if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 { + return c + } + + // Numerically equal. If both sides have trailing data, continue + // comparing after the digit runs. Otherwise fall through to + // lexical comparison of the full remaining strings (which makes + // leading-zero differences significant as a tie-breaker). + if endA < len(a) && endB < len(b) { + ia = endA + ib = endB + continue + } + return strings.Compare(a[ia:], b[ib:]) + case da != db: + return int(ca) - int(cb) + default: + if ca != cb { + return int(ca) - int(cb) + } + ia++ + ib++ + } + } + return (len(a) - ia) - (len(b) - ib) +} + +// compareNumbers compares two digit strings numerically. +// Leading zeros are stripped before comparison. +func compareNumbers(a, b string) int { + // Strip leading zeros. + sa := stripZeros(a) + sb := stripZeros(b) + + // Different lengths after stripping means different magnitude. + if len(sa) != len(sb) { + return len(sa) - len(sb) + } + + // Same length — compare digit by digit. + for i := range len(sa) { + if sa[i] != sb[i] { + return int(sa[i]) - int(sb[i]) + } + } + return 0 +} + +// stripZeros returns s with leading '0' bytes removed. +// If s is all zeros, returns the last byte (a single "0"). +func stripZeros(s string) string { + i := 0 + for i < len(s) && s[i] == '0' { + i++ + } + if i == len(s) && len(s) > 0 { + return s[len(s)-1:] + } + return s[i:] +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} diff --git a/utils/natural/natural_test.go b/utils/natural/natural_test.go new file mode 100644 index 000000000..825a944c0 --- /dev/null +++ b/utils/natural/natural_test.go @@ -0,0 +1,116 @@ +package natural_test + +import ( + "testing" + + "github.com/navidrome/navidrome/utils/natural" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNatural(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Natural Suite") +} + +var _ = Describe("Compare", func() { + DescribeTable("returns correct ordering", + func(a, b string, expected int) { + result := natural.Compare(a, b) + if expected < 0 { + Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b) + } else if expected > 0 { + Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b) + } else { + Expect(result).To(Equal(0), "expected %q == %q", a, b) + } + }, + // Basic string ordering + Entry("a < b", "a", "b", -1), + Entry("b > a", "b", "a", 1), + Entry("a < aa (prefix)", "a", "aa", -1), + Entry("aa > a", "aa", "a", 1), + + // Equal strings + Entry("equal strings return 0", "abc", "abc", 0), + Entry("both empty", "", "", 0), + Entry("a01 == a01", "a01", "a01", 0), + Entry("a1 == a1", "a1", "a1", 0), + + // Empty string edge cases + Entry("empty < non-empty", "", "a", -1), + Entry("non-empty > empty", "a", "", 1), + + // Numeric comparison + Entry("2 < 10 numerically", "2", "10", -1), + Entry("10 > 2 numerically", "10", "2", 1), + Entry("equal numbers", "42", "42", 0), + Entry("9 < 10", "9", "10", -1), + Entry("99 < 100", "99", "100", -1), + + // Simple numeric segments (from original library) + Entry("a0 < a1", "a0", "a1", -1), + Entry("a0 < a00", "a0", "a00", -1), + Entry("a00 < a01", "a00", "a01", -1), + Entry("a01 < a1", "a01", "a1", -1), + Entry("a01 < a2", "a01", "a2", -1), + Entry("a01x < a2x", "a01x", "a2x", -1), + Entry("a01 > a00", "a01", "a00", 1), + Entry("a2 > a01", "a2", "a01", 1), + Entry("a2x > a01x", "a2x", "a01x", 1), + + // Multiple numeric groups (from original library) + Entry("a0b00 < a00b1", "a0b00", "a00b1", -1), + Entry("a0b00 < a00b01", "a0b00", "a00b01", -1), + Entry("a00b0 < a0b00", "a00b0", "a0b00", -1), + Entry("a00b00 < a0b01", "a00b00", "a0b01", -1), + Entry("a00b00 < a0b1", "a00b00", "a0b1", -1), + Entry("a00b00 > a0b0", "a00b00", "a0b0", 1), + Entry("a00b01 > a0b00", "a00b01", "a0b00", 1), + Entry("a00b00 == a0b00", "a00b00", "a0b00", 0), + + // Leading zeros at end of string — lexical tie-break + Entry("file01 < file1", "file01", "file1", -1), + + // Prefix comparison + Entry("abc < abcd", "abc", "abcd", -1), + Entry("abcd > abc", "abcd", "abc", 1), + + // Navidrome use cases: cover art sorting + Entry("cover < cover.1", "cover", "cover.1", -1), + Entry("cover.1 < cover.2", "cover.1", "cover.2", -1), + Entry("cover.2 < cover.10", "cover.2", "cover.10", -1), + + // Navidrome use cases: disc sorting + Entry("disc1 < disc2", "disc1", "disc2", -1), + Entry("disc2 < disc10", "disc2", "disc10", -1), + Entry("disc1 < disc10", "disc1", "disc10", -1), + + // Multiple numeric segments + Entry("a1b2 < a1b10", "a1b2", "a1b10", -1), + Entry("a2b1 > a1b2", "a2b1", "a1b2", 1), + + // Numbers at the start + Entry("2abc < 10abc", "2abc", "10abc", -1), + + // Numbers larger than uint64 max (from original library) + Entry("large: fewer digits < more digits", + "a99999999999999999999", "a100000000000000000000", -1), + Entry("large: digit-by-digit comparison", + "a123456789012345678901234567890", "a123456789012345678901234567891", -1), + Entry("large: more digits > fewer digits", + "a999999999999999999999", "a1000000000000000000000", -1), + Entry("large: 20 digits < 100 digits by length", + "a20000000000000000000", "a100000000000000000000", -1), + Entry("large: 100 digits > 20 digits", + "a100000000000000000000", "a20000000000000000000", 1), + Entry("large: reverse of above", + "a1000000000000000000000", "a999999999999999999999", 1), + Entry("large: equal", + "a100000000000000000000", "a100000000000000000000", 0), + Entry("large: leading zeros with trailing data", + "a00000000000000000000001x", "a1x", 0), + Entry("large: leading zeros with trailing data (2)", + "a099999999999999999999x", "a99999999999999999999x", 0), + ) +}) From 8939f31d55cf1443ceec940650aafd24a6449737 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 09:59:52 -0400 Subject: [PATCH 091/596] refactor(jsoncommentstrip): replace go-jsoncommentstrip with custom JSON comment stripping --- core/playlists/parse_nsp.go | 2 +- utils/jsoncommentstrip/jsoncommentstrip.go | 128 ++++++++++++++ .../jsoncommentstrip/jsoncommentstrip_test.go | 164 ++++++++++++++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 utils/jsoncommentstrip/jsoncommentstrip.go create mode 100644 utils/jsoncommentstrip/jsoncommentstrip_test.go diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index c7f7dd946..56c80a950 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -9,10 +9,10 @@ import ( "os" "path/filepath" - "github.com/RaveNoX/go-jsoncommentstrip" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/utils/jsoncommentstrip" ) func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { diff --git a/utils/jsoncommentstrip/jsoncommentstrip.go b/utils/jsoncommentstrip/jsoncommentstrip.go new file mode 100644 index 000000000..54ddd88fa --- /dev/null +++ b/utils/jsoncommentstrip/jsoncommentstrip.go @@ -0,0 +1,128 @@ +// Package jsoncommentstrip provides an io.Reader that strips JavaScript-style +// comments (// line and /* block */) from JSON input while preserving +// comment-like sequences inside JSON string values. +package jsoncommentstrip + +import ( + "bufio" + "io" +) + +type state int + +const ( + stateNormal state = iota + stateInString + stateInStringEscape + stateMaybeComment // saw '/' + stateLineComment // inside // ... + stateBlockComment // inside /* ... */ + stateMaybeBlockEnd // saw '*' inside block comment +) + +type reader struct { + r *bufio.Reader + state state +} + +// NewReader returns an io.Reader that strips JSON comments from the +// underlying reader. It removes single-line comments (// to end of line) +// and block comments (/* ... */), while preserving comment-like sequences +// that appear inside JSON string values. +func NewReader(r io.Reader) io.Reader { + return &reader{ + r: bufio.NewReader(r), + state: stateNormal, + } +} + +func (cr *reader) Read(p []byte) (int, error) { + n := 0 + for n < len(p) { + b, err := cr.r.ReadByte() + if err != nil { + if cr.state == stateMaybeComment { + // Emit the pending '/' before returning EOF + p[n] = '/' + n++ + cr.state = stateNormal + } + return n, err + } + + switch cr.state { + case stateNormal: + switch b { + case '"': + p[n] = b + n++ + cr.state = stateInString + case '/': + cr.state = stateMaybeComment + default: + p[n] = b + n++ + } + + case stateInString: + p[n] = b + n++ + switch b { + case '\\': + cr.state = stateInStringEscape + case '"': + cr.state = stateNormal + } + + case stateInStringEscape: + p[n] = b + n++ + cr.state = stateInString + + case stateMaybeComment: + switch b { + case '/': + cr.state = stateLineComment + case '*': + cr.state = stateBlockComment + default: + // The '/' was not a comment start; emit it and the current byte + p[n] = '/' + n++ + if n < len(p) { + p[n] = b + n++ + } else { + // We need to "unread" the current byte since buffer is full + _ = cr.r.UnreadByte() + } + cr.state = stateNormal + } + + case stateLineComment: + if b == '\n' || b == '\r' { + p[n] = b + n++ + cr.state = stateNormal + } + // Otherwise, consume and discard + + case stateBlockComment: + if b == '*' { + cr.state = stateMaybeBlockEnd + } + // Otherwise, consume and discard + + case stateMaybeBlockEnd: + if b == '/' { + cr.state = stateNormal + } else if b == '*' { + // Stay in stateMaybeBlockEnd (consecutive *'s) + cr.state = stateMaybeBlockEnd + } else { + cr.state = stateBlockComment + } + } + } + return n, nil +} diff --git a/utils/jsoncommentstrip/jsoncommentstrip_test.go b/utils/jsoncommentstrip/jsoncommentstrip_test.go new file mode 100644 index 000000000..21e4bd0b1 --- /dev/null +++ b/utils/jsoncommentstrip/jsoncommentstrip_test.go @@ -0,0 +1,164 @@ +package jsoncommentstrip_test + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/navidrome/navidrome/utils/jsoncommentstrip" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJsonCommentStrip(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "JsonCommentStrip Suite") +} + +var _ = Describe("NewReader", func() { + read := func(input string) string { + r := jsoncommentstrip.NewReader(strings.NewReader(input)) + out, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return string(out) + } + + // compact returns the compacted JSON form of s, for readable comparisons. + compact := func(s string) string { + var buf bytes.Buffer + ExpectWithOffset(1, json.Compact(&buf, []byte(s))).To(Succeed()) + return buf.String() + } + + It("passes through JSON without comments unchanged", func() { + input := `{"key": "value", "num": 42}` + Expect(read(input)).To(Equal(input)) + }) + + It("strips single-line comments", func() { + input := `{ + // this is a comment + "key": "value" + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "key": "value" + }`))) + }) + + It("strips single-line comments at end of line", func() { + input := `{ + "key": "value" // inline comment + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "key": "value" + }`))) + }) + + It("strips block comments", func() { + input := `{/* comment */"key": "value"}` + Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) + }) + + It("strips multi-line block comments", func() { + input := `{ + /* this is + a multi-line + comment */ + "key": "value" + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "key": "value" + }`))) + }) + + It("preserves // inside JSON strings", func() { + input := `{"key": "value // not a comment"}` + Expect(read(input)).To(Equal(input)) + }) + + It("preserves /* inside JSON strings", func() { + input := `{"key": "value /* not a comment */"}` + Expect(read(input)).To(Equal(input)) + }) + + It("handles escaped quotes in strings", func() { + input := `{"key": "val\"ue // not a comment"}` + Expect(read(input)).To(Equal(input)) + }) + + It("handles / at end of input as literal", func() { + input := `{"key": "value"}/` + Expect(read(input)).To(Equal(input)) + }) + + It("handles * inside block comment not followed by /", func() { + input := `{/* a * b */"key": "value"}` + Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) + }) + + It("handles empty input", func() { + Expect(read("")).To(Equal("")) + }) + + It("handles mixed comments with real content", func() { + input := `{ + // line comment + "name": "test", /* inline block */ + /* multi + line */ + "value": "hello // world", + "other": 123 // trailing + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "name": "test", + "value": "hello // world", + "other": 123 + }`))) + }) + + It("handles consecutive slashes that are not comments", func() { + input := `{"path": "/a/b"}` + Expect(read(input)).To(Equal(input)) + }) + + It("handles block comment at end of input", func() { + input := `{"key": "value"}/* comment */` + Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) + }) + + It("strips comment with windows-style line endings", func() { + input := "{\r\n// comment\r\n\"key\": \"value\"\r\n}" + Expect(compact(read(input))).To(Equal(compact(`{"key": "value"}`))) + }) + + It("strips line comments with mixed line endings", func() { + // From original library: // comments with both \n and \r\n, including multiple on same line + input := "{\n\"one\": 1, // test //\n\"two\": 2, //test //\r\n\"string\": \"value\"\n//test\n}" + expected := "{\n\"one\": 1, \n\"two\": 2, \r\n\"string\": \"value\"\n\n}" + Expect(read(input)).To(Equal(expected)) + }) + + It("strips line comment at start of JSON", func() { + // From original library: // comment as first thing in JSON + input := "{// woot\n\"one\": 1, // test //\n\"two\": 2, //test //\r\n\"string\": \"value\"\n//test\n}" + expected := "{\n\"one\": 1, \n\"two\": 2, \r\n\"string\": \"value\"\n\n}" + Expect(read(input)).To(Equal(expected)) + }) + + It("strips block comments with mixed line endings inside", func() { + // From original library: block comment containing \r\n + input := "{/* multi\nline\r\ncomment */\"one\":1}" + expected := "{\"one\":1}" + Expect(read(input)).To(Equal(expected)) + }) + + It("handles complex mix of escaped quotes, comments, and strings", func() { + // From original library TestQuotationEscape: escaped quote inside string followed by + // comment-like chars, then real comments of both types + input := "{/* multi\nline\r\ncomment */\"one\": \"a value \\\" // /*woot\"/* m\nl *///woot\r\n}" + expected := "{\"one\": \"a value \\\" // /*woot\"\r\n}" + Expect(read(input)).To(Equal(expected)) + }) +}) From ba3974ee59f2df336df44d39865e496496cb2c39 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 09:59:52 -0400 Subject: [PATCH 092/596] refactor(shellquote): replace go-shellquote with custom shell quoting implementation --- core/playback/mpv/mpv.go | 2 +- core/playback/mpv/mpv_test.go | 2 +- utils/shellquote/shellquote.go | 115 ++++++++++++++++ utils/shellquote/shellquote_test.go | 200 ++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 utils/shellquote/shellquote.go create mode 100644 utils/shellquote/shellquote_test.go diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index f356a1410..035e18dd5 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -10,9 +10,9 @@ import ( "strings" "sync" - "github.com/kballard/go-shellquote" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/shellquote" ) func start(ctx context.Context, args []string) (Executor, error) { diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go index 20c02501b..b1f2435a3 100644 --- a/core/playback/mpv/mpv_test.go +++ b/core/playback/mpv/mpv_test.go @@ -188,7 +188,7 @@ var _ = Describe("MPV", func() { It("returns empty slice for empty template", func() { args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{})) + Expect(args).To(BeEmpty()) }) }) }) diff --git a/utils/shellquote/shellquote.go b/utils/shellquote/shellquote.go new file mode 100644 index 000000000..685e3c2a7 --- /dev/null +++ b/utils/shellquote/shellquote.go @@ -0,0 +1,115 @@ +package shellquote + +import ( + "errors" + "strings" +) + +var ( + ErrUnterminatedSingleQuote = errors.New("unterminated single-quoted string") + ErrUnterminatedDoubleQuote = errors.New("unterminated double-quoted string") + ErrUnterminatedEscape = errors.New("unterminated backslash-escape") +) + +type state int + +const ( + stateUnquoted state = iota + stateSingleQuoted + stateDoubleQuoted +) + +// Split splits a string into words following POSIX-like shell quoting rules. +// It handles single quotes, double quotes, and backslash escapes. +func Split(input string) ([]string, error) { + var words []string + var word strings.Builder + inWord := false + parseState := stateUnquoted + + i := 0 + for i < len(input) { + ch := input[i] + + switch parseState { + case stateUnquoted: + switch { + case ch == '\\': + if i+1 >= len(input) { + return nil, ErrUnterminatedEscape + } + if input[i+1] == '\n' { + // Line continuation: skip both backslash and newline + i += 2 + continue + } + i++ + word.WriteByte(input[i]) + inWord = true + case ch == '\'': + parseState = stateSingleQuoted + inWord = true + case ch == '"': + parseState = stateDoubleQuoted + inWord = true + case ch == ' ' || ch == '\t' || ch == '\n': + if inWord { + words = append(words, word.String()) + word.Reset() + inWord = false + } + default: + word.WriteByte(ch) + inWord = true + } + + case stateSingleQuoted: + if ch == '\'' { + parseState = stateUnquoted + } else { + word.WriteByte(ch) + } + + case stateDoubleQuoted: + switch { + case ch == '"': + parseState = stateUnquoted + case ch == '\\': + if i+1 >= len(input) { + return nil, ErrUnterminatedEscape + } + next := input[i+1] + // In double quotes, backslash only escapes: $ ` " \n \ + if next == '$' || next == '`' || next == '"' || next == '\n' || next == '\\' { + if next == '\n' { + // Line continuation: skip both backslash and newline + i += 2 + continue + } + i++ + word.WriteByte(next) + } else { + // Backslash is literal for other characters + word.WriteByte(ch) + } + default: + word.WriteByte(ch) + } + } + + i++ + } + + switch parseState { + case stateSingleQuoted: + return nil, ErrUnterminatedSingleQuote + case stateDoubleQuoted: + return nil, ErrUnterminatedDoubleQuote + } + + if inWord { + words = append(words, word.String()) + } + + return words, nil +} diff --git a/utils/shellquote/shellquote_test.go b/utils/shellquote/shellquote_test.go new file mode 100644 index 000000000..889b83a5f --- /dev/null +++ b/utils/shellquote/shellquote_test.go @@ -0,0 +1,200 @@ +package shellquote_test + +import ( + "testing" + + "github.com/navidrome/navidrome/utils/shellquote" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestShellquote(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shellquote Suite") +} + +var _ = Describe("Split", func() { + It("splits simple space-separated words", func() { + words, err := shellquote.Split("a b c") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"a", "b", "c"})) + }) + + It("handles multiple spaces between words", func() { + words, err := shellquote.Split("a b") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"a", "b"})) + }) + + It("handles single-quoted strings", func() { + words, err := shellquote.Split("'hello world'") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"hello world"})) + }) + + It("handles double-quoted strings", func() { + words, err := shellquote.Split(`"hello world"`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"hello world"})) + }) + + It("handles backslash escapes in unquoted mode", func() { + words, err := shellquote.Split(`hello\ world`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"hello world"})) + }) + + It("handles escaped quotes inside double quotes", func() { + words, err := shellquote.Split(`"hello \" world"`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{`hello " world`})) + }) + + It("handles mixed quoting in a single argument", func() { + words, err := shellquote.Split("he'llo wo'rld") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"hello world"})) + }) + + It("returns empty slice for empty input", func() { + words, err := shellquote.Split("") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(BeEmpty()) + }) + + It("returns empty slice for whitespace-only input", func() { + words, err := shellquote.Split(" ") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(BeEmpty()) + }) + + It("returns error for unterminated single quote", func() { + _, err := shellquote.Split("'hello") + Expect(err).To(MatchError(shellquote.ErrUnterminatedSingleQuote)) + }) + + It("returns error for unterminated double quote", func() { + _, err := shellquote.Split(`"hello`) + Expect(err).To(MatchError(shellquote.ErrUnterminatedDoubleQuote)) + }) + + It("returns error for unterminated escape", func() { + _, err := shellquote.Split(`hello\`) + Expect(err).To(MatchError(shellquote.ErrUnterminatedEscape)) + }) + + It("handles tabs and newlines as delimiters", func() { + words, err := shellquote.Split("a\tb\nc") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"a", "b", "c"})) + }) + + It("parses the default MPV command template", func() { + words, err := shellquote.Split("mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(HaveLen(6)) + Expect(words).To(Equal([]string{ + "mpv", + "--audio-device=%d", + "--no-audio-display", + "--pause", + "%f", + "--input-ipc-server=%s", + })) + }) + + It("preserves spaces in quoted paths", func() { + words, err := shellquote.Split(`--ao-pcm-file="/audio/my folder/file"`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{`--ao-pcm-file=/audio/my folder/file`})) + }) + + It("handles backslash in double quotes for special chars", func() { + words, err := shellquote.Split(`"hello\\world"`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{`hello\world`})) + }) + + It("preserves backslash in double quotes for non-special chars", func() { + words, err := shellquote.Split(`"hello\nworld"`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{`hello\nworld`})) + }) + + It("handles escaped newline in double quotes", func() { + words, err := shellquote.Split("\"hello\\\nworld\"") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"helloworld"})) + }) + + // Cases from original go-shellquote test suite + It("handles shell glob characters as literals", func() { + words, err := shellquote.Split("glob* test?") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"glob*", "test?"})) + }) + + It("handles backslash-escaped special characters", func() { + words, err := shellquote.Split("don\\'t you know the dewey decimal system\\?") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"don't", "you", "know", "the", "dewey", "decimal", "system?"})) + }) + + It("handles single-quote escape idiom", func() { + // Shell idiom: end single-quote, escaped literal quote, start single-quote again + words, err := shellquote.Split("'don'\\''t you know the dewey decimal system?'") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"don't you know the dewey decimal system?"})) + }) + + It("handles empty string argument via quotes", func() { + words, err := shellquote.Split("one '' two") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"one", "", "two"})) + }) + + It("handles backslash-newline joining words in unquoted mode", func() { + words, err := shellquote.Split("text with\\\na backslash-escaped newline") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"text", "witha", "backslash-escaped", "newline"})) + }) + + It("handles quoted newline inside double quotes", func() { + words, err := shellquote.Split("text \"with\na\" quoted newline") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"text", "with\na", "quoted", "newline"})) + }) + + It("handles complex double-quoted escapes with backslash-newline", func() { + words, err := shellquote.Split("\"quoted\\d\\\\\\\" text with\\\na backslash-escaped newline\"") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"quoted\\d\\\" text witha backslash-escaped newline"})) + }) + + It("handles backslash-newline between words", func() { + words, err := shellquote.Split("text with an escaped \\\n newline in the middle") + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"text", "with", "an", "escaped", "newline", "in", "the", "middle"})) + }) + + It("handles double-quoted substring concatenation", func() { + words, err := shellquote.Split(`foo"bar"baz`) + Expect(err).ToNot(HaveOccurred()) + Expect(words).To(Equal([]string{"foobarbaz"})) + }) + + It("returns error for unterminated quote after escape idiom", func() { + _, err := shellquote.Split("'test'\\''ing") + Expect(err).To(MatchError(shellquote.ErrUnterminatedSingleQuote)) + }) + + It("returns error for unterminated double quote with single quote inside", func() { + _, err := shellquote.Split("\"foo'bar") + Expect(err).To(MatchError(shellquote.ErrUnterminatedDoubleQuote)) + }) + + It("returns error for unterminated escape with leading whitespace", func() { + _, err := shellquote.Split(" \\") + Expect(err).To(MatchError(shellquote.ErrUnterminatedEscape)) + }) +}) From c63346de040cfc26222b5decdba8f96b04e6472f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 09:59:52 -0400 Subject: [PATCH 093/596] chore: run go mod tidy after dependency replacements --- go.mod | 5 ++--- go.sum | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 33333ca33..28fae78e3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927 require ( github.com/Masterminds/squirrel v1.5.4 - github.com/RaveNoX/go-jsoncommentstrip v1.0.0 github.com/andybalholm/cascadia v1.3.3 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 @@ -35,10 +34,8 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/maruel/natural v1.3.0 github.com/mattn/go-sqlite3 v1.14.34 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 @@ -98,6 +95,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect @@ -108,6 +106,7 @@ require ( github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index 30ca76e86..fd02f812e 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= From 549b812633e079a1aa9c932041d7025739c593c0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 20:36:57 -0400 Subject: [PATCH 094/596] fix(ui): prevent duplicate getCoverArt requests on artist page useMediaQuery defaults to false on the first render (SSR compat), causing MobileArtistDetails to briefly render on desktop. Its CSS background-image triggered a full-size image fetch before the component switched to DesktopArtistDetails, which fetched again. Pass noSsr: true so the media query evaluates synchronously, and cap the mobile background image to 800px. --- ui/src/artist/ArtistShow.jsx | 4 +++- ui/src/artist/MobileArtistDetails.jsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index ba8586d06..935b0bab7 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -50,7 +50,9 @@ const useStyles = makeStyles( const ArtistDetails = (props) => { const record = useRecordContext(props) - const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'), { + noSsr: true, + }) const [artistInfo, setArtistInfo] = useState() const biography = artistInfo?.biography || record.biography diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index e8c044d66..4947a4634 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -83,7 +83,7 @@ const useStyles = makeStyles( ) const MobileArtistDetails = ({ artistInfo, biography, record }) => { - const img = subsonic.getCoverArtUrl(record) + const img = subsonic.getCoverArtUrl(record, 800) const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name From 197d357f02c53d0c74c3218c6dd7c59fd13a69b5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 21:47:26 -0400 Subject: [PATCH 095/596] fix(ui): prevent mobile touch events from triggering playback after lightbox close Signed-off-by: Deluan --- ui/src/common/SongDatagrid.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index fbf2b9e31..d2c98bbe7 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -85,7 +85,14 @@ const DiscSubtitleRow = forwardRef( const classes = useStyles({ isDesktop }) const [imageError, setImageError] = useState(false) const [isLightboxOpen, setLightboxOpen] = useState(false) + const lightboxClosedAt = React.useRef(0) const handlePlaySubset = (discNumber) => () => { + // Ignore clicks shortly after the lightbox was closed to prevent + // mobile touch events from "falling through" the overlay and + // triggering playback. + if (Date.now() - lightboxClosedAt.current < 400) { + return + } onClick(discNumber) } @@ -112,7 +119,10 @@ const DiscSubtitleRow = forwardRef( [imageError], ) - const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + const handleCloseLightbox = useCallback(() => { + lightboxClosedAt.current = Date.now() + setLightboxOpen(false) + }, []) const subtitle = record.discSubtitle ? record.discSubtitle From 6b8fcc37c68f83cf4d7c2b7798e6df079e669a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 15 Mar 2026 00:12:58 -0400 Subject: [PATCH 096/596] fix(share): add ownership checks to Delete and Update (#5189) * test(share): add failing tests for Delete ownership checks * fix(share): add ownership check to Delete * test(share): add failing tests for Update ownership checks * fix(share): add ownership check to Update * refactor(share): extract checkOwnership helper with lightweight query - Deduplicate ownership check from Delete and Update into a single helper - Use a minimal single-column SELECT instead of Get (avoids loadMedia overhead) - Use positive bypass form (IsAdmin || invalidUserId) matching codebase convention * fix(share): convert model.ErrNotFound to rest.ErrNotFound in checkOwnership Ensure consistent 404 responses when a nonexistent share ID is passed to Delete or Update, by handling the conversion in checkOwnership rather than relying on the subsequent write operation. --- persistence/share_repository.go | 30 +++++++++- persistence/share_repository_test.go | 88 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 9343e3e77..415109640 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -30,7 +30,33 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito return r } +// TODO: Ownership checks should be moved to the service layer (core/share.go) +func (r *shareRepository) checkOwnership(id string) error { + usr := loggedUser(r.ctx) + if usr.IsAdmin || usr.ID == invalidUserId { + return nil + } + sel := r.newSelect().Columns("user_id").Where(Eq{"id": id}) + var share struct { + UserID string `db:"user_id"` + } + err := r.queryOne(sel, &share) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err + } + if share.UserID != usr.ID { + return rest.ErrPermissionDenied + } + return nil +} + func (r *shareRepository) Delete(id string) error { + if err := r.checkOwnership(id); err != nil { + return err + } err := r.delete(Eq{"id": id}) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound @@ -140,7 +166,9 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { func (r *shareRepository) Update(id string, entity any, cols ...string) error { s := entity.(*model.Share) - // TODO Validate record + if err := r.checkOwnership(id); err != nil { + return err + } s.ID = id s.UpdatedAt = time.Now() cols = append(cols, "updated_at") diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 96fd0c2bc..6988f323f 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -130,4 +131,91 @@ var _ = Describe("ShareRepository", func() { Expect(share.Albums).To(BeEmpty()) }) }) + + Describe("Ownership Checks", func() { + var ownerUser = model.User{ID: "2222", UserName: "regular-user"} + var otherUser = model.User{ID: "3333", UserName: "third-user"} + + insertShare := func(shareID, userID string) { + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": shareID, + "user": userID, + "desc": "Test Share", + "type": "media_file", + "ids": "1001", + "created": time.Now(), + "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + } + + Describe("Delete", func() { + It("allows a non-admin user to delete their own share", func() { + insertShare("own-share-del", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("own-share-del") + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies a non-admin user from deleting another user's share", func() { + insertShare("other-share-del", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("other-share-del") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + + It("allows an admin to delete any user's share", func() { + insertShare("admin-del-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("admin-del-share") + Expect(err).ToNot(HaveOccurred()) + }) + + It("allows headless context (no user) to delete a share", func() { + insertShare("headless-del-share", ownerUser.ID) + repo := NewShareRepository(context.Background(), GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("headless-del-share") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Update", func() { + It("allows a non-admin user to update their own share", func() { + insertShare("own-share-upd", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies a non-admin user from updating another user's share", func() { + insertShare("other-share-upd", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + + It("allows an admin to update any user's share", func() { + insertShare("admin-upd-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") + Expect(err).ToNot(HaveOccurred()) + }) + + It("allows headless context (no user) to update a share", func() { + insertShare("headless-upd-share", ownerUser.ID) + repo := NewShareRepository(context.Background(), GetDBXBuilder()) + err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) }) From 69e7d163fc00d8a723ac6f3072c6fdcbfffff1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 15 Mar 2026 13:18:54 -0400 Subject: [PATCH 097/596] remove built-in Spotify integration (#5197) * refactor: remove built-in Spotify integration Remove the Spotify adapter and all related configuration, replacing the built-in integration with the plugin system. This deletes the adapters/spotify package, removes Spotify config options (ID/Secret), updates the default agents list from "deezer,lastfm,spotify" to "deezer,lastfm", and cleans up all references across configuration, metrics, logging, artwork caching, and documentation. Users with Spotify config options will now see a warning that the options are no longer available. * feat: add ListenBrainz to list of default agents Signed-off-by: Deluan --------- Signed-off-by: Deluan --- adapters/spotify/client.go | 116 --------------------- adapters/spotify/client_test.go | 131 ------------------------ adapters/spotify/responses.go | 30 ------ adapters/spotify/responses_test.go | 48 --------- adapters/spotify/spotify.go | 96 ----------------- adapters/spotify/spotify_suite_test.go | 17 --- cmd/root.go | 1 - cmd/wire_gen.go | 1 - conf/configuration.go | 32 ++++-- core/agents/README.md | 2 +- core/artwork/reader_artist.go | 2 +- core/external/provider_topsongs_test.go | 1 - core/metrics/insights.go | 1 - core/metrics/insights/data.go | 1 - log/log.go | 1 - plugins/examples/wikimedia/README.md | 2 +- plugins/manifest-schema.json | 4 +- plugins/manifest_gen.go | 4 +- plugins/manifest_test.go | 4 +- server/initial_setup.go | 6 -- server/nativeapi/config.go | 4 +- server/nativeapi/config_test.go | 8 -- 22 files changed, 32 insertions(+), 480 deletions(-) delete mode 100644 adapters/spotify/client.go delete mode 100644 adapters/spotify/client_test.go delete mode 100644 adapters/spotify/responses.go delete mode 100644 adapters/spotify/responses_test.go delete mode 100644 adapters/spotify/spotify.go delete mode 100644 adapters/spotify/spotify_suite_test.go diff --git a/adapters/spotify/client.go b/adapters/spotify/client.go deleted file mode 100644 index 975175930..000000000 --- a/adapters/spotify/client.go +++ /dev/null @@ -1,116 +0,0 @@ -package spotify - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/navidrome/navidrome/log" -) - -const apiBaseUrl = "https://api.spotify.com/v1/" - -var ( - ErrNotFound = errors.New("spotify: not found") -) - -type httpDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -func newClient(id, secret string, hc httpDoer) *client { - return &client{id, secret, hc} -} - -type client struct { - id string - secret string - hc httpDoer -} - -func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { - token, err := c.authorize(ctx) - if err != nil { - return nil, err - } - - params := url.Values{} - params.Add("type", "artist") - params.Add("q", name) - params.Add("offset", "0") - params.Add("limit", strconv.Itoa(limit)) - req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil) - req.URL.RawQuery = params.Encode() - req.Header.Add("Authorization", "Bearer "+token) - - var results SearchResults - err = c.makeRequest(req, &results) - if err != nil { - return nil, err - } - - if len(results.Artists.Items) == 0 { - return nil, ErrNotFound - } - return results.Artists.Items, err -} - -func (c *client) authorize(ctx context.Context) (string, error) { - payload := url.Values{} - payload.Add("grant_type", "client_credentials") - - encodePayload := payload.Encode() - req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload)) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload))) - auth := c.id + ":" + c.secret - req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) - - response := map[string]any{} - err := c.makeRequest(req, &response) - if err != nil { - return "", err - } - - if v, ok := response["access_token"]; ok { - return v.(string), nil - } - log.Error(ctx, "Invalid spotify response", "resp", response) - return "", errors.New("invalid response") -} - -func (c *client) makeRequest(req *http.Request, response any) error { - log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL) - resp, err := c.hc.Do(req) - if err != nil { - return err - } - - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - if resp.StatusCode != 200 { - return c.parseError(data) - } - - return json.Unmarshal(data, response) -} - -func (c *client) parseError(data []byte) error { - var e Error - err := json.Unmarshal(data, &e) - if err != nil { - return err - } - return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message) -} diff --git a/adapters/spotify/client_test.go b/adapters/spotify/client_test.go deleted file mode 100644 index 2782d2122..000000000 --- a/adapters/spotify/client_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package spotify - -import ( - "bytes" - "context" - "io" - "net/http" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("client", func() { - var httpClient *fakeHttpClient - var client *client - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient) - }) - - Describe("ArtistImages", func() { - It("returns artist images from a successful request", func() { - f, _ := os.Open("tests/fixtures/spotify.search.artist.json") - httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200}) - httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)), - }) - - artists, err := client.searchArtists(context.TODO(), "U2", 10) - Expect(err).To(BeNil()) - Expect(artists).To(HaveLen(20)) - Expect(artists[0].Popularity).To(Equal(82)) - - images := artists[0].Images - Expect(images).To(HaveLen(3)) - Expect(images[0].Width).To(Equal(640)) - Expect(images[1].Width).To(Equal(320)) - Expect(images[2].Width).To(Equal(160)) - }) - - It("fails if artist was not found", func() { - httpClient.mock("https://api.spotify.com/v1/search", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{ - "artists" : { - "href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20", - "items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0 - }}`)), - }) - httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)), - }) - - _, err := client.searchArtists(context.TODO(), "U2", 10) - Expect(err).To(MatchError(ErrNotFound)) - }) - - It("fails if not able to authorize", func() { - f, _ := os.Open("tests/fixtures/spotify.search.artist.json") - httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200}) - httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ - StatusCode: 400, - Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)), - }) - - _, err := client.searchArtists(context.TODO(), "U2", 10) - Expect(err).To(MatchError("spotify error(invalid_client): Invalid client")) - }) - }) - - Describe("authorize", func() { - It("returns an access_token on successful authorization", func() { - httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)), - }) - - token, err := client.authorize(context.TODO()) - Expect(err).To(BeNil()) - Expect(token).To(Equal("NEW_ACCESS_TOKEN")) - auth := httpClient.lastRequest.Header.Get("Authorization") - Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA==")) - }) - - It("fails on unsuccessful authorization", func() { - httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ - StatusCode: 400, - Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)), - }) - - _, err := client.authorize(context.TODO()) - Expect(err).To(MatchError("spotify error(invalid_client): Invalid client")) - }) - - It("fails on invalid JSON response", func() { - httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)), - }) - - _, err := client.authorize(context.TODO()) - Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string")) - }) - }) -}) - -type fakeHttpClient struct { - responses map[string]*http.Response - lastRequest *http.Request -} - -func (c *fakeHttpClient) mock(url string, response http.Response) { - if c.responses == nil { - c.responses = make(map[string]*http.Response) - } - c.responses[url] = &response -} - -func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { - c.lastRequest = req - u := req.URL - u.RawQuery = "" - if resp, ok := c.responses[u.String()]; ok { - return resp, nil - } - panic("URL not mocked: " + u.String()) -} diff --git a/adapters/spotify/responses.go b/adapters/spotify/responses.go deleted file mode 100644 index 21166bf74..000000000 --- a/adapters/spotify/responses.go +++ /dev/null @@ -1,30 +0,0 @@ -package spotify - -type SearchResults struct { - Artists ArtistsResult `json:"artists"` -} - -type ArtistsResult struct { - HRef string `json:"href"` - Items []Artist `json:"items"` -} - -type Artist struct { - Genres []string `json:"genres"` - HRef string `json:"href"` - ID string `json:"id"` - Popularity int `json:"popularity"` - Images []Image `json:"images"` - Name string `json:"name"` -} - -type Image struct { - URL string `json:"url"` - Width int `json:"width"` - Height int `json:"height"` -} - -type Error struct { - Code string `json:"error"` - Message string `json:"error_description"` -} diff --git a/adapters/spotify/responses_test.go b/adapters/spotify/responses_test.go deleted file mode 100644 index 704119816..000000000 --- a/adapters/spotify/responses_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package spotify - -import ( - "encoding/json" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Responses", func() { - Describe("Search type=artist", func() { - It("parses the artist search result correctly ", func() { - var resp SearchResults - body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json") - err := json.Unmarshal(body, &resp) - Expect(err).To(BeNil()) - - Expect(resp.Artists.Items).To(HaveLen(20)) - u2 := resp.Artists.Items[0] - Expect(u2.Name).To(Equal("U2")) - Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock")) - Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ")) - Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ")) - Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d")) - Expect(u2.Images[0].Width).To(Equal(640)) - Expect(u2.Images[0].Height).To(Equal(640)) - Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d")) - Expect(u2.Images[1].Width).To(Equal(320)) - Expect(u2.Images[1].Height).To(Equal(320)) - Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534")) - Expect(u2.Images[2].Width).To(Equal(160)) - Expect(u2.Images[2].Height).To(Equal(160)) - }) - }) - - Describe("Error", func() { - It("parses the error response correctly", func() { - var errorResp Error - body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`) - err := json.Unmarshal(body, &errorResp) - Expect(err).To(BeNil()) - - Expect(errorResp.Code).To(Equal("invalid_client")) - Expect(errorResp.Message).To(Equal("Invalid client")) - }) - }) -}) diff --git a/adapters/spotify/spotify.go b/adapters/spotify/spotify.go deleted file mode 100644 index 633c32984..000000000 --- a/adapters/spotify/spotify.go +++ /dev/null @@ -1,96 +0,0 @@ -package spotify - -import ( - "context" - "errors" - "fmt" - "net/http" - "sort" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - "github.com/xrash/smetrics" -) - -const spotifyAgentName = "spotify" - -type spotifyAgent struct { - ds model.DataStore - id string - secret string - client *client -} - -func spotifyConstructor(ds model.DataStore) agents.Interface { - if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" { - return nil - } - l := &spotifyAgent{ - ds: ds, - id: conf.Server.Spotify.ID, - secret: conf.Server.Spotify.Secret, - } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } - chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) - l.client = newClient(l.id, l.secret, chc) - return l -} - -func (s *spotifyAgent) AgentName() string { - return spotifyAgentName -} - -func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) { - a, err := s.searchArtist(ctx, name) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Artist not found in Spotify", "artist", name) - } else { - log.Error(ctx, "Error calling Spotify", "artist", name, err) - } - return nil, err - } - - var res []agents.ExternalImage - for _, img := range a.Images { - res = append(res, agents.ExternalImage{ - URL: img.URL, - Size: img.Width, - }) - } - return res, nil -} - -func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { - artists, err := s.client.searchArtists(ctx, name, 40) - if err != nil || len(artists) == 0 { - return nil, model.ErrNotFound - } - name = strings.ToLower(name) - - // Sort results, prioritizing artists with images, with similar names and with high popularity, in this order - sort.Slice(artists, func(i, j int) bool { - ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity) - aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity) - return ai < aj - }) - - // If the first one has the same name, that's the one - if strings.ToLower(artists[0].Name) != name { - return nil, model.ErrNotFound - } - return &artists[0], err -} - -func init() { - conf.AddHook(func() { - agents.Register(spotifyAgentName, spotifyConstructor) - }) -} diff --git a/adapters/spotify/spotify_suite_test.go b/adapters/spotify/spotify_suite_test.go deleted file mode 100644 index 275b05e73..000000000 --- a/adapters/spotify/spotify_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package spotify - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestSpotify(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Spotify Test Suite") -} diff --git a/cmd/root.go b/cmd/root.go index ff9a574ee..5fdb591ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/spotify" _ "github.com/navidrome/navidrome/adapters/taglib" ) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d045c12ec..c2f6edaf8 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -39,7 +39,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/spotify" _ "github.com/navidrome/navidrome/adapters/taglib" ) diff --git a/conf/configuration.go b/conf/configuration.go index dc5fd1b59..986ab7a5a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -105,7 +105,6 @@ type configOptions struct { Inspect inspectOptions `json:",omitzero"` Subsonic subsonicOptions `json:",omitzero"` LastFM lastfmOptions `json:",omitzero"` - Spotify spotifyOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` EnableScrobbleHistory bool @@ -187,11 +186,6 @@ type lastfmOptions struct { Languages []string // Computed from Language, split by comma } -type spotifyOptions struct { - ID string - Secret string //nolint:gosec -} - type deezerOptions struct { Enabled bool Language string @@ -411,6 +405,7 @@ func Load(noConfigDump bool) { // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) Server.Deezer.Languages = parseLanguages(Server.Deezer.Language) + // Deprecated options logDeprecatedOptions("Scanner.GenreSeparators", "") logDeprecatedOptions("Scanner.GroupAlbumReleases", "") logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored @@ -420,6 +415,9 @@ func Load(noConfigDump bool) { logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") + // Removed options + logRemovedOptions("Spotify.ID", "Spotify.Secret") + // Call init hooks for _, hook := range hooks { hook() @@ -444,6 +442,23 @@ func logDeprecatedOptions(oldName, newName string) { } } +// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is +// not available anymore +func logRemovedOptions(options ...string) { + for _, option := range options { + envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) + logWarning := func(option string) { + log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option)) + } + if viper.InConfig(option) { + logWarning(option) + } + if os.Getenv(envVar) != "" { + logWarning(envVar) + } + } +} + // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { @@ -482,7 +497,6 @@ func disableExternalServices() { Server.EnableInsightsCollector = false Server.EnableM3UExternalAlbumArt = false Server.LastFM.Enabled = false - Server.Spotify.ID = "" Server.Deezer.Enabled = false Server.ListenBrainz.Enabled = false Server.Agents = "" @@ -712,14 +726,12 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") - viper.SetDefault("agents", "deezer,lastfm,spotify") + viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.scrobblefirstartistonly", false) - viper.SetDefault("spotify.id", "") - viper.SetDefault("spotify.secret", "") viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) diff --git a/core/agents/README.md b/core/agents/README.md index 1a3a8e96e..cce62889c 100644 --- a/core/agents/README.md +++ b/core/agents/README.md @@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules: 2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides. 3) Register itself (in its `init()` function). -For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents +For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents For a simple Agent example, look at the [local_agent](local_agent.go) agent source code. diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 9905039be..990942d87 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -79,7 +79,7 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A } func (a *artistReader) Key() string { - hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID)) + hash := md5.Sum([]byte(conf.Server.Agents)) return fmt.Sprintf( "%s.%t.%x", a.cacheKey.Key(), diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 0e513aa37..b73c8ab3e 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -6,7 +6,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/spotify" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f059d739a..5f3f491ea 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -199,7 +199,6 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.EnableSharing = conf.Server.EnableSharing data.Config.EnableStarRating = conf.Server.EnableStarRating data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" - data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled data.Config.EnableDeezer = conf.Server.Deezer.Enabled data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 5580d895d..27186e020 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -61,7 +61,6 @@ type Data struct { EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` EnableDeezer bool `json:"enableDeezer,omitempty"` EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` - EnableSpotify bool `json:"enableSpotify,omitempty"` EnableJukebox bool `json:"enableJukebox,omitempty"` EnablePrometheus bool `json:"enablePrometheus,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` diff --git a/log/log.go b/log/log.go index 7d294792b..ef38484c8 100644 --- a/log/log.go +++ b/log/log.go @@ -27,7 +27,6 @@ var redacted = &Hook{ // Keys from the config "(ApiKey:\")[\\w]*", "(Secret:\")[\\w]*", - "(Spotify.*ID:\")[\\w]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*", diff --git a/plugins/examples/wikimedia/README.md b/plugins/examples/wikimedia/README.md index e4833cff6..181055c69 100644 --- a/plugins/examples/wikimedia/README.md +++ b/plugins/examples/wikimedia/README.md @@ -63,7 +63,7 @@ Folder = "/path/to/navidrome/plugins" Add the plugin to your agents list: ```toml -Agents = "lastfm,spotify,wikimedia" +Agents = "lastfm,wikimedia" ``` ## Testing with Extism CLI diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 8daf88ccf..c15a3bf3d 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -149,7 +149,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.spotify.com')", + "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org')", "items": { "type": "string" } @@ -189,7 +189,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.spotify.com')", + "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org')", "items": { "type": "string" } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index a565ed3d1..efe93e05f 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -57,7 +57,7 @@ type HTTPPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for HTTP requests (e.g., 'api.example.com', - // '*.spotify.com') + // '*.musicbrainz.org') RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } @@ -251,6 +251,6 @@ type WebSocketPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for WebSocket connections (e.g., - // 'api.example.com', '*.spotify.com') + // 'api.example.com', '*.musicbrainz.org') RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index de15324ab..c45a480eb 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -19,7 +19,7 @@ var _ = Describe("Manifest", func() { "permissions": { "http": { "reason": "Fetch metadata", - "requiredHosts": ["api.example.com", "*.spotify.com"] + "requiredHosts": ["api.example.com", "*.musicbrainz.org"] } } }`) @@ -34,7 +34,7 @@ var _ = Describe("Manifest", func() { Expect(*m.Website).To(Equal("https://example.com")) Expect(m.Permissions.Http).ToNot(BeNil()) Expect(*m.Permissions.Http.Reason).To(Equal("Fetch metadata")) - Expect(m.Permissions.Http.RequiredHosts).To(ContainElements("api.example.com", "*.spotify.com")) + Expect(m.Permissions.Http.RequiredHosts).To(ContainElements("api.example.com", "*.musicbrainz.org")) }) It("parses a minimal manifest", func() { diff --git a/server/initial_setup.go b/server/initial_setup.go index ebfdad47a..d50f25958 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -91,11 +91,5 @@ func checkExternalCredentials() { } else { log.Debug("ListenBrainz integration is ENABLED", "ListenBrainz.BaseURL", conf.Server.ListenBrainz.BaseURL) } - - if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" { - log.Info("Spotify integration is not enabled: missing ID/Secret") - } else { - log.Debug("Spotify integration is ENABLED") - } } } diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 086e8d3c1..02626a4ee 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -16,13 +16,11 @@ import ( // using partial masking (first and last character visible, middle replaced with *). // For values with 7+ characters: "secretvalue123" becomes "s***********3" // For values with <7 characters: "short" becomes "****" -// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret") +// Add field paths using dot notation (e.g., "LastFM.ApiKey") var sensitiveFieldsPartialMask = []string{ "LastFM.ApiKey", "LastFM.Secret", "Prometheus.MetricsPath", - "Spotify.ID", - "Spotify.Secret", "DevAutoLoginUsername", } diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 546dd4f12..d7368cabf 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -78,7 +78,6 @@ var _ = Describe("Config API", func() { It("redacts sensitive fields", func() { conf.Server.LastFM.ApiKey = "secretapikey123" - conf.Server.Spotify.Secret = "spotifysecret456" conf.Server.PasswordEncryptionKey = "encryptionkey789" conf.Server.DevAutoCreateAdminPassword = "adminpassword123" conf.Server.Prometheus.Password = "prometheuspass" @@ -97,11 +96,6 @@ var _ = Describe("Config API", func() { Expect(ok).To(BeTrue()) Expect(lastfm["ApiKey"]).To(Equal("s*************3")) - // Check Spotify.Secret (partially masked) - spotify, ok := resp.Config["Spotify"].(map[string]any) - Expect(ok).To(BeTrue()) - Expect(spotify["Secret"]).To(Equal("s**************6")) - // Check PasswordEncryptionKey (fully masked) Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****")) @@ -172,7 +166,6 @@ var _ = Describe("Config API", func() { var _ = Describe("redactValue function", func() { It("partially masks long sensitive values", func() { Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a")) - Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3")) }) It("fully masks long sensitive values that should be completely hidden", func() { @@ -183,7 +176,6 @@ var _ = Describe("redactValue function", func() { It("fully masks short sensitive values", func() { Expect(redactValue("LastFM.Secret", "short")).To(Equal("****")) - Expect(redactValue("Spotify.ID", "abc")).To(Equal("****")) Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****")) Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****")) Expect(redactValue("Prometheus.Password", "short")).To(Equal("****")) From a887521d7a27ec626c04c557e9591a9f2e3819d1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 15 Mar 2026 13:36:26 -0400 Subject: [PATCH 098/596] fix(subsonic): always include mandatory title field in Child responses Removed `omitempty` from the `Title` struct tag in the `Child` response type. The Subsonic/OpenSubsonic API spec requires `title` to be a mandatory field, but songs with empty titles caused the field to be omitted entirely, crashing clients like Symfonium during sync. Ref: https://support.symfonium.app/t/app-gets-stuck-on-syncing-large-database/13004/8 --- server/subsonic/helpers_test.go | 8 ++++++++ .../Responses AlbumList with OS data should match .JSON | 1 + .../Responses AlbumList with OS data should match .XML | 2 +- .../Responses Child with data should match .JSON | 1 + .../Responses Child with data should match .XML | 2 +- .../Responses Child without data should match .JSON | 3 ++- .../Responses Child without data should match .XML | 2 +- ...ses Child without data should match OpenSubsonic .JSON | 1 + ...nses Child without data should match OpenSubsonic .XML | 2 +- server/subsonic/responses/responses.go | 2 +- 10 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index d20ca89ad..4eb756b98 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -309,6 +309,14 @@ var _ = Describe("helpers", func() { Expect(child.Artist).To(Equal("Test Artist")) }) }) + + Context("when MediaFile has an empty title", func() { + It("still includes the title field in the response", func() { + mf.Title = "" + child := childFromMediaFile(ctx, mf) + Expect(child.Title).To(Equal("")) + }) + }) }) Describe("osChildFromMediaFile", func() { diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index a50603bf9..8491a577b 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -9,6 +9,7 @@ { "id": "1", "isDir": false, + "title": "", "bpm": 0, "comment": "", "sortName": "sort name", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML index 45b7033f3..5d9e83f96 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML @@ -1,6 +1,6 @@ - + mood1 diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index 448a84d6a..d20a6d48c 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -115,6 +115,7 @@ { "id": "", "isDir": false, + "title": "", "bpm": 0, "comment": "", "sortName": "", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 2de1efbfb..1d307b0b9 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -25,7 +25,7 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON index 2d0995831..b66a2bdea 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON @@ -8,7 +8,8 @@ "child": [ { "id": "1", - "isDir": false + "isDir": false, + "title": "" } ], "id": "", diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match .XML b/server/subsonic/responses/.snapshots/Responses Child without data should match .XML index 3e5a1cf13..d64d526d6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match .XML @@ -1,5 +1,5 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index c3ccc6cd0..25284295e 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -9,6 +9,7 @@ { "id": "1", "isDir": false, + "title": "", "bpm": 0, "comment": "", "sortName": "", diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML index 3e5a1cf13..d64d526d6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML @@ -1,5 +1,5 @@ - + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c550ce61e..b70f3b128 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -135,7 +135,7 @@ type Child struct { Id string `xml:"id,attr" json:"id"` Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` IsDir bool `xml:"isDir,attr" json:"isDir"` - Title string `xml:"title,attr,omitempty" json:"title,omitempty"` + Title string `xml:"title,attr" json:"title"` Name string `xml:"name,attr,omitempty" json:"name,omitempty"` Album string `xml:"album,attr,omitempty" json:"album,omitempty"` Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` From c42570446b80d019840c59ba962da28c781cc76d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 15 Mar 2026 14:00:21 -0400 Subject: [PATCH 099/596] fix(ui): allow DefaultTheme "Auto" from config (#5190) * fix(ui): allow DefaultTheme "Auto" from config When DefaultTheme is set to "Auto" in the server config, the defaultTheme() function in themeReducer now returns AUTO_THEME_ID instead of falling through to the DarkTheme fallback. This allows useCurrentTheme to correctly read prefers-color-scheme and select Light or Dark theme automatically for new/incognito users. Adds themeReducer unit tests covering Auto, named-theme, and unrecognized-value fallback paths. * chore: format Signed-off-by: Deluan --------- Signed-off-by: Deluan Co-authored-by: Deluan --- ui/src/consts.js | 2 ++ ui/src/reducers/themeReducer.js | 4 ++++ ui/src/reducers/themeReducer.test.js | 32 ++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 ui/src/reducers/themeReducer.test.js diff --git a/ui/src/consts.js b/ui/src/consts.js index e3446c2fe..30731a080 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -7,6 +7,8 @@ export const M3U_MIME_TYPE = 'audio/x-mpegurl' export const AUTO_THEME_ID = 'AUTO_THEME_ID' +export const AUTO_THEME_CONFIG_VALUE = 'Auto' + export const DraggableTypes = { SONG: 'song', ALBUM: 'album', diff --git a/ui/src/reducers/themeReducer.js b/ui/src/reducers/themeReducer.js index 2a5d5bac6..16d5fa87b 100644 --- a/ui/src/reducers/themeReducer.js +++ b/ui/src/reducers/themeReducer.js @@ -1,8 +1,12 @@ import { CHANGE_THEME } from '../actions' +import { AUTO_THEME_ID, AUTO_THEME_CONFIG_VALUE } from '../consts' import config from '../config' import themes from '../themes' const defaultTheme = () => { + if (config.defaultTheme === AUTO_THEME_CONFIG_VALUE) { + return AUTO_THEME_ID + } return ( Object.keys(themes).find( (t) => themes[t].themeName === config.defaultTheme, diff --git a/ui/src/reducers/themeReducer.test.js b/ui/src/reducers/themeReducer.test.js new file mode 100644 index 000000000..2a66ea851 --- /dev/null +++ b/ui/src/reducers/themeReducer.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { AUTO_THEME_ID, AUTO_THEME_CONFIG_VALUE } from '../consts' + +describe('themeReducer', () => { + beforeEach(() => { + vi.resetModules() + }) + + it.each([ + { + configTheme: AUTO_THEME_CONFIG_VALUE, + expected: AUTO_THEME_ID, + description: 'is "Auto"', + }, + { configTheme: 'Dark', expected: 'DarkTheme', description: 'is "Dark"' }, + { + configTheme: 'NonExistent', + expected: 'DarkTheme', + description: 'is unrecognized', + }, + ])( + 'returns $expected when defaultTheme config $description', + async ({ configTheme, expected }) => { + vi.doMock('../config', () => ({ + default: { defaultTheme: configTheme }, + })) + const { themeReducer } = await import('./themeReducer') + const result = themeReducer(undefined, { type: 'UNKNOWN' }) + expect(result).toBe(expected) + }, + ) +}) From aa9391199182bb28fa4ffa222ec2a5fa02b77ee9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 15 Mar 2026 14:14:05 -0400 Subject: [PATCH 100/596] feat(server): add syslog priority prefixes for systemd-journald (#5192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add syslog priority prefixes for systemd-journald When running under systemd, all log messages were assigned priority 3 (error) by journald because navidrome wrote plain text to stderr without syslog priority prefixes. Add a journalFormatter that wraps the existing logrus formatter and prepends syslog priority prefixes (RFC 5424) to each log line. The formatter is automatically enabled when the JOURNAL_STREAM environment variable is set (indicating the process is managed by systemd). Priority mapping: - Fatal/Panic → <2>/<0> (crit/emerg) - Error → <3> (err) - Warn → <4> (warning) - Info → <6> (info) - Debug/Trace → <7> (debug) Fixes #5142 * test: refactor journalFormatter tests to use Ginkgo and DescribeTable Signed-off-by: Deluan --------- Signed-off-by: Deluan Co-authored-by: Deluan --- conf/configuration.go | 4 ++++ log/journal.go | 41 +++++++++++++++++++++++++++++++++++++++++ log/journal_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ log/log.go | 9 +++++++++ 4 files changed, 95 insertions(+) create mode 100644 log/journal.go create mode 100644 log/journal_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 986ab7a5a..dacd42539 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -340,6 +340,10 @@ func Load(noConfigDump bool) { os.Exit(1) } log.SetOutput(out) + } else if os.Getenv("JOURNAL_STREAM") != "" { + // When running under systemd, prepend syslog priority prefixes so + // journald assigns the correct severity to each log line. + log.EnableJournalFormat() } log.SetLevelString(Server.LogLevel) diff --git a/log/journal.go b/log/journal.go new file mode 100644 index 000000000..f1c17d2e7 --- /dev/null +++ b/log/journal.go @@ -0,0 +1,41 @@ +package log + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +// journalFormatter wraps a logrus.Formatter and prepends a syslog priority +// prefix () to each log line. When stderr is captured by systemd-journald, +// this prefix tells journald the correct severity for each message. +// +// See https://www.freedesktop.org/software/systemd/man/sd-daemon.html +type journalFormatter struct { + inner logrus.Formatter +} + +// levelToPriority maps logrus levels to syslog priority values. +// The mapping follows RFC 5424 severity levels. +var levelToPriority = map[logrus.Level]int{ + logrus.PanicLevel: 0, // emerg + logrus.FatalLevel: 2, // crit + logrus.ErrorLevel: 3, // err + logrus.WarnLevel: 4, // warning + logrus.InfoLevel: 6, // info + logrus.DebugLevel: 7, // debug + logrus.TraceLevel: 7, // debug +} + +func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) { + formatted, err := f.inner.Format(entry) + if err != nil { + return formatted, err + } + priority, ok := levelToPriority[entry.Level] + if !ok { + priority = 6 // default to info for unknown levels + } + prefix := []byte(fmt.Sprintf("<%d>", priority)) + return append(prefix, formatted...), nil +} diff --git a/log/journal_test.go b/log/journal_test.go new file mode 100644 index 000000000..770f12b6d --- /dev/null +++ b/log/journal_test.go @@ -0,0 +1,41 @@ +package log + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" +) + +var _ = Describe("journalFormatter", func() { + var formatter *journalFormatter + + BeforeEach(func() { + inner := &logrus.TextFormatter{ + DisableTimestamp: true, + DisableColors: true, + } + formatter = &journalFormatter{inner: inner} + }) + + DescribeTable("prefixes log lines with syslog priority", + func(level logrus.Level, expectedPrefix string) { + entry := &logrus.Entry{ + Logger: logrus.New(), + Level: level, + Message: "test message", + Data: logrus.Fields{}, + } + out, err := formatter.Format(entry) + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(HavePrefix(expectedPrefix)) + }, + Entry("error", logrus.ErrorLevel, "<3>"), + Entry("warning", logrus.WarnLevel, "<4>"), + Entry("info", logrus.InfoLevel, "<6>"), + Entry("debug", logrus.DebugLevel, "<7>"), + Entry("trace", logrus.TraceLevel, "<7>"), + Entry("fatal", logrus.FatalLevel, "<2>"), + Entry("panic", logrus.PanicLevel, "<0>"), + Entry("unknown level defaults to info", logrus.Level(99), "<6>"), + ) +}) diff --git a/log/log.go b/log/log.go index ef38484c8..2764d80e5 100644 --- a/log/log.go +++ b/log/log.go @@ -145,6 +145,15 @@ func SetOutput(w io.Writer) { defaultLogger.SetOutput(w) } +// EnableJournalFormat wraps the current logger formatter with syslog +// priority prefixes for systemd-journald. Only call this when output +// goes to stderr and JOURNAL_STREAM is set. +func EnableJournalFormat() { + loggerMu.Lock() + defer loggerMu.Unlock() + defaultLogger.Formatter = &journalFormatter{inner: defaultLogger.Formatter} +} + // Redact applies redaction to a single string func Redact(msg string) string { r, _ := redacted.redact(msg) From 36aea8a11fbccc2efca56f5ecd88545059ec90ac Mon Sep 17 00:00:00 2001 From: Thiago Sfredo Date: Sun, 15 Mar 2026 15:55:55 -0300 Subject: [PATCH 101/596] feat(ui): add tooltips for long playlist and album names - 5068 (#5070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(ui): add tooltips for long playlist and album names - 5068 Signed-off-by: Thiago Sfreddo * fix dnd and improve performance Signed-off-by: Thiago Sfreddo * lint fixes Signed-off-by: Thiago Sfreddo * fix(ui): update tooltip styles for improved visibility and consistency Signed-off-by: Deluan * fix(ui): add overflow tooltip to playlist name for better visibility Signed-off-by: Deluan * refactor(ui): simplify OverflowTooltip and improve render performance - Inline styles from useMenuTooltipStyles into OverflowTooltip (single consumer) - Use MUI named colors (grey[700]/grey[300] with alpha) instead of raw rgba - Stabilize ref callback with useCallback to avoid unnecessary ref churn - Memoize Tooltip classes and hoist TransitionProps to module level - Fix useLayoutEffect dependency: observe DOM size, not title string --------- Signed-off-by: Thiago Sfreddo Signed-off-by: Deluan Co-authored-by: Deluan Quintão --- ui/src/album/AlbumGridView.jsx | 11 +++- ui/src/common/OverflowTooltip.jsx | 90 +++++++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/layout/PlaylistsSubMenu.jsx | 10 ++-- ui/src/playlist/PlaylistDetails.jsx | 15 +++-- 5 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 ui/src/common/OverflowTooltip.jsx diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 58732bbde..b7db39730 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -13,7 +13,12 @@ import { linkToRecord, useListContext, Loading } from 'react-admin' import { withContentRect } from 'react-measure' import { useDrag } from 'react-dnd' import subsonic from '../subsonic' -import { AlbumContextMenu, PlayButton, ArtistLinkField } from '../common' +import { + AlbumContextMenu, + PlayButton, + ArtistLinkField, + OverflowTooltip, +} from '../common' import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' @@ -198,7 +203,9 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { to={linkToRecord(basePath, record.id, 'show')} > - {record.name} + + {record.name} + {record.tags && record.tags['albumversion'] && ( {record.tags['albumversion']} diff --git a/ui/src/common/OverflowTooltip.jsx b/ui/src/common/OverflowTooltip.jsx new file mode 100644 index 000000000..c000bd9f0 --- /dev/null +++ b/ui/src/common/OverflowTooltip.jsx @@ -0,0 +1,90 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Tooltip } from '@material-ui/core' +import { makeStyles, alpha } from '@material-ui/core/styles' +import grey from '@material-ui/core/colors/grey' + +const useStyles = makeStyles( + (theme) => ({ + tooltip: { + backgroundColor: + theme.palette.type === 'dark' + ? alpha(grey[700], 0.92) + : alpha(grey[300], 0.92), + color: + theme.palette.type === 'dark' + ? theme.palette.common.white + : theme.palette.common.black, + borderRadius: theme.shape.borderRadius, + ...theme.typography.body2, + padding: theme.spacing(0.5, 1), + maxWidth: 300, + }, + }), + { name: 'NDOverflowTooltip' }, +) + +const transitionProps = { timeout: 0 } + +export const OverflowTooltip = ({ + children, + title, + placement = 'bottom-start', +}) => { + const classes = useStyles() + const textRef = React.useRef(null) + const [isOverflowing, setIsOverflowing] = React.useState(false) + const tooltipClasses = React.useMemo( + () => ({ tooltip: classes.tooltip }), + [classes.tooltip], + ) + + React.useLayoutEffect(() => { + const el = textRef.current + if (!el) return + + const checkOverflow = () => { + setIsOverflowing(el.scrollWidth > el.clientWidth) + } + + const resizeObserver = new ResizeObserver(checkOverflow) + resizeObserver.observe(el) + + checkOverflow() + + return () => resizeObserver.disconnect() + }, []) + + const mergedRef = React.useCallback( + (el) => { + textRef.current = el + + const { ref } = children + if (typeof ref === 'function') { + ref(el) + } else if (ref && typeof ref === 'object') { + ref.current = el + } + }, + [children], + ) + + return ( + + {React.cloneElement(children, { ref: mergedRef })} + + ) +} + +OverflowTooltip.propTypes = { + children: PropTypes.element.isRequired, + title: PropTypes.string.isRequired, + placement: PropTypes.string, +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 356225680..a8dc354cb 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,4 +41,5 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './OverflowTooltip' export * from './useSearchRefocus' diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index a9f70b875..b94bebf86 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -12,7 +12,7 @@ import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' import { BiCog } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks } from '../common' +import { canChangeTracks, OverflowTooltip } from '../common' import { DraggableTypes } from '../consts' import config from '../config' @@ -39,9 +39,11 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { - {pls.name} - + + + {pls.name} + + } sidebarIsOpen={sidebarIsOpen} dense={false} diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index eefed83b4..aeddf0d42 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -19,6 +19,7 @@ import { DurationField, SizeField, isWritable, + OverflowTooltip, } from '../common' import config from '../config' import subsonic from '../subsonic' @@ -274,12 +275,14 @@ const PlaylistDetails = (props) => {
- - {record.name || translate('ra.page.loading')} - + + + {record.name || translate('ra.page.loading')} + + {record.songCount ? ( From be06196168f267510ab8b24f59f0a762804732de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 15 Mar 2026 20:44:59 -0400 Subject: [PATCH 102/596] fix(ui): update Bulgarian, Catalan, Danish, German, Greek, Spanish, Finnish, French, Galician, Russian, Slovenian, Swedish, Thai, Chinese (traditional) translations from POEditor (#5044) Co-authored-by: navidrome-bot --- resources/i18n/bg.json | 284 +++++++++++++++++++----------------- resources/i18n/ca.json | 22 ++- resources/i18n/da.json | 21 ++- resources/i18n/de.json | 22 ++- resources/i18n/el.json | 22 ++- resources/i18n/es.json | 22 ++- resources/i18n/fi.json | 22 ++- resources/i18n/fr.json | 22 ++- resources/i18n/gl.json | 22 ++- resources/i18n/ru.json | 22 ++- resources/i18n/sl.json | 28 ++-- resources/i18n/sv.json | 22 ++- resources/i18n/th.json | 26 +++- resources/i18n/zh-Hant.json | 166 +++++++++++---------- 14 files changed, 430 insertions(+), 293 deletions(-) diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index bce5a3a6e..626657129 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -31,13 +31,14 @@ "mood": "Настроение", "participants": "Допълнителни участници", "tags": "Допълнителни етикети", - "mappedTags": "", - "rawTags": "", + "mappedTags": "Картирани тагове", + "rawTags": "Сурови тагове", "bitDepth": "Битова дълбочина", - "sampleRate": "", + "sampleRate": "Честота на семплиране", "missing": "Липсва", - "libraryName": "", - "composer": "" + "libraryName": "Библиотека", + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "Пусни по-късно", @@ -47,8 +48,8 @@ "download": "Свали", "playNext": "Следваща", "info": "Информация", - "showInPlaylist": "", - "instantMix": "" + "showInPlaylist": "Показване в плейлиста", + "instantMix": "Незабавен микс" } }, "album": { @@ -80,7 +81,7 @@ "mood": "Настроение", "date": "Дата на запис", "missing": "Липсва", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "playAll": "Пусни", @@ -129,12 +130,12 @@ "remixer": "Ремиксер |||| Ремиксери", "djmixer": "DJ миксер |||| DJ миксери", "performer": "Изпълнител |||| Изпълнители", - "maincredit": "" + "maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители" }, "actions": { - "shuffle": "", - "radio": "", - "topSongs": "" + "shuffle": "Разбъркване", + "radio": "Радио", + "topSongs": "Топ песни" } }, "user": { @@ -152,11 +153,11 @@ "newPassword": "Нова парола", "token": "Токен", "lastAccessAt": "Последен достъп", - "libraries": "" + "libraries": "Библиотеки" }, "helperTexts": { "name": "Промените в името ще бъдат отразени при следващото влизане", - "libraries": "" + "libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране" }, "notifications": { "created": "Потребителят е създаден", @@ -166,11 +167,11 @@ "message": { "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.", "clickHereForToken": "Кликнете тук, за да получите Вашия токен", - "selectAllLibraries": "", - "adminAutoLibraries": "" + "selectAllLibraries": "Изберете всички библиотеки", + "adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки" }, "validation": { - "librariesRequired": "" + "librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права" } }, "player": { @@ -215,16 +216,22 @@ "export": "Експорт", "makePublic": "Направи публичен", "makePrivate": "Направи личен", - "saveQueue": "", - "searchOrCreate": "", - "pressEnterToCreate": "", - "removeFromSelection": "" + "saveQueue": "Запазване на опашката в плейлист", + "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", + "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", + "removeFromSelection": "Премахване от селекцията", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Добави дублирани песни", "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", - "noPlaylistsFound": "", - "noPlaylists": "" + "noPlaylistsFound": "Няма намерени плейлисти", + "noPlaylists": "Няма налични плейлисти", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -263,7 +270,7 @@ "path": "Път", "size": "Размер", "updatedAt": "Изчезнал на", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "remove": "Премахни", @@ -275,134 +282,136 @@ "empty": "Няма липсващи файлове" }, "library": { - "name": "", + "name": "Библиотека |||| Библиотеки", "fields": { - "name": "", - "path": "", - "remotePath": "", - "lastScanAt": "", - "songCount": "", - "albumCount": "", - "artistCount": "", - "totalSongs": "", - "totalAlbums": "", - "totalArtists": "", - "totalFolders": "", - "totalFiles": "", - "totalMissingFiles": "", - "totalSize": "", - "totalDuration": "", - "defaultNewUsers": "", - "createdAt": "", - "updatedAt": "" + "name": "Име", + "path": "Път", + "remotePath": "Отдалечен път", + "lastScanAt": "Последно сканиране", + "songCount": "Песни", + "albumCount": "Албуми", + "artistCount": "Изпълнители", + "totalSongs": "Песни", + "totalAlbums": "Албуми", + "totalArtists": "Изпълнители", + "totalFolders": "Папки", + "totalFiles": "Файлове", + "totalMissingFiles": "Липсващи файлове", + "totalSize": "Общ размер", + "totalDuration": "Продължителност", + "defaultNewUsers": "По подразбиране за нови потребители", + "createdAt": "Създаден", + "updatedAt": "Актуализиран" }, "sections": { - "basic": "", - "statistics": "" + "basic": "Основна информация", + "statistics": "Статистика" }, "actions": { - "scan": "", - "manageUsers": "", - "viewDetails": "", + "scan": "Сканирай библиотеката", + "manageUsers": "Управление на потребителския достъп", + "viewDetails": "Преглед на подробности", "quickScan": "Quick Scan", - "fullScan": "" + "fullScan": "Пълно сканиране" }, "notifications": { - "created": "", - "updated": "", - "deleted": "", - "scanStarted": "", - "scanCompleted": "", - "quickScanStarted": "", - "fullScanStarted": "", - "scanError": "" + "created": "Библиотеката е създадена успешно", + "updated": "Библиотеката е актуализирана успешно", + "deleted": "Библиотеката е изтрита успешно", + "scanStarted": "Сканирането на библиотеката започна", + "scanCompleted": "Сканирането на библиотеката е завършено", + "quickScanStarted": "Бързото сканиране започна", + "fullScanStarted": "Пълното сканиране започна", + "scanError": "Грешка при стартиране на сканирането. Проверете лог файловете" }, "validation": { - "nameRequired": "", - "pathRequired": "", - "pathNotDirectory": "", - "pathNotFound": "", - "pathNotAccessible": "", - "pathInvalid": "" + "nameRequired": "Името на библиотеката е задължително", + "pathRequired": "Пътят към библиотеката е задължителен", + "pathNotDirectory": "Пътят до библиотеката трябва да е директория", + "pathNotFound": "Пътят към библиотеката не е намерен", + "pathNotAccessible": "Пътят до библиотеката не е достъпен", + "pathInvalid": "Невалиден път към библиотеката" }, "messages": { - "deleteConfirm": "", - "scanInProgress": "", - "noLibrariesAssigned": "" + "deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.", + "scanInProgress": "Сканирането е в ход...", + "noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител" } }, "plugin": { - "name": "", + "name": "Плъгин |||| Плъгини", "fields": { - "id": "", - "name": "", - "description": "", - "version": "", - "author": "", - "website": "", - "permissions": "", - "enabled": "", - "status": "", - "path": "", - "lastError": "", - "hasError": "", - "updatedAt": "", - "createdAt": "", - "configKey": "", - "configValue": "", - "allUsers": "", - "selectedUsers": "", - "allLibraries": "", - "selectedLibraries": "" + "id": "ID номер", + "name": "Име", + "description": "Описание", + "version": "Версия", + "author": "Автор", + "website": "Уебсайт", + "permissions": "Разрешения", + "enabled": "Активирано", + "status": "Статус", + "path": "Път", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Актуализирано", + "createdAt": "Инсталирано", + "configKey": "Ключ", + "configValue": "Стойност", + "allUsers": "Разрешаване на всички потребители", + "selectedUsers": "Избрани потребители", + "allLibraries": "Разрешаване на всички библиотеки", + "selectedLibraries": "Избрани библиотеки", + "allowWriteAccess": "" }, "sections": { - "status": "", - "info": "", - "configuration": "", - "manifest": "", - "usersPermission": "", - "libraryPermission": "" + "status": "Статус", + "info": "Информация за плъгина", + "configuration": "Конфигурация", + "manifest": "Манифест", + "usersPermission": "Права за потребители", + "libraryPermission": "Права за библиотека" }, "status": { - "enabled": "", - "disabled": "" + "enabled": "Активирано", + "disabled": "Деактивирано" }, "actions": { - "enable": "", - "disable": "", - "disabledDueToError": "", - "disabledUsersRequired": "", - "disabledLibrariesRequired": "", - "addConfig": "", - "rescan": "" + "enable": "Активирай", + "disable": "Деактивирай", + "disabledDueToError": "Поправете грешката преди активиране", + "disabledUsersRequired": "Изберете потребители преди активиране", + "disabledLibrariesRequired": "Изберете библиотеки преди активиране", + "addConfig": "Добавяне на конфигурация", + "rescan": "Повторно сканиране" }, "notifications": { - "enabled": "", - "disabled": "", - "updated": "", - "error": "" + "enabled": "Плъгинът е активиран", + "disabled": "Плъгинът е деактивиран", + "updated": "Плъгинът е актуализиран", + "error": "Грешка при актуализиране на плъгина" }, "validation": { - "invalidJson": "" + "invalidJson": "Конфигурацията трябва да е валиден JSON" }, "messages": { - "configHelp": "", - "clickPermissions": "", - "noConfig": "", - "allUsersHelp": "", - "noUsers": "", - "permissionReason": "", - "usersRequired": "", - "allLibrariesHelp": "", - "noLibraries": "", - "librariesRequired": "", - "requiredHosts": "", - "configValidationError": "", - "schemaRenderError": "" + "configHelp": "Конфигурирайте плъгина, използвайки двойки ключ-стойност. Оставете празно, ако плъгинът не изисква конфигурация.", + "clickPermissions": "Кликнете върху разрешение за подробности", + "noConfig": "Няма зададена конфигурация", + "allUsersHelp": "Когато е активиран, плъгинът ще има достъп до всички потребители, включително тези, създадени в бъдеще.", + "noUsers": "Няма избрани потребители", + "permissionReason": "Причина", + "usersRequired": "Този плъгин изисква достъп до потребителска информация. Изберете до кои потребители плъгинът може да има достъп или активирайте „Разрешаване на всички потребители“.", + "allLibrariesHelp": "Когато е активиран, плъгинът ще има достъп до всички библиотеки, включително тези, създадени в бъдеще.", + "noLibraries": "Няма избрани библиотеки", + "librariesRequired": "Този плъгин изисква достъп до информация за библиотеката. Изберете до кои библиотеки плъгинът може да има достъп или активирайте „Разрешаване на всички библиотеки“.", + "requiredHosts": "Необходими хостове", + "configValidationError": "Валидирането на конфигурацията не бе успешно:", + "schemaRenderError": "Не може да се изобрази формята за конфигурация. Схемата на плъгина може да е невалидна.", + "allowWriteAccessHelp": "" }, "placeholders": { - "configKey": "", - "configValue": "" + "configKey": "ключ", + "configValue": "стойност" } } }, @@ -586,9 +595,9 @@ "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", "remove_all_missing_title": "Премахни всички липсващи файлове", "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", - "noSimilarSongsFound": "", - "noTopSongsFound": "", - "startingInstantMix": "" + "noSimilarSongsFound": "Не са намерени подобни песни", + "noTopSongsFound": "Няма намерени топ песни", + "startingInstantMix": "Зареждане на незабавен микс..." }, "menu": { "library": "Библиотека", @@ -619,10 +628,10 @@ "playlists": "Плейлисти", "sharedPlaylists": "Споделени плейлисти", "librarySelector": { - "allLibraries": "", - "multipleLibraries": "", - "selectLibraries": "", - "none": "" + "allLibraries": "Всички библиотеки (%{count})", + "multipleLibraries": "%{selected} от %{total} библиотеки", + "selectLibraries": "Изберете библиотеки", + "none": "Няма" } }, "player": { @@ -655,7 +664,7 @@ "homepage": "Начална страница", "source": "Програмен код", "featureRequests": "Заявете функционалност", - "lastInsightsCollection": "", + "lastInsightsCollection": "Последна колекция от анализи", "insights": { "disabled": "Деактивиран", "waiting": "Изчакване" @@ -669,12 +678,13 @@ "configName": "Име на конфигурация", "environmentVariable": "Променлива на средата", "currentValue": "Текуща стойност", - "configurationFile": "", + "configurationFile": "Конфигурационен файл", "exportToml": "Експортиране на конфигурация (TOML)", "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML", "exportFailed": "Неуспешно копиране на конфигурация", - "devFlagsHeader": "", - "devFlagsComment": "" + "devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)", + "devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.", + "downloadToml": "Изтегляне на конфигурация (TOML)" } }, "activity": { @@ -687,7 +697,7 @@ "scanType": "Последно сканиране", "status": "Грешка при сканиране", "elapsedTime": "Изминало време", - "selectiveScan": "" + "selectiveScan": "Селективен" }, "help": { "title": "Бързи клавиши на Navidrome", @@ -704,8 +714,8 @@ } }, "nowPlaying": { - "title": "", - "empty": "", - "minutesAgo": "" + "title": "Сега свири", + "empty": "Нищо не се възпроизвежда", + "minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути" } } \ No newline at end of file diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index 264a76639..079c130d1 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -37,7 +37,8 @@ "sampleRate": "Freqüencia de mostreig", "missing": "Desaparegut", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "" }, "actions": { "addToQueue": "Reprodueix després", @@ -218,13 +219,19 @@ "saveQueue": "Desar la cua a una llista", "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...", "pressEnterToCreate": "Prem Retorn per crear una nova llista", - "removeFromSelection": "Elimina de la selecció" + "removeFromSelection": "Elimina de la selecció", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Afegeix cançons duplicades", "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?", "noPlaylistsFound": "No s'ha trobat cap llista", - "noPlaylists": "No hi ha cap llista disponible" + "noPlaylists": "No hi ha cap llista disponible", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Permet tots els usuaris", "selectedUsers": "Usuaris seleccionats", "allLibraries": "Permet totes les llibreries", - "selectedLibraries": "Biblioteques seleccionades" + "selectedLibraries": "Biblioteques seleccionades", + "allowWriteAccess": "" }, "sections": { "status": "Estat", @@ -398,7 +406,8 @@ "librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».", "requiredHosts": "Hosts requerits", "configValidationError": "Ha fallat la validació de la configuració:", - "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid." + "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clau", @@ -674,7 +683,8 @@ "exportSuccess": "Configuració exportada al porta-retalls en format TOML", "exportFailed": "La còpia de la configuració ha fallat", "devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)", - "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures" + "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures", + "downloadToml": "Descarrega la configuració (TOML)" } }, "activity": { diff --git a/resources/i18n/da.json b/resources/i18n/da.json index 01d0856d6..a7c090cca 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -37,7 +37,8 @@ "sampleRate": "Samplingfrekvens", "missing": "Manglende", "libraryName": "Bibliotek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "" }, "actions": { "addToQueue": "Afspil senere", @@ -218,13 +219,19 @@ "saveQueue": "Gem kø på afspilningsliste", "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...", "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste", - "removeFromSelection": "Fjern fra valg" + "removeFromSelection": "Fjern fra valg", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Tilføj dubletter af sange", "song_exist": "Der føjes dubletter til playlisten", "noPlaylistsFound": "Ingen playlister fundet", - "noPlaylists": "Ingen tilgængelige playlister" + "noPlaylists": "Ingen tilgængelige playlister", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Tillad alle brugere", "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", - "selectedLibraries": "Valgte biblioteker" + "selectedLibraries": "Valgte biblioteker", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -398,7 +406,8 @@ "librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.", "requiredHosts": "Påkrævede hosts", "configValidationError": "Konfigurationsvalidering mislykkedes:", - "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt." + "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "nøgle", @@ -675,7 +684,7 @@ "exportFailed": "Kunne ikke kopiere konfigurationen", "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)", "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver", - "downloadToml": "" + "downloadToml": "Download konfigurationen (TOML)" } }, "activity": { diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 568c65c51..6ba8a14eb 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -37,7 +37,8 @@ "sampleRate": "Samplerate", "missing": "Fehlend", "libraryName": "Bibliothek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "" }, "actions": { "addToQueue": "Später abspielen", @@ -218,13 +219,19 @@ "saveQueue": "Warteschlange in Wiedergabeliste speichern", "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", - "removeFromSelection": "Von Auswahl entfernen" + "removeFromSelection": "Von Auswahl entfernen", + "uploadCover": "Cover hochladen", + "removeCover": "Cover entfernen" }, "message": { "duplicate_song": "Duplikate hinzufügen", "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?", "noPlaylistsFound": "Keine Wiedergabeliste gefunden", - "noPlaylists": "Keine Wiedergabelisten vorhanden" + "noPlaylists": "Keine Wiedergabelisten vorhanden", + "coverUploaded": "Cover aktualisiert", + "coverRemoved": "Cover entfernt", + "coverUploadError": "Fehler beim Hochladen des Covers", + "coverRemoveError": "Fehler beim Entfernen des Covers" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Alle Benutzer", "selectedUsers": "Ausgewählte Benutzer", "allLibraries": "Alle Bibliotheken", - "selectedLibraries": "Ausgewählte Bibliotheken" + "selectedLibraries": "Ausgewählte Bibliotheken", + "allowWriteAccess": "Schreibzugriff erlauben" }, "sections": { "status": "Status", @@ -398,7 +406,8 @@ "librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.", "requiredHosts": "Benötigte Hosts", "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", - "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt." + "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", + "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." }, "placeholders": { "configKey": "Schlüssel", @@ -674,7 +683,8 @@ "exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert", "exportFailed": "Fehler beim Kopieren der Konfiguration", "devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)", - "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden" + "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden", + "downloadToml": "Konfiguration Herunterladen (TOML)" } }, "activity": { diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 02d0b06c4..e5d983d6f 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -37,7 +37,8 @@ "sampleRate": "Ποσοστό δειγματοληψίας", "missing": "Απών", "libraryName": "Βιβλιοθήκη", - "composer": "Συνθέτης" + "composer": "Συνθέτης", + "disc": "" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -218,13 +219,19 @@ "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", - "removeFromSelection": "Αφαίρεση από την επιλογή" + "removeFromSelection": "Αφαίρεση από την επιλογή", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?", "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής", - "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής" + "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Επιτρέψτε όλους τους χρήστες", "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", - "selectedLibraries": "Επιλεγμένες βιβλιοθήκες" + "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", + "allowWriteAccess": "" }, "sections": { "status": "Κατάσταση", @@ -398,7 +406,8 @@ "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'", "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", - "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο." + "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "κλειδί", @@ -674,7 +683,8 @@ "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML", "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε", "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)", - "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις" + "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις", + "downloadToml": "Λήψη διαμόρφωσης (TOML)" } }, "activity": { diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 38c1379c9..ef246a0e6 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -37,7 +37,8 @@ "sampleRate": "Frecuencia de muestreo", "missing": "Faltante", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "" }, "actions": { "addToQueue": "Reproducir después", @@ -218,13 +219,19 @@ "saveQueue": "Guardar la fila de reproducción en una playlist", "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", - "removeFromSelection": "Quitar de la selección" + "removeFromSelection": "Quitar de la selección", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?", "noPlaylistsFound": "No se encontraron listas de reproducción", - "noPlaylists": "No hay listas de reproducción disponibles" + "noPlaylists": "No hay listas de reproducción disponibles", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Permitir todos los usuarios", "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", - "selectedLibraries": "Bibliotecas seleccionadas" + "selectedLibraries": "Bibliotecas seleccionadas", + "allowWriteAccess": "" }, "sections": { "status": "Estado", @@ -398,7 +406,8 @@ "librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.", "requiredHosts": "Hosts requeridos", "configValidationError": "La validación de la configuración falló:", - "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido." + "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clave", @@ -674,7 +683,8 @@ "exportSuccess": "Configuración exportada al portapapeles en formato TOML", "exportFailed": "Error al copiar la configuración", "devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)", - "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras" + "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras", + "downloadToml": "Descargar la configuración (TOML)" } }, "activity": { diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 0d260fb44..ea5d22f4c 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -37,7 +37,8 @@ "sampleRate": "Näytteenottotaajuus", "missing": "Puuttuva", "libraryName": "Kirjasto", - "composer": "Säveltäjä" + "composer": "Säveltäjä", + "disc": "" }, "actions": { "addToQueue": "Lisää jonoon", @@ -218,13 +219,19 @@ "saveQueue": "Tallenna jono soittolistaan", "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...", "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan", - "removeFromSelection": "Poista valinnasta" + "removeFromSelection": "Poista valinnasta", + "uploadCover": "Lataa kansikuva", + "removeCover": "Poista kansikuva" }, "message": { "duplicate_song": "Lisää olemassa oleva kappale", "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?", "noPlaylistsFound": "Soittolistoja ei löytynyt", - "noPlaylists": "Soittolistoja ei ole saatavilla" + "noPlaylists": "Soittolistoja ei ole saatavilla", + "coverUploaded": "Kansikuva päivitetty", + "coverRemoved": "Kansikuva poistettu", + "coverUploadError": "Virhe ladattaessa kansikuvaa", + "coverRemoveError": "Virhe poistettaessa kansikuvaa" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Salli kaikki käyttäjät", "selectedUsers": "Valitut käyttäjät", "allLibraries": "Salli kaikki kirjastot", - "selectedLibraries": "Valitut kirjastot" + "selectedLibraries": "Valitut kirjastot", + "allowWriteAccess": "Salli kirjoitusoikeus" }, "sections": { "status": "Tila", @@ -398,7 +406,8 @@ "librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.", "requiredHosts": "Vaaditut palvelimet", "configValidationError": "Määrityksen validointi epäonnistui:", - "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen." + "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", + "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus." }, "placeholders": { "configKey": "avain", @@ -674,7 +683,8 @@ "exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa", "exportFailed": "Määritysten kopiointi epäonnistui", "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", - "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa" + "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa", + "downloadToml": "Lataa määritykset (TOML)" } }, "activity": { diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 66bd454cc..57af0b8cd 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -37,7 +37,8 @@ "sampleRate": "Fréquence d'échantillonnage", "missing": "Manquant", "libraryName": "Bibliothèque", - "composer": "Compositeur·e" + "composer": "Compositeur·e", + "disc": "" }, "actions": { "addToQueue": "Ajouter à la file", @@ -218,13 +219,19 @@ "saveQueue": "Sauvegarder la file de lecture dans la playlist", "searchOrCreate": "Chercher ou créer une nouvelle playlist...", "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", - "removeFromSelection": "Supprimer de la sélection" + "removeFromSelection": "Supprimer de la sélection", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Ajouter les titres déjà présents dans la playlist", "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?", "noPlaylistsFound": "Aucune playlist trouvée", - "noPlaylists": "Aucune playlist disponible" + "noPlaylists": "Aucune playlist disponible", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Autoriser tous les utilisateur·rices", "selectedUsers": "Utilisateur·rices sélectionné.e.s", "allLibraries": "Autoriser toutes les bibliothèques", - "selectedLibraries": "Bibliothèques sélectionnées" + "selectedLibraries": "Bibliothèques sélectionnées", + "allowWriteAccess": "" }, "sections": { "status": "Statut", @@ -398,7 +406,8 @@ "librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.", "requiredHosts": "Hôtes requis", "configValidationError": "Erreur lors de la validation de la configuration", - "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide." + "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clef", @@ -674,7 +683,8 @@ "exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML", "exportFailed": "Une erreur est survenue en copiant la configuration", "devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)", - "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur" + "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur", + "downloadToml": "Télécharger la configuration (TOML)" } }, "activity": { diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 32d0d919f..c10560912 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -37,7 +37,8 @@ "sampleRate": "Taxa de mostra", "missing": "Falta", "libraryName": "Biblioteca", - "composer": "Composición" + "composer": "Composición", + "disc": "" }, "actions": { "addToQueue": "Ao final da cola", @@ -218,13 +219,19 @@ "saveQueue": "Salvar a Cola como Lista de reprodución", "searchOrCreate": "Buscar listas ou escribe para crear nova…", "pressEnterToCreate": "Preme Enter para crear nova lista", - "removeFromSelection": "Retirar da selección" + "removeFromSelection": "Retirar da selección", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Engadir cancións duplicadas", "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?", "noPlaylistsFound": "Sen listas de reprodución", - "noPlaylists": "Sen listas dispoñibles" + "noPlaylists": "Sen listas dispoñibles", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Para todas as usuarias", "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Selecciona bibliotecas" + "selectedLibraries": "Selecciona bibliotecas", + "allowWriteAccess": "" }, "sections": { "status": "Estado", @@ -398,7 +406,8 @@ "librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.", "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", - "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido." + "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clave", @@ -674,7 +683,8 @@ "exportSuccess": "Configuración exportada ao portapapeis no formato TOML", "exportFailed": "Fallou a copia da configuración", "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)", - "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións" + "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións", + "downloadToml": "Descargar configuración (TOML)" } }, "activity": { diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 5b20c3e19..6f0439a38 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -37,7 +37,8 @@ "sampleRate": "Частота дискретизации (Hz)", "missing": "Поле отсутствует", "libraryName": "Библиотека", - "composer": "Композитор" + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "В очередь", @@ -218,13 +219,19 @@ "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", - "removeFromSelection": "Удалить из списка выделенных" + "removeFromSelection": "Удалить из списка выделенных", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Повторяющиеся треки", "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?", "noPlaylistsFound": "Плейлисты не найдены", - "noPlaylists": "Нет доступных плейлистов" + "noPlaylists": "Нет доступных плейлистов", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Разрешить всем пользователям", "selectedUsers": "Выбранные пользователи", "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Избранные библиотеки" + "selectedLibraries": "Избранные библиотеки", + "allowWriteAccess": "" }, "sections": { "status": "Статус", @@ -398,7 +406,8 @@ "librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".", "requiredHosts": "Необходимые хосты", "configValidationError": "Проверка конфигурации завершилась неудачей:", - "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна." + "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ключ", @@ -674,7 +683,8 @@ "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", "exportFailed": "Не удалось скопировать конфигурацию", "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", - "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях." + "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", + "downloadToml": "Скачать конфигурацию (TOML)" } }, "activity": { diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index f499d6ad5..2ea1cad49 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -37,7 +37,8 @@ "sampleRate": "Frekvenca vzorčenja", "missing": "Manjka", "libraryName": "Knjižnica", - "composer": "Skladatelj" + "composer": "Skladatelj", + "disc": "" }, "actions": { "addToQueue": "Predvajaj kasneje", @@ -48,7 +49,7 @@ "playNext": "Naslednji", "info": "Več informacij", "showInPlaylist": "Prikaži na seznamu predvajanja", - "instantMix": "" + "instantMix": "Instant Mix" } }, "album": { @@ -218,13 +219,19 @@ "saveQueue": "Shrani čakalno vrsto na seznam predvajanja", "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...", "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja", - "removeFromSelection": "Odstrani iz izbora" + "removeFromSelection": "Odstrani iz izbora", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Dodaj podvojene pesmi", "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?", "noPlaylistsFound": "Ni najdenih seznamov predvajanja", - "noPlaylists": "Ni na voljo seznamov predvajanja" + "noPlaylists": "Ni na voljo seznamov predvajanja", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Dovoli vsem uporabnikom", "selectedUsers": "Izbrani uporabniki", "allLibraries": "Dovoli vse knjižnice", - "selectedLibraries": "Izbrane knjižnice" + "selectedLibraries": "Izbrane knjižnice", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -397,8 +405,9 @@ "noLibraries": "Ni izbranih knjižnic", "librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.", "requiredHosts": "Zahtevani gostitelji", - "configValidationError": "", - "schemaRenderError": "" + "configValidationError": "Validacija konfiguracije neuspešna:", + "schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ključ", @@ -588,7 +597,7 @@ "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", "noSimilarSongsFound": "Ni najdenih podobnih pesmi", "noTopSongsFound": "Ni najdenih najboljših pesmi", - "startingInstantMix": "" + "startingInstantMix": "Nalaganje Instant Mix..." }, "menu": { "library": "Knjižnica", @@ -674,7 +683,8 @@ "exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML", "exportFailed": "Kopiranje konfiguracije ni uspelo", "devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)", - "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah" + "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah", + "downloadToml": "Naloži konfiguracijo (TOML)" } }, "activity": { diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 5896b4ed9..b792943d0 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -37,7 +37,8 @@ "sampleRate": "Samplingsfrekvens", "missing": "Saknade", "libraryName": "Bibliotek", - "composer": "Kompositör" + "composer": "Kompositör", + "disc": "" }, "actions": { "addToQueue": "Lägg till i kön", @@ -218,13 +219,19 @@ "saveQueue": "Spara kö till spellista", "searchOrCreate": "Sök spellista eller skapa ny...", "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval" + "removeFromSelection": "Ta bort från urval", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Lägg till dubletter", "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", "noPlaylistsFound": "Hittade inga spellistor", - "noPlaylists": "Inga spellistor tillgängliga" + "noPlaylists": "Inga spellistor tillgängliga", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", - "selectedLibraries": "Valda bibliotek" + "selectedLibraries": "Valda bibliotek", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -398,7 +406,8 @@ "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt." + "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "nyckel", @@ -674,7 +683,8 @@ "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", "exportFailed": "Kopiering av inställningarna misslyckades", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", - "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner" + "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", + "downloadToml": "Ladda ner konfiguration (TOML)" } }, "activity": { diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 45a5e5f34..d3d64bdbf 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -37,7 +37,8 @@ "sampleRate": "แซมเปิ้ลเรต", "missing": "หายไป", "libraryName": "ห้องสมุด", - "composer": "ผู้แต่ง" + "composer": "ผู้แต่ง", + "disc": "" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -48,7 +49,7 @@ "playNext": "เล่นถัดไป", "info": "ดูรายละเอียด", "showInPlaylist": "แสดงในเพลย์ลิสต์", - "instantMix": "" + "instantMix": "อินสแตนต์ มิก" } }, "album": { @@ -218,13 +219,19 @@ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์", "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่", "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์", - "removeFromSelection": "เอาออกจากที่เลือกไว้" + "removeFromSelection": "เอาออกจากที่เลือกไว้", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "เพิ่มเพลงซ้ำ", "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม", "noPlaylistsFound": "ไม่พบเพลย์ลิสต์", - "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่" + "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "อนุญาติผู้ใช้ทั้งหมด", "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", - "selectedLibraries": "ห้องสมุดเพลงถูกเลือก" + "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", + "allowWriteAccess": "" }, "sections": { "status": "สถานะ", @@ -398,7 +406,8 @@ "librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด", "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", - "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน" + "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -588,7 +597,7 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "" + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." }, "menu": { "library": "ห้องสมุดเพลง", @@ -674,7 +683,8 @@ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว", "exportFailed": "คัดลอกการตั้งค่าล้มเหลว", "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)", - "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง" + "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง", + "downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)" } }, "activity": { diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 1bb59a8b1..aca2e4742 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -10,19 +10,14 @@ "playCount": "播放次數", "title": "標題", "artist": "藝人", - "composer": "作曲者", "album": "專輯", "path": "檔案路徑", - "libraryName": "媒體庫", "genre": "曲風", "compilation": "合輯", "year": "發行年份", "size": "檔案大小", "updatedAt": "更新於", "bitRate": "位元率", - "bitDepth": "位元深度", - "sampleRate": "取樣率", - "channels": "聲道", "discSubtitle": "光碟副標題", "starred": "收藏", "comment": "註解", @@ -30,6 +25,7 @@ "quality": "品質", "bpm": "BPM", "playDate": "上次播放", + "channels": "聲道", "createdAt": "建立於", "grouping": "分組", "mood": "情緒", @@ -37,17 +33,22 @@ "tags": "額外標籤", "mappedTags": "分類後標籤", "rawTags": "原始標籤", - "missing": "遺失" + "bitDepth": "位元深度", + "sampleRate": "取樣率", + "missing": "遺失", + "libraryName": "媒體庫", + "composer": "作曲者", + "disc": "" }, "actions": { "addToQueue": "加入至播放佇列", "playNow": "立即播放", "addToPlaylist": "加入至播放清單", - "showInPlaylist": "在播放清單中顯示", "shuffleAll": "全部隨機播放", "download": "下載", "playNext": "下一首播放", "info": "取得資訊", + "showInPlaylist": "在播放清單中顯示", "instantMix": "即時混音" } }, @@ -59,38 +60,38 @@ "duration": "長度", "songCount": "歌曲數", "playCount": "播放次數", - "size": "檔案大小", "name": "名稱", - "libraryName": "媒體庫", "genre": "曲風", "compilation": "合輯", "year": "發行年份", - "date": "錄製日期", - "originalDate": "原始日期", - "releaseDate": "發行日期", - "releases": "發行", - "released": "已發行", "updatedAt": "更新於", "comment": "註解", "rating": "評分", "createdAt": "建立於", + "size": "檔案大小", + "originalDate": "原始日期", + "releaseDate": "發行日期", + "releases": "發行", + "released": "已發行", "recordLabel": "唱片公司", "catalogNum": "目錄編號", "releaseType": "發行類型", "grouping": "分組", "media": "媒體類型", "mood": "情緒", - "missing": "遺失" + "date": "錄製日期", + "missing": "遺失", + "libraryName": "媒體庫" }, "actions": { "playAll": "播放全部", "playNext": "下一首播放", "addToQueue": "加入至播放佇列", - "share": "分享", "shuffle": "隨機播放", "addToPlaylist": "加入至播放清單", "download": "下載", - "info": "取得資訊" + "info": "取得資訊", + "share": "分享" }, "lists": { "all": "所有", @@ -108,10 +109,10 @@ "name": "名稱", "albumCount": "專輯數", "songCount": "歌曲數", - "size": "檔案大小", "playCount": "播放次數", "rating": "評分", "genre": "曲風", + "size": "檔案大小", "role": "參與角色", "missing": "遺失" }, @@ -132,9 +133,9 @@ "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人" }, "actions": { - "topSongs": "熱門歌曲", "shuffle": "隨機播放", - "radio": "電台" + "radio": "電台", + "topSongs": "熱門歌曲" } }, "user": { @@ -143,7 +144,6 @@ "userName": "使用者名稱", "isAdmin": "管理員", "lastLoginAt": "上次登入", - "lastAccessAt": "上次存取", "updatedAt": "更新於", "name": "名稱", "password": "密碼", @@ -152,6 +152,7 @@ "currentPassword": "目前密碼", "newPassword": "新密碼", "token": "權杖", + "lastAccessAt": "上次存取", "libraries": "媒體庫" }, "helperTexts": { @@ -163,14 +164,14 @@ "updated": "使用者已更新", "deleted": "使用者已刪除" }, - "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" - }, "message": { "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖", "selectAllLibraries": "選取全部媒體庫", "adminAutoLibraries": "管理員預設可存取所有媒體庫" + }, + "validation": { + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" } }, "player": { @@ -213,18 +214,24 @@ "selectPlaylist": "選取播放清單:", "addNewPlaylist": "建立「%{name}」", "export": "匯出", - "saveQueue": "將播放佇列儲存到播放清單", "makePublic": "設為公開", "makePrivate": "設為私人", + "saveQueue": "將播放佇列儲存到播放清單", "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", - "removeFromSelection": "移除選取項目" + "removeFromSelection": "移除選取項目", + "uploadCover": "上傳封面", + "removeCover": "移除封面" }, "message": { "duplicate_song": "加入重複的歌曲", "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", "noPlaylistsFound": "找不到播放清單", - "noPlaylists": "暫無播放清單" + "noPlaylists": "暫無播放清單", + "coverUploaded": "已更新封面圖", + "coverRemoved": "已移除封面圖", + "coverUploadError": "上傳封面圖時發生錯誤", + "coverRemoveError": "移除封面圖時發生錯誤" } }, "radio": { @@ -246,7 +253,6 @@ "username": "分享者", "url": "網址", "description": "描述", - "downloadable": "允許下載?", "contents": "內容", "expiresAt": "過期時間", "lastVisitedAt": "上次造訪時間", @@ -254,19 +260,17 @@ "format": "格式", "maxBitRate": "最大位元率", "updatedAt": "更新於", - "createdAt": "建立於" - }, - "notifications": {}, - "actions": {} + "createdAt": "建立於", + "downloadable": "允許下載?" + } }, "missing": { "name": "遺失檔案 |||| 遺失檔案", - "empty": "無遺失檔案", "fields": { "path": "路徑", "size": "檔案大小", - "libraryName": "媒體庫", - "updatedAt": "遺失於" + "updatedAt": "遺失於", + "libraryName": "媒體庫" }, "actions": { "remove": "刪除", @@ -274,7 +278,8 @@ }, "notifications": { "removed": "遺失檔案已刪除" - } + }, + "empty": "無遺失檔案" }, "library": { "name": "媒體庫 |||| 媒體庫", @@ -304,20 +309,20 @@ }, "actions": { "scan": "掃描媒體庫", - "quickScan": "快速掃描", - "fullScan": "完整掃描", "manageUsers": "管理使用者權限", - "viewDetails": "查看詳細資料" + "viewDetails": "查看詳細資料", + "quickScan": "快速掃描", + "fullScan": "完整掃描" }, "notifications": { "created": "成功建立媒體庫", "updated": "成功更新媒體庫", "deleted": "成功刪除媒體庫", "scanStarted": "開始掃描媒體庫", + "scanCompleted": "媒體庫掃描完成", "quickScanStarted": "快速掃描已開始", "fullScanStarted": "完整掃描已開始", - "scanError": "掃描啟動失敗,請檢查日誌", - "scanCompleted": "媒體庫掃描完成" + "scanError": "掃描啟動失敗,請檢查日誌" }, "validation": { "nameRequired": "請輸入媒體庫名稱", @@ -355,7 +360,8 @@ "allUsers": "允許所有使用者", "selectedUsers": "選定的使用者", "allLibraries": "允許所有媒體庫", - "selectedLibraries": "選定的媒體庫" + "selectedLibraries": "選定的媒體庫", + "allowWriteAccess": "允許寫入權限" }, "sections": { "status": "狀態", @@ -389,8 +395,6 @@ }, "messages": { "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", - "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", @@ -400,7 +404,10 @@ "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", - "requiredHosts": "必要的 Hosts" + "requiredHosts": "必要的 Hosts", + "configValidationError": "設定驗證失敗:", + "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -443,7 +450,6 @@ "add": "加入", "back": "返回", "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "取消", "clear_input_value": "清除", "clone": "複製", @@ -467,6 +473,7 @@ "close_menu": "關閉選單", "unselect": "取消選取", "skip": "略過", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "分享", "download": "下載" }, @@ -558,48 +565,42 @@ "transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。", "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。", "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單", - "noSimilarSongsFound": "找不到相似歌曲", - "startingInstantMix": "正在載入即時混音...", - "noTopSongsFound": "找不到熱門歌曲", "noPlaylistsAvailable": "沒有可用的播放清單", "delete_user_title": "刪除使用者「%{name}」", "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", - "remove_missing_title": "刪除遺失檔案", - "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", - "remove_all_missing_title": "刪除所有遺失檔案", - "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", "lastfmLinkFailure": "無法連接 Last.fm", "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" }, "lastfmLink": "查看更多…", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", + "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", + "downloadOriginalFormat": "下載原始格式", "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}", - "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}", "shareFailure": "分享連結複製失敗:%{url}", "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "下載原始格式" + "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", + "remove_missing_title": "刪除遺失檔案", + "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", + "remove_all_missing_title": "刪除所有遺失檔案", + "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", + "noSimilarSongsFound": "找不到相似歌曲", + "noTopSongsFound": "找不到熱門歌曲", + "startingInstantMix": "正在載入即時混音..." }, "menu": { "library": "媒體庫", - "librarySelector": { - "allLibraries": "所有媒體庫 (%{count})", - "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", - "selectLibraries": "選取媒體庫", - "none": "無" - }, "settings": "設定", "version": "版本", "theme": "主題", @@ -610,7 +611,6 @@ "language": "語言", "defaultView": "預設畫面", "desktop_notifications": "桌面通知", - "lastfmNotConfigured": "Last.fm API 金鑰未設定", "lastfmScrobbling": "啟用 Last.fm 音樂記錄", "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", "replaygain": "重播增益模式", @@ -619,13 +619,20 @@ "none": "無", "album": "專輯增益", "track": "曲目增益" - } + }, + "lastfmNotConfigured": "Last.fm API 金鑰未設定" } }, "albumList": "專輯", + "about": "關於", "playlists": "播放清單", "sharedPlaylists": "分享的播放清單", - "about": "關於" + "librarySelector": { + "allLibraries": "所有媒體庫 (%{count})", + "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", + "selectLibraries": "選取媒體庫", + "none": "無" + } }, "player": { "playListsText": "播放佇列", @@ -676,7 +683,8 @@ "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿", "exportFailed": "設定複製失敗", "devFlagsHeader": "開發旗標(可能會更改/刪除)", - "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除" + "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除", + "downloadToml": "下載設定檔 (TOML)" } }, "activity": { @@ -684,17 +692,12 @@ "totalScanned": "已掃描的資料夾總數", "quickScan": "快速掃描", "fullScan": "完全掃描", - "selectiveScan": "選擇性掃描", "serverUptime": "伺服器運作時間", "serverDown": "伺服器已離線", "scanType": "掃描類型", "status": "掃描錯誤", - "elapsedTime": "經過時間" - }, - "nowPlaying": { - "title": "正在播放", - "empty": "無播放內容", - "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" + "elapsedTime": "經過時間", + "selectiveScan": "選擇性掃描" }, "help": { "title": "Navidrome 快捷鍵", @@ -704,10 +707,15 @@ "toggle_play": "播放/暫停", "prev_song": "上一首歌", "next_song": "下一首歌", - "current_song": "前往目前歌曲", "vol_up": "提高音量", "vol_down": "降低音量", - "toggle_love": "新增此歌曲至收藏" + "toggle_love": "新增此歌曲至收藏", + "current_song": "前往目前歌曲" } + }, + "nowPlaying": { + "title": "正在播放", + "empty": "無播放內容", + "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file From ab8a58157a0b1d25ba699e2751c8c961aa9ab4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 15 Mar 2026 22:19:55 -0400 Subject: [PATCH 103/596] feat: add artist image uploads and image-folder artwork source (#5198) * feat: add shared ImageUploadService for entity image management * feat: add UploadedImage field and methods to Artist model * feat: add uploaded_image column to artist table * feat: add ArtistImageFolder config option * refactor: wire ImageUploadService and delegate playlist file ops to it Wire ImageUploadService into the DI container and refactor the playlist service to delegate image file operations (SetImage/RemoveImage) to the shared ImageUploadService, removing duplicated file I/O logic. A local ImageUploadService interface is defined in core/playlists to avoid an import cycle between core and core/playlists. * feat: artist artwork reader checks uploaded image first * feat: add image-folder priority source for artist artwork * feat: cache key invalidation for image-folder and uploaded images * refactor: extract shared image upload HTTP helpers * feat: add artist image upload/delete API endpoints * refactor: playlist handlers use shared image upload helpers * feat: add shared ImageUploadOverlay component * feat: add i18n keys for artist image upload * feat: add image upload overlay to artist detail pages * refactor: playlist details uses shared ImageUploadOverlay component * fix: add gosec nolint directive for ParseMultipartForm * refactor: deduplicate image upload code and optimize dir scanning - Remove dead ImageFilename methods from Artist and Playlist models (production code uses core.imageFilename exclusively) - Extract shared uploadedImagePath helper in model/image.go - Extract findImageInArtistFolder to deduplicate dir-scanning logic between fromArtistImageFolder and getArtistImageFolderModTime - Fix fileInputRef in useCallback dependency array * fix: include artist UpdatedAt in artwork cache key Without this, uploading or deleting an artist image would not invalidate the cached artwork because the cache key was only based on album folder timestamps, not the artist's own UpdatedAt field. * feat: add Portuguese translations for artist image upload * refactor: use shared i18n keys for cover art upload messages Move cover art upload/remove translations from per-entity sections (artist, playlist) to a shared top-level "message" section, avoiding duplication across entity types and translation files. * refactor: move cover art i18n keys to shared message section for all languages * refactor: simplify image upload code and eliminate redundancies Extracted duplicate image loading/lightbox state logic from DesktopArtistDetails and MobileArtistDetails into a shared useArtistImageState hook. Moved entity type constants to the consts package and replaced raw string literals throughout model, core, and nativeapi packages. Exported model.UploadedImagePath and reused it in core/image_upload.go to consolidate path construction. Cached the ArtistImageFolder lookup result in artistReader to eliminate a redundant os.ReadDir call on every artwork request. Signed-off-by: Deluan * style: fix prettier formatting in ImageUploadOverlay * fix: address code review feedback on image upload error handling - RemoveImage now returns errors instead of swallowing them - Artist handlers distinguish not-found from other DB errors - Defer multipart temp file cleanup after parsing * fix: enforce hard request size limit with MaxBytesReader for image uploads Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/scan.go | 3 +- cmd/wire_gen.go | 14 +- conf/configuration.go | 1 + consts/consts.go | 6 + core/artwork/reader_artist.go | 79 +++++- core/artwork/reader_artist_test.go | 253 ++++++++++++++++++ core/image_upload.go | 71 +++++ core/image_upload_test.go | 99 +++++++ core/playlists/import_test.go | 31 +-- core/playlists/playlists.go | 50 ++-- core/playlists/playlists_test.go | 17 +- core/playlists/rest_adapter_test.go | 3 +- core/wire_providers.go | 2 + ...0260315233131_add_artist_uploaded_image.go | 22 ++ model/artist.go | 8 + model/artist_test.go | 30 +++ model/image.go | 17 ++ model/playlist.go | 18 +- model/playlist_test.go | 22 -- resources/i18n/bg.json | 12 +- resources/i18n/ca.json | 12 +- resources/i18n/da.json | 12 +- resources/i18n/de.json | 20 +- resources/i18n/el.json | 12 +- resources/i18n/es.json | 12 +- resources/i18n/fi.json | 20 +- resources/i18n/fr.json | 12 +- resources/i18n/gl.json | 12 +- resources/i18n/pt-br.json | 14 +- resources/i18n/ru.json | 12 +- resources/i18n/sl.json | 12 +- resources/i18n/sv.json | 12 +- resources/i18n/th.json | 12 +- resources/i18n/zh-Hant.json | 20 +- scanner/controller_test.go | 3 +- scanner/scanner_benchmark_test.go | 3 +- scanner/scanner_multilibrary_test.go | 3 +- scanner/scanner_selective_test.go | 3 +- scanner/scanner_test.go | 3 +- server/e2e/e2e_suite_test.go | 6 +- server/e2e/subsonic_multilibrary_test.go | 3 +- server/nativeapi/artists.go | 72 +++++ server/nativeapi/config_test.go | 2 +- server/nativeapi/image_upload.go | 120 +++++++++ server/nativeapi/library_test.go | 2 +- server/nativeapi/native_api.go | 7 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists.go | 118 +------- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- ui/src/artist/DesktopArtistDetails.jsx | 48 ++-- ui/src/artist/MobileArtistDetails.jsx | 48 ++-- ui/src/artist/useArtistImageState.js | 46 ++++ ui/src/common/ImageUploadOverlay.jsx | 139 ++++++++++ ui/src/common/index.js | 1 + ui/src/i18n/en.json | 14 +- ui/src/playlist/PlaylistDetails.jsx | 137 +--------- 57 files changed, 1169 insertions(+), 567 deletions(-) create mode 100644 core/image_upload.go create mode 100644 core/image_upload_test.go create mode 100644 db/migrations/20260315233131_add_artist_uploaded_image.go create mode 100644 model/artist_test.go create mode 100644 model/image.go create mode 100644 server/nativeapi/artists.go create mode 100644 server/nativeapi/image_upload.go create mode 100644 ui/src/artist/useArtistImageState.js create mode 100644 ui/src/common/ImageUploadOverlay.jsx diff --git a/cmd/scan.go b/cmd/scan.go index ffb77b108..d8a563396 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -74,7 +75,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c2f6edaf8..5b9fd648f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -63,7 +63,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) insights := metrics.GetInstance(dataStore) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() @@ -79,7 +80,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) return router } @@ -100,7 +101,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) @@ -169,7 +171,8 @@ func CreateScanner(ctx context.Context) model.Scanner { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -186,7 +189,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher diff --git a/conf/configuration.go b/conf/configuration.go index dacd42539..9efa352bd 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -71,6 +71,7 @@ type configOptions struct { CoverArtPriority string CoverArtQuality int ArtistArtPriority string + ArtistImageFolder string DiscArtPriority string LyricsPriority string EnableGravatar bool diff --git a/consts/consts.go b/consts/consts.go index 061aebd7b..9f4387ae6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -103,6 +103,12 @@ const ( DefaultCacheCleanUpInterval = 10 * time.Minute ) +// Entity types +const ( + EntityArtist = "artist" + EntityPlaylist = "playlist" +) + const ( AlbumPlayCountModeAbsolute = "absolute" AlbumPlayCountModeNormalized = "normalized" diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 990942d87..96ba08b8f 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -29,11 +29,12 @@ const ( type artistReader struct { cacheKey - a *artwork - provider external.Provider - artist model.Artist - artistFolder string - imgFiles []string + a *artwork + provider external.Provider + artist model.Artist + artistFolder string + imgFiles []string + imgFolderImgPath string // cached path from ArtistImageFolder lookup } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -71,9 +72,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt a.cacheKey.lastUpdate = *imagesUpdatedAt + if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *ar.UpdatedAt + } if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = artistFolderLastUpdate } + if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { + a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) + if a.imgFolderImgPath != "" { + if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = info.ModTime() + } + } + } a.cacheKey.artID = artID return a, nil } @@ -93,10 +105,15 @@ func (a *artistReader) LastUpdated() time.Time { } func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority) + ff := []sourceFunc{a.fromArtistUploadedImage()} + ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) return selectImageReader(ctx, a.artID, ff...) } +func (a *artistReader) fromArtistUploadedImage() sourceFunc { + return fromLocalFile(a.artist.UploadedImagePath()) +} + func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { var ff []sourceFunc for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { @@ -104,6 +121,8 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin switch { case pattern == "external": ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) + case pattern == "image-folder": + ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) default: @@ -196,3 +215,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu } return folderPath, folders[0].ImagesUpdatedAt, nil } + +func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return nil, "", nil + } + // Use cached path from newArtistArtworkReader if available, + // avoiding a second directory scan. + path := a.imgFolderImgPath + if path == "" { + path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) + } + if path == "" { + return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name +// (case-insensitive). Returns the full path, or empty string if not found. +func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { + entries, err := os.ReadDir(folder) + if err != nil { + return "" + } + for _, candidate := range []string{mbzArtistID, artistName} { + if candidate == "" { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + base := strings.TrimSuffix(name, filepath.Ext(name)) + if strings.EqualFold(base, candidate) && model.IsImageFile(name) { + return filepath.Join(folder, name) + } + } + } + return "" +} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 4aa71c9ca..5e2066aeb 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -413,6 +415,257 @@ var _ = Describe("artistArtworkReader", func() { }) }) }) + + Describe("fromArtistUploadedImage", func() { + var ( + tempDir string + reader *artistReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + // Create the artwork/artist directory + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) + + reader = &artistReader{} + }) + + When("artist has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("uploaded artist image")) + r.Close() + }) + }) + + When("artist has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.artist = model.Artist{ID: "ar-1"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("fromArtistImageFolder", func() { + var ( + ctx context.Context + tempDir string + ar *artistReader + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + ar = &artistReader{} + }) + + When("ArtistImageFolder is not configured", func() { + It("returns nil (skips)", func() { + conf.Server.ArtistImageFolder = "" + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + + When("image exists matching MBID", func() { + It("finds the image by MBID", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("MBID match is case-insensitive", func() { + It("finds the image regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" + imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") + Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("no MBID file exists but artist name file does", func() { + It("falls back to artist name match", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("name image")) + r.Close() + }) + }) + + When("artist name match is case-insensitive", func() { + It("matches regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "test artist.jpg") + Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("both MBID and name files exist", func() { + It("prefers MBID over name match", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + mbidPath := filepath.Join(tempDir, mbid+".jpg") + namePath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) + Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(mbidPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("no matching image found", func() { + It("returns an error", func() { + conf.Server.ArtistImageFolder = tempDir + // Create an unrelated file + Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, _, err := sf() + Expect(err).To(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("no image found")) + }) + }) + + When("cached imgFolderImgPath is set", func() { + It("uses cached path instead of scanning", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "cached.jpg") + Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + ar.imgFolderImgPath = imgPath + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("cached image")) + r.Close() + }) + }) + }) + + Describe("findImageInArtistFolder", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + When("matching file exists by MBID", func() { + It("returns the file path", func() { + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, mbid, "Test") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("matching file exists by name", func() { + It("returns the file path", func() { + imgPath := filepath.Join(tempDir, "Test Artist.png") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, "", "Test Artist") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("no matching file exists", func() { + It("returns empty string", func() { + path := findImageInArtistFolder(tempDir, "", "Unknown Artist") + Expect(path).To(BeEmpty()) + }) + }) + + When("folder does not exist", func() { + It("returns empty string", func() { + path := findImageInArtistFolder("/nonexistent/path", "", "Test") + Expect(path).To(BeEmpty()) + }) + }) + }) }) type fakeFolderRepo struct { diff --git a/core/image_upload.go b/core/image_upload.go new file mode 100644 index 000000000..c2432b647 --- /dev/null +++ b/core/image_upload.go @@ -0,0 +1,71 @@ +package core + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error +} + +type imageUploadService struct{} + +func NewImageUploadService() ImageUploadService { + return &imageUploadService{} +} + +func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { + filename := imageFilename(entityID, name, ext) + absPath := model.UploadedImagePath(entityType, filename) + + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return "", fmt.Errorf("creating image directory: %w", err) + } + + // Remove old image if it exists + if oldPath != "" { + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove old image", "path", oldPath, err) + } + } + + // Save new image + f, err := os.Create(absPath) + if err != nil { + return "", fmt.Errorf("creating image file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("writing image file: %w", err) + } + + return filename, nil +} + +func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing image %q: %w", path, err) + } + return nil +} + +func imageFilename(id, name, ext string) string { + clean := utils.CleanFileName(name) + if clean == "" { + return id + ext + } + return id + "_" + clean + ext +} diff --git a/core/image_upload_test.go b/core/image_upload_test.go new file mode 100644 index 000000000..d13a04775 --- /dev/null +++ b/core/image_upload_test.go @@ -0,0 +1,99 @@ +package core_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageUploadService", func() { + var svc core.ImageUploadService + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + svc = core.NewImageUploadService() + }) + + Describe("SetImage", func() { + It("creates directory and saves image file", func() { + ctx := context.Background() + reader := strings.NewReader("fake image data") + filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) + + absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") + data, err := os.ReadFile(absPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + }) + + It("falls back to ID-only filename when name cleans to empty", func() { + ctx := context.Background() + reader := strings.NewReader("data") + filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("pl-1.png")) + }) + + It("removes old image when replacing", func() { + ctx := context.Background() + oldDir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) + oldFile := filepath.Join(oldDir, "ar-1_old.png") + Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) + + reader := strings.NewReader("new image") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(oldFile).ToNot(BeAnExistingFile()) + + newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") + Expect(newPath).To(BeAnExistingFile()) + }) + + It("ignores missing old file without error", func() { + ctx := context.Background() + reader := strings.NewReader("data") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("RemoveImage", func() { + It("removes the file at the given path", func() { + ctx := context.Background() + dir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + path := filepath.Join(dir, "ar-1_test.jpg") + Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) + + err := svc.RemoveImage(ctx, path) + Expect(err).ToNot(HaveOccurred()) + Expect(path).ToNot(BeAnExistingFile()) + }) + + It("succeeds when file does not exist", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") + Expect(err).ToNot(HaveOccurred()) + }) + + It("succeeds with empty path", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "") + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 5312df95d..a6320bc7e 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -42,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -117,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -135,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -154,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -173,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -190,7 +191,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -207,7 +208,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -224,7 +225,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -242,7 +243,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -256,7 +257,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -283,7 +284,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -358,7 +359,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -418,7 +419,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("handles relative paths that reference files in other libraries", func() { @@ -574,7 +575,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -617,7 +618,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 0649b16a9..a0086cd2d 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -2,7 +2,6 @@ package playlists import ( "context" - "fmt" "io" "os" "path/filepath" @@ -12,6 +11,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -50,12 +50,20 @@ type Playlists interface { TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } -type playlists struct { - ds model.DataStore +// ImageUploadService is a local interface satisfied by core.ImageUploadService. +// Defined here to avoid an import cycle between core and core/playlists. +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error } -func NewPlaylists(ds model.DataStore) Playlists { - return &playlists{ds: ds} +type playlists struct { + ds model.DataStore + imgUpload ImageUploadService +} + +func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists { + return &playlists{ds: ds, imgUpload: imgUpload} } func InPath(folder model.Folder) bool { @@ -288,33 +296,13 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R return err } - filename := pls.ImageFilename(ext) oldPath := pls.UploadedImagePath() - pls.UploadedImage = filename - absPath := pls.UploadedImagePath() - - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return fmt.Errorf("creating playlist images directory: %w", err) - } - - // Remove old image if it exists - if oldPath != "" { - if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err) - } - } - - // Save new image - f, err := os.Create(absPath) + filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext) if err != nil { - return fmt.Errorf("creating playlist image file: %w", err) - } - defer f.Close() - - if _, err := io.Copy(f, reader); err != nil { - return fmt.Errorf("writing playlist image file: %w", err) + return err } + pls.UploadedImage = filename return s.ds.Playlist(ctx).Put(pls) } @@ -324,10 +312,8 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { return err } - if path := pls.UploadedImagePath(); path != "" { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove playlist image", "path", path, err) - } + if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil { + return err } pls.UploadedImage = "" diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index ec73c329a..52d5c88d8 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -41,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to delete their playlist", func() { @@ -80,7 +81,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("creates a new playlist with owner set from context", func() { @@ -138,7 +139,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to update their playlist", func() { @@ -201,7 +202,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to add tracks", func() { @@ -249,7 +250,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to remove tracks", func() { @@ -283,7 +284,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to reorder", func() { @@ -312,7 +313,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("saves image file and updates UploadedImage", func() { @@ -382,7 +383,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("removes file and clears UploadedImage", func() { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 70ca8a9e4..097bc6310 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -36,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) Describe("Save", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index 153df7262..276d9556a 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -23,6 +23,8 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + NewImageUploadService, + wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)), stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, diff --git a/db/migrations/20260315233131_add_artist_uploaded_image.go b/db/migrations/20260315233131_add_artist_uploaded_image.go new file mode 100644 index 000000000..964e346f5 --- /dev/null +++ b/db/migrations/20260315233131_add_artist_uploaded_image.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage) +} + +func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`) + return err +} + +func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/artist.go b/model/artist.go index 309ee800f..2085f0051 100644 --- a/model/artist.go +++ b/model/artist.go @@ -4,6 +4,8 @@ import ( "maps" "slices" "time" + + "github.com/navidrome/navidrome/consts" ) type Artist struct { @@ -34,6 +36,8 @@ type Artist struct { Missing bool `structs:"missing" json:"missing"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` + CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` } @@ -58,6 +62,10 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } +func (a Artist) UploadedImagePath() string { + return UploadedImagePath(consts.EntityArtist, a.UploadedImage) +} + // Roles returns the roles this artist has participated in., based on the Stats field func (a Artist) Roles() []Role { return slices.Collect(maps.Keys(a.Stats)) diff --git a/model/artist_test.go b/model/artist_test.go new file mode 100644 index 000000000..5a24504eb --- /dev/null +++ b/model/artist_test.go @@ -0,0 +1,30 @@ +package model_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artist", func() { + Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = "/data" + }) + + It("returns empty string when no image uploaded", func() { + a := model.Artist{ID: "ar-1"} + Expect(a.UploadedImagePath()).To(BeEmpty()) + }) + + It("returns full path when image is set", func() { + a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg"))) + }) + }) +}) diff --git a/model/image.go b/model/image.go new file mode 100644 index 000000000..68d8ae64c --- /dev/null +++ b/model/image.go @@ -0,0 +1,17 @@ +package model + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" +) + +// UploadedImagePath returns the absolute filesystem path for a manually uploaded +// entity cover image. Returns empty string if filename is empty. +func UploadedImagePath(entityType, filename string) string { + if filename == "" { + return "" + } + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) +} diff --git a/model/playlist.go b/model/playlist.go index 5c9052eb7..e2f93993d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,15 +1,12 @@ package model import ( - "path/filepath" "slices" "strconv" "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/utils" ) type Playlist struct { @@ -108,16 +105,6 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { pls.refreshStats() } -// ImageFilename returns a human-friendly filename for an uploaded playlist cover image. -// Format: _, falling back to if the name cleans to empty. -func (pls Playlist) ImageFilename(ext string) string { - clean := utils.CleanFileName(pls.Name) - if clean == "" { - return pls.ID + ext - } - return pls.ID + "_" + clean + ext -} - func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } @@ -127,10 +114,7 @@ func (pls Playlist) CoverArtID() ArtworkID { // This does NOT cover sidecar images or external URLs — those are resolved // by the artwork reader's fallback chain. func (pls Playlist) UploadedImagePath() string { - if pls.UploadedImage == "" { - return "" - } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage) + return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } type Playlists []Playlist diff --git a/model/playlist_test.go b/model/playlist_test.go index 98dd4e978..a54cecd53 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -7,28 +7,6 @@ import ( ) var _ = Describe("Playlist", func() { - Describe("ImageFilename", func() { - It("returns ID_cleanname.ext for a normal name", func() { - pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"} - Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg")) - }) - - It("falls back to ID.ext when name cleans to empty", func() { - pls := model.Playlist{ID: "abc123", Name: "!!!"} - Expect(pls.ImageFilename(".png")).To(Equal("abc123.png")) - }) - - It("falls back to ID.ext for empty name", func() { - pls := model.Playlist{ID: "abc123", Name: ""} - Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg")) - }) - - It("handles names with special characters", func() { - pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"} - Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp")) - }) - }) - Describe("ToM3U8()", func() { var pls model.Playlist BeforeEach(func() { diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index 626657129..7a0281f33 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -219,19 +219,13 @@ "saveQueue": "Запазване на опашката в плейлист", "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", - "removeFromSelection": "Премахване от селекцията", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Премахване от селекцията" }, "message": { "duplicate_song": "Добави дублирани песни", "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", "noPlaylistsFound": "Няма намерени плейлисти", - "noPlaylists": "Няма налични плейлисти", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Няма налични плейлисти" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Нищо не се възпроизвежда", "minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути" } -} \ No newline at end of file +} diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index 079c130d1..1ef2ce016 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -219,19 +219,13 @@ "saveQueue": "Desar la cua a una llista", "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...", "pressEnterToCreate": "Prem Retorn per crear una nova llista", - "removeFromSelection": "Elimina de la selecció", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Elimina de la selecció" }, "message": { "duplicate_song": "Afegeix cançons duplicades", "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?", "noPlaylistsFound": "No s'ha trobat cap llista", - "noPlaylists": "No hi ha cap llista disponible", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "No hi ha cap llista disponible" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "No s'està reproduint res", "minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts" } -} \ No newline at end of file +} diff --git a/resources/i18n/da.json b/resources/i18n/da.json index a7c090cca..a47b30bbc 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -219,19 +219,13 @@ "saveQueue": "Gem kø på afspilningsliste", "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...", "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste", - "removeFromSelection": "Fjern fra valg", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Fjern fra valg" }, "message": { "duplicate_song": "Tilføj dubletter af sange", "song_exist": "Der føjes dubletter til playlisten", "noPlaylistsFound": "Ingen playlister fundet", - "noPlaylists": "Ingen tilgængelige playlister", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Ingen tilgængelige playlister" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Intet afspilles nu", "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" } -} \ No newline at end of file +} diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 6ba8a14eb..ab1760ed5 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -219,19 +219,13 @@ "saveQueue": "Warteschlange in Wiedergabeliste speichern", "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", - "removeFromSelection": "Von Auswahl entfernen", - "uploadCover": "Cover hochladen", - "removeCover": "Cover entfernen" + "removeFromSelection": "Von Auswahl entfernen" }, "message": { "duplicate_song": "Duplikate hinzufügen", "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?", "noPlaylistsFound": "Keine Wiedergabeliste gefunden", - "noPlaylists": "Keine Wiedergabelisten vorhanden", - "coverUploaded": "Cover aktualisiert", - "coverRemoved": "Cover entfernt", - "coverUploadError": "Fehler beim Hochladen des Covers", - "coverRemoveError": "Fehler beim Entfernen des Covers" + "noPlaylists": "Keine Wiedergabelisten vorhanden" } }, "radio": { @@ -597,7 +591,13 @@ "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", "noSimilarSongsFound": "Keine ähnlichen Titel gefunden", "noTopSongsFound": "Keine beliebten Titel gefunden", - "startingInstantMix": "Lade Sofort-Mix..." + "startingInstantMix": "Lade Sofort-Mix...", + "uploadCover": "Cover hochladen", + "removeCover": "Cover entfernen", + "coverUploaded": "Cover aktualisiert", + "coverRemoved": "Cover entfernt", + "coverUploadError": "Fehler beim Hochladen des Covers", + "coverRemoveError": "Fehler beim Entfernen des Covers" }, "menu": { "library": "Bibliothek", @@ -718,4 +718,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} \ No newline at end of file +} diff --git a/resources/i18n/el.json b/resources/i18n/el.json index e5d983d6f..019d05978 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -219,19 +219,13 @@ "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", - "removeFromSelection": "Αφαίρεση από την επιλογή", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Αφαίρεση από την επιλογή" }, "message": { "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?", "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής", - "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Δεν παίζει τίποτα", "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } -} \ No newline at end of file +} diff --git a/resources/i18n/es.json b/resources/i18n/es.json index ef246a0e6..29d1a367f 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -219,19 +219,13 @@ "saveQueue": "Guardar la fila de reproducción en una playlist", "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", - "removeFromSelection": "Quitar de la selección", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Quitar de la selección" }, "message": { "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?", "noPlaylistsFound": "No se encontraron listas de reproducción", - "noPlaylists": "No hay listas de reproducción disponibles", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "No hay listas de reproducción disponibles" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Nada en reproducción", "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} \ No newline at end of file +} diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index ea5d22f4c..59f353350 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -219,19 +219,13 @@ "saveQueue": "Tallenna jono soittolistaan", "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...", "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan", - "removeFromSelection": "Poista valinnasta", - "uploadCover": "Lataa kansikuva", - "removeCover": "Poista kansikuva" + "removeFromSelection": "Poista valinnasta" }, "message": { "duplicate_song": "Lisää olemassa oleva kappale", "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?", "noPlaylistsFound": "Soittolistoja ei löytynyt", - "noPlaylists": "Soittolistoja ei ole saatavilla", - "coverUploaded": "Kansikuva päivitetty", - "coverRemoved": "Kansikuva poistettu", - "coverUploadError": "Virhe ladattaessa kansikuvaa", - "coverRemoveError": "Virhe poistettaessa kansikuvaa" + "noPlaylists": "Soittolistoja ei ole saatavilla" } }, "radio": { @@ -597,7 +591,13 @@ "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.", "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt", "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt", - "startingInstantMix": "Ladataan Pikasekoitus..." + "startingInstantMix": "Ladataan Pikasekoitus...", + "uploadCover": "Lataa kansikuva", + "removeCover": "Poista kansikuva", + "coverUploaded": "Kansikuva päivitetty", + "coverRemoved": "Kansikuva poistettu", + "coverUploadError": "Virhe ladattaessa kansikuvaa", + "coverRemoveError": "Virhe poistettaessa kansikuvaa" }, "menu": { "library": "Kirjasto", @@ -718,4 +718,4 @@ "empty": "Ei soita mitään", "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } -} \ No newline at end of file +} diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 57af0b8cd..891fde03a 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -219,19 +219,13 @@ "saveQueue": "Sauvegarder la file de lecture dans la playlist", "searchOrCreate": "Chercher ou créer une nouvelle playlist...", "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", - "removeFromSelection": "Supprimer de la sélection", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Supprimer de la sélection" }, "message": { "duplicate_song": "Ajouter les titres déjà présents dans la playlist", "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?", "noPlaylistsFound": "Aucune playlist trouvée", - "noPlaylists": "Aucune playlist disponible", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Aucune playlist disponible" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Aucun titre en cours de lecture", "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } -} \ No newline at end of file +} diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index c10560912..aba22b714 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -219,19 +219,13 @@ "saveQueue": "Salvar a Cola como Lista de reprodución", "searchOrCreate": "Buscar listas ou escribe para crear nova…", "pressEnterToCreate": "Preme Enter para crear nova lista", - "removeFromSelection": "Retirar da selección", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Retirar da selección" }, "message": { "duplicate_song": "Engadir cancións duplicadas", "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?", "noPlaylistsFound": "Sen listas de reprodución", - "noPlaylists": "Sen listas dispoñibles", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Sen listas dispoñibles" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Sen reprodución", "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } -} \ No newline at end of file +} diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 6c9e154d1..bc2a5f85e 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -218,15 +218,9 @@ "saveQueue": "Salvar fila em nova Playlist", "searchOrCreate": "Buscar playlists ou criar nova...", "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção", - "uploadCover": "Enviar Capa", - "removeCover": "Remover Capa" + "removeFromSelection": "Remover da seleção" }, "message": { - "coverUploaded": "Capa atualizada", - "coverRemoved": "Capa removida", - "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", "duplicate_song": "Adicionar músicas duplicadas", "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", "noPlaylistsFound": "Nenhuma playlist encontrada", @@ -560,6 +554,12 @@ } }, "message": { + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa", + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa", "note": "ATENÇÃO", "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 6f0439a38..78e7cfa26 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -219,19 +219,13 @@ "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", - "removeFromSelection": "Удалить из списка выделенных", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Удалить из списка выделенных" }, "message": { "duplicate_song": "Повторяющиеся треки", "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?", "noPlaylistsFound": "Плейлисты не найдены", - "noPlaylists": "Нет доступных плейлистов", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Нет доступных плейлистов" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Ничего не играет", "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" } -} \ No newline at end of file +} diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index 2ea1cad49..ceb56e9b7 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -219,19 +219,13 @@ "saveQueue": "Shrani čakalno vrsto na seznam predvajanja", "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...", "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja", - "removeFromSelection": "Odstrani iz izbora", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Odstrani iz izbora" }, "message": { "duplicate_song": "Dodaj podvojene pesmi", "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?", "noPlaylistsFound": "Ni najdenih seznamov predvajanja", - "noPlaylists": "Ni na voljo seznamov predvajanja", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Ni na voljo seznamov predvajanja" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Nič se ne predvaja", "minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami" } -} \ No newline at end of file +} diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index b792943d0..23bd5fbc2 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -219,19 +219,13 @@ "saveQueue": "Spara kö till spellista", "searchOrCreate": "Sök spellista eller skapa ny...", "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Ta bort från urval" }, "message": { "duplicate_song": "Lägg till dubletter", "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", "noPlaylistsFound": "Hittade inga spellistor", - "noPlaylists": "Inga spellistor tillgängliga", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} \ No newline at end of file +} diff --git a/resources/i18n/th.json b/resources/i18n/th.json index d3d64bdbf..b445d7464 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -219,19 +219,13 @@ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์", "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่", "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์", - "removeFromSelection": "เอาออกจากที่เลือกไว้", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "เอาออกจากที่เลือกไว้" }, "message": { "duplicate_song": "เพิ่มเพลงซ้ำ", "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม", "noPlaylistsFound": "ไม่พบเพลย์ลิสต์", - "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} \ No newline at end of file +} diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index aca2e4742..dabf61bdb 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -219,19 +219,13 @@ "saveQueue": "將播放佇列儲存到播放清單", "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", - "removeFromSelection": "移除選取項目", - "uploadCover": "上傳封面", - "removeCover": "移除封面" + "removeFromSelection": "移除選取項目" }, "message": { "duplicate_song": "加入重複的歌曲", "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", "noPlaylistsFound": "找不到播放清單", - "noPlaylists": "暫無播放清單", - "coverUploaded": "已更新封面圖", - "coverRemoved": "已移除封面圖", - "coverUploadError": "上傳封面圖時發生錯誤", - "coverRemoveError": "移除封面圖時發生錯誤" + "noPlaylists": "暫無播放清單" } }, "radio": { @@ -597,7 +591,13 @@ "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", "noSimilarSongsFound": "找不到相似歌曲", "noTopSongsFound": "找不到熱門歌曲", - "startingInstantMix": "正在載入即時混音..." + "startingInstantMix": "正在載入即時混音...", + "uploadCover": "上傳封面", + "removeCover": "移除封面", + "coverUploaded": "已更新封面圖", + "coverRemoved": "已移除封面圖", + "coverUploadError": "上傳封面圖時發生錯誤", + "coverRemoveError": "移除封面圖時發生錯誤" }, "menu": { "library": "媒體庫", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} \ No newline at end of file +} diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 2af52066b..d60d432b4 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -31,7 +32,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 1ac7b50a4..8f0dcd340 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -12,6 +12,7 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -40,7 +41,7 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 6990f1984..856015239 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -77,7 +78,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6e4511179..594b74e38 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -63,7 +64,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index d5688a1dc..922d21e62 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -84,7 +85,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index ef379ad05..cb851debf 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -442,7 +442,7 @@ var _ = BeforeSuite(func() { buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS), metrics.NewNoopInstance()) + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -479,7 +479,7 @@ func setupTestDB() { streamerSpy = &spyStreamer{} decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -489,7 +489,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds), + playlists.NewPlaylists(ds, core.NewImageUploadService()), noopPlayTracker{}, core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index f59187d00..a837da124 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -6,6 +6,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -53,7 +54,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go new file mode 100644 index 000000000..1b78bb93e --- /dev/null +++ b/server/nativeapi/artists.go @@ -0,0 +1,72 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addArtistRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Artist{}) + } + r.Route("/artist", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Post("/image", api.uploadArtistImage()) + r.Delete("/image", api.deleteArtistImage()) + }) + }) +} + +func (api *Router) uploadArtistImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + artistID := chi.URLParamFromCtx(ctx, "id") + ar, err := api.ds.Artist(ctx).Get(artistID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath := ar.UploadedImagePath() + filename, err := api.imgUpload.SetImage(ctx, consts.EntityArtist, ar.ID, ar.Name, oldPath, reader, ext) + if err != nil { + return err + } + ar.UploadedImage = filename + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + }) +} + +func (api *Router) deleteArtistImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + artistID := chi.URLParamFromCtx(ctx, "id") + ar, err := api.ds.Artist(ctx).Get(artistID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, ar.UploadedImagePath()); err != nil { + return err + } + ar.UploadedImage = "" + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + }) +} diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index d7368cabf..4e6e9e89b 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -28,7 +28,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go new file mode 100644 index 000000000..c29f14bdc --- /dev/null +++ b/server/nativeapi/image_upload.go @@ -0,0 +1,120 @@ +package nativeapi + +import ( + "context" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + _ "golang.org/x/image/webp" +) + +const maxImageSize = 10 << 20 // 10MB + +func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { + user, _ := request.UserFrom(r.Context()) + if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { + http.Error(w, "cover art upload is disabled", http.StatusForbidden) + return false + } + return true +} + +func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !checkImageUploadPermission(w, r) { + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) + if err := r.ParseMultipartForm(maxImageSize / 2); err != nil { + log.Error(ctx, "Error parsing multipart form", err) + http.Error(w, "file too large or invalid form", http.StatusBadRequest) + return + } + defer func() { + if r.MultipartForm != nil { + if err := r.MultipartForm.RemoveAll(); err != nil { + log.Warn(ctx, "Error removing multipart temp files", err) + } + } + }() + file, header, err := r.FormFile("image") + if err != nil { + log.Error(ctx, "Error reading uploaded file", err) + http.Error(w, "missing image file", http.StatusBadRequest) + return + } + defer file.Close() + _, format, err := image.DecodeConfig(file) + if err != nil { + log.Error(ctx, "Uploaded file is not a valid image", err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + if seeker, ok := file.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + log.Error(ctx, "Error seeking file", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + ext := "." + format + if ext == "." { + ext = strings.ToLower(filepath.Ext(header.Filename)) + } + if ext == "" || ext == "." { + log.Error(ctx, "Could not determine image type", "filename", header.Filename) + http.Error(w, "could not determine image type", http.StatusBadRequest) + return + } + if err := saveFn(ctx, file, ext); err != nil { + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + log.Error(ctx, "Error saving image", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) + } +} + +func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !checkImageUploadPermission(w, r) { + return + } + if err := deleteFn(ctx); err != nil { + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + log.Error(ctx, "Error removing image", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) + } +} diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 5b9cf7e4e..ed5564a41 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -29,7 +29,7 @@ var _ = Describe("Library API", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3191991eb..3ef00ebb1 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -44,10 +44,11 @@ type Router struct { users core.User maintenance core.Maintenance pluginManager PluginManager + imgUpload core.ImageUploadService } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r } @@ -66,7 +67,7 @@ func (api *Router) routes() http.Handler { api.RX(r, "/user", api.users.NewRepository, true) api.R(r, "/song", model.MediaFile{}, false) api.R(r, "/album", model.Album{}, false) - api.R(r, "/artist", model.Artist{}, false) + api.addArtistRoute(r) api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b192e00ac..f0ee50ebb 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -94,7 +94,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 118528f68..ea1cf579b 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -5,25 +5,17 @@ import ( "encoding/json" "errors" "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" "io" "net/http" - "path/filepath" "strconv" "strings" "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/req" - _ "golang.org/x/image/webp" ) type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc @@ -234,110 +226,16 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { } } -const maxImageSize = 10 << 20 // 10MB - func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) - return - } - p := req.Params(r) - playlistId, _ := p.String(":id") - - if err := r.ParseMultipartForm(maxImageSize); err != nil { //nolint:gosec // size is limited by maxImageSize parameter - log.Error(ctx, "Error parsing multipart form", err) - http.Error(w, "file too large or invalid form", http.StatusBadRequest) - return - } - - file, header, err := r.FormFile("image") - if err != nil { - log.Error(ctx, "Error reading uploaded file", err) - http.Error(w, "missing image file", http.StatusBadRequest) - return - } - defer file.Close() - - // Validate the uploaded file is a valid image - _, format, err := image.DecodeConfig(file) - if err != nil { - log.Error(ctx, "Uploaded file is not a valid image", err) - http.Error(w, "invalid image file", http.StatusBadRequest) - return - } - - // Reset reader after DecodeConfig consumed some bytes - if seeker, ok := file.(io.Seeker); ok { - if _, err := seeker.Seek(0, io.SeekStart); err != nil { - log.Error(ctx, "Error seeking file", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - - // Determine file extension from decoded format or original filename - ext := "." + format - if ext == "." { - ext = strings.ToLower(filepath.Ext(header.Filename)) - } - if ext == "" || ext == "." { - log.Error(ctx, "Could not determine image type", "playlistId", playlistId, "filename", header.Filename) - http.Error(w, "could not determine image type", http.StatusBadRequest) - return - } - - err = pls.SetImage(ctx, playlistId, file, ext) - if errors.Is(err, model.ErrNotAuthorized) { - log.Error(ctx, "Not authorized to upload playlist image", "playlistId", playlistId, err) - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Playlist not found for image upload", "playlistId", playlistId, err) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error saving playlist image", "playlistId", playlistId, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec - } + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + playlistId := chi.URLParamFromCtx(ctx, "id") + return pls.SetImage(ctx, playlistId, reader, ext) + }) } func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) - return - } - p := req.Params(r) - playlistId, _ := p.String(":id") - - err := pls.RemoveImage(ctx, playlistId) - if errors.Is(err, model.ErrNotAuthorized) { - log.Error(ctx, "Not authorized to remove playlist image", "playlistId", playlistId, err) - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Playlist not found for image removal", "playlistId", playlistId, err) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error removing playlist image", "playlistId", playlistId, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec - } + return handleImageDelete(func(ctx context.Context) error { + playlistId := chi.URLParamFromCtx(ctx, "id") + return pls.RemoveImage(ctx, playlistId) + }) } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 7f0cd7de1..dfe6b9296 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -98,7 +98,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 7946b90fd..8fc88e09c 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -33,7 +33,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 1e074ce4e..da8d06014 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -6,12 +6,13 @@ import CardContent from '@material-ui/core/CardContent' import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField } from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -57,6 +58,7 @@ const useStyles = makeStyles( alignItems: 'center', justifyContent: 'center', boxShadow: 'none', + position: 'relative', }, artistDetail: { flex: '1', @@ -85,36 +87,15 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const [isLightboxOpen, setLightboxOpen] = React.useState(false) - const [imageLoading, setImageLoading] = React.useState(false) - const [imageError, setImageError] = React.useState(false) - - // Reset image state when artist changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = React.useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = React.useCallback( - () => setLightboxOpen(false), - [], - ) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useArtistImageState(record.id) return (
@@ -135,6 +116,11 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { }} /> )} +
diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 4947a4634..3add1e994 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -4,10 +4,11 @@ import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField } from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -67,6 +68,7 @@ const useStyles = makeStyles( minWidth: '7rem', display: 'flex', borderRadius: '5em', + position: 'relative', }, loveButton: { top: theme.spacing(-0.2), @@ -87,36 +89,15 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const [isLightboxOpen, setLightboxOpen] = React.useState(false) - const [imageLoading, setImageLoading] = React.useState(false) - const [imageError, setImageError] = React.useState(false) - - // Reset image state when artist changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = React.useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = React.useCallback( - () => setLightboxOpen(false), - [], - ) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useArtistImageState(record.id) return ( <> @@ -138,6 +119,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { }} /> )} +
{ + const [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) + const [isLightboxOpen, setLightboxOpen] = useState(false) + + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [recordId]) + + const handleImageLoad = useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + + const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + + return { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } +} + +export default useArtistImageState diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx new file mode 100644 index 000000000..e0d0d0a9a --- /dev/null +++ b/ui/src/common/ImageUploadOverlay.jsx @@ -0,0 +1,139 @@ +import { IconButton, Tooltip } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' +import DeleteIcon from '@material-ui/icons/Delete' +import { useTranslate, useNotify, useRefresh } from 'react-admin' +import { useCallback, useRef } from 'react' +import config from '../config' +import { REST_URL } from '../consts' +import { httpClient } from '../dataProvider' + +const useStyles = makeStyles(() => ({ + coverOverlay: { + position: 'absolute', + bottom: 0, + right: 0, + display: 'flex', + gap: '2px', + padding: '2px', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: '4px 0 0 0', + opacity: 0, + transition: 'opacity 0.2s ease-in-out', + '*:hover > &': { + opacity: 1, + }, + }, + overlayButton: { + color: '#fff', + padding: '4px', + '&:hover': { + backgroundColor: 'rgba(255,255,255,0.2)', + }, + }, + overlayIcon: { + fontSize: '1.2rem', + }, +})) + +export const ImageUploadOverlay = ({ + entityType, + entityId, + hasUploadedImage, + onImageChange, +}) => { + const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() + const classes = useStyles() + const fileInputRef = useRef(null) + + const canEdit = + config.enableCoverArtUpload || localStorage.getItem('role') === 'admin' + + const handleUploadClick = useCallback((e) => { + e.stopPropagation() + if (fileInputRef.current) { + fileInputRef.current.click() + } + }, []) + + const handleFileChange = useCallback( + async (e) => { + const file = e.target.files[0] + if (!file || !entityId) return + + const formData = new FormData() + formData.append('image', file) + + try { + await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { + method: 'POST', + headers: new Headers({}), + body: formData, + }) + notify(`message.coverUploaded`, 'success') + if (onImageChange) onImageChange() + refresh() + } catch (err) { + notify(`message.coverUploadError`, 'warning') + } + + e.target.value = '' + }, + [entityType, entityId, notify, refresh, onImageChange], + ) + + const handleRemoveCover = useCallback( + async (e) => { + e.stopPropagation() + if (!entityId) return + + try { + await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { + method: 'DELETE', + }) + notify(`message.coverRemoved`, 'success') + if (onImageChange) onImageChange() + refresh() + } catch (err) { + notify(`message.coverRemoveError`, 'warning') + } + }, + [entityType, entityId, notify, refresh, onImageChange], + ) + + if (!canEdit) return null + + return ( +
+ + + + + + {hasUploadedImage && ( + + + + + + )} + +
+ ) +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index a8dc354cb..953e38cf8 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -43,3 +43,4 @@ export * from './PathField.jsx' export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' +export * from './ImageUploadOverlay' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index baf4c9fa3..6c6592178 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -219,15 +219,9 @@ "makePrivate": "Make Private", "searchOrCreate": "Search playlists or type to create new...", "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection", - "uploadCover": "Upload Cover", - "removeCover": "Remove Cover" + "removeFromSelection": "Remove from selection" }, "message": { - "coverUploaded": "Cover art updated", - "coverRemoved": "Cover art removed", - "coverUploadError": "Error uploading cover art", - "coverRemoveError": "Error removing cover art", "duplicate_song": "Add duplicated songs", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", "noPlaylistsFound": "No playlists found", @@ -563,6 +557,12 @@ } }, "message": { + "uploadCover": "Upload Cover", + "removeCover": "Remove Cover", + "coverUploaded": "Cover art updated", + "coverRemoved": "Cover art removed", + "coverUploadError": "Error uploading cover art", + "coverRemoveError": "Error removing cover art", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index aeddf0d42..a2d5e753b 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -2,29 +2,23 @@ import { Card, CardContent, CardMedia, - IconButton, - Tooltip, Typography, useMediaQuery, } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' -import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' -import DeleteIcon from '@material-ui/icons/Delete' -import { useTranslate, useNotify, useRefresh } from 'react-admin' -import { useCallback, useRef, useState, useEffect } from 'react' +import { useTranslate } from 'react-admin' +import { useCallback, useState, useEffect } from 'react' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import { CollapsibleComment, DurationField, + ImageUploadOverlay, SizeField, isWritable, OverflowTooltip, } from '../common' -import config from '../config' import subsonic from '../subsonic' -import { REST_URL } from '../consts' -import { httpClient } from '../dataProvider' const useStyles = makeStyles( (theme) => ({ @@ -82,31 +76,6 @@ const useStyles = makeStyles( coverLoading: { opacity: 0.5, }, - coverOverlay: { - position: 'absolute', - bottom: 0, - right: 0, - display: 'flex', - gap: '2px', - padding: '2px', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: '4px 0 0 0', - opacity: 0, - transition: 'opacity 0.2s ease-in-out', - '$coverParent:hover &': { - opacity: 1, - }, - }, - overlayButton: { - color: '#fff', - padding: '4px', - '&:hover': { - backgroundColor: 'rgba(255,255,255,0.2)', - }, - }, - overlayIcon: { - fontSize: '1.2rem', - }, title: { overflow: 'hidden', textOverflow: 'ellipsis', @@ -125,20 +94,14 @@ const useStyles = makeStyles( const PlaylistDetails = (props) => { const { record = {} } = props const translate = useTranslate() - const notify = useNotify() - const refresh = useRefresh() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const [isLightboxOpen, setLightboxOpen] = useState(false) const [imageLoading, setImageLoading] = useState(false) const [imageError, setImageError] = useState(false) - const fileInputRef = useRef(null) const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - const canEdit = - isWritable(record.ownerId) && - (config.enableCoverArtUpload || localStorage.getItem('role') === 'admin') // Reset image state when playlist changes useEffect(() => { @@ -164,60 +127,6 @@ const PlaylistDetails = (props) => { const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - const handleUploadClick = useCallback( - (e) => { - e.stopPropagation() - if (fileInputRef.current) { - fileInputRef.current.click() - } - }, - [fileInputRef], - ) - - const handleFileChange = useCallback( - async (e) => { - const file = e.target.files[0] - if (!file || !record.id) return - - const formData = new FormData() - formData.append('image', file) - - try { - await httpClient(`${REST_URL}/playlist/${record.id}/image`, { - method: 'POST', - headers: new Headers({}), - body: formData, - }) - notify('resources.playlist.message.coverUploaded', 'success') - refresh() - } catch (err) { - notify('resources.playlist.message.coverUploadError', 'warning') - } - - // Reset file input so the same file can be re-selected - e.target.value = '' - }, - [record.id, notify, refresh], - ) - - const handleRemoveCover = useCallback( - async (e) => { - e.stopPropagation() - if (!record.id) return - - try { - await httpClient(`${REST_URL}/playlist/${record.id}/image`, { - method: 'DELETE', - }) - notify('resources.playlist.message.coverRemoved', 'success') - refresh() - } catch (err) { - notify('resources.playlist.message.coverRemoveError', 'warning') - } - }, - [record.id, notify, refresh], - ) - return (
@@ -237,40 +146,12 @@ const PlaylistDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> - {canEdit && ( -
- - - - - - {record.uploadedImage && ( - - - - - - )} - -
+ {isWritable(record.ownerId) && ( + )}
From cefa6e9619a9025ce07489b9e1d4a6ef190efd20 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 16 Mar 2026 06:39:48 -0400 Subject: [PATCH 104/596] feat(ui): add CoverArtAvatar component and integrate it into artist and playlist lists Signed-off-by: Deluan --- ui/src/artist/ArtistList.jsx | 6 +++++ ui/src/artist/ArtistSimpleList.jsx | 7 +++++- ui/src/common/CoverArtAvatar.jsx | 36 ++++++++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/playlist/PlaylistList.jsx | 29 ++---------------------- 5 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 ui/src/common/CoverArtAvatar.jsx diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index e175763e3..6c526a5a5 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -22,6 +22,7 @@ import { useDrag } from 'react-dnd' import clsx from 'clsx' import { ArtistContextMenu, + CoverArtAvatar, List, QuickFilter, useGetHandleArtistClick, @@ -43,6 +44,10 @@ const useStyles = makeStyles({ verticalAlign: 'text-top', }, row: { + '& td': { + paddingTop: '4px !important', + paddingBottom: '4px !important', + }, '&:hover': { '& $contextMenu': { visibility: 'visible', @@ -170,6 +175,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { /> ) : ( + linkType(id)}> + + +
{data[id].name}
diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx new file mode 100644 index 000000000..6610cb75e --- /dev/null +++ b/ui/src/common/CoverArtAvatar.jsx @@ -0,0 +1,36 @@ +import { useRecordContext } from 'react-admin' +import { Avatar } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import clsx from 'clsx' +import subsonic from '../subsonic' + +const useStyles = makeStyles({ + avatar: { + width: '55px', + height: '55px', + }, + square: { + borderRadius: '4px', + }, +}) + +export const CoverArtAvatar = ({ + record: recordProp, + variant = 'circular', +}) => { + const classes = useStyles() + const recordContext = useRecordContext() + const record = recordProp || recordContext + if (!record) return null + const square = variant !== 'circular' + return ( + + ) +} + +CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 953e38cf8..b93e40219 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -44,3 +44,4 @@ export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' +export * from './CoverArtAvatar' diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 67c456f27..8732725bc 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -16,10 +16,10 @@ import { usePermissions, } from 'react-admin' import Switch from '@material-ui/core/Switch' -import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { + CoverArtAvatar, DurationField, List, Writable, @@ -29,17 +29,11 @@ import { } from '../common' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' -import subsonic from '../subsonic' const useStyles = makeStyles((theme) => ({ button: { color: theme.palette.type === 'dark' ? 'white' : undefined, }, - coverArt: { - width: '40px', - height: '40px', - borderRadius: '4px', - }, })) const PlaylistFilter = (props) => { @@ -126,25 +120,6 @@ const ToggleAutoImport = ({ resource, source }) => { ) : null } -const CoverArtField = () => { - const classes = useStyles() - const record = useRecordContext() - if (!record) return null - return ( - - ) -} - -CoverArtField.defaultProps = { - label: '', - sortable: false, -} - const PlaylistListBulkActions = (props) => { const classes = useStyles() return ( @@ -204,7 +179,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && } > isWritable(r?.ownerId)}> - + {columns} From 9ae9134a9140be56a13417fd15ee30203dd43349 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 16 Mar 2026 06:46:47 -0400 Subject: [PATCH 105/596] feat(ui): integrate CoverArtAvatar component into AlbumTableView Signed-off-by: Deluan --- ui/src/album/AlbumTableView.jsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index 1fa33d769..d1a89d512 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, + CoverArtAvatar, DurationField, RangeField, SimpleList, @@ -161,12 +162,18 @@ const AlbumTableView = ({       )} + leftIcon={(r) => ( + + + + )} linkType={'show'} rightIcon={(r) => } {...rest} /> ) : ( + {columns} Date: Mon, 16 Mar 2026 07:55:22 -0400 Subject: [PATCH 106/596] fix(db): normalize timestamps and fix recently added album sorting (#5176) * fix(db): normalize timestamps and fix recently added album sorting SQLite stores timestamps as TEXT and uses string comparison for ORDER BY. Timestamps in RFC3339 T-format ('2024-01-01T10:00:00Z') sort incorrectly against space-format ('2024-01-01 10:00:00+00:00') because 'T' (ASCII 84) > ' ' (ASCII 32), causing albums with T-format timestamps to appear as newer than they are in the "Recently Added" list. This adds a migration to normalize all T-format timestamps across all tables to the space-format expected by go-sqlite3, wraps the recently_added sort with datetime() to make it format-agnostic, and replaces the plain album timestamp indexes with expression indexes to maintain query performance. * fix(test): improve recently_added sort test robustness Use same-date timestamps (2024-01-15T08:00:00Z vs 2024-01-15 20:00:00) so the T-vs-space character difference at position 10 actually triggers the sorting bug. Initialize index variables to -1 and assert both test albums are found before comparing positions. * chore(db): update migration timestamp to 2026-03-16 --- .../20260316000000_normalize_timestamps.sql | 74 +++++++++++++++++++ persistence/album_repository.go | 4 +- persistence/album_repository_test.go | 47 ++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 db/migrations/20260316000000_normalize_timestamps.sql diff --git a/db/migrations/20260316000000_normalize_timestamps.sql b/db/migrations/20260316000000_normalize_timestamps.sql new file mode 100644 index 000000000..a2e1183e9 --- /dev/null +++ b/db/migrations/20260316000000_normalize_timestamps.sql @@ -0,0 +1,74 @@ +-- +goose Up + +-- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format. +-- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32) +-- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering. + +UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%'; +UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; + +UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%'; + +UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; + +UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%'; +UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%'; +UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%'; + +UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%'; + +UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%'; +UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%'; + +UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%'; + +UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%'; +UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%'; + +UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%'; + +UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%'; +UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%'; + +UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%'; +UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%'; + +UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +-- Replace plain indexes with expression indexes for datetime()-based sorting +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(datetime(created_at)); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(datetime(updated_at)); + +-- +goose Down +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(created_at); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(updated_at); diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 7207bf5a2..c51a5beb1 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "updated_at" + return "datetime(album.updated_at)" } - return "created_at" + return "datetime(album.created_at)" } func recentlyPlayedFilter(string, any) Sqlizer { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 66b6eba9f..2792cec97 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -85,6 +85,53 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("recently_added sort", func() { + It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() { + // Both timestamps share the same date prefix "2024-01-15" so the T vs space + // character at position 10 determines sort order in raw string comparison. + // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older + // T-format timestamp sort AFTER the newer space-format one. + + // Older album: morning of Jan 15, stored in T-format + olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"} + Expect(albumRepo.Put(olderAlbum)).To(Succeed()) + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15T08:00:00Z"). + Where(squirrel.Eq{"id": "ts-older"})) + Expect(err).ToNot(HaveOccurred()) + + // Newer album: evening of Jan 15, stored in space-format + newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"} + Expect(albumRepo.Put(newerAlbum)).To(Succeed()) + _, err = albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15 20:00:00+00:00"). + Where(squirrel.Eq{"id": "ts-newer"})) + Expect(err).ToNot(HaveOccurred()) + + albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + // Find positions of our test albums + olderIdx, newerIdx := -1, -1 + for i, a := range albums { + switch a.ID { + case "ts-older": + olderIdx = i + case "ts-newer": + newerIdx = i + } + } + Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results") + Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results") + // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order + Expect(newerIdx).To(BeNumerically("<", olderIdx), + "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order") + + // Clean up + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) + }) + }) + Context("Filters", func() { var albumWithoutAnnotation model.Album From 2f5b2b51359db24573d965fe4b114a45c72d3b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 16 Mar 2026 18:08:39 -0400 Subject: [PATCH 107/596] fix(artwork): fallback mediafile cover art to disc artwork before album (#5216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(artwork): fallback mediafile cover art to disc artwork before album Changed the mediafile cover art fallback chain to go through disc artwork before album artwork (mediafile → disc → album). Previously, mediafiles without embedded art fell back directly to album cover, bypassing any disc-specific artwork. Renamed AlbumCoverArtID() to DiscCoverArtID() to encapsulate the disc-vs-album decision in a single method, used by both CoverArtID() and the mediafile artwork reader. Signed-off-by: Deluan * fix(artwork): fix cache invalidation for mediafile and album cover art Include imagesUpdatedAt from album folders in the mediafile artwork reader's cache key, so that when a cover image file changes on disk (without audio metadata changes) the mediafile cache properly invalidates. Also include CoverArtPriority unconditionally in the album artwork reader's cache key hash, so that changing the priority order with external services disabled correctly invalidates the album cache. * fix(artwork): skip disc artwork resolution for single-disc albums Single-disc albums with DiscNumber=1 were unnecessarily routed through discArtworkReader, which does extra DB queries only to fall through to album art anyway. Now only multi-disc albums use the disc fallback path. * refactor(artwork): restore AlbumCoverArtID as a separate method Extract AlbumCoverArtID back out of DiscCoverArtID so the single-disc fallback path in reader_mediafile can reference it by name instead of inlining the artwork ID construction. --------- Signed-off-by: Deluan --- core/artwork/artwork_internal_test.go | 28 +++++++++++++++++++++++++-- core/artwork/reader_album.go | 5 +++-- core/artwork/reader_mediafile.go | 20 +++++++++++++++---- model/mediafile.go | 11 ++++++++++- model/mediafile_test.go | 17 ++++++++++++++-- 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 5ca32f401..4b2359898 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -28,7 +28,7 @@ var _ = Describe("Artwork", func() { var ffmpeg *tests.MockFFmpeg var folderRepo *fakeFolderRepo ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album + var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album var arMultipleCovers model.Artist var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile @@ -44,8 +44,9 @@ var _ = Describe("Artwork", func() { } alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} - alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}} + alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} + alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} arMultipleCovers = model.Artist{ID: "777", Name: "All options"} alMultipleCovers = model.Album{ ID: "666", @@ -193,6 +194,7 @@ var _ = Describe("Artwork", func() { ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ alOnlyEmbed, alOnlyExternal, + alSingleDisc, }) ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ mfWithEmbed, @@ -236,6 +238,28 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal("al-444_0")) }) + It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { + mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Should fall back to disc art, which itself falls back to album art + Expect(path).To(Equal("dc-444:2_0")) + }) + It("falls back to album cover art for single-disc albums even with a disc number", func() { + mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Single-disc album should skip disc art and go straight to album art + Expect(path).To(Equal("al-888_0")) + }) }) }) Describe("playlistArtworkReader", func() { diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 98a2105eb..6de1d31d1 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -59,10 +59,11 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar } func (a *albumArtworkReader) Key() string { - var hash [16]byte + hashInput := conf.Server.CoverArtPriority if conf.Server.EnableExternalServices { - hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority)) + hashInput += conf.Server.Agents } + hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( "%s.%x.%t", a.cacheKey.Key(), diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index c72d9543d..cf25c8f5d 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -26,16 +26,22 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, } a.cacheKey.artID = artID - if al.UpdatedAt.After(mf.UpdatedAt) { + a.cacheKey.lastUpdate = mf.UpdatedAt + if al.UpdatedAt.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = al.UpdatedAt - } else { - a.cacheKey.lastUpdate = mf.UpdatedAt + } + if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *imagesUpdatedAt } return a, nil } @@ -60,6 +66,12 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str fromFFmpegTag(ctx, a.a.ffmpeg, path), } } - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + // For multi-disc albums, fall back to disc artwork first; for single-disc albums, + // skip disc resolution (it would just fall through to album art anyway). + if len(a.album.Discs) > 1 { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) + } else { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + } return selectImageReader(ctx, a.artID, ff...) } diff --git a/model/mediafile.go b/model/mediafile.go index 20532bfb9..ec83b76fd 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -119,7 +119,16 @@ func (mf MediaFile) CoverArtID() ArtworkID { if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt { return artworkIDFromMediaFile(mf) } - // if it does not have a coverArt, fallback to the album cover + // Otherwise fallback to disc (if available) or album cover + return mf.DiscCoverArtID() +} + +// DiscCoverArtID returns the disc artwork ID when the media file has a disc number, +// otherwise it returns the album artwork ID. +func (mf MediaFile) DiscCoverArtID() ArtworkID { + if mf.DiscNumber > 0 { + return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + } return mf.AlbumCoverArtID() } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 207d3c155..038ac93d5 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -504,13 +504,26 @@ var _ = Describe("MediaFile", func() { Expect(id.Kind).To(Equal(KindMediaFileArtwork)) Expect(id.ID).To(Equal(mf.ID)) }) - It("returns its album id if HasCoverArt is false", func() { + It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() { + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindDiscArtwork)) + Expect(id.ID).To(Equal("1:2")) + }) + It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false} id := mf.CoverArtID() Expect(id.Kind).To(Equal(KindAlbumArtwork)) Expect(id.ID).To(Equal(mf.AlbumID)) }) - It("returns its album id if EnableMediaFileCoverArt is disabled", func() { + It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() { + conf.Server.EnableMediaFileCoverArt = false + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindDiscArtwork)) + Expect(id.ID).To(Equal("1:3")) + }) + It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() { conf.Server.EnableMediaFileCoverArt = false mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() From 8f05f7815eb97b57a6e28a5e1ebabb420c1c5dcc Mon Sep 17 00:00:00 2001 From: Simon Teixidor Date: Tue, 17 Mar 2026 13:04:47 +0100 Subject: [PATCH 108/596] fix(server): use http.TimeFormat for Last-Modified header (#5219) Navidrome returns Last-Modified values like `Fri, 12 Dec 2025 03:32:26 UTC`. This is invalid according to RFC 7231 which requires HTTP dates to use GMT instead of UTC. Switch to http.TimeFormat instead of time.RFC1123 to resolve the issue. --- server/public/handle_images.go | 2 +- server/subsonic/media_retrieval.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/public/handle_images.go b/server/public/handle_images.go index f4985dea5..50f9238e5 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -60,7 +60,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { defer imgReader.Close() w.Header().Set("Cache-Control", "public, max-age=315360000") - w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123)) + w.Header().Set("Last-Modified", lastUpdate.Format(http.TimeFormat)) cnt, err := io.Copy(w, imgReader) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 54fcb5e3a..3faae1650 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -81,7 +81,7 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons defer imgReader.Close() w.Header().Set("cache-control", "public, max-age=315360000") - w.Header().Set("last-modified", lastUpdate.Format(time.RFC1123)) + w.Header().Set("last-modified", lastUpdate.Format(http.TimeFormat)) cnt, err := io.Copy(w, imgReader) if err != nil { From a0fe72809869ddd2da94458f059313aac18ea858 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 12:14:57 -0400 Subject: [PATCH 109/596] fix(player): fix play next after transcoding changes Signed-off-by: Deluan --- ui/package-lock.json | 343 +++++++++++++------------------ ui/package.json | 2 +- ui/src/reducers/playerReducer.js | 26 ++- 3 files changed, 155 insertions(+), 216 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 04d6fe07c..7d1131005 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.1", + "navidrome-music-player": "4.25.2", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", @@ -73,6 +73,106 @@ "vitest": "^4.0.3" } }, + "../../react-music-player": { + "name": "navidrome-music-player", + "version": "4.25.2", + "license": "MIT", + "dependencies": { + "@react-icons/all-files": "^4.1.0", + "classnames": "^2.3.1", + "downloadjs": "^1.4.7", + "is-mobile": "^2.2.2", + "prop-types": "^15.7.2", + "rc-slider": "^9.7.2", + "rc-switch": "^3.2.2", + "react-draggable": "^4.4.3", + "sortablejs": "^1.13.0" + }, + "devDependencies": { + "@babel/cli": "7.13.16", + "@babel/core": "7.14.0", + "@babel/plugin-proposal-class-properties": "7.13.0", + "@babel/plugin-proposal-object-rest-spread": "7.13.8", + "@babel/plugin-syntax-dynamic-import": "7.8.3", + "@babel/plugin-syntax-object-rest-spread": "7.8.3", + "@babel/plugin-transform-object-assign": "7.12.13", + "@babel/plugin-transform-runtime": "^7.13.15", + "@babel/preset-env": "7.14.1", + "@babel/preset-react": "^7.13.13", + "@babel/runtime": "7.14.0", + "@commitlint/config-conventional": "^12.1.1", + "@semantic-release/changelog": "^5.0.1", + "@semantic-release/git": "^9.0.0", + "@types/jest": "^26.0.23", + "@types/sortablejs": "^1.10.6", + "autoprefixer": "^10.2.5", + "babel-core": "^7.0.0-bridge.0", + "babel-eslint": "^10.1.0", + "babel-jest": "^26.6.3", + "babel-loader": "^8.2.2", + "babel-plugin-import": "^1.13.3", + "babel-plugin-transform-runtime": "^6.23.0", + "bundlesize": "^0.18.1", + "clean-webpack-plugin": "^3.0.0", + "codecov": "^3.8.2", + "commitizen": "^4.2.4", + "commitlint": "^12.1.1", + "copy-webpack-plugin": "latest", + "coveralls": "^3.1.0", + "cross-env": "^7.0.3", + "css-loader": "^5.2.4", + "css-minimizer-webpack-plugin": "^2.0.0", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.6", + "enzyme-to-json": "^3.6.2", + "esbuild-webpack-plugin": "^1.1.0", + "eslint": "^7.26.0", + "eslint-config-airbnb": "^18.2.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-babel": "^5.3.1", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jest": "^24.3.6", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-prettier": "^3.4.0", + "eslint-plugin-react": "^7.23.2", + "file-loader": "^6.2.0", + "html-loader": "^2.1.2", + "html-webpack-plugin": "^5.3.1", + "husky": "^6.0.0", + "jest": "^26.6.3", + "jest-environment-jsdom": "^26.6.2", + "jsdom": "^16.5.3", + "less": "^4.1.1", + "less-loader": "^8.1.1", + "lint-staged": "^11.0.0", + "mini-css-extract-plugin": "^1.6.0", + "postcss": "^8.2.14", + "postcss-loader": "^5.2.0", + "power-assert": "^1.6.1", + "pre-commit": "^1.2.2", + "prettier": "^2.2.1", + "pretty-quick": "^3.1.0", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-hot-loader": "^4.13.0", + "react-loader": "^2.4.7", + "rimraf": "^3.0.2", + "semantic-release": "^17.4.2", + "style-loader": "~2.0.0", + "stylelint": "^13.13.1", + "stylelint-config-prettier": "^8.0.2", + "stylelint-config-standard": "^22.0.0", + "uglifyjs-webpack-plugin": "^2.2.0", + "webpack": "^5.36.2", + "webpack-bundle-analyzer": "^4.4.1", + "webpack-cli": "^4.7.0", + "webpack-dev-server": "3.11.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -129,6 +229,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1743,6 +1844,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1766,6 +1868,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2573,6 +2676,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2626,6 +2730,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2641,6 +2746,7 @@ "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2687,6 +2793,7 @@ "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2982,15 +3089,6 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "license": "MIT" }, - "node_modules/@react-icons/all-files": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", - "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", - "license": "MIT", - "peerDependencies": { - "react": "*" - } - }, "node_modules/@redux-saga/core": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz", @@ -3410,6 +3508,7 @@ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", + "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3462,6 +3561,7 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3483,6 +3583,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.90.tgz", "integrity": "sha512-P9beVR/x06U9rCJzSxtENnOr4BrbJ6VrsrDTc+73TtHv9XHhryXKbjGRB+6oooB2r0G/pQkD/S4dHo/7jUfwFw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3652,6 +3753,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3983,6 +4085,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4664,6 +4767,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4885,12 +4989,6 @@ "node": ">= 6" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -5039,6 +5137,7 @@ "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5480,12 +5579,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-align": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", - "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", - "license": "MIT" - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -5541,12 +5634,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/downloadjs": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", - "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==", - "license": "MIT" - }, "node_modules/downshift": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", @@ -5926,6 +6013,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6509,6 +6597,7 @@ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -6525,6 +6614,7 @@ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", + "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6939,6 +7029,7 @@ "integrity": "sha512-hM9gltmtQLfmWPqoPreUtRdP3nZCSzQEw7l/JC+up5CxquDykhYFKzIzoFFeVev3AGFEULNvsbE8fpZPgxUYEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -7052,6 +7143,7 @@ "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -7598,12 +7690,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-mobile": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz", - "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==", - "license": "MIT" - }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -8660,6 +8746,7 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -8703,25 +8790,8 @@ "license": "MIT" }, "node_modules/navidrome-music-player": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.1.tgz", - "integrity": "sha512-bHYr84ATUf/4+/PUoTpUSmpF4/igBx2UPhgnPqvda4FND+GJZtb1ikbMs1U+mhkNEUebe+2I29ob1zY7YZdtjg==", - "license": "MIT", - "dependencies": { - "@react-icons/all-files": "^4.1.0", - "classnames": "^2.3.1", - "downloadjs": "^1.4.7", - "is-mobile": "^2.2.2", - "prop-types": "^15.7.2", - "rc-slider": "^9.7.2", - "rc-switch": "^3.2.2", - "react-draggable": "^4.4.3", - "sortablejs": "^1.13.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } + "resolved": "../../react-music-player", + "link": true }, "node_modules/node-polyglot": { "version": "2.6.0", @@ -9379,6 +9449,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9470,6 +9541,7 @@ "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", + "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9723,128 +9795,6 @@ "rc": "cli.js" } }, - "node_modules/rc-align": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", - "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "dom-align": "^1.7.0", - "rc-util": "^5.26.0", - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-motion": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", - "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.44.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-slider": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.7.5.tgz", - "integrity": "sha512-LV/MWcXFjco1epPbdw1JlLXlTgmWpB9/Y/P2yinf8Pg3wElHxA9uajN21lJiWtZjf5SCUekfSP6QMJfDo4t1hg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-tooltip": "^5.0.1", - "rc-util": "^5.16.1", - "shallowequal": "^1.1.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-switch": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-3.2.2.tgz", - "integrity": "sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.1", - "rc-util": "^5.0.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tooltip": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.3.1.tgz", - "integrity": "sha512-e6H0dMD38EPaSPD2XC8dRfct27VvT2TkPdoBSuNl3RRZ5tspiY/c5xYEmGC0IrABvMBgque4Mr2SMZuliCvoiQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "classnames": "^2.3.1", - "rc-trigger": "^5.3.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-trigger": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", - "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "classnames": "^2.2.6", - "rc-align": "^4.0.0", - "rc-motion": "^2.0.0", - "rc-util": "^5.19.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-util": { - "version": "5.44.4", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", - "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -9865,6 +9815,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9946,6 +9897,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9965,20 +9917,6 @@ "prop-types": "^15.5.8" } }, - "node_modules/react-draggable": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", - "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" - } - }, "node_modules/react-dropzone": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.2.tgz", @@ -10018,6 +9956,7 @@ "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -10035,6 +9974,7 @@ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -10140,6 +10080,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -10175,6 +10116,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10195,6 +10137,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10376,6 +10319,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -10385,6 +10329,7 @@ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", + "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -10638,6 +10583,7 @@ "integrity": "sha512-oWKZLjYwTihnTeINcNenxIIDfeotkQ2GAjFJPe7aYsMONrwDwQQXcAl3Qv0qON7Hdc8RTsFomq22zotm/i6VVQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10897,12 +10843,6 @@ "node": ">= 0.4" } }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11025,12 +10965,6 @@ "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", "license": "MIT" }, - "node_modules/sortablejs": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", - "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", - "license": "MIT" - }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -11598,6 +11532,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11819,6 +11754,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12063,6 +11999,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12187,6 +12124,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12200,6 +12138,7 @@ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -12716,6 +12655,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12803,6 +12743,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/package.json b/ui/package.json index 5b4deb773..d4c149b23 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,7 +32,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.1", + "navidrome-music-player": "4.25.2", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index d3291633c..466a3ec87 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -134,30 +134,24 @@ const reduceAddTracks = (state, { data }) => { } const reducePlayNext = (state, { data }) => { + const newTracks = Object.keys(data).map((id) => mapToAudioLists(data[id])) 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])) - }) + newQueue.push(...newTracks) } }) if (!foundPos) { - Object.keys(data).forEach((id) => { - newQueue.push(mapToAudioLists(data[id])) - }) + newQueue.push(...newTracks) } return { ...state, queue: newQueue, - playIndex: foundPos ? currentIndex : undefined, clear: true, } } @@ -170,14 +164,18 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { + // Only keep clear and playIndex alive when there is an actual pending + // track switch (playIndex differs from savedPlayIndex). This lets + // PLAYER_PLAY_TRACKS selections survive the sync, while allowing + // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to + // reset immediately and avoid restarting playback. + const hasPendingSwitch = + state.playIndex != null && state.playIndex !== state.savedPlayIndex return { ...state, queue: audioLists, - // Keep clear and playIndex alive so the music player can still - // pick up a pending track selection set by PLAYER_PLAY_TRACKS. - // They will be consumed by the next PLAYER_CURRENT dispatch. - clear: state.playIndex != null ? state.clear : false, - playIndex: state.playIndex != null ? state.playIndex : undefined, + clear: hasPendingSwitch ? state.clear : false, + playIndex: hasPendingSwitch ? state.playIndex : undefined, } } From 693abe2f6bb9c2cbe36670883d3f1fe7afbcce22 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 12:28:20 -0400 Subject: [PATCH 110/596] fix(build): regenerate package-lock.json for navidrome-music-player 4.25.2 The lockfile still referenced the local file path from testing, causing CI to fail resolving the navidrome-music-player import. Regenerated to point to the npm registry. --- ui/package-lock.json | 2229 ++++-------------------------------------- 1 file changed, 200 insertions(+), 2029 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 7d1131005..0e6183720 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -73,117 +73,13 @@ "vitest": "^4.0.3" } }, - "../../react-music-player": { - "name": "navidrome-music-player", - "version": "4.25.2", - "license": "MIT", - "dependencies": { - "@react-icons/all-files": "^4.1.0", - "classnames": "^2.3.1", - "downloadjs": "^1.4.7", - "is-mobile": "^2.2.2", - "prop-types": "^15.7.2", - "rc-slider": "^9.7.2", - "rc-switch": "^3.2.2", - "react-draggable": "^4.4.3", - "sortablejs": "^1.13.0" - }, - "devDependencies": { - "@babel/cli": "7.13.16", - "@babel/core": "7.14.0", - "@babel/plugin-proposal-class-properties": "7.13.0", - "@babel/plugin-proposal-object-rest-spread": "7.13.8", - "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-syntax-object-rest-spread": "7.8.3", - "@babel/plugin-transform-object-assign": "7.12.13", - "@babel/plugin-transform-runtime": "^7.13.15", - "@babel/preset-env": "7.14.1", - "@babel/preset-react": "^7.13.13", - "@babel/runtime": "7.14.0", - "@commitlint/config-conventional": "^12.1.1", - "@semantic-release/changelog": "^5.0.1", - "@semantic-release/git": "^9.0.0", - "@types/jest": "^26.0.23", - "@types/sortablejs": "^1.10.6", - "autoprefixer": "^10.2.5", - "babel-core": "^7.0.0-bridge.0", - "babel-eslint": "^10.1.0", - "babel-jest": "^26.6.3", - "babel-loader": "^8.2.2", - "babel-plugin-import": "^1.13.3", - "babel-plugin-transform-runtime": "^6.23.0", - "bundlesize": "^0.18.1", - "clean-webpack-plugin": "^3.0.0", - "codecov": "^3.8.2", - "commitizen": "^4.2.4", - "commitlint": "^12.1.1", - "copy-webpack-plugin": "latest", - "coveralls": "^3.1.0", - "cross-env": "^7.0.3", - "css-loader": "^5.2.4", - "css-minimizer-webpack-plugin": "^2.0.0", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.6", - "enzyme-to-json": "^3.6.2", - "esbuild-webpack-plugin": "^1.1.0", - "eslint": "^7.26.0", - "eslint-config-airbnb": "^18.2.1", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-babel": "^5.3.1", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jest": "^24.3.6", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-prettier": "^3.4.0", - "eslint-plugin-react": "^7.23.2", - "file-loader": "^6.2.0", - "html-loader": "^2.1.2", - "html-webpack-plugin": "^5.3.1", - "husky": "^6.0.0", - "jest": "^26.6.3", - "jest-environment-jsdom": "^26.6.2", - "jsdom": "^16.5.3", - "less": "^4.1.1", - "less-loader": "^8.1.1", - "lint-staged": "^11.0.0", - "mini-css-extract-plugin": "^1.6.0", - "postcss": "^8.2.14", - "postcss-loader": "^5.2.0", - "power-assert": "^1.6.1", - "pre-commit": "^1.2.2", - "prettier": "^2.2.1", - "pretty-quick": "^3.1.0", - "react": "16.13.1", - "react-dom": "16.13.1", - "react-hot-loader": "^4.13.0", - "react-loader": "^2.4.7", - "rimraf": "^3.0.2", - "semantic-release": "^17.4.2", - "style-loader": "~2.0.0", - "stylelint": "^13.13.1", - "stylelint-config-prettier": "^8.0.2", - "stylelint-config-standard": "^22.0.0", - "uglifyjs-webpack-plugin": "^2.2.0", - "webpack": "^5.36.2", - "webpack-bundle-analyzer": "^4.4.1", - "webpack-cli": "^4.7.0", - "webpack-dev-server": "3.11.2" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", "dependencies": { @@ -196,15 +92,11 @@ }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/@babel/code-frame": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -217,8 +109,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -226,8 +116,6 @@ }, "node_modules/@babel/core": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", "peer": true, "dependencies": { @@ -257,8 +145,6 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -266,8 +152,6 @@ }, "node_modules/@babel/generator": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.6", @@ -282,8 +166,6 @@ }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "license": "MIT", "dependencies": { "@babel/types": "^7.27.3" @@ -294,8 +176,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -310,8 +190,6 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -319,8 +197,6 @@ }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -340,8 +216,6 @@ }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -349,8 +223,6 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -366,8 +238,6 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -375,8 +245,6 @@ }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", @@ -391,8 +259,6 @@ }, "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -411,8 +277,6 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -420,8 +284,6 @@ }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.5", @@ -433,8 +295,6 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -446,8 +306,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -463,8 +321,6 @@ }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "license": "MIT", "dependencies": { "@babel/types": "^7.27.1" @@ -475,8 +331,6 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -484,8 +338,6 @@ }, "node_modules/@babel/helper-remap-async-to-generator": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", @@ -501,8 +353,6 @@ }, "node_modules/@babel/helper-replace-supers": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", @@ -518,8 +368,6 @@ }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -531,8 +379,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -540,8 +386,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -549,8 +393,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -558,8 +400,6 @@ }, "node_modules/@babel/helper-wrap-function": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -572,8 +412,6 @@ }, "node_modules/@babel/helpers": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -585,8 +423,6 @@ }, "node_modules/@babel/parser": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "license": "MIT", "dependencies": { "@babel/types": "^7.28.6" @@ -600,8 +436,6 @@ }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -616,8 +450,6 @@ }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -631,8 +463,6 @@ }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -646,8 +476,6 @@ }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -663,8 +491,6 @@ }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -679,8 +505,6 @@ }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -691,8 +515,6 @@ }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -706,8 +528,6 @@ }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -721,8 +541,6 @@ }, "node_modules/@babel/plugin-syntax-unicode-sets-regex": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -737,8 +555,6 @@ }, "node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -752,8 +568,6 @@ }, "node_modules/@babel/plugin-transform-async-generator-functions": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", - "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -769,8 +583,6 @@ }, "node_modules/@babel/plugin-transform-async-to-generator": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -786,8 +598,6 @@ }, "node_modules/@babel/plugin-transform-block-scoped-functions": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -801,8 +611,6 @@ }, "node_modules/@babel/plugin-transform-block-scoping": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -816,8 +624,6 @@ }, "node_modules/@babel/plugin-transform-class-properties": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -832,8 +638,6 @@ }, "node_modules/@babel/plugin-transform-class-static-block": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -848,8 +652,6 @@ }, "node_modules/@babel/plugin-transform-classes": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -868,8 +670,6 @@ }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -884,8 +684,6 @@ }, "node_modules/@babel/plugin-transform-destructuring": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -900,8 +698,6 @@ }, "node_modules/@babel/plugin-transform-dotall-regex": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -916,8 +712,6 @@ }, "node_modules/@babel/plugin-transform-duplicate-keys": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -931,8 +725,6 @@ }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", - "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -947,8 +739,6 @@ }, "node_modules/@babel/plugin-transform-dynamic-import": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -962,8 +752,6 @@ }, "node_modules/@babel/plugin-transform-explicit-resource-management": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -978,8 +766,6 @@ }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -993,8 +779,6 @@ }, "node_modules/@babel/plugin-transform-export-namespace-from": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1008,8 +792,6 @@ }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -1024,8 +806,6 @@ }, "node_modules/@babel/plugin-transform-function-name": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.27.1", @@ -1041,8 +821,6 @@ }, "node_modules/@babel/plugin-transform-json-strings": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1056,8 +834,6 @@ }, "node_modules/@babel/plugin-transform-literals": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1071,8 +847,6 @@ }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1086,8 +860,6 @@ }, "node_modules/@babel/plugin-transform-member-expression-literals": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1101,8 +873,6 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -1117,8 +887,6 @@ }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.28.6", @@ -1133,8 +901,6 @@ }, "node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", - "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.28.3", @@ -1151,8 +917,6 @@ }, "node_modules/@babel/plugin-transform-modules-umd": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -1167,8 +931,6 @@ }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", @@ -1183,8 +945,6 @@ }, "node_modules/@babel/plugin-transform-new-target": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1198,8 +958,6 @@ }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1213,8 +971,6 @@ }, "node_modules/@babel/plugin-transform-numeric-separator": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1228,8 +984,6 @@ }, "node_modules/@babel/plugin-transform-object-rest-spread": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -1247,8 +1001,6 @@ }, "node_modules/@babel/plugin-transform-object-super": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -1263,8 +1015,6 @@ }, "node_modules/@babel/plugin-transform-optional-catch-binding": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1278,8 +1028,6 @@ }, "node_modules/@babel/plugin-transform-optional-chaining": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -1294,8 +1042,6 @@ }, "node_modules/@babel/plugin-transform-parameters": { "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1309,8 +1055,6 @@ }, "node_modules/@babel/plugin-transform-private-methods": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -1325,8 +1069,6 @@ }, "node_modules/@babel/plugin-transform-private-property-in-object": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -1342,8 +1084,6 @@ }, "node_modules/@babel/plugin-transform-property-literals": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1357,8 +1097,6 @@ }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { @@ -1373,8 +1111,6 @@ }, "node_modules/@babel/plugin-transform-react-jsx-source": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1389,8 +1125,6 @@ }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", - "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1404,8 +1138,6 @@ }, "node_modules/@babel/plugin-transform-regexp-modifiers": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1420,8 +1152,6 @@ }, "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1435,8 +1165,6 @@ }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1450,8 +1178,6 @@ }, "node_modules/@babel/plugin-transform-spread": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -1466,8 +1192,6 @@ }, "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1481,8 +1205,6 @@ }, "node_modules/@babel/plugin-transform-template-literals": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1496,8 +1218,6 @@ }, "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1511,8 +1231,6 @@ }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1526,8 +1244,6 @@ }, "node_modules/@babel/plugin-transform-unicode-property-regex": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1542,8 +1258,6 @@ }, "node_modules/@babel/plugin-transform-unicode-regex": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", @@ -1558,8 +1272,6 @@ }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1574,8 +1286,6 @@ }, "node_modules/@babel/preset-env": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz", - "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -1658,8 +1368,6 @@ }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1667,8 +1375,6 @@ }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -1681,8 +1387,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1690,8 +1394,6 @@ }, "node_modules/@babel/runtime-corejs3": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.6.tgz", - "integrity": "sha512-kz2fAQ5UzjV7X7D3ySxmj3vRq89dTpqOZWv76Z6pNPztkwb/0Yj1Mtx1xFrYj6mbIHysxtBot8J4o0JLCblcFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1703,8 +1405,6 @@ }, "node_modules/@babel/template": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -1717,8 +1417,6 @@ }, "node_modules/@babel/traverse": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -1735,8 +1433,6 @@ }, "node_modules/@babel/types": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1748,8 +1444,6 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -1758,8 +1452,6 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -1778,8 +1470,6 @@ }, "node_modules/@csstools/css-calc": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -1802,8 +1492,6 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -1830,8 +1518,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -1854,8 +1540,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -1875,14 +1559,10 @@ }, "node_modules/@date-io/core": { "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz", - "integrity": "sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA==", "license": "MIT" }, "node_modules/@date-io/moment": { "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-1.3.11.tgz", - "integrity": "sha512-pLEkqp8+P1DfC+QU8StaIANXoiadjJjoImLQCy0rhFAo0RVcJB9cM7mWr7fVgM49EjCwpA8a1JekNhuRLIJVwQ==", "license": "MIT", "dependencies": { "@date-io/core": "^1.3.11" @@ -1893,8 +1573,6 @@ }, "node_modules/@emotion/hash": { "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { @@ -1967,8 +1645,6 @@ }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -2341,8 +2017,6 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2360,8 +2034,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -2370,8 +2042,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2394,8 +2064,6 @@ }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2405,8 +2073,6 @@ }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -2418,8 +2084,6 @@ }, "node_modules/@eslint/js": { "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { @@ -2428,9 +2092,6 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2444,8 +2105,6 @@ }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2455,8 +2114,6 @@ }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -2468,8 +2125,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2482,16 +2137,11 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", "dev": true, "license": "BSD-3-Clause" }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "license": "MIT", "engines": { "node": "20 || >=22" @@ -2499,8 +2149,6 @@ }, "node_modules/@isaacs/brace-expansion": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -2511,8 +2159,6 @@ }, "node_modules/@isaacs/cliui": { "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -2528,8 +2174,6 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -2540,8 +2184,6 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -2552,8 +2194,6 @@ }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -2569,8 +2209,6 @@ }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -2584,8 +2222,6 @@ }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -2601,8 +2237,6 @@ }, "node_modules/@jest/types": { "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", - "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2618,8 +2252,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2628,8 +2260,6 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2638,8 +2268,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2647,8 +2275,6 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2657,14 +2283,10 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2673,8 +2295,6 @@ }, "node_modules/@jsonforms/core": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", - "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", "peer": true, "dependencies": { @@ -2688,9 +2308,6 @@ }, "node_modules/@jsonforms/core/node_modules/uuid": { "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -2698,8 +2315,6 @@ }, "node_modules/@jsonforms/material-renderers": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@jsonforms/material-renderers/-/material-renderers-2.5.2.tgz", - "integrity": "sha512-0C6MVyhLoMOf1Byhgs9ZNvV4NWHdbMce6m7hW2LF1Mt9mK1wDfTTst+xM3g7K5b8FSiBiF5kMnTggJcRweAEBA==", "license": "MIT", "dependencies": { "@date-io/moment": "1.3.11", @@ -2717,9 +2332,6 @@ }, "node_modules/@jsonforms/material-renderers/node_modules/uuid": { "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -2727,8 +2339,6 @@ }, "node_modules/@jsonforms/react": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", - "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", "peer": true, "dependencies": { @@ -2742,9 +2352,6 @@ }, "node_modules/@material-ui/core": { "version": "4.12.4", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", - "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", - "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "peer": true, "dependencies": { @@ -2781,8 +2388,6 @@ }, "node_modules/@material-ui/core/node_modules/clsx": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2790,8 +2395,6 @@ }, "node_modules/@material-ui/icons": { "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", - "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", "peer": true, "dependencies": { @@ -2814,9 +2417,6 @@ }, "node_modules/@material-ui/lab": { "version": "4.0.0-alpha.61", - "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz", - "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==", - "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2842,8 +2442,6 @@ }, "node_modules/@material-ui/lab/node_modules/clsx": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2851,9 +2449,6 @@ }, "node_modules/@material-ui/pickers": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.11.tgz", - "integrity": "sha512-pDYjbjUeabapijS2FpSwK/ruJdk7IGeAshpLbKDa3PRRKRy7Nv6sXxAvUg2F+lID/NwUKgBmCYS5bzrl7Xxqzw==", - "deprecated": "This package no longer supported. It has been relaced by @mui/x-date-pickers", "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.0", @@ -2873,8 +2468,6 @@ }, "node_modules/@material-ui/pickers/node_modules/clsx": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2882,9 +2475,6 @@ }, "node_modules/@material-ui/styles": { "version": "4.11.5", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", - "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", - "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2924,8 +2514,6 @@ }, "node_modules/@material-ui/styles/node_modules/clsx": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2933,8 +2521,6 @@ }, "node_modules/@material-ui/system": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", - "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2962,8 +2548,6 @@ }, "node_modules/@material-ui/types": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -2976,8 +2560,6 @@ }, "node_modules/@material-ui/utils": { "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", - "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2994,8 +2576,6 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -3008,8 +2588,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -3018,8 +2596,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -3032,8 +2608,6 @@ }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "license": "MIT", "engines": { "node": ">=12.22.0" @@ -3041,8 +2615,6 @@ }, "node_modules/@pnpm/network.ca-file": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", "license": "MIT", "dependencies": { "graceful-fs": "4.2.10" @@ -3053,14 +2625,10 @@ }, "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "license": "ISC" }, "node_modules/@pnpm/npm-conf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", "license": "MIT", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", @@ -3073,26 +2641,27 @@ }, "node_modules/@react-dnd/asap": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", - "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", "license": "MIT" }, "node_modules/@react-dnd/invariant": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", - "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", "license": "MIT" }, "node_modules/@react-dnd/shallowequal": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", - "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "license": "MIT" }, + "node_modules/@react-icons/all-files": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", + "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/@redux-saga/core": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz", - "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -3110,14 +2679,10 @@ }, "node_modules/@redux-saga/deferred": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz", - "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==", "license": "MIT" }, "node_modules/@redux-saga/delay-p": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz", - "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==", "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.2.1" @@ -3125,8 +2690,6 @@ }, "node_modules/@redux-saga/is": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz", - "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==", "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.2.1", @@ -3135,27 +2698,19 @@ }, "node_modules/@redux-saga/symbols": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz", - "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==", "license": "MIT" }, "node_modules/@redux-saga/types": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz", - "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==", "license": "MIT" }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", "dev": true, "license": "MIT" }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", @@ -3178,8 +2733,6 @@ }, "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": { "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -3198,8 +2751,6 @@ }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", "license": "MIT", "dependencies": { "serialize-javascript": "^6.0.1", @@ -3220,8 +2771,6 @@ }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -3242,14 +2791,10 @@ }, "node_modules/@rollup/pluginutils/node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -3260,15 +2805,11 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", - "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", "license": "Apache-2.0", "dependencies": { "ejs": "^3.1.6", @@ -3279,8 +2820,6 @@ }, "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": { "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" @@ -3288,8 +2827,6 @@ }, "node_modules/@testing-library/dom": { "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "peer": true, @@ -3309,8 +2846,6 @@ }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -3329,15 +2864,11 @@ }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, "node_modules/@testing-library/react": { "version": "12.1.5", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", - "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", "dev": true, "license": "MIT", "dependencies": { @@ -3355,8 +2886,6 @@ }, "node_modules/@testing-library/react-hooks": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", - "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", "dev": true, "license": "MIT", "dependencies": { @@ -3385,8 +2914,6 @@ }, "node_modules/@testing-library/react/node_modules/@testing-library/dom": { "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "dev": true, "license": "MIT", "dependencies": { @@ -3405,8 +2932,6 @@ }, "node_modules/@testing-library/react/node_modules/aria-query": { "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3415,8 +2940,6 @@ }, "node_modules/@testing-library/user-event": { "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -3429,15 +2952,11 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3450,8 +2969,6 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3460,8 +2977,6 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3471,8 +2986,6 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3481,8 +2994,6 @@ }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -3492,21 +3003,15 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", - "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", "peer": true, "dependencies": { @@ -3518,15 +3023,11 @@ }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "license": "MIT", "dependencies": { @@ -3535,8 +3036,6 @@ }, "node_modules/@types/istanbul-reports": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3545,20 +3044,14 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, "node_modules/@types/minimist": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "license": "MIT" }, "node_modules/@types/node": { "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", "peer": true, @@ -3568,20 +3061,14 @@ }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { "version": "17.0.90", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.90.tgz", - "integrity": "sha512-P9beVR/x06U9rCJzSxtENnOr4BrbJ6VrsrDTc+73TtHv9XHhryXKbjGRB+6oooB2r0G/pQkD/S4dHo/7jUfwFw==", "license": "MIT", "peer": true, "dependencies": { @@ -3592,8 +3079,6 @@ }, "node_modules/@types/react-dom": { "version": "17.0.26", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", - "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3602,8 +3087,6 @@ }, "node_modules/@types/react-redux": { "version": "7.1.34", - "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", - "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", "license": "MIT", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", @@ -3614,8 +3097,6 @@ }, "node_modules/@types/react-test-renderer": { "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", - "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3624,8 +3105,6 @@ }, "node_modules/@types/react-transition-group": { "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -3633,33 +3112,23 @@ }, "node_modules/@types/react/node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/@types/resolve": { "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, "node_modules/@types/scheduler": { "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", "license": "MIT" }, "node_modules/@types/semver": { "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true, "license": "MIT" }, "node_modules/@types/styled-jsx": { "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.9.tgz", - "integrity": "sha512-W/iTlIkGEyTBGTEvZCey8EgQlQ5l0DwMqi3iOXlLs2kyBwYTXHKEiU6IZ5EwoRwngL8/dGYuzezSup89ttVHLw==", "license": "MIT", "dependencies": { "@types/react": "*" @@ -3667,27 +3136,19 @@ }, "node_modules/@types/trusted-types": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, "node_modules/@types/uuid": { "version": "3.4.13", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.13.tgz", - "integrity": "sha512-pAeZeUbLE4Z9Vi9wsWV2bYPTweEHeJJy0G4pEjOA/FSvy1Ad5U5Km8iDV6TKre1mjBiVNfAdVHKruP8bAh4Q5A==", "license": "MIT" }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", - "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", "dev": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { @@ -3696,8 +3157,6 @@ }, "node_modules/@types/yargs": { "version": "15.0.20", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.20.tgz", - "integrity": "sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3706,15 +3165,11 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { @@ -3749,8 +3204,6 @@ }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3779,8 +3232,6 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3797,8 +3248,6 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { @@ -3825,8 +3274,6 @@ }, "node_modules/@typescript-eslint/types": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "license": "MIT", "engines": { @@ -3839,8 +3286,6 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3868,8 +3313,6 @@ }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3894,8 +3337,6 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "license": "MIT", "dependencies": { @@ -3912,15 +3353,11 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3940,8 +3377,6 @@ }, "node_modules/@vitest/coverage-v8": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", - "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", "dev": true, "license": "MIT", "dependencies": { @@ -3971,8 +3406,6 @@ }, "node_modules/@vitest/expect": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3989,8 +3422,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4016,8 +3447,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", "dev": true, "license": "MIT", "dependencies": { @@ -4029,8 +3458,6 @@ }, "node_modules/@vitest/runner": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4043,8 +3470,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4058,8 +3483,6 @@ }, "node_modules/@vitest/spy": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", "dev": true, "license": "MIT", "funding": { @@ -4068,8 +3491,6 @@ }, "node_modules/@vitest/utils": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", "dev": true, "license": "MIT", "dependencies": { @@ -4082,8 +3503,6 @@ }, "node_modules/acorn": { "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "peer": true, "bin": { @@ -4095,8 +3514,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4105,8 +3522,6 @@ }, "node_modules/agent-base": { "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { @@ -4115,8 +3530,6 @@ }, "node_modules/ajv": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4131,8 +3544,6 @@ }, "node_modules/ansi-align": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", "license": "ISC", "dependencies": { "string-width": "^4.1.0" @@ -4140,8 +3551,6 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -4155,8 +3564,6 @@ }, "node_modules/ansi-escapes/node_modules/type-fest": { "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -4167,8 +3574,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -4176,8 +3581,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4191,8 +3594,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4204,15 +3605,11 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4221,8 +3618,6 @@ }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4237,8 +3632,6 @@ }, "node_modules/array-includes": { "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4260,8 +3653,6 @@ }, "node_modules/array-union": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -4270,8 +3661,6 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4291,8 +3680,6 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -4310,8 +3697,6 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -4329,8 +3714,6 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -4346,8 +3729,6 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -4367,8 +3748,6 @@ }, "node_modules/arrify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4376,8 +3755,6 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -4386,15 +3763,11 @@ }, "node_modules/ast-types-flow": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", - "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4405,21 +3778,15 @@ }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, "license": "MIT" }, "node_modules/async": { "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, "node_modules/async-function": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4427,8 +3794,6 @@ }, "node_modules/at-least-node": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "license": "ISC", "engines": { "node": ">= 4.0.0" @@ -4436,8 +3801,6 @@ }, "node_modules/atomically": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", - "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", "license": "MIT", "dependencies": { "stubborn-fs": "^2.0.0", @@ -4446,8 +3809,6 @@ }, "node_modules/attr-accept": { "version": "2.2.5", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", - "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", "license": "MIT", "engines": { "node": ">=4" @@ -4455,8 +3816,6 @@ }, "node_modules/autosuggest-highlight": { "version": "3.3.4", - "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", - "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", "license": "MIT", "dependencies": { "remove-accents": "^0.4.2" @@ -4464,8 +3823,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4479,8 +3836,6 @@ }, "node_modules/axe-core": { "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4489,8 +3844,6 @@ }, "node_modules/axobject-query": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4499,8 +3852,6 @@ }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.7", @@ -4513,8 +3864,6 @@ }, "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4522,8 +3871,6 @@ }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", @@ -4535,8 +3882,6 @@ }, "node_modules/babel-plugin-polyfill-regenerator": { "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" @@ -4547,8 +3892,6 @@ }, "node_modules/babel-runtime": { "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", "license": "MIT", "dependencies": { "core-js": "^2.4.0", @@ -4557,14 +3900,10 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -4583,8 +3922,6 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -4592,8 +3929,6 @@ }, "node_modules/binary-extensions": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "license": "MIT", "engines": { "node": ">=8" @@ -4604,8 +3939,6 @@ }, "node_modules/bl": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4615,14 +3948,10 @@ }, "node_modules/blueimp-md5": { "version": "2.19.0", - "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", "license": "MIT" }, "node_modules/boxen": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", "license": "MIT", "dependencies": { "ansi-align": "^3.0.1", @@ -4643,8 +3972,6 @@ }, "node_modules/boxen/node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -4655,8 +3982,6 @@ }, "node_modules/boxen/node_modules/camelcase": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", "license": "MIT", "engines": { "node": ">=16" @@ -4667,8 +3992,6 @@ }, "node_modules/boxen/node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4679,14 +4002,10 @@ }, "node_modules/boxen/node_modules/emoji-regex": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/boxen/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -4702,8 +4021,6 @@ }, "node_modules/boxen/node_modules/strip-ansi": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -4717,8 +4034,6 @@ }, "node_modules/boxen/node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -4729,8 +4044,6 @@ }, "node_modules/brace-expansion": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4738,8 +4051,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4750,8 +4061,6 @@ }, "node_modules/browserslist": { "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -4784,8 +4093,6 @@ }, "node_modules/buffer": { "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -4808,14 +4115,10 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4832,8 +4135,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4845,8 +4146,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4861,14 +4160,10 @@ }, "node_modules/call-me-maybe": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -4877,8 +4172,6 @@ }, "node_modules/camelcase": { "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", "engines": { "node": ">=6" @@ -4886,8 +4179,6 @@ }, "node_modules/camelcase-keys": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", "license": "MIT", "dependencies": { "camelcase": "^5.3.1", @@ -4903,8 +4194,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", "funding": [ { "type": "opencollective", @@ -4923,8 +4212,6 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -4933,8 +4220,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4949,14 +4234,10 @@ }, "node_modules/chardet": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, "node_modules/chokidar": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -4979,8 +4260,6 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4989,10 +4268,14 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-boxes": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", "engines": { "node": ">=10" @@ -5003,8 +4286,6 @@ }, "node_modules/cli-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -5015,8 +4296,6 @@ }, "node_modules/cli-spinners": { "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "license": "MIT", "engines": { "node": ">=6" @@ -5027,8 +4306,6 @@ }, "node_modules/cli-width": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "license": "ISC", "engines": { "node": ">= 10" @@ -5036,8 +4313,6 @@ }, "node_modules/clone": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "license": "MIT", "engines": { "node": ">=0.8" @@ -5045,8 +4320,6 @@ }, "node_modules/clsx": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -5054,8 +4327,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5066,20 +4337,14 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, "node_modules/common-tags": { "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -5087,21 +4352,15 @@ }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", - "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/config-chain": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "license": "MIT", "dependencies": { "ini": "^1.3.4", @@ -5110,14 +4369,10 @@ }, "node_modules/config-chain/node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/configstore": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", - "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", "license": "BSD-2-Clause", "dependencies": { "atomically": "^2.0.3", @@ -5134,8 +4389,6 @@ }, "node_modules/connected-react-router": { "version": "6.9.3", - "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", - "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", "peer": true, "dependencies": { @@ -5156,22 +4409,15 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, "node_modules/core-js": { "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", "hasInstallScript": true, "license": "MIT" }, "node_modules/core-js-compat": { "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", - "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", "license": "MIT", "dependencies": { "browserslist": "^4.28.0" @@ -5183,8 +4429,6 @@ }, "node_modules/core-js-pure": { "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz", - "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5195,8 +4439,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5209,8 +4451,6 @@ }, "node_modules/crypto-random-string": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "license": "MIT", "engines": { "node": ">=8" @@ -5218,14 +4458,10 @@ }, "node_modules/css-mediaquery": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", - "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "license": "BSD" }, "node_modules/css-vendor": { "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.3", @@ -5234,15 +4470,11 @@ }, "node_modules/css.escape": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, "license": "MIT" }, "node_modules/cssstyle": { "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { @@ -5255,21 +4487,15 @@ }, "node_modules/csstype": { "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/data-urls": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { @@ -5282,8 +4508,6 @@ }, "node_modules/data-urls/node_modules/whatwg-mimetype": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -5292,8 +4516,6 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -5309,8 +4531,6 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -5326,8 +4546,6 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -5343,14 +4561,10 @@ }, "node_modules/date-fns": { "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5366,8 +4580,6 @@ }, "node_modules/decamelize": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5375,8 +4587,6 @@ }, "node_modules/decamelize-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "license": "MIT", "dependencies": { "decamelize": "^1.1.0", @@ -5391,8 +4601,6 @@ }, "node_modules/decamelize-keys/node_modules/map-obj": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5400,15 +4608,11 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, "node_modules/decode-uri-component": { "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -5416,8 +4620,6 @@ }, "node_modules/deep-equal": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, "license": "MIT", "dependencies": { @@ -5449,15 +4651,11 @@ }, "node_modules/deep-equal/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -5465,15 +4663,11 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5481,8 +4675,6 @@ }, "node_modules/defaults": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -5493,8 +4685,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5510,8 +4700,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -5527,8 +4715,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", "engines": { @@ -5537,8 +4723,6 @@ }, "node_modules/dir-glob": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5550,8 +4734,6 @@ }, "node_modules/dnd-core": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", - "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", "license": "MIT", "dependencies": { "@react-dnd/asap": "^4.0.0", @@ -5561,8 +4743,6 @@ }, "node_modules/doctrine": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5574,15 +4754,17 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT" }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -5591,14 +4773,10 @@ }, "node_modules/dom-helpers/node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", "engines": { "node": ">=20" @@ -5609,8 +4787,6 @@ }, "node_modules/dot-prop": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "license": "MIT", "dependencies": { "type-fest": "^4.18.2" @@ -5624,8 +4800,6 @@ }, "node_modules/dot-prop/node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -5634,10 +4808,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/downloadjs": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", + "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==", + "license": "MIT" + }, "node_modules/downshift": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", - "integrity": "sha512-mbUO9ZFhMGtksIeVWRFFjNOPN237VsUqZSEYi0VS0Wj38XNLzpgOBTUcUjdjFeB8KVgmrcRa6GGFkTbACpG6FA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", @@ -5651,14 +4829,10 @@ }, "node_modules/downshift/node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5671,14 +4845,10 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, "node_modules/ejs": { "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" @@ -5692,20 +4862,14 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, "node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5717,8 +4881,6 @@ }, "node_modules/error-ex": { "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -5726,8 +4888,6 @@ }, "node_modules/es-abstract": { "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -5794,8 +4954,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5803,8 +4961,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5812,8 +4968,6 @@ }, "node_modules/es-get-iterator": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, "license": "MIT", "dependencies": { @@ -5833,15 +4987,11 @@ }, "node_modules/es-get-iterator/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/es-iterator-helpers": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "dev": true, "license": "MIT", "dependencies": { @@ -5868,15 +5018,11 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5887,8 +5033,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5902,8 +5046,6 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -5915,8 +5057,6 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "license": "MIT", "dependencies": { "is-callable": "^1.2.7", @@ -5932,8 +5072,6 @@ }, "node_modules/esbuild": { "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5974,8 +5112,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -5983,8 +5119,6 @@ }, "node_modules/escape-goat": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "license": "MIT", "engines": { "node": ">=12" @@ -5995,8 +5129,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -6008,9 +5140,6 @@ }, "node_modules/eslint": { "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "peer": true, @@ -6066,8 +5195,6 @@ }, "node_modules/eslint-config-prettier": { "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -6082,8 +5209,6 @@ }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6112,8 +5237,6 @@ }, "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6122,8 +5245,6 @@ }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6133,8 +5254,6 @@ }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -6146,8 +5265,6 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -6179,8 +5296,6 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -6192,8 +5307,6 @@ }, "node_modules/eslint-plugin-react-refresh": { "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6202,8 +5315,6 @@ }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6213,8 +5324,6 @@ }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6226,8 +5335,6 @@ }, "node_modules/eslint-plugin-react/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -6239,8 +5346,6 @@ }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -6249,8 +5354,6 @@ }, "node_modules/eslint-scope": { "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6266,8 +5369,6 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6279,8 +5380,6 @@ }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6290,8 +5389,6 @@ }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -6303,8 +5400,6 @@ }, "node_modules/espree": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6321,8 +5416,6 @@ }, "node_modules/esprima": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -6334,8 +5427,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6347,8 +5438,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6360,8 +5449,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -6370,8 +5457,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -6380,8 +5465,6 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -6389,20 +5472,14 @@ }, "node_modules/eventemitter3": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "license": "MIT" }, "node_modules/exenv": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", - "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", "license": "BSD-3-Clause" }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6411,8 +5488,6 @@ }, "node_modules/external-editor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "license": "MIT", "dependencies": { "chardet": "^0.7.0", @@ -6425,8 +5500,6 @@ }, "node_modules/external-editor/node_modules/iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -6437,14 +5510,10 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -6460,8 +5529,6 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -6473,21 +5540,15 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -6502,8 +5563,6 @@ }, "node_modules/fastq": { "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -6512,8 +5571,6 @@ }, "node_modules/figures": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" @@ -6527,8 +5584,6 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -6536,8 +5591,6 @@ }, "node_modules/file-entry-cache": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", "dependencies": { @@ -6549,8 +5602,6 @@ }, "node_modules/file-selector": { "version": "0.1.19", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz", - "integrity": "sha512-kCWw3+Aai8Uox+5tHCNgMFaUdgidxvMnLWO6fM5sZ0hA2wlHP5/DHGF0ECe84BiB95qdJbKNEJhWKVDvMN+JDQ==", "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -6561,8 +5612,6 @@ }, "node_modules/filelist": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" @@ -6570,8 +5619,6 @@ }, "node_modules/filelist/node_modules/minimatch": { "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -6582,8 +5629,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6594,8 +5639,6 @@ }, "node_modules/final-form": { "version": "4.20.10", - "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", - "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", "peer": true, "dependencies": { @@ -6611,8 +5654,6 @@ }, "node_modules/final-form-arrays": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", - "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", "peer": true, "peerDependencies": { @@ -6621,8 +5662,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -6638,8 +5677,6 @@ }, "node_modules/flat-cache": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", "dependencies": { @@ -6653,15 +5690,11 @@ }, "node_modules/flatted": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -6675,8 +5708,6 @@ }, "node_modules/foreground-child": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6691,8 +5722,6 @@ }, "node_modules/foreground-child/node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" @@ -6703,8 +5732,6 @@ }, "node_modules/fs-extra": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -6718,16 +5745,11 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -6739,8 +5761,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6748,8 +5768,6 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -6768,8 +5786,6 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6777,8 +5793,6 @@ }, "node_modules/generator-function": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6786,8 +5800,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6795,8 +5807,6 @@ }, "node_modules/get-east-asian-width": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "license": "MIT", "engines": { "node": ">=18" @@ -6807,8 +5817,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6831,20 +5839,14 @@ }, "node_modules/get-node-dimensions": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz", - "integrity": "sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==", "license": "MIT" }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", "license": "ISC" }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6856,8 +5858,6 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6873,9 +5873,6 @@ }, "node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -6895,8 +5892,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -6908,8 +5903,6 @@ }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6919,8 +5912,6 @@ }, "node_modules/glob/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -6932,8 +5923,6 @@ }, "node_modules/global-directory": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "license": "MIT", "dependencies": { "ini": "4.1.1" @@ -6947,8 +5936,6 @@ }, "node_modules/globals": { "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6963,8 +5950,6 @@ }, "node_modules/globalthis": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -6979,8 +5964,6 @@ }, "node_modules/globby": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -7000,8 +5983,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7012,21 +5993,15 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, "node_modules/happy-dom": { "version": "20.3.3", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.3.3.tgz", - "integrity": "sha512-hM9gltmtQLfmWPqoPreUtRdP3nZCSzQEw7l/JC+up5CxquDykhYFKzIzoFFeVev3AGFEULNvsbE8fpZPgxUYEQ==", "dev": true, "license": "MIT", "peer": true, @@ -7044,8 +6019,6 @@ }, "node_modules/hard-rejection": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "license": "MIT", "engines": { "node": ">=6" @@ -7053,8 +6026,6 @@ }, "node_modules/has-bigints": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7065,8 +6036,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -7074,8 +6043,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7086,8 +6053,6 @@ }, "node_modules/has-proto": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" @@ -7101,8 +6066,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7113,8 +6076,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7128,8 +6089,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7140,8 +6099,6 @@ }, "node_modules/history": { "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", "peer": true, "dependencies": { @@ -7155,8 +6112,6 @@ }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" @@ -7164,20 +6119,14 @@ }, "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/hosted-git-info": { "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "license": "ISC" }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7189,15 +6138,11 @@ }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { @@ -7210,8 +6155,6 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { @@ -7224,14 +6167,10 @@ }, "node_modules/hyphenate-style-name": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", - "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, "node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7243,14 +6182,10 @@ }, "node_modules/idb": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, "node_modules/ieee754": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", @@ -7269,8 +6204,6 @@ }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -7279,15 +6212,11 @@ }, "node_modules/immutable": { "version": "4.3.8", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", - "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "license": "MIT", "optional": true }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7303,8 +6232,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -7313,8 +6240,6 @@ }, "node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "license": "MIT", "engines": { "node": ">=8" @@ -7322,8 +6247,6 @@ }, "node_modules/inflection": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", - "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -7331,9 +6254,6 @@ }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { @@ -7343,14 +6263,10 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -7358,8 +6274,6 @@ }, "node_modules/inquirer": { "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", @@ -7382,8 +6296,6 @@ }, "node_modules/internal-slot": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7396,8 +6308,6 @@ }, "node_modules/is-arguments": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", "dependencies": { @@ -7413,8 +6323,6 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7430,14 +6338,10 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "license": "MIT", "dependencies": { "async-function": "^1.0.0", @@ -7455,8 +6359,6 @@ }, "node_modules/is-bigint": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" @@ -7470,8 +6372,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -7482,8 +6382,6 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -7498,8 +6396,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7510,8 +6406,6 @@ }, "node_modules/is-core-module": { "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -7525,8 +6419,6 @@ }, "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7542,8 +6434,6 @@ }, "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7558,8 +6448,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7567,8 +6455,6 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -7582,8 +6468,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -7591,8 +6475,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -7610,8 +6492,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7622,14 +6502,10 @@ }, "node_modules/is-in-browser": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", "license": "MIT" }, "node_modules/is-in-ci": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -7643,8 +6519,6 @@ }, "node_modules/is-installed-globally": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", - "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "license": "MIT", "dependencies": { "global-directory": "^4.0.1", @@ -7659,8 +6533,6 @@ }, "node_modules/is-installed-globally/node_modules/is-path-inside": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "license": "MIT", "engines": { "node": ">=12" @@ -7671,8 +6543,6 @@ }, "node_modules/is-interactive": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "license": "MIT", "engines": { "node": ">=8" @@ -7680,8 +6550,6 @@ }, "node_modules/is-map": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7690,16 +6558,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-mobile": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz", + "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==", + "license": "MIT" + }, "node_modules/is-module": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "license": "MIT" }, "node_modules/is-negative-zero": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7710,8 +6580,6 @@ }, "node_modules/is-npm": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", - "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -7722,8 +6590,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7731,8 +6597,6 @@ }, "node_modules/is-number-object": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -7747,8 +6611,6 @@ }, "node_modules/is-obj": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7756,8 +6618,6 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", "engines": { @@ -7766,8 +6626,6 @@ }, "node_modules/is-plain-obj": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7775,15 +6633,11 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7800,8 +6654,6 @@ }, "node_modules/is-regexp": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7809,8 +6661,6 @@ }, "node_modules/is-set": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7821,8 +6671,6 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -7836,8 +6684,6 @@ }, "node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { "node": ">=8" @@ -7848,8 +6694,6 @@ }, "node_modules/is-string": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -7864,8 +6708,6 @@ }, "node_modules/is-symbol": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7881,8 +6723,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -7896,8 +6736,6 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "license": "MIT", "engines": { "node": ">=10" @@ -7908,8 +6746,6 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7920,8 +6756,6 @@ }, "node_modules/is-weakref": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -7935,8 +6769,6 @@ }, "node_modules/is-weakset": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -7951,20 +6783,14 @@ }, "node_modules/isarray": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7973,8 +6799,6 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7988,8 +6812,6 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8002,8 +6824,6 @@ }, "node_modules/iterator.prototype": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -8020,8 +6840,6 @@ }, "node_modules/jackspeak": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -8035,8 +6853,6 @@ }, "node_modules/jake": { "version": "10.9.4", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", - "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", @@ -8052,14 +6868,10 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -8071,8 +6883,6 @@ }, "node_modules/jsdom": { "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { @@ -8111,8 +6921,6 @@ }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -8121,8 +6929,6 @@ }, "node_modules/jsesc": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8133,28 +6939,19 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-ref-parser": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-7.1.3.tgz", - "integrity": "sha512-/Lmyl0PW27dOmCO03PI339+1gs4Z2PlqIyUgzIOtoRp08zkkMCB30TRbdppbPO7WWzZX0uT98HqkDiZSujkmbA==", - "deprecated": "Please switch to @apidevtools/json-schema-ref-parser", "license": "MIT", "dependencies": { "call-me-maybe": "^1.0.1", @@ -8164,8 +6961,6 @@ }, "node_modules/json-schema-ref-parser/node_modules/argparse": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -8173,8 +6968,6 @@ }, "node_modules/json-schema-ref-parser/node_modules/js-yaml": { "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -8186,21 +6979,15 @@ }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -8211,8 +6998,6 @@ }, "node_modules/jsonexport": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-2.5.2.tgz", - "integrity": "sha512-4joNLCxxUAmS22GN3GA5os/MYFnq8oqXOKvoCymmcT0MPz/QPZ5eA+Fh5sIPxUji45RKq8DdQ1yoKq91p4E9VA==", "license": "Apache-2.0", "bin": { "jsonexport": "bin/jsonexport.js" @@ -8220,8 +7005,6 @@ }, "node_modules/jsonfile": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -8232,8 +7015,6 @@ }, "node_modules/jsonpointer": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", - "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8241,8 +7022,6 @@ }, "node_modules/jss": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", - "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8257,8 +7036,6 @@ }, "node_modules/jss-plugin-camel-case": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", - "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8268,8 +7045,6 @@ }, "node_modules/jss-plugin-default-unit": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", - "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8278,8 +7053,6 @@ }, "node_modules/jss-plugin-global": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", - "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8288,8 +7061,6 @@ }, "node_modules/jss-plugin-nested": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", - "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8299,8 +7070,6 @@ }, "node_modules/jss-plugin-props-sort": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", - "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8309,8 +7078,6 @@ }, "node_modules/jss-plugin-rule-value-function": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", - "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8320,8 +7087,6 @@ }, "node_modules/jss-plugin-vendor-prefixer": { "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", - "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -8331,14 +7096,10 @@ }, "node_modules/jss/node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/jsx-ast-utils": { "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8353,8 +7114,6 @@ }, "node_modules/jwt-decode": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", "engines": { "node": ">=18" @@ -8362,8 +7121,6 @@ }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -8372,8 +7129,6 @@ }, "node_modules/kind-of": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8381,8 +7136,6 @@ }, "node_modules/ky": { "version": "1.14.2", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.2.tgz", - "integrity": "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==", "license": "MIT", "engines": { "node": ">=18" @@ -8393,15 +7146,11 @@ }, "node_modules/language-subtag-registry": { "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/language-tags": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", "dependencies": { @@ -8413,8 +7162,6 @@ }, "node_modules/latest-version": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", - "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", "license": "MIT", "dependencies": { "package-json": "^10.0.0" @@ -8428,8 +7175,6 @@ }, "node_modules/leven": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "license": "MIT", "engines": { "node": ">=6" @@ -8437,8 +7182,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8451,14 +7194,10 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -8473,45 +7212,31 @@ }, "node_modules/lodash": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, "node_modules/lodash.isequalwith": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", - "integrity": "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "license": "MIT" }, "node_modules/lodash.throttle": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -8526,8 +7251,6 @@ }, "node_modules/loose-envify": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -8538,8 +7261,6 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -8547,8 +7268,6 @@ }, "node_modules/lz-string": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", "bin": { @@ -8557,8 +7276,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8567,8 +7284,6 @@ }, "node_modules/magicast": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", "dev": true, "license": "MIT", "dependencies": { @@ -8579,8 +7294,6 @@ }, "node_modules/make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -8595,8 +7308,6 @@ }, "node_modules/map-obj": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "license": "MIT", "engines": { "node": ">=8" @@ -8607,8 +7318,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -8616,8 +7325,6 @@ }, "node_modules/meow": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", - "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", "license": "MIT", "dependencies": { "@types/minimist": "^1.2.0", @@ -8641,8 +7348,6 @@ }, "node_modules/meow/node_modules/type-fest": { "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -8653,8 +7358,6 @@ }, "node_modules/merge2": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { @@ -8663,8 +7366,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -8677,8 +7378,6 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", "engines": { "node": ">=6" @@ -8686,8 +7385,6 @@ }, "node_modules/min-indent": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "license": "MIT", "engines": { "node": ">=4" @@ -8695,8 +7392,6 @@ }, "node_modules/minimatch": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "license": "ISC", "dependencies": { @@ -8711,8 +7406,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8720,8 +7413,6 @@ }, "node_modules/minimist-options": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "license": "MIT", "dependencies": { "arrify": "^1.0.1", @@ -8734,8 +7425,6 @@ }, "node_modules/minipass": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -8743,8 +7432,6 @@ }, "node_modules/moment": { "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", "peer": true, "engines": { @@ -8753,20 +7440,14 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mute-stream": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -8784,19 +7465,32 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/navidrome-music-player": { - "resolved": "../../react-music-player", - "link": true + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.2.tgz", + "integrity": "sha512-k7RXHOOKHeJRCsfmpmQ+TkErndckFfvYMjzwVAKZvViw2PL9ubKWziPfruHZVQr4FiJd2oYKEuTNiWZgAK87CA==", + "license": "MIT", + "dependencies": { + "@react-icons/all-files": "^4.1.0", + "classnames": "^2.3.1", + "downloadjs": "^1.4.7", + "is-mobile": "^2.2.2", + "prop-types": "^15.7.2", + "rc-slider": "^9.7.2", + "rc-switch": "^3.2.2", + "react-draggable": "^4.4.3", + "sortablejs": "^1.13.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } }, "node_modules/node-polyglot": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.6.0.tgz", - "integrity": "sha512-ZZFkaYzIfGfBvSM6QhA9dM8EEaUJOVewzGSRcXWbJELXDj0lajAtKaENCYxvF5yE+TgHg6NQb0CmgYMsMdcNJQ==", "license": "BSD-2-Clause", "dependencies": { "hasown": "^2.0.2", @@ -8809,14 +7503,10 @@ }, "node_modules/node-releases": { "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/normalize-package-data": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^2.1.4", @@ -8827,8 +7517,6 @@ }, "node_modules/normalize-package-data/node_modules/resolve": { "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -8847,8 +7535,6 @@ }, "node_modules/normalize-package-data/node_modules/semver": { "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "license": "ISC", "bin": { "semver": "bin/semver" @@ -8856,8 +7542,6 @@ }, "node_modules/normalize-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8865,15 +7549,11 @@ }, "node_modules/nwsapi": { "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8881,8 +7561,6 @@ }, "node_modules/object-hash": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "license": "MIT", "engines": { "node": ">= 6" @@ -8890,8 +7568,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -8902,8 +7578,6 @@ }, "node_modules/object-is": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8919,8 +7593,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -8928,8 +7600,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8948,8 +7618,6 @@ }, "node_modules/object.entries": { "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8963,8 +7631,6 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8982,8 +7648,6 @@ }, "node_modules/object.values": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -9001,8 +7665,6 @@ }, "node_modules/obug": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -9012,8 +7674,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -9022,8 +7682,6 @@ }, "node_modules/onetime": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -9037,14 +7695,10 @@ }, "node_modules/ono": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz", - "integrity": "sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA==", "license": "MIT" }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -9061,8 +7715,6 @@ }, "node_modules/ora": { "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -9084,8 +7736,6 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9093,8 +7743,6 @@ }, "node_modules/own-keys": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", @@ -9110,8 +7758,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9126,8 +7772,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -9142,8 +7786,6 @@ }, "node_modules/p-try": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", "engines": { "node": ">=6" @@ -9151,8 +7793,6 @@ }, "node_modules/package-json": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", - "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", "license": "MIT", "dependencies": { "ky": "^1.2.0", @@ -9169,14 +7809,10 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -9188,8 +7824,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -9206,8 +7840,6 @@ }, "node_modules/parse5": { "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { @@ -9219,8 +7851,6 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -9232,8 +7862,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", "engines": { "node": ">=8" @@ -9241,8 +7869,6 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { @@ -9251,8 +7877,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -9260,14 +7884,10 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, "node_modules/path-scurry": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -9282,8 +7902,6 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9291,8 +7909,6 @@ }, "node_modules/path-to-regexp": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", - "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "license": "MIT", "dependencies": { "isarray": "0.0.1" @@ -9300,8 +7916,6 @@ }, "node_modules/path-type": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, "license": "MIT", "engines": { @@ -9310,21 +7924,15 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -9335,14 +7943,10 @@ }, "node_modules/popper.js": { "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", "license": "MIT" }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9350,8 +7954,6 @@ }, "node_modules/postcss": { "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -9379,8 +7981,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -9389,8 +7989,6 @@ }, "node_modules/prettier": { "version": "3.8.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", - "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", "bin": { @@ -9405,8 +8003,6 @@ }, "node_modules/pretty-bytes": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", - "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", "dev": true, "license": "MIT", "engines": { @@ -9418,8 +8014,6 @@ }, "node_modules/pretty-format": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9433,8 +8027,6 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -9446,8 +8038,6 @@ }, "node_modules/prop-types": { "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "peer": true, "dependencies": { @@ -9458,20 +8048,14 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/proto-list": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { "node": ">=6" @@ -9479,8 +8063,6 @@ }, "node_modules/pupa": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", - "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -9494,8 +8076,6 @@ }, "node_modules/query-string": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", - "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", "license": "MIT", "dependencies": { "decode-uri-component": "^0.2.0", @@ -9508,8 +8088,6 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -9529,8 +8107,6 @@ }, "node_modules/quick-lru": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "license": "MIT", "engines": { "node": ">=8" @@ -9538,8 +8114,6 @@ }, "node_modules/ra-core": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", - "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", "peer": true, "dependencies": { @@ -9568,14 +8142,10 @@ }, "node_modules/ra-core/node_modules/classnames": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", - "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", "license": "MIT" }, "node_modules/ra-core/node_modules/inflection": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", - "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", "engines": [ "node >= 0.4.0" ], @@ -9583,8 +8153,6 @@ }, "node_modules/ra-data-json-server": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.19.12.tgz", - "integrity": "sha512-SEa0ueZd9LUG6iuPnHd+MHWf7BTgLKjx3Eky16VvTsqf6ueHkMU8AZiH1pHzrdxV6ku5VL34MCYWVSIbm2iDnw==", "license": "MIT", "dependencies": { "query-string": "^5.1.1", @@ -9593,8 +8161,6 @@ }, "node_modules/ra-i18n-polyglot": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.19.12.tgz", - "integrity": "sha512-7VkNybY+RYVL5aDf8MdefYpRMkaELOjSXx7rrRY7PzVwmQzVe5ESoKBcH4Cob2M8a52pAlXY32dwmA3dZ91l/Q==", "license": "MIT", "dependencies": { "node-polyglot": "^2.2.2", @@ -9603,8 +8169,6 @@ }, "node_modules/ra-language-english": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.19.12.tgz", - "integrity": "sha512-aYY0ma74eXLuflPT9iXEQtVEDZxebw1NiQZ5pPGiBCpsq+hoiDWuzerLU13OdBHbySD5FHLuk89SkyAdfMtUaQ==", "license": "MIT", "dependencies": { "ra-core": "^3.19.12" @@ -9612,8 +8176,6 @@ }, "node_modules/ra-test": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/ra-test/-/ra-test-3.19.12.tgz", - "integrity": "sha512-SX6oi+VPADIeQeQlGWUVj2kgEYgLbizpzYMq+oacCmnAqvHezwnQ2MXrLDRK6C56YIl+t8DyY/ipYBiRPZnHbA==", "dev": true, "license": "MIT", "dependencies": { @@ -9633,8 +8195,6 @@ }, "node_modules/ra-test/node_modules/@testing-library/dom": { "version": "7.31.2", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", - "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9653,8 +8213,6 @@ }, "node_modules/ra-test/node_modules/@testing-library/react": { "version": "11.2.7", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz", - "integrity": "sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA==", "dev": true, "license": "MIT", "dependencies": { @@ -9671,15 +8229,11 @@ }, "node_modules/ra-test/node_modules/@types/aria-query": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", "dev": true, "license": "MIT" }, "node_modules/ra-test/node_modules/aria-query": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -9692,15 +8246,11 @@ }, "node_modules/ra-test/node_modules/classnames": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", - "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", "dev": true, "license": "MIT" }, "node_modules/ra-test/node_modules/pretty-format": { "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", "dev": true, "license": "MIT", "dependencies": { @@ -9715,8 +8265,6 @@ }, "node_modules/ra-ui-materialui": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.19.12.tgz", - "integrity": "sha512-8Zz88r5yprmUxOw9/F0A/kjjVmFMb2n+sjpel8fuOWtS6y++JWonDsvTwo4yIuSF9mC0fht3f/hd2KEHQdmj6Q==", "license": "MIT", "dependencies": { "autosuggest-highlight": "^3.1.1", @@ -9752,20 +8300,14 @@ }, "node_modules/ra-ui-materialui/node_modules/classnames": { "version": "2.2.6", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", - "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", "license": "MIT" }, "node_modules/ra-ui-materialui/node_modules/dompurify": { "version": "2.5.9", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", - "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/ra-ui-materialui/node_modules/inflection": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", - "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", "engines": [ "node >= 0.4.0" ], @@ -9773,8 +8315,6 @@ }, "node_modules/randombytes": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" @@ -9782,8 +8322,6 @@ }, "node_modules/rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -9795,16 +8333,134 @@ "rc": "cli.js" } }, + "node_modules/rc-align": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", + "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "dom-align": "^1.7.0", + "rc-util": "^5.26.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-slider": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.7.5.tgz", + "integrity": "sha512-LV/MWcXFjco1epPbdw1JlLXlTgmWpB9/Y/P2yinf8Pg3wElHxA9uajN21lJiWtZjf5SCUekfSP6QMJfDo4t1hg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-tooltip": "^5.0.1", + "rc-util": "^5.16.1", + "shallowequal": "^1.1.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-3.2.2.tgz", + "integrity": "sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-util": "^5.0.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.3.1.tgz", + "integrity": "sha512-e6H0dMD38EPaSPD2XC8dRfct27VvT2TkPdoBSuNl3RRZ5tspiY/c5xYEmGC0IrABvMBgque4Mr2SMZuliCvoiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "^2.3.1", + "rc-trigger": "^5.3.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-trigger": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", + "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.6", + "rc-align": "^4.0.0", + "rc-motion": "^2.0.0", + "rc-util": "^5.19.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9812,8 +8468,6 @@ }, "node_modules/react": { "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", "peer": true, "dependencies": { @@ -9826,8 +8480,6 @@ }, "node_modules/react-admin": { "version": "3.19.12", - "resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.19.12.tgz", - "integrity": "sha512-LanWS3Yjie7n5GZI8v7oP73DSvQyCeZD0dpkC65IC0+UOhkInxa1zedJc8CyD3+ZwlgVC+CGqi6jQ1fo73Cdqw==", "license": "MIT", "dependencies": { "@material-ui/core": "^4.12.1", @@ -9855,8 +8507,6 @@ }, "node_modules/react-dnd": { "version": "14.0.5", - "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", - "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", "license": "MIT", "dependencies": { "@react-dnd/invariant": "^2.0.0", @@ -9885,8 +8535,6 @@ }, "node_modules/react-dnd-html5-backend": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", - "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", "license": "MIT", "dependencies": { "dnd-core": "14.0.1" @@ -9894,8 +8542,6 @@ }, "node_modules/react-dom": { "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", "peer": true, "dependencies": { @@ -9909,18 +8555,28 @@ }, "node_modules/react-drag-listview": { "version": "0.1.9", - "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.9.tgz", - "integrity": "sha512-/OsYevKtCUlw4FhJIfZPH7INHEmyl89sSC5COzonHW5Z2c8rHg4DNYFnUxOyqH+65o7sHweL13oaf6wr7dFvPA==", "license": "MIT", "dependencies": { "babel-runtime": "^6.26.0", "prop-types": "^15.5.8" } }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-dropzone": { "version": "10.2.2", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.2.tgz", - "integrity": "sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA==", "license": "MIT", "dependencies": { "attr-accept": "^2.0.0", @@ -9936,8 +8592,6 @@ }, "node_modules/react-error-boundary": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", - "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", "dev": true, "license": "MIT", "dependencies": { @@ -9953,8 +8607,6 @@ }, "node_modules/react-final-form": { "version": "6.5.9", - "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", - "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", "peer": true, "dependencies": { @@ -9971,8 +8623,6 @@ }, "node_modules/react-final-form-arrays": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", - "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", "peer": true, "dependencies": { @@ -9987,8 +8637,6 @@ }, "node_modules/react-ga": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", - "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", "license": "Apache-2.0", "peerDependencies": { "prop-types": "^15.6.0", @@ -9997,8 +8645,6 @@ }, "node_modules/react-hotkeys": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", - "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", "license": "ISC", "dependencies": { "prop-types": "^15.6.1" @@ -10009,8 +8655,6 @@ }, "node_modules/react-icons": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", "license": "MIT", "peerDependencies": { "react": "*" @@ -10018,9 +8662,6 @@ }, "node_modules/react-image-lightbox": { "version": "5.1.4", - "resolved": "https://registry.npmjs.org/react-image-lightbox/-/react-image-lightbox-5.1.4.tgz", - "integrity": "sha512-kTiAODz091bgT7SlWNHab0LSMZAPJtlNWDGKv7pLlLY1krmf7FuG1zxE0wyPpeA8gPdwfr3cu6sPwZRqWsc3Eg==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", "dependencies": { "prop-types": "^15.7.2", @@ -10033,20 +8674,14 @@ }, "node_modules/react-is": { "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, "node_modules/react-measure": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-2.5.2.tgz", - "integrity": "sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.2.0", @@ -10061,8 +8696,6 @@ }, "node_modules/react-modal": { "version": "3.16.3", - "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz", - "integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==", "license": "MIT", "dependencies": { "exenv": "^1.2.0", @@ -10077,8 +8710,6 @@ }, "node_modules/react-redux": { "version": "7.2.9", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", - "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", "peer": true, "dependencies": { @@ -10103,8 +8734,6 @@ }, "node_modules/react-refresh": { "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -10113,8 +8742,6 @@ }, "node_modules/react-router": { "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", - "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", "peer": true, "dependencies": { @@ -10134,8 +8761,6 @@ }, "node_modules/react-router-dom": { "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", - "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", "peer": true, "dependencies": { @@ -10153,14 +8778,10 @@ }, "node_modules/react-router/node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/react-transition-group": { "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", @@ -10175,8 +8796,6 @@ }, "node_modules/read-pkg": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.0", @@ -10190,8 +8809,6 @@ }, "node_modules/read-pkg-up": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", "license": "MIT", "dependencies": { "find-up": "^4.1.0", @@ -10207,8 +8824,6 @@ }, "node_modules/read-pkg-up/node_modules/find-up": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -10220,8 +8835,6 @@ }, "node_modules/read-pkg-up/node_modules/locate-path": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -10232,8 +8845,6 @@ }, "node_modules/read-pkg-up/node_modules/p-limit": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -10247,8 +8858,6 @@ }, "node_modules/read-pkg-up/node_modules/p-locate": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -10259,8 +8868,6 @@ }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -10268,8 +8875,6 @@ }, "node_modules/read-pkg/node_modules/type-fest": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -10277,8 +8882,6 @@ }, "node_modules/readable-stream": { "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -10291,8 +8894,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -10303,8 +8904,6 @@ }, "node_modules/redent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "license": "MIT", "dependencies": { "indent-string": "^4.0.0", @@ -10316,8 +8915,6 @@ }, "node_modules/redux": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", "peer": true, "dependencies": { @@ -10326,8 +8923,6 @@ }, "node_modules/redux-saga": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", - "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", "peer": true, "dependencies": { @@ -10336,8 +8931,6 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10358,14 +8951,10 @@ }, "node_modules/regenerate": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -10376,14 +8965,10 @@ }, "node_modules/regenerator-runtime": { "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10402,8 +8987,6 @@ }, "node_modules/regexpu-core": { "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -10419,8 +9002,6 @@ }, "node_modules/registry-auth-token": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", - "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^3.0.2" @@ -10431,8 +9012,6 @@ }, "node_modules/registry-url": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", "license": "MIT", "dependencies": { "rc": "1.2.8" @@ -10446,14 +9025,10 @@ }, "node_modules/regjsgen": { "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", "license": "MIT" }, "node_modules/regjsparser": { "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -10464,14 +9039,10 @@ }, "node_modules/remove-accents": { "version": "0.4.4", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", - "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==", "license": "MIT" }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10479,20 +9050,14 @@ }, "node_modules/reselect": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", - "integrity": "sha512-b/6tFZCmRhtBMa4xGqiiRp9jh9Aqi2A687Lo265cN0/QohJQEBPiQ52f4QB6i0eF3yp3hmLL21LSGBcML2dlxA==", "license": "MIT" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, "node_modules/resolve": { "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", "dependencies": { @@ -10509,8 +9074,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -10519,14 +9082,10 @@ }, "node_modules/resolve-pathname": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", "license": "MIT" }, "node_modules/restore-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -10538,8 +9097,6 @@ }, "node_modules/reusify": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -10549,8 +9106,6 @@ }, "node_modules/rifm": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz", - "integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1" @@ -10561,9 +9116,6 @@ }, "node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -10579,8 +9131,6 @@ "node_modules/rollup": { "name": "@rollup/wasm-node", "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.55.2.tgz", - "integrity": "sha512-oWKZLjYwTihnTeINcNenxIIDfeotkQ2GAjFJPe7aYsMONrwDwQQXcAl3Qv0qON7Hdc8RTsFomq22zotm/i6VVQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -10600,15 +9150,11 @@ }, "node_modules/rrweb-cssom": { "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, "node_modules/run-async": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -10616,8 +9162,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -10640,8 +9184,6 @@ }, "node_modules/rxjs": { "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" @@ -10652,14 +9194,10 @@ }, "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, "node_modules/safe-array-concat": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10677,14 +9215,10 @@ }, "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -10703,8 +9237,6 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10719,14 +9251,10 @@ }, "node_modules/safe-push-apply/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10742,14 +9270,10 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -10761,8 +9285,6 @@ }, "node_modules/scheduler": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -10771,15 +9293,11 @@ }, "node_modules/seamless-immutable": { "version": "7.1.4", - "resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz", - "integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==", "license": "BSD-3-Clause", "optional": true }, "node_modules/semver": { "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10790,8 +9308,6 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -10799,8 +9315,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -10816,8 +9330,6 @@ }, "node_modules/set-function-name": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -10831,8 +9343,6 @@ }, "node_modules/set-proto": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10843,10 +9353,14 @@ "node": ">= 0.4" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10857,8 +9371,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -10866,8 +9378,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10885,8 +9395,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10901,8 +9409,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10919,8 +9425,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10938,21 +9442,15 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/slash": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { @@ -10961,15 +9459,16 @@ }, "node_modules/smob": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "license": "MIT" + }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", "license": "MIT" }, "node_modules/source-map": { "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", "license": "BSD-3-Clause", "dependencies": { "whatwg-url": "^7.0.0" @@ -10980,8 +9479,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -10990,8 +9487,6 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -11000,8 +9495,6 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -11009,8 +9502,6 @@ }, "node_modules/source-map/node_modules/tr46": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "license": "MIT", "dependencies": { "punycode": "^2.1.0" @@ -11018,14 +9509,10 @@ }, "node_modules/source-map/node_modules/webidl-conversions": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause" }, "node_modules/source-map/node_modules/whatwg-url": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "license": "MIT", "dependencies": { "lodash.sortby": "^4.7.0", @@ -11035,15 +9522,10 @@ }, "node_modules/sourcemap-codec": { "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, "node_modules/spdx-correct": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -11052,14 +9534,10 @@ }, "node_modules/spdx-exceptions": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -11068,34 +9546,24 @@ }, "node_modules/spdx-license-ids": { "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -11107,8 +9575,6 @@ }, "node_modules/strict-uri-encode": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", - "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11116,8 +9582,6 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -11125,8 +9589,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11140,8 +9602,6 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11154,20 +9614,14 @@ }, "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/string.prototype.includes": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -11181,8 +9635,6 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -11208,8 +9660,6 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -11219,8 +9669,6 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -11240,8 +9688,6 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -11258,8 +9704,6 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -11275,8 +9719,6 @@ }, "node_modules/stringify-object": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", "license": "BSD-2-Clause", "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", @@ -11289,8 +9731,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11302,8 +9742,6 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11314,8 +9752,6 @@ }, "node_modules/strip-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", - "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", "license": "MIT", "engines": { "node": ">=10" @@ -11323,8 +9759,6 @@ }, "node_modules/strip-indent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "license": "MIT", "dependencies": { "min-indent": "^1.0.0" @@ -11335,8 +9769,6 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -11348,8 +9780,6 @@ }, "node_modules/stubborn-fs": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", "license": "MIT", "dependencies": { "stubborn-utils": "^1.0.1" @@ -11357,14 +9787,10 @@ }, "node_modules/stubborn-utils": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -11375,8 +9801,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -11387,15 +9811,11 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/temp-dir": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", "license": "MIT", "engines": { "node": ">=8" @@ -11403,8 +9823,6 @@ }, "node_modules/tempy": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", - "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", "license": "MIT", "dependencies": { "is-stream": "^2.0.0", @@ -11421,8 +9839,6 @@ }, "node_modules/tempy/node_modules/type-fest": { "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -11433,8 +9849,6 @@ }, "node_modules/terser": { "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -11451,40 +9865,28 @@ }, "node_modules/text-table": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, "license": "MIT" }, "node_modules/through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, "node_modules/tiny-invariant": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, "node_modules/tiny-warning": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, "license": "MIT", "engines": { @@ -11493,8 +9895,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11510,8 +9910,6 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -11528,8 +9926,6 @@ }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "peer": true, @@ -11542,8 +9938,6 @@ }, "node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -11552,8 +9946,6 @@ }, "node_modules/tldts": { "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11565,15 +9957,11 @@ }, "node_modules/tldts-core": { "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, "node_modules/tmp": { "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" @@ -11584,8 +9972,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -11596,8 +9982,6 @@ }, "node_modules/tough-cookie": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -11609,8 +9993,6 @@ }, "node_modules/tr46": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { @@ -11622,8 +10004,6 @@ }, "node_modules/trim-newlines": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", "license": "MIT", "engines": { "node": ">=8" @@ -11631,8 +10011,6 @@ }, "node_modules/ts-api-utils": { "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, "license": "MIT", "engines": { @@ -11644,14 +10022,10 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -11663,8 +10037,6 @@ }, "node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -11676,8 +10048,6 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -11690,8 +10060,6 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -11709,8 +10077,6 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -11730,8 +10096,6 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -11750,8 +10114,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -11765,8 +10127,6 @@ }, "node_modules/typescript-compare": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", - "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", "license": "MIT", "dependencies": { "typescript-logic": "^0.0.0" @@ -11774,14 +10134,10 @@ }, "node_modules/typescript-logic": { "version": "0.0.0", - "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", - "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", "license": "MIT" }, "node_modules/typescript-tuple": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", - "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", "license": "MIT", "dependencies": { "typescript-compare": "^0.0.2" @@ -11789,8 +10145,6 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -11807,15 +10161,11 @@ }, "node_modules/undici-types": { "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "license": "MIT", "engines": { "node": ">=4" @@ -11823,8 +10173,6 @@ }, "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -11836,8 +10184,6 @@ }, "node_modules/unicode-match-property-value-ecmascript": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "license": "MIT", "engines": { "node": ">=4" @@ -11845,8 +10191,6 @@ }, "node_modules/unicode-property-aliases-ecmascript": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "license": "MIT", "engines": { "node": ">=4" @@ -11854,8 +10198,6 @@ }, "node_modules/unique-string": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", "license": "MIT", "dependencies": { "crypto-random-string": "^2.0.0" @@ -11866,8 +10208,6 @@ }, "node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -11875,8 +10215,6 @@ }, "node_modules/upath": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", "license": "MIT", "engines": { "node": ">=4", @@ -11885,8 +10223,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -11915,8 +10251,6 @@ }, "node_modules/update-notifier": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", - "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", "license": "BSD-2-Clause", "dependencies": { "boxen": "^8.0.1", @@ -11939,8 +10273,6 @@ }, "node_modules/update-notifier/node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -11951,8 +10283,6 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -11960,14 +10290,10 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -11979,8 +10305,6 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -11989,14 +10313,10 @@ }, "node_modules/value-equal": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", "license": "MIT" }, "node_modules/vite": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "peer": true, @@ -12071,8 +10391,6 @@ }, "node_modules/vite-plugin-pwa": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", - "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", "dev": true, "license": "MIT", "dependencies": { @@ -12102,8 +10420,6 @@ }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -12120,8 +10436,6 @@ }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "peer": true, @@ -12134,8 +10448,6 @@ }, "node_modules/vitest": { "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", "peer": true, @@ -12213,8 +10525,6 @@ }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -12226,8 +10536,6 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -12239,8 +10547,6 @@ }, "node_modules/warning": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" @@ -12248,8 +10554,6 @@ }, "node_modules/wcwidth": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -12257,8 +10561,6 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -12267,9 +10569,6 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -12281,8 +10580,6 @@ }, "node_modules/whatwg-mimetype": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", "engines": { @@ -12291,8 +10588,6 @@ }, "node_modules/whatwg-url": { "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { @@ -12305,14 +10600,10 @@ }, "node_modules/when-exit": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", "license": "MIT" }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -12326,8 +10617,6 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -12345,8 +10634,6 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12372,14 +10659,10 @@ }, "node_modules/which-builtin-type/node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -12396,8 +10679,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -12417,8 +10698,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -12434,8 +10713,6 @@ }, "node_modules/widest-line": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -12449,8 +10726,6 @@ }, "node_modules/widest-line/node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -12461,14 +10736,10 @@ }, "node_modules/widest-line/node_modules/emoji-regex": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/widest-line/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -12484,8 +10755,6 @@ }, "node_modules/widest-line/node_modules/strip-ansi": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -12499,8 +10768,6 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -12509,8 +10776,6 @@ }, "node_modules/workbox-background-sync": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", - "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -12519,8 +10784,6 @@ }, "node_modules/workbox-broadcast-update": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", - "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12528,8 +10791,6 @@ }, "node_modules/workbox-build": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", - "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", "license": "MIT", "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", @@ -12576,8 +10837,6 @@ }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", - "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", "license": "MIT", "dependencies": { "json-schema": "^0.4.0", @@ -12593,8 +10852,6 @@ }, "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.10.4", @@ -12616,8 +10873,6 @@ }, "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^3.1.0", @@ -12629,8 +10884,6 @@ }, "node_modules/workbox-build/node_modules/@rollup/pluginutils": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", "license": "MIT", "dependencies": { "@types/estree": "0.0.39", @@ -12646,14 +10899,10 @@ }, "node_modules/workbox-build/node_modules/@types/estree": { "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "license": "MIT" }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -12669,14 +10918,10 @@ }, "node_modules/workbox-build/node_modules/estree-walker": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", "license": "MIT" }, "node_modules/workbox-build/node_modules/glob": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -12698,14 +10943,10 @@ }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/workbox-build/node_modules/magic-string": { "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" @@ -12713,8 +10954,6 @@ }, "node_modules/workbox-build/node_modules/minimatch": { "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" @@ -12728,8 +10967,6 @@ }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "license": "MIT", "engines": { "node": ">=6" @@ -12740,8 +10977,6 @@ }, "node_modules/workbox-build/node_modules/rollup": { "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", "peer": true, "bin": { @@ -12756,8 +10991,6 @@ }, "node_modules/workbox-cacheable-response": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", - "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12765,8 +10998,6 @@ }, "node_modules/workbox-cli": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-cli/-/workbox-cli-7.4.0.tgz", - "integrity": "sha512-BTc9CbW+aXMyIxBdW2mX+dLYHwTeCdKARX0zpjLvR/mZ2ho/7d9XWckwgFGLQRsJfcxml5WngNqp1PG7+qa9Ug==", "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -12792,8 +11023,6 @@ }, "node_modules/workbox-cli/node_modules/glob": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -12815,8 +11044,6 @@ }, "node_modules/workbox-cli/node_modules/minimatch": { "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" @@ -12830,8 +11057,6 @@ }, "node_modules/workbox-cli/node_modules/pretty-bytes": { "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "license": "MIT", "engines": { "node": ">=6" @@ -12842,14 +11067,10 @@ }, "node_modules/workbox-core": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", - "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", "license": "MIT" }, "node_modules/workbox-expiration": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", - "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -12858,8 +11079,6 @@ }, "node_modules/workbox-google-analytics": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", - "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", "license": "MIT", "dependencies": { "workbox-background-sync": "7.4.0", @@ -12870,8 +11089,6 @@ }, "node_modules/workbox-navigation-preload": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", - "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12879,8 +11096,6 @@ }, "node_modules/workbox-precaching": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", - "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -12890,8 +11105,6 @@ }, "node_modules/workbox-range-requests": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", - "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12899,8 +11112,6 @@ }, "node_modules/workbox-recipes": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", - "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", "license": "MIT", "dependencies": { "workbox-cacheable-response": "7.4.0", @@ -12913,8 +11124,6 @@ }, "node_modules/workbox-routing": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", - "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12922,8 +11131,6 @@ }, "node_modules/workbox-strategies": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", - "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12931,8 +11138,6 @@ }, "node_modules/workbox-streams": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", - "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -12941,14 +11146,10 @@ }, "node_modules/workbox-sw": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", - "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", "license": "MIT" }, "node_modules/workbox-window": { "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", - "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", @@ -12957,8 +11158,6 @@ }, "node_modules/wrap-ansi": { "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -12975,8 +11174,6 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -12992,8 +11189,6 @@ }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -13004,8 +11199,6 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -13016,14 +11209,10 @@ }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -13039,8 +11228,6 @@ }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -13054,15 +11241,11 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/ws": { "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -13083,8 +11266,6 @@ }, "node_modules/xdg-basedir": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", "license": "MIT", "engines": { "node": ">=12" @@ -13095,8 +11276,6 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -13105,21 +11284,15 @@ }, "node_modules/xmlchars": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, "node_modules/yargs-parser": { "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -13131,8 +11304,6 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { From f39d75e7d230be7cf31dd9d84ce8987bf4651835 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:20:10 +0000 Subject: [PATCH 111/596] fix(subsonic): never omit duration for AlbumID3 (#5217) --- .../Responses AlbumWithSongsID3 with data should match .JSON | 1 + .../Responses AlbumWithSongsID3 with data should match .XML | 2 +- ...Responses AlbumWithSongsID3 without data should match .JSON | 3 ++- .../Responses AlbumWithSongsID3 without data should match .XML | 2 +- ...umWithSongsID3 without data should match OpenSubsonic .JSON | 1 + ...bumWithSongsID3 without data should match OpenSubsonic .XML | 2 +- server/subsonic/responses/responses.go | 2 +- server/subsonic/responses/responses_test.go | 2 +- 8 files changed, 9 insertions(+), 6 deletions(-) diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 6ed471e8b..07678407a 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -8,6 +8,7 @@ "id": "1", "name": "album", "artist": "artist", + "duration": 292, "genre": "rock", "userRating": 4, "genres": [ diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index 67dcf6bd7..f7b23cb4e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -1,5 +1,5 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index fbeded48a..14e96939e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -6,6 +6,7 @@ "openSubsonic": true, "album": { "id": "", - "name": "" + "name": "", + "duration": 0 } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 159967c1d..868265347 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON index 758aef0cb..446368fa5 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -7,6 +7,7 @@ "album": { "id": "", "name": "", + "duration": 0, "userRating": 0, "genres": [], "musicBrainzId": "", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML index 159967c1d..868265347 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b70f3b128..b9a39b6f9 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -250,7 +250,7 @@ type AlbumID3 struct { ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` - Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + Duration int32 `xml:"duration,attr" json:"duration"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ccf15afe3..15f2da9c6 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -288,7 +288,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { album := AlbumID3{ - Id: "1", Name: "album", Artist: "artist", Genre: "rock", + Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock", } album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, From ad92b752bec101ef13a88fcace674579ce90b094 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 18:34:13 -0400 Subject: [PATCH 112/596] chore(deps): update dependencies for go-sqlite3, golang.org/x packages Signed-off-by: Deluan --- go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 28fae78e3..487b57ef7 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mattn/go-sqlite3 v1.14.34 + github.com/mattn/go-sqlite3 v1.14.37 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.28.1 @@ -58,12 +58,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.36.0 - golang.org/x/net v0.51.0 + golang.org/x/image v0.37.0 + golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 - golang.org/x/term v0.40.0 - golang.org/x/text v0.34.0 + golang.org/x/term v0.41.0 + golang.org/x/text v0.35.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -80,13 +80,13 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.8.3 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect @@ -134,10 +134,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index fd02f812e..d7b16d9d3 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= -github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -96,8 +96,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= @@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= 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.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/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= @@ -319,19 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= -golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -343,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -372,8 +372,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -382,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -394,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From b013b71ba9fd77999cea8f7465f2faeaa8a8ebb4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 19:39:00 -0400 Subject: [PATCH 113/596] fix(server): clean up uploaded artist images during GC When artists are purged during garbage collection, any custom uploaded cover images were left orphaned on disk. Modified purgeEmpty() to query for uploaded_image filenames before the bulk DELETE, then remove the corresponding files from disk afterwards. Image cleanup is best-effort to avoid failing the GC if a file is already missing or inaccessible. Also populated album_artists entries in the persistence test suite setup to reflect the actual album-artist relationships from test data, ensuring purgeEmpty() doesn't inadvertently delete shared test artists. --- persistence/artist_repository.go | 30 +++++++++- persistence/artist_repository_test.go | 86 +++++++++++++++++++++++++++ persistence/persistence_suite_test.go | 22 +++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 7f3d61540..e75a0e58c 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -4,7 +4,9 @@ import ( "cmp" "context" "encoding/json" + "errors" "fmt" + "os" "slices" "strings" "time" @@ -12,6 +14,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -315,7 +318,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles } func (r *artistRepository) purgeEmpty() error { - del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)") + orphanFilter := "id not in (select artist_id from album_artists)" + + // Collect uploaded image filenames before deleting + sel := Select("uploaded_image").From(r.tableName). + Where(orphanFilter). + Where("uploaded_image != ''") + var imageFiles []string + if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("collecting artist images for cleanup: %w", err) + } + + // Delete orphan artists + del := Delete(r.tableName).Where(orphanFilter) c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("purging empty artists: %w", err) @@ -323,6 +338,19 @@ func (r *artistRepository) purgeEmpty() error { if c > 0 { log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) } + + if len(imageFiles) == 0 { + return nil + } + + // Best-effort cleanup of uploaded image files + log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) + for _, filename := range imageFiles { + path := model.UploadedImagePath(consts.EntityArtist, filename) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err) + } + } return nil } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 90e449e8d..e2904466c 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,11 +3,14 @@ package persistence import ( "context" "encoding/json" + "os" + "path/filepath" "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" @@ -829,6 +832,89 @@ var _ = Describe("ArtistRepository", func() { }) }) }) + + Describe("purgeEmpty", func() { + var repo *artistRepository + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + }) + + // Helper to create an artist image file on disk and return its path + createImageFile := func(filename string) string { + dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + path := filepath.Join(dir, filename) + Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed()) + return path + } + + It("removes uploaded image files for purged artists", func() { + // Create an orphan artist (not in album_artists) with an uploaded image + orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"} + Expect(repo.Put(&orphanArtist)).To(Succeed()) + imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg") + + Expect(repo.purgeEmpty()).To(Succeed()) + + // Artist should be gone from DB + exists, err := repo.Exists("orphan-with-image") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + + // Image file should be removed from disk + _, err = os.Stat(imgPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("handles missing image files gracefully", func() { + // Artist has UploadedImage set but no actual file on disk + orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"} + Expect(repo.Put(&orphanArtist)).To(Succeed()) + + Expect(repo.purgeEmpty()).To(Succeed()) + + // Artist should be gone from DB + exists, err := repo.Exists("orphan-no-file") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("does not delete images for artists that are kept", func() { + // Create an artist with an uploaded image AND an album_artists entry so it won't be purged + keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"} + Expect(repo.Put(&keptArtist)).To(Succeed()) + imgPath := createImageFile("kept-artist_Kept_Artist.jpg") + + // Insert an album_artists record to keep this artist from being purged + _, err := repo.executeSQL(squirrel.Insert("album_artists"). + SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""})) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) + _ = repo.delete(squirrel.Eq{"id": "kept-artist"}) + }) + + Expect(repo.purgeEmpty()).To(Succeed()) + + // Artist should still exist (check directly, bypassing library filter) + var ids []string + err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(HaveLen(1)) + + // Image file should still be on disk + _, err = os.Stat(imgPath) + Expect(err).ToNot(HaveOccurred()) + }) + }) }) // Helper function to create an artist with proper library association. diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 0ee1570a1..3ed443129 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/db" @@ -211,6 +212,27 @@ var _ = BeforeSuite(func() { } } + // Populate album_artists based on the AlbumArtistID relationships in testAlbums + artistIDs := map[string]bool{} + for _, a := range testArtists { + artistIDs[a.ID] = true + } + for i := range testAlbums { + a := testAlbums[i] + if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] { + continue + } + _, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{ + "album_id": a.ID, + "artist_id": a.AlbumArtistID, + "role": "artist", + "sub_role": "", + })) + if err != nil { + panic(err) + } + } + mr := NewMediaFileRepository(ctx, conn) for i := range testSongs { err := mr.Put(&testSongs[i]) From d2a54243a88daed840b946b78ca3612ba8818ce3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 20:24:21 -0400 Subject: [PATCH 114/596] fix(ui): prevent layout flash on album grid during cover loading Added aspect-ratio: 1 to the cover container so it reserves the correct square dimensions immediately on first render, before react-measure reports the container width. Previously, contentRect.bounds.width started as undefined/0, causing images to render with zero height and producing a brief flash of compressed tiles before the measurement callback fired. --- ui/src/album/AlbumGridView.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index b7db39730..c0d88d50e 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -94,6 +94,11 @@ const useStyles = makeStyles( ) const useCoverStyles = makeStyles({ + coverContainer: { + width: '100%', + aspectRatio: '1', + overflow: 'hidden', + }, cover: { display: 'inline-block', width: '100%', @@ -150,7 +155,7 @@ const Cover = withContentRect('bounds')(({ }, []) return ( -
+
Date: Tue, 17 Mar 2026 20:49:35 -0400 Subject: [PATCH 115/596] fix(ui): hide pagination during album list loading Added a custom AlbumListPagination component that returns null while the list is loading, preventing stale pagination controls from appearing alongside the Loading spinner when navigating to the Random album view. --- ui/src/album/AlbumList.jsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index f10f8dbd3..d00d97701 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -10,6 +10,7 @@ import { ReferenceArrayInput, ReferenceInput, SearchInput, + useListContext, usePermissions, useRefresh, useTranslate, @@ -174,6 +175,14 @@ const AlbumListTitle = ({ albumListType }) => { return } +const AlbumListPagination = (props) => { + const { loading } = useListContext() + if (loading) { + return null + } + return <Pagination {...props} /> +} + const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { @@ -234,7 +243,7 @@ const AlbumList = (props) => { actions={<AlbumListActions />} filters={<AlbumFilter />} perPage={perPage} - pagination={<Pagination rowsPerPageOptions={perPageOptions} />} + pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />} title={<AlbumListTitle albumListType={albumListType} />} > {albumView.grid ? ( From b5164c61abfd7485bf51b41709a7bfe6c0d46db7 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 17 Mar 2026 21:34:00 -0400 Subject: [PATCH 116/596] build(worktree): add script for setting up git worktrees Signed-off-by: Deluan <deluan@navidrome.org> --- .gitignore | 3 ++- Makefile | 33 +++++++++++++++++++++++ scripts/setup-worktree.sh | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100755 scripts/setup-worktree.sh diff --git a/.gitignore b/.gitignore index db8c0abcf..73475a53a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ AGENTS.md *.wasm *.ndp openspec/ -go.work* \ No newline at end of file +go.work* +.worktrees/ \ No newline at end of file diff --git a/Makefile b/Makefile index c9c88f506..3bad5b620 100644 --- a/Makefile +++ b/Makefile @@ -233,6 +233,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc .PHONY: get-music +########################################## +#### Worktrees + +WORKTREES_DIR := .worktrees + +wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1] + @if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi + @mkdir -p $(WORKTREES_DIR) + @echo "Creating worktree for branch '${name}'..." + @git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \ + git worktree add $(WORKTREES_DIR)/${name} ${name} + @if [ -n "${go}" ]; then \ + ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \ + else \ + ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \ + fi + @echo "\nWorktree ready at $(WORKTREES_DIR)/${name}" + @echo " cd $(WORKTREES_DIR)/${name}" +.PHONY: wt + +rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name + @if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi + @if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi + @echo "Removing worktree '${name}'..." + @git worktree remove --force $(WORKTREES_DIR)/${name} + @echo "Worktree '${name}' removed." + @echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}" +.PHONY: rm-wt + +ls-wt: ##@Worktrees List all active git worktrees + @git worktree list +.PHONY: ls-wt + ########################################## #### Miscellaneous diff --git a/scripts/setup-worktree.sh b/scripts/setup-worktree.sh new file mode 100755 index 000000000..65113f3b2 --- /dev/null +++ b/scripts/setup-worktree.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Setup a git worktree for Navidrome development. +# This script is called automatically by `make worktree` and by Claude Code's +# worktree isolation, but can also be run standalone: +# +# ./scripts/setup-worktree.sh <worktree-path> [--go-only] +# +# Options: +# --go-only Skip frontend (npm) setup. Useful for agents working only on Go code. +# +set -euo pipefail + +WORKTREE_PATH="${1:?Usage: $0 <worktree-path> [--go-only]}" +GO_ONLY="${2:-}" + +# Resolve the main worktree root (where the original repo lives) +MAIN_WORKTREE="$(git -C "$WORKTREE_PATH" worktree list --porcelain | head -1 | sed 's/^worktree //')" + +if [ ! -d "$WORKTREE_PATH" ]; then + echo "ERROR: Worktree path does not exist: $WORKTREE_PATH" + exit 1 +fi + +cd "$WORKTREE_PATH" + +echo "==> Setting up worktree at $WORKTREE_PATH" + +# 1. Download Go dependencies +echo "==> Downloading Go dependencies..." +go mod download + +# 2. Install frontend dependencies (unless --go-only) +if [ "$GO_ONLY" != "--go-only" ]; then + echo "==> Installing frontend dependencies..." + (cd ui && npm ci --prefer-offline --no-audit 2>/dev/null || npm ci) +else + echo "==> Skipping frontend setup (--go-only)" +fi + +# 3. Create required directories +mkdir -p data + +# 4. Copy navidrome.toml from main worktree if it exists and not already present +if [ ! -f navidrome.toml ] && [ -f "$MAIN_WORKTREE/navidrome.toml" ]; then + echo "==> Copying navidrome.toml from main worktree..." + cp "$MAIN_WORKTREE/navidrome.toml" navidrome.toml +fi + +# 5. Copy existing database from main worktree (already migrated and scanned) +# This is much faster than running migrations + a full scan from scratch. +if [ ! -f data/navidrome.db ] && [ -f "$MAIN_WORKTREE/data/navidrome.db" ]; then + echo "==> Copying database from main worktree (pre-migrated, pre-scanned)..." + cp "$MAIN_WORKTREE/data/navidrome.db" data/navidrome.db +fi + +echo "==> Worktree setup complete: $WORKTREE_PATH" From 31d94acfe7fbbe09759d91d409043bbb4c1004cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 18 Mar 2026 08:03:46 -0400 Subject: [PATCH 117/596] fix(scanner): widen WASM panic recovery to cover tag/property reading (#5223) * fix(scanner): widen WASM panic recovery to cover tag/property reading The panic recovery in gotaglib's extractMetadata was only inside openFile(), which covers taglib.OpenStream(). Panics from f.AllTags() and f.Properties() (e.g. readString crashes on malformed files) were uncaught, crashing the scanner subprocess with exit status 2. Move the recover() up to extractMetadata() so it covers the entire tag reading lifecycle, matching the CGO taglib wrapper's approach. Fixes #5220 * fix(scanner): use consistent log key "filePath" in panic recovery * fix(scanner): include stack trace in WASM panic recovery log Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/gotaglib/gotaglib.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 9b71cb462..7ea98a442 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -58,7 +58,20 @@ func (e extractor) Version() string { return "unknown" } -func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { +func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) { + // Recover from panics in the WASM runtime that can occur during any taglib + // operation (opening, reading tags, or reading properties). This catches crashes + // from malformed files or WASM runtime issues (e.g., wazero mmap failures on + // hardened systems with MemoryDenyWriteExecute=true). + debug.SetPanicOnFault(true) + defer func() { + if r := recover(); r != nil { + log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r) + debug.PrintStack() + err = fmt.Errorf("WASM runtime panic: %v", r) + } + }() + f, close, err := e.openFile(filePath) if err != nil { log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err) @@ -112,16 +125,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { // openFile opens the file at filePath using the extractor's filesystem. // It returns a TagLib File handle and a cleanup function to close resources. func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) { - // Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory - // on hardened systems like NixOS with MemoryDenyWriteExecute=true) - debug.SetPanicOnFault(true) - defer func() { - if r := recover(); r != nil { - log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r) - err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r) - } - }() - // Open the file from the filesystem file, err := e.fs.Open(filePath) if err != nil { From 00b8fbd7894a0124973fc77404845a2d0f703e1e Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 18 Mar 2026 07:59:10 -0400 Subject: [PATCH 118/596] feat(artwork): add UIThumbnailSize constant and update cache warmer to pre-cache thumbnails Signed-off-by: Deluan <deluan@navidrome.org> --- consts/consts.go | 1 + core/artwork/cache_warmer.go | 17 +++++++++-------- core/artwork/cache_warmer_test.go | 31 ++++++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/consts/consts.go b/consts/consts.go index 9f4387ae6..38b277eab 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -71,6 +71,7 @@ const ( PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAvatar = "logo-192x192.png" UICoverArtSize = 300 + UIThumbnailSize = 80 DefaultUIVolume = 100 DefaultUISearchDebounceMs = 200 diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index f13820d00..83c98c806 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -142,14 +142,15 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true) - if err != nil { - return fmt.Errorf("caching id='%s': %w", id, err) - } - defer r.Close() - _, err = io.Copy(io.Discard, r) - if err != nil { - return err + for _, size := range []int{consts.UICoverArtSize, consts.UIThumbnailSize} { + r, _, err := a.artwork.Get(ctx, id, size, true) + if err != nil { + return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) + } + defer r.Close() + if _, err = io.Copy(io.Discard, r); err != nil { + return err + } } return nil } diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index abf4f259a..47e187f41 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "strings" + "sync" "sync/atomic" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" @@ -173,20 +175,47 @@ var _ = Describe("CacheWarmer", func() { return len(cw.buffer) }).Should(Equal(0)) }) + + It("pre-caches both UICoverArtSize and UIThumbnailSize", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + + sizes := aw.getCachedSizes() + Expect(sizes).To(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) + }) }) }) type mockArtwork struct { - err error + err error + mu sync.Mutex + cachedSizes []int } func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { if m.err != nil { return nil, time.Time{}, m.err } + m.mu.Lock() + m.cachedSizes = append(m.cachedSizes, size) + m.mu.Unlock() return io.NopCloser(strings.NewReader("test")), time.Now(), nil } +func (m *mockArtwork) getCachedSizes() []int { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]int, len(m.cachedSizes)) + copy(result, m.cachedSizes) + return result +} + func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { return m.Get(ctx, model.ArtworkID{}, size, square) } From 3f7226d253cdde7bdd948f364acf61c64946bc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 18 Mar 2026 12:39:03 -0400 Subject: [PATCH 119/596] fix(server): improve transcoding failure diagnostics and error responses (#5227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): capture ffmpeg stderr and warn on empty transcoded output When ffmpeg fails during transcoding (e.g., missing codec like libopus), the error was silently discarded because stderr was sent to io.Discard and the HTTP response returned 200 OK with a 0-byte body. - Capture ffmpeg stderr in a bounded buffer (4KB) and include it in the error message when the process exits with a non-zero status code - Log a warning when transcoded output is 0 bytes, guiding users to check codec support and enable Trace logging for details - Remove log level guard so transcoding errors are always logged, not just at Debug level Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): return proper error responses for empty transcoded output Instead of returning HTTP 200 with 0-byte body when transcoding fails, return a Subsonic error response (for stream/download/getTranscodeStream) or HTTP 500 (for public shared streams). This gives clients a clear signal that the request failed rather than a misleading empty success. Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): add tests for empty transcoded stream error responses Add E2E tests verifying that stream and download endpoints return Subsonic error responses when transcoding produces empty output. Extend spyStreamer with SimulateEmptyStream and SimulateError fields to support failure injection in tests. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(server): extract stream serving logic into Stream.Serve method Extract the duplicated non-seekable stream serving logic (header setup, estimateContentLength, HEAD draining, io.Copy with error/empty detection) from server/subsonic/stream.go and server/public/handle_streams.go into a single Stream.Serve method on core/stream. Both callers now delegate to it, eliminating ~30 lines of near-identical code. * fix(server): return 200 with empty body for stream/download on empty transcoded output Don't return a Subsonic error response when transcoding produces empty output on stream/download endpoints — just log the error and return 200 with an empty body. The getTranscodeStream and public share endpoints still return HTTP 500 for empty output. Stream.Serve now returns (int64, error) so callers can check the byte count. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/ffmpeg/ffmpeg.go | 42 +++++++++++++++---- core/ffmpeg/ffmpeg_test.go | 40 ++++++++++++++++++ core/stream/media_streamer.go | 58 +++++++++++++++++++++++++-- server/e2e/e2e_suite_test.go | 17 ++++++-- server/e2e/subsonic_stream_test.go | 55 +++++++++++++++++++++++++ server/e2e/subsonic_transcode_test.go | 31 ++++++++++++++ server/public/handle_streams.go | 32 ++------------- server/subsonic/stream.go | 54 ++++--------------------- server/subsonic/transcode.go | 6 ++- 9 files changed, 244 insertions(+), 91 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 2b06250a1..33d6733c8 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "bytes" "context" "encoding/json" "errors" @@ -258,10 +259,11 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) ( type ffCmd struct { *io.PipeReader - out *io.PipeWriter - args []string - cmd *exec.Cmd - input io.Reader // optional stdin source + out *io.PipeWriter + args []string + cmd *exec.Cmd + input io.Reader // optional stdin source + stderr *bytes.Buffer } func (j *ffCmd) start(ctx context.Context) error { @@ -270,10 +272,12 @@ func (j *ffCmd) start(ctx context.Context) error { if j.input != nil { cmd.Stdin = j.input } + j.stderr = &bytes.Buffer{} + stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096} if log.IsGreaterOrEqualTo(log.LevelTrace) { - cmd.Stderr = os.Stderr + cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter) } else { - cmd.Stderr = io.Discard + cmd.Stderr = stderrWriter } j.cmd = cmd @@ -287,7 +291,11 @@ func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { - _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) + errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) + if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { + errMsg += ": " + stderrOutput + } + _ = j.out.CloseWithError(errors.New(errMsg)) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) } @@ -296,6 +304,26 @@ func (j *ffCmd) wait() { _ = j.out.Close() } +// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached. +// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage. +type limitedWriter struct { + buf *bytes.Buffer + limit int +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + n := len(p) + remaining := w.limit - w.buf.Len() + if remaining <= 0 { + return n, nil // Discard but report success to avoid breaking the writer + } + if len(p) > remaining { + p = p[:remaining] + } + w.buf.Write(p) + return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter +} + // formatCodecMap maps target format to ffmpeg codec flag. var formatCodecMap = map[string]string{ "mp3": "libmp3lame", diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 23e419219..04663828f 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -604,6 +604,46 @@ var _ = Describe("ffmpeg", func() { }) }) + Context("stderr capture", func() { + BeforeEach(func() { + if runtime.GOOS == "windows" { + Skip("stderr capture tests use /bin/sh, skipping on Windows") + } + }) + + It("should include stderr in error when process fails", func() { + ff := &ffmpeg{} + ctx := GinkgoT().Context() + + // Directly call start() with a bash command that writes to stderr and fails + args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"} + stream, err := ff.start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + buf := make([]byte, 1024) + _, err = stream.Read(buf) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("codec not found: libopus")) + }) + + It("should not include stderr in error when process succeeds", func() { + ff := &ffmpeg{} + ctx := GinkgoT().Context() + + // Command that writes to stderr but exits successfully + args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"} + stream, err := ff.start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + buf := make([]byte, 1024) + n, err := stream.Read(buf) + Expect(err).ToNot(HaveOccurred()) + Expect(string(buf[:n])).To(Equal("output")) + }) + }) + Context("with mock process behavior", func() { var longRunningCmd string BeforeEach(func() { diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index 062a13884..de03b4d2f 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -5,8 +5,9 @@ import ( "fmt" "io" "mime" + "net/http" "os" - "strings" + "strconv" "sync" "time" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/req" ) type MediaStreamer interface { @@ -51,6 +53,9 @@ func (j *streamJob) Key() string { return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) } +// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding) +// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about +// the streaming request and whether the transcoding result was served from cache or not. func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) { var format string var bitRate int @@ -133,14 +138,59 @@ func (s *Stream) EstimatedContentLength() int { return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) } -// NewTestStream creates a Stream for testing purposes. -func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream { +// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent +// (supporting range requests). For non-seekable streams it writes directly and logs any errors. +// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written +// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). +// Empty output (0 bytes, no error) is logged but not treated as an error. +func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { + if s.Seekable() { + http.ServeContent(w, r, s.Name(), s.ModTime(), s) + return -1, nil + } + + w.Header().Set("Accept-Ranges", "none") + w.Header().Set("Content-Type", s.ContentType()) + + if req.Params(r).BoolOr("estimateContentLength", false) { + length := strconv.Itoa(s.EstimatedContentLength()) + log.Trace(ctx, "Estimated content-length", "contentLength", length) + w.Header().Set("Content-Length", length) + } + + if r.Method == http.MethodHead { + go func() { _, _ = io.Copy(io.Discard, s) }() + return 0, nil + } + + id := s.mf.ID + c, err := io.Copy(w, s) + if err != nil { + log.Error(ctx, "Error sending transcoded file", "id", id, err) + if c == 0 { + w.Header().Del("Content-Length") + return 0, fmt.Errorf("sending transcoded file: %w", err) + } + return c, nil + } + if c == 0 { + log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ + "Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details", + "id", id, "format", s.ContentType()) + } else { + log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c) + } + return c, nil +} + +// NewStream creates a non-seekable Stream from the given components. +func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream { return &Stream{ ctx: context.Background(), mf: mf, format: format, bitRate: bitRate, - ReadCloser: io.NopCloser(strings.NewReader("")), + ReadCloser: r, } } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index cb851debf..262a5ed36 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "testing" "testing/fstest" "time" @@ -287,18 +288,28 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool // spyStreamer captures the Request passed to NewStream for test assertions, // then returns a minimal fake Stream so the handler completes without error. type spyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // When set, NewStream returns this error + SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) } func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { s.LastRequest = req s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } format := req.Format if format == "" || format == "raw" { format = mf.Suffix } - return stream.NewTestStream(mf, format, req.BitRate), nil + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + r := io.NopCloser(strings.NewReader(content)) + return stream.NewStream(mf, format, req.BitRate, r), nil } // noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go index d144dc4eb..6a11c1740 100644 --- a/server/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -1,9 +1,12 @@ package e2e import ( + "encoding/json" + "errors" "net/http" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -124,4 +127,56 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) }) }) + + Describe("stream creation failure", func() { + BeforeEach(func() { + streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'") + }) + AfterEach(func() { + streamerSpy.SimulateError = nil + }) + + It("returns a Subsonic error for stream endpoint", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) + Expect(wrapper.Subsonic.Error).ToNot(BeNil()) + }) + + It("returns a Subsonic error for download endpoint", func() { + conf.Server.EnableDownloads = true + w := doRawReq("download", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) + Expect(wrapper.Subsonic.Error).ToNot(BeNil()) + }) + }) + + Describe("empty transcoded output", func() { + BeforeEach(func() { + streamerSpy.SimulateEmptyStream = true + }) + AfterEach(func() { + streamerSpy.SimulateEmptyStream = false + }) + + It("returns 200 with empty body for stream endpoint", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("returns 200 with empty body for download endpoint", func() { + conf.Server.EnableDownloads = true + w := doRawReq("download", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + }) }) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index a9a180dc8..f134448df 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -1,6 +1,7 @@ package e2e import ( + "errors" "net/http" "time" @@ -602,6 +603,36 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { mf.UpdatedAt = originalUpdatedAt Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) }) + + It("returns 500 when stream creation fails", func() { + // Get a valid decision token + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Simulate streamer failure (e.g., ffmpeg missing codec) + streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'") + defer func() { streamerSpy.SimulateError = nil }() + + w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("returns 500 when transcoded stream is empty", func() { + // Get a valid decision token + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Simulate ffmpeg producing 0 bytes + streamerSpy.SimulateEmptyStream = true + defer func() { streamerSpy.SimulateEmptyStream = false }() + + w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) }) Describe("round-trip: decision then stream", func() { diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 6cdf8b44a..daa09c375 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -2,7 +2,6 @@ package public import ( "errors" - "io" "net/http" "strconv" @@ -54,34 +53,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) - if stream.Seekable() { - http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) - } else { - // If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length - w.Header().Set("Accept-Ranges", "none") - w.Header().Set("Content-Type", stream.ContentType()) - - estimateContentLength := p.BoolOr("estimateContentLength", false) - - // if Client requests the estimated content-length, send it - if estimateContentLength { - length := strconv.Itoa(stream.EstimatedContentLength()) - log.Trace(ctx, "Estimated content-length", "contentLength", length) - w.Header().Set("Content-Length", length) - } - - if r.Method == http.MethodHead { - go func() { _, _ = io.Copy(io.Discard, stream) }() - } else { - c, err := io.Copy(w, stream) - if log.IsGreaterOrEqualTo(log.LevelDebug) { - if err != nil { - log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err) - } else { - log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c) - } - } - } + n, err := stream.Serve(ctx, w, r) + if err != nil || n == 0 { + http.Error(w, "internal error", http.StatusInternalServerError) } } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index ebebb97f1..b49af2b24 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,15 +1,12 @@ package subsonic import ( - "context" "fmt" - "io" "net/http" "strconv" "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -17,38 +14,6 @@ import ( "github.com/navidrome/navidrome/utils/req" ) -func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *stream.Stream, id string) { - if stream.Seekable() { - http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) - } else { - // If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length - w.Header().Set("Accept-Ranges", "none") - w.Header().Set("Content-Type", stream.ContentType()) - - estimateContentLength := req.Params(r).BoolOr("estimateContentLength", false) - - // if Client requests the estimated content-length, send it - if estimateContentLength { - length := strconv.Itoa(stream.EstimatedContentLength()) - log.Trace(ctx, "Estimated content-length", "contentLength", length) - w.Header().Set("Content-Length", length) - } - - if r.Method == http.MethodHead { - go func() { _, _ = io.Copy(io.Discard, stream) }() - } else { - c, err := io.Copy(w, stream) - if log.IsGreaterOrEqualTo(log.LevelDebug) { - if err != nil { - log.Error(ctx, "Error sending transcoded file", "id", id, err) - } else { - log.Trace(ctx, "Success sending transcode file", "id", id, "size", c) - } - } - } - } -} - func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) @@ -81,9 +46,8 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) - api.serveStream(ctx, w, r, stream, id) - - return nil, nil + _, err = stream.Serve(ctx, w, r) + return nil, err } func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { @@ -151,20 +115,18 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) w.Header().Set("Content-Disposition", disposition) - api.serveStream(ctx, w, r, stream, id) - return nil, nil + _, err = stream.Serve(ctx, w, r) + return nil, err case *model.Album: setHeaders(v.Name) - err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) case *model.Artist: setHeaders(v.Name) - err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) case *model.Playlist: setHeaders(v.Name) - err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) default: - err = model.ErrNotFound + return nil, model.ErrNotFound } - - return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 64e74d460..4e494b324 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -395,7 +395,9 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* w.Header().Set("X-Content-Type-Options", "nosniff") - api.serveStream(ctx, w, r, stream, mediaID) - + n, err := stream.Serve(ctx, w, r) + if err != nil || n == 0 { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } return nil, nil } From ba8d4278900066aee27319998a0c549a6941572d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 18 Mar 2026 18:57:33 -0400 Subject: [PATCH 120/596] feat(ui): add cover art support for internet radio stations (#5229) * feat(artwork): add KindRadioArtwork and EntityRadio constant * feat(model): add UploadedImage field and artwork methods to Radio * feat(model): add Radio to GetEntityByID lookup chain * feat(db): add uploaded_image column to radio table * feat(artwork): add radio artwork reader with uploaded image fallback * feat(api): add radio image upload/delete endpoints * feat(ui): add radio artwork ID prefix to getCoverArtUrl * feat(ui): add cover art display and upload to RadioEdit * feat(ui): add cover art thumbnails to radio list * feat(ui): prefer artwork URL in radio player helper * refactor: remove redundant code in radio artwork - Remove duplicate Avatar rendering in RadioList by reusing CoverArtField - Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put) * refactor(ui): extract shared useImageLoadingState hook Move image loading/error/lightbox state management into a shared useImageLoadingState hook in common/. Consolidates duplicated logic from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views. * feat(ui): use radio placeholder icon when no uploaded image Remove album placeholder fallback from radio artwork reader so radios without an uploaded image return ErrUnavailable. On the frontend, show the internet-radio-icon.svg placeholder instead of requesting server artwork when no image is uploaded, allowing favicon fallback in the player. * refactor(ui): update defaultOff fields in useSelectedFields for RadioList Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback - Add missing alt attribute to CardMedia in RadioEdit for accessibility - Fix UpdateInternetRadio to preserve UploadedImage field by fetching existing radio before updating (prevents Subsonic API from clearing custom artwork) - Add Reader() level tests to verify ErrUnavailable is returned when radio has no uploaded image * refactor: add colsToUpdate to RadioRepository.Put Use the base sqlRepository.put with column filtering instead of hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API fields, preventing UploadedImage from being cleared. Image upload/delete handlers specify only UploadedImage. * fix: ensure UpdatedAt is included in colsToUpdate for radio Put --------- Signed-off-by: Deluan <deluan@navidrome.org> --- consts/consts.go | 1 + core/artwork/artwork.go | 2 + core/artwork/reader_radio.go | 40 +++++++++ core/artwork/reader_radio_test.go | 84 +++++++++++++++++++ ...20260318182414_add_radio_uploaded_image.go | 22 +++++ model/artwork_id.go | 10 +++ model/get_entity.go | 4 + model/radio.go | 29 +++++-- model/radio_test.go | 42 ++++++++++ persistence/radio_repository.go | 24 ++---- server/nativeapi/native_api.go | 2 +- server/nativeapi/radios.go | 70 ++++++++++++++++ server/subsonic/radio.go | 2 +- tests/mock_radio_repository.go | 2 +- ui/src/album/AlbumDetails.jsx | 39 +++------ ui/src/artist/DesktopArtistDetails.jsx | 10 ++- ui/src/artist/MobileArtistDetails.jsx | 10 ++- ui/src/common/index.js | 1 + .../useImageLoadingState.js} | 10 +-- ui/src/consts.js | 2 + ui/src/playlist/PlaylistDetails.jsx | 38 +++------ ui/src/radio/RadioEdit.jsx | 67 ++++++++++++++- ui/src/radio/RadioList.jsx | 21 ++++- ui/src/radio/helper.jsx | 24 ++++-- ui/src/subsonic/index.js | 3 + 25 files changed, 450 insertions(+), 109 deletions(-) create mode 100644 core/artwork/reader_radio.go create mode 100644 core/artwork/reader_radio_test.go create mode 100644 db/migrations/20260318182414_add_radio_uploaded_image.go create mode 100644 model/radio_test.go create mode 100644 server/nativeapi/radios.go rename ui/src/{artist/useArtistImageState.js => common/useImageLoadingState.js} (76%) diff --git a/consts/consts.go b/consts/consts.go index 38b277eab..6fb6c5dac 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -108,6 +108,7 @@ const ( const ( EntityArtist = "artist" EntityPlaylist = "playlist" + EntityRadio = "radio" ) const ( diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 4a0a32afc..b8c395c12 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newPlaylistArtworkReader(ctx, a, artID) case model.KindDiscArtwork: artReader, err = newDiscArtworkReader(ctx, a, artID) + case model.KindRadioArtwork: + artReader, err = newRadioArtworkReader(ctx, a, artID) default: return nil, ErrUnavailable } diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go new file mode 100644 index 000000000..22db6e302 --- /dev/null +++ b/core/artwork/reader_radio.go @@ -0,0 +1,40 @@ +package artwork + +import ( + "context" + "io" + "time" + + "github.com/navidrome/navidrome/model" +) + +type radioArtworkReader struct { + cacheKey + a *artwork + radio model.Radio +} + +func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { + r, err := artwork.ds.Radio(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &radioArtworkReader{a: artwork, radio: *r} + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = r.UpdatedAt + return a, nil +} + +func (a *radioArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + return selectImageReader(ctx, a.artID, + a.fromRadioUploadedImage(), + ) +} + +func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { + return fromLocalFile(a.radio.UploadedImagePath()) +} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go new file mode 100644 index 000000000..1f5bc9084 --- /dev/null +++ b/core/artwork/reader_radio_test.go @@ -0,0 +1,84 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("radioArtworkReader", func() { + var ( + tempDir string + reader *radioArtworkReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) + + reader = &radioArtworkReader{} + }) + + Describe("fromRadioUploadedImage", func() { + When("radio has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.radio = model.Radio{ID: "rd-1"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("Reader", func() { + When("radio has an uploaded image", func() { + It("returns the image reader", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns ErrUnavailable", func() { + reader.radio = model.Radio{ID: "rd-1"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(r).To(BeNil()) + }) + }) + }) +}) diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go new file mode 100644 index 000000000..e92a6d2ef --- /dev/null +++ b/db/migrations/20260318182414_add_radio_uploaded_image.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage) +} + +func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`) + return err +} + +func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/artwork_id.go b/model/artwork_id.go index 8d935f427..1bd146c1f 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -23,6 +23,7 @@ var ( KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} KindDiscArtwork = Kind{"dc", "disc"} + KindRadioArtwork = Kind{"ra", "radio"} ) var artworkKindMap = map[string]Kind{ @@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{ KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindDiscArtwork.prefix: KindDiscArtwork, + KindRadioArtwork.prefix: KindRadioArtwork, } type ArtworkID struct { @@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID { ID: ar.ID, } } + +func artworkIDFromRadio(r Radio) ArtworkID { + return ArtworkID{ + Kind: KindRadioArtwork, + ID: r.ID, + LastUpdate: r.UpdatedAt, + } +} diff --git a/model/get_entity.go b/model/get_entity.go index 26f718396..60972b2e9 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { if err == nil { return mf, nil } + r, err := ds.Radio(ctx).Get(id) + if err == nil { + return r, nil + } return nil, err } diff --git a/model/radio.go b/model/radio.go index 567d32e44..86f27c24c 100644 --- a/model/radio.go +++ b/model/radio.go @@ -1,14 +1,27 @@ package model -import "time" +import ( + "time" + + "github.com/navidrome/navidrome/consts" +) type Radio struct { - ID string `structs:"id" json:"id"` - StreamUrl string `structs:"stream_url" json:"streamUrl"` - Name string `structs:"name" json:"name"` - HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ID string `structs:"id" json:"id"` + StreamUrl string `structs:"stream_url" json:"streamUrl"` + Name string `structs:"name" json:"name"` + HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +func (r Radio) CoverArtID() ArtworkID { + return artworkIDFromRadio(r) +} + +func (r Radio) UploadedImagePath() string { + return UploadedImagePath(consts.EntityRadio, r.UploadedImage) } type Radios []Radio @@ -19,5 +32,5 @@ type RadioRepository interface { Delete(id string) error Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) - Put(u *Radio) error + Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go new file mode 100644 index 000000000..dc421454e --- /dev/null +++ b/model/radio_test.go @@ -0,0 +1,42 @@ +package model_test + +import ( + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio", func() { + Describe("CoverArtID", func() { + It("returns a radio artwork ID", func() { + now := time.Now() + r := model.Radio{ID: "rd-1", UpdatedAt: now} + artID := r.CoverArtID() + Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) + Expect(artID.ID).To(Equal("rd-1")) + Expect(artID.LastUpdate).To(Equal(now)) + }) + }) + + Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = "/data" + }) + + It("returns empty string when no image uploaded", func() { + r := model.Radio{ID: "rd-1"} + Expect(r.UploadedImagePath()).To(BeEmpty()) + }) + + It("returns full path when image is set", func() { + r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg"))) + }) + }) +}) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 543b76c5e..a073643db 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e return res, err } -func (r *radioRepository) Put(radio *model.Radio) error { +func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - var values map[string]any - radio.UpdatedAt = time.Now() - if radio.ID == "" { radio.CreatedAt = time.Now() radio.ID = id.NewRandom() - values, _ = toSQLArgs(*radio) - } else { - values, _ = toSQLArgs(*radio) - update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values) - count, err := r.executeSQL(update) - - if err != nil { - return err - } else if count > 0 { - return nil - } } - - values["created_at"] = time.Now() - insert := Insert(r.tableName).SetMap(values) - _, err := r.executeSQL(insert) + if len(colsToUpdate) > 0 { + colsToUpdate = append(colsToUpdate, "UpdatedAt") + } + _, err := r.put(radio.ID, radio, colsToUpdate...) return err } diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3ef00ebb1..669c4d7b5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler { api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) - api.R(r, "/radio", model.Radio{}, true) + api.addRadioRoute(r) api.R(r, "/tag", model.Tag{}, true) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go new file mode 100644 index 000000000..701c6c926 --- /dev/null +++ b/server/nativeapi/radios.go @@ -0,0 +1,70 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addRadioRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Radio{}) + } + r.Route("/radio", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + r.Post("/image", api.uploadRadioImage()) + r.Delete("/image", api.deleteRadioImage()) + }) + }) +} + +func (api *Router) uploadRadioImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + radioID := chi.URLParamFromCtx(ctx, "id") + radio, err := api.ds.Radio(ctx).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath := radio.UploadedImagePath() + filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext) + if err != nil { + return err + } + radio.UploadedImage = filename + return api.ds.Radio(ctx).Put(radio, "UploadedImage") + }) +} + +func (api *Router) deleteRadioImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + radioID := chi.URLParamFromCtx(ctx, "id") + radio, err := api.ds.Radio(ctx).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil { + return err + } + radio.UploadedImage = "" + return api.ds.Radio(ctx).Put(radio, "UploadedImage") + }) +} diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 9f2cd48f6..c66268344 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -103,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio(ctx).Put(radio) + err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name") if err != nil { return nil, err } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 279b735db..c50a529e5 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -73,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) Put(radio *model.Radio) error { +func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index bd6a41523..c5d9a7ac4 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { Card, CardContent, @@ -29,6 +29,7 @@ import { RatingField, SizeField, useAlbumsPerPage, + useImageLoadingState, } from '../common' import config from '../config' import { formatFullDate, intersperse } from '../utils' @@ -220,11 +221,17 @@ const AlbumDetails = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const classes = useStyles() - const [isLightboxOpen, setLightboxOpen] = useState(false) const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const [imageLoading, setImageLoading] = useState(false) - const [imageError, setImageError] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) let notes = albumInfo?.notes || record.notes @@ -247,33 +254,9 @@ const AlbumDetails = (props) => { }) }, [record]) - // Reset image state when album changes - useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - const imageUrl = subsonic.getCoverArtUrl(record, 300) const fullImageUrl = subsonic.getCoverArtUrl(record) - const handleImageLoad = useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - return ( <Card className={classes.root}> <div className={classes.cardContents}> diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index da8d06014..7052b1634 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -6,13 +6,17 @@ import CardContent from '@material-ui/core/CardContent' import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { + LoveButton, + RatingField, + ImageUploadOverlay, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' -import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { handleImageError, handleOpenLightbox, handleCloseLightbox, - } = useArtistImageState(record.id) + } = useImageLoadingState(record.id) return ( <div className={classes.root}> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 3add1e994..03cc4de8f 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -4,11 +4,15 @@ import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { + LoveButton, + RatingField, + ImageUploadOverlay, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' -import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { handleImageError, handleOpenLightbox, handleCloseLightbox, - } = useArtistImageState(record.id) + } = useImageLoadingState(record.id) return ( <> diff --git a/ui/src/common/index.js b/ui/src/common/index.js index b93e40219..a7d6a43c4 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -45,3 +45,4 @@ export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' +export * from './useImageLoadingState' diff --git a/ui/src/artist/useArtistImageState.js b/ui/src/common/useImageLoadingState.js similarity index 76% rename from ui/src/artist/useArtistImageState.js rename to ui/src/common/useImageLoadingState.js index bd7e4ad96..3528b0f3f 100644 --- a/ui/src/artist/useArtistImageState.js +++ b/ui/src/common/useImageLoadingState.js @@ -1,11 +1,11 @@ import { useState, useEffect, useCallback } from 'react' /** - * Manages image loading/error state and lightbox open/close for artist detail views. - * Resets when record.id changes. + * Manages image loading/error state and lightbox open/close. + * Resets when recordId changes. */ -const useArtistImageState = (recordId) => { - const [imageLoading, setImageLoading] = useState(false) +export const useImageLoadingState = (recordId) => { + const [imageLoading, setImageLoading] = useState(true) const [imageError, setImageError] = useState(false) const [isLightboxOpen, setLightboxOpen] = useState(false) @@ -42,5 +42,3 @@ const useArtistImageState = (recordId) => { handleCloseLightbox, } } - -export default useArtistImageState diff --git a/ui/src/consts.js b/ui/src/consts.js index 30731a080..472cd4940 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -24,6 +24,8 @@ DraggableTypes.ALL.push( DraggableTypes.ARTIST, ) +export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' + export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index a2d5e753b..911e6c716 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -7,7 +7,6 @@ import { } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useTranslate } from 'react-admin' -import { useCallback, useState, useEffect } from 'react' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import { @@ -17,6 +16,7 @@ import { SizeField, isWritable, OverflowTooltip, + useImageLoadingState, } from '../common' import subsonic from '../subsonic' @@ -96,37 +96,19 @@ const PlaylistDetails = (props) => { const translate = useTranslate() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const [isLightboxOpen, setLightboxOpen] = useState(false) - const [imageLoading, setImageLoading] = useState(false) - const [imageError, setImageError] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - // Reset image state when playlist changes - useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - return ( <Card className={classes.root}> <div className={classes.cardContents}> diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index f00f889f3..6b1d2df79 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -6,8 +6,37 @@ import { TextInput, useTranslate, } from 'react-admin' +import { CardMedia } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' -import { Title } from '../common' +import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' +import subsonic from '../subsonic' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' + +const useStyles = makeStyles({ + coverParent: { + display: 'inline-flex', + position: 'relative', + width: '8rem', + height: '8rem', + marginBottom: '1em', + }, + cover: { + width: '8rem', + height: '8rem', + objectFit: 'cover', + cursor: 'pointer', + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, + }, + placeholder: { + width: '8rem', + height: '8rem', + objectFit: 'contain', + }, +}) const RadioTitle = ({ record }) => { const translate = useTranslate() @@ -21,6 +50,7 @@ const RadioEdit = (props) => { return ( <Edit title={<RadioTitle />} {...props}> <SimpleForm variant="outlined" {...props}> + <RadioCoverArt /> <TextInput source="name" validate={[required()]} /> <TextInput type="url" @@ -41,4 +71,39 @@ const RadioEdit = (props) => { ) } +const RadioCoverArt = ({ record }) => { + const classes = useStyles() + const { imageLoading, handleImageLoad, handleImageError } = + useImageLoadingState(record?.id) + + if (!record) return null + + return ( + <div className={classes.coverParent}> + {record.uploadedImage ? ( + <CardMedia + component="img" + src={subsonic.getCoverArtUrl(record, 300, true)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onLoad={handleImageLoad} + onError={handleImageError} + title={record.name} + alt={record.name} + /> + ) : ( + <img + src={RADIO_PLACEHOLDER_IMAGE} + className={classes.placeholder} + alt={record.name} + /> + )} + <ImageUploadOverlay + entityType="radio" + entityId={record.id} + hasUploadedImage={!!record.uploadedImage} + /> + </div> + ) +} + export default RadioEdit diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 3d1adacc9..582fcaffc 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -1,4 +1,4 @@ -import { makeStyles, useMediaQuery } from '@material-ui/core' +import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core' import React, { cloneElement } from 'react' import { CreateButton, @@ -16,9 +16,11 @@ import { } from 'react-admin' import { List } from '../common' import { ToggleFieldsMenu, useSelectedFields } from '../common' +import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' import { songFromRadio } from './helper' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' import { useDispatch } from 'react-redux' const useStyles = makeStyles({ @@ -73,6 +75,19 @@ const RadioListActions = ({ ) } +const avatarStyle = { width: 40, height: 40 } + +const CoverArtField = ({ record }) => { + if (!record) return null + const src = record.uploadedImage + ? subsonic.getCoverArtUrl(record, 40, true) + : RADIO_PLACEHOLDER_IMAGE + return ( + <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> + ) +} +CoverArtField.defaultProps = { label: '' } + const RadioList = ({ permissions, ...props }) => { const classes = useStyles() const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) @@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => { const isAdmin = permissions === 'admin' const toggleableFields = { + coverArt: <CoverArtField source="id" sortable={false} />, name: <TextField source="name" />, homePageUrl: ( <UrlField @@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => { const columns = useSelectedFields({ resource: 'radio', columns: toggleableFields, - defaultOff: ['createdAt'], + defaultOff: ['streamUrl', 'createdAt'], }) const handleRowClick = async (id, basePath, record) => { @@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => { > {isXsmall ? ( <SimpleList + leftAvatar={(r) => <CoverArtField record={r} />} leftIcon={(r) => ( <StreamField record={r} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx index 57de244b9..4c313d3bc 100644 --- a/ui/src/radio/helper.jsx +++ b/ui/src/radio/helper.jsx @@ -1,16 +1,24 @@ +import subsonic from '../subsonic' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' + export async function songFromRadio(radio) { if (!radio) { return undefined } - let cover = 'internet-radio-icon.svg' - try { - const url = new URL(radio.homePageUrl ?? radio.streamUrl) - url.pathname = '/favicon.ico' - await resourceExists(url) - cover = url.toString() - } catch { - // ignore + let cover = RADIO_PLACEHOLDER_IMAGE + if (radio.uploadedImage) { + cover = subsonic.getCoverArtUrl(radio, 300, true) + } else { + // Try favicon as fallback + try { + const url = new URL(radio.homePageUrl ?? radio.streamUrl) + url.pathname = '/favicon.ico' + await resourceExists(url) + cover = url.toString() + } catch { + // No cover available + } } return { diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 65155e8f4..3579619aa 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -86,6 +86,9 @@ const getCoverArtUrl = (record, size, square) => { } else if (record.sync !== undefined) { // This is a playlist return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) + } else if (record.streamUrl !== undefined) { + // This is a radio station + return baseUrl(url('getCoverArt', 'ra-' + record.id, options)) } else { return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) } From f7b60c79527f4ddc10c960acb64d0975cb9ffdb8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 19 Mar 2026 13:14:24 -0400 Subject: [PATCH 121/596] fix(tests): fix race condition in CacheWarmer pre-cache size test The test was checking that the buffer was drained before asserting on cached sizes, but the buffer is cleared before processBatch completes. Use Eventually on getCachedSizes() directly to properly wait for the artwork caching to finish. --- core/artwork/cache_warmer_test.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 47e187f41..6ddda00d6 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -180,14 +180,9 @@ var _ = Describe("CacheWarmer", func() { cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw.PreCache(model.MustParseArtworkID("al-1")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - - sizes := aw.getCachedSizes() - Expect(sizes).To(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) + Eventually(func() []int { + return aw.getCachedSizes() + }).Should(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) }) }) }) From a4c289b28c76bac6352b9500e5d783bab5602e99 Mon Sep 17 00:00:00 2001 From: JRoshthen1 <37882816+JRoshthen1@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:33:09 +0100 Subject: [PATCH 122/596] feat(ui): add Slovak language translation (#5231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): Add Slovak language translation Signed-off-by: jrosh <martin@jrosh.eu> * fix(i18n): Fix typos and add missing translations Signed-off-by: jrosh <martin@jrosh.eu> --------- Signed-off-by: jrosh <martin@jrosh.eu> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/sk.json | 723 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 resources/i18n/sk.json diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json new file mode 100644 index 000000000..af5afade7 --- /dev/null +++ b/resources/i18n/sk.json @@ -0,0 +1,723 @@ +{ + "languageName": "Slovenčina", + "resources": { + "song": { + "name": "Skladba |||| Skladieb", + "fields": { + "albumArtist": "Interpret albumu", + "duration": "Dĺžka", + "trackNumber": "#", + "playCount": "Počet prehratí", + "title": "Názov", + "artist": "Interpret", + "composer": "Skladateľ", + "album": "Album", + "path": "Cesta k súboru", + "libraryName": "Knižnica", + "genre": "Žáner", + "compilation": "Kompilácia", + "year": "Rok", + "size": "Veľkosť súboru", + "updatedAt": "Nahrané", + "bitRate": "Prenosová rýchlosť", + "bitDepth": "Bitová hĺbka", + "sampleRate": "Vzorkovacia frekvencia", + "channels": "Kanály", + "disc": "Disk %{discNumber}", + "discSubtitle": "Podtitul disku", + "starred": "Obľúbené", + "comment": "Komentár", + "rating": "Hodnotenie", + "quality": "Kvalita", + "bpm": "BPM", + "playDate": "Naposledy prehraná skladba", + "createdAt": "Pridané", + "grouping": "Zoskupovanie", + "mood": "Nálada", + "participants": "Ďalší účastníci", + "tags": "Ďalšie značky", + "mappedTags": "Mapované značky", + "rawTags": "Nespracované značky", + "missing": "Chýbajúce" + }, + "actions": { + "addToQueue": "Prehrať neskôr", + "playNow": "Prehrať teraz", + "addToPlaylist": "Pridať do zoznamu skladieb", + "showInPlaylist": "Zobraziť v zozname skladieb", + "shuffleAll": "Zamiešať všetko", + "download": "Stiahnuť", + "playNext": "Prehrať ako ďalšie", + "info": "Získať informácie", + "instantMix": "Okamžitý mix" + } + }, + "album": { + "name": "Album |||| Albumy", + "fields": { + "albumArtist": "Interpret albumu", + "artist": "Interpret", + "duration": "Dĺžka", + "songCount": "Skladby", + "playCount": "Počet prehratí", + "size": "Veľkosť", + "name": "Názov", + "libraryName": "Knižnica", + "genre": "Žáner", + "compilation": "Kompilácia", + "year": "Rok", + "date": "Dátum záznamu", + "originalDate": "Pôvodné", + "releaseDate": "Vydané", + "releases": "Vydanie |||| Vydania", + "released": "Vydané", + "updatedAt": "Aktualizované", + "comment": "Komentár", + "rating": "Hodnotenie", + "createdAt": "Pridané", + "recordLabel": "Štítok", + "catalogNum": "Katalógové číslo", + "releaseType": "Typ vydania", + "grouping": "Zoskupovanie", + "media": "Médiá", + "mood": "Nálada", + "missing": "Chýbajúce" + }, + "actions": { + "playAll": "Prehrať", + "playNext": "Prehrať ako ďalšie", + "addToQueue": "Prehrať neskôr", + "share": "Zdieľať", + "shuffle": "Zamiešať", + "addToPlaylist": "Pridať do zoznamu skladieb", + "download": "Stiahnuť", + "info": "Získať informácie" + }, + "lists": { + "all": "Všetko", + "random": "Náhodné", + "recentlyAdded": "Nedávno pridané", + "recentlyPlayed": "Nedávno prehrané", + "mostPlayed": "Najviac prehrávané", + "starred": "Obľúbené", + "topRated": "Najlepšie hodnotené" + } + }, + "artist": { + "name": "Interpret |||| Interpreti", + "fields": { + "name": "Názov", + "albumCount": "Počet albumov", + "songCount": "Počet skladieb", + "size": "Veľkosť", + "playCount": "Prehrania", + "rating": "Hodnotenie", + "genre": "Žáner", + "role": "Rola", + "missing": "Chýbajúci" + }, + "roles": { + "albumartist": "Interpret albumu |||| Interpreti albumov", + "artist": "Interpret |||| Interpreti", + "composer": "Skladateľ |||| Skladatelia", + "conductor": "Dirigent |||| Dirigenti", + "lyricist": "Textár |||| Textári", + "arranger": "Aranžér |||| Aranžéri", + "producer": "Producent |||| Producenti", + "director": "Režisér |||| Režiséri", + "engineer": "Zvukový technik |||| Zvukoví technici", + "mixer": "Mixér |||| Mixéri", + "remixer": "Remixér |||| Remixéri", + "djmixer": "DJ Mixér |||| DJ Mixéri", + "performer": "Účinkujúci |||| Účinkujúci", + "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" + }, + "actions": { + "topSongs": "Najpopulárnejšie skladby", + "shuffle": "Zamiešať", + "radio": "Rádio" + } + }, + "user": { + "name": "Používateľ |||| Používatelia", + "fields": { + "userName": "Používateľské meno", + "isAdmin": "Správca", + "lastLoginAt": "Naposledy prihlásený", + "lastAccessAt": "Posledný Prístup", + "updatedAt": "Upravený", + "name": "Meno", + "password": "Heslo", + "createdAt": "Vytvorený", + "changePassword": "Zmeniť heslo?", + "currentPassword": "Súčastné heslo", + "newPassword": "Nové heslo", + "token": "Token", + "libraries": "Knižnice" + }, + "helperTexts": { + "name": "Zmena mena sa zobrazí až po ďalšom prihlásení", + "libraries": "Vyberte konkrétne knižnice pre tohto používateľa alebo nechajte pole prázdne, ak chcete použiť predvolené knižnice" + }, + "notifications": { + "created": "Používateľ vytvorený", + "updated": "Používateľ upravený", + "deleted": "Používateľ odstránený" + }, + "validation": { + "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" + }, + "message": { + "listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.", + "clickHereForToken": "Kliknite sem pre získanie svojho tokenu", + "selectAllLibraries": "Vybrať všetky knižnice", + "adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam" + } + }, + "player": { + "name": "Prehrávač |||| Prehrávače", + "fields": { + "name": "Názov", + "transcodingId": "ID transkódovania", + "maxBitRate": "Max. prenosová rýchlosť", + "client": "Klient", + "userName": "Používateľské meno", + "lastSeen": "Naposledy videný", + "reportRealPath": "Skutočná cesta hlásenia", + "scrobbleEnabled": "Odosielať scrobbling na externé služby" + } + }, + "transcoding": { + "name": "Transkódovanie |||| Transkódovania", + "fields": { + "name": "Názov", + "targetFormat": "Cieľový formát", + "defaultBitRate": "Predvolená prenosová rýchlosť", + "command": "Príkaz" + } + }, + "playlist": { + "name": "Zoznam skladieb |||| Zoznamy skladieb", + "fields": { + "name": "Názov", + "duration": "Dĺžka", + "ownerName": "Autor", + "public": "Verejný", + "updatedAt": "Nahraný", + "createdAt": "Vytvorený", + "songCount": "Skladby", + "comment": "Komentár", + "sync": "Auto-import", + "path": "Importovať z" + }, + "actions": { + "selectPlaylist": "Vybrať zoznam skladieb:", + "addNewPlaylist": "Vytvoriť \"%{name}\"", + "export": "Export", + "saveQueue": "Uložiť rad do zoznamu skladieb", + "makePublic": "Zverejniť", + "makePrivate": "Nastaviť ako súkromné", + "searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...", + "pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb", + "removeFromSelection": "Odstrániť z výberu" + }, + "message": { + "duplicate_song": "Pridať duplicitné položky", + "song_exist": "Pridávate duplikát už existujúcej položky v zozname skladieb. Chcete pridať duplikát alebo ho preskočiť?", + "noPlaylistsFound": "Žiadne zoznamy skladieb sa nenašli", + "noPlaylists": "Žiadne zoznamy skladieb nie sú dostupné" + } + }, + "radio": { + "name": "Rádio |||| Rádiá", + "fields": { + "name": "Názov", + "streamUrl": "URL streamu", + "homePageUrl": "URL stránky", + "updatedAt": "Nahrané", + "createdAt": "Vytvorené" + }, + "actions": { + "playNow": "Spustiť" + } + }, + "share": { + "name": "Zdieľanie |||| Zdieľania", + "fields": { + "username": "Zdieľané", + "url": "URL", + "description": "Popis", + "downloadable": "Povoliť sťahovanie?", + "contents": "Obsah", + "expiresAt": "Vyprší", + "lastVisitedAt": "Naposledy navštívené", + "visitCount": "Počet návštev", + "format": "Formát", + "maxBitRate": "Max. Bit Rate", + "updatedAt": "Nahrané", + "createdAt": "Vytvorené" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "Chýbajúci súbor |||| Chýbajúce súbory", + "empty": "Žiadne chýbajúce súbory", + "fields": { + "path": "Cesta", + "size": "Veľkosť", + "libraryName": "Knižnica", + "updatedAt": "Zmizol dňa" + }, + "actions": { + "remove": "Odstrániť", + "remove_all": "Odstrániť všetky" + }, + "notifications": { + "removed": "Chýbajúce súbory odstránené" + } + }, + "library": { + "name": "Knižnica |||| Knižnice", + "fields": { + "name": "Názov", + "path": "Cesta", + "remotePath": "Vzdialená cesta", + "lastScanAt": "Posledný sken", + "songCount": "Skladby", + "albumCount": "Albumy", + "artistCount": "Interpreti", + "totalSongs": "Skladby", + "totalAlbums": "Albumy", + "totalArtists": "Interpreti", + "totalFolders": "Priečinky", + "totalFiles": "Súbory", + "totalMissingFiles": "Chýbajúce súbory", + "totalSize": "Celková veľkosť", + "totalDuration": "Dĺžka", + "defaultNewUsers": "Predvolené pre nových používateľov", + "createdAt": "Vytvorené", + "updatedAt": "Aktualizované" + }, + "sections": { + "basic": "Základné informácie", + "statistics": "Štatistiky" + }, + "actions": { + "scan": "Skenovať knižnicu", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken", + "manageUsers": "Spravovať prístup používateľov", + "viewDetails": "Zobraziť detaily" + }, + "notifications": { + "created": "Knižnica úspešne vytvorená", + "updated": "Knižnica úspešne aktualizovaná", + "deleted": "Knižnica úspešne odstránená", + "scanStarted": "Skenovanie knižnice spustené", + "quickScanStarted": "Rýchly sken spustený", + "fullScanStarted": "Úplný sken spustený", + "scanError": "Chyba pri spustení skenu. Skontrolujte logy", + "scanCompleted": "Skenovanie knižnice dokončené" + }, + "validation": { + "nameRequired": "Názov knižnice je povinný", + "pathRequired": "Cesta ku knižnici je povinná", + "pathNotDirectory": "Cesta ku knižnici musí byť priečinok", + "pathNotFound": "Cesta ku knižnici sa nenašla", + "pathNotAccessible": "Cesta ku knižnici nie je dostupná", + "pathInvalid": "Neplatná cesta ku knižnici" + }, + "messages": { + "deleteConfirm": "Ste si istý, že chcete odstrániť túto knižnicu? Tým sa odstránia všetky súvisiace dáta a prístupy používateľov.", + "scanInProgress": "Skenovanie prebieha...", + "noLibrariesAssigned": "Tomuto používateľovi nie sú priradené žiadne knižnice" + } + }, + "plugin": { + "name": "Plugin |||| Pluginy", + "fields": { + "id": "ID", + "name": "Názov", + "description": "Popis", + "version": "Verzia", + "author": "Autor", + "website": "Webová stránka", + "permissions": "Oprávnenia", + "enabled": "Povolený", + "status": "Stav", + "path": "Cesta", + "lastError": "Chyba", + "hasError": "Chyba", + "updatedAt": "Aktualizovaný", + "createdAt": "Nainštalovaný", + "configKey": "Kľúč", + "configValue": "Hodnota", + "allUsers": "Povoliť všetkých používateľov", + "selectedUsers": "Vybraní používatelia", + "allLibraries": "Povoliť všetky knižnice", + "selectedLibraries": "Vybrané knižnice", + "allowWriteAccess": "Povoliť prístup na zápis" + }, + "sections": { + "status": "Stav", + "info": "Informácie o plugine", + "configuration": "Konfigurácia", + "manifest": "Manifest", + "usersPermission": "Oprávnenia používateľov", + "libraryPermission": "Oprávnenia knižnice" + }, + "status": { + "enabled": "Povolený", + "disabled": "Zakázaný" + }, + "actions": { + "enable": "Povoliť", + "disable": "Zakázať", + "disabledDueToError": "Opravte chybu pred povolením", + "disabledUsersRequired": "Vyberte používateľov pred povolením", + "disabledLibrariesRequired": "Vyberte knižnice pred povolením", + "addConfig": "Pridať konfiguráciu", + "rescan": "Znovu skenovať" + }, + "notifications": { + "enabled": "Plugin povolený", + "disabled": "Plugin zakázaný", + "updated": "Plugin aktualizovaný", + "error": "Chyba pri aktualizácii pluginu" + }, + "validation": { + "invalidJson": "Konfigurácia musí byť platný JSON" + }, + "messages": { + "configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.", + "configValidationError": "Overenie konfigurácie zlyhalo:", + "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", + "clickPermissions": "Kliknite na oprávnenie pre detaily", + "noConfig": "Žiadna konfigurácia nastavená", + "allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.", + "noUsers": "Žiadni používatelia nevybraní", + "permissionReason": "Dôvod", + "usersRequired": "Tento plugin vyžaduje prístup k informáciám o používateľoch. Vyberte, ku ktorým používateľom má plugin prístup, alebo povolte 'Povoliť všetkých používateľov'.", + "allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.", + "noLibraries": "Žiadne knižnice nevybrané", + "librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.", + "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.", + "requiredHosts": "Požadovaní hostitelia" + }, + "placeholders": { + "configKey": "kľúč", + "configValue": "hodnota" + } + } + }, + "ra": { + "auth": { + "welcome1": "Ďakujeme, že ste si nainštalovali Navidrome!", + "welcome2": "Najskôr vytvorte účet správcu", + "confirmPassword": "Potvrďte heslo", + "buttonCreateAdmin": "Vytvoriť správcu", + "auth_check_error": "Pre pokračovanie sa prosím prihláste", + "user_menu": "Profil", + "username": "Používateľské meno", + "password": "Heslo", + "sign_in": "Prihlásiť sa", + "sign_in_error": "Overenie zlyhalo, skúste to znova", + "logout": "Odhlásiť sa", + "insightsCollectionNote": "Navidrome zhromažďuje anonymné údaje\n o používaní, aby pomohol zlepšiť projekt.\nKliknite [sem] a dozviete sa viac a v prípade\npotreby sa odhláste." + }, + "validation": { + "invalidChars": "Prosím, používajte iba písmená a čísla", + "passwordDoesNotMatch": "Heslá sa nezhodujú", + "required": "Povinné pole", + "minLength": "Musí obsahovať najmenej %{min} znakov", + "maxLength": "Môže obsahovať maximálne %{max} znakov", + "minValue": "Musí byť aspoň %{min}", + "maxValue": "Môže byť maximálne %{max}", + "number": "Musí byť číslo", + "email": "Musí byť platná e-mailová adresa", + "oneOf": "Musí spĺňať jedno z: %{options}", + "regex": "Musí byť v špecifickom formáte (regexp): %{pattern}", + "unique": "Musí byť jedinečný", + "url": "Musí byť platná URL" + }, + "action": { + "add_filter": "Pridať filter", + "add": "Pridať", + "back": "Ísť späť", + "bulk_actions": "1 vybraná |||| %{smart_count} vybraných", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "Zrušiť", + "clear_input_value": "Vymazať hodnotu", + "clone": "Klonovať", + "confirm": "Potvrdiť", + "create": "Vytvoriť", + "delete": "Vymazať", + "edit": "Upraviť", + "export": "Exportovať", + "list": "Zoznam", + "refresh": "Obnoviť", + "remove_filter": "Odstrániť filter", + "remove": "Odstrániť", + "save": "Uložiť", + "search": "Vyhľadať", + "show": "Zobraziť", + "sort": "Zoradiť", + "undo": "Vrátiť", + "expand": "Rozbaliť", + "close": "Zavrieť", + "open_menu": "Otvoriť ponuku", + "close_menu": "Zavrieť ponuku", + "unselect": "Zrušiť výber", + "skip": "Preskočiť", + "share": "Zdieľať", + "download": "Stiahnuť" + }, + "boolean": { + "true": "Áno", + "false": "Nie" + }, + "page": { + "create": "Vytvoriť %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Niečo sa pokazilo", + "list": "%{name}", + "loading": "Načítavanie", + "not_found": "Nenájdené", + "show": "%{name} #%{id}", + "empty": "Zatiaľ žiaden %{name}.", + "invite": "Chcete pridať nové?" + }, + "input": { + "file": { + "upload_several": "Presuňte súbory pre nahranie alebo kliknite pre výber.", + "upload_single": "Presuňte súbor pre nahranie alebo kliknite pre jeho výber." + }, + "image": { + "upload_several": "Presuňte obrázky pre nahranie alebo kliknite pre výber.", + "upload_single": "Presuňte obrázok pre nahranie alebo kliknite pre jeho výber." + }, + "references": { + "all_missing": "Referencované dáta sa nenašli.", + "many_missing": "Aspoň jedna z referencií už nie je dostupná.", + "single_missing": "Referencia sa zdá byť nedostupná." + }, + "password": { + "toggle_visible": "Skryť heslo", + "toggle_hidden": "Zobraziť heslo" + } + }, + "message": { + "about": "O Navidrome", + "are_you_sure": "Ste si istý?", + "bulk_delete_content": "Ste si istý, že chcete vymazať %{name}? |||| Ste si istý, že chcete vymazať týchto %{smart_count} položiek?", + "bulk_delete_title": "Vymazať %{name} |||| Vymazať %{smart_count} %{name} položiek", + "delete_content": "Ste si istý, že chcete vymazať túto položku?", + "delete_title": "Vymazať %{name} #%{id}", + "details": "Detaily", + "error": "Vyskytla sa chyba klienta a vaša požiadavka nemohla byť splnená.", + "invalid_form": "Formulár nie je platný. Prosím skontrolujte ho.", + "loading": "Stránka sa načítava, prosím počkajte", + "no": "Nie", + "not_found": "Zadali ste nesprávnu adresu URL, alebo ste nasledovali nesprávny odkaz.", + "yes": "Áno", + "unsaved_changes": "Niektoré vaše zmeny neboli uložené. Ste si istí, že ich chcete ignorovať?" + }, + "navigation": { + "no_results": "Nenašli sa žiadne výsledky", + "no_more_results": "Stránka číslo %{page} je mimo rozsah. Skúste predchádzajúcu.", + "page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah", + "page_out_from_end": "Nemožno ísť za poslednú stranu", + "page_out_from_begin": "Nemožno ísť pred prvú stranu", + "page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}", + "page_rows_per_page": "Položiek na stránke:", + "next": "Ďalší", + "prev": "Predchádzajúci", + "skip_nav": "Preskočiť na obsah" + }, + "notification": { + "updated": "Prvok aktualizovaný |||| %{smart_count} prvkov aktualizovaných", + "created": "Prvok vytvorený", + "deleted": "Prvok vymazaný |||| %{smart_count} prvkov vymazaných", + "bad_item": "Nesprávny prvok", + "item_doesnt_exist": "Prvok neexistuje", + "http_error": "Chyba komunikácie servera", + "data_provider_error": "Chyba dataProvideru. Detaily nájdete v konzole.", + "i18n_error": "Nemožno načítať preklady pre vybraný jazyk", + "canceled": "Akcia zrušená", + "logged_out": "Vaša relácia skončila, prosím pripojte sa znova.", + "new_version": "Je dostupná nová verzia! Prosím obnovte toto okno." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Stĺpce na zobrazenie", + "layout": "Rozloženie", + "grid": "Mriežka", + "table": "Tabuľka" + } + }, + "message": { + "uploadCover": "Nahrať obrázok obalu", + "removeCover": "Odstrániť obrázok obalu", + "coverUploaded": "Obrázok obalu albumu aktualizovaný", + "coverRemoved": "Obrázok obalu albumu odstránený", + "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", + "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu", + "note": "POZNÁMKA", + "transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.", + "transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.", + "songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb", + "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", + "startingInstantMix": "Načítava sa Instant Mix...", + "noTopSongsFound": "Nenašli sa žiadne top skladby", + "noPlaylistsAvailable": "Žiadne nie sú dostupné", + "delete_user_title": "Odstrániť používateľa '%{name}'", + "delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?", + "remove_missing_title": "Odstráňte chýbajúce súbory", + "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", + "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača", + "notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https", + "lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý", + "lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť", + "lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý", + "lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť", + "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", + "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", + "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", + "openIn": { + "lastfm": "Otvoriť na Last.fm", + "musicbrainz": "Otvoriť na MusicBrainz" + }, + "lastfmLink": "Čítať ďalej...", + "shareOriginalFormat": "Zdieľať v pôvodnom formáte", + "shareDialogTitle": "Zdieľať %{resource} '%{name}'", + "shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}", + "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", + "shareSuccess": "URL skopírovaná do schránky: %{url}", + "shareFailure": "Chyba pri kopírovaní URL %{url} do schránky", + "downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte" + }, + "menu": { + "library": "Knižnica", + "librarySelector": { + "allLibraries": "Všetky knižnice (%{count})", + "multipleLibraries": "%{selected} z %{total} knižníc", + "selectLibraries": "Vyberte knižnice", + "none": "Žiadne" + }, + "settings": "Nastavenia", + "version": "Verzia", + "theme": "Téma", + "personal": { + "name": "Osobné", + "options": { + "theme": "Téma", + "language": "Jazyk", + "defaultView": "Predvolená stránka", + "desktop_notifications": "Oznámenia na ploche", + "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný", + "lastfmScrobbling": "Scrobblovať na Last.fm", + "listenBrainzScrobbling": "Scrobblovať na ListenBrainz", + "replaygain": "Mód ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Vypnuté", + "album": "Použiť Album Gain", + "track": "Použiť Track Gain" + } + } + }, + "albumList": "Albumy", + "playlists": "Zoznamy skladieb", + "sharedPlaylists": "Zdieľané zoznamy skladieb", + "about": "O Navidrome" + }, + "player": { + "playListsText": "Rad", + "openText": "Otvoriť", + "closeText": "Zavrieť", + "notContentText": "Žiadne skladby", + "clickToPlayText": "Kliknite pre prehranie", + "clickToPauseText": "Kliknite pre pozastavenie", + "nextTrackText": "Ďalšia skladba", + "previousTrackText": "Predchádzajúca skladba", + "reloadText": "Znovu načítať", + "volumeText": "Hlasitosť", + "toggleLyricText": "Prepnúť text", + "toggleMiniModeText": "Zmenšiť", + "destroyText": "Zničiť", + "downloadText": "Stiahnuť", + "removeAudioListsText": "Vymazať zoznam", + "clickToDeleteText": "Kliknite pre odstránenie %{name}", + "emptyLyricText": "Bez textu", + "playModeText": { + "order": "Po poradí", + "orderLoop": "Opakovať", + "singleLoop": "Opakovať raz", + "shufflePlay": "Zamiešať" + } + }, + "about": { + "links": { + "homepage": "Domovská stránka", + "source": "Zdrojový kód", + "featureRequests": "Požiadavky na funkcie", + "lastInsightsCollection": "Posledný zber štatistík", + "insights": { + "disabled": "Zakázané", + "waiting": "Čakanie" + } + }, + "tabs": { + "about": "O aplikácii", + "config": "Konfigurácia" + }, + "config": { + "configName": "Názov konfigurácie", + "environmentVariable": "Premenná prostredia", + "currentValue": "Aktuálna hodnota", + "configurationFile": "Konfiguračný súbor", + "exportToml": "Exportovať konfiguráciu (TOML)", + "downloadToml": "Stiahnuť konfiguráciu (TOML)", + "exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML", + "exportFailed": "Nepodarilo sa skopírovať konfiguráciu", + "devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)", + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách" + } + }, + "activity": { + "title": "Aktivita", + "totalScanned": "Naskenované priečinky", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken", + "selectiveScan": "Selektívne", + "serverUptime": "Doba od spustenia", + "serverDown": "OFFLINE", + "scanType": "Posledný Sken", + "status": "Chyba skenovania", + "elapsedTime": "Uplynutý čas" + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" + }, + "help": { + "title": "Klávesové skratky Navidrome", + "hotkeys": { + "show_help": "Zobraziť túto nápovedu", + "toggle_menu": "Prepnúť bočné menu", + "toggle_play": "Prehrať / Pozastaviť", + "prev_song": "Predchádzajúca skladba", + "next_song": "Nasledujúca skladba", + "current_song": "Prejsť na aktuálnu skladbu", + "vol_up": "Zvýšiť hlasitosť", + "vol_down": "Znížiť hlasitosť", + "toggle_love": "Pridať túto skladbu do obľúbených" + } + } +} \ No newline at end of file From 5cd1fcb49237defc59d3f1c9af4bd5307b7c9e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 20 Mar 2026 08:57:13 -0400 Subject: [PATCH 123/596] feat(scheduler): add crontab(5) random ~ syntax support (#5233) * feat(scheduler): add CrontabSchedule with crontab(5) random ~ syntax Implement ParseCrontab() that extends robfig/cron with support for the crontab(5) random ~ operator (e.g., 0~30 * * * *). Random values are resolved fresh on each Next() call for load spreading. Supports A~B, ~B, A~, and bare ~ forms in all 6 fields (including seconds). Expressions without ~ delegate to robfig's standard parser with zero overhead. Integrates into scheduler.Add() and conf.validateSchedule() so that scanner.schedule and backup.schedule config values accept ~ syntax. * refactor(scheduler): resolve random ~ values once at parse time Change from per-Next() randomization to per-parse randomization, matching crontab(5) semantics. This prevents double-firing within the same period when random values land after the current time. ParseCrontab now resolves ~ fields to concrete values, substitutes them into the spec string, and delegates to robfig's parser. This eliminates CrontabSchedule, randomField, and resolveField entirely. * test(scheduler): replace WaitGroup with channel for job execution synchronization Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 10 +- scheduler/crontab_schedule.go | 133 ++++++++++++++++++++ scheduler/crontab_schedule_test.go | 194 +++++++++++++++++++++++++++++ scheduler/scheduler.go | 3 +- scheduler/scheduler_test.go | 40 +++--- 5 files changed, 347 insertions(+), 33 deletions(-) create mode 100644 scheduler/crontab_schedule.go create mode 100644 scheduler/crontab_schedule_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 9efa352bd..a5c253746 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -16,8 +16,8 @@ import ( "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/utils/run" - "github.com/robfig/cron/v3" "github.com/spf13/viper" ) @@ -570,15 +570,9 @@ func validateBackupSchedule() error { } func validateSchedule(schedule, field string) (string, error) { - if _, err := time.ParseDuration(schedule); err == nil { - schedule = "@every " + schedule - } - c := cron.New() - id, err := c.AddFunc(schedule, func() {}) + _, err := scheduler.ParseCrontab(schedule) if err != nil { log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err) - } else { - c.Remove(id) } return schedule, err } diff --git a/scheduler/crontab_schedule.go b/scheduler/crontab_schedule.go new file mode 100644 index 000000000..5de1ae145 --- /dev/null +++ b/scheduler/crontab_schedule.go @@ -0,0 +1,133 @@ +package scheduler + +import ( + "fmt" + "math/rand/v2" + "strconv" + "strings" + "time" + + "github.com/robfig/cron/v3" +) + +var parser = cron.NewParser( + cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, +) + +// ParseCrontab parses a cron expression with support for the crontab(5) random ~ syntax. +// Random values are resolved once at parse time. If no ~ is present, it delegates to +// robfig/cron's standard parser. Duration strings (e.g., "5m") are converted to "@every 5m". +func ParseCrontab(spec string) (cron.Schedule, error) { + if spec == "" { + return nil, fmt.Errorf("empty spec string") + } + + if _, err := time.ParseDuration(spec); err == nil { + spec = "@every " + spec + } + + if !strings.Contains(spec, "~") { + return parser.Parse(spec) + } + + // Handle TZ=/CRON_TZ= prefix + var tzPrefix string + if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { + i := strings.Index(spec, " ") + if i == -1 { + return nil, fmt.Errorf("missing spec after timezone") + } + tzPrefix = spec[:i] + " " + spec = strings.TrimSpace(spec[i:]) + } + + // @ descriptors cannot contain ~ + if strings.HasPrefix(spec, "@") { + return nil, fmt.Errorf("random ~ syntax cannot be used with descriptors: %s", spec) + } + + fields := strings.Fields(spec) + fields, err := normalizeFields(fields) + if err != nil { + return nil, err + } + + // Resolve each ~ field to a concrete random value + for i, field := range fields { + if !strings.Contains(field, "~") { + continue + } + if strings.ContainsAny(field, ",/") { + return nil, fmt.Errorf("random ~ cannot be combined with lists or steps: %s", field) + } + v, parseErr := resolveRandomField(field, fieldBounds[i]) + if parseErr != nil { + return nil, parseErr + } + fields[i] = strconv.FormatUint(uint64(v), 10) + } + + // Re-assemble and parse with robfig + resolved := tzPrefix + strings.Join(fields, " ") + return parser.Parse(resolved) +} + +type bounds struct { + min, max uint +} + +var fieldBounds = [6]bounds{ + {0, 59}, // Second + {0, 59}, // Minute + {0, 23}, // Hour + {1, 31}, // Dom + {1, 12}, // Month + {0, 6}, // Dow +} + +// resolveRandomField parses a ~ field and returns a random value within the range. +func resolveRandomField(field string, b bounds) (uint, error) { + parts := strings.SplitN(field, "~", 2) + + min := b.min + max := b.max + + if parts[0] != "" { + v, err := strconv.ParseUint(parts[0], 10, 0) + if err != nil { + return 0, fmt.Errorf("invalid random range start: %s", parts[0]) + } + min = uint(v) + } + + if parts[1] != "" { + v, err := strconv.ParseUint(parts[1], 10, 0) + if err != nil { + return 0, fmt.Errorf("invalid random range end: %s", parts[1]) + } + max = uint(v) + } + + if min < b.min { + return 0, fmt.Errorf("random range start (%d) below minimum (%d): %s", min, b.min, field) + } + if max > b.max { + return 0, fmt.Errorf("random range end (%d) above maximum (%d): %s", max, b.max, field) + } + if min > max { + return 0, fmt.Errorf("random range start (%d) beyond end (%d): %s", min, max, field) + } + + return min + uint(rand.IntN(int(max-min+1))), nil //nolint:gosec // Cryptographic randomness not needed for schedule jitter +} + +func normalizeFields(fields []string) ([]string, error) { + switch len(fields) { + case 5: + return append([]string{"0"}, fields...), nil + case 6: + return fields, nil + default: + return nil, fmt.Errorf("expected 5 or 6 fields, found %d: %v", len(fields), fields) + } +} diff --git a/scheduler/crontab_schedule_test.go b/scheduler/crontab_schedule_test.go new file mode 100644 index 000000000..b1e26f1de --- /dev/null +++ b/scheduler/crontab_schedule_test.go @@ -0,0 +1,194 @@ +package scheduler + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/robfig/cron/v3" +) + +var _ = Describe("ParseCrontab", func() { + Describe("standard expressions", func() { + It("parses a 5-field expression", func() { + sched, err := ParseCrontab("5 * * * *") + Expect(err).ToNot(HaveOccurred()) + Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{})) + }) + + It("parses a 6-field expression with seconds", func() { + sched, err := ParseCrontab("30 5 * * * *") + Expect(err).ToNot(HaveOccurred()) + Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{})) + }) + + It("converts duration string to @every", func() { + sched, err := ParseCrontab("5m") + Expect(err).ToNot(HaveOccurred()) + Expect(sched).To(BeAssignableToTypeOf(cron.ConstantDelaySchedule{})) + }) + + It("returns error for empty string", func() { + _, err := ParseCrontab("") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("random ~ syntax", func() { + It("resolves A~B to a value within range", func() { + sched, err := ParseCrontab("0~30 * * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + minute := findSetBit(spec.Minute) + Expect(minute).To(BeNumerically(">=", 0)) + Expect(minute).To(BeNumerically("<=", 30)) + }) + + It("resolves ~ alone to full field range", func() { + sched, err := ParseCrontab("~ * * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + minute := findSetBit(spec.Minute) + Expect(minute).To(BeNumerically(">=", 0)) + Expect(minute).To(BeNumerically("<=", 59)) + }) + + It("resolves ~B as min~B", func() { + sched, err := ParseCrontab("~15 * * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + minute := findSetBit(spec.Minute) + Expect(minute).To(BeNumerically(">=", 0)) + Expect(minute).To(BeNumerically("<=", 15)) + }) + + It("resolves A~ as A~max", func() { + sched, err := ParseCrontab("15~ * * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + minute := findSetBit(spec.Minute) + Expect(minute).To(BeNumerically(">=", 15)) + Expect(minute).To(BeNumerically("<=", 59)) + }) + + It("resolves multiple random fields independently", func() { + sched, err := ParseCrontab("0~30 0~12 * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + Expect(findSetBit(spec.Minute)).To(BeNumerically("<=", 30)) + Expect(findSetBit(spec.Hour)).To(BeNumerically("<=", 12)) + }) + + It("resolves ~ in DOM field with correct bounds", func() { + sched, err := ParseCrontab("0 0 ~ * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + dom := findSetBit(spec.Dom) + Expect(dom).To(BeNumerically(">=", 1)) + Expect(dom).To(BeNumerically("<=", 31)) + }) + + It("resolves ~ in month field with correct bounds", func() { + sched, err := ParseCrontab("0 0 1 ~ *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + month := findSetBit(spec.Month) + Expect(month).To(BeNumerically(">=", 1)) + Expect(month).To(BeNumerically("<=", 12)) + }) + + It("resolves ~ in DOW field with correct bounds", func() { + sched, err := ParseCrontab("0 0 * * ~") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + dow := findSetBit(spec.Dow) + Expect(dow).To(BeNumerically(">=", 0)) + Expect(dow).To(BeNumerically("<=", 6)) + }) + + It("preserves TZ= prefix through resolution", func() { + sched, err := ParseCrontab("TZ=America/New_York 0~30 * * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + nyc, _ := time.LoadLocation("America/New_York") + Expect(spec.Location).To(Equal(nyc)) + }) + + It("preserves non-random fields", func() { + sched, err := ParseCrontab("0~30 10 * * *") + Expect(err).ToNot(HaveOccurred()) + spec := sched.(*cron.SpecSchedule) + Expect(spec.Hour & (1 << 10)).ToNot(BeZero()) + }) + + It("resolves to a stable value across repeated Next calls", func() { + sched, err := ParseCrontab("0~30 * * * *") + Expect(err).ToNot(HaveOccurred()) + + ref := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + first := sched.Next(ref) + for range 50 { + Expect(sched.Next(ref)).To(Equal(first)) + } + }) + }) + + Describe("error cases", func() { + It("rejects min > max", func() { + _, err := ParseCrontab("30~0 * * * *") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("beyond end")) + }) + + It("rejects value above field maximum", func() { + _, err := ParseCrontab("0~60 * * * *") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("above maximum")) + }) + + It("rejects value below field minimum", func() { + _, err := ParseCrontab("0 0 0~15 * *") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("below minimum")) + }) + + It("rejects ~ mixed with comma (list)", func() { + _, err := ParseCrontab("0~30,45 * * * *") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot be combined")) + }) + + It("rejects ~ mixed with slash (step)", func() { + _, err := ParseCrontab("0~30/5 * * * *") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot be combined")) + }) + + It("rejects @ descriptor with ~", func() { + _, err := ParseCrontab("@every 0~30m") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("descriptor")) + }) + + It("rejects wrong number of fields", func() { + _, err := ParseCrontab("0~30 * *") + Expect(err).To(HaveOccurred()) + }) + + It("rejects non-numeric range values", func() { + _, err := ParseCrontab("a~b * * * *") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +// findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63). +func findSetBit(v uint64) int { + v &^= 1 << 63 // clear starBit + for i := 0; i < 63; i++ { + if v&(1<<uint(i)) != 0 { + return i + } + } + return -1 +} diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index b377e7947..cb53af421 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -33,10 +33,11 @@ func (s *scheduler) Run(ctx context.Context) { } func (s *scheduler) Add(crontab string, cmd func()) (int, error) { - entryID, err := s.c.AddFunc(crontab, cmd) + schedule, err := ParseCrontab(crontab) if err != nil { return 0, err } + entryID := s.c.Schedule(schedule, cron.FuncJob(cmd)) return int(entryID), nil } diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go index 4737ae389..795f07115 100644 --- a/scheduler/scheduler_test.go +++ b/scheduler/scheduler_test.go @@ -1,19 +1,16 @@ package scheduler import ( - "sync" "testing" "time" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/robfig/cron/v3" ) func TestScheduler(t *testing.T) { - tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Scheduler Suite") @@ -33,54 +30,49 @@ var _ = Describe("Scheduler", func() { }) It("adds and executes a job", func() { - wg := sync.WaitGroup{} - wg.Add(1) + done := make(chan struct{}) - executed := false - id, err := s.Add("@every 100ms", func() { - executed = true - wg.Done() + id, err := s.Add("@every 50ms", func() { + close(done) }) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeZero()) - wg.Wait() - Expect(executed).To(BeTrue()) + Eventually(done).Should(BeClosed()) + }) + + It("adds a job with random ~ syntax", func() { + id, err := s.Add("0~59 * * * *", func() {}) + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeZero()) + s.Remove(id) }) It("removes a job", func() { - // Use a WaitGroup to ensure the job executes once - wg := sync.WaitGroup{} - wg.Add(1) + done := make(chan struct{}) counter := 0 - id, err := s.Add("@every 100ms", func() { + id, err := s.Add("@every 50ms", func() { counter++ if counter == 1 { - wg.Done() // Signal that the job has executed once + close(done) } }) - Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeZero()) - // Wait for the job to execute at least once - wg.Wait() - // Verify job executed + Eventually(done).Should(BeClosed()) Expect(counter).To(Equal(1)) // Remove the job s.Remove(id) - // Store the counter value - currentCount := counter - // Wait some time to ensure job doesn't execute again time.Sleep(200 * time.Millisecond) // Verify counter didn't increase - Expect(counter).To(Equal(currentCount)) + Expect(counter).To(Equal(1)) }) }) From 03844a9a369afd247c30bc93103cbee90319ae32 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 20 Mar 2026 18:16:07 -0400 Subject: [PATCH 124/596] feat(plugins): add NoFollowRedirects option to HTTPRequest Allow plugins to opt out of automatic redirect following on a per-request basis. When set to true, the response returns the redirect status code and Location header directly instead of following to the final destination. --- plugins/host/http.go | 11 +++++----- plugins/host_httpclient.go | 14 +++++++++++++ plugins/host_httpclient_test.go | 20 +++++++++++++++++++ plugins/pdk/go/host/nd_host_http.go | 11 +++++----- plugins/pdk/go/host/nd_host_http_stub.go | 11 +++++----- .../pdk/rust/nd-pdk-host/src/nd_host_http.rs | 2 ++ 6 files changed, 54 insertions(+), 15 deletions(-) diff --git a/plugins/host/http.go b/plugins/host/http.go index b61361c57..96e832a0e 100644 --- a/plugins/host/http.go +++ b/plugins/host/http.go @@ -4,11 +4,12 @@ import "context" // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers,omitempty"` - Body []byte `json:"body,omitempty"` - TimeoutMs int32 `json:"timeoutMs,omitempty"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + NoFollowRedirects bool `json:"noFollowRedirects,omitempty"` + Body []byte `json:"body,omitempty"` + TimeoutMs int32 `json:"timeoutMs,omitempty"` } // HTTPResponse represents the response from an outbound HTTP request. diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 4dc8a2b9e..f1d64deb7 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -22,6 +22,12 @@ const ( httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB ) +// contextKey is used for per-request redirect control via context. +type contextKey struct{} + +// noFollowRedirectsKey signals the CheckRedirect callback to stop following redirects. +var noFollowRedirectsKey = contextKey{} + // httpServiceImpl implements host.HTTPService. type httpServiceImpl struct { pluginName string @@ -44,6 +50,9 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI // Timeout is set per-request via context deadline, not here. // CheckRedirect validates hosts and enforces redirect limits. CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.Context().Value(noFollowRedirectsKey) != nil { + return http.ErrUseLastResponse + } if len(via) >= httpClientMaxRedirects { log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) return http.ErrUseLastResponse @@ -80,6 +89,11 @@ func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (* ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + // Signal CheckRedirect to not follow redirects for this request + if request.NoFollowRedirects { + ctx = context.WithValue(ctx, noFollowRedirectsKey, true) + } + // Build request body method := strings.ToUpper(request.Method) var body io.Reader diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 29796b052..27e92d59d 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -311,6 +311,26 @@ var _ = Describe("httpServiceImpl", func() { Expect(err.Error()).To(ContainSubstring("context canceled")) }) + It("should not follow redirects when NoFollowRedirects is true", func() { + dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("final")) + })) + defer dest.Close() + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, dest.URL, http.StatusFound) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + NoFollowRedirects: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(302))) + Expect(resp.Headers["Location"]).To(Equal(dest.URL)) + Expect(string(resp.Body)).ToNot(Equal("final")) + }) + It("should send request headers", func() { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(r.Header.Get("X-Custom"))) diff --git a/plugins/pdk/go/host/nd_host_http.go b/plugins/pdk/go/host/nd_host_http.go index d77db4762..d999d3718 100644 --- a/plugins/pdk/go/host/nd_host_http.go +++ b/plugins/pdk/go/host/nd_host_http.go @@ -17,11 +17,12 @@ import ( // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` - TimeoutMs int32 `json:"timeoutMs"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + NoFollowRedirects bool `json:"noFollowRedirects"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` } // HTTPResponse represents the HTTPResponse data structure. diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index b5d1eee75..2f15a91a9 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -13,11 +13,12 @@ import "github.com/stretchr/testify/mock" // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` - TimeoutMs int32 `json:"timeoutMs"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + NoFollowRedirects bool `json:"noFollowRedirects"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` } // HTTPResponse represents the HTTPResponse data structure. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index d3bb2d326..1c44cd2f3 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -38,6 +38,8 @@ pub struct HTTPRequest { #[serde(default)] pub headers: std::collections::HashMap<String, String>, #[serde(default)] + pub no_follow_redirects: bool, + #[serde(default)] #[serde(with = "base64_bytes")] pub body: Vec<u8>, #[serde(default)] From 400a079fcd54b816b4742c9a7d44eea31fcab0da Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 21 Mar 2026 19:19:03 -0400 Subject: [PATCH 125/596] fix(ui): fix hover overlay not covering full album cover Removed marginBottom: '3px' from tileBar and tileBarMobile styles that was causing the hover overlay to not fully cover the album cover art. The margin pushed the absolutely-positioned GridListTileBar up, leaving a visible gap at the bottom. This became apparent after d2a54243a added aspectRatio: 1 to the cover container. --- ui/src/album/AlbumGridView.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index c0d88d50e..6b44c5fe9 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -33,13 +33,11 @@ const useStyles = makeStyles( transition: 'all 150ms ease-out', opacity: 0, textAlign: 'left', - marginBottom: '3px', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', }, tileBarMobile: { textAlign: 'left', - marginBottom: '3px', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', }, From cb396f3dba3084ab3d581c5f7c89c353291d5d44 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 22 Mar 2026 14:54:28 -0400 Subject: [PATCH 126/596] feat(ui): increase cover art size to 600px and use CatmullRom scaling Increased the UI cover art request size from 300px to 600px for sharper images on high-DPI displays. Replaced BiLinear with CatmullRom (bicubic) interpolation for higher quality image resizing. Extracted the hardcoded size into a COVER_ART_SIZE constant in the frontend and consolidated backend sizes into a CacheWarmerImageSizes slice. Removed the unused UIThumbnailSize constant. Signed-off-by: Deluan <deluan@navidrome.org> --- consts/consts.go | 8 ++++++-- core/artwork/cache_warmer.go | 9 ++++----- core/artwork/cache_warmer_test.go | 4 ++-- core/artwork/reader_playlist.go | 2 +- core/artwork/reader_resized.go | 2 +- ui/src/album/AlbumDetails.jsx | 3 ++- ui/src/album/AlbumGridView.jsx | 4 ++-- ui/src/artist/DesktopArtistDetails.jsx | 3 ++- ui/src/artist/MobileArtistDetails.jsx | 3 ++- ui/src/common/CoverArtAvatar.jsx | 3 ++- ui/src/consts.js | 2 ++ ui/src/playlist/PlaylistDetails.jsx | 3 ++- ui/src/radio/RadioEdit.jsx | 4 ++-- ui/src/radio/helper.jsx | 4 ++-- ui/src/subsonic/index.test.js | 21 +++++++++++---------- 15 files changed, 43 insertions(+), 32 deletions(-) diff --git a/consts/consts.go b/consts/consts.go index 6fb6c5dac..f1010a872 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -70,8 +70,6 @@ const ( PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAvatar = "logo-192x192.png" - UICoverArtSize = 300 - UIThumbnailSize = 80 DefaultUIVolume = 100 DefaultUISearchDebounceMs = 200 @@ -86,6 +84,12 @@ const ( Zwsp = string('\u200b') ) +const ( + UICoverArtSize = 600 +) + +var CacheWarmerImageSizes = []int{UICoverArtSize} + // Prometheus options const ( PrometheusDefaultPath = "/metrics" diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index 83c98c806..bd1359b74 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -142,15 +142,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - for _, size := range []int{consts.UICoverArtSize, consts.UIThumbnailSize} { + for _, size := range consts.CacheWarmerImageSizes { r, _, err := a.artwork.Get(ctx, id, size, true) if err != nil { return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) } - defer r.Close() - if _, err = io.Copy(io.Discard, r); err != nil { - return err - } + _, err = io.Copy(io.Discard, r) + r.Close() + return err } return nil } diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 6ddda00d6..9798ea8d6 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -176,13 +176,13 @@ var _ = Describe("CacheWarmer", func() { }).Should(Equal(0)) }) - It("pre-caches both UICoverArtSize and UIThumbnailSize", func() { + It("pre-caches UICoverArtSize", func() { cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw.PreCache(model.MustParseArtworkID("al-1")) Eventually(func() []int { return aw.getCachedSizes() - }).Should(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) + }).Should(ContainElements(consts.UICoverArtSize)) }) }) }) diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index 7919eead6..09707843d 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -264,6 +264,6 @@ func fillCenter(src image.Image, dstW, dstH int) image.Image { } dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - xdraw.BiLinear.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) return dst } diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 7e90f10e9..72baad434 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -155,7 +155,7 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dstRect = dst.Bounds() } - xdraw.BiLinear.Scale(dst, dstRect, original, bounds, draw.Src, nil) + xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) buf := bufPool.Get().(*bytes.Buffer) buf.Reset() diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index c5d9a7ac4..2411b8611 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -18,6 +18,7 @@ import { useTranslate, } from 'react-admin' import Lightbox from 'react-image-lightbox' +import { COVER_ART_SIZE } from '../consts' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' import { @@ -254,7 +255,7 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, 300) + const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 6b44c5fe9..e90e7a77b 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -19,7 +19,7 @@ import { ArtistLinkField, OverflowTooltip, } from '../common' -import { DraggableTypes } from '../consts' +import { COVER_ART_SIZE, DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' @@ -157,7 +157,7 @@ const Cover = withContentRect('bounds')(({ <div ref={dragAlbumRef}> <img key={record.id} // Force re-render when record changes - src={subsonic.getCoverArtUrl(record, 300, true)} + src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)} alt={record.name} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onLoad={handleImageLoad} diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 7052b1634..bc2312477 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -15,6 +15,7 @@ import { import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' +import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -109,7 +110,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { <CardMedia key={record.id} component="img" - src={subsonic.getCoverArtUrl(record, 300)} + src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} onLoad={handleImageLoad} diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 03cc4de8f..9c6cd88ae 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -11,6 +11,7 @@ import { useImageLoadingState, } from '../common' import Lightbox from 'react-image-lightbox' +import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -112,7 +113,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { <CardMedia key={record.id} component="img" - src={subsonic.getCoverArtUrl(record, 300)} + src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} onLoad={handleImageLoad} diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index 6610cb75e..5642f2504 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -2,6 +2,7 @@ import { useRecordContext } from 'react-admin' import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' +import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' const useStyles = makeStyles({ @@ -25,7 +26,7 @@ export const CoverArtAvatar = ({ const square = variant !== 'circular' return ( <Avatar - src={subsonic.getCoverArtUrl(record, 80, square)} + src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)} variant={variant} className={clsx(classes.avatar, square && classes.square)} alt={record.name} diff --git a/ui/src/consts.js b/ui/src/consts.js index 472cd4940..68ec478f1 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -26,6 +26,8 @@ DraggableTypes.ALL.push( export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' +export const COVER_ART_SIZE = 600 + export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 911e6c716..f2f528d4b 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -18,6 +18,7 @@ import { OverflowTooltip, useImageLoadingState, } from '../common' +import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' const useStyles = makeStyles( @@ -106,7 +107,7 @@ const PlaylistDetails = (props) => { handleCloseLightbox, } = useImageLoadingState(record.id) - const imageUrl = subsonic.getCoverArtUrl(record, 300, true) + const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index 6b1d2df79..5f804535a 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -11,7 +11,7 @@ import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' import subsonic from '../subsonic' -import { RADIO_PLACEHOLDER_IMAGE } from '../consts' +import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' const useStyles = makeStyles({ coverParent: { @@ -83,7 +83,7 @@ const RadioCoverArt = ({ record }) => { {record.uploadedImage ? ( <CardMedia component="img" - src={subsonic.getCoverArtUrl(record, 300, true)} + src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onLoad={handleImageLoad} onError={handleImageError} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx index 4c313d3bc..b278c0d7d 100644 --- a/ui/src/radio/helper.jsx +++ b/ui/src/radio/helper.jsx @@ -1,5 +1,5 @@ import subsonic from '../subsonic' -import { RADIO_PLACEHOLDER_IMAGE } from '../consts' +import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' export async function songFromRadio(radio) { if (!radio) { @@ -8,7 +8,7 @@ export async function songFromRadio(radio) { let cover = RADIO_PLACEHOLDER_IMAGE if (radio.uploadedImage) { - cover = subsonic.getCoverArtUrl(radio, 300, true) + cover = subsonic.getCoverArtUrl(radio, COVER_ART_SIZE, true) } else { // Try favicon as fallback try { diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 2544e03f8..38b910b08 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -1,4 +1,5 @@ import { vi } from 'vitest' +import { COVER_ART_SIZE } from '../consts' import subsonic from './index' describe('getCoverArtUrl', () => { @@ -30,10 +31,10 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) + const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) expect(url).toContain('pl-playlist-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') }) @@ -44,10 +45,10 @@ describe('getCoverArtUrl', () => { sync: true, } - const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) + const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) expect(url).toContain('pl-playlist-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') expect(url).not.toContain('_=') }) @@ -59,10 +60,10 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(albumRecord, 300, true) + const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true) expect(url).toContain('al-album-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') }) @@ -73,10 +74,10 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(songRecord, 300, true) + const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true) expect(url).toContain('mf-song-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') }) @@ -86,10 +87,10 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(artistRecord, 300, true) + const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true) expect(url).toContain('ar-artist-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') }) From 03608d3eef7b62768983d10cd92c21a36e64ed18 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 22 Mar 2026 15:20:15 -0400 Subject: [PATCH 127/596] feat(subsonic): add coverArt to internetRadioStation response Add OpenSubsonic coverArt extension to GetInternetRadios, showing uploaded radio images for non-legacy clients. Ref: https://github.com/opensubsonic/open-subsonic-api/pull/224 Signed-off-by: Deluan <deluan@navidrome.org> --- server/subsonic/radio.go | 12 ++ server/subsonic/radio_test.go | 146 +++++++++++++++++++++++++ server/subsonic/responses/responses.go | 13 ++- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 server/subsonic/radio_test.go diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index c66268344..7121566f9 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -2,8 +2,11 @@ package subsonic import ( "net/http" + "strings" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" ) @@ -66,6 +69,15 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro StreamUrl: g.StreamUrl, HomepageUrl: g.HomePageUrl, } + + player, _ := request.PlayerFrom(ctx) + if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { + continue + } + // Add coverArt if not legacy client + res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ + CoverArt: g.UploadedImage, + } } response := newResponse() diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go new file mode 100644 index 000000000..d5b764f60 --- /dev/null +++ b/server/subsonic/radio_test.go @@ -0,0 +1,146 @@ +package subsonic + +import ( + "context" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio", func() { + var api *Router + var ds *tests.MockDataStore + var ctx context.Context + var radioRepo *tests.MockedRadioRepo + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + api = &Router{ds: ds} + ctx = context.Background() + radioRepo = tests.CreateMockedRadioRepo() + ds.MockedRadio = radioRepo + }) + + Describe("GetInternetRadios", func() { + BeforeEach(func() { + radioRepo.All = model.Radios{ + {ID: "rd-1", Name: "Radio 1", StreamUrl: "http://stream1.example.com", HomePageUrl: "http://home1.example.com", UploadedImage: "rd-1_cover.jpg"}, + {ID: "rd-2", Name: "Radio 2", StreamUrl: "http://stream2.example.com"}, + } + }) + + It("returns all radios with basic fields", func() { + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations).ToNot(BeNil()) + Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) + Expect(response.InternetRadioStations.Radios[0].ID).To(Equal("rd-1")) + Expect(response.InternetRadioStations.Radios[0].Name).To(Equal("Radio 1")) + Expect(response.InternetRadioStations.Radios[0].StreamUrl).To(Equal("http://stream1.example.com")) + Expect(response.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("http://home1.example.com")) + Expect(response.InternetRadioStations.Radios[1].ID).To(Equal("rd-2")) + Expect(response.InternetRadioStations.Radios[1].HomepageUrl).To(BeEmpty()) + }) + + Context("with a non-legacy client", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "modern-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("includes coverArt from UploadedImage", func() { + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) + Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) + Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) + }) + }) + + Context("with a legacy client", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("does not include coverArt", func() { + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) + Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).To(BeNil()) + Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).To(BeNil()) + }) + }) + + Context("when no player in context", func() { + It("does not include coverArt (empty client matches legacy list)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Subsonic.LegacyClients = "legacy-client" + + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).To(BeNil()) + }) + }) + + Context("when legacy clients list is empty", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Subsonic.LegacyClients = "" + player := model.Player{Client: "any-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("includes coverArt for all clients", func() { + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg")) + }) + }) + + It("returns error when repository fails", func() { + radioRepo.SetError(true) + + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + _, err := api.GetInternetRadios(r) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b9a39b6f9..f0bb26f66 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -509,10 +509,15 @@ type InternetRadioStations struct { } type Radio struct { - ID string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"` - HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"` + ID string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"` + HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"` + *OpenSubsonicRadio `xml:",omitempty" json:",omitempty"` +} + +type OpenSubsonicRadio struct { + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt"` } type JukeboxStatus struct { From d91b5e8f4daeae4ee9070b5672398aeed09ea419 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 23 Mar 2026 11:39:52 -0400 Subject: [PATCH 128/596] refactor: simplify playlist name extraction using strings.CutPrefix --- core/playlists/parse_m3u.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 97ed7df6f..b9f5c92a2 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -31,8 +31,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m filteredLines := make([]string, 0, len(lines)) for _, line := range lines { line := strings.TrimSpace(line) - if strings.HasPrefix(line, "#PLAYLIST:") { - pls.Name = line[len("#PLAYLIST:"):] + if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok { + pls.Name = after continue } if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { From 4cca7bce4e36123ca8905115ca819c048f42f500 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 23 Mar 2026 11:59:11 -0400 Subject: [PATCH 129/596] test: increase FlakeAttempts for library directory tests and remove flaky job test --- plugins/host_library_test.go | 4 ++-- scheduler/scheduler_test.go | 28 ---------------------------- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index d92abfe30..5746a3bed 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -544,7 +544,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { // Note: This test is slightly flaky due to a potential race condition in wazero's // WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts // to automatically retry on failure. - It("should read file from mounted library directory", FlakeAttempts(3), func() { + It("should read file from mounted library directory", FlakeAttempts(5), func() { ctx := GinkgoT().Context() output, err := callTestLibrary(ctx, testLibraryInput{ @@ -557,7 +557,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { }) // Note: Uses FlakeAttempts for the same reason as the read_file test above - It("should list files in mounted library directory", FlakeAttempts(3), func() { + It("should list files in mounted library directory", FlakeAttempts(5), func() { ctx := GinkgoT().Context() output, err := callTestLibrary(ctx, testLibraryInput{ diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go index 795f07115..1a134a7f3 100644 --- a/scheduler/scheduler_test.go +++ b/scheduler/scheduler_test.go @@ -2,7 +2,6 @@ package scheduler import ( "testing" - "time" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" @@ -48,31 +47,4 @@ var _ = Describe("Scheduler", func() { Expect(id).ToNot(BeZero()) s.Remove(id) }) - - It("removes a job", func() { - done := make(chan struct{}) - - counter := 0 - id, err := s.Add("@every 50ms", func() { - counter++ - if counter == 1 { - close(done) - } - }) - Expect(err).ToNot(HaveOccurred()) - Expect(id).ToNot(BeZero()) - - // Verify job executed - Eventually(done).Should(BeClosed()) - Expect(counter).To(Equal(1)) - - // Remove the job - s.Remove(id) - - // Wait some time to ensure job doesn't execute again - time.Sleep(200 * time.Millisecond) - - // Verify counter didn't increase - Expect(counter).To(Equal(1)) - }) }) From 221d301c4249694e2b388dc1b6ef46dcb19804be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:19:16 -0400 Subject: [PATCH 130/596] chore(deps): bump nick-fields/retry from 3 to 4 in /.github/workflows (#5241) Bumps [nick-fields/retry](https://github.com/nick-fields/retry) from 3 to 4. - [Release notes](https://github.com/nick-fields/retry/releases) - [Commits](https://github.com/nick-fields/retry/compare/v3...v4) --- updated-dependencies: - dependency-name: nick-fields/retry dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index eb7523d4e..2529aaf36 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -338,7 +338,7 @@ jobs: hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Create manifest list and push to Docker Hub - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 5 max_attempts: 3 From 8a19fa9991f486034dafcd9f259517ca465d55bb Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:09:59 +0000 Subject: [PATCH 131/596] fix(server): require additional variable to enable systemd logging (#5222) * fix(logging): require additional variable to enable systemd logging * use a better name --- cmd/svc.go | 1 + conf/configuration.go | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/svc.go b/cmd/svc.go index e277bd459..89ca08056 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -248,6 +248,7 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}} TimeoutStopSec=20 RestartSec=120 EnvironmentFile=-/etc/sysconfig/{{.Name}} +Environment="ND_SYSTEMD_PRIORITY_LOGGING=1" DevicePolicy=closed NoNewPrivileges=yes diff --git a/conf/configuration.go b/conf/configuration.go index a5c253746..5f74d6db0 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -341,9 +341,11 @@ func Load(noConfigDump bool) { os.Exit(1) } log.SetOutput(out) - } else if os.Getenv("JOURNAL_STREAM") != "" { + } else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" { // When running under systemd, prepend syslog priority prefixes so // journald assigns the correct severity to each log line. + // Note that we have an additional environment variable, as JOURNAL_STREAM + // can be present in a systemd environment even if not running as a systemd service log.EnableJournalFormat() } From 356b0716b69b5648df53613c7f3d905741f34af8 Mon Sep 17 00:00:00 2001 From: Tom Boucher <trekkie@nomorestars.com> Date: Mon, 23 Mar 2026 18:32:05 -0400 Subject: [PATCH 132/596] fix(scanner): exclude Vorbis VERSION from albumversion tag mapping (#5194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vorbis/FLAC VERSION field is for track-level disambiguation (e.g. remix, live, 30s edit), not album versioning. Including it in the albumversion aliases caused albums to split incorrectly when tracks had different VERSION values and no MusicBrainz Album ID was set. Remove 'version' from the albumversion aliases in mappings.yaml. Users who want the old behavior can re-add it via Tags config. Update the PID test to use 'albumversion' directly instead of 'version' as the raw PID attribute, with a realistic value. Fixes #5082 Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- model/metadata/persistent_ids_test.go | 12 ++++++------ resources/mappings.yaml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index ad81eaa53..9f1dacbd4 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -65,7 +65,7 @@ var _ = Describe("getPID", func() { Context("calculated attributes", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate" + conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" }) When("field is title", func() { It("should return the pid", func() { @@ -88,13 +88,13 @@ var _ = Describe("getPID", func() { It("should return the pid", func() { spec := "albumid|title" md.tags = map[model.TagName][]string{ - "title": {"title"}, - "album": {"album name"}, - "version": {"version"}, - "releasedate": {"2021-01-01"}, + "title": {"title"}, + "album": {"album name"}, + "albumversion": {"deluxe edition"}, + "releasedate": {"2021-01-01"}, } mf.AlbumArtist = "Album Artist" - Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))")) + Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))")) }) }) When("field is albumartistid", func() { diff --git a/resources/mappings.yaml b/resources/mappings.yaml index d1da5c620..19ba0b090 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -81,7 +81,7 @@ main: albumsort: aliases: [ tsoa, albumsort, soal, wm/albumsortorder ] albumversion: - aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment, version] + aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment] album: true genre: aliases: [ tcon, genre, ©gen, wm/genre, ignr ] From 0a0f1779cbe32f80606d6a94a7cc587ee9676bdd Mon Sep 17 00:00:00 2001 From: cafecitopuro <60331056+cafecitopuro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:39:02 +0000 Subject: [PATCH 133/596] feat(ui): add Nutball theme (#4544) * add nutball theme * fix album grid outline * cleanup * Pagination fix + accent color update * Fix animation on Activity icon * style: fix prettier formatting in nutball theme --------- Co-authored-by: Deluan <deluan@navidrome.org> --- ui/src/themes/index.js | 2 + ui/src/themes/nutball.css.js | 380 +++++++++++++++++++ ui/src/themes/nutball.js | 684 +++++++++++++++++++++++++++++++++++ 3 files changed, 1066 insertions(+) create mode 100644 ui/src/themes/nutball.css.js create mode 100644 ui/src/themes/nutball.js diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index d0eb742f8..f65948438 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -11,6 +11,7 @@ import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' +import NutballTheme from './nutball' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' @@ -33,6 +34,7 @@ export default { NautilineTheme, NordTheme, NuclearTheme, + NutballTheme, SpotifyTheme, SquiddiesGlassTheme, } diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js new file mode 100644 index 000000000..aaf1c0e16 --- /dev/null +++ b/ui/src/themes/nutball.css.js @@ -0,0 +1,380 @@ +const stylesheet = ` +html { + scrollbar-width: none; +} +body { + -ms-overflow-style: none; + font-family: monospace; +} +body::-webkit-scrollbar, body::-webkit-scrollbar-button { + display: none; +} +.react-jinke-music-player-main .music-player-panel { + background-color: white!important; + box-shadow: none; + font-family: monospace; + color: black; + border-top: 1px solid black; +} +.react-jinke-music-player-main .music-player-panel .panel-content div.img-content { + animation: none; + box-shadow: none; + border-radius: 5px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content { + flex: 0 0 auto; + width: calc(50% - 150px); + margin-left: 10px; + padding: 0; +} +section.audio-main { + position: absolute; + width: calc(100% - 131px)!important; + bottom: 0; + margin-bottom: 10px; +} +span.audio-title { + margin-bottom: 20px; +} +span.audio-title .songTitle { + color: black!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content { + flex: 1; + margin-bottom: 20px; + padding-left: 0; +} +div.player-content > span:first-child { + flex: 1!important; + justify-content: flex-start!important; +} +div.player-content > span:first-child svg { + width: 50px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content > .group { + flex: 0; +} +.play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn { + margin-left: 0!important; +} +.play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn svg { + width: 20px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + padding: 0; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-icon svg { + height: .75em; +} +.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .current-time, .react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .duration { + flex-basis: 0; +} +.progress-bar > div:nth-child(2) > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} +.progress-load-bar { + display: none; +} +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sound-operation { + width: 60px; +} +.rc-slider { + border-radius: 0px; + border: 1px solid black; + padding: 3px 0!important; +} +.rc-slider .rc-slider-handle { + box-shadow: none!important; + border-radius: 0px; + background-color: black!important; + border: hidden!important; +} +.rc-slider[style*="left: 0%"] { + transform: translateX(0) !important; +} +.rc-slider .rc-slider-track { + display: none; +} +.react-jinke-music-player-main .rc-slider-rail, .react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: white!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { + margin-right: 10px; +} +.lyric-btn { + display: none!important; +} +button[data-testid="save-queue-button"] { + display: none!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + box-shadow: 0 0 0 0; + margin: 0; + margin-left: -8px; + margin-right: -5px; +} +.audio-lists-btn:hover span, +.audio-lists-btn:hover svg { + color: #a8fe40!important; +} +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: white!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-num { + color: grey; + margin-left: 5px; + font-size: .7rem; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .hide-panel { + margin-left: 2px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .hide-panel svg { + stroke-width: 15px; + stroke: #fff; + height: .8em; +} +@media screen and (max-width: 810px) { + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { + margin-left: 5px; + margin-right: 0; + } + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .loop-btn { + margin-left: 5px; + } + .play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn { + margin-left: -3px!important; + } +} +.panel-content li { + flex-grow: 0; +} +.react-jinke-music-player .music-player-controller, +.react-jinke-music-player-main.light-theme .music-player-controller { + border-radius: 5px; + box-shadow: none; +} +.react-jinke-music-player .music-player-controller:hover, +.react-jinke-music-player .music-player-controller:has(+ .destroy-btn:hover) { + border: 1px solid black; +} +.react-jinke-music-player .music-player-controller .controller-title, +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + display: none; +} +.react-jinke-music-player .music-player-controller.music-player-playing:before { + animation: none; + border: none; +} +@media screen and (max-width:767px) { + .react-jinke-music-player .music-player .destroy-btn { + right: 0; + } + .react-jinke-music-player-main .destroy-btn svg { + font-size: 10px; + } +} +.react-jinke-music-player-main svg { + transition: none; +} +.react-jinke-music-player-main svg, .react-jinke-music-player-main.light-theme svg { + color: black; +} +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover, +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #a8fe40; +} +.react-jinke-music-player-main .play-mode-title { + font-family: monospace; + background-color: white; + color: black; +} +.react-jinke-music-player-mobile, +.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile { + font-family: monospace; + background-color: rgba(255, 255, 255, .9); + color: black!important; + justify-content: center; + padding: 50px; +} +.react-jinke-music-player-mobile:before { + content: " "; + display: block; + position: absolute; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + text-align: center; + width: 90%; + height: 700px; + background-color: white; + border: 1px solid black; + z-index: -1; + border-radius: 4px; +} +.react-jinke-music-player-mobile-header { + align-items: start; + margin-bottom: 4rem; + justify-content: start; +} +.react-jinke-music-player-mobile-header-title { + text-align: left; + padding: 0; +} +.react-jinke-music-player-mobile-header-right { + color: black; +} +.react-jinke-music-player-mobile > .group { + flex: 0; +} +.react-jinke-music-player-mobile-cover, +.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-cover { + border-radius: 5px; + box-shadow: none; + animation: none; + border: 1px solid black; + margin: 0 auto 4rem auto; + width: auto; + height: auto; +} +.react-jinke-music-player-mobile-cover .cover { + animation: none; +} +.react-jinke-music-player-mobile-progress .current-time { + /* margin-right: 17px; */ +} +.react-jinke-music-player-mobile-progress .current-time, .react-jinke-music-player-mobile-progress .duration { + color: black!important; +} +.react-jinke-music-player-mobile-progress .rc-slider { + height: 24px; +} +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: 2px solid black; + margin-top: -4px; + height: 24px; + width: 24px; +} +.react-jinke-music-player-mobile-toggle { + margin-bottom: 1rem; + padding: 2rem 0; +} +.react-jinke-music-player-mobile-operation .items .item svg { + color: black!important; + font-size: 2rem; + width: 2rem; +} +.react-jinke-music-player-mobile-operation .items .item svg:hover, +.react-jinke-music-player-mobile-operation .items .item button:hover svg { + color: #a8fe40!important; +} +.react-jinke-music-player-mobile-operation .items .item .MuiIconButton-root:hover { + background-color: rgba(0, 0, 0, 0.0); +} +.react-jinke-music-player-mobile-operation .MuiButtonBase-root.Mui-disabled { + cursor: pointer; + pointer-events: auto; +} +.react-jinke-music-player-mobile-operation .items li:nth-child(5) svg { + font-size: 1.4rem; +} +.react-jinke-music-player-mobile-operation .items li:nth-child(5) svg g path:nth-child(2) { + stroke-width: .4px; +} +.react-jinke-music-player-mobile-operation .items li:nth-child(2), +.react-jinke-music-player-mobile-operation .items li:nth-child(3) { + display: none; +} +.react-jinke-music-player-mobile-play-model-tip { + display: none; +} +.audio-lists-panel { + overflow-y: scroll; + scrollbar-width: none; + border-radius: .625rem; + bottom: 6.25rem; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel { + font-family: monospace; + box-shadow: none; + border: 1px solid black; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel-header { + text-shadow: none; + border-bottom: 1px solid black; +} +.audio-lists-panel-header-line { + width: 0; +} +.audio-lists-panel-header-close-btn:hover svg { + animation: none; +} +.audio-lists-panel-content .audio-item, +.react-jinke-music-player-main.light-theme .audio-item { + border-radius: 0px; + margin: 0; + border-bottom: none; + box-shadow: none; + transition: none; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: white!important; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-lists-panel-content .audio-item:nth-child(2n+1):hover { + background-color: #fafafa!important; +} +.audio-lists-panel-content .audio-item .player-singer { + width: unset; + padding-right: 20px; +} +.audio-lists-panel-content .audio-item .player-delete:hover svg { + color: #a8fe40!important; + animation: none; +} +.react-jinke-music-player-main .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, +.react-jinke-music-player-main .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg, +.react-jinke-music-player-main.light-theme .audio-item:active svg, +.react-jinke-music-player-main.light-theme .audio-item:hover svg { + color: black; +} +.audio-lists-panel-content .audio-item .player-delete { + justify-content: center; + width: 25px; +} +.audio-lists-panel-content .audio-item .player-delete svg { + font-size: 20px; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { + color: #a8fe40!important; +} +.audio-lists-panel-content .audio-item .player-name, +.audio-lists-panel-content .audio-item .player-singer, +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer, +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing .player-delete svg { + color: black!important; +} +.audio-lists-panel-mobile { + height: 750px !important; + top: calc(100vh / 2 - 375px) !important; + width: 91% !important; + margin: 0 auto; +} +.audio-lists-panel-mobile .audio-lists-panel-content { + height: auto!important; +} +.audio-lists-panel-content { + scrollbar-width: none; +} +@keyframes fromOut { + 0% { + transform:scale(1) translateZ(0) + } + to { + transform:scale(1) translate3d(0,150%,0); + } +} +` +export default stylesheet diff --git a/ui/src/themes/nutball.js b/ui/src/themes/nutball.js new file mode 100644 index 000000000..f43250f5c --- /dev/null +++ b/ui/src/themes/nutball.js @@ -0,0 +1,684 @@ +import stylesheet from './nutball.css.js' + +export default { + themeName: 'Nutball', + palette: { + primary: { + main: '#80ea00', + light: '#fff', + }, + secondary: { + main: '#80ea00', + contrastText: '#fff', + }, + }, + typography: { + fontFamily: 'monospace', + h6: { + fontSize: '1rem', + }, + h4: { + fontSize: '1.2rem', + }, + h1: { + fontSize: '1.4rem', + }, + body: { + fontFamily: 'monospace', + }, + }, + overrides: { + MuiAppBar: { + root: { + borderBottom: '1px solid black', + }, + colorSecondary: { + color: 'black', + backgroundColor: 'white', + }, + }, + MuiPaper: { + elevation1: { + boxShadow: 'none', + }, + elevation4: { + boxShadow: 'none', + }, + elevation6: { + boxShadow: 'none', + }, + elevation8: { + boxShadow: 'none', + border: '1px solid black', + }, + elevation16: { + boxShadow: 'none', + borderRight: '1px solid grey!important', + }, + elevation24: { + boxShadow: 'none', + border: '1px solid black', + }, + }, + MuiButton: { + root: { + color: '#80ea00', + border: '1px solid rgba(0, 0, 0, 0.23)', + transition: 'none', + '&[aria-label="Grid"]': { + width: '50%', + marginLeft: '15px', + marginRight: '2px', + marginBottom: '10px', + '& .MuiButton-label': { + justifyContent: 'center', + }, + }, + '&[aria-label="Table"]': { + width: '50%', + marginRight: '15px', + marginLeft: '2px', + marginBottom: '10px', + '& .MuiButton-label': { + justifyContent: 'center', + }, + }, + }, + textPrimary: { + color: 'rgba(0,0,0,.57)', + '&:hover': { + borderColor: 'black', + backgroundColor: '#eaeaea', + }, + '&[aria-label="Grid"]': { + color: 'black', + borderColor: 'black!important', + }, + '&[aria-label="Table"]': { + color: 'black', + borderColor: 'black!important', + }, + }, + textSecondary: { + color: 'rgba(0,0,0,.57)', + '&:hover': { + borderColor: 'black', + backgroundColor: '#eaeaea', + }, + '&[aria-label="Grid"]': { + color: 'grey', + borderColor: 'grey!important', + }, + '&[aria-label="Table"]': { + color: 'grey', + borderColor: 'grey!important', + }, + }, + label: { + '& svg': { + display: 'none', + }, + '& span': { + paddingLeft: '0', + }, + }, + contained: { + boxShadow: 'none', + '&:hover': { + boxShadow: 'none', + }, + }, + }, + MuiButtonGroup: { + groupedTextHorizontal: { + justifyContent: 'flex-start', + margin: '0 .5rem', + '& button': { + width: '25%', + }, + }, + groupedTextPrimary: { + '&:not(:last-child)': { + border: 'none', + }, + }, + }, + MuiIconButton: { + root: { + '&[aria-label="Settings"]': { + padding: '12px!important', + marginRight: '-9px!important', + }, + }, + }, + MuiSwitch: { + thumb: { + color: '#eaeaea', + boxShadow: 'none', + borderRadius: '0', + }, + track: { + borderRadius: '0', + }, + switchBase: { + color: '#eaeaea', + }, + }, + MuiCheckbox: { + root: { + '& svg': { + width: '.8em', + }, + }, + }, + PrivateSwitchBase: { + root: { + padding: '8px 8px', + }, + }, + RaButton: { + button: { + marginRight: '10px', + lineHeight: 'normal', + }, + }, + MuiMenu: { + list: { + '& p': { + fontSize: '.85rem', + }, + '& p:first-of-type': { + margin: '6px 1rem', + }, + '& li:has(span.MuiCheckbox-root)': { + marginLeft: '-10px', + }, + '& span.MuiCheckbox-root .MuiSvgIcon-root': { + width: '.75em', + }, + }, + }, + MuiMenuItem: { + root: { + fontSize: '.85rem', + minHeight: 'inherit', + '&[aria-label="Clear value"]:before': { + display: 'block', + content: "'(any)'", + }, + }, + }, + MuiListItem: { + button: { + '& span.MuiCheckbox-root': { + padding: '0px 8px', + }, + }, + }, + MuiTooltip: { + tooltip: { + backgroundColor: 'rgb(117 117 117)', + }, + }, + MuiCircularProgress: { + root: { + color: '#80ea00!important', + }, + }, + MuiAvatar: { + img: { + borderRadius: '5px', + }, + }, + MuiFab: { + root: { + boxShadow: 'none', + }, + }, + MuiTableHead: { + root: { + boxShadow: 'none!important', + }, + }, + MuiTableCell: { + root: { + borderBottom: 'none', + }, + sizeSmall: { + '&:last-child': { + textAlign: 'right', + }, + '&:last-child:is(th)': { + paddingRight: '45px', + }, + }, + }, + MuiTablePagination: { + root: { + fontSize: '.6rem', + }, + caption: { + fontSize: '.6rem', + }, + menuItem: { + fontSize: '.6rem', + }, + }, + MuiTabs: { + root: { + marginBottom: '1rem', + }, + }, + MuiToolbar: { + gutters: { + '@media (min-width: 600px)': { + paddingLeft: '16px', + }, + }, + }, + RaListToolbar: { + toolbar: { + alignItems: 'start', + '& form:has(> div:nth-child(3))': { + paddingBottom: '.6rem', + }, + }, + actions: { + paddingRight: '0!important', + marginTop: '-8px', + textWrap: 'nowrap', + '@media (max-width: 599.95px)': { + marginTop: '3px', + }, + '& .MuiButton-text': { + height: '2.5rem', + padding: '7px 10px', + marginRight: '0', + }, + }, + }, + RaTopToolbar: { + root: { + '& div:first-of-type > div:first-of-type': { + display: 'flex', + flexWrap: 'wrap', + rowGap: '10px', + }, + '& div:first-of-type > div:first-of-type button': { + height: '2rem', + }, + '& div:first-of-type > div:first-of-type .MuiIconButton-root': { + padding: '0', + marginRight: '2rem', + }, + '& div:first-of-type > div:nth-of-type(2)': { + height: '2rem', + }, + }, + }, + RaToolbar: { + toolbar: { + backgroundColor: 'white', + }, + }, + RaFilterButton: { + root: { + textWrap: 'nowrap', + '& button': { + '@media (max-width: 599.95px)': { + padding: '12px', + }, + }, + "&[resource*='song']": { + marginLeft: '10px', + }, + }, + }, + RaDeleteWithUndoButton: { + deleteButton: { + color: 'rgba(0,0,0,.57)', + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + }, + }, + }, + RaAutocompleteSuggestionList: { + suggestionsContainer: { + borderRadius: '4px', + outline: '1px solid black', + backgroundColor: 'white', + }, + }, + RaEmpty: { + message: { + marginTop: '3rem', + }, + icon: { + display: 'none', + }, + }, + RaAutocompleteArrayInput: { + chipContainerOutlined: { + '&:empty': { + margin: '0', + }, + margin: '10px 0', + }, + chip: { + margin: '4px 4px 4px 0!important', + }, + inputInput: { + flexGrow: '0', + '& #genre_id': { + flexGrow: '0', + }, + }, + }, + RaLayout: { + content: { + width: '100%', + }, + }, + RaDatagrid: { + headerCell: { + fontWeight: 'bold', + }, + }, + NDAlbumShow: { + albumActions: { + padding: '0', + alignItems: 'center', + margin: '1rem 0', + }, + }, + MuiCardContent: { + root: { + fontFamily: 'monospace', + fontSize: '.8rem', + '& #now-playing-title': { + fontSize: '.8rem', + }, + '&:last-child': { + paddingBottom: '16px', + }, + '&[class*="makeStyles-usernameWrap-"]': { + paddingBottom: '16px', + }, + }, + }, + MuiDialogContent: { + root: { + '& .MuiTableCell-sizeSmall:last-child': { + textAlign: 'left', + }, + }, + }, + MuiGridList: { + root: { + '&:empty': { + display: 'none', + }, + backgroundColor: 'white', + borderRadius: '4px', + }, + }, + MuiGridListTile: { + root: { + '@media (max-width: 599.95px)': { + padding: '7px!important', + }, + }, + tile: { + '& img': { + borderRadius: '5px', + }, + }, + }, + NDAlbumGridView: { + root: { + '&:has(.MuiGridList-root:empty)': { + display: 'none', + }, + }, + albumContainer: { + border: '1px solid white', + borderRadius: '5px', + '& a:hover img': { + outline: '1px solid black', + }, + '& a:hover > div:nth-of-type(2)': { + border: 'none', + outline: '1px solid black', + }, + }, + albumLink: { + paddingRight: '6px', + }, + albumSubtitle: { + fontFamily: 'monospace', + }, + tileBar: { + transition: 'all 50ms ease-out', + }, + tileBarMobile: { + transition: 'all 50ms ease-out', + borderLeft: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + }, + }, + MuiGridListTileBar: { + root: { + height: '30px!important', + background: 'white!important', + borderTop: '1px solid black', + borderBottom: '1px solid black', + borderRadius: '0 0 5px 5px', + }, + titleWrap: { + marginLeft: '0px', + }, + titlePositionBottom: { + bottom: '0', + }, + subtitle: { + '& button': { + color: 'black!important', + }, + }, + actionIcon: { + '& button': { + color: 'black!important', + }, + }, + }, + RaFilter: { + form: { + width: '100%', + '& div.filter-field:first-child': { + flex: '1 100%', + '& [class*="RaSearchInput-input-"]': { + width: '100%', + }, + }, + }, + }, + MuiInputAdornment: { + positionEnd: { + justifyContent: 'flex-end', + }, + }, + RaFilterFormInput: { + body: { + '& label': { + transform: 'translate(14px, -6px) scale(0.75)!important', + backgroundColor: '#fafafa', + padding: '0 5px', + }, + }, + hideButton: { + order: '1', + marginLeft: '2px', + top: '-7px', + padding: '8px', + }, + spacer: { + order: '2', + }, + }, + RaPaginationActions: { + actions: { + '& button': { + border: 'none', + fontSize: '.6rem', + }, + }, + }, + NDAlbumDetails: { + cover: { + borderRadius: '5px', + }, + content: { + padding: '0', + marginLeft: '1rem', + }, + externalLinks: { + marginTop: '5px', + }, + notes: { + display: 'none', + }, + root: { + '& p': { + fontSize: '.7rem', + backgroundColor: '#e0e0e0', + borderRadius: '10px', + width: 'fit-content', + padding: '2px 7px', + }, + }, + }, + NDPlaylistDetails: { + cover: { + borderRadius: '5px', + }, + }, + NDDesktopArtistDetails: { + cover: { + borderRadius: '0px', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(250 250 250))!important', + }, + }, + NDArtistShow: { + actionsContainer: { + '& button': { + padding: '4px 5px', + fontSize: '0.8125rem', + height: '2rem', + }, + }, + }, + NDAudioPlayer: { + audioTitle: { + color: 'black', + }, + }, + NDLogin: { + main: { + background: 'white', + '& .MuiFormLabel-root': { + color: '#000', + }, + '& .MuiFormLabel-root.Mui-error': { + color: '#000', + }, + '& .MuiInput-underline:before': { + borderBottom: 'none', + }, + '& .MuiInput-underline:after': { + borderBottom: 'none', + }, + '& .MuiFormHelperText-root.Mui-error': { + color: '#000', + paddingLeft: '10px', + }, + '& .MuiInput-underline:hover:not(.Mui-disabled):before': { + borderBottom: 'none', + }, + }, + card: { + minWidth: 300, + marginTop: '6em', + backgroundColor: '#ffffffe6', + border: '1px solid black', + }, + avatar: { + marginTop: '1rem', + '& img': { + filter: 'invert(1)', + }, + }, + icon: {}, + input: { + '& .MuiInput-root': { + border: '1px solid black', + borderRadius: '4px', + padding: '10px', + }, + '& .MuiInputLabel-root': { + padding: '10px', + }, + '& .MuiInputLabel-shrink': { + transform: 'translate(0, -5.5px) scale(0.75)', + }, + }, + actions: { + marginTop: '2rem', + }, + button: { + boxShadow: 'none', + '&:hover': { + boxShadow: 'none', + backgroundColor: 'rgb(117, 177, 44)', + }, + }, + systemNameLink: { + fontFamily: 'monospace', + marginBottom: '1rem', + color: 'black', + '&:before': { + content: "'Welcome to '", + }, + '&:after': { + content: "' *~*!'", + }, + }, + }, + MuiCssBaseline: { + '@global': { + '*::-webkit-scrollbar': { + display: 'none', + }, + }, + }, + MuiBackdrop: { + root: { + backgroundColor: 'rgba(255, 255, 255, 0.5)', + }, + }, + RaLoading: { + message: { + fontFamily: 'monospace', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} From 4c919368481ad4ddc5b0b00974ecef821cb5a9b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:24:46 -0400 Subject: [PATCH 134/596] chore(deps): bump picomatch in /ui (#5248) Bumps and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together. Updates `picomatch` from 2.3.1 to 2.3.2 - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) Updates `picomatch` from 4.0.3 to 4.0.4 - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect - dependency-name: picomatch dependency-version: 4.0.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 59 +++++++++++--------------------------------- 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 0e6183720..a9b83d76e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -117,7 +117,6 @@ "node_modules/@babel/core": { "version": "7.28.6", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1530,7 +1529,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1552,7 +1550,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2296,7 +2293,6 @@ "node_modules/@jsonforms/core": { "version": "2.5.2", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2340,7 +2336,6 @@ "node_modules/@jsonforms/react": { "version": "2.5.2", "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2353,7 +2348,6 @@ "node_modules/@material-ui/core": { "version": "4.12.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2396,7 +2390,6 @@ "node_modules/@material-ui/icons": { "version": "4.11.3", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2794,7 +2787,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -3013,7 +3008,6 @@ "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "license": "MIT", - "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3054,7 +3048,6 @@ "version": "24.10.9", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3070,7 +3063,6 @@ "node_modules/@types/react": { "version": "17.0.90", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3206,7 +3198,6 @@ "version": "6.21.0", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3504,7 +3495,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4076,7 +4066,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4390,7 +4379,6 @@ "node_modules/connected-react-router": { "version": "6.9.3", "license": "MIT", - "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5142,7 +5130,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5640,7 +5627,6 @@ "node_modules/final-form": { "version": "4.20.10", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -5655,7 +5641,6 @@ "node_modules/final-form-arrays": { "version": "3.1.0", "license": "MIT", - "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6004,7 +5989,6 @@ "version": "20.3.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6100,7 +6084,6 @@ "node_modules/history": { "version": "4.10.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -7433,7 +7416,6 @@ "node_modules/moment": { "version": "2.30.1", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -7932,7 +7914,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -8039,7 +8023,6 @@ "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8115,7 +8098,6 @@ "node_modules/ra-core": { "version": "3.19.12", "license": "MIT", - "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -8469,7 +8451,6 @@ "node_modules/react": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -8543,7 +8524,6 @@ "node_modules/react-dom": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -8608,7 +8588,6 @@ "node_modules/react-final-form": { "version": "6.5.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -8624,7 +8603,6 @@ "node_modules/react-final-form-arrays": { "version": "3.1.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -8711,7 +8689,6 @@ "node_modules/react-redux": { "version": "7.2.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -8743,7 +8720,6 @@ "node_modules/react-router": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8762,7 +8738,6 @@ "node_modules/react-router-dom": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8916,7 +8891,6 @@ "node_modules/redux": { "version": "4.2.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -8924,7 +8898,6 @@ "node_modules/redux-saga": { "version": "1.4.2", "license": "MIT", - "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -9133,7 +9106,6 @@ "version": "4.55.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9925,10 +9897,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10116,7 +10089,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10319,7 +10291,6 @@ "version": "7.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10435,10 +10406,11 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10450,7 +10422,6 @@ "version": "4.0.17", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -10524,7 +10495,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10904,7 +10877,6 @@ "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10978,7 +10950,6 @@ "node_modules/workbox-build/node_modules/rollup": { "version": "2.79.2", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, From 33e20d355ef2c5edfe1cbeafd98062744a0d5b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 25 Mar 2026 21:30:40 -0400 Subject: [PATCH 135/596] fix(ui): cancel in-flight image requests on pagination, cache across remounts (#5249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): cancel in-flight image requests on pagination and cache across remounts When paginating quickly through list/grid views, image requests for previous pages were never canceled, queuing on the server and blocking new images. This adds a useImageUrl hook that loads images via fetch() with AbortController, so requests are canceled when components unmount. A module-level cache (URL → blob URL) with reference counting ensures React Admin refreshes display images instantly without re-fetching. * feat(ui): update AlbumListPagination to conditionally render based on albumListType Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): abort all in-flight image fetches on pagination change Pagination component now watches page/perPage via useListContext and calls abortAllInFlight() when either changes, freeing the browser connection pool immediately for the next page's data request. Also adds empty placeholder style to CoverArtAvatar so it renders as a clean transparent area while loading instead of the default person icon. * Revert "feat(ui): abort all in-flight image fetches on pagination change" This reverts commit 3bc09f9d0374aa63572a381e38a30e2f2cec4da8. * fix(ui): limit concurrent image fetches to prevent connection pool saturation With <img src>, the browser prioritizes API requests over image loads. With fetch(), all requests compete equally for the HTTP/1.1 connection pool (6 per origin), causing API requests to queue behind images and making pagination feel unresponsive. Caps concurrent image fetches at 4 with a pending queue, leaving connections free for API requests. Queued fetches for unmounted components are removed without ever hitting the network. * fix(ui): fix queued fetch not aborted on unmount Set queued=false when doFetch executes from the pending queue, so cleanup correctly calls controller.abort() instead of searching an already-drained queue. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/album/AlbumGridView.jsx | 27 +--- ui/src/album/AlbumList.jsx | 13 +- ui/src/common/CoverArtAvatar.jsx | 23 ++- ui/src/common/index.js | 1 + ui/src/common/useImageUrl.js | 144 ++++++++++++++++++ ui/src/common/useImageUrl.test.js | 234 ++++++++++++++++++++++++++++++ ui/src/radio/RadioList.jsx | 16 +- 7 files changed, 423 insertions(+), 35 deletions(-) create mode 100644 ui/src/common/useImageUrl.js create mode 100644 ui/src/common/useImageUrl.test.js diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index e90e7a77b..c8a161571 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -18,6 +18,7 @@ import { PlayButton, ArtistLinkField, OverflowTooltip, + useImageUrl, } from '../common' import { COVER_ART_SIZE, DraggableTypes } from '../consts' import clsx from 'clsx' @@ -105,7 +106,7 @@ const useCoverStyles = makeStyles({ transition: 'opacity 0.3s ease-in-out', }, coverLoading: { - opacity: 0.5, + opacity: 0, }, }) @@ -125,8 +126,6 @@ const Cover = withContentRect('bounds')(({ // Force height to be the same as the width determined by the GridList // noinspection JSSuspiciousNameCombination const classes = useCoverStyles({ height: contentRect.bounds.width }) - const [imageLoading, setImageLoading] = React.useState(true) - const [imageError, setImageError] = React.useState(false) const [, dragAlbumRef] = useDrag( () => ({ type: DraggableTypes.ALBUM, @@ -136,32 +135,16 @@ const Cover = withContentRect('bounds')(({ [record], ) - // Reset image state when record changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) + const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const { imgUrl, loading: imageLoading } = useImageUrl(url) return ( <div ref={measureRef} className={classes.coverContainer}> <div ref={dragAlbumRef}> <img - key={record.id} // Force re-render when record changes - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)} + src={imgUrl || undefined} alt={record.name} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onLoad={handleImageLoad} - onError={handleImageError} /> </div> </div> diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index d00d97701..28a24981c 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -175,12 +175,12 @@ const AlbumListTitle = ({ albumListType }) => { return <Title subTitle={title} args={{ smart_count: 2 }} /> } -const AlbumListPagination = (props) => { +const AlbumListPagination = ({ albumListType, ...rest }) => { const { loading } = useListContext() - if (loading) { + if (loading && albumListType === 'random') { return null } - return <Pagination {...props} /> + return <Pagination {...rest} /> } const randomStartingSeed = Math.random().toString() @@ -243,7 +243,12 @@ const AlbumList = (props) => { actions={<AlbumListActions />} filters={<AlbumFilter />} perPage={perPage} - pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />} + pagination={ + <AlbumListPagination + rowsPerPageOptions={perPageOptions} + albumListType={albumListType} + /> + } title={<AlbumListTitle albumListType={albumListType} />} > {albumView.grid ? ( diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index 5642f2504..bc6dd8a3c 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -4,12 +4,16 @@ import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' const useStyles = makeStyles({ avatar: { width: '55px', height: '55px', }, + avatarEmpty: { + backgroundColor: 'transparent', + }, square: { borderRadius: '4px', }, @@ -22,15 +26,26 @@ export const CoverArtAvatar = ({ const classes = useStyles() const recordContext = useRecordContext() const record = recordProp || recordContext - if (!record) return null const square = variant !== 'circular' + const url = record + ? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square) + : null + const { imgUrl } = useImageUrl(url) + if (!record) return null return ( <Avatar - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)} + src={imgUrl || undefined} variant={variant} - className={clsx(classes.avatar, square && classes.square)} + className={clsx( + classes.avatar, + square && classes.square, + !imgUrl && classes.avatarEmpty, + )} alt={record.name} - /> + > + {/* Empty child prevents default person icon while loading */} + {!imgUrl && <span />} + </Avatar> ) } diff --git a/ui/src/common/index.js b/ui/src/common/index.js index a7d6a43c4..362a0ced3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -46,3 +46,4 @@ export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' export * from './useImageLoadingState' +export * from './useImageUrl' diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js new file mode 100644 index 000000000..9bcc70d74 --- /dev/null +++ b/ui/src/common/useImageUrl.js @@ -0,0 +1,144 @@ +import { useEffect, useState, useRef } from 'react' + +// Persists across component mount/unmount cycles so that +// React Admin refreshes (which remount list items) don't re-fetch images. +const cache = new Map() +const MAX_CACHE_SIZE = 300 + +// Limit concurrent fetches to leave browser connections free for API requests. +// Browsers allow ~6 connections per origin on HTTP/1.1; reserving 2 for API +// calls prevents image fetches from blocking pagination/data requests. +const MAX_CONCURRENT = 4 +let activeFetches = 0 +const pendingQueue = [] + +const processQueue = () => { + while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) { + const next = pendingQueue.shift() + next() + } +} + +// Evicts oldest unused entries (Map iterates in insertion order). +const evictIfNeeded = () => { + if (cache.size <= MAX_CACHE_SIZE) return + for (const [key, entry] of cache) { + if (cache.size <= MAX_CACHE_SIZE) break + if (entry.refCount === 0) { + if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl) + cache.delete(key) + } + } +} + +/** + * Loads an image via fetch() with AbortController so that in-flight requests + * are canceled on unmount (e.g., during pagination). Uses a module-level cache + * so remounting returns the cached blob URL instantly. + */ +export const useImageUrl = (url) => { + const cached = url ? cache.get(url) : null + const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null) + const [loading, setLoading] = useState(!!url && !cached) + const [error, setError] = useState(cached?.error || false) + const abortedRef = useRef(false) + + useEffect(() => { + abortedRef.current = false + + if (!url) { + setImgUrl(null) + setLoading(false) + setError(false) + return + } + + // Re-check: another component's effect may have populated the cache + // between this component's render and effect execution. + const entry = cache.get(url) + if (entry) { + entry.refCount++ + setImgUrl(entry.blobUrl) + setLoading(false) + setError(entry.error || false) + return () => { + entry.refCount-- + } + } + + const controller = new AbortController() + let queued = true + setImgUrl(null) + setLoading(true) + setError(false) + + const doFetch = () => { + queued = false + activeFetches++ + fetch(url, { signal: controller.signal }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + return res.blob() + }) + .then((blob) => { + activeFetches-- + processQueue() + // Guard against late resolution after abort + if (abortedRef.current) { + return + } + const objectUrl = URL.createObjectURL(blob) + // Handle concurrent fetches: if another component already cached + // this URL, use its entry and discard our blob. + const existing = cache.get(url) + if (existing && existing.blobUrl) { + existing.refCount++ + URL.revokeObjectURL(objectUrl) + setImgUrl(existing.blobUrl) + } else { + cache.set(url, { blobUrl: objectUrl, refCount: 1 }) + evictIfNeeded() + setImgUrl(objectUrl) + } + setLoading(false) + }) + .catch((err) => { + activeFetches-- + processQueue() + if (err.name === 'AbortError') { + return // Expected on unmount or URL change + } + // Cache the error so repeated mounts don't re-fetch broken URLs + cache.set(url, { blobUrl: null, error: true, refCount: 0 }) + setError(true) + setLoading(false) + }) + } + + if (activeFetches < MAX_CONCURRENT) { + queued = false + doFetch() + } else { + pendingQueue.push(doFetch) + } + + return () => { + abortedRef.current = true + if (queued) { + // Remove from queue if not yet started + const idx = pendingQueue.indexOf(doFetch) + if (idx !== -1) pendingQueue.splice(idx, 1) + } else { + controller.abort() + } + const entry = cache.get(url) + if (entry) { + entry.refCount-- + } + } + }, [url]) + + return { imgUrl, loading, error } +} diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js new file mode 100644 index 000000000..976317105 --- /dev/null +++ b/ui/src/common/useImageUrl.test.js @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' + +// Helper to flush all pending promises +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +// We need a fresh module for each test to reset the module-level cache +let useImageUrl + +describe('useImageUrl', () => { + let abortSpy + let OriginalAbortController + let originalCreateObjectURL + let originalRevokeObjectURL + let originalFetch + + beforeEach(async () => { + // Reset module to clear the cache + vi.resetModules() + const mod = await import('./useImageUrl') + useImageUrl = mod.useImageUrl + + abortSpy = vi.fn() + OriginalAbortController = global.AbortController + originalCreateObjectURL = global.URL.createObjectURL + originalRevokeObjectURL = global.URL.revokeObjectURL + originalFetch = global.fetch + + global.AbortController = function () { + this.signal = 'mock-signal' + this.abort = abortSpy + } + global.URL.createObjectURL = vi.fn(() => 'blob:mock-url') + global.URL.revokeObjectURL = vi.fn() + }) + + afterEach(() => { + global.AbortController = OriginalAbortController + global.URL.createObjectURL = originalCreateObjectURL + global.URL.revokeObjectURL = originalRevokeObjectURL + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should return null values when url is null', () => { + const { result } = renderHook(() => useImageUrl(null)) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(false) + }) + + it('should return loading state initially', () => { + global.fetch = vi.fn(() => new Promise(() => {})) + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + expect(result.current.loading).toBe(true) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(false) + }) + + it('should fetch image and return blob URL on success', async () => { + const mockBlob = new Blob(['image-data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBe('blob:mock-url') + expect(result.current.error).toBe(false) + expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', { + signal: 'mock-signal', + }) + }) + + it('should set error on HTTP failure', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) + + const { result } = renderHook(() => + useImageUrl('http://example.com/missing.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(true) + }) + + it('should abort fetch on unmount', async () => { + global.fetch = vi.fn(() => new Promise(() => {})) + + const { unmount } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + unmount() + + expect(abortSpy).toHaveBeenCalled() + }) + + it('should abort previous fetch when URL changes', async () => { + const abortSpies = [] + global.AbortController = function () { + const spy = vi.fn() + abortSpies.push(spy) + this.signal = `signal-${abortSpies.length}` + this.abort = spy + } + + const mockBlob = new Blob(['data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + const { rerender } = renderHook(({ url }) => useImageUrl(url), { + initialProps: { url: 'http://example.com/img1.jpg' }, + }) + + await act(async () => { + await flushPromises() + }) + + // Change URL - should abort the first controller + rerender({ url: 'http://example.com/img2.jpg' }) + + expect(abortSpies[0]).toHaveBeenCalled() + }) + + it('should not set error on AbortError', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + global.fetch = vi.fn(() => Promise.reject(abortError)) + + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.error).toBe(false) + }) + + it('should use cached blob URL on remount without re-fetching', async () => { + const mockBlob = new Blob(['data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + // First mount — fetches and caches + const { unmount } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + + // Unmount (simulates React Admin refresh) + unmount() + + // Remount with same URL — should use cache + const { result: result2 } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + // Should NOT have fetched again + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result2.current.imgUrl).toBe('blob:mock-url') + expect(result2.current.loading).toBe(false) + }) + + it('should cache errors and not re-fetch broken URLs', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) + + // First mount — fetch fails and error is cached + const { unmount } = renderHook(() => + useImageUrl('http://example.com/broken.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + unmount() + + // Remount with same URL — should use cached error, not re-fetch + const { result: result2 } = renderHook(() => + useImageUrl('http://example.com/broken.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result2.current.error).toBe(true) + expect(result2.current.imgUrl).toBeNull() + expect(result2.current.loading).toBe(false) + }) +}) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 582fcaffc..945bac519 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -14,8 +14,12 @@ import { UrlField, useTranslate, } from 'react-admin' -import { List } from '../common' -import { ToggleFieldsMenu, useSelectedFields } from '../common' +import { + List, + useImageUrl, + ToggleFieldsMenu, + useSelectedFields, +} from '../common' import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' @@ -78,10 +82,12 @@ const RadioListActions = ({ const avatarStyle = { width: 40, height: 40 } const CoverArtField = ({ record }) => { - if (!record) return null - const src = record.uploadedImage + const directUrl = record?.uploadedImage ? subsonic.getCoverArtUrl(record, 40, true) - : RADIO_PLACEHOLDER_IMAGE + : null + const { imgUrl } = useImageUrl(directUrl) + if (!record) return null + const src = imgUrl || RADIO_PLACEHOLDER_IMAGE return ( <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> ) From ccee33f47410fcef245014c73117c7fe916ac900 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 26 Mar 2026 20:15:28 -0400 Subject: [PATCH 136/596] fix(search): use explicit AND in FTS5 queries to fix apostrophe search FTS5's implicit AND (space-separated tokens) silently fails when combined with parenthesized OR groups produced by processPunctuatedWords. For example, searching "you've got" generated the query `("you ve" OR youve*) got*` which returned no results. Using explicit AND (`("you ve" OR youve*) AND got*`) resolves this FTS5 quirk. Since implicit and explicit AND are semantically identical in FTS5, this change is safe for all queries unconditionally. --- persistence/sql_search_fts.go | 4 +++- persistence/sql_search_fts_test.go | 29 +++++++++++++++-------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 9eb01f0cf..1d4116b5d 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -178,7 +178,9 @@ func buildFTS5Query(userInput string) string { tokens[i] = t + "*" } - result = strings.Join(tokens, " ") + // Use explicit AND between tokens — FTS5's implicit AND (space-separated) + // doesn't work correctly with parenthesized OR groups from processPunctuatedWords. + result = strings.Join(tokens, " AND ") for i, phrase := range phrases { placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index 337d54201..d0e26c8e3 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -17,32 +17,33 @@ var _ = DescribeTable("buildFTS5Query", Entry("returns empty string for empty input", "", ""), Entry("returns empty string for whitespace-only input", " ", ""), Entry("appends * to a single word for prefix matching", "beatles", "beatles*"), - Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"), + Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"), Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`), Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"), - Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"), - Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"), - Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`), - Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"), - Entry("collapses multiple spaces", "abbey road", "abbey* road*"), + Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"), + Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"), + Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`), + Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"), + Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"), Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"), - Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"), + Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"), Entry("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"), + Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"), Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), - Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`), - Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"), + Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), + Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), + Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), + Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), - Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), - Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"), - Entry("does not collapse single standalone letter", "A test", "A* test*"), + Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"), + Entry("does not collapse single standalone letter", "A test", "A* AND test*"), Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), From 79e1af7cd6c9a5d1b8e4d4cea7e33eeb5bd5d912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 27 Mar 2026 18:04:47 -0400 Subject: [PATCH 137/596] fix(ui): update Danish, German, Greek, Finnish, Galician, Portuguese (BR), Swedish, Ukrainian, Chinese (traditional) translations from POEditor (#5218) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/da.json | 16 +++++-- resources/i18n/de.json | 6 +-- resources/i18n/el.json | 16 +++++-- resources/i18n/fi.json | 4 +- resources/i18n/gl.json | 16 +++++-- resources/i18n/pt-br.json | 21 ++++---- resources/i18n/sv.json | 16 +++++-- resources/i18n/uk.json | 95 +++++++++++++++++++++++++++++++++++-- resources/i18n/zh-Hant.json | 4 +- 9 files changed, 153 insertions(+), 41 deletions(-) diff --git a/resources/i18n/da.json b/resources/i18n/da.json index a47b30bbc..edb1df183 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -38,7 +38,7 @@ "missing": "Manglende", "libraryName": "Bibliotek", "composer": "Komponist", - "disc": "" + "disc": "Disk %{discNumber}" }, "actions": { "addToQueue": "Afspil senere", @@ -355,7 +355,7 @@ "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", "selectedLibraries": "Valgte biblioteker", - "allowWriteAccess": "" + "allowWriteAccess": "Tillad skriveadgang" }, "sections": { "status": "Status", @@ -401,7 +401,7 @@ "requiredHosts": "Påkrævede hosts", "configValidationError": "Konfigurationsvalidering mislykkedes:", "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Når aktiveret, kan denne plugin rette filer i biblioteksmapper. På forhånd har plugins kun læseadgang." }, "placeholders": { "configKey": "nøgle", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", "noSimilarSongsFound": "Ingen lignende sange fundet", "noTopSongsFound": "Ingen topsange fundet", - "startingInstantMix": "Indlæser Instant Mix..." + "startingInstantMix": "Indlæser Instant Mix...", + "uploadCover": "Upload omslag", + "removeCover": "Fjern omslag", + "coverUploaded": "Omslagsbillede opdateret", + "coverRemoved": "Omslagsbillede fjernet", + "coverUploadError": "Fejl ved upload af omslagsbillede", + "coverRemoveError": "Fejl ved fjernelse af omslagsbillede" }, "menu": { "library": "Bibliotek", @@ -712,4 +718,4 @@ "empty": "Intet afspilles nu", "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" } -} +} \ No newline at end of file diff --git a/resources/i18n/de.json b/resources/i18n/de.json index ab1760ed5..c540dee05 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -18,7 +18,7 @@ "size": "Dateigröße", "updatedAt": "Hochgeladen am", "bitRate": "Bitrate", - "discSubtitle": "CD Untertitel", + "discSubtitle": "Disc Untertitel", "starred": "Favorit", "comment": "Kommentar", "rating": "Bewertung", @@ -38,7 +38,7 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Später abspielen", @@ -718,4 +718,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} +} \ No newline at end of file diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 019d05978..3876c2e33 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -38,7 +38,7 @@ "missing": "Απών", "libraryName": "Βιβλιοθήκη", "composer": "Συνθέτης", - "disc": "" + "disc": "Δίσκος %{discNumber}" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -355,7 +355,7 @@ "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", - "allowWriteAccess": "" + "allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής" }, "sections": { "status": "Κατάσταση", @@ -401,7 +401,7 @@ "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." }, "placeholders": { "configKey": "κλειδί", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια", - "startingInstantMix": "Φόρτωση Άμεσης Μίξης..." + "startingInstantMix": "Φόρτωση Άμεσης Μίξης...", + "uploadCover": "Μεταφόρτωση εξωφύλλου", + "removeCover": "Αφαίρεση καλύμματος", + "coverUploaded": "Το εξώφυλλο ενημερώθηκε", + "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", + "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" }, "menu": { "library": "Βιβλιοθήκη", @@ -712,4 +718,4 @@ "empty": "Δεν παίζει τίποτα", "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } -} +} \ No newline at end of file diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 59f353350..bbad47bd6 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,7 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "" + "disc": "Levy %{discNumber}" }, "actions": { "addToQueue": "Lisää jonoon", @@ -718,4 +718,4 @@ "empty": "Ei soita mitään", "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } -} +} \ No newline at end of file diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index aba22b714..d62ca2ab2 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,7 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Ao final da cola", @@ -355,7 +355,7 @@ "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", "selectedLibraries": "Selecciona bibliotecas", - "allowWriteAccess": "" + "allowWriteAccess": "Conceder acceso de escritura" }, "sections": { "status": "Estado", @@ -401,7 +401,7 @@ "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." }, "placeholders": { "configKey": "clave", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.", "noSimilarSongsFound": "Sen cancións parecidas", "noTopSongsFound": "Sen cancións destacadas", - "startingInstantMix": "Cargando Mestura Súbita…" + "startingInstantMix": "Cargando Mestura Súbita…", + "uploadCover": "Subir capa", + "removeCover": "Retirar capa", + "coverUploaded": "Subiuse a capa", + "coverRemoved": "Retirouse a capa", + "coverUploadError": "Erro ao subir a capa", + "coverRemoveError": "Erro ao retirar a capa" }, "menu": { "library": "Biblioteca", @@ -712,4 +718,4 @@ "empty": "Sen reprodución", "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index bc2a5f85e..2e4f517a9 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -37,7 +37,8 @@ "sampleRate": "Taxa de amostragem", "missing": "Ausente", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Adicionar à fila", @@ -397,10 +398,10 @@ "allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.", "noLibraries": "Nenhuma biblioteca selecionada", "librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", "requiredHosts": "Hosts necessários", "configValidationError": "Falha na validação da configuração:", - "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido." + "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.", + "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura." }, "placeholders": { "configKey": "chave", @@ -554,12 +555,6 @@ } }, "message": { - "uploadCover": "Enviar Capa", - "removeCover": "Remover Capa", - "coverUploaded": "Capa atualizada", - "coverRemoved": "Capa removida", - "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", "note": "ATENÇÃO", "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", @@ -596,7 +591,13 @@ "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", "noSimilarSongsFound": "Nenhuma música semelhante encontrada", "noTopSongsFound": "Nenhuma música mais tocada encontrada", - "startingInstantMix": "Carregando Mix Instantâneo..." + "startingInstantMix": "Carregando Mix Instantâneo...", + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa", + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa" }, "menu": { "library": "Biblioteca", diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 23bd5fbc2..228cc2cf3 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -38,7 +38,7 @@ "missing": "Saknade", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Lägg till i kön", @@ -355,7 +355,7 @@ "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "" + "allowWriteAccess": "Tillåt skrivrättigheter" }, "sections": { "status": "Status", @@ -401,7 +401,7 @@ "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." }, "placeholders": { "configKey": "nyckel", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", - "startingInstantMix": "Laddar direktmix..." + "startingInstantMix": "Laddar direktmix...", + "uploadCover": "Ladda upp omslagsbild", + "removeCover": "Ta bort omslagsbild", + "coverUploaded": "Omslagsbild uppdaterad", + "coverRemoved": "Omslagsbild borttagen", + "coverUploadError": "Fel vid uppladdning av omslagsbild", + "coverRemoveError": "Fel vid borttagning av omslagsbild" }, "menu": { "library": "Bibliotek", @@ -712,4 +718,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} +} \ No newline at end of file diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index 2c74c890a..c5644fde7 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -36,7 +36,9 @@ "bitDepth": "Глибина розрядності", "sampleRate": "Частота дискретизації", "missing": "Поле відсутнє", - "libraryName": "Бібліотека" + "libraryName": "Бібліотека", + "composer": "Композитор", + "disc": "Диск %{discNumber}" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -46,7 +48,8 @@ "download": "Завантажити", "playNext": "Наступна", "info": "Отримати інформацію", - "showInPlaylist": "Показати у плейлісті" + "showInPlaylist": "Показати у плейлісті", + "instantMix": "Мікс" } }, "album": { @@ -328,6 +331,82 @@ "scanInProgress": "Сканування триває...", "noLibrariesAssigned": "Немає бібліотек, призначених цьому користувачеві" } + }, + "plugin": { + "name": "Плагін |||| Плагіни", + "fields": { + "id": "ІД", + "name": "Назва", + "description": "Опис", + "version": "Версія", + "author": "Автор", + "website": "Вебсайт", + "permissions": "Дозволи", + "enabled": "Увімкнено", + "status": "Стан", + "path": "Шлях", + "lastError": "Помилка", + "hasError": "Помилка", + "updatedAt": "Оновлено", + "createdAt": "Встановлено", + "configKey": "Ключ", + "configValue": "Значення", + "allUsers": "Дозволити усім користувачам", + "selectedUsers": "Вибрані користувачі", + "allLibraries": "Дозволити всі бібліотеки", + "selectedLibraries": "Вибрані бібліотеки", + "allowWriteAccess": "Дозволити доступ до запису" + }, + "sections": { + "status": "Стан", + "info": "Інформація про плагін", + "configuration": "Конфігурація", + "manifest": "Маніфест", + "usersPermission": "Дозволи користувачів", + "libraryPermission": "Дозволи бібліотеки" + }, + "status": { + "enabled": "Увімкнено", + "disabled": "Вимкнено" + }, + "actions": { + "enable": "Увімкнути", + "disable": "Вимкнути", + "disabledDueToError": "Виправте помилку перед увімкненням", + "disabledUsersRequired": "Виберіть користувачі перед увімкненням", + "disabledLibrariesRequired": "Виберіть бібліотеки перед увімкненням", + "addConfig": "Додати конфігурацію", + "rescan": "Пересканувати" + }, + "notifications": { + "enabled": "Увімкнути плагін", + "disabled": "Вимкнути плагін", + "updated": "Плагін оновлено", + "error": "Помилка оновлення плагіну" + }, + "validation": { + "invalidJson": "Конфігурація повинна має відповідати формату JSON" + }, + "messages": { + "configHelp": "Налаштуйте плагін використовуючи пару ключ-значення. Залиште порожнім, якщо плагін не вимагає конфігурації.", + "clickPermissions": "Натисніть дозволи для детальної інформації", + "noConfig": "Конфігурація не налаштована", + "allUsersHelp": "При увімкненні плагін матиме доступ до всіх користувачів, включно ті, які будуть створені в майбутньому.", + "noUsers": "Немає вибраних користувачів", + "permissionReason": "Причина", + "usersRequired": "Цей плагін вимагає доступу до інформації про користувача. Виберіть, до яких користувачів плагін може отримати доступ, або ввімкніть «Дозволити всім користувачам».", + "allLibrariesHelp": "Коли увімкнуто, плагін матиме доступ до всіх бібліотек, включаючи ті, які будуть створені в майбутньому.", + "noLibraries": "Немає виділених бібліотек", + "librariesRequired": "Цей плагін вимагає доступу до інформації бібліотеки. Виберіть, до яких бібліотек плагін може отримати доступ, або ввімкніть «Дозволити всі бібліотеки».", + "requiredHosts": "Обов'язкові хости", + "configValidationError": "Перевірка конфігурації зазнала невдачі:", + "schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.", + "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання." + }, + "placeholders": { + "configKey": "ключ", + "configValue": "значення" + } } }, "ra": { @@ -511,7 +590,14 @@ "remove_all_missing_title": "Видалити всі відсутні файли", "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.", "noSimilarSongsFound": "Не знайдено схожих треків", - "noTopSongsFound": "Не знайдено ТОП-треків" + "noTopSongsFound": "Не знайдено ТОП-треків", + "startingInstantMix": "Завантаження міксу...", + "uploadCover": "Завантажити обкладинку", + "removeCover": "Видалити обкладинку", + "coverUploaded": "Обкладинку оновлено", + "coverRemoved": "Обкладинка видалена", + "coverUploadError": "Помилка завантаження обкладинки", + "coverRemoveError": "Помилка видалення обкладинки" }, "menu": { "library": "Бібліотека", @@ -597,7 +683,8 @@ "exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML", "exportFailed": "Не вдалося скопіювати конфігурацію", "devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)", - "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях." + "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях.", + "downloadToml": "Завантажити конфігурацію (TOML)" } }, "activity": { diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index dabf61bdb..93951a311 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,7 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "" + "disc": "光碟 %{discNumber}" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file From f33ca753781d36b3fbd9b137f830ed3ddc815ce8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 27 Mar 2026 19:33:46 -0400 Subject: [PATCH 138/596] refactor: rename EnableCoverArtUpload to EnableArtworkUpload The config flag gates all image uploads (artists, radios, playlists), not just cover art. Rename it to accurately reflect its scope across the backend config, native API permission check, Subsonic CoverArtRole, serve_index JSON key, and frontend config. --- conf/configuration.go | 4 ++-- server/nativeapi/image_upload.go | 4 ++-- server/nativeapi/playlists_test.go | 8 ++++---- server/serve_index.go | 2 +- server/subsonic/users.go | 2 +- server/subsonic/users_test.go | 4 ++-- ui/src/common/ImageUploadOverlay.jsx | 2 +- ui/src/config.js | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 5f74d6db0..a9264d3b7 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -78,7 +78,7 @@ type configOptions struct { EnableFavourites bool EnableStarRating bool EnableUserEditing bool - EnableCoverArtUpload bool + EnableArtworkUpload bool EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -689,7 +689,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) - viper.SetDefault("enablecoverartupload", true) + viper.SetDefault("enableartworkupload", true) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index c29f14bdc..1f55e3851 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -24,8 +24,8 @@ const maxImageSize = 10 << 20 // 10MB func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) + if !conf.Server.EnableArtworkUpload && !user.IsAdmin { + http.Error(w, "artwork upload is disabled", http.StatusForbidden) return false } return true diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index dfe6b9296..e1c933709 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -28,8 +28,8 @@ var _ = Describe("Playlist Image Endpoints", func() { }) DescribeTable("uploadPlaylistImage guard", - func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload handler := uploadPlaylistImage(&mockPlaylistsService{}) req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil) @@ -47,8 +47,8 @@ var _ = Describe("Playlist Image Endpoints", func() { ) DescribeTable("deletePlaylistImage guard", - func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload handler := deletePlaylistImage(&mockPlaylistsService{}) req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil) diff --git a/server/serve_index.go b/server/serve_index.go index 6b0c890a6..0d1a2f330 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -61,7 +61,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, "enableUserEditing": conf.Server.EnableUserEditing, - "enableCoverArtUpload": conf.Server.EnableCoverArtUpload, + "enableArtworkUpload": conf.Server.EnableArtworkUpload, "enableSharing": conf.Server.EnableSharing, "shareURL": conf.Server.ShareURL, "defaultDownloadableShare": conf.Server.DefaultDownloadableShare, diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 4f6dccaac..8b7406b60 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -22,7 +22,7 @@ func buildUserResponse(user model.User) responses.User { ScrobblingEnabled: true, DownloadRole: conf.Server.EnableDownloads, ShareRole: conf.Server.EnableSharing, - CoverArtRole: conf.Server.EnableCoverArtUpload || user.IsAdmin, + CoverArtRole: conf.Server.EnableArtworkUpload || user.IsAdmin, Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }), } diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go index 1fd5dce71..2d08b3377 100644 --- a/server/subsonic/users_test.go +++ b/server/subsonic/users_test.go @@ -105,8 +105,8 @@ var _ = Describe("Users", func() { ) DescribeTable("CoverArt role permissions", - func(enableCoverArtUpload, isAdmin, expectedCoverArtRole bool) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin, expectedCoverArtRole bool) { + conf.Server.EnableArtworkUpload = enableArtworkUpload testUser.IsAdmin = isAdmin response := buildUserResponse(testUser) diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx index e0d0d0a9a..a370e40fe 100644 --- a/ui/src/common/ImageUploadOverlay.jsx +++ b/ui/src/common/ImageUploadOverlay.jsx @@ -49,7 +49,7 @@ export const ImageUploadOverlay = ({ const fileInputRef = useRef(null) const canEdit = - config.enableCoverArtUpload || localStorage.getItem('role') === 'admin' + config.enableArtworkUpload || localStorage.getItem('role') === 'admin' const handleUploadClick = useCallback((e) => { e.stopPropagation() diff --git a/ui/src/config.js b/ui/src/config.js index 0672a58f4..bd11d7df6 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -22,7 +22,7 @@ const defaultConfig = { defaultUIVolume: 100, uiSearchDebounceMs: 200, enableUserEditing: true, - enableCoverArtUpload: true, + enableArtworkUpload: true, enableSharing: true, shareURL: '', defaultDownloadableShare: true, From 2588558946a21690e2d54a5d3c089be839aa1f51 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 27 Mar 2026 19:38:42 -0400 Subject: [PATCH 139/596] fix: resolve flaky ffmpeg context cancellation test Replaced single Read assertion with Eventually loop to drain buffered pipe data after context cancellation. The previous test assumed the first Read after cancel() would fail, but ffmpeg may have already written data into the pipe buffer before being killed, causing the Read to succeed from buffered content. --- core/ffmpeg/ffmpeg_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 04663828f..01b284172 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -584,9 +584,12 @@ var _ = Describe("ffmpeg", func() { // Cancel the context cancel() - // Next read should fail due to cancelled context - _, err = stream.Read(buf) - Expect(err).To(HaveOccurred()) + // Subsequent reads should eventually fail due to cancelled context. + // There may be buffered data in the pipe, so we drain until an error occurs. + Eventually(func() error { + _, err = stream.Read(buf) + return err + }).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred()) }) It("should handle immediate context cancellation", func() { From 2b041c02ad98fa8285d1400fd62ed9474f2cc11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 28 Mar 2026 13:17:31 -0400 Subject: [PATCH 140/596] feat: accept ND_-prefixed env var names in config files (#5258) * feat: add toPascalCase helper for config key display Adds a toPascalCase helper that converts dotted lowercase Viper config keys (e.g. 'scanner.schedule') to PascalCase (e.g. 'Scanner.Schedule') for use in user-facing warning messages. Includes export_test.go binding and a full Ginkgo DescribeTable test suite covering simple, dotted, multi-segment, already-capitalized, and empty-string cases. * feat: remap ND_-prefixed env var names found in config files Detect when users mistakenly use environment variable names (like ND_ADDRESS) in config files, remap them to canonical keys, and warn. Fatal error if both ND_ and canonical versions of the same key exist. Closes #5242 --- conf/configuration.go | 52 +++++++++++++++++++++++++++ conf/configuration_test.go | 56 ++++++++++++++++++++++++++++++ conf/export_test.go | 8 +++++ conf/testdata/cfg_nd_conflict.toml | 2 ++ conf/testdata/cfg_nd_keys.toml | 3 ++ 5 files changed, 121 insertions(+) create mode 100644 conf/testdata/cfg_nd_conflict.toml create mode 100644 conf/testdata/cfg_nd_keys.toml diff --git a/conf/configuration.go b/conf/configuration.go index a9264d3b7..3d04e725c 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -258,6 +258,13 @@ type searchOptions struct { FullString bool } +// fatalFunc is called for fatal config errors. Defaults to printing + os.Exit(1). +// Overridden in tests to allow testing fatal paths. +var fatalFunc = func(msg string) { + _, _ = fmt.Fprintln(os.Stderr, "FATAL:", msg) + os.Exit(1) +} + var ( Server = &configOptions{} hooks []func() @@ -275,6 +282,7 @@ func LoadFromFile(confFile string) { func Load(noConfigDump bool) { parseIniFileConfiguration() + remapEnvVarKeysFromConfig() // Map deprecated options to their new names for backwards compatibility mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") @@ -466,6 +474,35 @@ func logRemovedOptions(options ...string) { } } +// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly +// using environment variable names) and remaps them to canonical Viper keys with a warning. +func remapEnvVarKeysFromConfig() { + for _, key := range viper.AllKeys() { + if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) { + continue + } + stripped := strings.TrimPrefix(key, "nd_") + canonicalKey := strings.ReplaceAll(stripped, "_", ".") + displayNDKey := "ND_" + strings.ToUpper(stripped) + displayCanonical := toPascalCase(canonicalKey) + + if viper.InConfig(canonicalKey) { + fatalFunc(fmt.Sprintf( + "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ + "The 'ND_' prefix is only needed for environment variables, not config file keys.", + displayNDKey, displayCanonical, + )) + return + } + + viper.Set(canonicalKey, viper.Get(key)) + _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ + "The 'ND_' prefix is only needed for environment variables.\n", + displayNDKey, displayCanonical, + ) + } +} + // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { @@ -617,6 +654,21 @@ func normalizeSearchBackend(value string) string { } } +// toPascalCase converts a dotted lowercase config key to PascalCase for display. +// Example: "scanner.schedule" → "Scanner.Schedule" +func toPascalCase(key string) string { + if key == "" { + return "" + } + parts := strings.Split(key, ".") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, ".") +} + // AddHook is used to register initialization code that should run as soon as the config is loaded func AddHook(hook func()) { hooks = append(hooks, hook) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 73fec4196..9aba04197 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -108,6 +108,62 @@ var _ = Describe("Configuration", func() { Entry("falls back to 'fts' for empty string", "", "fts"), ) + DescribeTable("ToPascalCase", + func(input, expected string) { + Expect(conf.ToPascalCase(input)).To(Equal(expected)) + }, + Entry("simple key", "address", "Address"), + Entry("dotted key", "scanner.schedule", "Scanner.Schedule"), + Entry("already capitalized", "Address", "Address"), + Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"), + Entry("empty string", "", ""), + ) + + Describe("remapEnvVarKeysFromConfig", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("remaps ND_-prefixed keys to canonical keys", func() { + filename := filepath.Join("testdata", "cfg_nd_keys.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + Expect(conf.Server.Address).To(Equal("127.0.0.1")) + Expect(conf.Server.Port).To(Equal(4531)) + Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) + }) + + It("exits with fatal error when both ND_ and canonical key exist", func() { + cleanup := conf.SetFatalFunc(func(msg string) { + panic(msg) + }) + defer cleanup() + + filename := filepath.Join("testdata", "cfg_nd_conflict.toml") + conf.InitConfig(filename, false) + + Expect(func() { conf.Load(true) }).To(PanicWith(And( + ContainSubstring("ND_ADDRESS"), + ContainSubstring("Address"), + ContainSubstring("only needed for environment variables"), + ))) + }) + + It("does nothing when no ND_ keys are present", func() { + filename := filepath.Join("testdata", "cfg.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + // Verify normal config loading still works + Expect(conf.Server.MusicFolder).To(Equal("/toml/music")) + }) + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index d1d1bb3a9..6498215dc 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -11,3 +11,11 @@ var ParseLanguages = parseLanguages var ValidateURL = validateURL var NormalizeSearchBackend = normalizeSearchBackend + +var ToPascalCase = toPascalCase + +func SetFatalFunc(f func(string)) func() { + old := fatalFunc + fatalFunc = f + return func() { fatalFunc = old } +} diff --git a/conf/testdata/cfg_nd_conflict.toml b/conf/testdata/cfg_nd_conflict.toml new file mode 100644 index 000000000..2e8b94bc3 --- /dev/null +++ b/conf/testdata/cfg_nd_conflict.toml @@ -0,0 +1,2 @@ +ND_ADDRESS = "127.0.0.1" +Address = "0.0.0.0" diff --git a/conf/testdata/cfg_nd_keys.toml b/conf/testdata/cfg_nd_keys.toml new file mode 100644 index 000000000..de441ce66 --- /dev/null +++ b/conf/testdata/cfg_nd_keys.toml @@ -0,0 +1,3 @@ +ND_ADDRESS = "127.0.0.1" +ND_PORT = 4531 +ND_SCANNER_SCHEDULE = "@every 1h" From 049fc78177f797b711aab21d121a16b3ad6c6ba7 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 28 Mar 2026 13:23:03 -0400 Subject: [PATCH 141/596] refactor: extract logFatal helper for config error handling Replace 14 repeated fmt.Fprintln(os.Stderr, "FATAL:", ...)/os.Exit(1) patterns with a single logFatal function. This reduces duplication and makes all fatal config paths testable via SetLogFatal. Signed-off-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 47 ++++++++++----------------- conf/configuration_test.go | 65 +++++++++++++++++++++++++++++++++++--- conf/export_test.go | 8 ++--- 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 3d04e725c..653e3d8e1 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -258,10 +258,10 @@ type searchOptions struct { FullString bool } -// fatalFunc is called for fatal config errors. Defaults to printing + os.Exit(1). +// logFatal prints a fatal error message to stderr and exits. // Overridden in tests to allow testing fatal paths. -var fatalFunc = func(msg string) { - _, _ = fmt.Fprintln(os.Stderr, "FATAL:", msg) +var logFatal = func(args ...any) { + _, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...) os.Exit(1) } @@ -274,8 +274,7 @@ func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err) - os.Exit(1) + logFatal("Error reading config file:", err) } Load(true) } @@ -292,14 +291,12 @@ func Load(noConfigDump bool) { err := viper.Unmarshal(&Server) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } err = os.MkdirAll(Server.DataFolder, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err) - os.Exit(1) + logFatal("Error creating data path:", err) } if Server.CacheFolder == "" { @@ -307,14 +304,12 @@ func Load(noConfigDump bool) { } err = os.MkdirAll(Server.CacheFolder, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err) - os.Exit(1) + logFatal("Error creating cache path:", err) } err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err) - os.Exit(1) + logFatal("Error creating artwork path:", err) } if Server.Plugins.Enabled { @@ -323,8 +318,7 @@ func Load(noConfigDump bool) { } err = os.MkdirAll(Server.Plugins.Folder, 0700) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) - os.Exit(1) + logFatal("Error creating plugins path:", err) } } @@ -336,8 +330,7 @@ func Load(noConfigDump bool) { if Server.Backup.Path != "" { err = os.MkdirAll(Server.Backup.Path, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err) - os.Exit(1) + logFatal("Error creating backup path:", err) } } @@ -345,8 +338,7 @@ func Load(noConfigDump bool) { if Server.LogFile != "" { out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error()) - os.Exit(1) + logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) } log.SetOutput(out) } else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" { @@ -378,8 +370,7 @@ func Load(noConfigDump bool) { if Server.BaseURL != "" { u, err := url.Parse(Server.BaseURL) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err) - os.Exit(1) + logFatal("Invalid BaseURL:", err) } Server.BasePath = u.Path u.Path = "" @@ -487,7 +478,7 @@ func remapEnvVarKeysFromConfig() { displayCanonical := toPascalCase(canonicalKey) if viper.InConfig(canonicalKey) { - fatalFunc(fmt.Sprintf( + logFatal(fmt.Sprintf( "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ "The 'ND_' prefix is only needed for environment variables, not config file keys.", displayNDKey, displayCanonical, @@ -520,18 +511,15 @@ func parseIniFileConfiguration() { var iniConfig map[string]any err := viper.Unmarshal(&iniConfig) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } cfg, ok := iniConfig["default"].(map[string]any) if !ok { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig) - os.Exit(1) + logFatal("Error parsing config: missing [default] section:", iniConfig) } err = viper.MergeConfigMap(cfg) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } } } @@ -872,8 +860,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) { err := viper.ReadInConfig() if viper.ConfigFileUsed() != "" && err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err) - os.Exit(1) + logFatal("Navidrome could not open config file:", err) } } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 9aba04197..eb2176e83 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -2,6 +2,7 @@ package conf_test import ( "fmt" + "os" "path/filepath" "testing" @@ -24,6 +25,11 @@ var _ = Describe("Configuration", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("loglevel", "error") conf.ResetConf() + + // Panic instead of exiting on fatal errors to allow testing error conditions + DeferCleanup(conf.SetLogFatal(func(args ...any) { + panic(fmt.Sprint(args...)) + })) }) Describe("ParseLanguages", func() { @@ -139,11 +145,6 @@ var _ = Describe("Configuration", func() { }) It("exits with fatal error when both ND_ and canonical key exist", func() { - cleanup := conf.SetFatalFunc(func(msg string) { - panic(msg) - }) - defer cleanup() - filename := filepath.Join("testdata", "cfg_nd_conflict.toml") conf.InitConfig(filename, false) @@ -164,6 +165,60 @@ var _ = Describe("Configuration", func() { }) }) + Describe("logFatal", func() { + var invalidPath string + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("loglevel", "error") + conf.ResetConf() + + // Create a file so that any path under it is invalid on all OSes + f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker") + Expect(err).ToNot(HaveOccurred()) + f.Close() + invalidPath = filepath.Join(f.Name(), "subdir") + }) + + It("is called when LoadFromFile gets an invalid config file", func() { + Expect(func() { + conf.LoadFromFile(filepath.Join(invalidPath, "file.toml")) + }).To(PanicWith(ContainSubstring("Error reading config file"))) + }) + + It("is called when DataFolder is not writable", func() { + viper.SetDefault("datafolder", invalidPath) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error creating data path"))) + }) + + It("is called when CacheFolder is not writable", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("cachefolder", invalidPath) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error creating cache path"))) + }) + + It("is called when LogFile path is not writable", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error opening log file"))) + }) + + It("is called when BaseURL is invalid", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("baseurl", "://invalid") + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Invalid BaseURL"))) + }) + + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index 6498215dc..051f9bb65 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -14,8 +14,8 @@ var NormalizeSearchBackend = normalizeSearchBackend var ToPascalCase = toPascalCase -func SetFatalFunc(f func(string)) func() { - old := fatalFunc - fatalFunc = f - return func() { fatalFunc = old } +func SetLogFatal(f func(...any)) func() { + old := logFatal + logFatal = f + return func() { logFatal = old } } From dc99994bdd652765fa1e3d754dc2a4e4cfefaf15 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 29 Mar 2026 14:57:57 -0400 Subject: [PATCH 142/596] feat: add EnableArtworkUpload and CoverArtQuality to insights Signed-off-by: Deluan <deluan@navidrome.org> --- core/metrics/insights.go | 2 ++ core/metrics/insights/data.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 5f3f491ea..b87f1df5e 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -193,6 +193,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache + data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload + data.Config.CoverArtQuality = conf.Server.CoverArtQuality data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableDownloads = conf.Server.EnableDownloads diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 27186e020..b316c866d 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -63,6 +63,8 @@ type Data struct { EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` EnableJukebox bool `json:"enableJukebox,omitempty"` EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` From a293d1203423f542c93ff58d371618fb99e9939c Mon Sep 17 00:00:00 2001 From: ChekeredList71 <66330496+ChekeredList71@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:50:58 +0000 Subject: [PATCH 143/596] fix(ui): update Hungarian translation (#5263) * [ui] hungarian translation * Update resources/i18n/hu.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update resources/i18n/hu.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ChekeredList71 <asd@asd.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- resources/i18n/hu.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index 115b2d1a4..ce3d5ae87 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -22,6 +22,7 @@ "bitRate": "Bitráta", "bitDepth": "Bitmélység", "sampleRate": "Mintavételezési frekvencia", + "disc": "Lemez %{discNumber}", "discSubtitle": "Lemezfelirat", "starred": "Kedvenc", "comment": "Megjegyzés", @@ -350,7 +351,8 @@ "allUsers": "Összes felhasználó engedélyezése", "selectedUsers": "Kiválasztott felhasználók engedélyezése", "allLibraries": "Összes könyvtár engedélyezése", - "selectedLibraries": "Kiválasztott könyvtárak engedélyezése" + "selectedLibraries": "Kiválasztott könyvtárak engedélyezése", +"allowWriteAccess": "Írási hozzáférés engedélyezése" }, "sections": { "status": "Státusz", @@ -395,6 +397,7 @@ "allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.", "noLibraries": "Nincs kiválasztott könyvtár", "librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.", +"allowWriteAccessHelp": "Amikor ez engedélyezve van, a kiegészítő módosíthatja a könyvtár mappáit. Alapbeállításon a kiegészítőknek csak olvasási joguk van.", "requiredHosts": "Szükséges hostok" }, "placeholders": { @@ -549,6 +552,12 @@ } }, "message": { + "uploadCover": "Borítókép feltöltése", + "removeCover": "Borítókép törlése", + "coverUploaded": "Borítókép feltöltve", + "coverRemoved": "Borítókép eltávolítva", + "coverUploadError": "Borítókép feltöltése sikertelen", + "coverRemoveError": "Borítókép törlése sikertelen", "note": "MEGJEGYZÉS", "transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.", "transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.", From 9fe9cf3ff6aaf8c1ac06b2f6cd2cef43616d7679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 29 Mar 2026 19:55:29 -0400 Subject: [PATCH 144/596] fix(ui): update Spanish, French translations from POEditor (#5260) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/es.json | 16 +++++++++++----- resources/i18n/fr.json | 16 +++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 29d1a367f..a018eda3d 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -38,7 +38,7 @@ "missing": "Faltante", "libraryName": "Biblioteca", "composer": "Compositor", - "disc": "" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Reproducir después", @@ -355,7 +355,7 @@ "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", "selectedLibraries": "Bibliotecas seleccionadas", - "allowWriteAccess": "" + "allowWriteAccess": "Permitir acceso de escritura" }, "sections": { "status": "Estado", @@ -401,7 +401,7 @@ "requiredHosts": "Hosts requeridos", "configValidationError": "La validación de la configuración falló:", "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Cuando está activado, el plugin puede modificar archivos en los directorios de la biblioteca. Por defecto, los plugins tienen acceso de solo lectura." }, "placeholders": { "configKey": "clave", @@ -591,7 +591,13 @@ "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", "noSimilarSongsFound": "No se encontraron canciones similares", "noTopSongsFound": "No se encontraron canciones destacadas", - "startingInstantMix": "Cargando la mezcla instantánea..." + "startingInstantMix": "Cargando la mezcla instantánea...", + "uploadCover": "Subir portada", + "removeCover": "Eliminar portada", + "coverUploaded": "Portada actualizada", + "coverRemoved": "Portada eliminada", + "coverUploadError": "Error al subir la portada", + "coverRemoveError": "Error al eliminar la portada" }, "menu": { "library": "Biblioteca", @@ -712,4 +718,4 @@ "empty": "Nada en reproducción", "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 891fde03a..dae9f47e7 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -38,7 +38,7 @@ "missing": "Manquant", "libraryName": "Bibliothèque", "composer": "Compositeur·e", - "disc": "" + "disc": "Disque %{discNumber}" }, "actions": { "addToQueue": "Ajouter à la file", @@ -355,7 +355,7 @@ "selectedUsers": "Utilisateur·rices sélectionné.e.s", "allLibraries": "Autoriser toutes les bibliothèques", "selectedLibraries": "Bibliothèques sélectionnées", - "allowWriteAccess": "" + "allowWriteAccess": "Autoriser l'accès en écriture" }, "sections": { "status": "Statut", @@ -401,7 +401,7 @@ "requiredHosts": "Hôtes requis", "configValidationError": "Erreur lors de la validation de la configuration", "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Lorsque cette option est activée, le plugin peut modifier les fichiers dans les répertoires de la bibliothèque. Par défaut, les plugins ont un accès en lecture seule." }, "placeholders": { "configKey": "clef", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.", "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé", "noTopSongsFound": "Aucun meilleur titre n'a été trouvé", - "startingInstantMix": "Chargement du mix instantanné..." + "startingInstantMix": "Chargement du mix instantané...", + "uploadCover": "Téléverser la pochette", + "removeCover": "Supprimer la pochette", + "coverUploaded": "Pochette mise à jour", + "coverRemoved": "Pochette supprimée", + "coverUploadError": "Erreur lors du téléversement de la pochette", + "coverRemoveError": "Erreur lors de la suppression de la pochette" }, "menu": { "library": "Bibliothèque", @@ -712,4 +718,4 @@ "empty": "Aucun titre en cours de lecture", "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } -} +} \ No newline at end of file From 420d2c8e5ae147b11dc4fb1e6b9734d3ab7c137f Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 30 Mar 2026 07:01:38 -0400 Subject: [PATCH 145/596] fix(artwork): validate ffmpeg pipe before returning in cover art fallback ffmpeg.ExtractImage returns a pipe-based reader immediately, before ffmpeg finishes processing. When the audio file has no embedded image stream (e.g. a plain MP3), ffmpeg exits with an error that closes the pipe asynchronously. The selectImageReader function saw the non-nil reader as a success and returned it instead of falling through to the next source in the chain (album art). This caused getCoverArt to return an error response for tracks on albums where the disc artwork reader was invoked but no embedded art existed. Fixed by reading one byte from the pipe to validate the stream delivers data before returning it. If the read fails, the reader is closed and nil is returned, allowing the fallback chain to continue to album artwork. Closes #5265 --- core/artwork/sources.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 0628461e0..d830593fc 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -130,10 +130,25 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc if err != nil { return nil, "", err } - return r, path, nil + // Validate that the stream actually contains image data by reading the first byte. + // ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the + // file has no video/image stream (e.g., an MP3 without embedded art). + buf := make([]byte, 1) + n, err := r.Read(buf) + if n == 0 || err != nil { + r.Close() + return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err) + } + return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil } } +// readCloser combines a Reader and a Closer into an io.ReadCloser. +type readCloser struct { + io.Reader + io.Closer +} + func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { return func() (io.ReadCloser, string, error) { r, _, err := a.Get(ctx, id, 0, false) From 0f6a076dcae679d2cc4407b905a0786e9c923336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 30 Mar 2026 09:35:02 -0400 Subject: [PATCH 146/596] fix(artwork): refresh stale artist image URLs on expiry (#5267) * fix(external): refresh stale artist image URLs on expiry ArtistImage() was serving cached image URLs from the database indefinitely, ignoring ExternalInfoUpdatedAt. When users changed agent configuration (e.g. disabling Deezer), old URLs persisted because only the UpdateArtistInfo code path checked the TTL. Now ArtistImage() checks the expiry and enqueues a background refresh when the cached info is stale, matching the pattern used by refreshArtistInfo(). The stale URL is still returned immediately to avoid blocking clients. Fixes #5266 * test: add expired artist image info test with log assertion Verify that ArtistImage() enqueues a background refresh when cached info is expired, by capturing log output and checking for the expected debug message. Also asserts the stale URL is returned immediately without calling the agent. Signed-off-by: Deluan <deluan@navidrome.org> * fix: only enqueue refresh when returning a stale cached URL Move the expiry check to the else branch so we only enqueue a background refresh when a cached image URL exists and is being returned. This avoids doubling external API calls when the URL is empty (synchronous fetch) but ExternalInfoUpdatedAt is old. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/external/provider.go | 10 +++- core/external/provider_artistimage_test.go | 65 ++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/core/external/provider.go b/core/external/provider.go index a30afed15..40ca34069 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -374,8 +374,6 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, err } - // Use already-stored image URL if available, avoiding expensive external API calls. - // If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it. imageUrl := artist.ArtistImageUrl() if imageUrl == "" { // No cached URL — must fetch from external source synchronously @@ -385,6 +383,14 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, ctx.Err() } imageUrl = artist.ArtistImageUrl() + } else { + // If cached info is expired, enqueue a background refresh so that config changes + // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. + updatedAt := V(artist.ExternalInfoUpdatedAt) + if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { + log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) + e.artistQueue.enqueue(&artist) + } } if imageUrl == "" { diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 11290bb66..529289ed3 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -1,14 +1,17 @@ package external_test import ( + "bytes" "context" "errors" "net/url" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -266,6 +269,68 @@ var _ = Describe("Provider - ArtistImage", func() { mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") }) + It("returns cached URL and does not call agent when info is not expired", func() { + // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt + recentTime := time.Now().Add(-1 * time.Minute) + cachedArtist := &model.Artist{ + ID: "artist-cached", + Name: "Cached Artist", + LargeImageUrl: "http://example.com/cached-large.jpg", + ExternalInfoUpdatedAt: &recentTime, + } + mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-cached") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) + + // Assert: background refresh was NOT enqueued + Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) + + }) + + It("returns stale URL and enqueues refresh when info is expired", func() { + // Arrange + conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond + expiredTime := time.Now().Add(-1 * time.Hour) + staleArtist := &model.Artist{ + ID: "artist-expired", + Name: "Expired Artist", + LargeImageUrl: "http://example.com/expired-large.jpg", + ExternalInfoUpdatedAt: &expiredTime, + } + mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-expired") + + // Assert: returns stale URL immediately, no agent call + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) + + // Assert: background refresh was enqueued + Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) + }) + Context("Unicode handling in artist names", func() { var artistWithEnDash *model.Artist var expectedURL *url.URL From c5bb920b88c1e7a397fbd3509fa5cbede92c8300 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:57:43 -0400 Subject: [PATCH 147/596] chore(deps): bump golang.org/x/image from 0.37.0 to 0.38.0 (#5268) Bumps [golang.org/x/image](https://github.com/golang/image) from 0.37.0 to 0.38.0. - [Commits](https://github.com/golang/image/compare/v0.37.0...v0.38.0) --- updated-dependencies: - dependency-name: golang.org/x/image dependency-version: 0.38.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 487b57ef7..3c6f7dc69 100644 --- a/go.mod +++ b/go.mod @@ -58,7 +58,7 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.37.0 + golang.org/x/image v0.38.0 golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 diff --git a/go.sum b/go.sum index d7b16d9d3..eb6e6bf27 100644 --- a/go.sum +++ b/go.sum @@ -323,8 +323,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= -golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= From 4030bfe06f808573b568c47d3d272a563e10f027 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 1 Apr 2026 08:38:29 -0400 Subject: [PATCH 148/596] fix(artwork): preserve animation for square thumbnails with animated images Signed-off-by: Deluan <deluan@navidrome.org> --- core/artwork/reader_resized.go | 24 ++++++++-------- core/artwork/reader_resized_test.go | 44 ++++++++++++++++------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 72baad434..88ca8b83b 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -98,21 +98,19 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return nil, 0, fmt.Errorf("reading image data: %w", err) } - // Preserve animation for animated images (skip for square thumbnails) - if !a.square { - if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) + // Preserve animation for animated images + if isAnimatedGIF(data) { + if a.a.ffmpeg.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil + log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil } return resizeStaticImage(data, a.size, a.square) diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go index 7c9e21c0a..7c14f5e44 100644 --- a/core/artwork/reader_resized_test.go +++ b/core/artwork/reader_resized_test.go @@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() { Expect(len(output)).To(BeNumerically(">", 0)) }) - It("skips animation for square thumbnails even with animated GIF", func() { + It("preserves animation for square thumbnails with animated GIF", func() { r.square = true data := createAnimatedGIF(3) result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Should fall through to static resize (not ffmpeg conversion) - // The minimal test GIF may or may not resize successfully, - // but ffmpeg should NOT have been called for animated conversion - _ = result - _ = err - // Verify by checking the mock wasn't used for animated conversion: - // If ffmpeg was called, it would return mock data, not static resize result + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should have been processed by ffmpeg (mock returns input data) + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) }) }) @@ -81,13 +81,17 @@ var _ = Describe("resizeImage", func() { Expect(output).To(Equal(data)) }) - It("does not passthrough animated WebP for square thumbnails", func() { + It("preserves animated WebP for square thumbnails", func() { r.square = true data := createAnimatedWebPBytes() - // Should fall through to static resize, which will fail on fake WebP data - _, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static decode will fail on our minimal test WebP bytes (not a real image) - Expect(err).To(HaveOccurred()) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) }) }) @@ -104,15 +108,17 @@ var _ = Describe("resizeImage", func() { Expect(output).To(Equal(data)) }) - It("does not passthrough animated PNG for square thumbnails", func() { + It("preserves animated PNG for square thumbnails", func() { r.square = true data := createAPNGBytes() - // Should fall through to static resize result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static PNG decode should succeed on our APNG (it's a valid PNG) - if err == nil { - Expect(result).ToNot(BeNil()) - } + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) }) }) From 6109bf519228b6cd6ae6b6a8b06a6a8c4e7a6cae Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 1 Apr 2026 08:51:10 -0400 Subject: [PATCH 149/596] chore(deps): update go-sqlite3 to v1.14.38 and go-toml to v2.3.0 Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 3c6f7dc69..fcee08c7e 100644 --- a/go.mod +++ b/go.mod @@ -36,12 +36,12 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mattn/go-sqlite3 v1.14.37 + github.com/mattn/go-sqlite3 v1.14.38 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/pelletier/go-toml/v2 v2.3.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 @@ -104,7 +104,7 @@ require ( github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect diff --git a/go.sum b/go.sum index eb6e6bf27..e0671367a 100644 --- a/go.sum +++ b/go.sum @@ -167,8 +167,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= -github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= @@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= 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.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= -github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= +github.com/mattn/go-sqlite3 v1.14.38/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= @@ -199,8 +199,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= From 220019a9f196bb146d1c8df407944afe2be709ff Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 1 Apr 2026 18:19:06 -0400 Subject: [PATCH 150/596] fix: add missing viper defaults for mpvpath, artistimagefolder, and plugins.loglevel Fix #5284 Several configOptions struct fields were missing corresponding viper.SetDefault entries, making them invisible to environment variable overrides and config file parsing. Added defaults for mpvpath (consistent with ffmpegpath), artistimagefolder, and plugins.loglevel. Signed-off-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/conf/configuration.go b/conf/configuration.go index 653e3d8e1..fce5e0b2f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -712,10 +712,12 @@ func setViperDefaults() { viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") + viper.SetDefault("mpvpath", "") viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartquality", 75) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") + viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) @@ -794,6 +796,7 @@ func setViperDefaults() { viper.SetDefault("plugins.enabled", true) viper.SetDefault("plugins.cachesize", "200MB") viper.SetDefault("plugins.autoreload", false) + viper.SetDefault("plugins.loglevel", "") // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) From c60637de249d52266121bbf08d4d8192acf1b478 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 2 Apr 2026 15:44:20 -0400 Subject: [PATCH 151/596] fix(subsonic): return proper artwork ID format in getInternetRadioStations The coverArt field was returning the raw uploaded image filename instead of the standard ra-{id} artwork ID format. This caused getCoverArt to fail when clients passed the coverArt value directly. Now uses CoverArtID().String() consistent with how albums, artists, and playlists return their coverArt values. Fixes #5293. --- server/subsonic/radio.go | 6 +++++- server/subsonic/radio_test.go | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 7121566f9..4fbd6a53d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -75,8 +75,12 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro continue } // Add coverArt if not legacy client + var coverArt string + if g.UploadedImage != "" { + coverArt = g.CoverArtID().String() + } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ - CoverArt: g.UploadedImage, + CoverArt: coverArt, } } diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index d5b764f60..e959ebe29 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,7 +71,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) @@ -129,7 +129,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) }) }) From 23f3556371321faf199866989b906f2ef06a8034 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 2 Apr 2026 16:37:52 -0400 Subject: [PATCH 152/596] fix(subsonic): strip OpenSubsonic extensions from playlists for legacy clients buildOSPlaylist was the only OpenSubsonic builder function missing the LegacyClients guard, causing attributes like `validUntil` and `readonly` to appear in playlist XML responses for legacy clients like DSub2000. This caused a crash when DSub2000 tried to parse evaluated smart playlists containing the `validUntil` attribute. --- server/subsonic/playlists.go | 4 ++++ server/subsonic/playlists_test.go | 34 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index baae7514b..a8c3da68c 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -159,6 +159,10 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response } func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist { + player, ok := request.PlayerFrom(ctx) + if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) { + return nil + } pls := responses.OpenSubsonicPlaylist{} if p.IsSmartPlaylist() { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 41701b4de..3f2a2068e 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -128,6 +128,23 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("with legacy client", func() { + BeforeEach(func() { + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all standard fields but no OpenSubsonic extensions", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) + Context("when no player in context", func() { It("returns all fields", func() { result := router.buildPlaylist(ctx, playlist) @@ -213,6 +230,23 @@ var _ = Describe("buildPlaylist", func() { Expect(result.ValidUntil).To(Equal(&validUntil)) }) }) + + Context("with legacy client", func() { + BeforeEach(func() { + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns standard fields but no OpenSubsonic extensions", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) }) }) From 80c1e602593dc20c4d72255324acaa8403a4a524 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 4 Apr 2026 10:37:28 -0400 Subject: [PATCH 153/596] feat(playlists): add sampleRate, codec, and missing fields for smart playlists Closes #5302 --- model/criteria/fields.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b9d91f087..bc3c7a3d3 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -35,6 +35,7 @@ var fieldMap = map[string]*mappedField{ "releasedate": {field: "media_file.release_date"}, "size": {field: "media_file.size"}, "compilation": {field: "media_file.compilation"}, + "missing": {field: "media_file.missing"}, "explicitstatus": {field: "media_file.explicit_status"}, "dateadded": {field: "media_file.created_at"}, "datemodified": {field: "media_file.updated_at"}, @@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{ "catalognumber": {field: "media_file.catalog_num"}, "filepath": {field: "media_file.path"}, "filetype": {field: "media_file.suffix"}, + "codec": {field: "media_file.codec"}, "duration": {field: "media_file.duration"}, "bitrate": {field: "media_file.bit_rate"}, "bitdepth": {field: "media_file.bit_depth"}, + "samplerate": {field: "media_file.sample_rate"}, "bpm": {field: "media_file.bpm"}, "channels": {field: "media_file.channels"}, "loved": {field: "COALESCE(annotation.starred, false)"}, From c87db92cee70fa5aa78e781b9a2ef93c46000d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 4 Apr 2026 15:17:01 -0400 Subject: [PATCH 154/596] fix(artwork): address WebP performance regression on low-power hardware (#5286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(artwork): rename DevJpegCoverArt to EnableWebPEncoding Replaced the internal DevJpegCoverArt flag with a user-facing EnableWebPEncoding config option (defaults to true). When disabled, the fallback encoding now preserves the original image format — PNG sources stay PNG for non-square resizes, matching v0.60.3 behavior. The previous implementation incorrectly re-encoded PNG sources as JPEG in non-square mode. Also added EnableWebPEncoding to the insights data. * feat: add configurable UICoverArtSize option Converted the hardcoded UICoverArtSize constant (600px) into a configurable option, allowing users to reduce the cover art size requested by the UI to mitigate slow image encoding. The value is served to the frontend via the app config and used by all components that request cover art. Also simplified the cache warmer by removing a single-iteration loop in favor of direct code. * style: fix prettier formatting in subsonic test * feat: log WebP encoder/decoder selection Signed-off-by: Deluan <deluan@navidrome.org> * fix(artwork): address PR review feedback - Add DevJpegCoverArt to logRemovedOptions so users with the old config key get a clear warning instead of a silent ignore. - Include EnableWebPEncoding in the resized artwork cache key to prevent stale WebP responses after toggling the setting. - Skip animated GIF to WebP conversion via ffmpeg when EnableWebPEncoding is false, so the setting is consistent across all image types. - Fix data race in cache warmer by reading UICoverArtSize at construction time instead of per-image, avoiding concurrent access with config cleanup in tests. - Clarify cache warmer docstring to accurately describe caching behavior. * Revert "fix(artwork): address PR review feedback" This reverts commit 3a213ef03e401930977138afe0e84c83290df683. * fix(artwork): avoid data race in cache warmer config access Capture UICoverArtSize at construction time instead of reading from conf.Server on each doCacheImage call. The background goroutine could race with test config cleanup, causing intermittent race detector failures in CI. * fix(configuration): clamp UICoverArtSize to be within 200 and 1200 Signed-off-by: Deluan <deluan@navidrome.org> * fix(artwork): preserve album cache key compatibility with v0.60.3 Restored the v0.60.3 hash input order for album artwork cache keys (Agents + CoverArtPriority) so that existing caches remain valid on upgrade when EnableExternalServices is true. Also ensures CoverArtPriority is always part of the hash even when external services are disabled, fixing a v0.60.3 bug where changing CoverArtPriority had no effect on cache invalidation. Signed-off-by: Deluan <deluan@navidrome.org> * fix: default EnableWebPEncoding to false and reduce artwork parallelism Changed EnableWebPEncoding default to false so that upgrading users get the same JPEG/PNG encoding behavior as v0.60.3 out of the box, avoiding the WebP WASM overhead until native libwebp is available. Users can opt in to WebP by setting EnableWebPEncoding=true. Also reduced the default DevArtworkMaxRequests to half the CPU count (min 2) to lower resource pressure during artwork processing. * fix(configuration): update DefaultUICoverArtSize to 300 Signed-off-by: Deluan <deluan@navidrome.org> * fix(Makefile): append EXTRA_BUILD_TAGS to GO_BUILD_TAGS Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- Makefile | 4 +- conf/configuration.go | 15 +++++-- consts/consts.go | 4 +- core/artwork/artwork_internal_test.go | 55 +++++++++++++++++++------- core/artwork/cache_warmer.go | 39 +++++++++--------- core/artwork/cache_warmer_test.go | 3 +- core/artwork/reader_album.go | 2 +- core/artwork/reader_resized.go | 24 +++++++---- core/metrics/insights.go | 2 + core/metrics/insights/data.go | 2 + server/public/handle_shares.go | 3 +- server/serve_index.go | 1 + server/serve_index_test.go | 1 + ui/src/album/AlbumDetails.jsx | 5 +-- ui/src/album/AlbumGridView.jsx | 5 ++- ui/src/artist/DesktopArtistDetails.jsx | 3 +- ui/src/artist/MobileArtistDetails.jsx | 3 +- ui/src/common/CoverArtAvatar.jsx | 4 +- ui/src/config.js | 1 + ui/src/consts.js | 2 - ui/src/playlist/PlaylistDetails.jsx | 4 +- ui/src/radio/RadioEdit.jsx | 5 ++- ui/src/radio/helper.jsx | 5 ++- ui/src/subsonic/index.test.js | 28 ++++++++++--- 24 files changed, 142 insertions(+), 78 deletions(-) diff --git a/Makefile b/Makefile index 3bad5b620..0673838c2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') NODE_VERSION=$(shell cat .nvmrc) -GO_BUILD_TAGS=netgo,sqlite_fts5 + +comma:=, +GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS)) # Set global environment variables, required for most targets export CGO_CFLAGS_ALLOW=--define-prefix diff --git a/conf/configuration.go b/conf/configuration.go index fce5e0b2f..58239884a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -70,6 +70,7 @@ type configOptions struct { MPVCmdTemplate string CoverArtPriority string CoverArtQuality int + EnableWebPEncoding bool ArtistArtPriority string ArtistImageFolder string DiscArtPriority string @@ -87,6 +88,7 @@ type configOptions struct { DefaultLanguage string DefaultUIVolume int UISearchDebounceMs int + UICoverArtSize int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool @@ -141,7 +143,6 @@ type configOptions struct { DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool - DevJpegCoverArt bool } type scannerOptions struct { @@ -424,6 +425,13 @@ func Load(noConfigDump bool) { // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") + // Validate other options + if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 { + newValue := max(200, min(1200, Server.UICoverArtSize)) + log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue) + Server.UICoverArtSize = newValue + } + // Call init hooks for _, hook := range hooks { hook() @@ -716,6 +724,7 @@ func setViperDefaults() { viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartquality", 75) + viper.SetDefault("enablewebpencoding", false) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") @@ -728,6 +737,7 @@ func setViperDefaults() { viper.SetDefault("defaultlanguage", "") viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs) + viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize) viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) @@ -810,7 +820,7 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) - viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU())) + viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) @@ -826,7 +836,6 @@ func setViperDefaults() { viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) - viper.SetDefault("devjpegcoverart", false) } func init() { diff --git a/consts/consts.go b/consts/consts.go index f1010a872..ff5dedc2b 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -85,11 +85,9 @@ const ( ) const ( - UICoverArtSize = 600 + DefaultUICoverArtSize = 300 ) -var CacheWarmerImageSizes = []int{UICoverArtSize} - // Prometheus options const ( PrometheusDefaultPath = "/metrics" diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 4b2359898..380352d3f 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() { }) }) When("Square is false", func() { - It("returns WebP even if original image is a PNG", func() { + It("returns PNG if original image is a PNG", func() { conf.Server.CoverArtPriority = "front.png" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("webp")) + Expect(format).To(Equal("png")) Expect(img.Bounds().Size().X).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15)) }) - It("returns WebP if original image is not a PNG", func() { + It("returns JPEG if original image is not a PNG", func() { conf.Server.CoverArtPriority = "cover.jpg" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) - Expect(format).To(Equal("webp")) + Expect(format).To(Equal("jpeg")) Expect(err).ToNot(HaveOccurred()) Expect(img.Bounds().Size().X).To(Equal(200)) Expect(img.Bounds().Size().Y).To(Equal(200)) @@ -430,24 +430,51 @@ var _ = Describe("Artwork", func() { Expect(img.Bounds().Size().X).To(Equal(size)) Expect(img.Bounds().Size().Y).To(Equal(size)) }, - Entry("portrait png image", "png", "webp", false, 200), - Entry("landscape png image", "png", "webp", true, 200), - Entry("portrait jpg image", "jpg", "webp", false, 200), - Entry("landscape jpg image", "jpg", "webp", true, 200), + Entry("portrait png image", "png", "png", false, 200), + Entry("landscape png image", "png", "png", true, 200), + Entry("portrait jpg image", "jpg", "png", false, 200), + Entry("landscape jpg image", "jpg", "png", true, 200), ) }) - When("DevJpegCoverArt is true and square is false", func() { + When("EnableWebPEncoding is true and square is false", func() { BeforeEach(func() { - conf.Server.DevJpegCoverArt = true + conf.Server.EnableWebPEncoding = true }) - It("returns JPEG even if original image is a PNG", func() { + It("returns WebP even if original image is a PNG", func() { conf.Server.CoverArtPriority = "front.png" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("jpeg")) + Expect(format).To(Equal("webp")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns WebP if original image is not a PNG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(format).To(Equal("webp")) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("EnableWebPEncoding is false and square is false", func() { + BeforeEach(func() { + conf.Server.EnableWebPEncoding = false + }) + It("returns PNG if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) Expect(img.Bounds().Size().X).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15)) }) @@ -463,11 +490,11 @@ var _ = Describe("Artwork", func() { Expect(img.Bounds().Size().Y).To(Equal(200)) }) }) - When("DevJpegCoverArt is true and square is true", func() { + When("EnableWebPEncoding is false and square is true", func() { var alCover model.Album BeforeEach(func() { - conf.Server.DevJpegCoverArt = true + conf.Server.EnableWebPEncoding = false }) It("returns PNG for square mode", func() { dirName := createImage("png", false, 200) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index bd1359b74..5090d638e 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -10,7 +10,6 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -24,7 +23,7 @@ type CacheWarmer interface { // NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background // to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original -// image size, as well as the size defined in the UICoverArtSize constant. +// image size, as well as the size defined by the UICoverArtSize config option. func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { // If image cache is disabled, return a NOOP implementation if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { @@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { } a := &cacheWarmer{ - artwork: artwork, - cache: cache, - buffer: make(map[model.ArtworkID]struct{}), - wakeSignal: make(chan struct{}, 1), + artwork: artwork, + cache: cache, + buffer: make(map[model.ArtworkID]struct{}), + wakeSignal: make(chan struct{}, 1), + coverArtSize: conf.Server.UICoverArtSize, } // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts @@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { } type cacheWarmer struct { - artwork Artwork - buffer map[model.ArtworkID]struct{} - mutex sync.Mutex - cache cache.FileCache - wakeSignal chan struct{} + artwork Artwork + buffer map[model.ArtworkID]struct{} + mutex sync.Mutex + cache cache.FileCache + wakeSignal chan struct{} + coverArtSize int } func (a *cacheWarmer) PreCache(artID model.ArtworkID) { @@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - for _, size := range consts.CacheWarmerImageSizes { - r, _, err := a.artwork.Get(ctx, id, size, true) - if err != nil { - return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) - } - _, err = io.Copy(io.Discard, r) - r.Close() - return err + size := a.coverArtSize + r, _, err := a.artwork.Get(ctx, id, size, true) + if err != nil { + return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) } - return nil + _, err = io.Copy(io.Discard, r) + r.Close() + return err } func NoopCacheWarmer() CacheWarmer { diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 9798ea8d6..a5da2004c 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" @@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() { Eventually(func() []int { return aw.getCachedSizes() - }).Should(ContainElements(consts.UICoverArtSize)) + }).Should(ContainElements(conf.Server.UICoverArtSize)) }) }) }) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 6de1d31d1..641b12b33 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar func (a *albumArtworkReader) Key() string { hashInput := conf.Server.CoverArtPriority if conf.Server.EnableExternalServices { - hashInput += conf.Server.Agents + hashInput = conf.Server.Agents + hashInput } hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 88ca8b83b..85a19a4c3 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -19,6 +19,16 @@ import ( xdraw "golang.org/x/image/draw" ) +func init() { + conf.AddHook(func() { + if err := webp.Dynamic(); err != nil { + log.Debug("Using WASM WebP encoder/decoder", "reason", err) + } else { + log.Debug("Using native libwebp for WebP encoding/decoding") + } + }) +} + var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) @@ -117,7 +127,7 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader } func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, _, err := image.Decode(bytes.NewReader(data)) + original, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, 0, err } @@ -157,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro buf := bufPool.Get().(*bytes.Buffer) buf.Reset() - if conf.Server.DevJpegCoverArt { - if square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - } else { + if conf.Server.EnableWebPEncoding { err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) } if err != nil { bufPool.Put(buf) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index b87f1df5e..f069d3fb6 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -195,6 +195,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload data.Config.CoverArtQuality = conf.Server.CoverArtQuality + data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding + data.Config.UICoverArtSize = conf.Server.UICoverArtSize data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableDownloads = conf.Server.EnableDownloads diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index b316c866d..34648a49b 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -65,6 +65,8 @@ type Data struct { EnablePrometheus bool `json:"enablePrometheus,omitempty"` EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 15e63d4db..24ecff1d6 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -6,6 +6,7 @@ import ( "net/http" "path" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/publicurl" @@ -81,7 +82,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { s.URL = ShareURL(r, s.ID) - s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), consts.UICoverArtSize) + s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } diff --git a/server/serve_index.go b/server/serve_index.go index 0d1a2f330..bd5be44f5 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -55,6 +55,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, + "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, "gaTrackingId": conf.Server.GATrackingID, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e08a42643..7515e7276 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() { Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), + Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 2411b8611..cec66eb8b 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -18,7 +18,7 @@ import { useTranslate, } from 'react-admin' import Lightbox from 'react-image-lightbox' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' import { @@ -32,7 +32,6 @@ import { useAlbumsPerPage, useImageLoadingState, } from '../common' -import config from '../config' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -255,7 +254,7 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index c8a161571..9717618fa 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -20,7 +20,8 @@ import { OverflowTooltip, useImageUrl, } from '../common' -import { COVER_ART_SIZE, DraggableTypes } from '../consts' +import config from '../config' +import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' @@ -135,7 +136,7 @@ const Cover = withContentRect('bounds')(({ [record], ) - const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const { imgUrl, loading: imageLoading } = useImageUrl(url) return ( diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index bc2312477..dda761097 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -15,7 +15,6 @@ import { import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' -import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { <CardMedia key={record.id} component="img" - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)} + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} onLoad={handleImageLoad} diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 9c6cd88ae..e82d4c28a 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -11,7 +11,6 @@ import { useImageLoadingState, } from '../common' import Lightbox from 'react-image-lightbox' -import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -113,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { <CardMedia key={record.id} component="img" - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)} + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} onLoad={handleImageLoad} diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index bc6dd8a3c..f70403774 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin' import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import subsonic from '../subsonic' import { useImageUrl } from './useImageUrl' @@ -28,7 +28,7 @@ export const CoverArtAvatar = ({ const record = recordProp || recordContext const square = variant !== 'circular' const url = record - ? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square) + ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square) : null const { imgUrl } = useImageUrl(url) if (!record) return null diff --git a/ui/src/config.js b/ui/src/config.js index bd11d7df6..f4d60dfba 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -21,6 +21,7 @@ const defaultConfig = { defaultLanguage: '', defaultUIVolume: 100, uiSearchDebounceMs: 200, + uiCoverArtSize: 600, enableUserEditing: true, enableArtworkUpload: true, enableSharing: true, diff --git a/ui/src/consts.js b/ui/src/consts.js index 68ec478f1..472cd4940 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -26,8 +26,6 @@ DraggableTypes.ALL.push( export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' -export const COVER_ART_SIZE = 600 - export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index f2f528d4b..c396cbbeb 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -18,7 +18,7 @@ import { OverflowTooltip, useImageLoadingState, } from '../common' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import subsonic from '../subsonic' const useStyles = makeStyles( @@ -107,7 +107,7 @@ const PlaylistDetails = (props) => { handleCloseLightbox, } = useImageLoadingState(record.id) - const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index 5f804535a..bbe001e6f 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -11,7 +11,8 @@ import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' import subsonic from '../subsonic' -import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' +import config from '../config' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' const useStyles = makeStyles({ coverParent: { @@ -83,7 +84,7 @@ const RadioCoverArt = ({ record }) => { {record.uploadedImage ? ( <CardMedia component="img" - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)} + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onLoad={handleImageLoad} onError={handleImageError} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx index b278c0d7d..b451dae9a 100644 --- a/ui/src/radio/helper.jsx +++ b/ui/src/radio/helper.jsx @@ -1,5 +1,6 @@ import subsonic from '../subsonic' -import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' +import config from '../config' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' export async function songFromRadio(radio) { if (!radio) { @@ -8,7 +9,7 @@ export async function songFromRadio(radio) { let cover = RADIO_PLACEHOLDER_IMAGE if (radio.uploadedImage) { - cover = subsonic.getCoverArtUrl(radio, COVER_ART_SIZE, true) + cover = subsonic.getCoverArtUrl(radio, config.uiCoverArtSize, true) } else { // Try favicon as fallback try { diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 38b910b08..a750694f4 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -1,5 +1,5 @@ import { vi } from 'vitest' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import subsonic from './index' describe('getCoverArtUrl', () => { @@ -31,7 +31,11 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + playlistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('pl-playlist-123') expect(url).toContain('size=600') @@ -45,7 +49,11 @@ describe('getCoverArtUrl', () => { sync: true, } - const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + playlistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('pl-playlist-123') expect(url).toContain('size=600') @@ -60,7 +68,11 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + albumRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('al-album-123') expect(url).toContain('size=600') @@ -74,7 +86,7 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true) expect(url).toContain('mf-song-123') expect(url).toContain('size=600') @@ -87,7 +99,11 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + artistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('ar-artist-123') expect(url).toContain('size=600') From 93631cdee99ecfec713ff0721277022c6528eb50 Mon Sep 17 00:00:00 2001 From: Xabi <888924+xabirequejo@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:17:40 +0200 Subject: [PATCH 155/596] fix(ui): update Basque localisation (#5278) Added missing strings --- resources/i18n/eu.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 58954c9dc..6bfd09d0e 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -23,6 +23,7 @@ "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", "channels": "Kanalak", + "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", "starred": "Gogokoa", "comment": "Iruzkina", @@ -355,7 +356,8 @@ "allUsers": "Baimendu erabiltzaile guztiak", "selectedUsers": "Hautatutako erabiltzaileak", "allLibraries": "Baimendu liburutegi guztiak", - "selectedLibraries": "Hautatutako liburutegiak" + "selectedLibraries": "Hautatutako liburutegiak", + "allowWriteAccess": "Eman idazteko baimena" }, "sections": { "status": "Egoera", @@ -400,6 +402,7 @@ "allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.", "noLibraries": "Ez da liburutegirik hautatu", "librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.", + "allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.", "requiredHosts": "Beharrezko ostatatzaileak" }, "placeholders": { @@ -554,6 +557,12 @@ } }, "message": { + "uploadCover": "Igo azala", + "removeCover": "Kendu azala", + "coverUploaded": "Diskoaren azala eguneratu da", + "coverRemoved": "Diskoaren azala kendu da", + "coverUploadError": "Errorea diskoaren azala igotzean", + "coverRemoveError": "Errorea diskoaren azala kentzean", "note": "OHARRA", "transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.", "transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.", @@ -673,6 +682,7 @@ "currentValue": "Uneko balioa", "configurationFile": "Konfigurazio-fitxategia", "exportToml": "Esportatu konfigurazioa (TOML)", + "downloadToml": "Deskargatu konfigurazioa (TOML)", "exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan", "exportFailed": "Konfigurazioa kopiatzeak huts egin du", "devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)", From e7c7cba87374ebe1bace57271bc5e8cf731b7a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 4 Apr 2026 15:18:00 -0400 Subject: [PATCH 156/596] fix(ui): update Esperanto, Dutch translations from POEditor (#5301) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/eo.json | 95 ++++++++++++++++++++++++++++++++++++++++-- resources/i18n/nl.json | 40 +++++++++++------- 2 files changed, 116 insertions(+), 19 deletions(-) diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json index 7a13c471d..60eaa6d7c 100644 --- a/resources/i18n/eo.json +++ b/resources/i18n/eo.json @@ -36,7 +36,9 @@ "bitDepth": "Bitprofundo", "sampleRate": "Elprena rapido", "missing": "Mankaj", - "libraryName": "Biblioteko" + "libraryName": "Biblioteko", + "composer": "", + "disc": "" }, "actions": { "addToQueue": "Ludi Poste", @@ -46,7 +48,8 @@ "download": "Elŝuti", "playNext": "Ludu Poste", "info": "Akiri Informon", - "showInPlaylist": "Montri en Ludlisto" + "showInPlaylist": "Montri en Ludlisto", + "instantMix": "" } }, "album": { @@ -328,6 +331,82 @@ "scanInProgress": "Skano progresas...", "noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto" } + }, + "plugin": { + "name": "", + "fields": { + "id": "", + "name": "", + "description": "", + "version": "Versio", + "author": "Aŭtoro", + "website": "Retejo", + "permissions": "Permesoj", + "enabled": "Ebligite", + "status": "", + "path": "Vojo", + "lastError": "Eraro", + "hasError": "Eraro", + "updatedAt": "Ĝisdatigite", + "createdAt": "", + "configKey": "Ŝlosilo", + "configValue": "", + "allUsers": "", + "selectedUsers": "", + "allLibraries": "", + "selectedLibraries": "", + "allowWriteAccess": "" + }, + "sections": { + "status": "", + "info": "", + "configuration": "", + "manifest": "", + "usersPermission": "", + "libraryPermission": "" + }, + "status": { + "enabled": "", + "disabled": "" + }, + "actions": { + "enable": "", + "disable": "", + "disabledDueToError": "", + "disabledUsersRequired": "", + "disabledLibrariesRequired": "", + "addConfig": "", + "rescan": "" + }, + "notifications": { + "enabled": "", + "disabled": "", + "updated": "", + "error": "" + }, + "validation": { + "invalidJson": "" + }, + "messages": { + "configHelp": "", + "clickPermissions": "", + "noConfig": "", + "allUsersHelp": "", + "noUsers": "", + "permissionReason": "", + "usersRequired": "", + "allLibrariesHelp": "", + "noLibraries": "", + "librariesRequired": "", + "requiredHosts": "", + "configValidationError": "", + "schemaRenderError": "", + "allowWriteAccessHelp": "" + }, + "placeholders": { + "configKey": "", + "configValue": "" + } } }, "ra": { @@ -511,7 +590,14 @@ "remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn", "remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.", "noSimilarSongsFound": "Neniuj similaj kantoj trovitaj", - "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj" + "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj", + "startingInstantMix": "", + "uploadCover": "", + "removeCover": "", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" }, "menu": { "library": "Biblioteko", @@ -597,7 +683,8 @@ "exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato", "exportFailed": "Malsukcesis kopii agordojn", "devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)", - "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj" + "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj", + "downloadToml": "" } }, "activity": { diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 86793ee19..3f638c13c 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -37,7 +37,8 @@ "sampleRate": "Sample waarde", "missing": "Ontbrekend", "libraryName": "Bibliotheek", - "composer": "" + "composer": "Componist", + "disc": "Schijf %{discNumber}" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -48,7 +49,7 @@ "playNext": "Volgende", "info": "Meer info", "showInPlaylist": "Toon in afspeellijst", - "instantMix": "" + "instantMix": "Instant mix" } }, "album": { @@ -350,10 +351,11 @@ "createdAt": "Geinstalleerd", "configKey": "Sleutel", "configValue": "Waarde", - "allUsers": "Alle gebruikers toelaten", + "allUsers": "Sta toe voor alle gebruikers", "selectedUsers": "Geselecteerde gebruikers", - "allLibraries": "Alle bibliotheken toestaan", - "selectedLibraries": "Geselecteerde bibliotheken" + "allLibraries": "Sta toe voor alle bibliotheken", + "selectedLibraries": "Geselecteerde bibliotheken", + "allowWriteAccess": "Sta schrijftoegang toe" }, "sections": { "status": "Status", @@ -379,26 +381,27 @@ "notifications": { "enabled": "Plugin actief", "disabled": "Plugin niet actief", - "updated": "Plugin geupdate", + "updated": "Plugin bijgewerkt", "error": "Fout bij updaten plugin" }, "validation": { "invalidJson": "Configuratie moet geldige JSON zijn" }, "messages": { - "configHelp": "", + "configHelp": "Configureer de plug-in met key-value paren. Leeglaten als de plug-in niet geconfigueerd hoeft te worden.", "clickPermissions": "Klik op permissie voor details", "noConfig": "Geen configuratie ingesteld", - "allUsersHelp": "", + "allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.", "noUsers": "Geen gebruikers geselecteerd", "permissionReason": "Reden", - "usersRequired": "", - "allLibrariesHelp": "", + "usersRequired": "Deze plug-in heeft toegang nodig tot gebruikersinformatie. Selecteer welke gebruikers de plug-in toegang toe heeft, of schakel 'sta toe voor alle gebruikers' in.", + "allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.", "noLibraries": "Geen bibliotheken geselecteerd", - "librariesRequired": "", + "librariesRequired": "Deze plug-in heeft toegang nodig tot bibliotheek informatie. Selecteer welke bibliotheken de plug-in toegang to heeft, of schakel 'sta toe voor alle bibliotheken' in.", "requiredHosts": "Benodigde hosts", - "configValidationError": "", - "schemaRenderError": "" + "configValidationError": "Configuratiecheck mislukt", + "schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.", + "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen." }, "placeholders": { "configKey": "Sleutel", @@ -588,7 +591,13 @@ "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.", "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden", "noTopSongsFound": "Geen beste nummers gevonden", - "startingInstantMix": "" + "startingInstantMix": "Laden van Instant mix...", + "uploadCover": "Albumhoes toevoegen", + "removeCover": "Verwijder albumhoes", + "coverUploaded": "Albumhoes bijgewerkt", + "coverRemoved": "Albumhoes verwijderd", + "coverUploadError": "Fout bij het toevoegen albumhoes", + "coverRemoveError": "Fout bij verwijderen albumhoes" }, "menu": { "library": "Bibliotheek", @@ -674,7 +683,8 @@ "exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat", "exportFailed": "Kopiëren van configuratie mislukt", "devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)", - "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd" + "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd", + "downloadToml": "Download configuratie (TOML)" } }, "activity": { From 2018979bc34e7bdc3f119c095a70e39fa26c524d Mon Sep 17 00:00:00 2001 From: Chris M <821688+tebriel@users.noreply.github.com> Date: Sun, 5 Apr 2026 11:37:50 -0400 Subject: [PATCH 157/596] chore(ui): regenerate package-lock.json to have integrity fields (#5276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): regenerate package-lock.json to have integrity fields * chore(deps): update esbuild and related packages to version 0.27.7 Signed-off-by: Deluan <deluan@navidrome.org> * chore(lint): exclude node_modules from golangci-lint Prevents lint errors from Go files inside npm packages under ui/node_modules from being picked up by golangci-lint. --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- .golangci.yml | 2 + ui/package-lock.json | 2793 +++++++++++++++++++++++++++++++++++------- 2 files changed, 2348 insertions(+), 447 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index b6c632dee..28eb375a5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -55,6 +55,7 @@ linters: - third_party$ - builtin$ - examples$ + - node_modules formatters: exclusions: generated: lax @@ -62,3 +63,4 @@ formatters: - third_party$ - builtin$ - examples$ + - node_modules diff --git a/ui/package-lock.json b/ui/package-lock.json index a9b83d76e..2dd91a674 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -75,11 +75,15 @@ }, "node_modules/@adobe/css-tools": { "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", "dependencies": { @@ -92,11 +96,15 @@ }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -108,25 +116,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -144,17 +157,21 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.28.6", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -165,6 +182,8 @@ }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "license": "MIT", "dependencies": { "@babel/types": "^7.27.3" @@ -175,6 +194,8 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -189,6 +210,8 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -196,6 +219,8 @@ }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -215,6 +240,8 @@ }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -222,6 +249,8 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -237,20 +266,24 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -258,6 +291,8 @@ }, "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -276,6 +311,8 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -283,6 +320,8 @@ }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.5", @@ -294,6 +333,8 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -305,6 +346,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -320,6 +363,8 @@ }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "license": "MIT", "dependencies": { "@babel/types": "^7.27.1" @@ -330,6 +375,8 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -337,6 +384,8 @@ }, "node_modules/@babel/helper-remap-async-to-generator": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", @@ -352,6 +401,8 @@ }, "node_modules/@babel/helper-replace-supers": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", @@ -367,6 +418,8 @@ }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -378,6 +431,8 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -385,6 +440,8 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -392,6 +449,8 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -399,6 +458,8 @@ }, "node_modules/@babel/helper-wrap-function": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -410,21 +471,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -435,6 +500,8 @@ }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -449,6 +516,8 @@ }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -462,6 +531,8 @@ }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -475,6 +546,8 @@ }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -490,6 +563,8 @@ }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -504,6 +579,8 @@ }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -514,6 +591,8 @@ }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -527,6 +606,8 @@ }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -540,6 +621,8 @@ }, "node_modules/@babel/plugin-syntax-unicode-sets-regex": { "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -554,6 +637,8 @@ }, "node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -566,12 +651,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -582,6 +669,8 @@ }, "node_modules/@babel/plugin-transform-async-to-generator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -597,6 +686,8 @@ }, "node_modules/@babel/plugin-transform-block-scoped-functions": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -610,6 +701,8 @@ }, "node_modules/@babel/plugin-transform-block-scoping": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -623,6 +716,8 @@ }, "node_modules/@babel/plugin-transform-class-properties": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -637,6 +732,8 @@ }, "node_modules/@babel/plugin-transform-class-static-block": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -651,6 +748,8 @@ }, "node_modules/@babel/plugin-transform-classes": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -669,6 +768,8 @@ }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -683,6 +784,8 @@ }, "node_modules/@babel/plugin-transform-destructuring": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -697,6 +800,8 @@ }, "node_modules/@babel/plugin-transform-dotall-regex": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -711,6 +816,8 @@ }, "node_modules/@babel/plugin-transform-duplicate-keys": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -723,7 +830,9 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -738,6 +847,8 @@ }, "node_modules/@babel/plugin-transform-dynamic-import": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -751,6 +862,8 @@ }, "node_modules/@babel/plugin-transform-explicit-resource-management": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -765,6 +878,8 @@ }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -778,6 +893,8 @@ }, "node_modules/@babel/plugin-transform-export-namespace-from": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -791,6 +908,8 @@ }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -805,6 +924,8 @@ }, "node_modules/@babel/plugin-transform-function-name": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.27.1", @@ -820,6 +941,8 @@ }, "node_modules/@babel/plugin-transform-json-strings": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -833,6 +956,8 @@ }, "node_modules/@babel/plugin-transform-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -846,6 +971,8 @@ }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -859,6 +986,8 @@ }, "node_modules/@babel/plugin-transform-member-expression-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -872,6 +1001,8 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -886,6 +1017,8 @@ }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.28.6", @@ -899,13 +1032,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -916,6 +1051,8 @@ }, "node_modules/@babel/plugin-transform-modules-umd": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -929,11 +1066,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -944,6 +1083,8 @@ }, "node_modules/@babel/plugin-transform-new-target": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -957,6 +1098,8 @@ }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -970,6 +1113,8 @@ }, "node_modules/@babel/plugin-transform-numeric-separator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -983,6 +1128,8 @@ }, "node_modules/@babel/plugin-transform-object-rest-spread": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -1000,6 +1147,8 @@ }, "node_modules/@babel/plugin-transform-object-super": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -1014,6 +1163,8 @@ }, "node_modules/@babel/plugin-transform-optional-catch-binding": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1027,6 +1178,8 @@ }, "node_modules/@babel/plugin-transform-optional-chaining": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -1041,6 +1194,8 @@ }, "node_modules/@babel/plugin-transform-parameters": { "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1054,6 +1209,8 @@ }, "node_modules/@babel/plugin-transform-private-methods": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -1068,6 +1225,8 @@ }, "node_modules/@babel/plugin-transform-private-property-in-object": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -1083,6 +1242,8 @@ }, "node_modules/@babel/plugin-transform-property-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1096,6 +1257,8 @@ }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { @@ -1110,6 +1273,8 @@ }, "node_modules/@babel/plugin-transform-react-jsx-source": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1123,7 +1288,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1137,6 +1304,8 @@ }, "node_modules/@babel/plugin-transform-regexp-modifiers": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1151,6 +1320,8 @@ }, "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1164,6 +1335,8 @@ }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1177,6 +1350,8 @@ }, "node_modules/@babel/plugin-transform-spread": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -1191,6 +1366,8 @@ }, "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1204,6 +1381,8 @@ }, "node_modules/@babel/plugin-transform-template-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1217,6 +1396,8 @@ }, "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1230,6 +1411,8 @@ }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1243,6 +1426,8 @@ }, "node_modules/@babel/plugin-transform-unicode-property-regex": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1257,6 +1442,8 @@ }, "node_modules/@babel/plugin-transform-unicode-regex": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", @@ -1271,6 +1458,8 @@ }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1284,10 +1473,12 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", + "@babel/compat-data": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -1301,7 +1492,7 @@ "@babel/plugin-syntax-import-attributes": "^7.28.6", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.6", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", "@babel/plugin-transform-async-to-generator": "^7.28.6", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", "@babel/plugin-transform-block-scoping": "^7.28.6", @@ -1312,7 +1503,7 @@ "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-dotall-regex": "^7.28.6", "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-dynamic-import": "^7.27.1", "@babel/plugin-transform-explicit-resource-management": "^7.28.6", "@babel/plugin-transform-exponentiation-operator": "^7.28.6", @@ -1325,9 +1516,9 @@ "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-numeric-separator": "^7.28.6", @@ -1339,7 +1530,7 @@ "@babel/plugin-transform-private-methods": "^7.28.6", "@babel/plugin-transform-private-property-in-object": "^7.28.6", "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.6", + "@babel/plugin-transform-regenerator": "^7.29.0", "@babel/plugin-transform-regexp-modifiers": "^7.28.6", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", @@ -1352,10 +1543,10 @@ "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "engines": { @@ -1367,6 +1558,8 @@ }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1374,6 +1567,8 @@ }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -1385,18 +1580,22 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", "dev": true, "license": "MIT", "dependencies": { - "core-js-pure": "^3.43.0" + "core-js-pure": "^3.48.0" }, "engines": { "node": ">=6.9.0" @@ -1404,6 +1603,8 @@ }, "node_modules/@babel/template": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -1415,15 +1616,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -1431,7 +1634,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1443,6 +1648,8 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -1451,6 +1658,8 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -1469,6 +1678,8 @@ }, "node_modules/@csstools/css-calc": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -1491,6 +1702,8 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -1517,6 +1730,8 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -1529,6 +1744,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1538,6 +1754,8 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -1550,16 +1768,21 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@date-io/core": { "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz", + "integrity": "sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA==", "license": "MIT" }, "node_modules/@date-io/moment": { "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-1.3.11.tgz", + "integrity": "sha512-pLEkqp8+P1DfC+QU8StaIANXoiadjJjoImLQCy0rhFAo0RVcJB9cM7mWr7fVgM49EjCwpA8a1JekNhuRLIJVwQ==", "license": "MIT", "dependencies": { "@date-io/core": "^1.3.11" @@ -1570,12 +1793,14 @@ }, "node_modules/@emotion/hash": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -1590,9 +1815,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -1607,9 +1832,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -1624,9 +1849,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -1641,7 +1866,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -1656,9 +1883,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -1673,9 +1900,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -1690,9 +1917,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -1707,9 +1934,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -1724,9 +1951,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -1741,9 +1968,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -1758,9 +1985,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -1775,9 +2002,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -1792,9 +2019,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -1809,9 +2036,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -1826,9 +2053,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -1843,9 +2070,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -1860,9 +2087,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -1877,9 +2104,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -1894,9 +2121,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -1911,9 +2138,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -1928,9 +2155,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -1945,9 +2172,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -1962,9 +2189,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -1979,9 +2206,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -1996,9 +2223,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -2014,6 +2241,8 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2031,6 +2260,8 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -2039,6 +2270,8 @@ }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2060,7 +2293,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2069,7 +2304,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2081,6 +2318,8 @@ }, "node_modules/@eslint/js": { "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { @@ -2089,6 +2328,9 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2101,7 +2343,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2110,7 +2354,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2122,6 +2368,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2134,106 +2382,25 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, "node_modules/@jest/types": { "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2249,6 +2416,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2257,6 +2426,8 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2265,6 +2436,8 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2272,6 +2445,8 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2280,10 +2455,14 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2292,7 +2471,10 @@ }, "node_modules/@jsonforms/core": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", + "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2304,6 +2486,9 @@ }, "node_modules/@jsonforms/core/node_modules/uuid": { "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -2311,6 +2496,8 @@ }, "node_modules/@jsonforms/material-renderers": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@jsonforms/material-renderers/-/material-renderers-2.5.2.tgz", + "integrity": "sha512-0C6MVyhLoMOf1Byhgs9ZNvV4NWHdbMce6m7hW2LF1Mt9mK1wDfTTst+xM3g7K5b8FSiBiF5kMnTggJcRweAEBA==", "license": "MIT", "dependencies": { "@date-io/moment": "1.3.11", @@ -2328,6 +2515,9 @@ }, "node_modules/@jsonforms/material-renderers/node_modules/uuid": { "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -2335,7 +2525,10 @@ }, "node_modules/@jsonforms/react": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", + "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2347,7 +2540,11 @@ }, "node_modules/@material-ui/core": { "version": "4.12.4", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", + "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2382,6 +2579,8 @@ }, "node_modules/@material-ui/core/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2389,7 +2588,10 @@ }, "node_modules/@material-ui/icons": { "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", + "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2410,6 +2612,9 @@ }, "node_modules/@material-ui/lab": { "version": "4.0.0-alpha.61", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz", + "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2435,6 +2640,8 @@ }, "node_modules/@material-ui/lab/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2442,6 +2649,9 @@ }, "node_modules/@material-ui/pickers": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.11.tgz", + "integrity": "sha512-pDYjbjUeabapijS2FpSwK/ruJdk7IGeAshpLbKDa3PRRKRy7Nv6sXxAvUg2F+lID/NwUKgBmCYS5bzrl7Xxqzw==", + "deprecated": "This package no longer supported. It has been relaced by @mui/x-date-pickers", "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.0", @@ -2461,6 +2671,8 @@ }, "node_modules/@material-ui/pickers/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2468,7 +2680,11 @@ }, "node_modules/@material-ui/styles": { "version": "4.11.5", + "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", + "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@emotion/hash": "^0.8.0", @@ -2507,6 +2723,8 @@ }, "node_modules/@material-ui/styles/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2514,6 +2732,8 @@ }, "node_modules/@material-ui/system": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", + "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2541,6 +2761,8 @@ }, "node_modules/@material-ui/types": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -2553,6 +2775,8 @@ }, "node_modules/@material-ui/utils": { "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", + "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2569,6 +2793,8 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -2581,6 +2807,8 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -2589,6 +2817,8 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2601,6 +2831,8 @@ }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "license": "MIT", "engines": { "node": ">=12.22.0" @@ -2608,6 +2840,8 @@ }, "node_modules/@pnpm/network.ca-file": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", "license": "MIT", "dependencies": { "graceful-fs": "4.2.10" @@ -2618,10 +2852,14 @@ }, "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "license": "ISC" }, "node_modules/@pnpm/npm-conf": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", "license": "MIT", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", @@ -2634,14 +2872,20 @@ }, "node_modules/@react-dnd/asap": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", "license": "MIT" }, "node_modules/@react-dnd/invariant": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", "license": "MIT" }, "node_modules/@react-dnd/shallowequal": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "license": "MIT" }, "node_modules/@react-icons/all-files": { @@ -2655,6 +2899,8 @@ }, "node_modules/@redux-saga/core": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz", + "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -2672,10 +2918,14 @@ }, "node_modules/@redux-saga/deferred": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz", + "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==", "license": "MIT" }, "node_modules/@redux-saga/delay-p": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz", + "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==", "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.2.1" @@ -2683,6 +2933,8 @@ }, "node_modules/@redux-saga/is": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz", + "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==", "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.2.1", @@ -2691,19 +2943,27 @@ }, "node_modules/@redux-saga/symbols": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz", + "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==", "license": "MIT" }, "node_modules/@redux-saga/types": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz", + "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==", "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", @@ -2726,6 +2986,8 @@ }, "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -2744,6 +3006,8 @@ }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", "license": "MIT", "dependencies": { "serialize-javascript": "^6.0.1", @@ -2764,6 +3028,8 @@ }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -2784,6 +3050,8 @@ }, "node_modules/@rollup/pluginutils/node_modules/estree-walker": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { @@ -2800,11 +3068,15 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", "license": "Apache-2.0", "dependencies": { "ejs": "^3.1.6", @@ -2815,6 +3087,8 @@ }, "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": { "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" @@ -2822,6 +3096,8 @@ }, "node_modules/@testing-library/dom": { "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "peer": true, @@ -2841,6 +3117,8 @@ }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -2859,11 +3137,15 @@ }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, "node_modules/@testing-library/react": { "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", "dev": true, "license": "MIT", "dependencies": { @@ -2881,6 +3163,8 @@ }, "node_modules/@testing-library/react-hooks": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", "dev": true, "license": "MIT", "dependencies": { @@ -2909,6 +3193,8 @@ }, "node_modules/@testing-library/react/node_modules/@testing-library/dom": { "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "dev": true, "license": "MIT", "dependencies": { @@ -2927,6 +3213,8 @@ }, "node_modules/@testing-library/react/node_modules/aria-query": { "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2935,6 +3223,8 @@ }, "node_modules/@testing-library/user-event": { "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -2947,11 +3237,15 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2964,6 +3258,8 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2972,6 +3268,8 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2981,6 +3279,8 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2989,6 +3289,8 @@ }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -2998,16 +3300,23 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", + "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3017,11 +3326,15 @@ }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "license": "MIT", "dependencies": { @@ -3030,6 +3343,8 @@ }, "node_modules/@types/istanbul-reports": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3038,31 +3353,45 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, "node_modules/@types/minimist": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.9", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { - "version": "17.0.90", + "version": "17.0.91", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", + "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3071,6 +3400,8 @@ }, "node_modules/@types/react-dom": { "version": "17.0.26", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", + "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3079,6 +3410,8 @@ }, "node_modules/@types/react-redux": { "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", + "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", "license": "MIT", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", @@ -3089,6 +3422,8 @@ }, "node_modules/@types/react-test-renderer": { "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3097,6 +3432,8 @@ }, "node_modules/@types/react-transition-group": { "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -3104,23 +3441,33 @@ }, "node_modules/@types/react/node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/@types/resolve": { "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, "node_modules/@types/scheduler": { "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", "license": "MIT" }, "node_modules/@types/semver": { "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true, "license": "MIT" }, "node_modules/@types/styled-jsx": { "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.9.tgz", + "integrity": "sha512-W/iTlIkGEyTBGTEvZCey8EgQlQ5l0DwMqi3iOXlLs2kyBwYTXHKEiU6IZ5EwoRwngL8/dGYuzezSup89ttVHLw==", "license": "MIT", "dependencies": { "@types/react": "*" @@ -3128,19 +3475,27 @@ }, "node_modules/@types/trusted-types": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, "node_modules/@types/uuid": { "version": "3.4.13", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.13.tgz", + "integrity": "sha512-pAeZeUbLE4Z9Vi9wsWV2bYPTweEHeJJy0G4pEjOA/FSvy1Ad5U5Km8iDV6TKre1mjBiVNfAdVHKruP8bAh4Q5A==", "license": "MIT" }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", "dev": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { @@ -3149,6 +3504,8 @@ }, "node_modules/@types/yargs": { "version": "15.0.20", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.20.tgz", + "integrity": "sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3157,11 +3514,15 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { @@ -3196,8 +3557,11 @@ }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3223,6 +3587,8 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3239,6 +3605,8 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { @@ -3265,6 +3633,8 @@ }, "node_modules/@typescript-eslint/types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "license": "MIT", "engines": { @@ -3277,6 +3647,8 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3304,6 +3676,8 @@ }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3328,6 +3702,8 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "license": "MIT", "dependencies": { @@ -3344,18 +3720,22 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -3363,31 +3743,33 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.17", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.17", - "vitest": "4.0.17" + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3396,27 +3778,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3425,7 +3811,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3437,22 +3823,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -3460,11 +3850,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3473,7 +3866,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -3481,20 +3876,26 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { - "version": "8.15.0", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3504,6 +3905,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3512,6 +3915,8 @@ }, "node_modules/agent-base": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { @@ -3520,6 +3925,8 @@ }, "node_modules/ajv": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3534,6 +3941,8 @@ }, "node_modules/ansi-align": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", "license": "ISC", "dependencies": { "string-width": "^4.1.0" @@ -3541,6 +3950,8 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -3554,6 +3965,8 @@ }, "node_modules/ansi-escapes/node_modules/type-fest": { "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -3564,6 +3977,8 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -3571,6 +3986,8 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3584,6 +4001,8 @@ }, "node_modules/anymatch": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3595,11 +4014,15 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3608,6 +4031,8 @@ }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -3622,6 +4047,8 @@ }, "node_modules/array-includes": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3643,6 +4070,8 @@ }, "node_modules/array-union": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -3651,6 +4080,8 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3670,6 +4101,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -3687,6 +4120,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -3704,6 +4139,8 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -3719,6 +4156,8 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -3738,6 +4177,8 @@ }, "node_modules/arrify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3745,6 +4186,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -3753,30 +4196,40 @@ }, "node_modules/ast-types-flow": { "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/async": { "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, "node_modules/async-function": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3784,13 +4237,17 @@ }, "node_modules/at-least-node": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "license": "ISC", "engines": { "node": ">= 4.0.0" } }, "node_modules/atomically": { - "version": "2.1.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", "license": "MIT", "dependencies": { "stubborn-fs": "^2.0.0", @@ -3799,6 +4256,8 @@ }, "node_modules/attr-accept": { "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", "license": "MIT", "engines": { "node": ">=4" @@ -3806,6 +4265,8 @@ }, "node_modules/autosuggest-highlight": { "version": "3.3.4", + "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", + "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", "license": "MIT", "dependencies": { "remove-accents": "^0.4.2" @@ -3813,6 +4274,8 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -3825,7 +4288,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3834,6 +4299,8 @@ }, "node_modules/axobject-query": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3841,11 +4308,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -3854,27 +4323,33 @@ }, "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3882,6 +4357,8 @@ }, "node_modules/babel-runtime": { "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", "license": "MIT", "dependencies": { "core-js": "^2.4.0", @@ -3890,10 +4367,14 @@ }, "node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -3911,14 +4392,21 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", + "version": "2.10.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", + "integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "license": "MIT", "engines": { "node": ">=8" @@ -3929,6 +4417,8 @@ }, "node_modules/bl": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -3938,10 +4428,14 @@ }, "node_modules/blueimp-md5": { "version": "2.19.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", "license": "MIT" }, "node_modules/boxen": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", "license": "MIT", "dependencies": { "ansi-align": "^3.0.1", @@ -3962,6 +4456,8 @@ }, "node_modules/boxen/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -3972,6 +4468,8 @@ }, "node_modules/boxen/node_modules/camelcase": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", "license": "MIT", "engines": { "node": ">=16" @@ -3982,6 +4480,8 @@ }, "node_modules/boxen/node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -3992,10 +4492,14 @@ }, "node_modules/boxen/node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/boxen/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -4010,10 +4514,12 @@ } }, "node_modules/boxen/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -4024,6 +4530,8 @@ }, "node_modules/boxen/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -4033,7 +4541,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4041,6 +4551,8 @@ }, "node_modules/braces": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4050,7 +4562,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -4066,12 +4580,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4082,6 +4597,8 @@ }, "node_modules/buffer": { "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -4104,10 +4621,14 @@ }, "node_modules/buffer-from": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4124,6 +4645,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4135,6 +4658,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4149,10 +4674,14 @@ }, "node_modules/call-me-maybe": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -4161,6 +4690,8 @@ }, "node_modules/camelcase": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", "engines": { "node": ">=6" @@ -4168,6 +4699,8 @@ }, "node_modules/camelcase-keys": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", "license": "MIT", "dependencies": { "camelcase": "^5.3.1", @@ -4182,7 +4715,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", "funding": [ { "type": "opencollective", @@ -4201,6 +4736,8 @@ }, "node_modules/chai": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -4209,6 +4746,8 @@ }, "node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4223,10 +4762,14 @@ }, "node_modules/chardet": { "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, "node_modules/chokidar": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -4249,6 +4792,8 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4265,6 +4810,8 @@ }, "node_modules/cli-boxes": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", "engines": { "node": ">=10" @@ -4275,6 +4822,8 @@ }, "node_modules/cli-cursor": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -4285,6 +4834,8 @@ }, "node_modules/cli-spinners": { "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "license": "MIT", "engines": { "node": ">=6" @@ -4295,6 +4846,8 @@ }, "node_modules/cli-width": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "license": "ISC", "engines": { "node": ">= 10" @@ -4302,6 +4855,8 @@ }, "node_modules/clone": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "license": "MIT", "engines": { "node": ">=0.8" @@ -4309,6 +4864,8 @@ }, "node_modules/clsx": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -4316,6 +4873,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4326,14 +4885,20 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, "node_modules/common-tags": { "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -4341,15 +4906,21 @@ }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/config-chain": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "license": "MIT", "dependencies": { "ini": "^1.3.4", @@ -4358,10 +4929,14 @@ }, "node_modules/config-chain/node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/configstore": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", "license": "BSD-2-Clause", "dependencies": { "atomically": "^2.0.3", @@ -4378,7 +4953,10 @@ }, "node_modules/connected-react-router": { "version": "6.9.3", + "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", + "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -4397,18 +4975,25 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, "node_modules/core-js": { "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", "hasInstallScript": true, "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.47.0", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.0" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -4416,7 +5001,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.47.0", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4427,6 +5014,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4439,6 +5028,8 @@ }, "node_modules/crypto-random-string": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "license": "MIT", "engines": { "node": ">=8" @@ -4446,10 +5037,14 @@ }, "node_modules/css-mediaquery": { "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "license": "BSD" }, "node_modules/css-vendor": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.3", @@ -4458,11 +5053,15 @@ }, "node_modules/css.escape": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, "license": "MIT" }, "node_modules/cssstyle": { "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { @@ -4475,15 +5074,21 @@ }, "node_modules/csstype": { "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/data-urls": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { @@ -4496,6 +5101,8 @@ }, "node_modules/data-urls/node_modules/whatwg-mimetype": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -4504,6 +5111,8 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4519,6 +5128,8 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4534,6 +5145,8 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -4549,10 +5162,14 @@ }, "node_modules/date-fns": { "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4568,6 +5185,8 @@ }, "node_modules/decamelize": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4575,6 +5194,8 @@ }, "node_modules/decamelize-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "license": "MIT", "dependencies": { "decamelize": "^1.1.0", @@ -4589,6 +5210,8 @@ }, "node_modules/decamelize-keys/node_modules/map-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4596,11 +5219,15 @@ }, "node_modules/decimal.js": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, "node_modules/decode-uri-component": { "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -4608,6 +5235,8 @@ }, "node_modules/deep-equal": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, "license": "MIT", "dependencies": { @@ -4639,11 +5268,15 @@ }, "node_modules/deep-equal/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/deep-extend": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -4651,11 +5284,15 @@ }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4663,6 +5300,8 @@ }, "node_modules/defaults": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -4673,6 +5312,8 @@ }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4688,6 +5329,8 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -4703,6 +5346,8 @@ }, "node_modules/dequal": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", "engines": { @@ -4711,6 +5356,8 @@ }, "node_modules/dir-glob": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "license": "MIT", "dependencies": { @@ -4722,6 +5369,8 @@ }, "node_modules/dnd-core": { "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", "license": "MIT", "dependencies": { "@react-dnd/asap": "^4.0.0", @@ -4731,6 +5380,8 @@ }, "node_modules/doctrine": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4742,6 +5393,8 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT" }, @@ -4753,6 +5406,8 @@ }, "node_modules/dom-helpers": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -4761,20 +5416,23 @@ }, "node_modules/dom-helpers/node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.2", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "node_modules/dot-prop": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "license": "MIT", "dependencies": { "type-fest": "^4.18.2" @@ -4788,6 +5446,8 @@ }, "node_modules/dot-prop/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -4804,6 +5464,8 @@ }, "node_modules/downshift": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", + "integrity": "sha512-mbUO9ZFhMGtksIeVWRFFjNOPN237VsUqZSEYi0VS0Wj38XNLzpgOBTUcUjdjFeB8KVgmrcRa6GGFkTbACpG6FA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", @@ -4817,10 +5479,14 @@ }, "node_modules/downshift/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4831,12 +5497,10 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "license": "MIT" - }, "node_modules/ejs": { "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" @@ -4849,15 +5513,22 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/entities": { - "version": "4.5.0", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4869,6 +5540,8 @@ }, "node_modules/error-ex": { "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -4876,6 +5549,8 @@ }, "node_modules/es-abstract": { "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -4942,6 +5617,8 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4949,6 +5626,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4956,6 +5635,8 @@ }, "node_modules/es-get-iterator": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, "license": "MIT", "dependencies": { @@ -4975,11 +5656,15 @@ }, "node_modules/es-get-iterator/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/es-iterator-helpers": { - "version": "1.2.2", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4998,6 +5683,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" }, "engines": { @@ -5005,12 +5691,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5021,6 +5711,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5034,6 +5726,8 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -5045,6 +5739,8 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "license": "MIT", "dependencies": { "is-callable": "^1.2.7", @@ -5059,7 +5755,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5070,36 +5768,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -5107,6 +5807,8 @@ }, "node_modules/escape-goat": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "license": "MIT", "engines": { "node": ">=12" @@ -5117,6 +5819,8 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -5128,8 +5832,12 @@ }, "node_modules/eslint": { "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5182,6 +5890,8 @@ }, "node_modules/eslint-config-prettier": { "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -5196,6 +5906,8 @@ }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5224,6 +5936,8 @@ }, "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5231,7 +5945,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5240,7 +5956,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5252,6 +5970,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5283,6 +6003,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -5294,6 +6016,8 @@ }, "node_modules/eslint-plugin-react-refresh": { "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5301,7 +6025,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5311,6 +6037,8 @@ }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5321,7 +6049,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5333,6 +6063,8 @@ }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -5341,6 +6073,8 @@ }, "node_modules/eslint-scope": { "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5356,6 +6090,8 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5366,7 +6102,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5375,7 +6113,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5387,6 +6127,8 @@ }, "node_modules/espree": { "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5403,6 +6145,8 @@ }, "node_modules/esprima": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -5414,6 +6158,8 @@ }, "node_modules/esquery": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5425,6 +6171,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5436,6 +6184,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5444,6 +6194,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -5452,6 +6204,8 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5459,14 +6213,20 @@ }, "node_modules/eventemitter3": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "license": "MIT" }, "node_modules/exenv": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", "license": "BSD-3-Clause" }, "node_modules/expect-type": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5475,6 +6235,8 @@ }, "node_modules/external-editor": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "license": "MIT", "dependencies": { "chardet": "^0.7.0", @@ -5487,6 +6249,8 @@ }, "node_modules/external-editor/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -5497,10 +6261,14 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -5516,6 +6284,8 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -5527,15 +6297,21 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -5550,6 +6326,8 @@ }, "node_modules/fastq": { "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -5558,6 +6336,8 @@ }, "node_modules/figures": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" @@ -5571,6 +6351,8 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -5578,6 +6360,8 @@ }, "node_modules/file-entry-cache": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", "dependencies": { @@ -5589,6 +6373,8 @@ }, "node_modules/file-selector": { "version": "0.1.19", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz", + "integrity": "sha512-kCWw3+Aai8Uox+5tHCNgMFaUdgidxvMnLWO6fM5sZ0hA2wlHP5/DHGF0ECe84BiB95qdJbKNEJhWKVDvMN+JDQ==", "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -5598,14 +6384,18 @@ } }, "node_modules/filelist": { - "version": "1.0.4", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -5616,6 +6406,8 @@ }, "node_modules/fill-range": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5626,7 +6418,10 @@ }, "node_modules/final-form": { "version": "4.20.10", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", + "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -5640,13 +6435,18 @@ }, "node_modules/final-form-arrays": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", + "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", + "peer": true, "peerDependencies": { "final-form": "^4.20.8" } }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -5662,6 +6462,8 @@ }, "node_modules/flat-cache": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", "dependencies": { @@ -5674,12 +6476,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -5693,6 +6499,8 @@ }, "node_modules/foreground-child": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5707,6 +6515,8 @@ }, "node_modules/foreground-child/node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" @@ -5717,6 +6527,8 @@ }, "node_modules/fs-extra": { "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -5730,11 +6542,16 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -5746,6 +6563,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5753,6 +6572,8 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -5771,6 +6592,8 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5778,6 +6601,8 @@ }, "node_modules/generator-function": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5785,13 +6610,17 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -5802,6 +6631,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5824,14 +6655,20 @@ }, "node_modules/get-node-dimensions": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz", + "integrity": "sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==", "license": "MIT" }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", "license": "ISC" }, "node_modules/get-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5843,6 +6680,8 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -5858,6 +6697,9 @@ }, "node_modules/glob": { "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -5877,6 +6719,8 @@ }, "node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -5887,7 +6731,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5896,7 +6742,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5908,6 +6756,8 @@ }, "node_modules/global-directory": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "license": "MIT", "dependencies": { "ini": "4.1.1" @@ -5921,6 +6771,8 @@ }, "node_modules/globals": { "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5935,6 +6787,8 @@ }, "node_modules/globalthis": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -5949,6 +6803,8 @@ }, "node_modules/globby": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -5968,6 +6824,8 @@ }, "node_modules/gopd": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5978,22 +6836,29 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, "node_modules/happy-dom": { - "version": "20.3.3", + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", - "entities": "^4.5.0", + "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" }, @@ -6003,6 +6868,8 @@ }, "node_modules/hard-rejection": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "license": "MIT", "engines": { "node": ">=6" @@ -6010,6 +6877,8 @@ }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6020,6 +6889,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -6027,6 +6898,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -6037,6 +6910,8 @@ }, "node_modules/has-proto": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" @@ -6050,6 +6925,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6060,6 +6937,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6073,6 +6952,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6083,7 +6964,10 @@ }, "node_modules/history": { "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -6095,6 +6979,8 @@ }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" @@ -6102,14 +6988,20 @@ }, "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/hosted-git-info": { "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "license": "ISC" }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6121,11 +7013,15 @@ }, "node_modules/html-escaper": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { @@ -6138,6 +7034,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { @@ -6150,10 +7048,14 @@ }, "node_modules/hyphenate-style-name": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, "node_modules/iconv-lite": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6165,10 +7067,14 @@ }, "node_modules/idb": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, "node_modules/ieee754": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", @@ -6187,6 +7093,8 @@ }, "node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -6195,11 +7103,15 @@ }, "node_modules/immutable": { "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "license": "MIT", "optional": true }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6215,6 +7127,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -6223,6 +7137,8 @@ }, "node_modules/indent-string": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "license": "MIT", "engines": { "node": ">=8" @@ -6230,6 +7146,8 @@ }, "node_modules/inflection": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", + "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -6237,6 +7155,9 @@ }, "node_modules/inflight": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { @@ -6246,10 +7167,14 @@ }, "node_modules/inherits": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -6257,6 +7182,8 @@ }, "node_modules/inquirer": { "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", @@ -6279,6 +7206,8 @@ }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6291,6 +7220,8 @@ }, "node_modules/is-arguments": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", "dependencies": { @@ -6306,6 +7237,8 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -6321,10 +7254,14 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "license": "MIT", "dependencies": { "async-function": "^1.0.0", @@ -6342,6 +7279,8 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" @@ -6355,6 +7294,8 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -6365,6 +7306,8 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6379,6 +7322,8 @@ }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6389,6 +7334,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -6402,6 +7349,8 @@ }, "node_modules/is-data-view": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6417,6 +7366,8 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6431,6 +7382,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6438,6 +7391,8 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6451,6 +7406,8 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -6458,6 +7415,8 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -6475,6 +7434,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6485,10 +7446,14 @@ }, "node_modules/is-in-browser": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", "license": "MIT" }, "node_modules/is-in-ci": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -6502,6 +7467,8 @@ }, "node_modules/is-installed-globally": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "license": "MIT", "dependencies": { "global-directory": "^4.0.1", @@ -6516,6 +7483,8 @@ }, "node_modules/is-installed-globally/node_modules/is-path-inside": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "license": "MIT", "engines": { "node": ">=12" @@ -6526,6 +7495,8 @@ }, "node_modules/is-interactive": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "license": "MIT", "engines": { "node": ">=8" @@ -6533,6 +7504,8 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6549,10 +7522,14 @@ }, "node_modules/is-module": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "license": "MIT" }, "node_modules/is-negative-zero": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6563,6 +7540,8 @@ }, "node_modules/is-npm": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -6573,6 +7552,8 @@ }, "node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -6580,6 +7561,8 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6594,6 +7577,8 @@ }, "node_modules/is-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6601,6 +7586,8 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", "engines": { @@ -6609,6 +7596,8 @@ }, "node_modules/is-plain-obj": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6616,11 +7605,15 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6637,6 +7630,8 @@ }, "node_modules/is-regexp": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6644,6 +7639,8 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6654,6 +7651,8 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6667,6 +7666,8 @@ }, "node_modules/is-stream": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { "node": ">=8" @@ -6677,6 +7678,8 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6691,6 +7694,8 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6706,6 +7711,8 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -6719,6 +7726,8 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "license": "MIT", "engines": { "node": ">=10" @@ -6729,6 +7738,8 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6739,6 +7750,8 @@ }, "node_modules/is-weakref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6752,6 +7765,8 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6766,14 +7781,20 @@ }, "node_modules/isarray": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6782,6 +7803,8 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6795,6 +7818,8 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6807,6 +7832,8 @@ }, "node_modules/iterator.prototype": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -6822,10 +7849,12 @@ } }, "node_modules/jackspeak": { - "version": "4.1.1", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^9.0.0" }, "engines": { "node": "20 || >=22" @@ -6836,6 +7865,8 @@ }, "node_modules/jake": { "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", @@ -6851,10 +7882,14 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6866,6 +7901,8 @@ }, "node_modules/jsdom": { "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { @@ -6904,6 +7941,8 @@ }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -6912,6 +7951,8 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -6922,19 +7963,22 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-ref-parser": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-7.1.3.tgz", + "integrity": "sha512-/Lmyl0PW27dOmCO03PI339+1gs4Z2PlqIyUgzIOtoRp08zkkMCB30TRbdppbPO7WWzZX0uT98HqkDiZSujkmbA==", + "deprecated": "Please switch to @apidevtools/json-schema-ref-parser", "license": "MIT", "dependencies": { "call-me-maybe": "^1.0.1", @@ -6944,6 +7988,8 @@ }, "node_modules/json-schema-ref-parser/node_modules/argparse": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -6951,6 +7997,8 @@ }, "node_modules/json-schema-ref-parser/node_modules/js-yaml": { "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -6962,15 +8010,21 @@ }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -6981,6 +8035,8 @@ }, "node_modules/jsonexport": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-2.5.2.tgz", + "integrity": "sha512-4joNLCxxUAmS22GN3GA5os/MYFnq8oqXOKvoCymmcT0MPz/QPZ5eA+Fh5sIPxUji45RKq8DdQ1yoKq91p4E9VA==", "license": "Apache-2.0", "bin": { "jsonexport": "bin/jsonexport.js" @@ -6988,6 +8044,8 @@ }, "node_modules/jsonfile": { "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -6998,6 +8056,8 @@ }, "node_modules/jsonpointer": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7005,6 +8065,8 @@ }, "node_modules/jss": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", + "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7019,6 +8081,8 @@ }, "node_modules/jss-plugin-camel-case": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", + "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7028,6 +8092,8 @@ }, "node_modules/jss-plugin-default-unit": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", + "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7036,6 +8102,8 @@ }, "node_modules/jss-plugin-global": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", + "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7044,6 +8112,8 @@ }, "node_modules/jss-plugin-nested": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", + "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7053,6 +8123,8 @@ }, "node_modules/jss-plugin-props-sort": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", + "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7061,6 +8133,8 @@ }, "node_modules/jss-plugin-rule-value-function": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", + "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7070,6 +8144,8 @@ }, "node_modules/jss-plugin-vendor-prefixer": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", + "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7079,10 +8155,14 @@ }, "node_modules/jss/node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/jsx-ast-utils": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7097,6 +8177,8 @@ }, "node_modules/jwt-decode": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", "engines": { "node": ">=18" @@ -7104,6 +8186,8 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -7112,13 +8196,17 @@ }, "node_modules/kind-of": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/ky": { - "version": "1.14.2", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", "license": "MIT", "engines": { "node": ">=18" @@ -7129,11 +8217,15 @@ }, "node_modules/language-subtag-registry": { "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/language-tags": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", "dependencies": { @@ -7145,6 +8237,8 @@ }, "node_modules/latest-version": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", "license": "MIT", "dependencies": { "package-json": "^10.0.0" @@ -7158,6 +8252,8 @@ }, "node_modules/leven": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "license": "MIT", "engines": { "node": ">=6" @@ -7165,6 +8261,8 @@ }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7177,10 +8275,14 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -7194,32 +8296,46 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, "node_modules/lodash.isequalwith": { "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", + "integrity": "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "license": "MIT" }, "node_modules/lodash.throttle": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -7234,6 +8350,8 @@ }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -7244,6 +8362,8 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -7251,6 +8371,8 @@ }, "node_modules/lz-string": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", "bin": { @@ -7259,6 +8381,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7266,17 +8390,21 @@ } }, "node_modules/magicast": { - "version": "0.5.1", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -7291,6 +8419,8 @@ }, "node_modules/map-obj": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "license": "MIT", "engines": { "node": ">=8" @@ -7301,6 +8431,8 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7308,6 +8440,8 @@ }, "node_modules/meow": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", + "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", "license": "MIT", "dependencies": { "@types/minimist": "^1.2.0", @@ -7331,6 +8465,8 @@ }, "node_modules/meow/node_modules/type-fest": { "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -7341,6 +8477,8 @@ }, "node_modules/merge2": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { @@ -7349,6 +8487,8 @@ }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -7361,6 +8501,8 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", "engines": { "node": ">=6" @@ -7368,6 +8510,8 @@ }, "node_modules/min-indent": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "license": "MIT", "engines": { "node": ">=4" @@ -7375,6 +8519,8 @@ }, "node_modules/minimatch": { "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "license": "ISC", "dependencies": { @@ -7389,6 +8535,8 @@ }, "node_modules/minimist": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7396,6 +8544,8 @@ }, "node_modules/minimist-options": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "license": "MIT", "dependencies": { "arrify": "^1.0.1", @@ -7407,29 +8557,40 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/moment": { "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mute-stream": { "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -7447,6 +8608,8 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, @@ -7471,8 +8634,39 @@ "react-dom": ">=16.9.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-polyglot": { "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.6.0.tgz", + "integrity": "sha512-ZZFkaYzIfGfBvSM6QhA9dM8EEaUJOVewzGSRcXWbJELXDj0lajAtKaENCYxvF5yE+TgHg6NQb0CmgYMsMdcNJQ==", "license": "BSD-2-Clause", "dependencies": { "hasown": "^2.0.2", @@ -7484,11 +8678,15 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, "node_modules/normalize-package-data": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^2.1.4", @@ -7499,6 +8697,8 @@ }, "node_modules/normalize-package-data/node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -7517,6 +8717,8 @@ }, "node_modules/normalize-package-data/node_modules/semver": { "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "license": "ISC", "bin": { "semver": "bin/semver" @@ -7524,6 +8726,8 @@ }, "node_modules/normalize-path": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7531,11 +8735,15 @@ }, "node_modules/nwsapi": { "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7543,6 +8751,8 @@ }, "node_modules/object-hash": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "license": "MIT", "engines": { "node": ">= 6" @@ -7550,6 +8760,8 @@ }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7560,6 +8772,8 @@ }, "node_modules/object-is": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7575,6 +8789,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7582,6 +8798,8 @@ }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7600,6 +8818,8 @@ }, "node_modules/object.entries": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7613,6 +8833,8 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7630,6 +8852,8 @@ }, "node_modules/object.values": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -7647,6 +8871,8 @@ }, "node_modules/obug": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -7656,6 +8882,8 @@ }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -7664,6 +8892,8 @@ }, "node_modules/onetime": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -7677,10 +8907,14 @@ }, "node_modules/ono": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz", + "integrity": "sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA==", "license": "MIT" }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -7697,6 +8931,8 @@ }, "node_modules/ora": { "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -7718,6 +8954,8 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7725,6 +8963,8 @@ }, "node_modules/own-keys": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", @@ -7740,6 +8980,8 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7754,6 +8996,8 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -7768,6 +9012,8 @@ }, "node_modules/p-try": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", "engines": { "node": ">=6" @@ -7775,6 +9021,8 @@ }, "node_modules/package-json": { "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", "license": "MIT", "dependencies": { "ky": "^1.2.0", @@ -7791,10 +9039,14 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -7806,6 +9058,8 @@ }, "node_modules/parse-json": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -7822,6 +9076,8 @@ }, "node_modules/parse5": { "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { @@ -7833,6 +9089,8 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -7844,6 +9102,8 @@ }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", "engines": { "node": ">=8" @@ -7851,6 +9111,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { @@ -7859,6 +9121,8 @@ }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -7866,24 +9130,30 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.4", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -7891,6 +9161,8 @@ }, "node_modules/path-to-regexp": { "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "license": "MIT", "dependencies": { "isarray": "0.0.1" @@ -7898,6 +9170,8 @@ }, "node_modules/path-type": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, "license": "MIT", "engines": { @@ -7906,11 +9180,15 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { @@ -7927,17 +9205,23 @@ }, "node_modules/popper.js": { "version": "1.16.1-lts", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", + "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", "license": "MIT" }, "node_modules/possible-typed-array-names": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -7965,6 +9249,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -7972,7 +9258,9 @@ } }, "node_modules/prettier": { - "version": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -7987,6 +9275,8 @@ }, "node_modules/pretty-bytes": { "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", "dev": true, "license": "MIT", "engines": { @@ -7998,6 +9288,8 @@ }, "node_modules/pretty-format": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8011,6 +9303,8 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -8022,7 +9316,10 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8031,14 +9328,20 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/proto-list": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { "node": ">=6" @@ -8046,6 +9349,8 @@ }, "node_modules/pupa": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -8059,6 +9364,8 @@ }, "node_modules/query-string": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", "license": "MIT", "dependencies": { "decode-uri-component": "^0.2.0", @@ -8071,6 +9378,8 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -8090,6 +9399,8 @@ }, "node_modules/quick-lru": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "license": "MIT", "engines": { "node": ">=8" @@ -8097,7 +9408,10 @@ }, "node_modules/ra-core": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", + "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", + "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -8124,17 +9438,29 @@ }, "node_modules/ra-core/node_modules/classnames": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", + "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", "license": "MIT" }, "node_modules/ra-core/node_modules/inflection": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", "engines": [ "node >= 0.4.0" ], "license": "MIT" }, + "node_modules/ra-core/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/ra-data-json-server": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.19.12.tgz", + "integrity": "sha512-SEa0ueZd9LUG6iuPnHd+MHWf7BTgLKjx3Eky16VvTsqf6ueHkMU8AZiH1pHzrdxV6ku5VL34MCYWVSIbm2iDnw==", "license": "MIT", "dependencies": { "query-string": "^5.1.1", @@ -8143,6 +9469,8 @@ }, "node_modules/ra-i18n-polyglot": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.19.12.tgz", + "integrity": "sha512-7VkNybY+RYVL5aDf8MdefYpRMkaELOjSXx7rrRY7PzVwmQzVe5ESoKBcH4Cob2M8a52pAlXY32dwmA3dZ91l/Q==", "license": "MIT", "dependencies": { "node-polyglot": "^2.2.2", @@ -8151,6 +9479,8 @@ }, "node_modules/ra-language-english": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.19.12.tgz", + "integrity": "sha512-aYY0ma74eXLuflPT9iXEQtVEDZxebw1NiQZ5pPGiBCpsq+hoiDWuzerLU13OdBHbySD5FHLuk89SkyAdfMtUaQ==", "license": "MIT", "dependencies": { "ra-core": "^3.19.12" @@ -8158,6 +9488,8 @@ }, "node_modules/ra-test": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-test/-/ra-test-3.19.12.tgz", + "integrity": "sha512-SX6oi+VPADIeQeQlGWUVj2kgEYgLbizpzYMq+oacCmnAqvHezwnQ2MXrLDRK6C56YIl+t8DyY/ipYBiRPZnHbA==", "dev": true, "license": "MIT", "dependencies": { @@ -8177,6 +9509,8 @@ }, "node_modules/ra-test/node_modules/@testing-library/dom": { "version": "7.31.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", + "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8195,6 +9529,8 @@ }, "node_modules/ra-test/node_modules/@testing-library/react": { "version": "11.2.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz", + "integrity": "sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA==", "dev": true, "license": "MIT", "dependencies": { @@ -8211,11 +9547,15 @@ }, "node_modules/ra-test/node_modules/@types/aria-query": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", "dev": true, "license": "MIT" }, "node_modules/ra-test/node_modules/aria-query": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -8228,11 +9568,22 @@ }, "node_modules/ra-test/node_modules/classnames": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", + "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/ra-test/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/ra-test/node_modules/pretty-format": { "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", "dev": true, "license": "MIT", "dependencies": { @@ -8247,6 +9598,8 @@ }, "node_modules/ra-ui-materialui": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.19.12.tgz", + "integrity": "sha512-8Zz88r5yprmUxOw9/F0A/kjjVmFMb2n+sjpel8fuOWtS6y++JWonDsvTwo4yIuSF9mC0fht3f/hd2KEHQdmj6Q==", "license": "MIT", "dependencies": { "autosuggest-highlight": "^3.1.1", @@ -8282,21 +9635,35 @@ }, "node_modules/ra-ui-materialui/node_modules/classnames": { "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", "license": "MIT" }, "node_modules/ra-ui-materialui/node_modules/dompurify": { "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/ra-ui-materialui/node_modules/inflection": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", "engines": [ "node >= 0.4.0" ], "license": "MIT" }, + "node_modules/ra-ui-materialui/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" @@ -8304,6 +9671,8 @@ }, "node_modules/rc": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -8439,10 +9808,14 @@ }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8450,7 +9823,10 @@ }, "node_modules/react": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -8461,6 +9837,8 @@ }, "node_modules/react-admin": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.19.12.tgz", + "integrity": "sha512-LanWS3Yjie7n5GZI8v7oP73DSvQyCeZD0dpkC65IC0+UOhkInxa1zedJc8CyD3+ZwlgVC+CGqi6jQ1fo73Cdqw==", "license": "MIT", "dependencies": { "@material-ui/core": "^4.12.1", @@ -8488,6 +9866,8 @@ }, "node_modules/react-dnd": { "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", "license": "MIT", "dependencies": { "@react-dnd/invariant": "^2.0.0", @@ -8516,6 +9896,8 @@ }, "node_modules/react-dnd-html5-backend": { "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", "license": "MIT", "dependencies": { "dnd-core": "14.0.1" @@ -8523,7 +9905,10 @@ }, "node_modules/react-dom": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -8535,6 +9920,8 @@ }, "node_modules/react-drag-listview": { "version": "0.1.9", + "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.9.tgz", + "integrity": "sha512-/OsYevKtCUlw4FhJIfZPH7INHEmyl89sSC5COzonHW5Z2c8rHg4DNYFnUxOyqH+65o7sHweL13oaf6wr7dFvPA==", "license": "MIT", "dependencies": { "babel-runtime": "^6.26.0", @@ -8557,6 +9944,8 @@ }, "node_modules/react-dropzone": { "version": "10.2.2", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.2.tgz", + "integrity": "sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA==", "license": "MIT", "dependencies": { "attr-accept": "^2.0.0", @@ -8572,6 +9961,8 @@ }, "node_modules/react-error-boundary": { "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", "dev": true, "license": "MIT", "dependencies": { @@ -8587,7 +9978,10 @@ }, "node_modules/react-final-form": { "version": "6.5.9", + "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", + "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -8602,7 +9996,10 @@ }, "node_modules/react-final-form-arrays": { "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", + "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -8615,6 +10012,8 @@ }, "node_modules/react-ga": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", + "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", "license": "Apache-2.0", "peerDependencies": { "prop-types": "^15.6.0", @@ -8623,6 +10022,8 @@ }, "node_modules/react-hotkeys": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", + "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", "license": "ISC", "dependencies": { "prop-types": "^15.6.1" @@ -8632,7 +10033,9 @@ } }, "node_modules/react-icons": { - "version": "5.5.0", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" @@ -8640,6 +10043,9 @@ }, "node_modules/react-image-lightbox": { "version": "5.1.4", + "resolved": "https://registry.npmjs.org/react-image-lightbox/-/react-image-lightbox-5.1.4.tgz", + "integrity": "sha512-kTiAODz091bgT7SlWNHab0LSMZAPJtlNWDGKv7pLlLY1krmf7FuG1zxE0wyPpeA8gPdwfr3cu6sPwZRqWsc3Eg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", "dependencies": { "prop-types": "^15.7.2", @@ -8652,14 +10058,20 @@ }, "node_modules/react-is": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, "node_modules/react-measure": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-2.5.2.tgz", + "integrity": "sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.2.0", @@ -8674,6 +10086,8 @@ }, "node_modules/react-modal": { "version": "3.16.3", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz", + "integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==", "license": "MIT", "dependencies": { "exenv": "^1.2.0", @@ -8688,7 +10102,10 @@ }, "node_modules/react-redux": { "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -8711,6 +10128,8 @@ }, "node_modules/react-refresh": { "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -8719,7 +10138,10 @@ }, "node_modules/react-router": { "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8737,7 +10159,10 @@ }, "node_modules/react-router-dom": { "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8753,10 +10178,14 @@ }, "node_modules/react-router/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/react-transition-group": { "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", @@ -8771,6 +10200,8 @@ }, "node_modules/read-pkg": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.0", @@ -8784,6 +10215,8 @@ }, "node_modules/read-pkg-up": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", "license": "MIT", "dependencies": { "find-up": "^4.1.0", @@ -8799,6 +10232,8 @@ }, "node_modules/read-pkg-up/node_modules/find-up": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -8810,6 +10245,8 @@ }, "node_modules/read-pkg-up/node_modules/locate-path": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -8820,6 +10257,8 @@ }, "node_modules/read-pkg-up/node_modules/p-limit": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -8833,6 +10272,8 @@ }, "node_modules/read-pkg-up/node_modules/p-locate": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -8843,6 +10284,8 @@ }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -8850,6 +10293,8 @@ }, "node_modules/read-pkg/node_modules/type-fest": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -8857,6 +10302,8 @@ }, "node_modules/readable-stream": { "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -8869,6 +10316,8 @@ }, "node_modules/readdirp": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -8879,6 +10328,8 @@ }, "node_modules/redent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "license": "MIT", "dependencies": { "indent-string": "^4.0.0", @@ -8890,20 +10341,28 @@ }, "node_modules/redux": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } }, "node_modules/redux-saga": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", + "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", + "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8924,10 +10383,14 @@ }, "node_modules/regenerate": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -8938,10 +10401,14 @@ }, "node_modules/regenerator-runtime": { "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8960,6 +10427,8 @@ }, "node_modules/regexpu-core": { "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -8975,6 +10444,8 @@ }, "node_modules/registry-auth-token": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^3.0.2" @@ -8985,6 +10456,8 @@ }, "node_modules/registry-url": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", "license": "MIT", "dependencies": { "rc": "1.2.8" @@ -8998,10 +10471,14 @@ }, "node_modules/regjsgen": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -9012,10 +10489,14 @@ }, "node_modules/remove-accents": { "version": "0.4.4", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", + "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==", "license": "MIT" }, "node_modules/require-from-string": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9023,30 +10504,44 @@ }, "node_modules/reselect": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", + "integrity": "sha512-b/6tFZCmRhtBMa4xGqiiRp9jh9Aqi2A687Lo265cN0/QohJQEBPiQ52f4QB6i0eF3yp3hmLL21LSGBcML2dlxA==", "license": "MIT" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, "node_modules/resolve": { - "version": "2.0.0-next.5", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -9055,10 +10550,14 @@ }, "node_modules/resolve-pathname": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", "license": "MIT" }, "node_modules/restore-cursor": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -9070,6 +10569,8 @@ }, "node_modules/reusify": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -9079,6 +10580,8 @@ }, "node_modules/rifm": { "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz", + "integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1" @@ -9089,6 +10592,9 @@ }, "node_modules/rimraf": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -9103,9 +10609,12 @@ }, "node_modules/rollup": { "name": "@rollup/wasm-node", - "version": "4.55.2", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.60.1.tgz", + "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9122,11 +10631,15 @@ }, "node_modules/rrweb-cssom": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, "node_modules/run-async": { "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9134,6 +10647,8 @@ }, "node_modules/run-parallel": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -9156,6 +10671,8 @@ }, "node_modules/rxjs": { "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" @@ -9166,10 +10683,14 @@ }, "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, "node_modules/safe-array-concat": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9187,10 +10708,14 @@ }, "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -9209,6 +10734,8 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9223,10 +10750,14 @@ }, "node_modules/safe-push-apply/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9242,10 +10773,14 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -9257,6 +10792,8 @@ }, "node_modules/scheduler": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -9265,11 +10802,15 @@ }, "node_modules/seamless-immutable": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz", + "integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==", "license": "BSD-3-Clause", "optional": true }, "node_modules/semver": { - "version": "7.7.3", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9280,6 +10821,8 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -9287,6 +10830,8 @@ }, "node_modules/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -9302,6 +10847,8 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -9315,6 +10862,8 @@ }, "node_modules/set-proto": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -9333,6 +10882,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9343,6 +10894,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -9350,6 +10903,8 @@ }, "node_modules/side-channel": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9367,6 +10922,8 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9381,6 +10938,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9397,6 +10956,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9414,15 +10975,21 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/slash": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { @@ -9430,8 +10997,13 @@ } }, "node_modules/smob": { - "version": "1.5.0", - "license": "MIT" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/sortablejs": { "version": "1.15.7", @@ -9441,6 +11013,9 @@ }, "node_modules/source-map": { "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", "license": "BSD-3-Clause", "dependencies": { "whatwg-url": "^7.0.0" @@ -9451,6 +11026,8 @@ }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -9459,6 +11036,8 @@ }, "node_modules/source-map-support": { "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -9467,6 +11046,8 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9474,6 +11055,8 @@ }, "node_modules/source-map/node_modules/tr46": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "license": "MIT", "dependencies": { "punycode": "^2.1.0" @@ -9481,10 +11064,14 @@ }, "node_modules/source-map/node_modules/webidl-conversions": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause" }, "node_modules/source-map/node_modules/whatwg-url": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "license": "MIT", "dependencies": { "lodash.sortby": "^4.7.0", @@ -9494,10 +11081,15 @@ }, "node_modules/sourcemap-codec": { "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, "node_modules/spdx-correct": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -9506,10 +11098,14 @@ }, "node_modules/spdx-exceptions": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -9517,25 +11113,35 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9547,6 +11153,8 @@ }, "node_modules/strict-uri-encode": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9554,6 +11162,8 @@ }, "node_modules/string_decoder": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -9561,6 +11171,8 @@ }, "node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9571,29 +11183,16 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/string.prototype.includes": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -9607,6 +11206,8 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9632,6 +11233,8 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -9641,6 +11244,8 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9660,6 +11265,8 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9676,6 +11283,8 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -9691,6 +11300,8 @@ }, "node_modules/stringify-object": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", "license": "BSD-2-Clause", "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", @@ -9703,17 +11314,8 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9724,6 +11326,8 @@ }, "node_modules/strip-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", "license": "MIT", "engines": { "node": ">=10" @@ -9731,6 +11335,8 @@ }, "node_modules/strip-indent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "license": "MIT", "dependencies": { "min-indent": "^1.0.0" @@ -9741,6 +11347,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -9752,6 +11360,8 @@ }, "node_modules/stubborn-fs": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", "license": "MIT", "dependencies": { "stubborn-utils": "^1.0.1" @@ -9759,10 +11369,14 @@ }, "node_modules/stubborn-utils": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -9773,6 +11387,8 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9783,11 +11399,15 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/temp-dir": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", "license": "MIT", "engines": { "node": ">=8" @@ -9795,6 +11415,8 @@ }, "node_modules/tempy": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", "license": "MIT", "dependencies": { "is-stream": "^2.0.0", @@ -9811,6 +11433,8 @@ }, "node_modules/tempy/node_modules/type-fest": { "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -9820,7 +11444,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -9837,28 +11463,40 @@ }, "node_modules/text-table": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, "license": "MIT" }, "node_modules/through": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, "node_modules/tiny-invariant": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, "node_modules/tiny-warning": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -9867,6 +11505,8 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9882,6 +11522,8 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -9902,6 +11544,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9910,7 +11553,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -9919,6 +11564,8 @@ }, "node_modules/tldts": { "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9930,11 +11577,15 @@ }, "node_modules/tldts-core": { "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, "node_modules/tmp": { "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" @@ -9945,6 +11596,8 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -9955,6 +11608,8 @@ }, "node_modules/tough-cookie": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9966,6 +11621,8 @@ }, "node_modules/tr46": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { @@ -9977,6 +11634,8 @@ }, "node_modules/trim-newlines": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", "license": "MIT", "engines": { "node": ">=8" @@ -9984,6 +11643,8 @@ }, "node_modules/ts-api-utils": { "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, "license": "MIT", "engines": { @@ -9995,10 +11656,14 @@ }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -10010,6 +11675,8 @@ }, "node_modules/type-fest": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -10021,6 +11688,8 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -10033,6 +11702,8 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10050,6 +11721,8 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -10069,6 +11742,8 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -10087,8 +11762,11 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10099,6 +11777,8 @@ }, "node_modules/typescript-compare": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", + "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", "license": "MIT", "dependencies": { "typescript-logic": "^0.0.0" @@ -10106,10 +11786,14 @@ }, "node_modules/typescript-logic": { "version": "0.0.0", + "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", "license": "MIT" }, "node_modules/typescript-tuple": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", + "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", "license": "MIT", "dependencies": { "typescript-compare": "^0.0.2" @@ -10117,6 +11801,8 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -10133,11 +11819,15 @@ }, "node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "license": "MIT", "engines": { "node": ">=4" @@ -10145,6 +11835,8 @@ }, "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -10156,6 +11848,8 @@ }, "node_modules/unicode-match-property-value-ecmascript": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "license": "MIT", "engines": { "node": ">=4" @@ -10163,6 +11857,8 @@ }, "node_modules/unicode-property-aliases-ecmascript": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "license": "MIT", "engines": { "node": ">=4" @@ -10170,6 +11866,8 @@ }, "node_modules/unique-string": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", "license": "MIT", "dependencies": { "crypto-random-string": "^2.0.0" @@ -10180,6 +11878,8 @@ }, "node_modules/universalify": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -10187,6 +11887,8 @@ }, "node_modules/upath": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", "license": "MIT", "engines": { "node": ">=4", @@ -10195,6 +11897,8 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -10223,6 +11927,8 @@ }, "node_modules/update-notifier": { "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", "license": "BSD-2-Clause", "dependencies": { "boxen": "^8.0.1", @@ -10245,6 +11951,8 @@ }, "node_modules/update-notifier/node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -10255,6 +11963,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -10262,10 +11972,14 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -10277,6 +11991,8 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -10285,12 +12001,17 @@ }, "node_modules/value-equal": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", "license": "MIT" }, "node_modules/vite": { "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10362,6 +12083,8 @@ }, "node_modules/vite-plugin-pwa": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", + "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", "dev": true, "license": "MIT", "dependencies": { @@ -10391,6 +12114,8 @@ }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -10411,6 +12136,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10419,29 +12145,32 @@ } }, "node_modules/vitest": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -10457,12 +12186,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10491,6 +12221,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -10509,6 +12242,8 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -10520,6 +12255,8 @@ }, "node_modules/warning": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" @@ -10527,6 +12264,8 @@ }, "node_modules/wcwidth": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -10534,6 +12273,8 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -10542,6 +12283,9 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -10553,6 +12297,8 @@ }, "node_modules/whatwg-mimetype": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", "engines": { @@ -10561,6 +12307,8 @@ }, "node_modules/whatwg-url": { "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { @@ -10573,10 +12321,14 @@ }, "node_modules/when-exit": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", "license": "MIT" }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10590,6 +12342,8 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -10607,6 +12361,8 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10632,10 +12388,14 @@ }, "node_modules/which-builtin-type/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -10652,6 +12412,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -10671,6 +12433,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -10686,6 +12450,8 @@ }, "node_modules/widest-line": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -10699,6 +12465,8 @@ }, "node_modules/widest-line/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -10709,10 +12477,14 @@ }, "node_modules/widest-line/node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/widest-line/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -10727,10 +12499,12 @@ } }, "node_modules/widest-line/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -10741,6 +12515,8 @@ }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -10749,6 +12525,8 @@ }, "node_modules/workbox-background-sync": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", + "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -10757,6 +12535,8 @@ }, "node_modules/workbox-broadcast-update": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", + "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -10764,6 +12544,8 @@ }, "node_modules/workbox-build": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", + "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", "license": "MIT", "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", @@ -10809,11 +12591,12 @@ } }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "version": "0.3.6", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", "license": "MIT", "dependencies": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", + "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "engines": { @@ -10825,6 +12608,8 @@ }, "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.10.4", @@ -10846,6 +12631,8 @@ }, "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^3.1.0", @@ -10857,6 +12644,8 @@ }, "node_modules/workbox-build/node_modules/@rollup/pluginutils": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", "license": "MIT", "dependencies": { "@types/estree": "0.0.39", @@ -10872,11 +12661,16 @@ }, "node_modules/workbox-build/node_modules/@types/estree": { "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "license": "MIT" }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10888,12 +12682,38 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/workbox-build/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/workbox-build/node_modules/estree-walker": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", "license": "MIT" }, "node_modules/workbox-build/node_modules/glob": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -10915,23 +12735,29 @@ }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/workbox-build/node_modules/magic-string": { "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" } }, "node_modules/workbox-build/node_modules/minimatch": { - "version": "10.1.1", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10939,6 +12765,8 @@ }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "license": "MIT", "engines": { "node": ">=6" @@ -10948,8 +12776,11 @@ } }, "node_modules/workbox-build/node_modules/rollup": { - "version": "2.79.2", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -10962,6 +12793,8 @@ }, "node_modules/workbox-cacheable-response": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", + "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -10969,6 +12802,8 @@ }, "node_modules/workbox-cli": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cli/-/workbox-cli-7.4.0.tgz", + "integrity": "sha512-BTc9CbW+aXMyIxBdW2mX+dLYHwTeCdKARX0zpjLvR/mZ2ho/7d9XWckwgFGLQRsJfcxml5WngNqp1PG7+qa9Ug==", "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -10992,8 +12827,32 @@ "node": ">=20.0.0" } }, + "node_modules/workbox-cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/workbox-cli/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/workbox-cli/node_modules/glob": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -11014,13 +12873,15 @@ } }, "node_modules/workbox-cli/node_modules/minimatch": { - "version": "10.1.1", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11028,6 +12889,8 @@ }, "node_modules/workbox-cli/node_modules/pretty-bytes": { "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "license": "MIT", "engines": { "node": ">=6" @@ -11038,10 +12901,14 @@ }, "node_modules/workbox-core": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", + "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", "license": "MIT" }, "node_modules/workbox-expiration": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", + "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -11050,6 +12917,8 @@ }, "node_modules/workbox-google-analytics": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", + "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", "license": "MIT", "dependencies": { "workbox-background-sync": "7.4.0", @@ -11060,6 +12929,8 @@ }, "node_modules/workbox-navigation-preload": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", + "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11067,6 +12938,8 @@ }, "node_modules/workbox-precaching": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", + "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -11076,6 +12949,8 @@ }, "node_modules/workbox-range-requests": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", + "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11083,6 +12958,8 @@ }, "node_modules/workbox-recipes": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", + "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", "license": "MIT", "dependencies": { "workbox-cacheable-response": "7.4.0", @@ -11095,6 +12972,8 @@ }, "node_modules/workbox-routing": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", + "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11102,6 +12981,8 @@ }, "node_modules/workbox-strategies": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", + "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11109,6 +12990,8 @@ }, "node_modules/workbox-streams": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", + "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -11117,10 +13000,14 @@ }, "node_modules/workbox-sw": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", + "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", "license": "MIT" }, "node_modules/workbox-window": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", + "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", @@ -11129,6 +13016,8 @@ }, "node_modules/wrap-ansi": { "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -11142,24 +13031,10 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -11170,6 +13045,8 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -11180,10 +13057,14 @@ }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11198,10 +13079,12 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11212,11 +13095,15 @@ }, "node_modules/wrappy": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, "license": "MIT", "engines": { @@ -11237,6 +13124,8 @@ }, "node_modules/xdg-basedir": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", "license": "MIT", "engines": { "node": ">=12" @@ -11247,6 +13136,8 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -11255,15 +13146,21 @@ }, "node_modules/xmlchars": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, "node_modules/yargs-parser": { "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -11275,6 +13172,8 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { From d7baf6ee7f9533f5fdd51a66f806f48a75fcbbf5 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 5 Apr 2026 12:12:15 -0400 Subject: [PATCH 158/596] fix(shares): honor path component of ShareURL config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublicURL() copied only the scheme and host from conf.Server.ShareURL, silently dropping any path component. This broke OpenGraph image URLs (and other share links) when ShareURL was configured with a path prefix like https://example.com/navi — generated URLs pointed to /share/img/... at the root instead of /navi/share/img/... Now the ShareURL path is prepended to the resource path, with trailing slashes trimmed. When ShareURL has no path, behavior is unchanged. --- core/publicurl/publicurl.go | 3 +++ core/publicurl/publicurl_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index c1b8e01c4..b0865e78b 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -45,6 +45,9 @@ func PublicURL(req *http.Request, u string, params url.Values) string { } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host + if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" { + buildUrl.Path = path.Join(basePath, buildUrl.Path) + } if len(params) > 0 { buildUrl.RawQuery = params.Encode() } diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 18f8f8129..a195fb9cd 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -56,6 +56,31 @@ var _ = Describe("Public URL Utilities", func() { }) }) + When("ShareURL includes a path", func() { + BeforeEach(func() { + conf.Server.ShareURL = "https://example.com/navi" + }) + + It("prepends the ShareURL path to the resource", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.PublicURL(r, "/share/img/hash", nil) + Expect(result).To(Equal("https://example.com/navi/share/img/hash")) + }) + + It("prepends the ShareURL path and includes query parameters", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + params := url.Values{"size": []string{"600"}} + result := publicurl.PublicURL(r, "/share/img/hash", params) + Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) + }) + + It("handles trailing slash in ShareURL path", func() { + conf.Server.ShareURL = "https://example.com/navi/" + result := publicurl.PublicURL(nil, "/share/img/hash", nil) + Expect(result).To(Equal("https://example.com/navi/share/img/hash")) + }) + }) + When("ShareURL is not set", func() { BeforeEach(func() { conf.Server.ShareURL = "" From 991bd3ed213920baad7ce8488cbb50106d6d94c8 Mon Sep 17 00:00:00 2001 From: Barend <38956787+bvdwalt@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:56:32 +0200 Subject: [PATCH 159/596] fix(db): resolve schema inconsistencies in library_artist and scrobble_buffer tables (#5047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): resolve schema inconsistencies in library_artist and scrobble_buffer tables * fix(db): address PR comments around speed of the migration * fix(db): simplify schema inconsistencies migration Remove ineffective PRAGMA foreign_keys and cache_size statements, which are no-ops inside goose's wrapping transaction. Drop the down migration body (Navidrome does not run down migrations) and document the intent. Rename the file to refresh the timestamp after rebase. --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ...60405124200_fix_schema_inconsistencies.sql | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 db/migrations/20260405124200_fix_schema_inconsistencies.sql diff --git a/db/migrations/20260405124200_fix_schema_inconsistencies.sql b/db/migrations/20260405124200_fix_schema_inconsistencies.sql new file mode 100644 index 000000000..15fe95308 --- /dev/null +++ b/db/migrations/20260405124200_fix_schema_inconsistencies.sql @@ -0,0 +1,55 @@ +-- +goose Up + +-- NOTE: This migration recreates two tables to fix schema inconsistencies. +-- On large production databases, the data copy may take some time as tables are locked during the transaction. +-- This is necessary because SQLite does not support altering table constraints directly. +-- Consider applying this migration during a maintenance window if the tables are large. + +-- Fix library_artist table: Remove contradictory 'default null' from 'not null' column +-- This is a cosmetic fix (NOT NULL takes precedence), but improves schema consistency +CREATE TABLE library_artist_new +( + library_id integer NOT NULL DEFAULT 1 + REFERENCES library(id) ON DELETE CASCADE, + artist_id varchar NOT NULL + REFERENCES artist(id) ON DELETE CASCADE, + stats text DEFAULT '{}', + CONSTRAINT library_artist_ux UNIQUE (library_id, artist_id) +); + +INSERT INTO library_artist_new (library_id, artist_id, stats) +SELECT library_id, artist_id, stats FROM library_artist; + +DROP TABLE library_artist; + +ALTER TABLE library_artist_new RENAME TO library_artist; + +-- Fix scrobble_buffer table: Remove duplicate user_id from unique constraint +-- Original constraint had: UNIQUE (user_id, service, media_file_id, play_time, user_id) +-- Fixed constraint is: UNIQUE (user_id, service, media_file_id, play_time) +CREATE TABLE scrobble_buffer_new +( + user_id varchar NOT NULL + CONSTRAINT scrobble_buffer_user_id_fk + REFERENCES user ON UPDATE CASCADE ON DELETE CASCADE, + service varchar NOT NULL, + media_file_id varchar NOT NULL + CONSTRAINT scrobble_buffer_media_file_id_fk + REFERENCES media_file ON UPDATE CASCADE ON DELETE CASCADE, + play_time datetime NOT NULL, + enqueue_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + id varchar NOT NULL DEFAULT '', + CONSTRAINT scrobble_buffer_pk UNIQUE (user_id, service, media_file_id, play_time) +); + +INSERT INTO scrobble_buffer_new (user_id, service, media_file_id, play_time, enqueue_time, id) +SELECT user_id, service, media_file_id, play_time, enqueue_time, id FROM scrobble_buffer; + +DROP TABLE scrobble_buffer; + +ALTER TABLE scrobble_buffer_new RENAME TO scrobble_buffer; + +CREATE UNIQUE INDEX scrobble_buffer_id_ix ON scrobble_buffer (id); + +-- +goose Down +-- Down migration is intentionally a no-op: Navidrome does not run down migrations. From 664217f3f79e391522a2dc462d5ad3e12b016b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 5 Apr 2026 20:31:11 -0400 Subject: [PATCH 160/596] fix(transcoding): play WAV files directly in browsers instead of transcoding (#5309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: allow WAV direct play by aliasing pcm and wav codecs WAV files were being transcoded to FLAC even when the browser declared native WAV support. The backend normalizes ffprobe's pcm_s16le (and similar PCM variants) to the internal codec name "pcm", while browsers advertise WAV support as audioCodecs:["wav"] in their client profile. The direct-play codec check compared these literally and rejected the match with "audio codec not supported", forcing a needless FLAC transcode. Added {"pcm", "wav"} to codecAliasGroups so the matcher treats them as equivalent. The container check runs first, so AIFF files (which also normalize to codec "pcm" but use container "aiff") cannot accidentally match a WAV direct-play profile. * feat: include profile details in direct-play rejection reasons The transcodeReason array returned by getTranscodeDecision previously contained one generic string per failed DirectPlayProfile (e.g., five copies of "container not supported"), making it hard to correlate a reason with the profile that rejected the stream. Each rejection reason now embeds the offending source value (in single quotes) along with a compact representation of the full profile that rejected it, rendered as [container/codec]. For example, clients with two distinct ogg-container profiles (opus and vorbis) produced two identical rejection strings; they now read "container 'wav' not supported by profile [ogg/opus]" and "container 'wav' not supported by profile [ogg/vorbis]", making each entry in the transcodeReason array unique and self-describing. A small describeProfile helper renders profiles as [container/codec] (or [container] when no codec is constrained). * refactor(stream): address code review — narrow pcm/wav match, tighten tests Responds to reviewer feedback on the initial PR: - Replace the symmetric pcm↔wav codec alias with a contextual isPCMInWAVMatch check in checkDirectPlayProfile. The alias unconditionally equated the two names in matchesCodec, which would let AIFF sources (also normalized to codec "pcm") falsely satisfy a codec-only ["wav"] direct-play profile that omitted containers. The new check additionally requires src.Container == "wav" before bridging the names, closing the false-positive path. - Tighten the rejection-reason test assertions to verify the new formatted output (source value + profile descriptor) instead of just matching loose substrings like "container", preventing unrelated rejections from satisfying the expectations. - Add coverage for the WAV→wav-codec acceptance path and for the AIFF-in-wav-codec-profile rejection path to pin down the contract of isPCMInWAVMatch. * refactor(codec): rename isPCMInWAVMatch to matchesPCMWAVBridge for clarity Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/stream/decider.go | 19 ++++++++--- core/stream/decider_test.go | 63 ++++++++++++++++++++++++++++++++----- core/stream/types.go | 13 ++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/core/stream/decider.go b/core/stream/decider.go index 5cca0cb0f..6c1f06a06 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -195,6 +195,17 @@ func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { return &result, nil } +// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the +// "wav" codec name that browsers use to advertise audio/wav support. The match +// is scoped to WAV-container sources so AIFF files (which also normalize to +// codec "pcm" but use a different container) cannot false-match a codec-only +// ["wav"] profile. +func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool { + return strings.EqualFold(src.Codec, "pcm") && + strings.EqualFold(src.Container, "wav") && + containsIgnoreCase(profile.AudioCodecs, "wav") +} + // checkDirectPlayProfile returns "" if the profile matches (direct play OK), // or a typed reason string if it doesn't match. func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string { @@ -205,17 +216,17 @@ func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPla // Check container if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) { - return "container not supported" + return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile) } // Check codec - if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) { - return "audio codec not supported" + if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) { + return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile) } // Check channels if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { - return "audio channels not supported" + return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels) } // Check codec-specific limitations diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 42ebd84f1..9eaa00990 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -76,7 +76,10 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("container 'flac' not supported"), + ContainSubstring("[mp3]"), + ))) }) It("rejects direct play when codec doesn't match", func() { @@ -89,7 +92,10 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("audio codec 'alac' not supported"), + ContainSubstring("[m4a/aac]"), + ))) }) It("rejects direct play when channels exceed limit", func() { @@ -102,7 +108,44 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("audio channels 6 not supported"), + ContainSubstring("[flac]"), + ContainSubstring("(max 2)"), + ))) + }) + + It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() { + // ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but + // browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() { + // AIFF files also normalize to codec="pcm" but use container="aiff". + // Without the container guard they would falsely match a codec-only + // ["wav"] profile and be direct-played as if they were WAV. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'"))) }) It("handles container aliases (aac -> m4a)", func() { @@ -216,7 +259,10 @@ var _ = Describe("Decider", func() { Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) Expect(decision.TargetBitrate).To(Equal(256)) // kbps - Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("container 'flac' not supported"), + ContainSubstring("[mp3]"), + ))) }) It("rejects lossy to lossless transcoding", func() { @@ -901,9 +947,12 @@ var _ = Describe("Decider", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(HaveLen(3)) - Expect(decision.TranscodeReasons[0]).To(Equal("container not supported")) - Expect(decision.TranscodeReasons[1]).To(Equal("container not supported")) - Expect(decision.TranscodeReasons[2]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported")) + Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]")) + Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported")) + Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]")) + Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported")) + Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]")) }) }) diff --git a/core/stream/types.go b/core/stream/types.go index 0cb4ac47d..bd8ce292c 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -2,6 +2,7 @@ package stream import ( "errors" + "strings" "time" ) @@ -47,6 +48,18 @@ type DirectPlayProfile struct { MaxAudioChannels int } +func (p DirectPlayProfile) String() string { + containers := strings.Join(p.Containers, ",") + if containers == "" { + containers = "*" + } + codecs := strings.Join(p.AudioCodecs, ",") + if codecs == "" { + return "[" + containers + "]" + } + return "[" + containers + "/" + codecs + "]" +} + // Profile describes a transcoding target the client supports type Profile struct { Container string From c91721363b7de714ce7ac13b4331acacebade7ff Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 6 Apr 2026 08:36:28 -0400 Subject: [PATCH 161/596] fix(ui): prevent theme CSS filters from affecting disc cover art (fix #5312) The Squiddies Glass theme applies a CSS color filter to all images inside table cells (MuiTableCell '& img'), which was intended for small playback indicator icons. This inadvertently also applied to disc cover art thumbnails in multi-disc album views, turning them into solid color blocks. Adding 'filter: none !important' to the discCoverArt style ensures cover art images are always displayed correctly regardless of the active theme. Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/common/SongDatagrid.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index d2c98bbe7..5d2ae3ed1 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -51,6 +51,7 @@ const useStyles = makeStyles({ borderRadius: '4px', flexShrink: 0, cursor: 'pointer', + filter: 'none !important', }, row: { cursor: 'pointer', From 7834674381d86648c2f33606bff7ace59a85585d Mon Sep 17 00:00:00 2001 From: obskyr <robots@obskyr.io> Date: Tue, 7 Apr 2026 03:35:22 +0200 Subject: [PATCH 162/596] fix(scanner): map ORIGYEAR tag for VorbisComment and MP4 formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use ORIGYEAR tag for original date As it is a default mapping in MP3Tag. https://docs.mp3tag.de/mapping/#origyear * Test parsing `originaldate` and `ORIGYEAR` tags `originaldate` is populated by TagLib’s mappings. https://taglib.org/api/p_propertymapping.html --- adapters/gotaglib/gotaglib_test.go | 11 +++++++++++ resources/mappings.yaml | 2 +- .../01 Invisible (RED) Edit Version.m4a | Bin 38962 -> 38962 bytes tests/fixtures/test.aiff | Bin 109766 -> 109766 bytes tests/fixtures/test.flac | Bin 31146 -> 31146 bytes tests/fixtures/test.m4a | Bin 45120 -> 45120 bytes tests/fixtures/test.mp3 | Bin 64223 -> 64223 bytes tests/fixtures/test.ogg | Bin 32177 -> 32200 bytes tests/fixtures/test.opus | Bin 14236 -> 13219 bytes tests/fixtures/test.wav | Bin 109954 -> 109954 bytes tests/fixtures/test.wma | Bin 40717 -> 40717 bytes tests/fixtures/test.wv | Bin 42882 -> 42909 bytes 12 files changed, 12 insertions(+), 1 deletion(-) diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go index 8fdf5b406..6756fb690 100644 --- a/adapters/gotaglib/gotaglib_test.go +++ b/adapters/gotaglib/gotaglib_test.go @@ -127,6 +127,17 @@ var _ = Describe("Extractor", func() { Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"})) + // Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags + // to `originaldate`. + if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") { + Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) + } + // MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping + // for original release dates. + Expect(m.Tags).To(Or( + HaveKeyWithValue("origyear", []string{"1998-07-28"}), + HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}), + )) Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) Expect(m.Tags).To(Or( diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 19ba0b090..16dddd504 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -116,7 +116,7 @@ main: aliases: [ comm:description, comment, ©cmt, description, icmt ] maxLength: 4096 originaldate: - aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ] + aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear, origyear, ----:com.apple.itunes:origyear ] type: date recordingdate: aliases: [ tdrc, date, recordingdate, icrd, record date ] diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.m4a b/tests/fixtures/01 Invisible (RED) Edit Version.m4a index 005792eb5f7f2447de71a3e6719f69697c3d21bd..76b61a2d4331923781ec3f342382b5661e5f3baa 100644 GIT binary patch delta 154 zcmdnAfoanQrVSikjMp}EdVOZ*4ATVy1_lP1$^Jgl))IM%xv4-NgMW~xyQiO{kBei7 zE08Odl30=mq!~eShL)CQx`u|jMutF65VC%45oG<5u8u)K73g{`bPddPjVw0r@T+y3 P{M&aw6I1v`g<vKC;6NiO delta 32 qcmV+*0N?+zumZBM0<Z{50WPx%O7jDgAC<ndqfUE90s{ZBAo~K>OARpq diff --git a/tests/fixtures/test.aiff b/tests/fixtures/test.aiff index 1435115d9ca65b2574798a29fe10d75f45dff76c..d179f0714d9b074becd26a169971147a5ad45600 100644 GIT binary patch delta 74 zcmX?hlkM0|wuUW?Gb2S@{DT-67<d>Mm<=s0&2$Y7b&U+CPmg3wkqe24hyY570;T+e bJl!K*9fKHvYAkdO%yo?{w*QJ`oE`)K@_-Xe delta 27 jcmX?hlkM0|wuUW?Gb5+3k7P{RZV<&dEoi#HN=8!vu?!0& diff --git a/tests/fixtures/test.flac b/tests/fixtures/test.flac index 6c1270fd5091bde08bcdbcf147b9ac188dcc7367..50430f539ed2b9a4d350d46ec0e8f0fc719dcac7 100644 GIT binary patch delta 72 zcmZ4WnQ_%;#tFJY*LWBh7<>}T(##C>jP(o+3_>>Auj3XM28#LzdAdisItJMqT3TA@ b8kp-ES!{mGeMF5hZ1X{*Fp<sj?%hTJ7fKbI delta 52 zcmV-40L%ZX^8u>!0gx>czYG8X08C+aW;QS`GcGYOFjKKVt_!mS48$e@ZL`rbP85^i KG9R-aM~gDm5fQHd diff --git a/tests/fixtures/test.m4a b/tests/fixtures/test.m4a index c469dd9e411c678ec2b7548df4533c6f1fe4df0a..e9b54d44d26e997bae7a2cd2de3457f73663aa1e 100644 GIT binary patch delta 169 zcmX@`fa$;krVadIj7K*MgvBvV7K*mq{2}TR6Jyxqh6rV2iM+(zR0bgM5At;P^mFua zaSU+<a-~udOA>)JBS_BB($Y-V&`{UN5XcDv>6`p6oKp>?CDPR~2q=iI+(OsDT-V3~ pC=&xyzIk@I4D;lcB+JdeW2drh-jG`Dws}H|0wWW1{ziptW&qwoFM<F7 delta 56 zcmV-80LTBp-~zzl0<aHO0UfgtR$c+KAzMiUlO$L_v#nRm0+Y~TIkTf)l?Jo3s~`ch OIAwcA0t6SaAQJ?5hZNEP diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 index 7333249add06dbef860a5fe0b0c22e6d4cd823f9..18cb906749e047afda734b62c1ffcdd89ca72ead 100644 GIT binary patch delta 54 zcmV-60LlN~^#kAa1F)O{9#mLZSO5S36#xJOPf|%oSw%ro05Lf^I4v+YEiyQ>n*rmP MlMtN*v!R+kVBy*kkN^Mx delta 27 lcmV+$0ObGQ^#kAa1F)O{v%3M}n3JHEACnNB1hb)<K48wK4V?f0 diff --git a/tests/fixtures/test.ogg b/tests/fixtures/test.ogg index 507da9050c804b5382db91ce155def71dab4feb9..2b1deea981607853ac1137e85947b0bb227d7699 100644 GIT binary patch delta 60 zcmV-C0K@;W`vJ)N0gzAyqYOh3c9CITk#GzFX_3Gq0adZ&&S@7D0000_Qb|WyML|+M SF*!LnEigANGB~rcYX1_da}?VE delta 36 ucmV+<0Nek_`vI~00gzAy;~X0fc9CITk#GzFQjx$T0aUT%&S|sBYX1@<{}3?% diff --git a/tests/fixtures/test.opus b/tests/fixtures/test.opus index 5052c0e6edde9389cb62c50a22a24466b4c78766..044947c704f71bf109c430ded07e8e156a1b837b 100644 GIT binary patch delta 283 zcmbP}zc_t@t4RpoVHcMF|Nl$*7nBx<B&HYhFfcIqB$lO_8R;4885$S}1Nq!Q?C9eZ z>TBzmlT?~Jajr}~FGwgT#4|X=*0HE0v$%vEDBu*}Yinp^ED2;f`}_L3`i0m!=jZ08 z=9L(7K^aCMjX=o&Panq+Pk%pKLr$Q8i(`nZt&xGD2_J~#8SLyA>g(hhWNWBz#03;_ zcl8T$wGGNo&ej9+d?JH9Cm&~w5C=KUKgiQP($x{D)Y8&I*T7uY$YQcFV~;Y($swL0 qKCZSQnI$=?0w8XXqq8^EU}JqlgURwtw%m>gFgcy+_2xKsI|Bfb-beBP delta 334 zcmZ3SJ|};IEAQzy=Vo#IhXCP;eiFt)3=9n1Kpf&3;^S%?l39|I3gSBYIEDJ!I_4ym z=JJBLK_Q;OA-0Z1C7Hz~6Dwt6xqyQ1u6{wTwn6#H*_=S0i(`nZt&xGDi2#rj66EOY z?HB6n<QimatZ!(*2U6e}><kq$)Hh-WiZ})M+8P=egDi0N_YLs$aSZYF_p>#exKVa; yGh+{<-eh}5waLbemXnQ`Y`I+>fxrnwxKFNPdOcat*l4nXu`-=8+vZeeI|BfT{91Pa diff --git a/tests/fixtures/test.wav b/tests/fixtures/test.wav index 155d88bdb51e4e8ea0e377305b02acf669d59e08..b8c1f9a650c2f414baacf4d4fa4fa3fae89906de 100644 GIT binary patch delta 79 zcmZp=&DM0Atzipef24?ue-Hx$0}lfOv!SJ>nXaLsu94yN-blt2xsZs62%wZGP|82Z g(>>DFF^B=E#zNP?T-V5A`-e!z=|R)?tYJI`04~%OZ~y=R delta 31 ncmZp=&DM0Atzipef8_L~k&H>()uI@u1x;JYXgYn*8pdM))j<t6 diff --git a/tests/fixtures/test.wma b/tests/fixtures/test.wma index 2edb7260e8ccfceab39f70256bb9b465dcc76f92..2e7341cf558ba2cf8b39445743f83cae9ac99748 100644 GIT binary patch delta 170 zcmeC($JD!zX@eFs=M`@TFp%DC%<Sx4ug4J1;LD)T;LlLRkjan^#CZ&f3^@!z45>ge z7050Il96Cm5m=)bgCT<@5SlUQ0$GMY(g;X0FbDz72x9PLa0jY(WpD(tK>()O0;tY_ l!5pmG0w_L_{m%C7E6;DP^>b!q+RVK9qW?nX&9{S%ya9K`A%Fk? delta 42 vcmeC($JD!zX@eFsXN)%k7)Wk5W_I@8{Lasdkx83n^TZjFESql!8+ii&0J02v diff --git a/tests/fixtures/test.wv b/tests/fixtures/test.wv index 3722d28a24f04c02edc0c73543796137debfbc99..7f4118bdb343988ff70f96dcbd8c5119d952e21f 100644 GIT binary patch delta 520 zcmYjO&o2W(6n^!iL?j|4BpfOc7s~Edv=W!aY9xeI>DFy_4JNj`&2$M4h(Ex=ySRw0 z#yXO?Ik-w3RQL=09}d=bCo#!;@0;(v@5`IadA;en-t_ofIIXV$j6!(4MgeG!!0hTW z$Vy6F=UK%Bc|vK4+C@q@D3UxWh%B`^g`8vV2`0~pxd*8US!#_$s%7k#Ak2Rh?t5Kn z(aST}q7dd-G(P8)%FH4zb8L{~!h^f4;sX8@29rXo**naplmjI|4Z$w6L$xu?32+0; zH+@oD<TB4VHC>0p79&9zf?qn~%>47WNP>W6;ECUl5Bk7BM<iUyEoO_Fnge-yI+K*r zNhO0^AC{?YI$Oa8OXOgCN*Yg%%aYU{X@qIT;VzJ|gcUk}7l!acAHta+zWpA2_v85B zcLq2U;2w<Mk6e12NNU?b^y19z4!+#2g*aB-#Ve2DSCwJy_7m6d7_#aHcKfBL*V=LH Z=}kXbNT{n?Ud?LyNAS3RKYbQ$1;6>fi5vg` delta 502 zcmbPxo~h|P(}rE)PFwsL7_=C`U;%_;U|@wZ9CMONa~Z%S53-PgV^K+FaS235VRC(h z%;bUyQGRBq8m9nX216s`$zLKBMH!)D&iT0onK_9infZAPhLd9>RVLq#l$7R#%jTt& zCYR(FF(l{bC4z{g&HoeX+$P_TkWhme>ylZV%wVW*#17Tul9iiTky^x%l9dR=lPe-+ zV_^<=%}dYBO9jfMCNiWZu|PGrr{)!<G6dx(XTyx}sVvG&2Kgi>Q6Z5bCy4{95++`p zUsS@7sH2brlAOFcDr)k^2ytPUExusua}ybIlO`{Wsh#W_lQH>FTo{loGFdJ*b8>xj z`sDW5OOqv|gEqIuS22PmBI7bAKZ_2YED@hOnLkp3k$rMPl*DAcD4xmjQEHQ|qAVuQ zh!PHiSrL+1l9S2+CSkq^DN0PvW-!(_G=PajrY06K7#SFvI0m?eIJ&z=TmbqHDP9|( HzG46XL`r_4 From 478845bc5dba78a65c065b64488db8ed945c3ef6 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 6 Apr 2026 22:28:37 -0400 Subject: [PATCH 163/596] fix(plugins): fix race between KVStore cleanup goroutine and Close (navidrome/apple-music-plugin#7) The cleanupLoop goroutine could execute cleanupExpired against a closed database because Close() did not wait for the goroutine to exit before calling db.Close(). This caused 'sql: database is closed' errors during plugin unload or shutdown. Close() now cancels the cleanup goroutine's context and waits for it to finish via a sync.WaitGroup before running the final cleanup and closing the database. Signed-off-by: Deluan <deluan@navidrome.org> --- plugins/host_kvstore.go | 24 +++++++++++++----------- plugins/host_kvstore_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index 248e43c4d..c3f6ec734 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -9,6 +9,7 @@ import ( "path/filepath" "slices" "strings" + "sync" "time" "github.com/dustin/go-humanize" @@ -35,6 +36,8 @@ type kvstoreServiceImpl struct { pluginName string db *sql.DB maxSize int64 + cancel context.CancelFunc + wg sync.WaitGroup } // newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database. @@ -74,12 +77,15 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize))) + cleanupCtx, cancel := context.WithCancel(ctx) svc := &kvstoreServiceImpl{ pluginName: pluginName, db: db, maxSize: maxSize, + cancel: cancel, } - go svc.cleanupLoop(ctx) + svc.wg.Add(1) + go svc.cleanupLoop(cleanupCtx) return svc, nil } @@ -335,6 +341,7 @@ func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[st // cleanupLoop periodically removes expired keys from the database. // It stops when the provided context is cancelled. func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) { + defer s.wg.Done() ticker := time.NewTicker(cleanupInterval) defer ticker.Stop() for { @@ -359,17 +366,12 @@ func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) { } } -// Close runs a final cleanup and closes the SQLite database connection. -// The cleanup goroutine is stopped by the context passed to newKVStoreService. +// Close stops the cleanup goroutine and closes the SQLite database connection. func (s *kvstoreServiceImpl) Close() error { - if s.db != nil { - log.Debug("Closing plugin kvstore", "plugin", s.pluginName) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - s.cleanupExpired(ctx) - return s.db.Close() - } - return nil + log.Debug("Closing plugin kvstore", "plugin", s.pluginName) + s.cancel() + s.wg.Wait() + return s.db.Close() } // Compile-time verification diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 4928825ef..e5d467f79 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -445,6 +445,36 @@ var _ = Describe("KVStoreService", func() { }) }) + Describe("Close", func() { + It("does not race with cleanupLoop goroutine", func() { + // Create a service with a dedicated context so we can verify + // that Close() properly waits for the cleanup goroutine. + closeCtx, closeCancel := context.WithCancel(ctx) + defer closeCancel() + + maxSize := "1KB" + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) + Expect(err).ToNot(HaveOccurred()) + + // Insert an expired key so cleanup has work to do + _, err = svc.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('cleanup_race', 'old', 3, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + // Close should not panic or produce "database is closed" errors. + // Before the fix, the cleanup goroutine could race with db.Close(). + err = svc.Close() + Expect(err).ToNot(HaveOccurred()) + + // Verify the database is actually closed (further queries should fail) + _, err = svc.db.Exec(`SELECT 1`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("database is closed")) + }) + }) + Describe("SetWithTTL", func() { It("stores value that is retrievable before expiry", func() { err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600) From 1044c173cb3759883351f9ebac547ae89386e0a0 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:11:05 +0800 Subject: [PATCH 164/596] fix(ui): update Chinese (Simplified) translation (#5323) --- resources/i18n/zh-Hans.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index e26c2b664..63ea5cf60 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -23,6 +23,7 @@ "bitDepth": "位深度", "sampleRate": "采样率", "channels": "声道", + "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", "starred": "收藏", "comment": "注释", @@ -355,7 +356,8 @@ "allUsers": "允许所有用户", "selectedUsers": "指定用户", "allLibraries": "允许所有媒体库", - "selectedLibraries": "指定媒体库" + "selectedLibraries": "指定媒体库", + "allowWriteAccess": "允许写入权限" }, "sections": { "status": "状态", @@ -400,6 +402,7 @@ "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", "noLibraries": "未选择媒体库", "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。", + "allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。", "requiredHosts": "必需的主机" }, "placeholders": { @@ -554,6 +557,12 @@ } }, "message": { + "uploadCover": "上传封面", + "removeCover": "移除封面", + "coverUploaded": "封面已上传", + "coverRemoved": "封面已移除", + "coverUploadError": "上传封面时出错", + "coverRemoveError": "移除封面时出错", "note": "注意", "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", @@ -673,6 +682,7 @@ "currentValue": "当前值", "configurationFile": "配置文件", "exportToml": "导出配置(TOML)", + "downloadToml": "下载配置(TOML)", "exportSuccess": "配置以 TOML 格式导出到剪贴板完成", "exportFailed": "复制配置失败", "devFlagsHeader": "开发标志(可能会更改/删除)", From 1de4e43d29cb0e3c1945641915eeda23916dde31 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 7 Apr 2026 15:30:21 -0400 Subject: [PATCH 165/596] fix(gotaglib): update go-taglib to fix issue with empty id3v2 frames Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fcee08c7e..4f4ad0461 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.25.0 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index e0671367a..5a0761f15 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 h1:RpRSTEsAdLHx3Ci0d3M5wtpjcBZiKzhnGfnNAxGXrAE= -github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw= +github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/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= From 9e2c6adffdf9cdff4a05c70e4b48f647032515b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:07:46 -0400 Subject: [PATCH 166/596] chore(deps-dev): bump vite from 7.3.1 to 7.3.2 in /ui (#5321) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 48 ++++---------------------------------------- ui/package.json | 2 +- 2 files changed, 5 insertions(+), 45 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 2dd91a674..1f95f14f8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -68,7 +68,7 @@ "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^7.1.12", + "vite": "^7.3.2", "vite-plugin-pwa": "^1.1.0", "vitest": "^4.0.3" } @@ -129,7 +129,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1744,7 +1743,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1768,7 +1766,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2474,7 +2471,6 @@ "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2528,7 +2524,6 @@ "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2544,7 +2539,6 @@ "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2591,7 +2585,6 @@ "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2684,7 +2677,6 @@ "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@emotion/hash": "^0.8.0", @@ -3316,7 +3308,6 @@ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", - "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3369,7 +3360,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3391,7 +3381,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3561,7 +3550,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3895,7 +3883,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4580,7 +4567,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4956,7 +4942,6 @@ "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", - "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5837,7 +5822,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6421,7 +6405,6 @@ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -6438,7 +6421,6 @@ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", - "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6853,7 +6835,6 @@ "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6967,7 +6948,6 @@ "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -8570,7 +8550,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -9319,7 +9298,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9411,7 +9389,6 @@ "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", - "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9826,7 +9803,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9908,7 +9884,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9981,7 +9956,6 @@ "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -9999,7 +9973,6 @@ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -10105,7 +10078,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -10141,7 +10113,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10162,7 +10133,6 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10344,7 +10314,6 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -10354,7 +10323,6 @@ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", - "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -10614,7 +10582,6 @@ "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11544,7 +11511,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11766,7 +11732,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12006,12 +11971,11 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12136,7 +12100,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12150,7 +12113,6 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", @@ -12670,7 +12632,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12780,7 +12741,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/package.json b/ui/package.json index d4c149b23..b440f0595 100644 --- a/ui/package.json +++ b/ui/package.json @@ -77,7 +77,7 @@ "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^7.1.12", + "vite": "^7.3.2", "vite-plugin-pwa": "^1.1.0", "vitest": "^4.0.3" }, From 36a7be9eaf822447ce531ef5ee06c87dae1f8698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 7 Apr 2026 20:11:38 -0400 Subject: [PATCH 167/596] fix(transcoding): include ffprobe in MSI and fall back gracefully when absent (#5326) * fix(msi): include ffprobe executable in MSI build Signed-off-by: Deluan <deluan@navidrome.org> * feat(ffmpeg): add IsProbeAvailable() to FFmpeg interface Add runtime check for ffprobe binary availability with cached result and startup logging. When ffprobe is missing, logs a warning at startup. * feat(stream): guard MakeDecision behind ffprobe availability When ffprobe is not available, MakeDecision returns a decision with ErrorReason set and both CanDirectPlay and CanTranscode false, instead of failing with an opaque exec error. * feat(subsonic): only advertise transcoding extension when ffprobe is available The OpenSubsonic transcoding extension is now conditionally included based on ffprobe availability, so clients know not to call getTranscodeDecision when ffprobe is missing. * refactor(ffmpeg): move ffprobe startup warning to initial_setup Move the ffprobe availability warning from the lazy IsProbeAvailable() check to checkFFmpegInstallation() in server/initial_setup.go, alongside the existing ffmpeg warning. This ensures the warning appears at startup rather than on first endpoint call. * fix(e2e): set noopFFmpeg.IsProbeAvailable to true The e2e tests use pre-populated probe data and don't need a real ffprobe binary. Setting IsProbeAvailable to true allows the transcode decision logic to proceed normally in e2e tests. * fix(stream): only guard on ffprobe when probing is needed Move the IsProbeAvailable() guard inside the SkipProbe check so that legacy stream requests (which pass SkipProbe: true) are not blocked when ffprobe is missing. The guard only applies when probing is actually required (i.e., getTranscodeDecision endpoint). * refactor(stream): fall back to tag metadata when ffprobe is unavailable Instead of blocking getTranscodeDecision when ffprobe is missing, fall back to tag-based metadata (same behavior as /rest/stream). The transcoding extension is always advertised. A startup warning still alerts admins when ffprobe is not found. * fix(stream): downgrade ffprobe-unavailable log to Debug Avoids log spam when clients call getTranscodeDecision repeatedly without ffprobe installed. The startup warning in initial_setup.go already alerts admins at Warn level. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/ffmpeg/ffmpeg.go | 16 ++++++++++++++++ core/stream/decider.go | 12 ++++++++---- core/stream/decider_test.go | 1 + release/wix/build_msi.sh | 3 ++- release/wix/navidrome.wxs | 5 +++++ server/e2e/e2e_suite_test.go | 1 + server/initial_setup.go | 13 ++++++++----- tests/mock_ffmpeg.go | 7 ++++++- 8 files changed, 47 insertions(+), 11 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 33d6733c8..c034ca7d0 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -49,6 +49,7 @@ type FFmpeg interface { ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool + IsProbeAvailable() bool Version() string } @@ -224,6 +225,19 @@ func (e *ffmpeg) IsAvailable() bool { return err == nil } +func (e *ffmpeg) IsProbeAvailable() bool { + if _, err := ffmpegCmd(); err != nil { + return false + } + probeOnce.Do(func() { + probePath := ffprobePath(ffmpegPath) + if _, err := exec.LookPath(probePath); err == nil { + probeAvail = true + } + }) + return probeAvail +} + // Version executes ffmpeg -version and extracts the version from the output. // Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers func (e *ffmpeg) Version() string { @@ -533,4 +547,6 @@ var ( ffOnce sync.Once ffmpegPath string ffmpegErr error + probeOnce sync.Once + probeAvail bool ) diff --git a/core/stream/decider.go b/core/stream/decider.go index 6c1f06a06..713c779fe 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -44,10 +44,14 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, var probe *ffmpeg.AudioProbeResult if !opts.SkipProbe { - var err error - probe, err = s.ensureProbed(ctx, mf) - if err != nil { - return nil, err + if !s.ff.IsProbeAvailable() { + log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID) + } else { + var err error + probe, err = s.ensureProbed(ctx, mf) + if err != nil { + return nil, err + } } } diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 9eaa00990..c776cbdc3 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1164,6 +1164,7 @@ var _ = Describe("Decider", func() { Expect(bitrate).To(Equal(fallbackBitrate)) }) }) + }) Describe("ensureProbed", func() { diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh index 7e595311e..a8781a965 100755 --- a/release/wix/build_msi.sh +++ b/release/wix/build_msi.sh @@ -43,8 +43,9 @@ FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}" wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ "https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip" rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg" -unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" +unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" "*/ffprobe.exe" cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUTPUT_DIR" +cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffprobe.exe "$MSI_OUTPUT_DIR" cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR" cp "$BINARY" "$MSI_OUTPUT_DIR" diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 8ebba4632..6d94bab9d 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -67,6 +67,10 @@ <File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' /> </Component> + <Component Id='FFProbeExecutable' Guid='f8a3b2c1-5d4e-4f6a-9b8c-7e2d1a0f3c5b' Win64="$(var.Win64)"> + <File Id='ffprobe.exe' Name='ffprobe.exe' DiskId='1' Source='ffprobe.exe' KeyPath='yes' /> + </Component> + </Directory> </Directory> @@ -87,6 +91,7 @@ <ComponentRef Id='Configuration'/> <ComponentRef Id='MainExecutable' /> <ComponentRef Id='FFMpegExecutable' /> + <ComponentRef Id='FFProbeExecutable' /> <ComponentRef Id='PackageFile' /> </Feature> </Product> diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 262a5ed36..03fa9bbef 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -337,6 +337,7 @@ func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) ( func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } func (n noopFFmpeg) IsAvailable() bool { return false } +func (n noopFFmpeg) IsProbeAvailable() bool { return true } func (n noopFFmpeg) Version() string { return "noop" } // noopArchiver implements core.Archiver diff --git a/server/initial_setup.go b/server/initial_setup.go index d50f25958..7e974dc21 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -68,13 +68,16 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error { func checkFFmpegInstallation() { f := ffmpeg.New() _, err := f.CmdPath() - if err == nil { + if err != nil { + log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) + if conf.Server.Scanner.Extractor == "ffmpeg" { + log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") + conf.Server.Scanner.Extractor = "taglib" + } return } - log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) - if conf.Server.Scanner.Extractor == "ffmpeg" { - log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") - conf.Server.Scanner.Extractor = "taglib" + if !f.IsProbeAvailable() { + log.Warn("Unable to find ffprobe. Transcoding decisions will be limited") } } diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index 346209b71..f9862767e 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -12,7 +12,7 @@ import ( ) func NewMockFFmpeg(data string) *MockFFmpeg { - return &MockFFmpeg{Reader: strings.NewReader(data)} + return &MockFFmpeg{Reader: strings.NewReader(data), ProbeAvailable: true} } type MockFFmpeg struct { @@ -21,12 +21,17 @@ type MockFFmpeg struct { closed atomic.Bool Error error ProbeAudioResult *ffmpeg.AudioProbeResult + ProbeAvailable bool } func (ff *MockFFmpeg) IsAvailable() bool { return true } +func (ff *MockFFmpeg) IsProbeAvailable() bool { + return ff.ProbeAvailable +} + func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) { if ff.Error != nil { return nil, ff.Error From 4570dec675f904aa0cecd09b2c842685b762527e Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 8 Apr 2026 13:13:56 -0400 Subject: [PATCH 168/596] fix(ui): refine image filters for playing and paused states in SquiddiesGlass Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/common/SongDatagrid.jsx | 1 - ui/src/themes/SquiddiesGlass.js | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index 5d2ae3ed1..d2c98bbe7 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -51,7 +51,6 @@ const useStyles = makeStyles({ borderRadius: '4px', flexShrink: 0, cursor: 'pointer', - filter: 'none !important', }, row: { cursor: 'pointer', diff --git a/ui/src/themes/SquiddiesGlass.js b/ui/src/themes/SquiddiesGlass.js index 5c3844074..880b0be20 100644 --- a/ui/src/themes/SquiddiesGlass.js +++ b/ui/src/themes/SquiddiesGlass.js @@ -208,11 +208,11 @@ export default { borderBottom: `1px solid ${colors.gray[300]}`, padding: '10px !important', color: `${colors.gray[100]} !important`, - '& img': { + '& img[alt="playing"], & img[alt="paused"]': { filter: - 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);', + 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%)', }, - '& img + span': { + '& img[alt="playing"] + span, & img[alt="paused"] + span': { color: colors.pink[500], }, }, From 9b0bfc606bc5978032702a259de8defd820e474d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 10 Apr 2026 19:29:20 -0400 Subject: [PATCH 169/596] fix(subsonic): always emit required `created` field on AlbumID3 (#5340) * fix(subsonic): always emit required `created` field on AlbumID3 Strict OpenSubsonic clients (e.g. Navic via dev.zt64.subsonic) reject search3/getAlbum/getAlbumList2 responses that omit the `created` field, which the spec marks as required. Navidrome was dropping it whenever the album's CreatedAt was zero. Root cause was threefold: 1. buildAlbumID3/childFromAlbum conditionally emitted `created`, so a zero CreatedAt became a missing JSON key. 2. ToAlbum's `older()` helper treated a zero BirthTime as the minimum, so a single track with missing filesystem birth time could poison the album aggregation. 3. phase_1_folders' CopyAttributes copied `created_at` from the previous album row unconditionally, propagating an already-zero value forward on every metadata-driven album ID change. Since sql_base_repository drops `created_at` on UPDATE, a poisoned row could never self-heal. Fixes: - Always emit `created`, falling back to UpdatedAt/ImportedAt when CreatedAt is zero. Adds albumCreatedAt() helper used by both buildAlbumID3 and childFromAlbum. - Guard `older()` against a zero second argument. - Skip the CopyAttributes call in phase_1_folders when the previous album's created_at is zero, so the freshly-computed value survives. - New migration backfills existing broken rows from media_file.birth_time (falling back to updated_at). Tested against a real DB: repaired 605/6922 affected rows, no side effects on healthy rows. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): return albumCreatedAt by value to avoid heap escape Returning *time.Time from albumCreatedAt caused Go escape analysis to move the entire model.Album parameter to the heap, since the returned pointer aliased a field of the value receiver. For hot endpoints like getAlbumList2 and search3, this meant one full-struct heap allocation per album result. Return time.Time by value and let callers wrap it with gg.P() to take the address locally. Only the small time.Time value escapes; the model.Album struct stays on the stack. Also corrects the doc comment to reflect the actual guarantee ("best-effort" rather than "non-zero"), matching the test case that exercises the all-zero fallback. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- ...260410201914_fix_zero_album_created_at.sql | 22 +++++++++++++ model/mediafile.go | 3 ++ model/mediafile_test.go | 14 ++++++++ persistence/album_repository.go | 12 ++++++- persistence/album_repository_test.go | 26 +++++++++++++++ server/subsonic/helpers.go | 22 ++++++++++--- server/subsonic/helpers_test.go | 32 +++++++++++++++++++ 7 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 db/migrations/20260410201914_fix_zero_album_created_at.sql diff --git a/db/migrations/20260410201914_fix_zero_album_created_at.sql b/db/migrations/20260410201914_fix_zero_album_created_at.sql new file mode 100644 index 000000000..ff47eb95f --- /dev/null +++ b/db/migrations/20260410201914_fix_zero_album_created_at.sql @@ -0,0 +1,22 @@ +-- +goose Up + +-- Backfill album.created_at for rows poisoned by early scanner versions or +-- propagated via CopyAttributes during metadata-driven ID changes. Prefer the +-- oldest valid birth_time from the album's media files, fall back to updated_at. +UPDATE album +SET created_at = COALESCE( + (SELECT MIN(birth_time) + FROM media_file + WHERE media_file.album_id = album.id + AND birth_time IS NOT NULL + AND birth_time != '' + AND birth_time NOT LIKE '0001-%'), + updated_at +) +WHERE created_at IS NULL + OR created_at = '' + OR created_at LIKE '0001-%'; + +-- +goose Down + +SELECT 1; diff --git a/model/mediafile.go b/model/mediafile.go index ec83b76fd..6be8402ae 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -361,6 +361,9 @@ func older(t1, t2 time.Time) time.Time { if t1.IsZero() { return t2 } + if t2.IsZero() { + return t1 + } if t1.After(t2) { return t2 } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 038ac93d5..8b0c13da2 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -119,6 +119,20 @@ var _ = Describe("MediaFiles", func() { Expect(a.MinYear).To(Equal(1999)) }) }) + Context("CreatedAt aggregation", func() { + It("ignores zero BirthTime values when computing the oldest", func() { + mfs = MediaFiles{ + {BirthTime: t("2022-12-19 08:30")}, + {BirthTime: time.Time{}}, + {BirthTime: t("2022-12-18 10:00")}, + } + Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00"))) + }) + It("returns zero when all BirthTime values are zero", func() { + mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}} + Expect(mfs.ToAlbum().CreatedAt).To(BeZero()) + }) + }) }) When("we have multiple songs with same dates", func() { BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index c51a5beb1..99ed10877 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -252,7 +252,17 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) } to := make(map[string]any) for _, col := range columns { - to[col] = from[col] + v := from[col] + // created_at is aggregated from song birth_times and must never be + // overwritten with a zero/poisoned value, or it propagates forward on + // every metadata-driven album ID change. + if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) { + continue + } + to[col] = v + } + if len(to) == 0 { + return nil } _, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) return err diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 2792cec97..a6270933f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -41,6 +41,32 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("CopyAttributes", func() { + var srcTime, dstTime time.Time + BeforeEach(func() { + srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC) + Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}})) + }) + }) + It("copies a valid created_at from source to destination", func() { + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second)) + }) + It("leaves destination untouched when source created_at is zero", func() { + Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e930aa630..ffa10898e 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -10,6 +10,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -317,6 +319,20 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } +// albumCreatedAt returns a best-effort timestamp for the album's `created` +// field, which is required by the OpenSubsonic spec but may be zero on legacy +// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all +// three are unset. +func albumCreatedAt(al model.Album) time.Time { + if !al.CreatedAt.IsZero() { + return al.CreatedAt + } + if !al.UpdatedAt.IsZero() { + return al.UpdatedAt + } + return al.ImportedAt +} + func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child := responses.Child{} child.Id = al.ID @@ -329,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() - child.Created = &al.CreatedAt + child.Created = P(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -421,9 +437,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - if !album.CreatedAt.IsZero() { - dir.Created = &album.CreatedAt - } + dir.Created = P(albumCreatedAt(album)) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 4eb756b98..abf6116f3 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -571,6 +571,38 @@ var _ = Describe("helpers", func() { }) }) + Describe("buildAlbumID3 Created field", func() { + It("uses CreatedAt when set", func() { + t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a1", Name: "A", CreatedAt: t} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + Expect(*dir.Created).To(Equal(t)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + Expect(*dir.Created).To(Equal(updated)) + }) + + It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + Expect(*dir.Created).To(Equal(imported)) + }) + + It("never leaves Created nil even when all timestamps are zero", func() { + al := model.Album{ID: "a4", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + }) + }) + Describe("EnableAverageRating config", func() { It("excludes averageRating when disabled", func() { conf.Server.Subsonic.EnableAverageRating = false From ab2f1b45de4fd11fa674168b2945040ef0bd0a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 10 Apr 2026 21:59:49 -0400 Subject: [PATCH 170/596] perf: reduce hot-path heap escapes from value-param pointer aliasing (#5342) * perf(subsonic): keep album/mediafile params on stack in response helpers Two helpers were forcing their entire value parameter onto the heap via pointer-to-field aliasing, adding one full-struct heap allocation per response item on hot Subsonic endpoints (search3, getAlbumList2, etc.). - childFromMediaFile assigned &mf.BirthTime to the returned Child, pulling the whole ~1KB model.MediaFile to the heap on every call. - buildDiscSubtitles passed &a.UpdatedAt to NewArtworkID inside a loop, pulling the whole model.Album to the heap on every album with discs. Both now copy the time.Time to a stack-local and use gg.P / &local so only the small time.Time escapes. Verified via go build -gcflags=-m=2: moved to heap: mf and moved to heap: a are gone at these sites. * perf(metadata): avoid per-track closure allocations in PID computation createGetPID was a factory that returned nested closures capturing mf model.MediaFile (~992 bytes) by reference. Since it is called three times per track during scans (trackPID, albumID, artistID), every track triggered the allocation of three closures plus a heap copy of the full MediaFile. Refactor the body into package-level functions (computePID, getPIDAttr) that take hash as an explicit parameter and the inner slice.Map callback to an indexed for loop, removing the closure-capture of mf entirely. trackPID/albumID/artistID now call computePID directly. The tiny createGetPID wrapper was kept only for tests; move the closure-building into the test file so production has no dead API. Verified via go build -gcflags=-m=2 on model/metadata: no "moved to heap: mf" anywhere in persistent_ids.go, and the callers in map_mediafile.go / map_participants.go no longer heap-promote their MediaFile argument. --- model/metadata/persistent_ids.go | 115 +++++++++++++------------- model/metadata/persistent_ids_test.go | 11 +-- server/subsonic/helpers.go | 7 +- 3 files changed, 67 insertions(+), 66 deletions(-) diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index 70dfe0532..db315dc6b 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -12,88 +12,85 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) type hashFunc = func(...string) string -// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata -// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes -// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc. -// For each field, it gets all its attributes values and concatenates them, then hashes the result. -// If a field is empty, it is skipped and the function looks for the next field. -type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string - -func createGetPID(hash hashFunc) getPIDFunc { - var getPID getPIDFunc - getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string { - attr = strings.TrimSpace(strings.ToLower(attr)) - switch attr { - case "albumid": - if spec == conf.Server.PID.Album { - log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) - return "" +// computePID calculates the persistent ID for a given spec. The spec is a +// pipe-separated list of fields, where each field is a comma-separated list of +// attributes. Attributes can be either tags or processed values like folder, +// albumid, albumartistid, etc. For each field, it gets all its attribute values +// and concatenates them, then hashes the result. If a field is empty, it is +// skipped and the function looks for the next field. +// +// Taking hash as a parameter (instead of closing over it in a factory) keeps +// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the +// heap on every call. +func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { + switch spec { + case "track_legacy": + return legacyTrackID(mf, prependLibId) + case "album_legacy": + return legacyAlbumID(mf, md, prependLibId) + } + pid := "" + fields := strings.SplitSeq(spec, "|") + for field := range fields { + attributes := strings.Split(field, ",") + values := make([]string, len(attributes)) + hasValue := false + for i, attr := range attributes { + v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) + if v != "" { + hasValue = true } - return getPID(mf, md, conf.Server.PID.Album, prependLibId) - case "folder": - return filepath.Dir(mf.Path) - case "albumartistid": - return hash(str.Clear(strings.ToLower(mf.AlbumArtist))) - case "title": - return mf.Title - case "album": - return str.Clear(strings.ToLower(md.String(model.TagAlbum))) + values[i] = v + } + if hasValue { + pid += strings.Join(values, "\\") + break } - return md.String(model.TagName(attr)) } - getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - pid := "" - fields := strings.SplitSeq(spec, "|") - for field := range fields { - attributes := strings.Split(field, ",") - hasValue := false - values := slice.Map(attributes, func(attr string) string { - v := getAttr(mf, md, attr, prependLibId, spec) - if v != "" { - hasValue = true - } - return v - }) - if hasValue { - pid += strings.Join(values, "\\") - break - } - } - if prependLibId { - pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) - } - return hash(pid) + if prependLibId { + pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) } + return hash(pid) +} - return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - switch spec { - case "track_legacy": - return legacyTrackID(mf, prependLibId) - case "album_legacy": - return legacyAlbumID(mf, md, prependLibId) +func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { + attr = strings.TrimSpace(strings.ToLower(attr)) + switch attr { + case "albumid": + if spec == conf.Server.PID.Album { + log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) + return "" } - return getPID(mf, md, spec, prependLibId) + return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) + case "folder": + return filepath.Dir(mf.Path) + case "albumartistid": + return hash(str.Clear(strings.ToLower(mf.AlbumArtist))) + case "title": + return mf.Title + case "album": + return str.Clear(strings.ToLower(md.String(model.TagAlbum))) } + return md.String(model.TagName(attr)) } func (md Metadata) trackPID(mf model.MediaFile) string { - return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true) + return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) } func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return createGetPID(id.NewHash)(mf, md, pidConf, true) + return computePID(mf, md, pidConf, true, id.NewHash) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return createGetPID(id.NewHash)(mf, md, "albumartistid", false) + return computePID(mf, md, "albumartistid", false, id.NewHash) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 9f1dacbd4..47f5ca63f 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -12,15 +12,16 @@ import ( var _ = Describe("getPID", func() { var ( - md Metadata - mf model.MediaFile - sum hashFunc - getPID getPIDFunc + md Metadata + mf model.MediaFile + sum hashFunc ) + getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { + return computePID(mf, md, spec, prependLibId, sum) + } BeforeEach(func() { sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } - getPID = createGetPID(sum) }) Context("attributes are tags", func() { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index ffa10898e..74d57ade4 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -217,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = &mf.BirthTime + child.Created = P(mf.BirthTime) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -407,9 +407,12 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle { return nil } var discTitles []responses.DiscTitle + // Hoist UpdatedAt to a single stack-local so &updatedAt doesn't force the + // whole model.Album parameter onto the heap. + updatedAt := a.UpdatedAt for num, title := range a.Discs { artID := model.NewArtworkID(model.KindDiscArtwork, - model.DiscArtworkID(a.ID, num), &a.UpdatedAt) + model.DiscArtworkID(a.ID, num), &updatedAt) discTitles = append(discTitles, responses.DiscTitle{ Disc: int32(num), Title: title, From 1f3a7efa759c464455af789f9937938dec402038 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 11 Apr 2026 21:14:52 -0400 Subject: [PATCH 171/596] fix(backup): surface real SQLite error when backup step fails The error-check ordering after backupOp.Step(-1) checked !done before err, which masked the underlying SQLite error (e.g. SQLITE_BUSY, I/O errors) with a generic "backup not done with step -1" message. On failure, Step returns done=false together with a non-nil err, so the !done branch short-circuited before the real error was ever reported. Swap the checks so the SQLite error is returned first, making failing backups actually diagnosable. Refs https://github.com/navidrome/navidrome/issues/5305#issuecomment-4230470593 --- db/backup.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/backup.go b/db/backup.go index 8b0f18b1b..a34255d7e 100644 --- a/db/backup.go +++ b/db/backup.go @@ -81,12 +81,12 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error { // Caution: -1 means that sqlite will hold a read lock until the operation finishes // This will lock out other writes that could happen at the same time done, err := backupOp.Step(-1) - if !done { - return fmt.Errorf("backup not done with step -1") - } if err != nil { return fmt.Errorf("error during backup step: %w", err) } + if !done { + return fmt.Errorf("backup not done with step -1") + } err = backupOp.Finish() if err != nil { From de6475bb497bfbda4f1dc945efe203963839b2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 11 Apr 2026 21:19:57 -0400 Subject: [PATCH 172/596] fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path. --- core/artwork/reader_disc.go | 102 ++++++++-------- core/artwork/reader_disc_test.go | 194 +++++++++++++++++++++++++++++-- 2 files changed, 233 insertions(+), 63 deletions(-) diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 7548f76d2..5a7a8a65e 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -168,47 +168,38 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin } } -// extractDiscNumber extracts a disc number from a filename based on a glob pattern. -// It finds the portion of the filename that the wildcard matched and parses leading -// digits as the disc number. Returns (0, false) if the pattern doesn't match or -// no leading digits are found in the wildcard portion. +// globMetaChars holds the substitution metacharacters understood by +// filepath.Match. The '\' escape character is intentionally excluded: +// disc art patterns come from user config and never include escaped +// metachars in practice, and treating '\' as a metachar would misalign +// the literal-prefix extraction in extractDiscNumber. +const globMetaChars = "*?[" + +// extractDiscNumber parses the disc number from a filename matched by a +// filepath.Match-style glob pattern. +// +// Both pattern and filename must already be lowercased by the caller, which +// is also expected to have verified that filepath.Match(pattern, filename) +// is true before calling this function. func extractDiscNumber(pattern, filename string) (int, bool) { - filename = strings.ToLower(filename) - pattern = strings.ToLower(pattern) - - matched, err := filepath.Match(pattern, filename) - if err != nil || !matched { + metaIdx := strings.IndexAny(pattern, globMetaChars) + if metaIdx < 0 { return 0, false } - - // Find the prefix before the first '*' in the pattern - starIdx := strings.IndexByte(pattern, '*') - if starIdx < 0 { - return 0, false - } - prefix := pattern[:starIdx] - - // Strip the prefix from the filename to get the wildcard-matched portion + prefix := pattern[:metaIdx] if !strings.HasPrefix(filename, prefix) { return 0, false } - remainder := filename[len(prefix):] - // Extract leading ASCII digits from the remainder - var digits []byte - for _, r := range remainder { - if r >= '0' && r <= '9' { - digits = append(digits, byte(r)) - } else { - break - } + start := len(prefix) + end := start + for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' { + end++ } - - if len(digits) == 0 { + if end == start { return 0, false } - - num, err := strconv.Atoi(string(digits)) + num, err := strconv.Atoi(filename[start:end]) if err != nil { return 0, false } @@ -216,20 +207,16 @@ func extractDiscNumber(pattern, filename string) (int, bool) { } // fromExternalFile returns a sourceFunc that matches image files against a glob -// pattern with disc-number-aware filtering. -// -// Matching rules: -// - If a disc number can be extracted from the filename, the file matches only if -// the number equals the target disc number. -// - If no number is found and this is a multi-folder album, the file matches if -// it's in a folder containing tracks for this disc. -// - If no number is found and this is a single-folder album, the file is skipped -// (ambiguous). +// pattern. A numbered filename whose number equals the target disc wins over +// any unnumbered candidate; callers must pass a lowercase pattern. func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { + isLiteral := !strings.ContainsAny(pattern, globMetaChars) return func() (io.ReadCloser, string, error) { + var fallbacks []string for _, file := range d.imgFiles { _, name := filepath.Split(file) - match, err := filepath.Match(pattern, strings.ToLower(name)) + name = strings.ToLower(name) + match, err := filepath.Match(pattern, name) if err != nil { log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) continue @@ -238,24 +225,27 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string continue } - // Try to extract disc number from filename - num, hasNum := extractDiscNumber(pattern, name) - if hasNum { - // File has a disc number — must match target disc - if num != d.discNumber { - continue + if !isLiteral { + if num, hasNum := extractDiscNumber(pattern, name); hasNum { + if num != d.discNumber { + continue + } + f, err := os.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil } - } else if d.isMultiFolder { - // No number, multi-folder: match by folder association - dir := filepath.Dir(file) - if !d.discFolders[dir] { - continue - } - } else { - // No number, single-folder: ambiguous, skip - continue } + if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] { + continue + } + fallbacks = append(fallbacks, file) + } + + for _, file := range fallbacks { f, err := os.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index f8193e24e..7b633342f 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -42,11 +42,24 @@ var _ = Describe("Disc Artwork Reader", func() { // Case insensitive (filename already lowered by caller) Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true), - // Pattern doesn't match - Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false), + // HasPrefix guard: filename doesn't share the pattern's literal prefix + Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false), // Pattern with no wildcard before dot Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true), + + // '?' single-char wildcard + Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true), + Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true), + Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true), + + // '[...]' character class wildcard + Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true), + Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true), + Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true), + + // Literal pattern (no wildcard) returns false + Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false), ) }) @@ -85,19 +98,186 @@ var _ = Describe("Disc Artwork Reader", func() { Expect(path).To(Equal(f1)) }) - It("skips file without number in single-folder album", func() { - f1 := createFile("album/disc.jpg") + It("matches file without number in single-folder album (shared disc art)", func() { + f1 := createFile("album/cover.png") reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, } - sf := reader.fromExternalFile(ctx, "disc*.*") - r, _, _ := sf() - Expect(r).To(BeNil()) + sf := reader.fromExternalFile(ctx, "cover.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) }) + It("returns shared disc art for every disc number in single-folder album", func() { + f1 := createFile("album/shellac.png") + makeReader := func(discNum int) *discArtworkReader { + return &discArtworkReader{ + discNumber: discNum, + imgFiles: []string{f1}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + } + + for _, disc := range []int{1, 2, 5} { + sf := makeReader(disc).fromExternalFile(ctx, "shellac.png") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred(), "disc %d", disc) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1), "disc %d", disc) + } + }) + + It("numbered and unnumbered patterns both resolve against the same reader", func() { + f1 := createFile("album/cover.png") + f2 := createFile("album/disc1.jpg") + f3 := createFile("album/disc2.jpg") + reader := &discArtworkReader{ + discNumber: 2, + imgFiles: []string{f1, f2, f3}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f3)) + + sf = reader.fromExternalFile(ctx, "cover.*") + r, path, err = sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() { + f1 := createFile("album/cover.png") + f2 := createFile("album/disc1.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") + Expect(ff).To(HaveLen(2)) + r, path, err := ff[0]() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(f2)) + r.Close() + + ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*") + Expect(ff).To(HaveLen(2)) + r, path, err = ff[0]() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(f1)) + r.Close() + }) + + DescribeTable("numbered match wins over shared fallback within a pattern", + func(discNumber, expectedIdx int) { + files := []string{ + createFile("album/disc.jpg"), + createFile("album/disc1.jpg"), + createFile("album/disc2.jpg"), + } + reader := &discArtworkReader{ + discNumber: discNumber, + imgFiles: files, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(files[expectedIdx])) + }, + Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2), + Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0), + ) + + It("tries the next fallback candidate when the first one cannot be opened", func() { + f1 := createFile("album/cover.jpg") + f2 := createFile("album/cover.png") + // Remove f1 so os.Open will fail on it; f2 should still win. + Expect(os.Remove(f1)).To(Succeed()) + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, "cover.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f2)) + }) + + It("keeps scanning literal-pattern matches so fallback retry still works", func() { + // Guards against an 'early break on first literal match' optimization. + // Multiple imgFiles entries can share a basename (symlinks, case-variant + // duplicates on case-sensitive filesystems). If the loop breaks after + // recording just the first, the fallback retry cannot recover when + // that first file is unreadable. + f1 := createFile("album/stale/cover.png") + f2 := createFile("album/cover.png") + Expect(os.Remove(f1)).To(Succeed()) + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFolders: map[string]bool{ + filepath.Join(tmpDir, "album"): true, + filepath.Join(tmpDir, "album/stale"): true, + }, + isMultiFolder: true, + } + + sf := reader.fromExternalFile(ctx, "cover.png") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f2)) + }) + + DescribeTable("filters by disc number for non-'*' wildcard patterns", + func(pattern string, discNumber, expectedIdx int) { + files := []string{ + createFile("album/disc1.jpg"), + createFile("album/disc2.jpg"), + } + reader := &discArtworkReader{ + discNumber: discNumber, + imgFiles: files, + discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + } + + sf := reader.fromExternalFile(ctx, pattern) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(files[expectedIdx])) + }, + Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0), + Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1), + Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0), + Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1), + ) + It("matches file without number in multi-folder album by folder", func() { f1 := createFile("album/cd1/disc.jpg") f2 := createFile("album/cd2/disc.jpg") From 27209ed26a87dcc0eebd6dfd64fad94b3c3a073f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 11 Apr 2026 23:15:07 -0400 Subject: [PATCH 173/596] fix(transcoding): clamp target channels to codec limit (#5336) (#5345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(transcoding): clamp target channels to codec limit (#5336) When transcoding a multi-channel source (e.g. 6-channel FLAC) to MP3, the decider passed the source channel count through to ffmpeg unchanged. The default MP3 command path then emitted `-ac 6`, and the template path injected `-ac 6` after the template's own `-ac 2`, causing ffmpeg to honor the last occurrence and fail with exit code 234 since libmp3lame only supports up to 2 channels. Introduce `codecMaxChannels()` in core/stream/codec.go (mp3→2, opus→8), mirroring the existing `codecMaxSampleRate` pattern, and apply the clamp in `computeTranscodedStream` right after the sample-rate clamps. Also fix a pre-existing ordering bug where the profile's MaxAudioChannels check compared against src.Channels rather than ts.Channels, which would have let a looser profile setting raise the codec-clamped value back up. Comparing against the already-clamped ts.Channels makes profile limits strictly narrowing, which matches how the sample-rate block already behaves. The ffmpeg buildTemplateArgs comment is refreshed to point at the new upstream clamp, since the flags it injects are now always codec-safe. Adds unit tests for codecMaxChannels and four decider scenarios covering the literal issue repro (6-ch FLAC→MP3 clamps to 2), a stricter profile limit winning over the codec clamp, a looser profile limit leaving the codec clamp intact, and a codec with no hard limit (AAC) passing 6 channels through. * test(e2e): pin codec channel clamp at the Subsonic API surface (#5336) Add a 6-channel FLAC fixture to the e2e test suite and use it to assert the codec channel clamp end-to-end on both Subsonic streaming endpoints: - getTranscodeDecision (mp3OnlyClient, no MaxAudioChannels in profile): expects TranscodeStream.AudioChannels == 2 for the 6-channel source. This exercises the new codecMaxChannels() helper through the OpenSubsonic decision endpoint, with no profile-level channel limit masking the bug. - /rest/stream (legacy): requests format=mp3 against the multichannel fixture and asserts streamerSpy.LastRequest.Channels == 2, confirming the clamp propagates through ResolveRequest into the stream.Request that the streamer receives. The fixture is metadata-only (channels: 6 plumbed via the existing storagetest.File helper) — no real audio bytes required, since the e2e suite uses a spy streamer rather than invoking ffmpeg. Bumps the empty-query search3 song count expectation from 13 to 14 to account for the new fixture. * test(decider): clarify codec-clamp comment terminology Distinguish "transcoding profile MaxAudioChannels" (Profile.MaxAudioChannels field) from "LimitationAudioChannels" (CodecProfile rule constant). The regression test bypasses the former, not the latter. --- core/ffmpeg/ffmpeg.go | 5 +- core/stream/codec.go | 13 ++++++ core/stream/codec_test.go | 22 +++++++++ core/stream/decider.go | 9 +++- core/stream/decider_test.go | 67 +++++++++++++++++++++++++++ server/e2e/e2e_suite_test.go | 4 ++ server/e2e/subsonic_searching_test.go | 2 +- server/e2e/subsonic_stream_test.go | 14 +++++- server/e2e/subsonic_transcode_test.go | 29 +++++++++--- 9 files changed, 151 insertions(+), 14 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index c034ca7d0..5e6dcd115 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -412,8 +412,9 @@ func buildDynamicArgs(opts TranscodeOptions) []string { // buildTemplateArgs handles user-customized command templates, with dynamic injection // of sample rate, channels, and bit depth when requested by the transcode decision. -// Note: these flags are injected unconditionally when non-zero, even if the template -// already includes them. FFmpeg uses the last occurrence of duplicate flags. +// Values in opts have already been clamped to codec limits upstream (see +// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe — +// ffmpeg honors the last occurrence of a duplicate flag. func buildTemplateArgs(opts TranscodeOptions) []string { args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset) diff --git a/core/stream/codec.go b/core/stream/codec.go index 88d1ae45d..28bff75c4 100644 --- a/core/stream/codec.go +++ b/core/stream/codec.go @@ -75,3 +75,16 @@ func codecMaxSampleRate(codec string) int { } return 0 } + +// codecMaxChannels returns the hard maximum number of audio channels a codec +// supports. Returns 0 if the codec has no hard limit (or is unknown), in which +// case the source/profile constraints applied upstream are authoritative. +func codecMaxChannels(codec string) int { + switch strings.ToLower(codec) { + case "mp3": + return 2 + case "opus": + return 8 + } + return 0 +} diff --git a/core/stream/codec_test.go b/core/stream/codec_test.go index 4c76b3ecd..97e15bdb5 100644 --- a/core/stream/codec_test.go +++ b/core/stream/codec_test.go @@ -66,4 +66,26 @@ var _ = Describe("Codec", func() { Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) }) }) + + Describe("codecMaxChannels", func() { + It("returns 2 for mp3", func() { + Expect(codecMaxChannels("mp3")).To(Equal(2)) + }) + + It("returns 8 for opus", func() { + Expect(codecMaxChannels("opus")).To(Equal(8)) + }) + + It("is case-insensitive", func() { + Expect(codecMaxChannels("MP3")).To(Equal(2)) + Expect(codecMaxChannels("Opus")).To(Equal(8)) + }) + + It("returns 0 for codecs with no hard limit", func() { + Expect(codecMaxChannels("aac")).To(Equal(0)) + Expect(codecMaxChannels("flac")).To(Equal(0)) + Expect(codecMaxChannels("vorbis")).To(Equal(0)) + Expect(codecMaxChannels("")).To(Equal(0)) + }) + }) }) diff --git a/core/stream/decider.go b/core/stream/decider.go index 713c779fe..cde12f0f3 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -294,14 +294,19 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate { ts.SampleRate = maxRate } + if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh { + ts.Channels = maxCh + } // Determine target bitrate (all in kbps) if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok { return nil, "" } - // Apply MaxAudioChannels from the transcoding profile - if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { + // Apply MaxAudioChannels from the transcoding profile. Compare against the + // already-clamped ts.Channels (not src.Channels) so the codec hard limit + // applied above is never raised by a looser profile setting. + if profile.MaxAudioChannels > 0 && ts.Channels > profile.MaxAudioChannels { ts.Channels = profile.MaxAudioChannels } diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index c776cbdc3..8b58f3323 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -770,6 +770,73 @@ var _ = Describe("Decider", func() { }) }) + Context("Codec channel limits", func() { + It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", func() { + // Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels. + // The decider must clamp to the codec's hard limit even when no + // transcoding profile MaxAudioChannels is configured. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TranscodeStream.Channels).To(Equal(2)) + Expect(decision.TargetChannels).To(Equal(2)) + }) + + It("honors a stricter profile MaxAudioChannels over the codec clamp", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Channels).To(Equal(1)) + Expect(decision.TargetChannels).To(Equal(1)) + }) + + It("applies the codec clamp when the profile limit is looser", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Channels).To(Equal(2)) + Expect(decision.TargetChannels).To(Equal(2)) + }) + + It("passes channels through unchanged for codecs with no hard limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("aac")) + Expect(decision.TranscodeStream.Channels).To(Equal(6)) + Expect(decision.TargetChannels).To(Equal(6)) + }) + }) + Context("Probe-based lossless detection", func() { It("uses probe codec name for lossless detection", func() { // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv" diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 03fa9bbef..5b3500f7a 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -172,6 +172,10 @@ func buildTestFS() storagetest.FakeFS { "title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus", "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220), }), + "Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{ + "title": "TC FLAC Multichannel", "track": 7, "suffix": "flac", + "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), + }), // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index 7f6aaf57a..e348bc6b9 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -117,7 +117,7 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.SearchResult3).ToNot(BeNil()) Expect(resp.SearchResult3.Artist).To(HaveLen(6)) Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(13)) + Expect(resp.SearchResult3.Song).To(HaveLen(14)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go index 6a11c1740..281524636 100644 --- a/server/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -13,8 +13,9 @@ import ( var _ = Describe("stream.view (legacy streaming)", Ordered, func() { var ( - mp3TrackID string // Come Together (mp3, 320kbps) - flacTrackID string // TC FLAC Standard (flac, 900kbps) + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch) ) BeforeAll(func() { @@ -30,6 +31,8 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { Expect(mp3TrackID).ToNot(BeEmpty()) flacTrackID = byTitle["TC FLAC Standard"] Expect(flacTrackID).ToNot(BeEmpty()) + flacMultichTrackID = byTitle["TC FLAC Multichannel"] + Expect(flacMultichTrackID).ToNot(BeEmpty()) }) Describe("raw / direct play", func() { @@ -101,6 +104,13 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) + + It("clamps multichannel FLAC to 2 channels when transcoding to mp3 (#5336)", func() { + w := doRawReq("stream", "id", flacMultichTrackID, "format", "mp3", "maxBitRate", "256") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.Channels).To(Equal(2)) + }) }) Describe("downsampling with maxBitRate only", func() { diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index f134448df..6041cd013 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -114,13 +114,14 @@ const ( var _ = Describe("Transcode Endpoints", Ordered, func() { // Track IDs resolved in BeforeAll var ( - mp3TrackID string // Come Together (mp3, 320kbps) - flacTrackID string // TC FLAC Standard (flac, 900kbps) - flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps) - alacTrackID string // TC ALAC Track (m4a, alac) - dsdTrackID string // TC DSD Track (dsf, dsd) - opusTrackID string // TC Opus Track (opus, 128kbps) - mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag) + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps) + flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch) + alacTrackID string // TC ALAC Track (m4a, alac) + dsdTrackID string // TC DSD Track (dsf, dsd) + opusTrackID string // TC Opus Track (opus, 128kbps) + mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag) ) BeforeAll(func() { @@ -140,6 +141,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { mp3TrackID = ensureGetTrackID("Come Together") flacTrackID = ensureGetTrackID("TC FLAC Standard") flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes") + flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel") alacTrackID = ensureGetTrackID("TC ALAC Track") dsdTrackID = ensureGetTrackID("TC DSD Track") opusTrackID = ensureGetTrackID("TC Opus Track") @@ -353,6 +355,19 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) + + It("clamps multichannel FLAC to 2 channels when transcoding to MP3 (#5336)", func() { + // mp3OnlyClient has no MaxAudioChannels set, so this exercises the + // codec-intrinsic clamp in core/stream/codec.go (codecMaxChannels). + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacMultichTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.SourceStream.AudioChannels).To(Equal(int32(6))) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3")) + Expect(resp.TranscodeDecision.TranscodeStream.AudioChannels).To(Equal(int32(2))) + }) }) Describe("response structure", func() { From 501c6eaf8fe8458df4fdd6959a71e4415d1aa22e Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 11 Apr 2026 23:23:04 -0400 Subject: [PATCH 174/596] refactor(ffmpeg): consolidate dynamic audio flag injection into a single function Signed-off-by: Deluan <deluan@navidrome.org> --- core/ffmpeg/ffmpeg.go | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 5e6dcd115..abeda5c9e 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -387,18 +387,7 @@ func buildDynamicArgs(opts TranscodeOptions) []string { if opts.BitRate > 0 { args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k") } - if opts.SampleRate > 0 { - args = append(args, "-ar", strconv.Itoa(opts.SampleRate)) - } - if opts.Channels > 0 { - args = append(args, "-ac", strconv.Itoa(opts.Channels)) - } - // Only pass -sample_fmt for lossless output formats where bit depth matters. - // Lossy codecs (mp3, aac, opus) handle sample format conversion internally, - // and passing interleaved formats like "s16" causes silent failures. - if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { - args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) - } + args = injectDynamicAudioFlags(args, opts) args = append(args, "-v", "0") @@ -417,8 +406,14 @@ func buildDynamicArgs(opts TranscodeOptions) []string { // ffmpeg honors the last occurrence of a duplicate flag. func buildTemplateArgs(opts TranscodeOptions) []string { args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset) + return injectDynamicAudioFlags(args, opts) +} - // Dynamically inject -ar, -ac, and -sample_fmt before the output target +// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts. +// Only passes -sample_fmt for lossless output formats where bit depth matters: +// lossy codecs (mp3, aac, opus) handle sample format conversion internally, and +// passing interleaved formats like "s16" causes silent failures. +func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string { if opts.SampleRate > 0 { args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate)) } From 85e9982b434f27604f01817f45de006cddd18376 Mon Sep 17 00:00:00 2001 From: Jorge Pardo Pardo <78924065+J0R6IT0@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:27:58 +0200 Subject: [PATCH 175/596] feat(plugins): add path to Scrobbler and Lyrics plugin TrackInfo (#5339) * feat: add Path to TrackInfo struct * refactor: improve naming to follow the rest of the code * test: add tests * fix: actually check for filesystem permission * refactor: remove library logic from specific plugins * refactor: move hasFilesystemPermission to a Manifest method * test(plugins): add unit tests for hasLibraryFilesystemAccess method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): remove hasFilesystemPerm field and use manifest for filesystem permission checks Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): streamline library filesystem access checks in lyrics and scrobbler adapters Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org> --- plugins/capabilities/lyrics.yaml | 5 +++ plugins/capabilities/scrobbler.go | 3 ++ plugins/capabilities/scrobbler.yaml | 5 +++ plugins/lyrics_adapter.go | 2 +- plugins/manager_loader.go | 3 +- plugins/manager_plugin.go | 28 +++++++++++++++ plugins/manager_plugin_test.go | 34 +++++++++++++++++++ plugins/manifest.go | 7 ++++ plugins/pdk/go/lyrics/lyrics.go | 3 ++ plugins/pdk/go/lyrics/lyrics_stub.go | 3 ++ plugins/pdk/go/scrobbler/scrobbler.go | 3 ++ plugins/pdk/go/scrobbler/scrobbler_stub.go | 3 ++ .../rust/nd-pdk-capabilities/src/lyrics.rs | 4 +++ .../rust/nd-pdk-capabilities/src/scrobbler.rs | 4 +++ plugins/scrobbler_adapter.go | 16 ++++++--- plugins/scrobbler_adapter_test.go | 34 +++++++++++++++++++ 16 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 plugins/manager_plugin_test.go diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index e4f88476c..4ac907559 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -102,6 +102,11 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + path: + type: string + description: |- + Path is the full path to the track file, relative to the library root. + Only included if the plugin has library permission with filesystem access for the track's library. required: - id - title diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 8091efe50..34cf60015 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // NowPlayingRequest is the request for now playing notification. diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 5de351a5f..f62da1745 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -128,6 +128,11 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + path: + type: string + description: |- + Path is the full path to the track file, relative to the library root. + Only included if the plugin has library permission with filesystem access for the track's library. required: - id - title diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index aa9930664..43ebc0e4b 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -31,7 +31,7 @@ type LyricsPlugin struct { // using model.ToLyrics. func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { req := capabilities.GetLyricsRequest{ - Track: mediaFileToTrackInfo(mf), + Track: mediaFileToTrackInfo(l.plugin, mf), } resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse]( ctx, l.plugin, FuncLyricsGetLyrics, req, diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 59f48453f..ccda9e4cb 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -301,7 +301,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } // Configure filesystem access for library permission - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem { + if pkg.Manifest.HasLibraryFilesystemPermission() { adminCtx := adminContext(ctx) libraries, err := m.ds.Library(adminCtx).GetAll() if err != nil { @@ -384,6 +384,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { metrics: m.metrics, allowedUserIDs: allowedUsers, allUsers: p.AllUsers, + libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), } m.mu.Unlock() diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 08c0073b6..1d4a8c301 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -21,6 +21,7 @@ type plugin struct { metrics PluginMetricsRecorder allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users + libraries libraryAccess } // instance creates a new plugin instance for the given context. @@ -47,3 +48,30 @@ func (p *plugin) Close() error { } return errors.Join(errs...) } + +func (p *plugin) hasLibraryFilesystemAccess(libID int) bool { + return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID) +} + +// libraryAccess captures the set of libraries a plugin is permitted to see, +// precomputed at load time for O(1) lookup. +type libraryAccess struct { + allLibraries bool + libraryIDSet map[int]struct{} +} + +func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess { + set := make(map[int]struct{}, len(allowedLibraryIDs)) + for _, id := range allowedLibraryIDs { + set[id] = struct{}{} + } + return libraryAccess{allLibraries: allLibraries, libraryIDSet: set} +} + +func (a libraryAccess) contains(libID int) bool { + if a.allLibraries { + return true + } + _, ok := a.libraryIDSet[libID] + return ok +} diff --git a/plugins/manager_plugin_test.go b/plugins/manager_plugin_test.go new file mode 100644 index 000000000..513b8cb8e --- /dev/null +++ b/plugins/manager_plugin_test.go @@ -0,0 +1,34 @@ +package plugins + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("plugin", func() { + Describe("hasLibraryFilesystemAccess", func() { + fsManifest := &Manifest{ + Permissions: &Permissions{ + Library: &LibraryPermission{Filesystem: true}, + }, + } + + It("returns false when the manifest does not grant filesystem permission", func() { + p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess(nil, true)} + Expect(p.hasLibraryFilesystemAccess(1)).To(BeFalse()) + }) + + It("returns true for any library when allLibraries is set", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess(nil, true)} + Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue()) + Expect(p.hasLibraryFilesystemAccess(42)).To(BeTrue()) + }) + + It("returns true only for libraries in the allowed list", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1, 3}, false)} + Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue()) + Expect(p.hasLibraryFilesystemAccess(3)).To(BeTrue()) + Expect(p.hasLibraryFilesystemAccess(2)).To(BeFalse()) + }) + }) +}) diff --git a/plugins/manifest.go b/plugins/manifest.go index 375e73e7f..7484718e3 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -86,3 +86,10 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { func (m *Manifest) HasExperimentalThreads() bool { return m.Experimental != nil && m.Experimental.Threads != nil } + +// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. +func (m *Manifest) HasLibraryFilesystemPermission() bool { + return m.Permissions != nil && + m.Permissions.Library != nil && + m.Permissions.Library.Filesystem +} diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 4f5aa6302..188371fee 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Lyrics requires all methods to be implemented. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 1fdf184e5..91eec4997 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -65,6 +65,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Lyrics requires all methods to be implemented. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index c694f59d8..e16bfed4b 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -92,6 +92,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Scrobbler requires all methods to be implemented. diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 6d4afd818..86a71af03 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -89,6 +89,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Scrobbler requires all methods to be implemented. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 16882abae..fcfe553f8 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -102,6 +102,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// Path is the full path to the track file, relative to the library root. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, } /// Error represents an error from a capability method. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 2572712d1..dd42e6803 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -122,6 +122,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// Path is the full path to the track file, relative to the library root. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, } /// Error represents an error from a capability method. diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 874c6603a..4f7cd4661 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -80,7 +80,7 @@ func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track * username := getUsernameFromContext(ctx) input := capabilities.NowPlayingRequest{ Username: username, - Track: mediaFileToTrackInfo(track), + Track: mediaFileToTrackInfo(s.plugin, track), Position: int32(position), } @@ -93,7 +93,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb username := getUsernameFromContext(ctx) input := capabilities.ScrobbleRequest{ Username: username, - Track: mediaFileToTrackInfo(&sc.MediaFile), + Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile), Timestamp: sc.TimeStamp.Unix(), } @@ -109,9 +109,11 @@ func getUsernameFromContext(ctx context.Context) string { return "" } -// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo -func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo { - return capabilities.TrackInfo{ +// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo. +// Path is populated only when the plugin is allowed filesystem access to the +// track's library. +func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo { + ti := capabilities.TrackInfo{ ID: mf.ID, Title: mf.Title, Album: mf.Album, @@ -127,6 +129,10 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo { MBZReleaseGroupID: mf.MbzReleaseGroupID, MBZReleaseTrackID: mf.MbzReleaseTrackID, } + if p.hasLibraryFilesystemAccess(mf.LibraryID) { + ti.Path = mf.Path + } + return ti } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index ab8dc6f88..0ee229022 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -240,6 +240,40 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { Expect(names).ToNot(ContainElement("test-metadata-agent")) }) }) + + Describe("mediaFileToTrackInfo", func() { + var track *model.MediaFile + + BeforeEach(func() { + track = &model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Path: "/music/test.flac", + LibraryID: 1, + } + }) + + fsManifest := &Manifest{ + Permissions: &Permissions{ + Library: &LibraryPermission{Filesystem: true}, + }, + } + + It("includes Path when the plugin has filesystem access to the track's library", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)} + Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac")) + }) + + It("omits Path when the plugin lacks filesystem permission", func() { + p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)} + Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + }) + + It("omits Path when the track's library is not in the allowed set", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)} + Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + }) + }) }) var _ = Describe("mapScrobblerError", func() { From c49e5855b9d6651510cbf6d018f9027ae8793da4 Mon Sep 17 00:00:00 2001 From: m8tec <38794725+m8tec@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:16:00 +0200 Subject: [PATCH 176/596] feat(artwork): make max image upload size configurable (#5335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): make max image upload size configurable Let max image upload size be set from config or environment instead of a fixed 10 MB cap. The upload handler still falls back to 10 MB when MaxImageUploadSize is not set. Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com> * feat(config): support human-readable MaxImageUploadSize values Max image upload size can now be configured as a readable string like 10MB or 1GB instead of raw bytes. The config load validates it at startup, and the upload handler parses it before applying request limits (10MB fallback if it fails). + MaxImageUploadSize as human-readable string + removed redundant max(1, ...) to address code review + cap memory usage of ParseMultipartForm to 10MB (address code review) Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com> * refactor(config): consolidate MaxImageUploadSize default and add tests Move the "10MB" default constant to consts.DefaultMaxImageUploadSize so both the viper default and the runtime fallback share a single source of truth. Improve the validator error message with fmt.Errorf wrapping to match the project convention (e.g. validatePurgeMissingOption). Add unit tests for validateMaxImageUploadSize (valid/invalid inputs) and maxImageUploadSize (configured, empty, invalid, raw bytes). Compute maxImageSize once at handler creation rather than per request. --------- Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- conf/configuration.go | 13 ++++++++++ conf/configuration_test.go | 31 ++++++++++++++++++++++++ conf/export_test.go | 2 ++ consts/consts.go | 3 ++- server/nativeapi/image_upload.go | 13 ++++++++-- server/nativeapi/image_upload_test.go | 34 +++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 server/nativeapi/image_upload_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 58239884a..24d116e66 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -12,6 +12,7 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" + "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" @@ -80,6 +81,7 @@ type configOptions struct { EnableStarRating bool EnableUserEditing bool EnableArtworkUpload bool + MaxImageUploadSize string EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -360,6 +362,7 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, + validateMaxImageUploadSize, validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { @@ -584,6 +587,15 @@ func validatePurgeMissingOption() error { return nil } +func validateMaxImageUploadSize() error { + if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { + err = fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) + log.Error(err.Error()) + return err + } + return nil +} + func validateScanSchedule() error { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { Server.Scanner.Schedule = "" @@ -742,6 +754,7 @@ func setViperDefaults() { viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) viper.SetDefault("enableartworkupload", true) + viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index eb2176e83..121b1902c 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -219,6 +219,37 @@ var _ = Describe("Configuration", func() { }) + Describe("ValidateMaxImageUploadSize", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + DescribeTable("accepts valid size values", + func(input string) { + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(Succeed()) + }, + Entry("megabytes", "10MB"), + Entry("gigabytes", "1GB"), + Entry("raw bytes", "10485760"), + Entry("mebibytes", "10MiB"), + Entry("lower case", "50mb"), + ) + + DescribeTable("rejects invalid size values", + func(input string) { + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize"))) + }, + Entry("garbage string", "not-a-size"), + Entry("negative-looking", "-10MB"), + ) + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index 051f9bb65..85755aa12 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -14,6 +14,8 @@ var NormalizeSearchBackend = normalizeSearchBackend var ToPascalCase = toPascalCase +var ValidateMaxImageUploadSize = validateMaxImageUploadSize + func SetLogFatal(f func(...any)) func() { old := logFatal logFatal = f diff --git a/consts/consts.go b/consts/consts.go index ff5dedc2b..3db0b831a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -85,7 +85,8 @@ const ( ) const ( - DefaultUICoverArtSize = 300 + DefaultUICoverArtSize = 300 + DefaultMaxImageUploadSize = "10MB" ) // Prometheus options diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 1f55e3851..5e2d29876 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,14 +13,22 @@ import ( "path/filepath" "strings" + "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -const maxImageSize = 10 << 20 // 10MB +func maxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) @@ -32,13 +40,14 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + maxImageSize := maxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { return } r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) - if err := r.ParseMultipartForm(maxImageSize / 2); err != nil { + if err := r.ParseMultipartForm(min(maxImageSize, 10<<20)); err != nil { log.Error(ctx, "Error parsing multipart form", err) http.Error(w, "file too large or invalid form", http.StatusBadRequest) return diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go new file mode 100644 index 000000000..291912e67 --- /dev/null +++ b/server/nativeapi/image_upload_test.go @@ -0,0 +1,34 @@ +package nativeapi + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("maxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) From 1988a4162e51026eadc53d3fbaa27365d3cbcdec Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 12 Apr 2026 12:18:13 -0400 Subject: [PATCH 177/596] refactor(configuration): improve error handling in configuration validation Signed-off-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 24d116e66..a8b0e4c8a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -366,7 +366,7 @@ func Load(noConfigDump bool) { validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { - os.Exit(1) + logFatal(err) } Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend) @@ -552,8 +552,7 @@ func validatePlaylistsPath() error { for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) { _, err := doublestar.Match(path, "") if err != nil { - log.Error("Invalid PlaylistsPath", "path", path, err) - return err + return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err) } } return nil @@ -580,7 +579,6 @@ func validatePurgeMissingOption() error { valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing) if !valid { err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) - log.Error(err.Error()) Server.Scanner.PurgeMissing = consts.PurgeMissingNever return err } @@ -589,9 +587,7 @@ func validatePurgeMissingOption() error { func validateMaxImageUploadSize() error { if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { - err = fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) - log.Error(err.Error()) - return err + return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) } return nil } @@ -619,9 +615,9 @@ func validateBackupSchedule() error { func validateSchedule(schedule, field string) (string, error) { _, err := scheduler.ParseCrontab(schedule) if err != nil { - log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err) + return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err) } - return schedule, err + return schedule, nil } // validateURL checks if the provided URL is valid and has either http or https scheme. @@ -633,19 +629,13 @@ func validateURL(optionName, optionURL string) func() error { } u, err := url.Parse(optionURL) if err != nil { - log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err) - return err + return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err) } if u.Scheme != "http" && u.Scheme != "https" { - err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) - log.Error(err.Error()) - return err + return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) } - // Require an absolute URL with a non-empty host and no opaque component. if u.Host == "" || u.Opaque != "" { - err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) - log.Error(err.Error()) - return err + return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) } return nil } From 9dfd9ac8497caf7c1ec0310fb652b905c351e0be Mon Sep 17 00:00:00 2001 From: Alexander Makeenkov <whoami.tut@gmail.com> Date: Sun, 12 Apr 2026 20:17:09 +0300 Subject: [PATCH 178/596] fix(ui): update Russian translations and add missing gain keys (#5329) * feat(i18n): add album and track gain translation strings * chore(i18n): update Russian translations --------- Co-authored-by: Alexander Makeenkov <amakeenk@altlinux.org> Signed-off-by: Deluan <deluan@navidrome.org> --- resources/i18n/ru.json | 282 +++++++++++++++++++++-------------------- ui/src/i18n/en.json | 2 + 2 files changed, 144 insertions(+), 140 deletions(-) diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 78e7cfa26..1a7adcc4a 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -1,5 +1,5 @@ { - "languageName": "Pусский", + "languageName": "Русский", "resources": { "song": { "name": "Трек |||| Треки |||| Треков", @@ -7,19 +7,19 @@ "albumArtist": "Исполнитель альбома", "duration": "Длительность", "trackNumber": "#", - "playCount": "Проигрывания", + "playCount": "Прослушивания", "title": "Название трека", - "artist": "Исполнитель", + "artist": "Артист", "album": "Альбом", "path": "Путь", "genre": "Жанр", "compilation": "Сборник", "year": "Год", "size": "Размер", - "updatedAt": "Обновлен", + "updatedAt": "Обновлено", "bitRate": "Битрейт", "discSubtitle": "Название диска", - "starred": "Избранные", + "starred": "Избранное", "comment": "Комментарий", "rating": "Рейтинг", "quality": "Качество", @@ -35,10 +35,12 @@ "rawTags": "Исходные теги", "bitDepth": "Битовая глубина (Bit)", "sampleRate": "Частота дискретизации (Hz)", + "albumGain": "Усиление альбома", + "trackGain": "Усиление трека", "missing": "Поле отсутствует", "libraryName": "Библиотека", "composer": "Композитор", - "disc": "" + "disc": "Диск %{discNumber}" }, "actions": { "addToQueue": "В очередь", @@ -53,18 +55,18 @@ } }, "album": { - "name": "Альбом |||| Альбомы", + "name": "Альбом |||| Альбомы |||| Альбомов", "fields": { "albumArtist": "Исполнитель альбома", - "artist": "Исполнитель", + "artist": "Артист", "duration": "Длительность", - "songCount": "Треков", - "playCount": "Проигрывания", + "songCount": "Трек |||| Треки |||| Треков", + "playCount": "Прослушивания", "name": "Название альбома", "genre": "Жанр", "compilation": "Сборник", "year": "Год", - "updatedAt": "Обновлен", + "updatedAt": "Обновлено", "comment": "Комментарий", "rating": "Рейтинг", "createdAt": "Дата добавления", @@ -99,17 +101,17 @@ "recentlyAdded": "Новые", "recentlyPlayed": "Проигранные", "mostPlayed": "Популярные", - "starred": "Избранные", + "starred": "Избранное", "topRated": "Лучшие" } }, "artist": { - "name": "Исполнитель |||| Исполнители", + "name": "Артист |||| Артисты |||| Артистов", "fields": { "name": "Название исполнителя", "albumCount": "Количество альбомов", "songCount": "Количество треков", - "playCount": "Проигрывания", + "playCount": "Прослушивания", "rating": "Рейтинг", "genre": "Жанр", "size": "Размер", @@ -117,29 +119,29 @@ "missing": "Поле отсутствует" }, "roles": { - "albumartist": "Исполнитель альбома |||| Исполнители альбома", - "artist": "Исполнитель |||| Исполнители", - "composer": "Композитор |||| Композиторы", - "conductor": "Дирижёр |||| Дирижёры", - "lyricist": "Автор текста |||| Авторы текста", - "arranger": "Аранжировщик |||| Аранжировщики", - "producer": "Продюсер |||| Продюсеры", - "director": "Режиссёр |||| Режиссёры", - "engineer": "Инженер |||| Инженеры", - "mixer": "Звукоинженер |||| Звукоинженеры", - "remixer": "Ремиксер |||| Ремиксеры", - "djmixer": "DJ-миксер |||| DJ-миксеры", - "performer": "Исполнитель |||| Исполнители", - "maincredit": "Исполнитель альбома или Исполнитель |||| Исполнители альбома или Исполнители" + "albumartist": "Исполнитель альбома |||| Исполнители альбома |||| Исполнителей альбома", + "artist": "Артист |||| Артисты |||| Артистов", + "composer": "Композитор |||| Композиторы |||| Композиторов", + "conductor": "Дирижёр |||| Дирижёры |||| Дирижёров", + "lyricist": "Автор текста |||| Авторы текста |||| Авторов текста", + "arranger": "Аранжировщик |||| Аранжировщики |||| Аранжировщиков", + "producer": "Продюсер |||| Продюсеры |||| Продюсеров", + "director": "Режиссёр |||| Режиссёры |||| Режиссёров", + "engineer": "Инженер |||| Инженеры |||| Инженеров", + "mixer": "Звукоинженер |||| Звукоинженеры |||| Звукоинженеров", + "remixer": "Ремиксер |||| Ремиксеры |||| Ремиксеров", + "djmixer": "DJ-миксер |||| DJ-миксеры |||| DJ-миксеров", + "performer": "Исполнитель |||| Исполнители |||| Исполнителей", + "maincredit": "Исполнитель альбома или артист |||| Исполнители альбома или артисты |||| Исполнителей альбома или артистов" }, "actions": { - "shuffle": "Смешать", + "shuffle": "Перемешать", "radio": "Радио", "topSongs": "Топовые треки" } }, "user": { - "name": "Пользователь |||| Пользователи", + "name": "Пользователь |||| Пользователи |||| Пользователей", "fields": { "userName": "Имя пользователя", "isAdmin": "Администратор", @@ -175,9 +177,9 @@ } }, "player": { - "name": "Плеер |||| Плееры", + "name": "Плеер |||| Плееры |||| Плееров", "fields": { - "name": "Имя", + "name": "Название", "transcodingId": "Транскодирование", "maxBitRate": "Макс. битрейт", "client": "Клиент", @@ -188,7 +190,7 @@ } }, "transcoding": { - "name": "Транскодирование |||| Транскодирование", + "name": "Транскодирование |||| Транскодирование |||| Транскодирований", "fields": { "name": "Название", "targetFormat": "Целевой формат", @@ -197,15 +199,15 @@ } }, "playlist": { - "name": "Плейлист |||| Плейлисты", + "name": "Плейлист |||| Плейлисты |||| Плейлистов", "fields": { - "name": "Название трека", + "name": "Название", "duration": "Длительность", "ownerName": "Владелец", "public": "Публичный", - "updatedAt": "Обновлен", + "updatedAt": "Обновлено", "createdAt": "Создан", - "songCount": "Треков", + "songCount": "Трек |||| Трека |||| Треков", "comment": "Комментарий", "sync": "Автоимпорт", "path": "Импортировать из" @@ -218,7 +220,7 @@ "makePrivate": "Сделать личным", "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", - "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", + "pressEnterToCreate": "Нажмите Enter, чтобы создать новый плейлист", "removeFromSelection": "Удалить из списка выделенных" }, "message": { @@ -229,9 +231,9 @@ } }, "radio": { - "name": "Радио |||| Радио", + "name": "Радио |||| Радио |||| Радио", "fields": { - "name": "Имя", + "name": "Название", "streamUrl": "Ссылка на поток", "homePageUrl": "Домашняя страница", "updatedAt": "Обновлено", @@ -242,7 +244,7 @@ } }, "share": { - "name": "Общий доступ |||| Общий доступ", + "name": "Общий доступ |||| Общий доступ |||| Общий доступ", "fields": { "username": "Поделился", "url": "Ссылка", @@ -253,15 +255,15 @@ "visitCount": "Количество посещений", "format": "Формат", "maxBitRate": "Макс. битрейт", - "updatedAt": "Обновлено в", + "updatedAt": "Обновлено", "createdAt": "Создано", "downloadable": "Разрешить загрузку?" } }, "missing": { - "name": "Файл отсутствует |||| Файлы отсутствуют", + "name": "Отсутствующий файл |||| Отсутствующие файлы |||| Отсутствующих файлов", "fields": { - "path": "Место расположения", + "path": "Путь", "size": "Размер", "updatedAt": "Исчез", "libraryName": "Библиотека" @@ -276,21 +278,21 @@ "empty": "Нет отсутствующих файлов" }, "library": { - "name": "Библиотека |||| Библиотеки", + "name": "Библиотека |||| Библиотеки |||| Библиотек", "fields": { - "name": "Имя", + "name": "Название", "path": "Путь", "remotePath": "Удаленный путь", "lastScanAt": "Последнее сканирование", "songCount": "Треки", "albumCount": "Альбомы", - "artistCount": "Исполнители", + "artistCount": "Артисты", "totalSongs": "Треки", "totalAlbums": "Альбомы", - "totalArtists": "Исполнители", + "totalArtists": "Артисты", "totalFolders": "Папки", - "totalFiles": "Файлов", - "totalMissingFiles": "Пропавших файлов", + "totalFiles": "Файлы", + "totalMissingFiles": "Отсутствующие файлы", "totalSize": "Общий размер", "totalDuration": "Длительность", "defaultNewUsers": "По умолчанию для новых пользователей", @@ -319,7 +321,7 @@ "scanError": "Ошибка при запуске сканирования. Проверьте логи" }, "validation": { - "nameRequired": "Имя библиотеки обязательно", + "nameRequired": "Название библиотеки обязательно", "pathRequired": "Путь к библиотеке обязателен", "pathNotDirectory": "Путь к библиотеке должен быть директорией", "pathNotFound": "Путь к библиотеке не найден", @@ -333,14 +335,14 @@ } }, "plugin": { - "name": "Плагин |||| Плагины", + "name": "Плагин |||| Плагины |||| Плагинов", "fields": { "id": "ID", - "name": "Имя", + "name": "Название", "description": "Описание", "version": "Версия", "author": "Автор", - "website": "Вебсайт", + "website": "Веб-сайт", "permissions": "Разрешения", "enabled": "Включено", "status": "Статус", @@ -348,26 +350,26 @@ "lastError": "Ошибка", "hasError": "Ошибка", "updatedAt": "Обновлено", - "createdAt": "Установленный", + "createdAt": "Дата установки", "configKey": "Ключ", "configValue": "Значение", "allUsers": "Разрешить всем пользователям", "selectedUsers": "Выбранные пользователи", "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Избранные библиотеки", - "allowWriteAccess": "" + "selectedLibraries": "Выбранные библиотеки", + "allowWriteAccess": "Разрешить запись" }, "sections": { "status": "Статус", "info": "Информация о плагине", "configuration": "Конфигурация", "manifest": "Манифест", - "usersPermission": "Разрешение пользователей", - "libraryPermission": "Разрешение на использование библиотеки" + "usersPermission": "Права доступа пользователей", + "libraryPermission": "Права доступа к библиотекам" }, "status": { "enabled": "Включено", - "disabled": "Отключить" + "disabled": "Отключено" }, "actions": { "enable": "Включить", @@ -401,7 +403,7 @@ "requiredHosts": "Необходимые хосты", "configValidationError": "Проверка конфигурации завершилась неудачей:", "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Разрешить плагину изменять файлы в вашей библиотеке" }, "placeholders": { "configKey": "ключ", @@ -412,9 +414,9 @@ "ra": { "auth": { "welcome1": "Спасибо за установку Navidrome!", - "welcome2": "Для начала, создайте аккаунт Администратора", - "confirmPassword": "Подтвердить Пароль", - "buttonCreateAdmin": "Создать аккаунт Администратора", + "welcome2": "Для начала создайте аккаунт администратора", + "confirmPassword": "Подтвердите пароль", + "buttonCreateAdmin": "Создать аккаунт администратора", "auth_check_error": "Пожалуйста, авторизуйтесь для продолжения работы", "user_menu": "Профиль", "username": "Имя пользователя", @@ -428,14 +430,14 @@ "invalidChars": "Пожалуйста, используйте только буквы и цифры", "passwordDoesNotMatch": "Пароли не совпадают", "required": "Обязательно для заполнения", - "minLength": "Минимальное кол-во символов %{min}", - "maxLength": "Максимальное кол-во символов %{max}", - "minValue": "Минимальное значение %{min}", - "maxValue": "Значение может быть %{max} или меньше", - "number": "Должно быть цифрой", + "minLength": "Минимальное количество символов: %{min}", + "maxLength": "Максимальное количество символов: %{max}", + "minValue": "Минимальное значение: %{min}", + "maxValue": "Максимальное значение: %{max}", + "number": "Должно быть числом", "email": "Некорректный Email", "oneOf": "Должно быть одним из: %{options}", - "regex": "Должно быть в формате (regexp): %{pattern}", + "regex": "Должно соответствовать формату: %{pattern}", "unique": "Должно быть уникальным", "url": "Должен быть действительный URL" }, @@ -443,7 +445,7 @@ "add_filter": "Фильтр", "add": "Добавить", "back": "Назад", - "bulk_actions": "1 выбран |||| %{smart_count} выбрано |||| %{smart_count} выбрано", + "bulk_actions": "1 выбран |||| %{smart_count} выбраны |||| %{smart_count} выбрано", "cancel": "Отмена", "clear_input_value": "Очистить", "clone": "Дублировать", @@ -461,13 +463,13 @@ "show": "Просмотр", "sort": "Сортировать", "undo": "Отменить", - "expand": "Расширить", + "expand": "Развернуть", "close": "Закрыть", "open_menu": "Открыть меню", "close_menu": "Закрыть меню", - "unselect": "Отменить выделение", + "unselect": "Снять выделение", "skip": "Пропустить", - "bulk_actions_mobile": "1 |||| %{smart_count}", + "bulk_actions_mobile": "1 |||| %{smart_count} |||| %{smart_count}", "share": "Поделиться", "download": "Скачать" }, @@ -481,7 +483,7 @@ "edit": "%{name} #%{id}", "error": "Что-то пошло не так", "list": "%{name}", - "loading": "Загрузка", + "loading": "Загрузка...", "not_found": "Не найдено", "show": "%{name} #%{id}", "empty": "Нет %{name}.", @@ -493,13 +495,13 @@ "upload_single": "Перетащите файл для загрузки или щёлкните для выбора." }, "image": { - "upload_several": "Перетащите картинки для загрузки или щёлкните для выбора.", - "upload_single": "Перетащите картинку для загрузки или щёлкните для выбора." + "upload_several": "Перетащите изображения для загрузки или щёлкните для выбора.", + "upload_single": "Перетащите изображение для загрузки или щёлкните для выбора." }, "references": { "all_missing": "Связанных данных не найдено.", - "many_missing": "Некоторые из связанных данных не доступны", - "single_missing": "Связанный объект не доступен" + "many_missing": "Некоторые из связанных данных недоступны", + "single_missing": "Связанный объект недоступен" }, "password": { "toggle_visible": "Скрыть пароль", @@ -507,45 +509,45 @@ } }, "message": { - "about": "Справка", + "about": "О программе", "are_you_sure": "Вы уверены?", - "bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ?", - "bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} %{name} |||| Удалить %{smart_count} %{name}", - "delete_content": "Вы уверены что хотите удалить этот объект", + "bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Удалить %{smart_count} объекта? |||| Удалить %{smart_count} объектов?", + "bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} объекта |||| Удалить %{smart_count} объектов", + "delete_content": "Вы уверены, что хотите удалить этот объект?", "delete_title": "Удалить %{name} #%{id}", - "details": "Описание", - "error": "При выполнении запроса возникла ошибка, и он не может быть завершен", - "invalid_form": "Форма заполнена неверно, проверьте, пожалуйста, ошибки", - "loading": "Идет загрузка, пожалуйста, немного подождите", + "details": "Подробности", + "error": "При выполнении запроса возникла ошибка", + "invalid_form": "Форма заполнена неверно, проверьте ошибки", + "loading": "Загрузка, пожалуйста, подождите...", "no": "Нет", - "not_found": "Либо вы ввели неправильный URL, либо перешли по некорректной ссылке.", + "not_found": "Страница не найдена. Возможно, вы ввели неправильный URL.", "yes": "Да", - "unsaved_changes": "Некоторые из ваших изменений не сохранены. Продолжить без сохранения?" + "unsaved_changes": "Есть несохраненные изменения. Продолжить без сохранения?" }, "navigation": { "no_results": "Результатов не найдено", - "no_more_results": "Страница %{page} выходит за пределы нумерации, попробуйте предыдущую", - "page_out_of_boundaries": "Страница %{page} выходит за пределы нумерации", + "no_more_results": "Страница %{page} выходит за пределы, попробуйте предыдущую", + "page_out_of_boundaries": "Страница %{page} выходит за пределы", "page_out_from_end": "Невозможно переместиться дальше последней страницы", "page_out_from_begin": "Номер страницы не может быть меньше 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} из %{total}", "page_rows_per_page": "Строк на странице:", - "next": "Следующая", - "prev": "Предыдущая", - "skip_nav": "Перейти к содержанию" + "next": "Вперед", + "prev": "Назад", + "skip_nav": "Перейти к основному контенту" }, "notification": { - "updated": "Элемент обновлен |||| %{smart_count} обновлено |||| %{smart_count} обновлено", + "updated": "Элемент обновлен |||| %{smart_count} элемента обновлены |||| %{smart_count} элементов обновлено", "created": "Элемент создан", - "deleted": "Элемент удален |||| %{smart_count} удалено |||| %{smart_count} удалено", - "bad_item": "Неправильный элемент", + "deleted": "Элемент удален |||| %{smart_count} элемента удалены |||| %{smart_count} элементов удалено", + "bad_item": "Некорректный элемент", "item_doesnt_exist": "Элемент не существует", "http_error": "Ошибка сервера", - "data_provider_error": "Ошибка dataProvider, проверьте консоль", - "i18n_error": "Не удалось загрузить перевод для указанного языка", + "data_provider_error": "Ошибка поставщика данных, проверьте консоль", + "i18n_error": "Не удалось загрузить перевод", "canceled": "Операция отменена", - "logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова", - "new_version": "Доступна новая версия! Пожалуйста, обновите это окно." + "logged_out": "Сессия завершена, пожалуйста, войдите снова", + "new_version": "Доступна новая версия! Пожалуйста, обновите страницу." }, "toggleFieldsMenu": { "columnsToDisplay": "Отображение столбцов", @@ -556,42 +558,42 @@ }, "message": { "note": "ПРИМЕЧАНИЕ", - "transcodingDisabled": "Изменение настроек транскодирования через веб интерфейс, отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.", - "transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды с настройками транскодирования через веб интерфейс. В целях безопасности, мы рекомендуем отключить эту возможность.", - "songsAddedToPlaylist": "Один трек добавлен в плейлист |||| %{smart_count} треков добавлено в плейлист", + "transcodingDisabled": "Изменение настроек транскодирования через веб-интерфейс отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.", + "transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды транскодирования через веб-интерфейс. В целях безопасности мы рекомендуем отключить эту возможность.", + "songsAddedToPlaylist": "Добавлен 1 трек |||| Добавлены %{smart_count} трека |||| Добавлено %{smart_count} треков", "noPlaylistsAvailable": "Недоступно", "delete_user_title": "Удалить пользователя '%{name}'", - "delete_user_content": "Вы уверены, что вы хотите удалить пользователя и все его данные (включая плейлисты и настройки)?", + "delete_user_content": "Вы уверены, что хотите удалить пользователя и все его данные (включая плейлисты и настройки)?", "notifications_blocked": "Вы заблокировали уведомления для этой страницы в настройках вашего браузера", "notifications_not_available": "Ваш браузер не поддерживает всплывающие уведомления", "lastfmLinkSuccess": "Соединение с Last.fm установлено, скробблинг включен", - "lastfmLinkFailure": "Last.fm не может быть подключен", - "lastfmUnlinkSuccess": "Соединение с Last.fm удалено, скробблинг отключен", - "lastfmUnlinkFailure": "Соединение с Last.fm не может быть удалено", + "lastfmLinkFailure": "Не удалось подключиться к Last.fm", + "lastfmUnlinkSuccess": "Соединение с Last.fm разорвано, скробблинг отключен", + "lastfmUnlinkFailure": "Не удалось разорвать соединение с Last.fm", "openIn": { "lastfm": "Показать на Last.fm", "musicbrainz": "Показать на MusicBrainz" }, "lastfmLink": "Подробнее...", "listenBrainzLinkSuccess": "ListenBrainz скробблинг успешно подключен для пользователя: %{user}", - "listenBrainzLinkFailure": "ListenBrainz не может быть связан:", + "listenBrainzLinkFailure": "Не удалось подключить ListenBrainz:", "listenBrainzUnlinkSuccess": "ListenBrainz скробблинг отключен", - "listenBrainzUnlinkFailure": "ListenBrainz не удалось отключить", + "listenBrainzUnlinkFailure": "Не удалось отключить ListenBrainz", "downloadOriginalFormat": "Скачать в оригинальном формате", "shareOriginalFormat": "Поделиться в оригинальном формате", "shareDialogTitle": "Поделиться %{resource} '%{name}'", - "shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource}", + "shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource} |||| Поделиться %{smart_count} %{resource}", "shareSuccess": "URL скопирован в буфер обмена: %{url}", - "shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена", + "shareFailure": "Ошибка копирования URL %{url} в буфер обмена", "downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter", "remove_missing_title": "Удалить отсутствующие файлы?", "remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.", - "remove_all_missing_title": "Удалите все отсутствующие файлы", - "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.", + "remove_all_missing_title": "Удалить все отсутствующие файлы", + "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество прослушиваний и рейтинг.", "noSimilarSongsFound": "Похожих треков не найдено", "noTopSongsFound": "Лучших треков не найдено", - "startingInstantMix": "Загрузка быстрого микса" + "startingInstantMix": "Загрузка быстрого микса..." }, "menu": { "library": "Библиотека", @@ -599,7 +601,7 @@ "version": "Версия", "theme": "Тема", "personal": { - "name": "Личные", + "name": "Личное", "options": { "theme": "Тема", "language": "Язык", @@ -607,10 +609,10 @@ "desktop_notifications": "Уведомления на рабочем столе", "lastfmScrobbling": "Скробблинг Last.fm", "listenBrainzScrobbling": "Скробблинг ListenBrainz", - "replaygain": "ReplayGain режим", - "preAmp": "ReplayGain предусилитель (dB)", + "replaygain": "Режим ReplayGain", + "preAmp": "Предусилитель ReplayGain (дБ)", "gain": { - "none": "Отключить", + "none": "Отключено", "album": "Использовать усиление альбома", "track": "Использовать усиление трека" }, @@ -620,16 +622,16 @@ "albumList": "Альбомы", "about": "О программе", "playlists": "Плейлисты", - "sharedPlaylists": "Поделиться плейлистом", + "sharedPlaylists": "Общие плейлисты", "librarySelector": { "allLibraries": "Все библиотеки (%{count})", - "multipleLibraries": "%{selected} из %{total} Библиотеки", + "multipleLibraries": "%{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек", "selectLibraries": "Выбор библиотек", "none": "Отсутствует" } }, "player": { - "playListsText": "Очередь Воспроизведения", + "playListsText": "Очередь воспроизведения", "openText": "Открыть", "closeText": "Закрыть", "notContentText": "Нет музыки", @@ -643,19 +645,19 @@ "toggleMiniModeText": "Свернуть", "destroyText": "Выключить", "downloadText": "Скачать", - "removeAudioListsText": "Удалить список воспроизведения", + "removeAudioListsText": "Очистить очередь", "clickToDeleteText": "Нажмите для удаления %{name}", - "emptyLyricText": "Без текста", + "emptyLyricText": "Текст песни отсутствует", "playModeText": { "order": "По порядку", "orderLoop": "Повторять", - "singleLoop": "Повторить один раз", + "singleLoop": "Повторять один трек", "shufflePlay": "Перемешать" } }, "about": { "links": { - "homepage": "Главная", + "homepage": "Сайт проекта", "source": "Исходный код", "featureRequests": "Предложения", "lastInsightsCollection": "Последний сбор данных", @@ -665,51 +667,51 @@ } }, "tabs": { - "about": "О нас", + "about": "О программе", "config": "Конфигурация" }, "config": { - "configName": "Имя конфигурации", - "environmentVariable": "Переменная среды", + "configName": "Параметр", + "environmentVariable": "Переменная окружения", "currentValue": "Текущее значение", "configurationFile": "Файл конфигурации", - "exportToml": "Экспорт конфигурации (TOML)", - "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", + "exportToml": "Экспорт в TOML", + "exportSuccess": "Конфигурация скопирована в буфер обмена в формате TOML", "exportFailed": "Не удалось скопировать конфигурацию", "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", - "downloadToml": "Скачать конфигурацию (TOML)" + "downloadToml": "Скачать TOML" } }, "activity": { - "title": "Действия", + "title": "Активность", "totalScanned": "Всего просканировано папок", "quickScan": "Быстрое сканирование", "fullScan": "Полное сканирование", "serverUptime": "Время работы сервера", - "serverDown": "Оффлайн", + "serverDown": "Офлайн", "scanType": "Тип", - "status": "Ошибка сканирования", + "status": "Статус", "elapsedTime": "Прошедшее время", - "selectiveScan": "Избирательный" + "selectiveScan": "Избирательное" }, "help": { "title": "Горячие клавиши Navidrome", "hotkeys": { "show_help": "Показать справку", - "toggle_menu": "Показать / скрыть боковое меню", - "toggle_play": "Играть / Пауза", + "toggle_menu": "Показать/скрыть боковое меню", + "toggle_play": "Играть/Пауза", "prev_song": "Предыдущий трек", "next_song": "Следующий трек", "vol_up": "Увеличить громкость", "vol_down": "Уменьшить громкость", - "toggle_love": "Добавить / удалить песню из избранного", + "toggle_love": "Добавить/удалить из избранного", "current_song": "Перейти к текущему треку" } }, "nowPlaying": { "title": "Сейчас играет", "empty": "Ничего не играет", - "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" + "minutesAgo": "%{smart_count} минуту назад |||| %{smart_count} минуты назад |||| %{smart_count} минут назад" } } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 6c6592178..74fb23ab9 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -22,6 +22,8 @@ "bitRate": "Bit rate", "bitDepth": "Bit depth", "sampleRate": "Sample rate", + "albumGain": "Album gain", + "trackGain": "Track gain", "channels": "Channels", "disc": "Disc %{discNumber}", "discSubtitle": "Disc Subtitle", From aa84e645ba5be6d1f3d50b38c6e03558803e6627 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 12 Apr 2026 13:22:56 -0400 Subject: [PATCH 179/596] fix(ui): add albumGain and trackGain translations in Brazilian Portuguese Signed-off-by: Deluan <deluan@navidrome.org> --- resources/i18n/pt-br.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 2e4f517a9..d9f29f5d4 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -35,6 +35,8 @@ "rawTags": "Tags originais", "bitDepth": "Profundidade de bits", "sampleRate": "Taxa de amostragem", + "albumGain": "Ganho do álbum", + "trackGain": "Ganho da faixa", "missing": "Ausente", "libraryName": "Biblioteca", "composer": "Compositor", From 52e47b896a3f2b9a7e3683b01758baae51374f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 12 Apr 2026 16:47:22 -0400 Subject: [PATCH 180/596] refactor: extract song-to-library matcher to core/matcher package (#5348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: extract matchSongsToLibrary to core/matcher package Move the song-to-library matching algorithm from core/external into its own core/matcher package. The Matcher struct exposes a single public method MatchSongsToLibrary that implements a multi-phase matching algorithm (ID > MBID > ISRC > fuzzy title+artist). Includes pre-sanitization optimization for the fuzzy matching loop. No behavioral changes — the algorithm is identical to the version in core/external/provider_matching.go. * refactor: inject matcher.Matcher via Wire instead of creating it inline Add *matcher.Matcher as a dependency of external.NewProvider, wired via Google Wire. Update all provider test files to pass matcher.New(ds). This eliminates tight coupling so future consumers can reuse the matcher without depending on the external package. * refactor: remove old provider_matching files Delete core/external/provider_matching.go and its tests. All matching logic now lives in core/matcher/. * test(matcher): restore test coverage lost in extraction Port back 23 specs that existed in the old provider_matching_test.go but were dropped during the extraction. Covers specificity levels, fuzzy matching thresholds, fuzzy album matching, duration matching, and deduplication edge cases. * test(matcher): extract matchFieldInAnd/matchFieldInEq helpers The four inline mock.MatchedBy closures in setupAllPhaseExpectations all followed the same squirrel.And -> squirrel.Eq -> field-name-check pattern. Extract into two small helpers to reduce duplication and make the setup functions read as a concise list of phase expectations. * refactor(matcher): address PR #5348 review feedback - sanitizedTrack now holds *model.MediaFile instead of a value copy. Since MediaFile is a large struct (~74 fields), this avoids the per-track copy into sanitized[] and a second copy when findBestMatch assigns the winner. loadTracksByTitleAndArtist updated to iterate by index and pass &tracks[i]. - loadTracksByISRC now sorts results (starred desc, rating desc, year asc, compilation asc) so that when multiple library tracks share an ISRC the most relevant one is picked deterministically, matching the sort order already used by loadTracksByTitleAndArtist. - Restored the four worked examples (MBID Priority, ISRC Priority, Specificity Ranking, Fuzzy Title Matching) in the MatchSongsToLibrary godoc that were dropped during the extraction. - matcher_test.go: tests now enforce expectations via AssertExpectations in a DeferCleanup. The old setupAllPhaseExpectations helper was replaced with per-phase helpers (expectIDPhase/expectMBIDPhase/expectISRCPhase + allowOtherPhases) so each test deterministically verifies which matching phases fire. This surfaced (and fixes) a latent issue copilot flagged: the old .Once() expectations were not actually asserted, so tests would silently pass even when phases short-circuited unexpectedly. --- cmd/wire_gen.go | 16 +- core/external/provider.go | 10 +- core/external/provider_albumimage_test.go | 3 +- core/external/provider_artistimage_test.go | 3 +- core/external/provider_matching_test.go | 762 ----------------- core/external/provider_similarsongs_test.go | 3 +- core/external/provider_topsongs_test.go | 3 +- .../external/provider_updatealbuminfo_test.go | 3 +- .../provider_updateartistinfo_test.go | 3 +- .../matcher.go} | 208 ++--- .../matcher_internal_test.go} | 6 +- core/matcher/matcher_suite_test.go | 17 + core/matcher/matcher_test.go | 807 ++++++++++++++++++ core/wire_providers.go | 2 + 14 files changed, 947 insertions(+), 899 deletions(-) delete mode 100644 core/external/provider_matching_test.go rename core/{external/provider_matching.go => matcher/matcher.go} (57%) rename core/{external/provider_matching_internal_test.go => matcher/matcher_internal_test.go} (89%) create mode 100644 core/matcher/matcher_suite_test.go create mode 100644 core/matcher/matcher_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 5b9fd648f..b25b4c100 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -72,7 +73,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) @@ -93,7 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) @@ -121,7 +124,8 @@ func CreatePublicRouter() *public.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) @@ -168,7 +172,8 @@ func CreateScanner(ctx context.Context) model.Scanner { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() @@ -186,7 +191,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() diff --git a/core/external/provider.go b/core/external/provider.go index 40ca34069..7e8aaba1c 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -12,6 +12,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -41,6 +42,7 @@ type Provider interface { type provider struct { ds model.DataStore ag Agents + matcher *matcher.Matcher artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -85,8 +87,8 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents) Provider { - e := &provider{ds: ds, ag: agents} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { + e := &provider{ds: ds, ag: agents, matcher: m} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e @@ -300,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode } if err == nil && len(songs) > 0 { - return e.matchSongsToLibrary(ctx, songs, count) + return e.matcher.MatchSongsToLibrary(ctx, songs, count) } // Fallback to existing similar artists + top songs algorithm @@ -479,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT } } - mfs, err := e.matchSongsToLibrary(ctx, songs, count) + mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count) if err != nil { return nil, err } diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go index 8a81b4f4d..e801b7cce 100644 --- a/core/external/provider_albumimage_test.go +++ b/core/external/provider_albumimage_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -43,7 +44,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumAgent = newMockAlbumInfoAgent() agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) // Default mocks // Mocks for GetEntityByID sequence (initial failed lookups) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 529289ed3..37d3fd81a 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -51,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() { imageAgent: mockImageAgent, } - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) // Default mocks for successful Get calls mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() diff --git a/core/external/provider_matching_test.go b/core/external/provider_matching_test.go deleted file mode 100644 index b3624ef3a..000000000 --- a/core/external/provider_matching_test.go +++ /dev/null @@ -1,762 +0,0 @@ -package external_test - -import ( - "context" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - Song Matching", func() { - var ds model.DataStore - var provider Provider - var agentsCombined *mockAgents - var artistRepo *mockArtistRepo - var mediaFileRepo *mockMediaFileRepo - var albumRepo *mockAlbumRepo - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - - artistRepo = newMockArtistRepo() - mediaFileRepo = newMockMediaFileRepo() - albumRepo = newMockAlbumRepo() - - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedMediaFile: mediaFileRepo, - MockedAlbum: albumRepo, - } - - agentsCombined = &mockAgents{} - provider = NewProvider(ds, agentsCombined) - }) - - // Shared helper for tests that only need artist track queries (no ID/MBID matching) - setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) { - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(returnedSongs, nil).Once() - - // loadTracksByTitleAndArtist - queries by artist name - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasArtist := eq["order_artist_name"] - return hasArtist - })).Return(artistTracks, nil).Maybe() - } - - Describe("matchSongsToLibrary priority matching", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // Disable fuzzy matching for these tests to avoid unexpected GetAll calls - conf.Server.SimilarSongsMatchThreshold = 100 - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) { - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(returnedSongs, nil).Once() - - // loadTracksByID - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return ok - })).Return(idMatches, nil).Once() - - // loadTracksByMBID - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 1 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasMBID := eq["mbz_recording_id"] - return hasMBID - })).Return(mbidMatches, nil).Once() - - // loadTracksByTitleAndArtist - now queries by artist name - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasArtist := eq["order_artist_name"] - return hasArtist - })).Return(artistTracks, nil).Maybe() - } - - Context("when agent returns artist and album metadata", func() { - It("matches by title + artist MBID + album MBID (highest priority)", func() { - // Song in library with all MBIDs - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", - } - // Another song with same title but different MBIDs (should NOT match) - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", - } - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("matches by title + artist name + album name when MBIDs unavailable", func() { - // Song in library without MBIDs but with matching artist/album names - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", - } - // Another song with same title but different artist (should NOT match) - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - } - - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("matches by title + artist only when album info unavailable", func() { - // Song in library with matching artist - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", - } - // Another song with same title but different artist - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - } - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode"}, // No album info - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("does not match songs without artist info", func() { - // Songs without artist info cannot be matched since we query by artist - returnedSongs := []agents.Song{ - {Name: "Similar Song"}, // No artist/album info at all - } - - // No artist to query, so no GetAll calls for title matching - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("when matching multiple songs with the same title but different artists", func() { - It("returns distinct matches for each artist's version (covers scenario)", func() { - // Multiple covers of the same song by different artists - cover1 := model.MediaFile{ - ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - } - cover2 := model.MediaFile{ - ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", - } - cover3 := model.MediaFile{ - ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", - } - - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, - {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // All three covers should be returned, not just the first one - Expect(songs).To(HaveLen(3)) - // Verify all three different versions are included - ids := []string{songs[0].ID, songs[1].ID, songs[2].ID} - Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) - }) - }) - - Context("when matching multiple songs with different precision levels", func() { - It("prefers more precise matches for each song", func() { - // Library has multiple versions of same song - preciseMatch := model.MediaFile{ - ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", - MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", - } - lessAccurateMatch := model.MediaFile{ - ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", - } - artistTwoMatch := model.MediaFile{ - ID: "artist-two", Title: "Song B", Artist: "Artist Two", - } - - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artist: "Artist Two"}, // Different artist - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - // First song should be the precise match (has all MBIDs) - Expect(songs[0].ID).To(Equal("precise")) - // Second song matches by title + artist - Expect(songs[1].ID).To(Equal("artist-two")) - }) - }) - }) - - Describe("Fuzzy matching fallback", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - Context("with default threshold (85%)", func() { - It("matches songs with remastered suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - // Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered" - returnedSongs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, - } - // Artist catalog has the remastered version (fuzzy match will find it) - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("remastered")) - }) - - It("matches songs with live suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, - } - artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("live")) - }) - - It("does not match completely different songs", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles"}, - } - // Artist catalog has completely different songs - artistTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, - {ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("with threshold set to 100 (exact match only)", func() { - It("only matches exact titles", func() { - conf.Server.SimilarSongsMatchThreshold = 100 - - returnedSongs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, - } - // Artist catalog has only remastered version - no exact match - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("with lower threshold (75%)", func() { - It("matches more aggressively", func() { - conf.Server.SimilarSongsMatchThreshold = 75 - - returnedSongs := []agents.Song{ - {Name: "Song", Artist: "Artist"}, - } - artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("extended")) - }) - }) - - Context("with fuzzy album matching", func() { - It("matches album with (Remaster) suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - // Agent returns "A Night at the Opera" but library has remastered version - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - } - // Library has same album with remaster suffix - correctMatch := model.MediaFile{ - ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - // Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("matches album with (Deluxe Edition) suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, - } - correctMatch := model.MediaFile{ - ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("prefers exact album match over fuzzy album match", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, - } - exactMatch := model.MediaFile{ - ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - } - fuzzyMatch := model.MediaFile{ - ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - // Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity) - Expect(songs[0].ID).To(Equal("exact")) - }) - }) - }) - - Describe("Duration matching", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - Context("when agent provides duration", func() { - It("prefers tracks with matching duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has two versions: one matching duration, one not - correctMatch := model.MediaFile{ - ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, - } - wrongDuration := model.MediaFile{ - ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("matches tracks with close duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has track with 182.5 seconds (close to target) - closeDuration := model.MediaFile{ - ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("close-duration")) - }) - - It("prefers closer duration over farther duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has one close, one far - closeDuration := model.MediaFile{ - ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, - } - farDuration := model.MediaFile{ - ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("close")) - }) - - It("still matches when no tracks have matching duration", func() { - // Agent returns song with duration 180000ms - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library only has tracks with very different duration - differentDuration := model.MediaFile{ - ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Duration mismatch doesn't exclude the track; it's just scored lower - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("different")) - }) - - It("prefers title match over duration match when titles differ", func() { - // Agent returns "Similar Song" with duration 180000ms - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has: - // - differentTitle: matches duration but has different title (won't pass title threshold) - // - correctTitle: doesn't match duration but has correct title (wins on title similarity) - differentTitle := model.MediaFile{ - ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, - } - correctTitle := model.MediaFile{ - ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Title similarity is the top priority, so the correct title wins despite duration mismatch - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-title")) - }) - }) - - Context("when agent does not provide duration", func() { - It("matches without duration filtering (duration=0)", func() { - // Agent returns song without duration - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, - } - // Library tracks with various durations should all be candidates - anyTrack := model.MediaFile{ - ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("any")) - }) - }) - - Context("edge cases", func() { - It("handles very short songs with close duration", func() { - // 30-second song with 1-second difference - returnedSongs := []agents.Song{ - {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, - } - shortTrack := model.MediaFile{ - ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("short")) - }) - }) - }) - - Describe("Deduplication of mismatched songs", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - It("removes duplicates when different input songs match the same library track", func() { - // Agent returns two different versions that will both fuzzy-match to the same library track - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, - } - // Library only has one version - libraryTrack := model.MediaFile{ - ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should only return one track, not two duplicates - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("br-live")) - }) - - It("preserves duplicates when identical input songs match the same library track", func() { - // Agent returns the exact same song twice (intentional repetition) - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - } - // Library has matching track - libraryTrack := model.MediaFile{ - ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should return two tracks since input songs were identical - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("br")) - Expect(songs[1].ID).To(Equal("br")) - }) - - It("handles mixed scenario with both identical and different input songs", func() { - // Agent returns: Song A, Song B (different from A), Song A again (same as first) - // All three match to the same library track - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first - {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version - } - // Library only has one version - libraryTrack := model.MediaFile{ - ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should return 2 tracks: - // 1. First "Yesterday" (original) - // 2. Third "Yesterday" (same as first, so kept) - // Skip: Second "Yesterday (Remastered)" (different input, same library track) - // Skip: Fourth "Yesterday (Anthology)" (different input, same library track) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("yesterday")) - Expect(songs[1].ID).To(Equal("yesterday")) - }) - - It("does not deduplicate songs that match different library tracks", func() { - // Agent returns different songs that match different library tracks - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, - } - // Library has all three songs - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // All three should be returned since they match different library tracks - Expect(songs).To(HaveLen(3)) - Expect(songs[0].ID).To(Equal("track-a")) - Expect(songs[1].ID).To(Equal("track-b")) - Expect(songs[2].ID).To(Equal("track-c")) - }) - - It("respects count limit after deduplication", func() { - // Agent returns 4 songs: 2 unique + 2 that would create duplicates - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track - {Name: "Song B", Artist: "Artist"}, - {Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track - } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB}) - - // Request only 2 songs - songs, err := provider.SimilarSongs(ctx, "track-1", 2) - - Expect(err).ToNot(HaveOccurred()) - // Should return exactly 2: Song A and Song B (skipping duplicates) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("track-a")) - Expect(songs[1].ID).To(Equal("track-b")) - }) - }) -}) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 1491d394e..c9a1a64ef 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -7,6 +7,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -48,7 +49,7 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) }) Describe("dispatch by entity type", func() { diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index b73c8ab3e..4bd0e5959 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -44,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag) + p = NewProvider(ds, ag, matcher.New(ds)) }) It("returns top songs for a known artist", func() { diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 5f5d41a87..3dd8a587a 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -34,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag) + p = external.NewProvider(ds, ag, matcher.New(ds)) mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index 0c489eadd..e309ece6e 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -37,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag) + p = external.NewProvider(ds, ag, matcher.New(ds)) mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) diff --git a/core/external/provider_matching.go b/core/matcher/matcher.go similarity index 57% rename from core/external/provider_matching.go rename to core/matcher/matcher.go index 74ad56d42..40d4dc160 100644 --- a/core/external/provider_matching.go +++ b/core/matcher/matcher.go @@ -1,4 +1,4 @@ -package external +package matcher import ( "context" @@ -13,7 +13,17 @@ import ( "github.com/xrash/smetrics" ) -// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// Matcher matches agent song results to local library tracks. +type Matcher struct { + ds model.DataStore +} + +// New creates a new Matcher with the given DataStore. +func New(ds model.DataStore) *Matcher { + return &Matcher{ds: ds} +} + +// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase // matching algorithm that prioritizes accuracy over recall. // // # Algorithm Overview @@ -95,36 +105,34 @@ import ( // // Returns up to 'count' MediaFiles from the library that best match the input songs, // preserving the original order from the agent. Songs that cannot be matched are skipped. -func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - idMatches, err := e.loadTracksByID(ctx, songs) +func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + idMatches, err := m.loadTracksByID(ctx, songs) if err != nil { return nil, fmt.Errorf("failed to load tracks by ID: %w", err) } - mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches) + mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches) if err != nil { return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) } - isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) + isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) if err != nil { return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) } - titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) + titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) if err != nil { return nil, fmt.Errorf("failed to load tracks by title: %w", err) } - return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil + return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil } // songMatchedIn checks if a song has already been matched in any of the provided match maps. -// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys. func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { _, found := lookupByIdentifiers(s, priorMatches...) return found } // lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match. func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { keys := []string{s.ID, s.MBID, s.ISRC} for _, m := range maps { @@ -140,10 +148,7 @@ func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (mod } // loadTracksByID fetches MediaFiles from the library using direct ID matching. -// It extracts all non-empty ID fields from the input songs and performs a single -// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup. -// Only non-missing files are returned. -func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { var ids []string for _, s := range songs { if s.ID != "" { @@ -154,7 +159,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map if len(ids) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"media_file.id": ids}, squirrel.Eq{"missing": false}, @@ -172,10 +177,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map } // loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -// It extracts all non-empty MBID fields from the input songs and performs a single -// batch query against the mbz_recording_id column. Returns a map keyed by MBID for -// O(1) lookup. Only non-missing files are returned. -func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var mbids []string for _, s := range songs { if s.MBID != "" && !songMatchedIn(s, priorMatches...) { @@ -186,7 +188,7 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr if len(mbids) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"mbz_recording_id": mbids}, squirrel.Eq{"missing": false}, @@ -205,11 +207,8 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr return matches, nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard -// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and -// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for -// O(1) lookup. Only non-missing files are returned. -func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. +func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var isrcs []string for _, s := range songs { if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { @@ -220,8 +219,9 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr if len(isrcs) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, + Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { return matches, err @@ -237,27 +237,24 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr } // songQuery represents a normalized query for matching a song to library tracks. -// All string fields are sanitized (lowercased, diacritics removed) for comparison. -// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist. type songQuery struct { - title string // Sanitized song title - artist string // Sanitized artist name (without articles like "The") - artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching) - album string // Sanitized album name (optional, for specificity scoring) - albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching) - durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering) + title string + artist string + artistMBID string + album string + albumMBID string + durationMs uint32 } -// matchScore combines title/album similarity with metadata specificity for ranking matches +// matchScore combines title/album similarity with metadata specificity for ranking matches. type matchScore struct { - titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler) - durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown) - albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker - specificityLevel int // 0-5 (higher = more specific metadata match) + titleSimilarity float64 + durationProximity float64 + albumSimilarity float64 + specificityLevel int } // betterThan returns true if this score beats another. -// Comparison order: title similarity > duration proximity > specificity level > album similarity func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -271,58 +268,62 @@ func (s matchScore) betterThan(other matchScore) bool { return s.albumSimilarity > other.albumSimilarity } -// computeSpecificityLevel determines how well query metadata matches a track (0-5). -// Higher values indicate more specific matches (MBIDs > names > title only). -// Uses fuzzy matching for album names with the same threshold as title matching. -func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int { - title := str.SanitizeFieldForSorting(mf.Title) - artist := str.SanitizeFieldForSortingNoArticle(mf.Artist) - album := str.SanitizeFieldForSorting(mf.Album) +// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization +// when the same track is scored against multiple queries in the inner loop. The `mf` field +// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist +// sanitized slice. +type sanitizedTrack struct { + mf *model.MediaFile + title string + artist string + album string +} - // Level 5: Title + Artist MBID + Album MBID (most specific) +func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { + return sanitizedTrack{ + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + } +} + +// computeSpecificityLevel determines how well query metadata matches a track (0-5). +// The track's title, artist, and album fields must be pre-sanitized. +func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && - mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID { + t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { return 5 } - // Level 4: Title + Artist MBID + Album name (fuzzy) if q.artistMBID != "" && q.album != "" && - mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold { + t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { return 4 } - // Level 3: Title + Artist name + Album name (fuzzy) if q.artist != "" && q.album != "" && - artist == q.artist && similarityRatio(album, q.album) >= albumThreshold { + t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { return 3 } - // Level 2: Title + Artist MBID - if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID { + if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { return 2 } - // Level 1: Title + Artist name - if q.artist != "" && artist == q.artist { + if q.artist != "" && t.artist == q.artist { return 1 } - // Level 0: Title only match (but for fuzzy, title matched via similarity) - // Check if at least the title matches exactly - if title == q.title { + if t.title == q.title { return 0 } - return -1 // No exact title match, but could still be a fuzzy match + return -1 } // loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with -// metadata specificity (MBIDs, album names) for both exact and fuzzy matches. -// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs. -func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := e.buildTitleQueries(songs, priorMatches...) +func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + queries := m.buildTitleQueries(songs, priorMatches...) if len(queries) == 0 { return map[string]model.MediaFile{}, nil } threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0 - // Group queries by artist for efficient DB access byArtist := map[string][]songQuery{} for _, q := range queries { if q.artist != "" { @@ -332,8 +333,7 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent matches := map[string]model.MediaFile{} for artist, artistQueries := range byArtist { - // Single DB query per artist - get all their tracks - tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"order_artist_name": artist}, squirrel.Eq{"missing": false}, @@ -344,9 +344,13 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent continue } - // Find best match for each query using unified scoring + sanitized := make([]sanitizedTrack, len(tracks)) + for i := range tracks { + sanitized[i] = newSanitizedTrack(&tracks[i]) + } + for _, q := range artistQueries { - if mf, found := e.findBestMatch(q, tracks, threshold); found { + if mf, found := m.findBestMatch(q, sanitized, threshold); found { key := q.title + "|" + q.artist if _, exists := matches[key]; !exists { matches[key] = mf @@ -357,13 +361,11 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent return matches, nil } -// durationProximity returns a score from 0.0 to 1.0 indicating how close -// the track's duration is to the target. A perfect match returns 1.0, and the -// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0 -// if durationMs is 0 (unknown), so duration does not influence scoring. +// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration +// is to the target. Returns 1.0 if durationMs is 0 (unknown). func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 { - if durationMs <= 0 { - return 1.0 // Unknown duration — don't penalise + if durationMs == 0 { + return 1.0 } durationSec := float64(durationMs) / 1000.0 diff := math.Abs(durationSec - float64(mediaFileDurationSec)) @@ -371,41 +373,33 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -// A track must meet the threshold for title similarity, then the best match is chosen by: -// 1. Highest title similarity -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Highest specificity level -// 4. Highest album similarity (as final tiebreaker) -func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false - for _, mf := range tracks { - trackTitle := str.SanitizeFieldForSorting(mf.Title) - titleSim := similarityRatio(q.title, trackTitle) + for _, t := range sanitizedTracks { + titleSim := similarityRatio(q.title, t.title) if titleSim < threshold { continue } - // Compute album similarity for tiebreaking (0.0 if no album in query) var albumSim float64 if q.album != "" { - trackAlbum := str.SanitizeFieldForSorting(mf.Album) - albumSim = similarityRatio(q.album, trackAlbum) + albumSim = similarityRatio(q.album, t.album) } score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, mf.Duration), + durationProximity: durationProximity(q.durationMs, t.mf.Duration), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, mf, threshold), + specificityLevel: computeSpecificityLevel(q, t, threshold), } if score.betterThan(bestScore) { bestScore = score - bestMatch = mf + bestMatch = *t.mf found = true } } @@ -413,9 +407,7 @@ func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold } // buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes -// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names). -func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { +func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { var queries []songQuery for _, s := range songs { if songMatchedIn(s, priorMatches...) { @@ -434,18 +426,9 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st } // selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks. It iterates through the input songs in order and selects the first available match -// using priority order: ID > MBID > ISRC > title+artist. -// -// The function also handles deduplication: when multiple different input songs would match the same -// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library), -// only the first match is kept. However, if the same input song appears multiple times (intentional -// repetition), duplicates are preserved in the output. -// -// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped. -func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// library tracks using priority order: ID > MBID > ISRC > title+artist. +func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - // Track MediaFile.ID -> input song that added it, for deduplication addedBy := make(map[string]agents.Song, len(songs)) for _, t := range songs { @@ -458,11 +441,9 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by continue } - // Check for duplicate library track if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - // Only add duplicate if input songs are identical if t != prevSong { - continue // Different input songs → skip mismatch-induced duplicate + continue } } else { addedBy[mf.ID] = t @@ -473,14 +454,11 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by return mfs } -// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist. -// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists. +// findMatchingTrack looks up a song in the match maps using priority order. func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - // Try identifier-based matches first (ID, MBID, ISRC) if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { return mf, true } - // Fall back to title+artist fuzzy match key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) if mf, ok := byTitleArtist[key]; ok { return mf, true @@ -489,9 +467,6 @@ func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[st } // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. -// Returns a value between 0.0 (completely different) and 1.0 (identical). -// Jaro-Winkler is well-suited for matching song titles because it gives higher scores -// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered"). func similarityRatio(a, b string) float64 { if a == b { return 1.0 @@ -499,6 +474,5 @@ func similarityRatio(a, b string) float64 { if len(a) == 0 || len(b) == 0 { return 0.0 } - // JaroWinkler params: boostThreshold=0.7, prefixSize=4 return smetrics.JaroWinkler(a, b, 0.7, 4) } diff --git a/core/external/provider_matching_internal_test.go b/core/matcher/matcher_internal_test.go similarity index 89% rename from core/external/provider_matching_internal_test.go rename to core/matcher/matcher_internal_test.go index 5b9ccea3b..f111364c1 100644 --- a/core/external/provider_matching_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,4 +1,4 @@ -package external +package matcher import ( . "github.com/onsi/ginkgo/v2" @@ -16,25 +16,21 @@ var _ = Describe("similarityRatio", func() { }) It("returns high similarity for remastered suffix", func() { - // Jaro-Winkler gives ~0.92 for this case ratio := similarityRatio("paranoid android", "paranoid android remastered") Expect(ratio).To(BeNumerically(">=", 0.85)) }) It("returns high similarity for suffix additions like (Live)", func() { - // Jaro-Winkler gives ~0.96 for this case ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live") Expect(ratio).To(BeNumerically(">=", 0.90)) }) It("returns high similarity for 'yesterday' variants (common prefix)", func() { - // Jaro-Winkler gives ~0.90 because of common prefix ratio := similarityRatio("yesterday", "yesterday once more") Expect(ratio).To(BeNumerically(">=", 0.85)) }) It("returns low similarity for same suffix", func() { - // Jaro-Winkler gives ~0.70 for this case ratio := similarityRatio("postman (live)", "taxman (live)") Expect(ratio).To(BeNumerically("<", 0.85)) }) diff --git a/core/matcher/matcher_suite_test.go b/core/matcher/matcher_suite_test.go new file mode 100644 index 000000000..44877a3c8 --- /dev/null +++ b/core/matcher/matcher_suite_test.go @@ -0,0 +1,17 @@ +package matcher_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMatcher(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Matcher Suite") +} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go new file mode 100644 index 000000000..b1f59b258 --- /dev/null +++ b/core/matcher/matcher_test.go @@ -0,0 +1,807 @@ +package matcher_test + +import ( + "context" + "errors" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Matcher", func() { + var ds model.DataStore + var mediaFileRepo *mockMediaFileRepo + var ctx context.Context + var m *matcher.Matcher + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + mediaFileRepo = newMockMediaFileRepo() + DeferCleanup(func() { + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + ds = &tests.MockDataStore{ + MockedMediaFile: mediaFileRepo, + } + m = matcher.New(ds) + }) + + // Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation + // that will fail the suite via AssertExpectations if the phase is NOT called. Tests + // use these to deterministically verify which matching phases fire. Phases that may + // or may not fire should use the `allow*Phase` variants instead, which register + // .Maybe() fallbacks. + expectIDPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). + Return(matches, nil).Once() + } + expectMBIDPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(matches, nil).Once() + } + expectISRCPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(matches, nil).Once() + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(model.MediaFiles{}, nil).Maybe() + } + + // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise + // the title+artist phase. The title phase uses .Maybe() because it may short-circuit + // when no songs have an artist. + setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(artistTracks, nil).Maybe() + } + + Describe("MatchSongsToLibrary", func() { + Context("matching by direct ID", func() { + It("matches songs with an ID field to MediaFiles by ID", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, + } + idMatch := model.MediaFile{ + ID: "track-1", Title: "Some Song", Artist: "Some Artist", + } + expectIDPhase(model.MediaFiles{idMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) + + Context("matching by MBID", func() { + It("matches songs with MBID to tracks with matching mbz_recording_id", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, + } + mbidMatch := model.MediaFile{ + ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", + MbzRecordingID: "abc-123", + } + expectMBIDPhase(model.MediaFiles{mbidMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-mbid")) + }) + }) + + Context("matching by ISRC", func() { + It("matches songs with ISRC to tracks with matching ISRC tag", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, + } + isrcMatch := model.MediaFile{ + ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", + Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}}, + } + expectISRCPhase(model.MediaFiles{isrcMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-isrc")) + }) + }) + + Context("fuzzy title+artist matching", func() { + It("matches songs by title and artist name", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, + } + titleMatch := model.MediaFile{ + ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + } + setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-title")) + }) + + It("matches songs with fuzzy title similarity", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + fuzzyMatch := model.MediaFile{ + ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-fuzzy")) + }) + + It("does not match completely different titles", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles"}, + } + differentTracks := model.MediaFiles{ + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + } + setupTitleOnlyExpectations(differentTracks) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("deduplication", func() { + It("removes duplicates when different input songs match the same library track", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + } + libraryTrack := model.MediaFile{ + ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("br-live")) + }) + + It("preserves duplicates when identical input songs match the same library track", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + libraryTrack := model.MediaFile{ + ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + } + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("br")) + Expect(result[1].ID).To(Equal("br")) + }) + }) + + Context("priority ordering", func() { + It("prefers ID match over MBID match", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + // Song has both ID and MBID set. The matcher should resolve via ID + // and short-circuit the MBID phase entirely, so no MBID fetch should + // occur even though an mbz_recording_id exists in the input. + songs := []agents.Song{ + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, + } + idMatch := model.MediaFile{ + ID: "track-id", Title: "Song", Artist: "Artist", + } + expectIDPhase(model.MediaFiles{idMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-id")) + }) + }) + + Context("count limit", func() { + It("returns at most 'count' results", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + tracks := model.MediaFiles{ + {ID: "a", Title: "Song A", Artist: "Artist"}, + {ID: "b", Title: "Song B", Artist: "Artist"}, + {ID: "c", Title: "Song C", Artist: "Artist"}, + } + setupTitleOnlyExpectations(tracks) + result, err := m.MatchSongsToLibrary(ctx, songs, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + }) + }) + + Context("empty input", func() { + It("returns empty results for no songs", func() { + result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + }) + + Describe("specificity level matching", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 100 + }) + + It("matches by title + artist MBID + album MBID (highest priority)", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist name + album name when MBIDs unavailable", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist only when album info unavailable", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("does not match songs without artist info", func() { + songs := []agents.Song{ + {Name: "Similar Song"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("returns distinct matches for each artist's version (covers scenario)", func() { + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} + + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, + {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + ids := []string{result[0].ID, result[1].ID, result[2].ID} + Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) + }) + + It("prefers more precise matches for each song", func() { + preciseMatch := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", + MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + } + lessAccurateMatch := model.MediaFile{ + ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", + MbzArtistID: "mbid-1", + } + artistTwoMatch := model.MediaFile{ + ID: "artist-two", Title: "Song B", Artist: "Artist Two", + } + + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artist: "Artist Two"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("precise")) + Expect(result[1].ID).To(Equal("artist-two")) + }) + }) + + Describe("fuzzy matching thresholds", func() { + Context("with default threshold (85%)", func() { + It("matches songs with remastered suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + songs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("remastered")) + }) + + It("matches songs with live suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + artistTracks := model.MediaFiles{ + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("live")) + }) + }) + + Context("with threshold set to 100 (exact match only)", func() { + It("only matches exact titles", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + + songs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("with lower threshold (75%)", func() { + It("matches more aggressively", func() { + conf.Server.SimilarSongsMatchThreshold = 75 + + songs := []agents.Song{ + {Name: "Song", Artist: "Artist"}, + } + artistTracks := model.MediaFiles{ + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("extended")) + }) + }) + }) + + Describe("fuzzy album matching", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 85 + }) + + It("matches album with (Remaster) suffix", func() { + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("matches album with (Deluxe Edition) suffix", func() { + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("prefers exact album match over fuzzy album match", func() { + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + exactMatch := model.MediaFile{ + ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + fuzzyMatch := model.MediaFile{ + ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("exact")) + }) + }) + + Describe("duration matching", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 100 + }) + + It("prefers tracks with matching duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + } + wrongDuration := model.MediaFile{ + ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("matches tracks with close duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + closeDuration := model.MediaFile{ + ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + } + + setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("close-duration")) + }) + + It("prefers closer duration over farther duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + closeDuration := model.MediaFile{ + ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + } + farDuration := model.MediaFile{ + ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("close")) + }) + + It("still matches when no tracks have matching duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + differentDuration := model.MediaFile{ + ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("different")) + }) + + It("prefers title match over duration match when titles differ", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + differentTitle := model.MediaFile{ + ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + } + correctTitle := model.MediaFile{ + ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-title")) + }) + + It("matches without duration filtering when agent duration is 0", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + } + anyTrack := model.MediaFile{ + ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("any")) + }) + + It("handles very short songs with close duration", func() { + songs := []agents.Song{ + {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("short")) + }) + }) + + Describe("deduplication edge cases", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 85 + }) + + It("handles mixed scenario with both identical and different input songs", func() { + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, + } + libraryTrack := model.MediaFile{ + ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + } + + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("yesterday")) + Expect(result[1].ID).To(Equal("yesterday")) + }) + + It("does not deduplicate songs that match different library tracks", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} + + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].ID).To(Equal("track-a")) + Expect(result[1].ID).To(Equal("track-b")) + Expect(result[2].ID).To(Equal("track-c")) + }) + + It("respects count limit after deduplication", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song A (Live)", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song B (Remix)", Artist: "Artist"}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-a")) + Expect(result[1].ID).To(Equal("track-b")) + }) + }) +}) + +type mockMediaFileRepo struct { + mock.Mock + model.MediaFileRepository +} + +func newMockMediaFileRepo() *mockMediaFileRepo { + return &mockMediaFileRepo{} +} + +func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.MediaFiles), args.Error(1) +} + +func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) +} + +func (m *mockMediaFileRepo) SetError(hasError bool) { + if hasError { + m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error")) + } +} + +// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a +// squirrel.And whose first element is a squirrel.Eq containing the given field name. +func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 2 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasField := eq[fieldName] + return hasField + } +} + +// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a +// squirrel.Eq containing the given field name. +func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + eq, ok := opt.Filters.(squirrel.Eq) + if !ok { + return false + } + _, hasField := eq[fieldName] + return hasField + } +} diff --git a/core/wire_providers.go b/core/wire_providers.go index 276d9556a..a2fffa34f 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -28,6 +29,7 @@ var Set = wire.NewSet( stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, + matcher.New, wire.Bind(new(external.Agents), new(*agents.Agents)), ffmpeg.New, scrobbler.GetPlayTracker, From 0a6b5519cc9d32f57dad4b4163d988260a33a791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 12 Apr 2026 21:52:29 -0400 Subject: [PATCH 181/596] refactor(scanner): remove C++ taglib adapter (#5349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(build): remove CPP taglib adapter Remove the CGO-based TagLib adapter (adapters/taglib/) and all cross-taglib build infrastructure. The WASM-based go-taglib adapter (adapters/gotaglib/) is now the sole metadata extractor. - Delete adapters/taglib/ (CPP/CGO wrapper) - Delete .github/actions/download-taglib/ - Remove CROSS_TAGLIB_VERSION, CGO_CFLAGS_ALLOW, and all taglib-related references from Dockerfile, Makefile, CI pipeline, and devcontainer * fix(scanner): gracefully fallback to default extractor instead of crashing Replace log.Fatal with a graceful fallback when the configured scanner extractor is not found. Instead of terminating the process, the code now warns and falls back to the default taglib extractor using the existing consts.DefaultScannerExtractor constant. A fatal log is retained only for the case where the default extractor itself is not registered, which indicates a broken build. * test(scanner): cover default extractor fallback and suppress redundant warn Address review feedback on the extractor fallback in newLocalStorage: - Only log the "using default" warning when the configured extractor differs from the default, so a broken build (default extractor itself missing) logs only the fatal — not a misleading "falling back" warn followed immediately by the fatal. - Add a unit test that registers a mock under consts.DefaultScannerExtractor, sets the configured extractor to an unknown name, and asserts the local storage is constructed using the default extractor's constructor. --- .devcontainer/Dockerfile | 12 - .devcontainer/devcontainer.json | 3 +- .github/actions/download-taglib/action.yml | 23 -- .github/workflows/pipeline.yml | 18 +- Dockerfile | 25 +- Makefile | 5 - adapters/taglib/end_to_end_test.go | 274 ------------------- adapters/taglib/get_filename.go | 9 - adapters/taglib/get_filename_win.go | 96 ------- adapters/taglib/taglib.go | 178 ------------ adapters/taglib/taglib_suite_test.go | 17 -- adapters/taglib/taglib_test.go | 295 -------------------- adapters/taglib/taglib_wrapper.cpp | 299 --------------------- adapters/taglib/taglib_wrapper.go | 157 ----------- adapters/taglib/taglib_wrapper.h | 24 -- cmd/root.go | 1 - cmd/wire_gen.go | 1 - core/storage/local/local.go | 9 +- core/storage/local/local_test.go | 26 +- 19 files changed, 32 insertions(+), 1440 deletions(-) delete mode 100644 .github/actions/download-taglib/action.yml delete mode 100644 adapters/taglib/end_to_end_test.go delete mode 100644 adapters/taglib/get_filename.go delete mode 100644 adapters/taglib/get_filename_win.go delete mode 100644 adapters/taglib/taglib.go delete mode 100644 adapters/taglib/taglib_suite_test.go delete mode 100644 adapters/taglib/taglib_test.go delete mode 100644 adapters/taglib/taglib_wrapper.cpp delete mode 100644 adapters/taglib/taglib_wrapper.go delete mode 100644 adapters/taglib/taglib_wrapper.h diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c7ccbf9fa..b2aa76450 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -13,17 +13,5 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar 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.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 \ - && tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \ - && mv /usr/include/taglib/* /usr/include/ \ - && rmdir /usr/include/taglib \ - && rm /tmp/cross-taglib.tar.gz /usr/provenance.json - -ENV CGO_CFLAGS_ALLOW="--define-prefix" - # [Optional] Uncomment this line to install global node packages. # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 81398a3ce..311090b91 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,8 +7,7 @@ "VARIANT": "1.25", // Options "INSTALL_NODE": "true", - "NODE_VERSION": "v24", - "CROSS_TAGLIB_VERSION": "2.2.0-1" + "NODE_VERSION": "v24" } }, "workspaceMount": "", diff --git a/.github/actions/download-taglib/action.yml b/.github/actions/download-taglib/action.yml deleted file mode 100644 index ea6de8783..000000000 --- a/.github/actions/download-taglib/action.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: 'Download TagLib' -description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH' -inputs: - version: - description: 'Version of TagLib to download' - required: true - platform: - description: 'Platform to download TagLib for' - default: 'linux-amd64' -runs: - using: 'composite' - steps: - - name: Download TagLib - shell: bash - run: | - mkdir -p /tmp/taglib - cd /tmp - FILE=taglib-${{ inputs.platform }}.tar.gz - wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE} - tar -xzf ${FILE} -C taglib - PKG_CONFIG_PREFIX=/tmp/taglib - echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV - echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2529aaf36..e939f1d13 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -14,8 +14,6 @@ concurrency: cancel-in-progress: true env: - CROSS_TAGLIB_VERSION: "2.2.0-1" - CGO_CFLAGS_ALLOW: "--define-prefix" IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} jobs: @@ -66,11 +64,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Download TagLib - uses: ./.github/actions/download-taglib - with: - version: ${{ env.CROSS_TAGLIB_VERSION }} - - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: @@ -106,18 +99,11 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v6 - - name: Download TagLib - uses: ./.github/actions/download-taglib - with: - version: ${{ env.CROSS_TAGLIB_VERSION }} - - name: Download dependencies run: go mod download - name: Test - run: | - pkg-config --define-prefix --cflags --libs taglib # for debugging - go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v - name: Test ndpgen run: | @@ -232,7 +218,6 @@ jobs: build-args: | GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} - CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} - name: Upload Binaries uses: actions/upload-artifact@v7 @@ -253,7 +238,6 @@ jobs: build-args: | GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} - CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} outputs: | type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }} type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true diff --git a/Dockerfile b/Dockerfile index b32c1df56..f6ea14ff3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,26 +24,6 @@ RUN cd /out && \ FROM scratch AS xx 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.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 -RUN <<EOT - apk add --no-cache wget - PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-') - FILE=taglib-${PLATFORM}.tar.gz - - DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE} - wget ${DOWNLOAD_URL} - - mkdir /taglib - tar -xzf ${FILE} -C /taglib -EOT - ######################################################################################################################## ### Build Navidrome UI FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui @@ -88,14 +68,11 @@ RUN --mount=type=bind,source=. \ --mount=from=ui,source=/build,target=./ui/build,ro \ --mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \ --mount=type=cache,target=/root/.cache \ - --mount=type=cache,target=/go/pkg/mod \ - --mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT + --mount=type=cache,target=/go/pkg/mod <<EOT # Setup CGO cross-compilation environment xx-go --wrap export CGO_ENABLED=1 - export CGO_CFLAGS_ALLOW="--define-prefix" - export PKG_CONFIG_PATH=/taglib/lib/pkgconfig cat $(go env GOENV) # Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler. diff --git a/Makefile b/Makefile index 0673838c2..4efbc4db6 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,6 @@ comma:=, GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS)) # Set global environment variables, required for most targets -export CGO_CFLAGS_ALLOW=--define-prefix export ND_ENABLEINSIGHTSCOLLECTOR=false ifneq ("$(wildcard .git/HEAD)","") @@ -21,8 +20,6 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin 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.2.1-1 GOLANGCI_LINT_VERSION ?= v2.11.1 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") @@ -179,7 +176,6 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che --platform $(PLATFORMS) \ --build-arg GIT_TAG=${GIT_TAG} \ --build-arg GIT_SHA=${GIT_SHA} \ - --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \ --output "./binaries" --target binary . .PHONY: docker-build @@ -191,7 +187,6 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro --platform $(IMAGE_PLATFORMS) \ --build-arg GIT_TAG=${GIT_TAG} \ --build-arg GIT_SHA=${GIT_SHA} \ - --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \ --tag $(DOCKER_TAG) . .PHONY: docker-image diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go deleted file mode 100644 index 265f258f5..000000000 --- a/adapters/taglib/end_to_end_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package taglib - -import ( - "io/fs" - "os" - "time" - - "github.com/djherbis/times" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils/gg" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type testFileInfo struct { - fs.FileInfo -} - -func (t testFileInfo) BirthTime() time.Time { - if ts := times.Get(t.FileInfo); ts.HasBirthTime() { - return ts.BirthTime() - } - return t.FileInfo.ModTime() -} - -var _ = Describe("Extractor", func() { - toP := func(name, sortName, mbid string) model.Participant { - return model.Participant{ - Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid}, - } - } - - roles := []struct { - model.Role - model.ParticipantList - }{ - {model.RoleComposer, model.ParticipantList{ - toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"), - toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"), - }}, - {model.RoleLyricist, model.ParticipantList{ - toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"), - toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"), - }}, - {model.RoleArranger, model.ParticipantList{ - toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"), - toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"), - }}, - {model.RoleConductor, model.ParticipantList{ - toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"), - toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"), - }}, - {model.RoleDirector, model.ParticipantList{ - toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"), - toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"), - }}, - {model.RoleEngineer, model.ParticipantList{ - toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"), - toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"), - }}, - {model.RoleProducer, model.ParticipantList{ - toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"), - toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"), - }}, - {model.RoleRemixer, model.ParticipantList{ - toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"), - toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"), - }}, - {model.RoleDJMixer, model.ParticipantList{ - toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"), - toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"), - }}, - {model.RoleMixer, model.ParticipantList{ - toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"), - toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"), - }}, - } - - var e *extractor - - parseTestFile := func(path string) *model.MediaFile { - mds, err := e.Parse(path) - Expect(err).ToNot(HaveOccurred()) - - info, ok := mds[path] - Expect(ok).To(BeTrue()) - - fileInfo, err := os.Stat(path) - Expect(err).ToNot(HaveOccurred()) - info.FileInfo = testFileInfo{FileInfo: fileInfo} - - metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") - return &mf - } - - BeforeEach(func() { - e = &extractor{} - }) - - Describe("ReplayGain", func() { - DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { - mf := parseTestFile("tests/fixtures/" + file) - - Expect(mf.RGTrackGain).To(Equal(trackGain)) - Expect(mf.RGTrackPeak).To(Equal(trackPeak)) - Expect(mf.RGAlbumGain).To(Equal(albumGain)) - Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) - }, - Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), - Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), - ) - }) - - Describe("lyrics", func() { - makeLyrics := func(code, secondLine string) model.Lyrics { - return model.Lyrics{ - DisplayArtist: "", - DisplayTitle: "", - Lang: code, - Line: []model.Line{ - {Start: gg.P(int64(0)), Value: "This is"}, - {Start: gg.P(int64(2500)), Value: secondLine}, - }, - Offset: nil, - Synced: true, - } - } - - It("should fetch both synced and unsynced lyrics in mixed flac", func() { - mf := parseTestFile("tests/fixtures/mixed-lyrics.flac") - - lyrics, err := mf.StructuredLyrics() - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(2)) - - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[1].Synced).To(BeFalse()) - }) - - It("should handle mp3 with uslt and sylt", func() { - mf := parseTestFile("tests/fixtures/test.mp3") - - lyrics, err := mf.StructuredLyrics() - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(4)) - - engSylt := makeLyrics("eng", "English SYLT") - engUslt := makeLyrics("eng", "English") - unsSylt := makeLyrics("xxx", "unspecified SYLT") - unsUslt := makeLyrics("xxx", "unspecified") - - Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt)) - }) - - DescribeTable("format-specific lyrics", func(file string, isId3 bool) { - mf := parseTestFile("tests/fixtures/" + file) - - lyrics, err := mf.StructuredLyrics() - Expect(err).To(Not(HaveOccurred())) - Expect(lyrics).To(HaveLen(2)) - - unspec := makeLyrics("xxx", "unspecified") - eng := makeLyrics("xxx", "English") - - if isId3 { - eng.Lang = "eng" - } - - Expect(lyrics).To(Or( - Equal(model.LyricList{unspec, eng}), - Equal(model.LyricList{eng, unspec}))) - }, - Entry("flac", "test.flac", false), - Entry("m4a", "test.m4a", false), - Entry("ogg", "test.ogg", false), - Entry("wma", "test.wma", false), - Entry("wv", "test.wv", false), - Entry("wav", "test.wav", true), - Entry("aiff", "test.aiff", true), - ) - }) - - Describe("Participants", func() { - DescribeTable("test tags consistent across formats", func(format string) { - mf := parseTestFile("tests/fixtures/test." + format) - - for _, data := range roles { - role := data.Role - artists := data.ParticipantList - - actual := mf.Participants[role] - Expect(actual).To(HaveLen(len(artists))) - - for i := range artists { - actualArtist := actual[i] - expectedArtist := artists[i] - - Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) - Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName)) - Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID)) - } - } - - if format != "m4a" { - performers := mf.Participants[model.RolePerformer] - Expect(performers).To(HaveLen(8)) - - rules := map[string][]string{ - "pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"}, - "pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""}, - "pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"}, - "pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"}, - "pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"}, - } - - for name, rule := range rules { - mbid := rule[0] - for i := 1; i < len(rule); i++ { - found := false - - for _, mapped := range performers { - if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] { - found = true - break - } - } - - Expect(found).To(BeTrue(), "Could not find matching artist") - } - } - } - }, - Entry("FLAC format", "flac"), - Entry("M4a format", "m4a"), - Entry("OGG format", "ogg"), - Entry("WV format", "wv"), - - Entry("MP3 format", "mp3"), - Entry("WAV format", "wav"), - Entry("AIFF format", "aiff"), - ) - - It("should parse wma", func() { - mf := parseTestFile("tests/fixtures/test.wma") - - for _, data := range roles { - role := data.Role - artists := data.ParticipantList - actual := mf.Participants[role] - - // WMA has no Arranger role - if role == model.RoleArranger { - Expect(actual).To(HaveLen(0)) - continue - } - - Expect(actual).To(HaveLen(len(artists)), role.String()) - - // For some bizarre reason, the order is inverted. We also don't get - // sort names or MBIDs - for i := range artists { - idx := len(artists) - 1 - i - - actualArtist := actual[i] - expectedArtist := artists[idx] - - Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) - } - } - }) - }) -}) diff --git a/adapters/taglib/get_filename.go b/adapters/taglib/get_filename.go deleted file mode 100644 index df7cab860..000000000 --- a/adapters/taglib/get_filename.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package taglib - -import "C" - -func getFilename(s string) *C.char { - return C.CString(s) -} diff --git a/adapters/taglib/get_filename_win.go b/adapters/taglib/get_filename_win.go deleted file mode 100644 index 2093616c8..000000000 --- a/adapters/taglib/get_filename_win.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build windows - -package taglib - -// From https://github.com/orofarne/gowchar - -/* -#include <wchar.h> - -const size_t SIZEOF_WCHAR_T = sizeof(wchar_t); - -void gowchar_set (wchar_t *arr, int pos, wchar_t val) -{ - arr[pos] = val; -} - -wchar_t gowchar_get (wchar_t *arr, int pos) -{ - return arr[pos]; -} -*/ -import "C" - -import ( - "fmt" - "unicode/utf16" - "unicode/utf8" -) - -var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T) - -func getFilename(s string) *C.wchar_t { - wstr, _ := StringToWcharT(s) - return wstr -} - -func StringToWcharT(s string) (*C.wchar_t, C.size_t) { - switch SIZEOF_WCHAR_T { - case 2: - return stringToWchar2(s) // Windows - case 4: - return stringToWchar4(s) // Unix - default: - panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T)) - } - panic("?!!") -} - -// Windows -func stringToWchar2(s string) (*C.wchar_t, C.size_t) { - var slen int - s1 := s - for len(s1) > 0 { - r, size := utf8.DecodeRuneInString(s1) - if er, _ := utf16.EncodeRune(r); er == '\uFFFD' { - slen += 1 - } else { - slen += 2 - } - s1 = s1[size:] - } - slen++ // \0 - res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T) - var i int - for len(s) > 0 { - r, size := utf8.DecodeRuneInString(s) - if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' { - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1)) - i++ - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2)) - i++ - } else { - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r)) - i++ - } - s = s[size:] - } - C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0 - return (*C.wchar_t)(res), C.size_t(slen) -} - -// Unix -func stringToWchar4(s string) (*C.wchar_t, C.size_t) { - slen := utf8.RuneCountInString(s) - slen++ // \0 - res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T) - var i int - for len(s) > 0 { - r, size := utf8.DecodeRuneInString(s) - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r)) - s = s[size:] - i++ - } - C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0 - return (*C.wchar_t)(res), C.size_t(slen) -} diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go deleted file mode 100644 index ac299ea2b..000000000 --- a/adapters/taglib/taglib.go +++ /dev/null @@ -1,178 +0,0 @@ -package taglib - -import ( - "io/fs" - "path/filepath" - "strconv" - "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" -) - -type extractor struct { - baseDir string -} - -func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) { - results := make(map[string]metadata.Info) - for _, path := range files { - props, err := e.extractMetadata(path) - if err != nil { - continue - } - results[path] = *props - } - return results, nil -} - -func (e extractor) Version() string { - return Version() -} - -func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { - fullPath := filepath.Join(e.baseDir, filePath) - tags, err := Read(fullPath) - if err != nil { - log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err) - return nil, err - } - - // Parse audio properties - ap := metadata.AudioProperties{} - ap.BitRate = parseProp(tags, "__bitrate") - ap.Channels = parseProp(tags, "__channels") - ap.SampleRate = parseProp(tags, "__samplerate") - ap.BitDepth = parseProp(tags, "__bitspersample") - length := parseProp(tags, "__lengthinmilliseconds") - ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10) - - // Extract basic tags - parseBasicTag(tags, "__title", "title") - parseBasicTag(tags, "__artist", "artist") - parseBasicTag(tags, "__album", "album") - parseBasicTag(tags, "__comment", "comment") - parseBasicTag(tags, "__genre", "genre") - parseBasicTag(tags, "__year", "year") - parseBasicTag(tags, "__track", "tracknumber") - - // Parse track/disc totals - parseTuple := func(prop string) { - tagName := prop + "number" - tagTotal := prop + "total" - if value, ok := tags[tagName]; ok && len(value) > 0 { - parts := strings.Split(value[0], "/") - tags[tagName] = []string{parts[0]} - if len(parts) == 2 { - tags[tagTotal] = []string{parts[1]} - } - } - } - parseTuple("track") - parseTuple("disc") - - // Adjust some ID3 tags - parseLyrics(tags) - parseTIPL(tags) - delete(tags, "tmcl") // TMCL is already parsed by TagLib - - return &metadata.Info{ - Tags: tags, - AudioProperties: ap, - HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true", - }, nil -} - -// parseLyrics make sure lyrics tags have language -func parseLyrics(tags map[string][]string) { - lyrics := tags["lyrics"] - if len(lyrics) > 0 { - tags["lyrics:xxx"] = lyrics - delete(tags, "lyrics") - } -} - -// These are the only roles we support, based on Picard's tag map: -// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html -var tiplMapping = map[string]string{ - "arranger": "arranger", - "engineer": "engineer", - "producer": "producer", - "mix": "mixer", - "DJ-mix": "djmixer", -} - -// parseProp parses a property from the tags map and sets it to the target integer. -// It also deletes the property from the tags map after parsing. -func parseProp(tags map[string][]string, prop string) int { - if value, ok := tags[prop]; ok && len(value) > 0 { - v, _ := strconv.Atoi(value[0]) - delete(tags, prop) - return v - } - return 0 -} - -// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map. -// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.), -// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag. -func parseBasicTag(tags map[string][]string, basicName string, tagName string) { - basicValue := tags[basicName] - if len(basicValue) == 0 { - return - } - delete(tags, basicName) - if len(tags[tagName]) == 0 { - tags[tagName] = basicValue - } -} - -// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format: -// -// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson". -// -// and breaks it down into a map of roles and names, e.g.: -// -// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}. -func parseTIPL(tags map[string][]string) { - tipl := tags["tipl"] - if len(tipl) == 0 { - return - } - - addRole := func(currentRole string, currentValue []string) { - if currentRole != "" && len(currentValue) > 0 { - role := tiplMapping[currentRole] - tags[role] = append(tags[role], strings.Join(currentValue, " ")) - } - } - - var currentRole string - var currentValue []string - for _, part := range strings.Split(tipl[0], " ") { - if _, ok := tiplMapping[part]; ok { - addRole(currentRole, currentValue) - currentRole = part - currentValue = nil - continue - } - currentValue = append(currentValue, part) - } - addRole(currentRole, currentValue) - delete(tags, "tipl") -} - -var _ local.Extractor = (*extractor)(nil) - -func init() { - local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor { - // ignores fs, as taglib extractor only works with local files - return &extractor{baseDir} - }) - conf.AddHook(func() { - log.Debug("TagLib version", "version", Version()) - }) -} diff --git a/adapters/taglib/taglib_suite_test.go b/adapters/taglib/taglib_suite_test.go deleted file mode 100644 index 2b26612cf..000000000 --- a/adapters/taglib/taglib_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package taglib - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestTagLib(t *testing.T) { - tests.Init(t, true) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "TagLib Suite") -} diff --git a/adapters/taglib/taglib_test.go b/adapters/taglib/taglib_test.go deleted file mode 100644 index f524f77ec..000000000 --- a/adapters/taglib/taglib_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package taglib - -import ( - "io/fs" - "os" - "strings" - - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Extractor", func() { - var e *extractor - - BeforeEach(func() { - e = &extractor{} - }) - - Describe("Parse", func() { - It("correctly parses metadata from all files in folder", func() { - mds, err := e.Parse( - "tests/fixtures/test.mp3", - "tests/fixtures/test.ogg", - ) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - - // Test MP3 - m := mds["tests/fixtures/test.mp3"] - Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"})) - Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) - Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) - Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) - - Expect(m.HasPicture).To(BeTrue()) - Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s")) - Expect(m.AudioProperties.BitRate).To(Equal(192)) - Expect(m.AudioProperties.Channels).To(Equal(2)) - Expect(m.AudioProperties.SampleRate).To(Equal(44100)) - - Expect(m.Tags).To(Or( - HaveKeyWithValue("compilation", []string{"1"}), - HaveKeyWithValue("tcmp", []string{"1"})), - ) - Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) - Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"})) - Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) - Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"})) - Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"})) - Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) - Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) - Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"})) - - Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"})) - Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) - - Expect(m.Tags).ToNot(HaveKey("lyrics")) - Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{ - "[00:00.00]This is\n[00:02.50]English SYLT\n", - "[00:00.00]This is\n[00:02.50]English", - }), HaveKeyWithValue("lyrics:eng", []string{ - "[00:00.00]This is\n[00:02.50]English", - "[00:00.00]This is\n[00:02.50]English SYLT\n", - }))) - Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", - "[00:00.00]This is\n[00:02.50]unspecified", - }), HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", - }))) - - // Test OGG - m = mds["tests/fixtures/test.ogg"] - Expect(err).To(BeNil()) - Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"})) - - // TagLib 1.12 returns 18, previous versions return 39. - // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b - Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49)) - Expect(m.AudioProperties.Channels).To(BeElementOf(2)) - Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000)) - Expect(m.HasPicture).To(BeTrue()) - }) - - DescribeTable("Format-Specific tests", - func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) { - file = "tests/fixtures/" + file - mds, err := e.Parse(file) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(1)) - - m := mds[file] - - Expect(m.HasPicture).To(Equal(image)) - Expect(m.AudioProperties.Duration.String()).To(Equal(duration)) - Expect(m.AudioProperties.Channels).To(Equal(channels)) - Expect(m.AudioProperties.SampleRate).To(Equal(samplerate)) - Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth)) - - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_album_gain", []string{albumGain}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}), - )) - - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}), - )) - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_track_gain", []string{trackGain}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}), - )) - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}), - )) - - Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"})) - Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) - Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) - Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) - Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) - Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"})) - - Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) - Expect(m.Tags).To(Or( - HaveKeyWithValue("tracknumber", []string{"3"}), - HaveKeyWithValue("tracknumber", []string{"3/10"}), - )) - if !strings.HasSuffix(file, "test.wma") { - // TODO Not sure why this is not working for WMA - Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) - } - Expect(m.Tags).To(Or( - HaveKeyWithValue("discnumber", []string{"1"}), - HaveKeyWithValue("discnumber", []string{"1/2"}), - )) - Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) - - // WMA does not have a "compilation" tag, but "wm/iscompilation" - Expect(m.Tags).To(Or( - HaveKeyWithValue("compilation", []string{"1"}), - HaveKeyWithValue("wm/iscompilation", []string{"1"})), - ) - - if id3Lyrics { - Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{ - "[00:00.00]This is\n[00:02.50]English", - })) - Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - })) - } else { - Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - "[00:00.00]This is\n[00:02.50]English", - })) - } - - Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) - }, - - // ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac - Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true), - - Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), - Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), - Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma - // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order - Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv - Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav - Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true), - - // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff - Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true), - ) - - // Skip these tests when running as root - Context("Access Forbidden", func() { - var accessForbiddenFile string - var RegularUserContext = XContext - var isRegularUser = os.Getuid() != 0 - if isRegularUser { - RegularUserContext = Context - } - - // Only run permission tests if we are not root - RegularUserContext("when run without root privileges", func() { - BeforeEach(func() { - accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") - - f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - Expect(f.Close()).To(Succeed()) - Expect(os.Remove(accessForbiddenFile)).To(Succeed()) - }) - }) - - It("correctly handle unreadable file due to insufficient read permission", func() { - _, err := e.extractMetadata(accessForbiddenFile) - Expect(err).To(MatchError(os.ErrPermission)) - }) - - It("skips the file if it cannot be read", func() { - files := []string{ - "tests/fixtures/test.mp3", - "tests/fixtures/test.ogg", - accessForbiddenFile, - } - mds, err := e.Parse(files...) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - Expect(mds).ToNot(HaveKey(accessForbiddenFile)) - }) - }) - }) - - }) - - Describe("Error Checking", func() { - It("returns a generic ErrPath if file does not exist", func() { - testFilePath := "tests/fixtures/NON_EXISTENT.ogg" - _, err := e.extractMetadata(testFilePath) - Expect(err).To(MatchError(fs.ErrNotExist)) - }) - It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() { - // File has an empty TDAT frame - md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"})) - }) - }) - - Describe("parseTIPL", func() { - var tags map[string][]string - - BeforeEach(func() { - tags = make(map[string][]string) - }) - - Context("when the TIPL string is populated", func() { - It("correctly parses roles and names", func() { - tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"} - parseTIPL(tags) - Expect(tags["arranger"]).To(ConsistOf("Andrew Powell")) - Expect(tags["engineer"]).To(ConsistOf("Chris Blair")) - Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe")) - }) - - It("handles multiple names for a single role", func() { - tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"} - parseTIPL(tags) - Expect(tags["producer"]).To(ConsistOf("Eric Woolfson")) - Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) - }) - - It("discards roles without names", func() { - tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"} - parseTIPL(tags) - Expect(tags).ToNot(HaveKey("producer")) - Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) - }) - }) - - Context("when the TIPL string is empty", func() { - It("does nothing", func() { - tags["tipl"] = []string{""} - parseTIPL(tags) - Expect(tags).To(BeEmpty()) - }) - }) - - Context("when the TIPL is not present", func() { - It("does nothing", func() { - parseTIPL(tags) - Expect(tags).To(BeEmpty()) - }) - }) - }) - -}) diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp deleted file mode 100644 index 2985e8f18..000000000 --- a/adapters/taglib/taglib_wrapper.cpp +++ /dev/null @@ -1,299 +0,0 @@ -#include <stdlib.h> -#include <string.h> - -#define TAGLIB_STATIC -#include <apeproperties.h> -#include <apetag.h> -#include <aifffile.h> -#include <asffile.h> -#include <dsffile.h> -#include <fileref.h> -#include <flacfile.h> -#include <id3v2tag.h> -#include <unsynchronizedlyricsframe.h> -#include <synchronizedlyricsframe.h> -#include <mp4file.h> -#include <mpegfile.h> -#include <opusfile.h> -#include <tpropertymap.h> -#include <vorbisfile.h> -#include <wavfile.h> -#include <wavfile.h> -#include <wavpackfile.h> - -#include "taglib_wrapper.h" - -char has_cover(const TagLib::FileRef f); - -static char TAGLIB_VERSION[16]; - -char* taglib_version() { - snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION); - return (char *)TAGLIB_VERSION; -} - -int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { - TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast); - - if (f.isNull()) { - return TAGLIB_ERR_PARSE; - } - - if (!f.audioProperties()) { - return TAGLIB_ERR_AUDIO_PROPS; - } - - // Add audio properties to the tags - const TagLib::AudioProperties *props(f.audioProperties()); - goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds()); - goPutInt(id, (char *)"__bitrate", props->bitrate()); - goPutInt(id, (char *)"__channels", props->channels()); - goPutInt(id, (char *)"__samplerate", props->sampleRate()); - - // Extract bits per sample for supported formats - int bitsPerSample = 0; - if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) }) - bitsPerSample = apeProperties->bitsPerSample(); - else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) }) - bitsPerSample = asfProperties->bitsPerSample(); - else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) }) - bitsPerSample = flacProperties->bitsPerSample(); - else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) }) - bitsPerSample = mp4Properties->bitsPerSample(); - else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) }) - bitsPerSample = wavePackProperties->bitsPerSample(); - else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) }) - bitsPerSample = aiffProperties->bitsPerSample(); - else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) }) - bitsPerSample = wavProperties->bitsPerSample(); - else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) }) - bitsPerSample = dsfProperties->bitsPerSample(); - - if (bitsPerSample > 0) { - goPutInt(id, (char *)"__bitspersample", bitsPerSample); - } - - // Send all properties to the Go map - TagLib::PropertyMap tags = f.file()->properties(); - - // Make sure at least the basic properties are extracted - TagLib::Tag *basic = f.file()->tag(); - if (!basic->isEmpty()) { - if (!basic->title().isEmpty()) { - tags.insert("__title", basic->title()); - } - if (!basic->artist().isEmpty()) { - tags.insert("__artist", basic->artist()); - } - if (!basic->album().isEmpty()) { - tags.insert("__album", basic->album()); - } - if (!basic->comment().isEmpty()) { - tags.insert("__comment", basic->comment()); - } - if (!basic->genre().isEmpty()) { - tags.insert("__genre", basic->genre()); - } - if (basic->year() > 0) { - tags.insert("__year", TagLib::String::number(basic->year())); - } - if (basic->track() > 0) { - tags.insert("__track", TagLib::String::number(basic->track())); - } - } - - TagLib::ID3v2::Tag *id3Tags = NULL; - - // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) - TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file())); - if (mp3File != NULL) { - id3Tags = mp3File->ID3v2Tag(); - } - - if (id3Tags == NULL) { - TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file())); - if (wavFile != NULL && wavFile->hasID3v2Tag()) { - id3Tags = wavFile->ID3v2Tag(); - } - } - - if (id3Tags == NULL) { - TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())); - if (aiffFile && aiffFile->hasID3v2Tag()) { - id3Tags = aiffFile->tag(); - } - } - - // Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems - // with many players, so they will not be parsed - - if (id3Tags != NULL) { - const auto &frames = id3Tags->frameListMap(); - - for (const auto &kv: frames) { - if (kv.first == "USLT") { - for (const auto &tag: kv.second) { - TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag); - if (frame == NULL) continue; - - tags.erase("LYRICS"); - - const auto bv = frame->language(); - char language[4] = {'x', 'x', 'x', '\0'}; - if (bv.size() == 3) { - strncpy(language, bv.data(), 3); - } - - char *val = const_cast<char*>(frame->text().toCString(true)); - - goPutLyrics(id, language, val); - } - } else if (kv.first == "SYLT") { - for (const auto &tag: kv.second) { - TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag); - if (frame == NULL) continue; - - const auto bv = frame->language(); - char language[4] = {'x', 'x', 'x', '\0'}; - if (bv.size() == 3) { - strncpy(language, bv.data(), 3); - } - - const auto format = frame->timestampFormat(); - if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) { - - for (const auto &line: frame->synchedText()) { - char *text = const_cast<char*>(line.text.toCString(true)); - goPutLyricLine(id, language, text, line.time); - } - } else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) { - const int sampleRate = props->sampleRate(); - - if (sampleRate != 0) { - for (const auto &line: frame->synchedText()) { - const int timeInMs = (line.time * 1000) / sampleRate; - char *text = const_cast<char*>(line.text.toCString(true)); - goPutLyricLine(id, language, text, timeInMs); - } - } - } - } - } else if (kv.first == "TIPL"){ - if (!kv.second.isEmpty()) { - tags.insert(kv.first, kv.second.front()->toString()); - } - } - } - } - - // M4A may have some iTunes specific tags not captured by the PropertyMap interface - TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file())); - if (m4afile != NULL) { - const auto itemListMap = m4afile->tag()->itemMap(); - for (const auto item: itemListMap) { - char *key = const_cast<char*>(item.first.toCString(true)); - for (const auto value: item.second.toStringList()) { - char *val = const_cast<char*>(value.toCString(true)); - goPutM4AStr(id, key, val); - } - } - } - - // WMA/ASF files may have additional tags not captured by the PropertyMap interface - TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file())); - if (asfFile != NULL) { - const TagLib::ASF::Tag *asfTags{asfFile->tag()}; - const auto itemListMap = asfTags->attributeListMap(); - for (const auto item : itemListMap) { - char *key = const_cast<char*>(item.first.toCString(true)); - - for (auto j = item.second.begin(); - j != item.second.end(); ++j) { - - char *val = const_cast<char*>(j->toString().toCString(true)); - goPutStr(id, key, val); - } - } - } - - // Send all collected tags to the Go map - for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end(); - ++i) { - char *key = const_cast<char*>(i->first.toCString(true)); - for (TagLib::StringList::ConstIterator j = i->second.begin(); - j != i->second.end(); ++j) { - char *val = const_cast<char*>((*j).toCString(true)); - goPutStr(id, key, val); - } - } - - // Cover art has to be handled separately - if (has_cover(f)) { - goPutStr(id, (char *)"has_picture", (char *)"true"); - } - - return 0; -} - -// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise. -char has_cover(const TagLib::FileRef f) { - char hasCover = 0; - // ----- MP3 - if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) { - if (mp3File->ID3v2Tag()) { - const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()}; - hasCover = !frameListMap["APIC"].isEmpty(); - } - } - // ----- FLAC - else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) { - hasCover = !flacFile->pictureList().isEmpty(); - } - // ----- MP4 - else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) { - auto &coverItem{mp4File->tag()->itemMap()["covr"]}; - TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()}; - hasCover = !coverArtList.isEmpty(); - } - // ----- Ogg - else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) { - hasCover = !vorbisFile->tag()->pictureList().isEmpty(); - } - // ----- Opus - else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) { - hasCover = !opusFile->tag()->pictureList().isEmpty(); - } - // ----- WAV - else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) { - if (wavFile->hasID3v2Tag()) { - const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() }; - hasCover = !frameListMap["APIC"].isEmpty(); - } - } - // ----- AIFF - else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) { - if (aiffFile->hasID3v2Tag()) { - const auto& frameListMap{ aiffFile->tag()->frameListMap() }; - hasCover = !frameListMap["APIC"].isEmpty(); - } - } - // ----- WMA - else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) { - const TagLib::ASF::Tag *tag{ asfFile->tag() }; - hasCover = tag && tag->attributeListMap().contains("WM/Picture"); - } - // ----- DSF - else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) { - const TagLib::ID3v2::Tag *tag { dsffile->tag() }; - hasCover = tag && !tag->frameListMap()["APIC"].isEmpty(); - } - // ----- WAVPAK (APE tag) - else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) { - if (wvFile->hasAPETag()) { - // This is the particular string that Picard uses - hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty(); - } - } - - return hasCover; -} diff --git a/adapters/taglib/taglib_wrapper.go b/adapters/taglib/taglib_wrapper.go deleted file mode 100644 index 4a979920a..000000000 --- a/adapters/taglib/taglib_wrapper.go +++ /dev/null @@ -1,157 +0,0 @@ -package taglib - -/* -#cgo !windows pkg-config: --define-prefix taglib -#cgo windows pkg-config: taglib -#cgo illumos LDFLAGS: -lstdc++ -lsendfile -#cgo linux darwin CXXFLAGS: -std=c++11 -#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include "taglib_wrapper.h" -*/ -import "C" -import ( - "encoding/json" - "fmt" - "os" - "runtime/debug" - "strconv" - "strings" - "sync" - "sync/atomic" - "unsafe" - - "github.com/navidrome/navidrome/log" -) - -const iTunesKeyPrefix = "----:com.apple.itunes:" - -func Version() string { - return C.GoString(C.taglib_version()) -} - -func Read(filename string) (tags map[string][]string, err error) { - // Do not crash on failures in the C code/library - debug.SetPanicOnFault(true) - defer func() { - if r := recover(); r != nil { - log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r) - err = fmt.Errorf("extractor: recovered from panic: %s", r) - } - }() - - fp := getFilename(filename) - defer C.free(unsafe.Pointer(fp)) - id, m, release := newMap() - defer release() - - log.Trace("extractor: reading tags", "filename", filename, "map_id", id) - res := C.taglib_read(fp, C.ulong(id)) - switch res { - case C.TAGLIB_ERR_PARSE: - // Check additional case whether the file is unreadable due to permission - file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600) - defer file.Close() - - if os.IsPermission(fileErr) { - return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr) - } else if fileErr != nil { - return nil, fmt.Errorf("cannot parse file media file: %w", fileErr) - } else { - return nil, fmt.Errorf("cannot parse file media file") - } - case C.TAGLIB_ERR_AUDIO_PROPS: - return nil, fmt.Errorf("can't get audio properties from file") - } - if log.IsGreaterOrEqualTo(log.LevelDebug) { - j, _ := json.Marshal(m) - log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id) - } else { - log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id) - } - - return m, nil -} - -type tagMap map[string][]string - -var allMaps sync.Map -var mapsNextID atomic.Uint32 - -func newMap() (uint32, tagMap, func()) { - id := mapsNextID.Add(1) - - m := tagMap{} - allMaps.Store(id, m) - - return id, m, func() { - allMaps.Delete(id) - } -} - -func doPutTag(id C.ulong, key string, val *C.char) { - if key == "" { - return - } - - r, _ := allMaps.Load(uint32(id)) - m := r.(tagMap) - k := strings.ToLower(key) - v := strings.TrimSpace(C.GoString(val)) - m[k] = append(m[k], v) -} - -//export goPutM4AStr -func goPutM4AStr(id C.ulong, key *C.char, val *C.char) { - k := C.GoString(key) - - // Special for M4A, do not catch keys that have no actual name - k = strings.TrimPrefix(k, iTunesKeyPrefix) - doPutTag(id, k, val) -} - -//export goPutStr -func goPutStr(id C.ulong, key *C.char, val *C.char) { - doPutTag(id, C.GoString(key), val) -} - -//export goPutInt -func goPutInt(id C.ulong, key *C.char, val C.int) { - valStr := strconv.Itoa(int(val)) - vp := C.CString(valStr) - defer C.free(unsafe.Pointer(vp)) - goPutStr(id, key, vp) -} - -//export goPutLyrics -func goPutLyrics(id C.ulong, lang *C.char, val *C.char) { - doPutTag(id, "lyrics:"+C.GoString(lang), val) -} - -//export goPutLyricLine -func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) { - language := C.GoString(lang) - line := C.GoString(text) - timeGo := int64(time) - - ms := timeGo % 1000 - timeGo /= 1000 - sec := timeGo % 60 - timeGo /= 60 - minimum := timeGo % 60 - formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line) - - key := "lyrics:" + language - - r, _ := allMaps.Load(uint32(id)) - m := r.(tagMap) - k := strings.ToLower(key) - existing, ok := m[k] - if ok { - existing[0] += formattedLine - } else { - m[k] = []string{formattedLine} - } -} diff --git a/adapters/taglib/taglib_wrapper.h b/adapters/taglib/taglib_wrapper.h deleted file mode 100644 index c93f4c14a..000000000 --- a/adapters/taglib/taglib_wrapper.h +++ /dev/null @@ -1,24 +0,0 @@ -#define TAGLIB_ERR_PARSE -1 -#define TAGLIB_ERR_AUDIO_PROPS -2 - -#ifdef __cplusplus -extern "C" { -#endif - -#ifdef WIN32 -#define FILENAME_CHAR_T wchar_t -#else -#define FILENAME_CHAR_T char -#endif - -extern void goPutM4AStr(unsigned long id, char *key, char *val); -extern void goPutStr(unsigned long id, char *key, char *val); -extern void goPutInt(unsigned long id, char *key, int val); -extern void goPutLyrics(unsigned long id, char *lang, char *val); -extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time); -int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id); -char* taglib_version(); - -#ifdef __cplusplus -} -#endif diff --git a/cmd/root.go b/cmd/root.go index 5fdb591ff..08773176a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/taglib" ) var ( diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index b25b4c100..f66df2e75 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -40,7 +40,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/taglib" ) // Injectors from wire_injectors.go: diff --git a/core/storage/local/local.go b/core/storage/local/local.go index cd60c9ef1..5384581e0 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -11,6 +11,7 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/metadata" @@ -28,7 +29,13 @@ type localStorage struct { func newLocalStorage(u url.URL) storage.Storage { newExtractor, ok := extractors[conf.Server.Scanner.Extractor] if !ok || newExtractor == nil { - log.Fatal("Extractor not found", "path", conf.Server.Scanner.Extractor) + if conf.Server.Scanner.Extractor != consts.DefaultScannerExtractor { + log.Warn("Extractor not found, using default", "extractor", conf.Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor) + } + newExtractor = extractors[consts.DefaultScannerExtractor] + if newExtractor == nil { + log.Fatal("Default extractor not registered", "extractor", consts.DefaultScannerExtractor) + } } isWindowsPath := filepath.VolumeName(u.Host) != "" if u.Scheme == storage.LocalSchemaID && isWindowsPath { diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 3ed01bbc4..b977ef4a5 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model/metadata" . "github.com/onsi/ginkgo/v2" @@ -135,16 +136,31 @@ var _ = Describe("LocalStorage", func() { }) }) - Context("with invalid extractor", func() { - It("should handle extractor validation correctly", func() { - // Note: The actual implementation uses log.Fatal which exits the process, - // so we test the normal path where extractors exist + Context("when the configured extractor is not registered", func() { + var defaultExtractor *mockTestExtractor + + BeforeEach(func() { + defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)} + RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor { + return defaultExtractor + }) + DeferCleanup(func() { + lock.Lock() + delete(extractors, consts.DefaultScannerExtractor) + lock.Unlock() + }) + }) + + It("falls back to the default extractor instead of crashing", func() { + conf.Server.Scanner.Extractor = "nonexistent-extractor" u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) storage := newLocalStorage(*u) - Expect(storage).ToNot(BeNil()) + ls, ok := storage.(*localStorage) + Expect(ok).To(BeTrue()) + Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor)) }) }) }) From e53e60d39da2a6cd014a0253e64d3f03da872369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 13 Apr 2026 13:30:05 -0400 Subject: [PATCH 182/596] feat(artwork): enable native libwebp encoding in Docker image (#5350) * feat(docker): add musl build stage for native libwebp support Add a new build-alpine stage using Alpine/musl with xx cross-compilation, producing a dynamically-linked musl binary for the Docker image. The runtime image now installs libwebp, libwebpdemux, and libwebpmux and creates .so symlinks so gen2brain/webp can detect native libwebp via purego/dlopen at startup and use it automatically. The existing Debian/glibc 'build' stage is kept for standalone binary distribution (darwin, windows, and glibc linux binaries); the Docker image now ships the musl build from build-alpine instead. * fix(docker): use dynamic symlinks for libwebp libraries Avoid hardcoding SONAME versions (.so.7, .so.2, .so.3) which break on Alpine version bumps. Also fix misleading comment: the musl build is dynamic (required for purego dlopen), not static. * feat(docker): enable WebP encoding in Docker environment Signed-off-by: Deluan <deluan@navidrome.org> * fix(docker): pin build-alpine stage to Go 1.25 to match base stage Align the new build-alpine stage with the existing glibc 'base' stage, both pinned to Go 1.25. Bumping build-alpine independently would create a version skew between the Docker image binary and the standalone binaries, which should be avoided unless there is a specific reason. * fix(docker): harden build-alpine stage (musl pin, -latomic, dynamic-link check) Address review feedback on the build-alpine stage: - Pin Go builder to golang:1.25-alpine3.20 so the musl version used at build time matches the alpine:3.20 runtime image, eliminating any potential musl ABI skew between builder and runtime. - Add -extldflags '-latomic' so SQLite's 64-bit atomics resolve when cross-compiling for 32-bit arm targets (arm/v6, arm/v7). - Add a build-time check that the produced binary is dynamically linked (using 'file' from Alpine), failing the build if it is not. A fully-static binary cannot dlopen libwebp and would silently fall back to the WASM encoder, defeating the whole point of this stage. * fix(docker): revert to unpinned golang:1.25-alpine builder The golang:1.25-alpine3.20 tag suggested during review does not exist on public.ecr.aws (only 3.21, 3.22, 3.23, and unpinned 'alpine' are published). Revert to the unpinned 'golang:1.25-alpine' tag so the Docker build can resolve the base image. This means the builder's Alpine version can drift relative to the alpine:3.20 runtime, but in practice musl's backward compatibility covers this for Navidrome's small dlopen surface (a few libwebp symbols, no direct libc calls from the dlopen path). If a skew ever manifests, we can pin both builder and runtime to the same specific Alpine release in a follow-up. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- Dockerfile | 55 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index f6ea14ff3..66243f84c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,46 @@ FROM scratch AS ui-bundle COPY --from=ui /build /build ######################################################################################################################## -### Build Navidrome binary +### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-alpine AS build-alpine +COPY --from=xx / / + +ARG TARGETPLATFORM + +RUN apk add --no-cache clang lld file git +RUN xx-apk add --no-cache gcc musl-dev zlib-dev +RUN xx-verify --setup + +WORKDIR /workspace + +RUN --mount=type=bind,source=. \ + --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download + +ARG GIT_SHA +ARG GIT_TAG + +RUN --mount=type=bind,source=. \ + --mount=from=ui,source=/build,target=./ui/build,ro \ + --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/go/pkg/mod <<EOT + set -e + xx-go --wrap + export CGO_ENABLED=1 + # -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve. + go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \ + -linkmode=external -extldflags '-latomic' \ + -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ + -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ + -o /out/navidrome . + # Fail the build if the binary is accidentally statically linked: dlopen (and + # therefore native libwebp detection) only works with a dynamic interpreter. + file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; } +EOT + +######################################################################################################################## +### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / @@ -104,17 +143,23 @@ FROM public.ecr.aws/docker/library/alpine:3.20 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" -# Install ffmpeg and mpv -RUN apk add -U --no-cache ffmpeg mpv sqlite +# Install runtime dependencies +# - libwebp + symlinks: enables native WebP encoding via purego/dlopen +RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ + for lib in libwebp libwebpdemux libwebpmux; do \ + target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ + [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ + done -# Copy navidrome binary -COPY --from=build /out/navidrome /app/ +# Copy navidrome binary (musl build for Docker, enables native libwebp) +COPY --from=build-alpine /out/navidrome /app/ VOLUME ["/data", "/music"] ENV ND_MUSICFOLDER=/music ENV ND_DATAFOLDER=/data ENV ND_CONFIGFILE=/data/navidrome.toml ENV ND_PORT=4533 +ENV ND_ENABLEWEBPENCODING=true RUN touch /.nddockerenv EXPOSE ${ND_PORT} From 02c9fc3359fe07e96b5e9054008e2def156e660b Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 13 Apr 2026 20:32:42 -0400 Subject: [PATCH 183/596] chore(deps): update go-sqlite3 and other dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 24 ++++++++++++------------ go.sum | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 4f4ad0461..ebac8064f 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mattn/go-sqlite3 v1.14.38 + github.com/mattn/go-sqlite3 v1.14.42 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.28.1 @@ -58,12 +58,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.38.0 - golang.org/x/net v0.52.0 + golang.org/x/image v0.39.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 - golang.org/x/term v0.41.0 - golang.org/x/text v0.35.0 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 + golang.org/x/text v0.36.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -101,7 +101,7 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig v1.3.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect @@ -134,10 +134,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 5a0761f15..29b979413 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= -github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -161,8 +161,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= -github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= +github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= @@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= 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.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= -github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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= @@ -319,19 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -343,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -369,11 +369,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -382,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -394,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 15e011bd49be37ecd4297ce6092aff65858ff2bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:38:03 -0400 Subject: [PATCH 184/596] chore(deps-dev): bump flatted from 3.3.3 to 3.4.2 in /ui (#5236) Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From e86d3266c41a341bd2349693d42c414298fecb3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 14 Apr 2026 19:19:42 -0400 Subject: [PATCH 185/596] Add context7.json with URL and public key --- context7.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 context7.json diff --git a/context7.json b/context7.json new file mode 100644 index 000000000..343873063 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/navidrome/navidrome", + "public_key": "pk_WqzhKScNKWQ84J4n0oG0J" +} From 155e293f4d57bcb38e36ad62dbf204029e720838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 14 Apr 2026 19:31:01 -0400 Subject: [PATCH 186/596] chore(deps): upgrade Go to 1.26 (#5361) Bump the main module, Dockerfile build stages, and devcontainer to Go 1.26.0. Plugin sub-modules under plugins/ remain on go 1.25 intentionally (independent modules, untouched in this change). Also add an explicit actions/setup-go@v6 step (with go-version-file: go.mod) to the go-lint and go jobs in the CI pipeline. This matches the golangci-lint-action v4+ requirement that setup-go run before the linter, and pins the runner Go version to go.mod so CI does not depend on the ubuntu-latest tools cache picking up Go 1.26. --- .devcontainer/devcontainer.json | 2 +- .github/workflows/pipeline.yml | 8 ++++++++ Dockerfile | 4 ++-- go.mod | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 311090b91..c9e4ba2bf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.25", + "VARIANT": "1.26", // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24" diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index e939f1d13..6ebb579e8 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -64,6 +64,10 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: @@ -99,6 +103,10 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Download dependencies run: go mod download diff --git a/Dockerfile b/Dockerfile index 66243f84c..105656afb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -82,7 +82,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace diff --git a/go.mod b/go.mod index ebac8064f..b7dbb9eeb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.25.0 +go 1.26.0 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a From 28eba567a74c348263f3c59e1882c54b4fec9f33 Mon Sep 17 00:00:00 2001 From: bobo-xxx <111567133+bobo-xxx@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:35:33 +0800 Subject: [PATCH 187/596] fix(artwork): return correct timestamp when disc or album coverart changes (#5378) * fix(artwork): return imagesUpdatedAt in LastUpdated when cover art changes When cover art (cover.jpg) is updated in an album folder, the HTTP Last-Modified header was incorrectly returning album.UpdatedAt (which only tracks media file changes) instead of imagesUpdatedAt (which tracks cover art changes). This caused browsers to use their cached cover art because the Last-Modified header didn't change, even though the actual cover art image data was new (due to cache key changing based on imagesUpdatedAt). The fix ensures LastUpdated() returns a.lastUpdate (which is the max of album.UpdatedAt and imagesUpdatedAt) instead of always returning album.UpdatedAt. Fixes navidrome/navidrome#5377 * refactor tests Signed-off-by: Deluan <deluan@navidrome.org> * fix(artwork): return imagesUpdatedAt in disc LastUpdated The discArtworkReader had the same bug as albumArtworkReader (fixed in 9a741859f): LastUpdated() returned album.UpdatedAt while Key() used the max of album.UpdatedAt and ImagesUpdatedAt. This mismatch caused browsers to keep stale disc cover art in cache when only the image file changed. Also strengthen the album LastUpdated tests and add matching tests for the disc reader. The tests use DescribeTable and were verified to fail when the fix is reverted. --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org> --- core/artwork/artwork_internal_test.go | 48 +++++++++++++++++++++++++-- core/artwork/reader_album.go | 2 +- core/artwork/reader_disc.go | 2 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 380352d3f..0c03ef0ca 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -7,12 +7,11 @@ import ( "image/jpeg" "image/png" "io" - "os" "path/filepath" + "time" _ "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -146,6 +145,51 @@ var _ = Describe("Artwork", func() { Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), ) }) + Context("LastUpdated", func() { + // Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header. + // It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate + // cached cover art when only the image file changes. + now := time.Now().Truncate(time.Second) + DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", + func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { + album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} + folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + Expect(ar.LastUpdated()).To(Equal(expected)) + }, + Entry("album newer than images", now, now.Add(-1*time.Hour), now), + Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), + Entry("equal timestamps", now, now, now), + ) + }) + }) + Describe("discArtworkReader", func() { + Context("LastUpdated", func() { + // Regression test for #5377: same bug as albumArtworkReader — disc covers + // must also revalidate when the image file changes, not only when media files do. + now := time.Now().Truncate(time.Second) + DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", + func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { + album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} + folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"}, + }) + + artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil) + dr, err := newDiscArtworkReader(ctx, aw, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(dr.LastUpdated()).To(Equal(expected)) + }, + Entry("album newer than images", now, now.Add(-1*time.Hour), now), + Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), + Entry("equal timestamps", now, now, now), + ) + }) }) Describe("artistArtworkReader", func() { Context("Multiple covers", func() { diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 641b12b33..35d489b6c 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -72,7 +72,7 @@ func (a *albumArtworkReader) Key() string { ) } func (a *albumArtworkReader) LastUpdated() time.Time { - return a.album.UpdatedAt + return a.lastUpdate } func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 5a7a8a65e..30d4968e1 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -116,7 +116,7 @@ func (d *discArtworkReader) Key() string { } func (d *discArtworkReader) LastUpdated() time.Time { - return d.album.UpdatedAt + return d.lastUpdate } func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { From 3b7d3f4383c7818a7269a7fd6b27a06c0e882934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Apr 2026 12:54:41 -0400 Subject: [PATCH 188/596] feat(matcher): add Matcher.PreferStarred option to bias fuzzy matcher toward starred/high-rated tracks (#5387) * matcher: update godoc for matcher config scoring order * conf: log deprecated SimilarSongsMatchThreshold option * conf: enable matcher prefer-starred by default --- conf/configuration.go | 14 ++++- core/external/provider_topsongs_test.go | 2 +- core/matcher/matcher.go | 19 ++++-- core/matcher/matcher_test.go | 79 +++++++++++++++++++------ 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index a8b0e4c8a..0b44f8f62 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -60,8 +60,8 @@ type configOptions struct { SmartPlaylistRefreshDelay time.Duration AutoTranscodeDownload bool DefaultDownsamplingFormat string - Search searchOptions `json:",omitzero"` - SimilarSongsMatchThreshold int + Search searchOptions `json:",omitzero"` + Matcher matcherOptions `json:",omitzero"` RecentlyAddedByModTime bool PreferSortTags bool IgnoredArticles string @@ -261,6 +261,11 @@ type searchOptions struct { FullString bool } +type matcherOptions struct { + PreferStarred bool + FuzzyThreshold int +} + // logFatal prints a fatal error message to stderr and exits. // Overridden in tests to allow testing fatal paths. var logFatal = func(args ...any) { @@ -291,6 +296,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") + mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") err := viper.Unmarshal(&Server) if err != nil { @@ -424,6 +430,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") + logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") @@ -716,7 +723,8 @@ func setViperDefaults() { viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) viper.SetDefault("search.fullstring", false) viper.SetDefault("search.backend", "fts") - viper.SetDefault("similarsongsmatchthreshold", 85) + viper.SetDefault("matcher.preferstarred", true) + viper.SetDefault("matcher.fuzzythreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 4bd0e5959..0d9b5800d 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -30,7 +30,7 @@ var _ = Describe("Provider - TopSongs", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) // Disable fuzzy matching for these tests to avoid unexpected GetAll calls - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 ctx = GinkgoT().Context() diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 40d4dc160..cf6c99e28 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -46,18 +46,20 @@ func New(ds model.DataStore) *Matcher { // # Fuzzy Matching Details // // For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable -// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by: +// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: // // 1. Title similarity (Jaro-Winkler score, 0.0-1.0) // 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Specificity level (0-5, based on metadata precision): +// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is +// starred or has rating >= 4) +// 4. Specificity level (0-5, based on metadata precision): // - Level 5: Title + Artist MBID + Album MBID (most specific) // - Level 4: Title + Artist MBID + Album name (fuzzy) // - Level 3: Title + Artist name + Album name (fuzzy) // - Level 2: Title + Artist MBID // - Level 1: Title + Artist name // - Level 0: Title only -// 4. Album similarity (Jaro-Winkler, as final tiebreaker) +// 5. Album similarity (Jaro-Winkler, as final tiebreaker) // // # Examples // @@ -250,6 +252,7 @@ type songQuery struct { type matchScore struct { titleSimilarity float64 durationProximity float64 + preferredMatch bool albumSimilarity float64 specificityLevel int } @@ -262,6 +265,9 @@ func (s matchScore) betterThan(other matchScore) bool { if s.durationProximity != other.durationProximity { return s.durationProximity > other.durationProximity } + if s.preferredMatch != other.preferredMatch { + return s.preferredMatch + } if s.specificityLevel != other.specificityLevel { return s.specificityLevel > other.specificityLevel } @@ -322,7 +328,7 @@ func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents return map[string]model.MediaFile{}, nil } - threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0 + threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 byArtist := map[string][]songQuery{} for _, q := range queries { @@ -393,6 +399,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t score := matchScore{ titleSimilarity: titleSim, durationProximity: durationProximity(q.durationMs, t.mf.Duration), + preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), albumSimilarity: albumSim, specificityLevel: computeSpecificityLevel(q, t, threshold), } @@ -406,6 +413,10 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t return bestMatch, found } +func isPreferredTrack(mf *model.MediaFile) bool { + return mf.Starred || mf.Rating >= 4 +} + // buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { var queries []songQuery diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index b1f59b258..8996cf71d 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -78,7 +78,7 @@ var _ = Describe("Matcher", func() { Describe("MatchSongsToLibrary", func() { Context("matching by direct ID", func() { It("matches songs with an ID field to MediaFiles by ID", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, } @@ -96,7 +96,7 @@ var _ = Describe("Matcher", func() { Context("matching by MBID", func() { It("matches songs with MBID to tracks with matching mbz_recording_id", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, } @@ -115,7 +115,7 @@ var _ = Describe("Matcher", func() { Context("matching by ISRC", func() { It("matches songs with ISRC to tracks with matching ISRC tag", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, } @@ -134,7 +134,7 @@ var _ = Describe("Matcher", func() { Context("fuzzy title+artist matching", func() { It("matches songs by title and artist name", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, } @@ -149,7 +149,7 @@ var _ = Describe("Matcher", func() { }) It("matches songs with fuzzy title similarity", func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ {Name: "Bohemian Rhapsody", Artist: "Queen"}, } @@ -164,7 +164,7 @@ var _ = Describe("Matcher", func() { }) It("does not match completely different titles", func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ {Name: "Yesterday", Artist: "The Beatles"}, } @@ -180,7 +180,7 @@ var _ = Describe("Matcher", func() { Context("deduplication", func() { It("removes duplicates when different input songs match the same library track", func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, @@ -196,7 +196,7 @@ var _ = Describe("Matcher", func() { }) It("preserves duplicates when identical input songs match the same library track", func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, @@ -215,7 +215,7 @@ var _ = Describe("Matcher", func() { Context("priority ordering", func() { It("prefers ID match over MBID match", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 // Song has both ID and MBID set. The matcher should resolve via ID // and short-circuit the MBID phase entirely, so no MBID fetch should // occur even though an mbz_recording_id exists in the input. @@ -236,7 +236,7 @@ var _ = Describe("Matcher", func() { Context("count limit", func() { It("returns at most 'count' results", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ {Name: "Song A", Artist: "Artist"}, {Name: "Song B", Artist: "Artist"}, @@ -265,7 +265,7 @@ var _ = Describe("Matcher", func() { Describe("specificity level matching", func() { BeforeEach(func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 }) It("matches by title + artist MBID + album MBID (highest priority)", func() { @@ -396,7 +396,7 @@ var _ = Describe("Matcher", func() { Describe("fuzzy matching thresholds", func() { Context("with default threshold (85%)", func() { It("matches songs with remastered suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ {Name: "Paranoid Android", Artist: "Radiohead"}, @@ -415,7 +415,7 @@ var _ = Describe("Matcher", func() { }) It("matches songs with live suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ {Name: "Bohemian Rhapsody", Artist: "Queen"}, @@ -436,7 +436,7 @@ var _ = Describe("Matcher", func() { Context("with threshold set to 100 (exact match only)", func() { It("only matches exact titles", func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ {Name: "Paranoid Android", Artist: "Radiohead"}, @@ -456,7 +456,7 @@ var _ = Describe("Matcher", func() { Context("with lower threshold (75%)", func() { It("matches more aggressively", func() { - conf.Server.SimilarSongsMatchThreshold = 75 + conf.Server.Matcher.FuzzyThreshold = 75 songs := []agents.Song{ {Name: "Song", Artist: "Artist"}, @@ -478,7 +478,8 @@ var _ = Describe("Matcher", func() { Describe("fuzzy album matching", func() { BeforeEach(func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 + conf.Server.Matcher.PreferStarred = false }) It("matches album with (Remaster) suffix", func() { @@ -540,11 +541,53 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("exact")) }) + + It("prefers starred songs over better album match when enabled", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + albumMatch := model.MediaFile{ + ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + starredTrack := model.MediaFile{ + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, + } + + setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("starred")) + }) + + It("prefers 4-star songs over better album match when enabled", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + albumMatch := model.MediaFile{ + ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + ratedTrack := model.MediaFile{ + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, + } + + setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("rated")) + }) }) Describe("duration matching", func() { BeforeEach(func() { - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 }) It("prefers tracks with matching duration", func() { @@ -678,7 +721,7 @@ var _ = Describe("Matcher", func() { Describe("deduplication edge cases", func() { BeforeEach(func() { - conf.Server.SimilarSongsMatchThreshold = 85 + conf.Server.Matcher.FuzzyThreshold = 85 }) It("handles mixed scenario with both identical and different input songs", func() { From 64c8d3f4c5c3a17cae9d727f8e18910a64b3a330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Apr 2026 13:16:47 -0400 Subject: [PATCH 189/596] ci: run Go tests on Windows (#5380) * ci(windows): add skeleton go-windows job (compile-only smoke test) * ci(windows): fix comment to reference Task 7 not Task 6 * ci(windows): harden PATH visibility and set explicit bash shell * ci(windows): enable full go test suite and ndpgen check * test(gotaglib): skip Unix-only permission tests on Windows * test(lyrics): skip Windows-incompatible tests * test(utils): skip Windows-incompatible tests * test(mpv): skip Windows-incompatible playback tests Skip 3 subprocess-execution tests that rely on Unix-style mpv invocation; .bat output includes \r-terminated lines that break argument parsing (#TBD-mpv-windows). * test(storage): skip Windows-incompatible tests Skip relative-path test where filepath.Join uses backslash but the storage implementation returns a forward-slash URL path (#TBD-path-sep-storage). * test(storage/local): skip Windows-incompatible tests Skip 13 tests that fail because url.Parse("file://" + windowsPath) treats the drive letter colon as an invalid port; also skip the Windows drive-letter path test that exposes a backslash vs forward-slash normalisation bug (#TBD-path-sep-storage-local). * test(playlists): skip Windows-incompatible tests * test(model): skip Windows-incompatible tests * test(model/metadata): skip Windows-incompatible tests * test(core): skip Windows-incompatible tests AbsolutePath uses filepath.Join which produces OS-native path separators; skip the assertion test on Windows until the production code is fixed (#TBD-path-sep-core). * test(artwork): skip Windows-incompatible tests Artwork readers produce OS-native path separators on Windows while tests assert forward-slash paths; skip 11 affected tests pending a fix in production code (#TBD-path-sep-artwork). * test(persistence): skip Windows-incompatible tests Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths to backslashes on Windows. * test(scanner): skip Windows-incompatible tests Skip symlink tests (Unix-assumption), ndignore path-separator bugs (#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS forward-slash expectations, error message mismatch on Windows, and file format upgrade detection (#TBD-path-sep-scanner). * test(plugins): skip Windows-incompatible tests Add //go:build !windows tags to test files that reference the suite bootstrap (testManager, testdataDir, createTestManager) which is only compiled on non-Windows. Add a Windows-only suite stub that skips all specs via BeforeEach to prevent [build failed] on Windows CI. * test(server): skip Windows-incompatible tests Skip createUnixSocketFile tests that rely on Unix file permission bits (chmod/fchmod) which are not supported on Windows. * test(nativeapi): skip Windows-incompatible tests Skip the i18n JSON validation test that uses filepath.Join to build embedded-FS paths; filepath.Join produces backslashes on Windows which breaks fs.Open (embedded FS always uses forward slashes). * test(e2e): skip Windows-incompatible tests On Windows, SQLite holds file locks that prevent the Ginkgo TempDir DeferCleanup from deleting the DB file. Register an explicit db.Close DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock is released before the temp directory is removed. * test(windows): fix e2e AfterSuite and skip remaining scanner path test * test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner) * test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic) * test(scanner): skip 'detects file moved to different folder' on Windows * test(scanner): consolidate 'Library changes' Windows skips into BeforeEach * test(scanner): close DB before TempDir cleanup to fix Windows file lock * test(scanner): skip ScanFolders suite on Windows instead of closing shared DB * ci: retrigger for Windows soak run 2/3 * ci: retrigger for Windows soak run 3/3 * ci: retrigger for Windows soak run 3/3 (take 2) * test(scanner): skip Multi-Library suite on Windows (SQLite file lock) * ci(windows): promote go-windows to blocking status check * test(plugins): run platform-neutral specs on Windows, drop blanket Skip * test(windows): make tests cross-platform instead of skipping - subsonic: back-date submissionTime baseline by 1s so BeTemporally(">") passes under millisecond clock resolution - persistence: sleep briefly between Put calls so UpdatedAt is strictly after CreatedAt on low-resolution clocks - utils/files: close tempFile before os.Remove so the test works on Windows (where an open handle holds a file lock) - tests.TempFile: close the handle before returning; metadata tests no longer leak the open file into Ginkgo's TempDir cleanup Resolves Copilot review comments on #5380. * test(tests): add SkipOnWindows helper to reduce boilerplate Introduces tests.SkipOnWindows(reason) that wraps the 3-line runtime.GOOS guard pattern used in every Windows-skipped spec. * test(adapters): use tests.SkipOnWindows helper * test(core): use tests.SkipOnWindows helper * test(model): use tests.SkipOnWindows helper * test(persistence): use tests.SkipOnWindows helper * test(scanner): use tests.SkipOnWindows helper * test(server): use tests.SkipOnWindows helper * test(plugins): run pure-Go unit tests on Windows config_validation_test, manager_loader_test, and migrate_test have no WASM/exec dependencies and don't rely on the make-built test plugins from plugins_suite_test.go. Let them run on Windows too. --- .github/workflows/pipeline.yml | 75 +++++++++++++++++++++++- adapters/gotaglib/gotaglib_test.go | 2 + core/artwork/artwork_internal_test.go | 6 ++ core/artwork/reader_artist_test.go | 3 + core/common_test.go | 1 + core/lyrics/lyrics_test.go | 2 + core/playback/mpv/mpv_test.go | 4 ++ core/playlists/import_test.go | 4 ++ core/playlists/parse_m3u_test.go | 2 + core/storage/local/local_test.go | 11 ++++ core/storage/storage_test.go | 2 + model/folder_test.go | 3 + model/mediafile_test.go | 5 ++ model/metadata/persistent_ids_test.go | 2 + model/playlist_test.go | 2 + persistence/folder_repository_test.go | 5 ++ persistence/library_repository_test.go | 6 ++ plugins/config_validation_test.go | 2 - plugins/manager_loader_test.go | 2 - plugins/manager_test.go | 2 + plugins/manager_watcher_test.go | 2 + plugins/metadata_agent_test.go | 2 + plugins/migrate_test.go | 2 - plugins/plugins_suite_windows_test.go | 23 ++++++++ scanner/phase_4_playlists_test.go | 1 + scanner/scanner_multilibrary_test.go | 1 + scanner/scanner_selective_test.go | 1 + scanner/scanner_test.go | 3 + scanner/walk_dir_tree_test.go | 2 + scanner/watcher_test.go | 6 ++ server/e2e/e2e_suite_test.go | 7 +++ server/nativeapi/translations_test.go | 2 + server/server_test.go | 2 + server/subsonic/media_annotation_test.go | 4 +- tests/test_helpers.go | 23 +++++++- utils/files_test.go | 4 ++ 36 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 plugins/plugins_suite_windows_test.go diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6ebb579e8..09fca2572 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -120,6 +120,79 @@ jobs: go build -o ndpgen . ./ndpgen --help + go-windows: + name: Test Go code (Windows) + runs-on: windows-2022 + env: + FFMPEG_VERSION: "7.1" + FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: mingw-w64-x86_64-gcc + update: false + + - name: Add mingw64 to PATH + shell: bash + run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH + + - name: Cache ffmpeg + id: ffmpeg-cache + uses: actions/cache@v4 + with: + path: C:\ffmpeg + key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 + + - name: Download ffmpeg + if: steps.ffmpeg-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + $asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}" + $url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip" + Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip + Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted + New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null + Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin + Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin + + - name: Add ffmpeg to PATH + shell: bash + run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH + + - name: Verify toolchain + shell: pwsh + run: | + go version + where.exe gcc + gcc --version + ffmpeg -version + ffprobe -version + + - name: Download dependencies + shell: bash + run: go mod download + + - name: Test + shell: bash + env: + CGO_ENABLED: "1" + run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v + + - name: Test ndpgen + shell: pwsh + run: | + cd plugins\cmd\ndpgen + go test -shuffle=on -v + go build -o ndpgen.exe . + .\ndpgen.exe --help + js: name: Test JS code runs-on: ubuntu-latest @@ -184,7 +257,7 @@ jobs: build: name: Build - needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled] + needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go index 6756fb690..05924914d 100644 --- a/adapters/gotaglib/gotaglib_test.go +++ b/adapters/gotaglib/gotaglib_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -213,6 +214,7 @@ var _ = Describe("Extractor", func() { // Only run permission tests if we are not root RegularUserContext("when run without root privileges", func() { BeforeEach(func() { + tests.SkipOnWindows("uses Unix file permission bits") // Use root fs for absolute paths in temp directory e = &extractor{fs: os.DirFS("/")} accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 0c03ef0ca..12a7085e8 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -80,6 +80,7 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) @@ -103,6 +104,7 @@ var _ = Describe("Artwork", func() { }) }) It("returns external cover", func() { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"front.png"}, @@ -133,6 +135,7 @@ var _ = Describe("Artwork", func() { }) DescribeTable("CoverArtPriority", func(priority string, expected string) { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.CoverArtPriority = priority aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) @@ -210,6 +213,7 @@ var _ = Describe("Artwork", func() { }) DescribeTable("ArtistArtPriority", func(priority string, expected string) { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.ArtistArtPriority = priority aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) @@ -247,6 +251,7 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) @@ -254,6 +259,7 @@ var _ = Describe("Artwork", func() { Expect(path).To(Equal("tests/fixtures/test.mp3")) }) It("returns embed cover if successfully extracted by ffmpeg", func() { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) Expect(err).ToNot(HaveOccurred()) r, path, err := aw.Reader(ctx) diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 5e2066aeb..220c7554f 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -61,6 +62,7 @@ var _ = Describe("artistArtworkReader", func() { When("artist has only one album", func() { It("returns the parent folder", func() { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), } @@ -86,6 +88,7 @@ var _ = Describe("artistArtworkReader", func() { When("the album paths contain same prefix", func() { It("returns the common prefix", func() { + tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), filepath.FromSlash("/music/artist/album2"), diff --git a/core/common_test.go b/core/common_test.go index c8dde12d9..0d6e3a299 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -41,6 +41,7 @@ var _ = Describe("common.go", func() { }) It("returns the absolute path when library exists", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)") ctx := context.Background() abs := AbsolutePath(ctx, ds, libId, path) Expect(abs).To(Equal("/library/root/music/file.mp3")) diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 2e495a714..7e837782e 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" @@ -93,6 +94,7 @@ var _ = Describe("sources", func() { var accessForbiddenFile string BeforeEach(func() { + tests.SkipOnWindows("uses Unix file permission bits") accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222) diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go index b1f2435a3..6754b39ac 100644 --- a/core/playback/mpv/mpv_test.go +++ b/core/playback/mpv/mpv_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -199,6 +200,7 @@ var _ = Describe("MPV", func() { }) It("executes MPV command and captures arguments correctly", func() { + tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -226,6 +228,7 @@ var _ = Describe("MPV", func() { }) It("handles file paths with spaces", func() { + tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -253,6 +256,7 @@ var _ = Describe("MPV", func() { }) It("passes all snapcast arguments correctly", func() { + tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index a6320bc7e..53855d781 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -183,6 +183,7 @@ var _ = Describe("Playlists - Import", func() { }) It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() { + tests.SkipOnWindows("relies on Unix /etc filesystem") tmpDir := GinkgoT().TempDir() m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n" @@ -320,6 +321,7 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{})) }) It("returns an error if the playlist is not well-formed", func() { + tests.SkipOnWindows("line-ending differences affect JSON error offset") _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp") Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'")) }) @@ -347,6 +349,7 @@ var _ = Describe("Playlists - Import", func() { DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", func(storedForm, filesystemForm string) { + tests.SkipOnWindows("/tmp hardcoded in test") // Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301) plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed) plsNameNFD := norm.NFD.String(plsNameNFC) @@ -821,6 +824,7 @@ var _ = Describe("Playlists - Import", func() { }) It("returns true if folder is in PlaylistsPath", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") conf.Server.PlaylistsPath = "other/**:playlists/**" Expect(playlists.InPath(folder)).To(BeTrue()) }) diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index 05e1c30e1..d7fd5e001 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -15,6 +15,7 @@ var _ = Describe("libraryMatcher", func() { ctx := context.Background() BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") mockLibRepo = &tests.MockLibraryRepo{} ds = &tests.MockDataStore{ MockedLibrary: mockLibRepo, @@ -196,6 +197,7 @@ var _ = Describe("pathResolver", func() { ctx := context.Background() BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") mockLibRepo = &tests.MockLibraryRepo{} ds = &tests.MockDataStore{ MockedLibrary: mockLibRepo, diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index b977ef4a5..aef89cdd5 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -44,6 +45,10 @@ var _ = Describe("LocalStorage", func() { }) Describe("newLocalStorage", func() { + BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") + }) + Context("with valid path", func() { It("should create a localStorage instance with correct path", func() { u, err := url.Parse("file://" + tempDir) @@ -166,6 +171,10 @@ var _ = Describe("LocalStorage", func() { }) Describe("localStorage.FS", func() { + BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") + }) + Context("with existing directory", func() { It("should return a localFS instance", func() { u, err := url.Parse("file://" + tempDir) @@ -199,6 +208,7 @@ var _ = Describe("LocalStorage", func() { var testFile string BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // Create a test file testFile = filepath.Join(tempDir, "test.mp3") err := os.WriteFile(testFile, []byte("test data"), 0600) @@ -380,6 +390,7 @@ var _ = Describe("LocalStorage", func() { Describe("Storage registration", func() { It("should register localStorage for file scheme", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // This tests the init() function indirectly storage, err := storage.For("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 60496e611..32fbac413 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -54,6 +55,7 @@ var _ = Describe("Storage", func() { Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) }) It("should return a file implementation for a relative folder", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)") s, err := For("tmp") Expect(err).ToNot(HaveOccurred()) cwd, _ := os.Getwd() diff --git a/model/folder_test.go b/model/folder_test.go index 0535f6987..4c1b4c2b7 100644 --- a/model/folder_test.go +++ b/model/folder_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -66,6 +67,7 @@ var _ = Describe("Folder", func() { When("the folder has multiple subdirs", func() { It("should return the correct folder ID", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") folderPath := filepath.FromSlash("/music/rock/metal") expectedID := id.NewHash("1:rock/metal") Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) @@ -75,6 +77,7 @@ var _ = Describe("Folder", func() { Describe("NewFolder", func() { It("should create a new SubFolder with the correct attributes", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") folderPath := filepath.FromSlash("rock/metal") folder := model.NewFolder(lib, folderPath) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 8b0c13da2..c32701d99 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -447,6 +448,9 @@ var _ = Describe("MediaFiles", func() { DescribeTable("generates correct output", func(absolutePaths bool, expectedContent string) { + if absolutePaths { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") + } result := mfs.ToM3U8("Multi Track", absolutePaths) Expect(result).To(Equal(expectedContent)) }, @@ -467,6 +471,7 @@ var _ = Describe("MediaFiles", func() { Context("path variations", func() { It("handles different path structures", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") mfs = MediaFiles{ {Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"}, {Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"}, diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 47f5ca63f..eb66d11d1 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -79,6 +80,7 @@ var _ = Describe("getPID", func() { }) When("field is folder", func() { It("should return the pid", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)") spec := "folder|title" md.tags = map[model.TagName][]string{"title": {"title"}} mf.Path = "/path/to/file.mp3" diff --git a/model/playlist_test.go b/model/playlist_test.go index a54cecd53..9ed24f00f 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -2,6 +2,7 @@ package model_test import ( "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,6 +28,7 @@ var _ = Describe("Playlist", func() { } }) It("generates the correct M3U format", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") expected := `#EXTM3U #PLAYLIST:Mellow sunset #EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 7b6a0f764..ebc08fd04 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -99,6 +100,7 @@ var _ = Describe("FolderRepository", func() { }) It("includes all child folders when querying parent", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create a parent folder with multiple children parent := model.NewFolder(testLib, "TestParent/Music") child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") @@ -120,6 +122,7 @@ var _ = Describe("FolderRepository", func() { }) It("excludes children from other libraries", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent in testLib parent := model.NewFolder(testLib, "TestIsolation/Parent") child := model.NewFolder(testLib, "TestIsolation/Parent/Child") @@ -145,6 +148,7 @@ var _ = Describe("FolderRepository", func() { }) It("excludes missing children when querying parent", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent and children, mark one as missing parent := model.NewFolder(testLib, "TestMissingChild/Parent") child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") @@ -165,6 +169,7 @@ var _ = Describe("FolderRepository", func() { }) It("handles mix of existing and non-existing target paths", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create folders for one path but not the other existingParent := model.NewFolder(testLib, "TestMixed/Exists") existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 3e3972bdb..de7161643 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "time" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -64,6 +65,11 @@ var _ = Describe("LibraryRepository", func() { originalID := lib.ID originalCreatedAt := lib.CreatedAt + // Ensure the update's timestamp is strictly greater than the + // create's timestamp on platforms with coarse clock resolution + // (Windows' time.Now() is millisecond-granular). + time.Sleep(2 * time.Millisecond) + // Now update it lib.Name = "Updated Library" lib.Path = "/music/updated" diff --git a/plugins/config_validation_test.go b/plugins/config_validation_test.go index 20e1ce29b..b430c0b31 100644 --- a/plugins/config_validation_test.go +++ b/plugins/config_validation_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 3a00b07b7..cc07f0611 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 6cf90994a..9b6f7ea39 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 99326bde1..5b5ffca02 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 694cef716..067ae80ca 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/migrate_test.go b/plugins/migrate_test.go index 17ed43c5c..568ad34cb 100644 --- a/plugins/migrate_test.go +++ b/plugins/migrate_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go new file mode 100644 index 000000000..ed43bdcc3 --- /dev/null +++ b/plugins/plugins_suite_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package plugins + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Runs the subset of plugin specs compiled on Windows (files without the +// //go:build !windows tag): capabilities, manager_cache, manager_plugin, +// manifest, package. WASM-runtime-dependent specs live in !windows-tagged +// files and aren't reached here. +func TestPlugins(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Suite") +} diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 0b50d39cb..06e6fa686 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -111,6 +111,7 @@ var _ = Describe("phasePlaylists", func() { }) It("reports an error if there is an error reading files", func() { + tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) state.progress = progress folder := &model.Folder{Path: "/invalid/path"} diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 856015239..3ae50933c 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -43,6 +43,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } BeforeAll(func() { + tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)") ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) tmpDir := GinkgoT().TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL") diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 594b74e38..6c70eb268 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -34,6 +34,7 @@ var _ = Describe("ScanFolders", Ordered, func() { var fsys storagetest.FakeFS BeforeAll(func() { + tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)") ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) tmpDir := GinkgoT().TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL") diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 922d21e62..7bf91d64f 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -168,6 +168,7 @@ var _ = Describe("Scanner", Ordered, func() { }) It("should update the album", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") Expect(runScanner(ctx, true)).To(Succeed()) albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) @@ -268,6 +269,7 @@ var _ = Describe("Scanner", Ordered, func() { var beatlesMBID = uuid.NewString() BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") By("Having two MP3 albums") beatles := _t{ "artist": "The Beatles", @@ -872,6 +874,7 @@ var _ = Describe("Scanner", Ordered, func() { }) It("should update artist stats during quick scans when new albums are added", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") // Don't use the mocked artist repo for this test - we need the real one ds.MockedArtist = nil diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index c9add0bd1..42b7af7ba 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "golang.org/x/sync/errgroup" @@ -229,6 +230,7 @@ var _ = Describe("walk_dir_tree", func() { Context("with symlinks enabled", func() { BeforeEach(func() { + tests.SkipOnWindows("symlink semantics") conf.Server.Scanner.FollowSymlinks = true }) diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index e1600db32..a4016d470 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -389,6 +389,7 @@ var _ = Describe("Watcher", func() { }) It("should NOT send notification when nested ignored folder is deleted", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") startEventProcessing() // Simulate deletion of music/rock/artist/temp (matches **/temp) @@ -402,6 +403,7 @@ var _ = Describe("Watcher", func() { }) It("should send notification for non-ignored nested folder", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") startEventProcessing() // Simulate change in music/rock/artist (doesn't match any pattern) @@ -426,6 +428,7 @@ var _ = Describe("Watcher", func() { }) It("should NOT send notification for file changes in ignored folders", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") startEventProcessing() // Simulate file change in rock/_TEMP/file.mp3 @@ -464,11 +467,13 @@ var _ = Describe("resolveFolderPath", func() { }) It("walks up to parent directory when given a file path", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3") Expect(result).To(Equal("artist1/album1")) }) It("walks up multiple levels if needed", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3") Expect(result).To(Equal("artist1/album1")) }) @@ -489,6 +494,7 @@ var _ = Describe("resolveFolderPath", func() { }) It("handles nested file paths correctly", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") result := resolveFolderPath(mockFS, "artist1/album2/song.flac") Expect(result).To(Equal("artist1/album2")) }) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 5b3500f7a..4ad9e3daa 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -470,6 +470,13 @@ var _ = BeforeSuite(func() { Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) }) +// Close the database before the suite's TempDir cleanup runs. Required on +// Windows where open SQLite handles hold file locks that block temp-dir +// removal; harmless on other OSes. +var _ = AfterSuite(func() { + db.Close(ctx) +}) + // setupTestDB restores the database from the golden snapshot and creates the // Subsonic Router. Call this from BeforeEach/BeforeAll in each test container. func setupTestDB() { diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 06ad7addf..6c834070c 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -16,6 +17,7 @@ import ( var _ = Describe("Translations", func() { Describe("I18n files", func() { It("contains only valid json language files", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)") fsys := resources.FS() dir, _ := fsys.Open(consts.I18nFolder) files, _ := dir.(fs.ReadDirFile).ReadDir(-1) diff --git a/server/server_test.go b/server/server_test.go index 245fa013a..178c0015a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -30,6 +30,7 @@ var _ = Describe("createUnixSocketFile", func() { When("unixSocketPerm is valid", func() { It("updates the permission of the unix socket file and returns nil", func() { + tests.SkipOnWindows("uses Unix file permission bits") _, err := createUnixSocketFile(socketPath, "0777") fileInfo, _ := os.Stat(socketPath) actualPermission := fileInfo.Mode().Perm() @@ -50,6 +51,7 @@ var _ = Describe("createUnixSocketFile", func() { When("file already exists", func() { It("recreates the file as a socket with the right permissions", func() { + tests.SkipOnWindows("uses Unix file permission bits") _, err := os.Create(socketPath) Expect(err).ToNot(HaveOccurred()) Expect(os.Chmod(socketPath, os.FileMode(0777))).To(Succeed()) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index fc767b0ff..e110e2b93 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -32,7 +32,9 @@ var _ = Describe("MediaAnnotationController", func() { Describe("Scrobble", func() { It("submit all scrobbles with only the id", func() { - submissionTime := time.Now() + // Back-date the baseline so the assertion still passes on platforms + // with millisecond clock resolution (e.g. Windows). + submissionTime := time.Now().Add(-time.Second) r := newGetRequest("id=12", "id=34") _, err := router.Scrobble(r) diff --git a/tests/test_helpers.go b/tests/test_helpers.go index 0a2cad4ad..bdcd40d00 100644 --- a/tests/test_helpers.go +++ b/tests/test_helpers.go @@ -4,14 +4,25 @@ import ( "context" "os" "path/filepath" + "runtime" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/id" + "github.com/onsi/ginkgo/v2" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" ) +// SkipOnWindows marks the current spec (or surrounding BeforeEach) as skipped +// when running on Windows. The reason is included in the Ginkgo output so the +// backlog of Windows-skipped tests stays auditable. +func SkipOnWindows(reason string) { + if runtime.GOOS == "windows" { + ginkgo.Skip("not supported on Windows: " + reason) + } +} + type testingT interface { TempDir() string } @@ -20,10 +31,20 @@ func TempFileName(t testingT, prefix, suffix string) string { return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix) } +// TempFile creates an empty file in t.TempDir() and returns the closed handle. +// The handle is returned for backward compatibility, but is already closed so +// callers don't need to. On Windows, leaving the handle open would hold a file +// lock and block Ginkgo's TempDir cleanup. func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) { name := TempFileName(t, prefix, suffix) f, err := os.Create(name) - return f, name, err + if err != nil { + return nil, name, err + } + if cerr := f.Close(); cerr != nil { + return f, name, cerr + } + return f, name, nil } // ClearDB deletes all tables and data from the database diff --git a/utils/files_test.go b/utils/files_test.go index 72fc4f96f..c6e578f05 100644 --- a/utils/files_test.go +++ b/utils/files_test.go @@ -192,6 +192,10 @@ var _ = Describe("FileExists", func() { filePath := tempFile.Name() Expect(utils.FileExists(filePath)).To(BeTrue()) + // Close the file before removing it. On Windows, an open handle + // holds a file lock and os.Remove fails; closing first makes the + // test cross-platform. + Expect(tempFile.Close()).To(Succeed()) err := os.Remove(filePath) Expect(err).NotTo(HaveOccurred()) tempFile = nil // Prevent cleanup attempt From 2954c052f5d2e9775e5365e15db9a29d46baad72 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 19 Apr 2026 20:07:23 -0400 Subject: [PATCH 190/596] fix(tests): update media file paths in tests to be relative Signed-off-by: Deluan <deluan@navidrome.org> --- model/mediafile_test.go | 6 ++-- persistence/mediafile_repository_test.go | 36 ++++++++++++------------ persistence/persistence_suite_test.go | 36 ++++++++++++------------ persistence/playlist_repository_test.go | 8 +++--- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c32701d99..3547ec4ef 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -23,7 +23,7 @@ var _ = Describe("MediaFiles", func() { SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName", MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", - MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", FolderID: "Folder1", + MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "music1/file1.mp3", FolderID: "Folder1", }, { ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID", @@ -31,7 +31,7 @@ var _ = Describe("MediaFiles", func() { OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName", MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", MbzReleaseGroupID: "MbzReleaseGroupID", - Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", FolderID: "Folder2", + Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2", }, } }) @@ -52,7 +52,7 @@ var _ = Describe("MediaFiles", func() { Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID")) Expect(album.CatalogNum).To(Equal("CatalogNum")) Expect(album.Compilation).To(BeTrue()) - Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3")) + Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3")) Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2")) }) }) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 5a866379f..464d88288 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -48,10 +48,10 @@ var _ = Describe("MediaRepository", func() { var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile BeforeEach(func() { - mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"} - flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"} - flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"} - flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"} + mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "test/file.mp3"} + flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "test/file1.flac"} + flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"} + flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"} Expect(mr.Put(&mp3File)).To(Succeed()) Expect(mr.Put(&flacFile1)).To(Succeed()) @@ -109,7 +109,7 @@ 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"} + 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) @@ -124,7 +124,7 @@ var _ = Describe("MediaRepository", func() { newFile := model.MediaFile{ ID: id.NewRandom(), LibraryID: 1, - Path: "/test/created-at-preserved.mp3", + Path: "test/created-at-preserved.mp3", CreatedAt: originalTime, } Expect(mr.Put(&newFile)).To(Succeed()) @@ -142,7 +142,7 @@ var _ = Describe("MediaRepository", func() { newFile := model.MediaFile{ ID: fileID, LibraryID: 1, - Path: "/test/created-at-update.mp3", + Path: "test/created-at-update.mp3", Title: "Original Title", CreatedAt: originalTime, } @@ -152,7 +152,7 @@ var _ = Describe("MediaRepository", func() { updatedFile := model.MediaFile{ ID: fileID, LibraryID: 1, - Path: "/test/created-at-update.mp3", + Path: "test/created-at-update.mp3", Title: "Updated Title", // CreatedAt is zero - should NOT overwrite the stored value } @@ -231,7 +231,7 @@ var _ = Describe("MediaRepository", func() { It("returns 0 when no ratings exist", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) @@ -242,7 +242,7 @@ var _ = Describe("MediaRepository", func() { It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(5, newID)).To(Succeed()) mf, err := mr.Get(newID) @@ -255,7 +255,7 @@ var _ = Describe("MediaRepository", func() { It("calculates average across multiple users", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(3, newID)).To(Succeed()) @@ -273,7 +273,7 @@ var _ = Describe("MediaRepository", func() { It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) Expect(mr.SetRating(4, newID)).To(Succeed()) @@ -343,19 +343,19 @@ var _ = Describe("MediaRepository", func() { ID: id.NewRandom(), LibraryID: 1, Title: "Old Song", - Path: "/test/old.mp3", + Path: "test/old.mp3", }, { ID: id.NewRandom(), LibraryID: 1, Title: "Middle Song", - Path: "/test/middle.mp3", + Path: "test/middle.mp3", }, { ID: id.NewRandom(), LibraryID: 1, Title: "New Song", - Path: "/test/new.mp3", + Path: "test/new.mp3", }, } @@ -486,7 +486,7 @@ var _ = Describe("MediaRepository", func() { var mfWithoutAnnotation model.MediaFile BeforeEach(func() { - mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"} + mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"} Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed()) }) @@ -566,7 +566,7 @@ var _ = Describe("MediaRepository", func() { MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4 MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4 LibraryID: 1, - Path: "/test/path/test.mp3", + Path: "test/path/test.mp3", } // Insert the test media file into the database @@ -608,7 +608,7 @@ var _ = Describe("MediaRepository", func() { Title: "Test Missing MBID MediaFile", MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022", LibraryID: 1, - Path: "/test/path/missing.mp3", + Path: "test/path/missing.mp3", Missing: true, } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 3ed443129..ebc247d77 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -77,14 +77,14 @@ var ( ) var ( - albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967}) - albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) - albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2}) - albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4}) - albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1}) - albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019}, + albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967}) + albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) + albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2}) + albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4}) + albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1}) + albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019}, model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}) - albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1}) + albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1}) testAlbums = model.Albums{ albumSgtPeppers, albumAbbeyRoad, @@ -97,12 +97,12 @@ var ( ) var ( - songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("/beatles/1/sgt/a day.mp3")}) - songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("/beatles/1/come together.mp3")}) - songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("/kraft/radio/radio.mp3")}) + songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")}) + songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")}) + songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")}) songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", - Path: p("/kraft/radio/antenna.mp3"), + Path: p("kraft/radio/antenna.mp3"), RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ @@ -115,13 +115,13 @@ var ( }) songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"}) // Multi-disc album tracks (intentionally out of order to test sorting) - songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")}) - songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")}) - songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")}) + songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")}) + songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")}) + songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")}) testSongs = model.MediaFiles{ songDayInALife, songComeTogether, diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c091cb32b..88cb5f697 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -408,7 +408,7 @@ var _ = Describe("PlaylistRepository", func() { ArtistID: "1", Album: "Test Album", AlbumID: "101", - Path: "/test/grouping/song1.mp3", + Path: "test/grouping/song1.mp3", Tags: model.Tags{ "grouping": []string{"My Crate"}, }, @@ -426,7 +426,7 @@ var _ = Describe("PlaylistRepository", func() { ArtistID: "1", Album: "Test Album", AlbumID: "101", - Path: "/test/grouping/song2.mp3", + Path: "test/grouping/song2.mp3", Tags: model.Tags{}, Participants: model.Participants{}, LibraryID: 1, @@ -614,7 +614,7 @@ var _ = Describe("PlaylistRepository", func() { ArtistID: "1", Album: "Test Album", AlbumID: "101", - Path: "/music/lib1/song.mp3", + Path: "lib1/song.mp3", LibraryID: 1, Participants: model.Participants{}, Tags: model.Tags{}, @@ -630,7 +630,7 @@ var _ = Describe("PlaylistRepository", func() { ArtistID: "1", Album: "Test Album", AlbumID: "101", - Path: uniqueLibPath + "/song.mp3", + Path: "lib2/song.mp3", LibraryID: lib2ID, Participants: model.Participants{}, Tags: model.Tags{}, From 44e63596a08c83471eaa56b132762267899af44a Mon Sep 17 00:00:00 2001 From: Aengus Walton <ventolin@gmail.com> Date: Wed, 22 Apr 2026 03:27:54 +0200 Subject: [PATCH 191/596] feat(server): add EnforceNonRootUser config option to exit early if started as root (#5373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): Add EnforceNonRootUser config option to exit early if started as root Signed-off-by: Aengus Walton <ventolin@gmail.com> * Move validateEnforceNonRootUser check to directly after parsing the config * Ensure the data directory hasn't been created in test --------- Signed-off-by: Aengus Walton <ventolin@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- conf/configuration.go | 25 ++++++++++++++++++++++ conf/configuration_test.go | 43 ++++++++++++++++++++++++++++++++++++++ conf/export_test.go | 11 ++++++++++ 3 files changed, 79 insertions(+) diff --git a/conf/configuration.go b/conf/configuration.go index 0b44f8f62..916efe70b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -27,6 +27,7 @@ type configOptions struct { Address string Port int UnixSocketPerm string + EnforceNonRootUser bool MusicFolder string DataFolder string CacheFolder string @@ -273,6 +274,12 @@ var logFatal = func(args ...any) { os.Exit(1) } +var getEUID = os.Geteuid + +var currentGOOS = func() string { + return runtime.GOOS +} + var ( Server = &configOptions{} hooks []func() @@ -303,6 +310,11 @@ func Load(noConfigDump bool) { logFatal("Error parsing config:", err) } + // Validate non-root user early, before any filesystem operations + if err := validateEnforceNonRootUser(); err != nil { + logFatal(err) + } + err = os.MkdirAll(Server.DataFolder, os.ModePerm) if err != nil { logFatal("Error creating data path:", err) @@ -599,6 +611,18 @@ func validateMaxImageUploadSize() error { return nil } +func validateEnforceNonRootUser() error { + if !Server.EnforceNonRootUser || currentGOOS() == "windows" { + return nil + } + + if getEUID() == 0 { + return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root") + } + + return nil +} + func validateScanSchedule() error { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { Server.Scanner.Schedule = "" @@ -698,6 +722,7 @@ func setViperDefaults() { viper.SetDefault("address", "0.0.0.0") viper.SetDefault("port", 4533) viper.SetDefault("unixsocketperm", "0660") + viper.SetDefault("enforcenonrootuser", false) viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout) viper.SetDefault("baseurl", "") viper.SetDefault("tlscert", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 121b1902c..5d4e73fad 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -250,6 +250,49 @@ var _ = Describe("Configuration", func() { ) }) + Describe("EnforceNonRootUser", func() { + It("defaults to false", func() { + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeFalse()) + }) + + It("allows startup for non-root users when enabled", func() { + DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000)) + viper.Set("enforcenonrootuser", true) + + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeTrue()) + }) + + It("exits when enabled and running as root without having created a data folder", func() { + // Create a path that doesn't exist yet + tempBase := GinkgoT().TempDir() + nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data") + DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0)) + viper.Set("enforcenonrootuser", true) + viper.Set("datafolder", nonExistentDataFolder) + + // Attempt to load config as root user - should fail before creating directories + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root"))) + + // Verify that the data folder was NOT created + Expect(nonExistentDataFolder).ToNot(BeAnExistingFile()) + }) + + It("is a no-op on non-unix platforms", func() { + DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0)) + viper.Set("enforcenonrootuser", true) + + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeTrue()) + }) + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index 85755aa12..acebca551 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -16,6 +16,17 @@ var ToPascalCase = toPascalCase var ValidateMaxImageUploadSize = validateMaxImageUploadSize +func SetRuntimeInfoForTest(goos string, euid int) func() { + oldGOOS := currentGOOS + oldEUID := getEUID + currentGOOS = func() string { return goos } + getEUID = func() int { return euid } + return func() { + currentGOOS = oldGOOS + getEUID = oldEUID + } +} + func SetLogFatal(f func(...any)) func() { old := logFatal logFatal = f From 4488349a3aaca0079b18b5767913a1fc4d108093 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 22 Apr 2026 20:11:17 -0400 Subject: [PATCH 192/596] fix(makefile): adjust PATH order for golangci-lint installation and linting Signed-off-by: Deluan <deluan@navidrome.org> --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 4efbc4db6..ad96afd31 100644 --- a/Makefile +++ b/Makefile @@ -75,8 +75,8 @@ test-i18n: ##@Development Validate all translations files install-golangci-lint: ##@Development Install golangci-lint if not present @INSTALL=false; \ - if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \ - CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ + if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \ + CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \ if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \ echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \ @@ -93,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code - PATH=$$PATH:./bin golangci-lint run --timeout 5m + PATH=./bin:$$PATH golangci-lint run --timeout 5m .PHONY: lint lintall: lint ##@Development Lint Go and JS code From 7e083e0795c3b60008663925f6d0eac698e8b364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 23 Apr 2026 17:53:28 -0400 Subject: [PATCH 193/596] fix: split html sanitization from plaintext handling (#5403) * fix: split html sanitization from plaintext handling Add a dedicated SanitizeHTML helper for HTML-rendered values so entity-encoded markup is decoded before bluemonday sanitization. Use the new helper for the login welcome message and artist biographies while preserving SanitizeText semantics for lyrics and other plaintext callers. Add regression coverage for both helpers and the serveIndex welcomeMessage path. * docs: add SanitizeText and SanitizeHTML godoc Signed-off-by: Deluan <deluan@navidrome.org> * fix: preserve plain text in artist biographies Revert artist biography storage to SanitizeText so entity-encoded plain text remains decoded for Subsonic consumers. This avoids double-escaping values like R&B in XML responses while keeping the new welcomeMessage HTML sanitization in place, and adds a regression test covering the biography storage behavior. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- .../provider_updateartistinfo_test.go | 23 +++++++++++++++ server/serve_index.go | 2 +- server/serve_index_test.go | 12 ++++++++ utils/str/sanitize_strings.go | 9 ++++++ utils/str/sanitize_strings_test.go | 29 +++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index e309ece6e..cc9506d1f 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -105,6 +105,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) + It("preserves decoded plain text in biography storage", func() { + originalArtist := &model.Artist{ + ID: "ar-encoded-bio", + Name: "Encoded Bio Artist", + } + mockArtistRepo.SetData(model.Artists{*originalArtist}) + + expectedMBID := "mbid-encoded-bio" + expectedBio := "R&B" + + ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once() + ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe() + ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once() + ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe() + ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe() + + updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false) + + Expect(err).NotTo(HaveOccurred()) + Expect(updatedArtist).NotTo(BeNil()) + Expect(updatedArtist.Biography).To(Equal("R&B")) + }) + It("returns cached info when artist exists and info is not expired", func() { now := time.Now() originalArtist := &model.Artist{ diff --git a/server/serve_index.go b/server/serve_index.go index bd5be44f5..734aabc70 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -45,7 +45,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "variousArtistsId": consts.VariousArtistsID, "baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")), "loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL), - "welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage), + "welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage), "maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists, "enableTranscodingConfig": conf.Server.EnableTranscodingConfig, "enableDownloads": conf.Server.EnableDownloads, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 7515e7276..31bca02cf 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -108,6 +108,18 @@ var _ = Describe("serveIndex", func() { Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), ) + It("sanitizes entity-encoded welcomeMessage as html", func() { + conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>` + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKey("welcomeMessage")) + Expect(config["welcomeMessage"]).To(Equal(`<img src="x"><b>Hello</b>`)) + }) + DescribeTable("sets other UI configuration values", func(configKey string, expectedValueFunc func() any) { r := httptest.NewRequest("GET", "/index.html", nil) diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index 73608112e..c121aefe7 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -38,11 +38,20 @@ func SanitizeStrings(text ...string) string { var policy = bluemonday.UGCPolicy() +// SanitizeText unescapes the input string before sanitizing it as text. +// This should be used for fields rendered as plain text in the UI (e.g. lyrics, song titles, artist names) func SanitizeText(text string) string { s := policy.Sanitize(text) return html.UnescapeString(s) } +// SanitizeHTML unescapes the input string before sanitizing it as HTML. +// This should be used for fields rendered as HTML by clients (e.g. biographies, welcome messages) +// to prevent XSS bypasses via entity-encoded tags. +func SanitizeHTML(text string) string { + return policy.Sanitize(html.UnescapeString(text)) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) diff --git a/utils/str/sanitize_strings_test.go b/utils/str/sanitize_strings_test.go index ac28fe435..6527f326b 100644 --- a/utils/str/sanitize_strings_test.go +++ b/utils/str/sanitize_strings_test.go @@ -64,6 +64,35 @@ var _ = Describe("Sanitize Strings", func() { }) }) + Describe("SanitizeText", func() { + It("preserves decoded plaintext", func() { + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + }) + + It("keeps entity-encoded html readable", func() { + Expect(str.SanitizeText(`<b>ok</b>`)).To(Equal("<b>ok</b>")) + }) + }) + + Describe("SanitizeHTML", func() { + It("removes dangerous content from raw html", func() { + sanitized := str.SanitizeHTML(`<img src=x onerror=alert(1)><script>alert(2)</script><b>ok</b>`) + + Expect(sanitized).To(ContainSubstring("<b>ok</b>")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("<script")) + }) + + It("removes dangerous content from entity-encoded html", func() { + sanitized := str.SanitizeHTML(`<img src=x onerror=alert(1)><script>alert(2)</script><b>ok</b>`) + + Expect(sanitized).To(ContainSubstring("<b>ok</b>")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("<script")) + }) + }) + Describe("SanitizeFieldForSortingNoArticle", func() { BeforeEach(func() { conf.Server.IgnoredArticles = "The O" From 3b3b9a62ca5b7493c6f492a53a60891b48abed07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 24 Apr 2026 23:03:10 -0400 Subject: [PATCH 194/596] test(smartplaylists): add smart playlist e2e test suite (#5409) * test: add smart playlist e2e suite infrastructure * test: add string field smart playlist e2e tests * test: add numeric, boolean, tag, participant, annotation, logic, sorting smart playlist e2e tests * test: add playlist operator smart playlist e2e tests * test: add isNot and endsWith string field e2e tests * test: add date/time field smart playlist e2e tests * fix: add gosec nolint directives for safe SQL concatenation in e2e restore * refactor: address code review feedback for smart playlist e2e tests - Deduplicate evaluateRule by delegating to evaluateRuleOrdered - Cache table list in BeforeSuite instead of querying sqlite_master per test - Wrap restoreDB in a transaction with defer cleanup for DETACH/foreign_keys - Use JSON numbers for numeric criteria values to match canonical JSON shape * refactor: simplify e2e test infrastructure - Remove unused return value from buildTestFS - Add deferred ROLLBACK as safety net in restoreDB transaction - Cache Come Together ID to avoid repeated lookups in BeforeSuite - Use range-over-int for play count loop * test: add missing operator coverage to smart playlist e2e tests Add 4 tests for operators/paths that had no e2e coverage: - notContains on string fields (LIKE negation path) - before on date fields (Lt for dates, only after was tested) - startsWith on tag fields (json_tree + LIKE subquery) - endsWith on participant/role fields (json_tree + LIKE subquery) --- core/playlists/e2e/e2e_suite_test.go | 326 +++++++++++++++++++++++ core/playlists/e2e/smartplaylist_test.go | 290 ++++++++++++++++++++ 2 files changed, 616 insertions(+) create mode 100644 core/playlists/e2e/e2e_suite_test.go create mode 100644 core/playlists/e2e/smartplaylist_test.go diff --git a/core/playlists/e2e/e2e_suite_test.go b/core/playlists/e2e/e2e_suite_test.go new file mode 100644 index 000000000..e9a717d0a --- /dev/null +++ b/core/playlists/e2e/e2e_suite_test.go @@ -0,0 +1,326 @@ +package e2e + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "testing/fstest" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSmartPlaylistE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Smart Playlist E2E Suite") +} + +type _t = map[string]any + +var template = storagetest.Template +var track = storagetest.Track + +var ( + ctx context.Context + ds *tests.MockDataStore + lib model.Library + + dbFilePath string + snapshotPath string + snapshotTables []string + + testUser = model.User{ + ID: "sp-test-user-1", + UserName: "sptestuser", + Name: "SP Test User", + IsAdmin: true, + } + + otherUser = model.User{ + ID: "sp-test-user-2", + UserName: "spotheruser", + Name: "SP Other User", + IsAdmin: false, + } +) + +func buildTestFS() { + abbeyRoad := template(_t{ + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + }) + ledZepIV := template(_t{ + "albumartist": "Led Zeppelin", + "artist": "Led Zeppelin", + "album": "IV", + "year": 1971, + }) + kindOfBlue := template(_t{ + "albumartist": "Miles Davis", + "artist": "Miles Davis", + "album": "Kind of Blue", + "year": 1959, + "genre": "Jazz", + "composer": "Miles Davis", + }) + nightAtOpera := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "A Night at the Opera", + "year": 1975, + "genre": "Rock", + }) + electricLadyland := template(_t{ + "albumartist": "Jimi Hendrix", + "artist": "Jimi Hendrix", + "album": "Electric Ladyland", + "year": 1968, + "genre": "Rock;Blues", + }) + newsOfWorld := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "News of the World", + "year": 1977, + "genre": "Rock;Pop", + "compilation": "1", + }) + + fs := storagetest.FakeFS{} + fs.SetFiles(fstest.MapFS{ + "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", + _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", + _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What", + _t{"bpm": 136})), + "Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody", + _t{"composer": "Freddie Mercury", "bpm": 72})), + "Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower", + _t{"composer": "Bob Dylan", "bpm": 112})), + "Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions", + _t{"composer": "Freddie Mercury", "bpm": 64})), + }) + storagetest.Register("fake", &fs) +} + +func findMediaFileByTitle(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title) + return mfs[0].ID +} + +func evaluateRule(jsonRule string) []string { + titles := evaluateRuleOrdered(jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrdered(jsonRule string) []string { + var rules criteria.Criteria + err := json.Unmarshal([]byte(jsonRule), &rules) + Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) + + pls := &model.Playlist{ + Name: "test-smart-playlist", + OwnerID: testUser.ID, + Rules: &rules, + } + err = ds.Playlist(ctx).Put(pls) + Expect(err).ToNot(HaveOccurred()) + + loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + titles := make([]string, len(loaded.Tracks)) + for i, t := range loaded.Tracks { + titles[i] = t.Title + } + return titles +} + +func createPlaylist(owner model.User, public bool, titles ...string) string { + pls := &model.Playlist{ + Name: "ref-playlist", + OwnerID: owner.ID, + Public: public, + } + for _, title := range titles { + mfID := findMediaFileByTitle(title) + pls.AddMediaFilesByID([]string{mfID}) + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +func createPublicPlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, true, titles...) +} + +func createPrivatePlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, false, titles...) +} + +func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + var rules criteria.Criteria + Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) + pls := &model.Playlist{ + Name: "ref-smart-playlist", + OwnerID: owner.ID, + Public: true, + Rules: &rules, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), testUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") + snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + userWithPass := testUser + userWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) + + otherUserWithPass := otherUser + otherUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed()) + + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName) + Expect(err).ToNot(HaveOccurred()) + testUser.Libraries = loadedUser.Libraries + + loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName) + Expect(err).ToNot(HaveOccurred()) + otherUser.Libraries = loadedOther.Libraries + + ctx = request.WithUser(GinkgoT().Context(), testUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + comeTogetherID := findMediaFileByTitle("Come Together") + Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed()) + Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) + for range 10 { + Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed()) + } + Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) + + rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + snapshotTables = append(snapshotTables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }() + + _, err = sqlDB.Exec("BEGIN TRANSACTION") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("ROLLBACK") }() + + for _, table := range snapshotTables { + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("COMMIT") + Expect(err).ToNot(HaveOccurred()) +} + +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), testUser) + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + restoreDB() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} +} diff --git a/core/playlists/e2e/smartplaylist_test.go b/core/playlists/e2e/smartplaylist_test.go new file mode 100644 index 000000000..6e31c6787 --- /dev/null +++ b/core/playlists/e2e/smartplaylist_test.go @@ -0,0 +1,290 @@ +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart Playlists", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("String fields", func() { + It("matches by exact title", func() { + results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by title contains", func() { + results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by artist startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by title isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by artist endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + }) + + Describe("Numeric fields", func() { + It("matches by year greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions")) + }) + + It("matches by year less than", func() { + results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`) + Expect(results).To(ConsistOf("So What", "All Along the Watchtower")) + }) + + It("matches by BPM in range", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower")) + }) + }) + + Describe("Boolean fields", func() { + It("matches compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches non-compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower")) + }) + }) + + Describe("File type fields", func() { + It("matches by filetype", func() { + results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + }) + + Describe("Multi-valued tags", func() { + It("matches tracks with Blues genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + + It("excludes tracks with Rock genre", func() { + results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("matches genre contains", func() { + results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven")) + }) + + It("matches tracks with Pop genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches genre startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Participants", func() { + It("matches by exact composer", func() { + results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by composer contains", func() { + results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by composer isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower")) + }) + + It("matches by composer endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions")) + }) + }) + + Describe("Annotations", func() { + It("matches starred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches unstarred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by rating greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("matches by rating greater than or equal via inTheRange", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches by play count greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by play count greater than zero", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + }) + + Describe("Negated string operators", func() { + It("matches by title notContains", func() { + results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody")) + }) + }) + + Describe("Date/time fields", func() { + It("matches dateAdded before a far-future date", func() { + results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches lastPlayed inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + + It("matches lastPlayed notInTheLast (far future)", func() { + results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches dateLoved after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches dateRated after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches dateAdded inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Logic operators", func() { + It("matches with ALL (AND)", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) + Expect(results).To(ConsistOf("Black Dog")) + }) + + It("matches with ANY (OR)", func() { + results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("So What", "We Are the Champions")) + }) + + It("matches nested all/any", func() { + results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + }) + + Describe("Sorting and limits", func() { + It("returns tracks sorted by year descending with limit", func() { + results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`) + Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"})) + }) + + It("returns tracks sorted by title ascending", func() { + results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`) + Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"})) + }) + }) + + Describe("Combined real-world patterns", func() { + It("matches genre filter with exclusion and year range", func() { + results := evaluateRuleOrdered(`{ + "all":[ + {"any":[ + {"is":{"genre":"Blues"}}, + {"is":{"genre":"Folk"}} + ]}, + {"isNot":{"genre":"Jazz"}}, + {"gt":{"year":1965}} + ], + "sort":"-year,title" + }`) + Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"})) + }) + }) + + Describe("Playlist operators", func() { + It("matches tracks in a public regular playlist", func() { + refID := createPublicPlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches tracks not in a public regular playlist", func() { + refID := createPublicPlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("recursively refreshes a referenced smart playlist owned by the same user", func() { + smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("does not refresh a referenced smart playlist owned by another user", func() { + smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("does not match tracks from a private playlist", func() { + refID := createPrivatePlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("does not match tracks from a private playlist owned by another user", func() { + refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + }) +}) From 251cc71e2dabf56cd8ef59cf107f6e732ea7262c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 24 Apr 2026 23:18:20 -0400 Subject: [PATCH 195/596] refactor: move smart playlist criteria SQL to persistence (#5408) * refactor: move criteria SQL generation to persistence Keep model/criteria as a domain DSL with JSON parsing, field metadata, expression traversal, and child playlist extraction only. Move smart playlist SQL translation, sort SQL, and join planning into persistence behind smartPlaylistCriteria so repository code uses a small query-building API. * refactor: simplify criteria translator metadata Use generic helper functions for criteria operator maps so the SQL translator can pass named criteria map types directly. Remove unused pseudo-field metadata from the criteria field API while preserving special field name lookup. * test: add coverage check for criteria-to-SQL field mappings Add a test that iterates all fields registered in the criteria package and verifies that every non-tag/non-role field has a corresponding entry in the persistence layer's smartPlaylistFields map. This prevents silent drift between the domain field registry and the SQL translation layer. Also adds an AllFieldNames() function to the criteria package to support field enumeration from outside the package. --- model/criteria/criteria.go | 114 +------ model/criteria/criteria_test.go | 209 +------------ model/criteria/fields.go | 368 +++++++--------------- model/criteria/fields_test.go | 57 +++- model/criteria/operators.go | 219 ++----------- model/criteria/operators_test.go | 177 ----------- model/criteria/walk.go | 72 +++++ model/criteria/walk_test.go | 64 ++++ persistence/criteria_sql.go | 487 +++++++++++++++++++++++++++++ persistence/criteria_sql_test.go | 170 ++++++++++ persistence/playlist_repository.go | 40 ++- 11 files changed, 1017 insertions(+), 960 deletions(-) create mode 100644 model/criteria/walk.go create mode 100644 model/criteria/walk_test.go create mode 100644 persistence/criteria_sql.go create mode 100644 persistence/criteria_sql_test.go diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 278acf34c..1e161c9f2 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -1,17 +1,16 @@ -// Package criteria implements a Criteria API based on Masterminds/squirrel +// Package criteria implements the smart playlist criteria DSL. package criteria import ( "encoding/json" "errors" - "fmt" - "strings" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" ) -type Expression = squirrel.Sqlizer +type Expression interface { + criteriaExpression() +} type Criteria struct { Expression @@ -49,115 +48,12 @@ func (c Criteria) IsPercentageLimit() bool { return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100 } -func (c Criteria) OrderBy() string { - if c.Sort == "" { - c.Sort = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) - order = "" - } - - parts := strings.Split(c.Sort, ",") - fields := make([]string, 0, len(parts)) - - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - - dir := "asc" - if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") { - if strings.HasPrefix(p, "-") { - dir = "desc" - } - p = strings.TrimSpace(p[1:]) - } - - sortField := strings.ToLower(p) - f := fieldMap[sortField] - if f == nil { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - - var mapped string - - if f.order != "" { - mapped = f.order - } else if f.isTag { - // Use the actual field name (handles aliases like albumtype -> releasetype) - tagName := sortField - if f.field != "" { - tagName = f.field - } - mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" - } else if f.isRole { - mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" - } else { - mapped = f.field - } - if f.numeric { - mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) - } - // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction. - // This ensures that the global order applies consistently across all fields. - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - - fields = append(fields, mapped+" "+dir) - } - - return strings.Join(fields, ", ") -} - -func (c Criteria) ToSql() (sql string, args []any, err error) { - return c.Expression.ToSql() -} - -// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression, -// excluding any JOINs required solely for sorting. This is useful for COUNT -// queries where sort order is irrelevant. -func (c Criteria) ExpressionJoins() JoinType { - if c.Expression == nil { - return JoinNone - } - return extractJoinTypes(c.Expression) -} - -// RequiredJoins inspects the expression tree and Sort field to determine which -// additional JOINs are needed when evaluating this criteria. -func (c Criteria) RequiredJoins() JoinType { - result := JoinNone - if c.Expression != nil { - result |= extractJoinTypes(c.Expression) - } - // Also check Sort fields - if c.Sort != "" { - for _, p := range strings.Split(c.Sort, ",") { - p = strings.TrimSpace(p) - p = strings.TrimLeft(p, "+-") - p = strings.TrimSpace(p) - result |= fieldJoinType(p) - } - } - return result -} - func (c Criteria) ChildPlaylistIds() []string { if c.Expression == nil { return nil } - if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil { + if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok { return parent.ChildPlaylistIds() } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index a76b3fc1f..e0940a509 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() { } jsonObj = b.String() }) - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` + - `AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` + - `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` + - `AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`)) - gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3)) - }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) @@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() { gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) }) - Describe("OrderBy", func() { - It("sorts by regular fields", func() { - gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc")) - }) - - It("sorts by tag fields", func() { - goObj.Sort = "genre" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc", - ), - ) - }) - - It("sorts by role fields", func() { - goObj.Sort = "artist" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc", - ), - ) - }) - - It("casts numeric tags when sorting", func() { - AddTagNames([]string{"rate"}) - AddNumericTags([]string{"rate"}) - goObj.Sort = "rate" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"), - ) - }) - - It("sorts by albumtype alias (resolves to releasetype)", func() { - AddTagNames([]string{"releasetype"}) - goObj.Sort = "albumtype" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", - ), - ) - }) - - It("sorts by random", func() { - newObj := goObj - newObj.Sort = "random" - gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) - }) - - It("sorts by multiple fields", func() { - goObj.Sort = "title,-rating" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc, COALESCE(annotation.rating, 0) desc", - )) - }) - - It("reverts order when order is desc", func() { - goObj.Sort = "-date,artist" - goObj.Order = "desc" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc", - )) - }) - - It("ignores invalid sort fields", func() { - goObj.Sort = "bogus,title" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc", - )) - }) - }) - }) - - Context("with artist roles", func() { - BeforeEach(func() { - goObj = Criteria{ - Expression: All{ - Is{"artist": "The Beatles"}, - Contains{"composer": "Lennon"}, - }, - } - }) - - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` + - `exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`, - )) - gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%")) - }) - }) - - Describe("ExpressionJoins", func() { - It("excludes sort-only joins", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone)) - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - - It("includes expression-based joins", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - }) - - Describe("RequiredJoins", func() { - It("returns JoinNone when no annotation fields are used", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinNone for media_file annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"loved": true}, - Gt{"playCount": 5}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinAlbumAnnotation for album annotation fields", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation)) - }) - It("returns JoinArtistAnnotation for artist annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"artistLoved": true}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation)) - }) - It("returns both join types when both are used", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - Is{"artistLoved": true}, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types in nested expressions", func() { - c := Criteria{ - Expression: All{ - Any{ - All{ - Is{"albumLoved": true}, - }, - }, - Any{ - Gt{"artistPlayCount": 10}, - }, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field with direction prefix", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "-artistRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) }) Describe("LimitPercent", func() { @@ -470,5 +265,9 @@ var _ = Describe("Criteria", func() { ids := Criteria{}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("returns empty list for leaf expressions", func() { + ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.BeEmpty()) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index bc3c7a3d3..30541a945 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -1,280 +1,133 @@ package criteria -import ( - "fmt" - "reflect" - "strings" +import "strings" - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" -) - -// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression. -type JoinType int - -const ( - JoinNone JoinType = 0 - JoinAlbumAnnotation JoinType = 1 << iota - JoinArtistAnnotation -) - -// Has returns true if j contains all bits in other. -func (j JoinType) Has(other JoinType) bool { return j&other != 0 } - -var fieldMap = map[string]*mappedField{ - "title": {field: "media_file.title"}, - "album": {field: "media_file.album"}, - "hascoverart": {field: "media_file.has_cover_art"}, - "tracknumber": {field: "media_file.track_number"}, - "discnumber": {field: "media_file.disc_number"}, - "year": {field: "media_file.year"}, - "date": {field: "media_file.date", alias: "recordingdate"}, - "originalyear": {field: "media_file.original_year"}, - "originaldate": {field: "media_file.original_date"}, - "releaseyear": {field: "media_file.release_year"}, - "releasedate": {field: "media_file.release_date"}, - "size": {field: "media_file.size"}, - "compilation": {field: "media_file.compilation"}, - "missing": {field: "media_file.missing"}, - "explicitstatus": {field: "media_file.explicit_status"}, - "dateadded": {field: "media_file.created_at"}, - "datemodified": {field: "media_file.updated_at"}, - "discsubtitle": {field: "media_file.disc_subtitle"}, - "comment": {field: "media_file.comment"}, - "lyrics": {field: "media_file.lyrics"}, - "sorttitle": {field: "media_file.sort_title"}, - "sortalbum": {field: "media_file.sort_album_name"}, - "sortartist": {field: "media_file.sort_artist_name"}, - "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumcomment": {field: "media_file.mbz_album_comment"}, - "catalognumber": {field: "media_file.catalog_num"}, - "filepath": {field: "media_file.path"}, - "filetype": {field: "media_file.suffix"}, - "codec": {field: "media_file.codec"}, - "duration": {field: "media_file.duration"}, - "bitrate": {field: "media_file.bit_rate"}, - "bitdepth": {field: "media_file.bit_depth"}, - "samplerate": {field: "media_file.sample_rate"}, - "bpm": {field: "media_file.bpm"}, - "channels": {field: "media_file.channels"}, - "loved": {field: "COALESCE(annotation.starred, false)"}, - "dateloved": {field: "annotation.starred_at"}, - "lastplayed": {field: "annotation.play_date"}, - "daterated": {field: "annotation.rated_at"}, - "playcount": {field: "COALESCE(annotation.play_count, 0)"}, - "rating": {field: "COALESCE(annotation.rating, 0)"}, - "averagerating": {field: "media_file.average_rating", numeric: true}, - "albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation}, - "albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation}, - "albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation}, - "albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation}, - "albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation}, - "albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation}, - - "artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation}, - "artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation}, - "artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation}, - "artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation}, - "artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation}, - "artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation}, - - "mbz_album_id": {field: "media_file.mbz_album_id"}, - "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, - "mbz_artist_id": {field: "media_file.mbz_artist_id"}, - "mbz_recording_id": {field: "media_file.mbz_recording_id"}, - "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, - "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, - "library_id": {field: "media_file.library_id", numeric: true}, - - // Backward compatibility: albumtype is an alias for releasetype tag - "albumtype": {field: "releasetype", isTag: true}, - - // special fields - "random": {field: "", order: "random()"}, // pseudo-field for random sorting - "value": {field: "value"}, // pseudo-field for tag and roles values +// FieldInfo describes a criteria field without tying it to persistence details. +type FieldInfo struct { + Name string + IsTag bool + IsRole bool + Numeric bool } -type mappedField struct { - field string - order string - isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) - isTag bool // true if the field is a tag imported from the file metadata - alias string // name from `mappings.yml` that may differ from the name used in the smart playlist - numeric bool // true if the field/tag should be treated as numeric - joinType JoinType // which additional JOINs this field requires +var fieldMap = map[string]*fieldMetadata{ + "title": {name: "title"}, + "album": {name: "album"}, + "hascoverart": {name: "hascoverart"}, + "tracknumber": {name: "tracknumber"}, + "discnumber": {name: "discnumber"}, + "year": {name: "year"}, + "date": {name: "date", alias: "recordingdate"}, + "originalyear": {name: "originalyear"}, + "originaldate": {name: "originaldate"}, + "releaseyear": {name: "releaseyear"}, + "releasedate": {name: "releasedate"}, + "size": {name: "size"}, + "compilation": {name: "compilation"}, + "missing": {name: "missing"}, + "explicitstatus": {name: "explicitstatus"}, + "dateadded": {name: "dateadded"}, + "datemodified": {name: "datemodified"}, + "discsubtitle": {name: "discsubtitle"}, + "comment": {name: "comment"}, + "lyrics": {name: "lyrics"}, + "sorttitle": {name: "sorttitle"}, + "sortalbum": {name: "sortalbum"}, + "sortartist": {name: "sortartist"}, + "sortalbumartist": {name: "sortalbumartist"}, + "albumcomment": {name: "albumcomment"}, + "catalognumber": {name: "catalognumber"}, + "filepath": {name: "filepath"}, + "filetype": {name: "filetype"}, + "codec": {name: "codec"}, + "duration": {name: "duration"}, + "bitrate": {name: "bitrate"}, + "bitdepth": {name: "bitdepth"}, + "samplerate": {name: "samplerate"}, + "bpm": {name: "bpm"}, + "channels": {name: "channels"}, + "loved": {name: "loved"}, + "dateloved": {name: "dateloved"}, + "lastplayed": {name: "lastplayed"}, + "daterated": {name: "daterated"}, + "playcount": {name: "playcount"}, + "rating": {name: "rating"}, + "averagerating": {name: "averagerating", numeric: true}, + "albumrating": {name: "albumrating"}, + "albumloved": {name: "albumloved"}, + "albumplaycount": {name: "albumplaycount"}, + "albumlastplayed": {name: "albumlastplayed"}, + "albumdateloved": {name: "albumdateloved"}, + "albumdaterated": {name: "albumdaterated"}, + "artistrating": {name: "artistrating"}, + "artistloved": {name: "artistloved"}, + "artistplaycount": {name: "artistplaycount"}, + "artistlastplayed": {name: "artistlastplayed"}, + "artistdateloved": {name: "artistdateloved"}, + "artistdaterated": {name: "artistdaterated"}, + "mbz_album_id": {name: "mbz_album_id"}, + "mbz_album_artist_id": {name: "mbz_album_artist_id"}, + "mbz_artist_id": {name: "mbz_artist_id"}, + "mbz_recording_id": {name: "mbz_recording_id"}, + "mbz_release_track_id": {name: "mbz_release_track_id"}, + "mbz_release_group_id": {name: "mbz_release_group_id"}, + "library_id": {name: "library_id", numeric: true}, + + // Backward compatibility: albumtype is an alias for the releasetype tag. + "albumtype": {name: "releasetype", isTag: true}, + + "random": {name: "random"}, + "value": {name: "value"}, } -func mapFields(expr map[string]any) map[string]any { - m := make(map[string]any) - for f, v := range expr { - if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" { - m[dbf.field] = v - } else { - log.Error("Invalid field in criteria", "field", f) - } +type fieldMetadata struct { + name string + isRole bool + isTag bool + alias string + numeric bool +} + +// AllFieldNames returns the names of all registered criteria fields. +func AllFieldNames() []string { + names := make([]string, 0, len(fieldMap)) + for name := range fieldMap { + names = append(names, name) } - return m + return names } -// mapExpr maps a normal field expression to a specific type of expression (tag or role). -// This is required because tags are handled differently than other fields, -// as they are stored as a JSON column in the database. -func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer { - rv := reflect.ValueOf(expr) - if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String { - log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr)) +// LookupField returns semantic metadata for a criteria field name. +func LookupField(name string) (FieldInfo, bool) { + f, ok := fieldMap[strings.ToLower(name)] + if !ok { + return FieldInfo{}, false } - - // Extract the field name and value, then build a new map keyed by "value" - // for the inner condition. The original map is left untouched so that - // ToSql can be called multiple times without corruption. - var k string - var v any - for _, key := range rv.MapKeys() { - k = key.String() - v = rv.MapIndex(key).Interface() - break // only one key is expected (and supported) - } - - // Create a new map-based expression with "value" as the key, matching the - // column name inside json_tree subqueries. - newMap := reflect.MakeMap(rv.Type()) - newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v)) - newExpr := newMap.Interface().(squirrel.Sqlizer) - - return exprFunc(k, newExpr, negate) -} - -// mapTagExpr maps a normal field expression to a tag expression. -func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, tagExpr) -} - -// mapRoleExpr maps a normal field expression to an artist role expression. -func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, roleExpr) -} - -func isTagExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag { - return true - } - } - return false -} - -func isRoleExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole { - return true - } - } - return false -} - -func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return tagCond{tag: tag, cond: cond, not: negate} -} - -type tagCond struct { - tag string - cond squirrel.Sqlizer - not bool -} - -func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - - // Resolve the actual tag name (handles aliases like albumtype -> releasetype) - tagName := e.tag - if fm, ok := fieldMap[e.tag]; ok { - if fm.field != "" { - tagName = fm.field - } - if fm.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") - } - } - - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", - tagName, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return roleCond{role: role, cond: cond, not: negate} -} - -type roleCond struct { - role string - cond squirrel.Sqlizer - not bool -} - -func (e roleCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`, - e.role, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -// fieldJoinType returns the JoinType for a given field name (case-insensitive). -func fieldJoinType(name string) JoinType { - if f, ok := fieldMap[strings.ToLower(name)]; ok { - return f.joinType - } - return JoinNone -} - -// extractJoinTypes walks an expression tree and collects all required JoinType flags. -func extractJoinTypes(expr any) JoinType { - result := JoinNone - switch e := expr.(type) { - case All: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - case Any: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - default: - // Leaf expression: use reflection to check if it's a map with field names - rv := reflect.ValueOf(expr) - if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { - for _, key := range rv.MapKeys() { - result |= fieldJoinType(key.String()) - } - } - } - return result + return FieldInfo{ + Name: f.name, + IsTag: f.isTag, + IsRole: f.isRole, + Numeric: f.numeric, + }, true } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in -// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent. +// smart playlists. func AddRoles(roles []string) { for _, role := range roles { name := strings.ToLower(role) if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &mappedField{field: name, isRole: true} + fieldMap[name] = &fieldMetadata{name: name, isRole: true} } } // AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` -// file to the field map, so they can be used in smart playlists. -// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent. +// configuration file. func AddTagNames(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if _, ok := fieldMap[name]; ok { continue } @@ -285,20 +138,19 @@ func AddTagNames(tagNames []string) { } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &mappedField{field: name, isTag: true} + fieldMap[name] = &fieldMetadata{name: name, isTag: true} } } } -// AddNumericTags marks the given tag names as numeric so they can be cast -// when used in comparisons or sorting. +// AddNumericTags adds tags that should be treated as numbers. func AddNumericTags(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { fm.numeric = true } else { - fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true} + fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index accdebd3d..ecbeb5857 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -6,11 +6,58 @@ import ( ) var _ = Describe("fields", func() { - Describe("mapFields", func() { - It("ignores random fields", func() { - m := map[string]any{"random": "123"} - m = mapFields(m) - gomega.Expect(m).To(gomega.BeEmpty()) + Describe("LookupField", func() { + It("finds built-in fields case-insensitively", func() { + field, ok := LookupField("Title") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + }) + + It("resolves aliases to their semantic field name", func() { + field, ok := LookupField("albumtype") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("finds special fields", func() { + field, ok := LookupField("value") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("value")) + }) + + It("finds registered tag names", func() { + AddTagNames([]string{"task3_mood"}) + + field, ok := LookupField("task3_mood") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("marks registered numeric tags", func() { + AddTagNames([]string{"task3_score"}) + AddNumericTags([]string{"task3_score"}) + + field, ok := LookupField("task3_score") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + gomega.Expect(field.Numeric).To(gomega.BeTrue()) + }) + + It("finds registered roles", func() { + AddRoles([]string{"task3_producer"}) + + field, ok := LookupField("task3_producer") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) }) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 336f914de..6f911b12f 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,23 +1,13 @@ package criteria -import ( - "errors" - "fmt" - "reflect" - "strconv" - "time" - - "github.com/Masterminds/squirrel" -) +import "time" type ( - All squirrel.And + All []Expression And = All ) -func (all All) ToSql() (sql string, args []any, err error) { - return squirrel.And(all).ToSql() -} +func (All) criteriaExpression() {} func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -28,13 +18,11 @@ func (all All) ChildPlaylistIds() (ids []string) { } type ( - Any squirrel.Or + Any []Expression Or = Any ) -func (any Any) ToSql() (sql string, args []any, err error) { - return squirrel.Or(any).ToSql() -} +func (Any) criteriaExpression() {} func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -44,70 +32,42 @@ func (any Any) ChildPlaylistIds() (ids []string) { return extractPlaylistIds(any) } -type Is squirrel.Eq +type Is map[string]any type Eq = Is -func (is Is) ToSql() (sql string, args []any, err error) { - if isRoleExpr(is) { - return mapRoleExpr(is, false).ToSql() - } - if isTagExpr(is) { - return mapTagExpr(is, false).ToSql() - } - return squirrel.Eq(mapFields(is)).ToSql() -} +func (Is) criteriaExpression() {} func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } -type IsNot squirrel.NotEq +type IsNot map[string]any -func (in IsNot) ToSql() (sql string, args []any, err error) { - if isRoleExpr(in) { - return mapRoleExpr(squirrel.Eq(in), true).ToSql() - } - if isTagExpr(in) { - return mapTagExpr(squirrel.Eq(in), true).ToSql() - } - return squirrel.NotEq(mapFields(in)).ToSql() -} +func (IsNot) criteriaExpression() {} func (in IsNot) MarshalJSON() ([]byte, error) { return marshalExpression("isNot", in) } -type Gt squirrel.Gt +type Gt map[string]any -func (gt Gt) ToSql() (sql string, args []any, err error) { - if isTagExpr(gt) { - return mapTagExpr(gt, false).ToSql() - } - return squirrel.Gt(mapFields(gt)).ToSql() -} +func (Gt) criteriaExpression() {} func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt squirrel.Lt +type Lt map[string]any -func (lt Lt) ToSql() (sql string, args []any, err error) { - if isTagExpr(lt) { - return mapTagExpr(squirrel.Lt(lt), false).ToSql() - } - return squirrel.Lt(mapFields(lt)).ToSql() -} +func (Lt) criteriaExpression() {} func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before squirrel.Lt +type Before map[string]any -func (bf Before) ToSql() (sql string, args []any, err error) { - return Lt(bf).ToSql() -} +func (Before) criteriaExpression() {} func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) @@ -115,9 +75,7 @@ func (bf Before) MarshalJSON() ([]byte, error) { type After Gt -func (af After) ToSql() (sql string, args []any, err error) { - return Gt(af).ToSql() -} +func (After) criteriaExpression() {} func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) @@ -125,19 +83,7 @@ func (af After) MarshalJSON() ([]byte, error) { type Contains map[string]any -func (ct Contains) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(ct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(ct) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(ct) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (Contains) criteriaExpression() {} func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) @@ -145,19 +91,7 @@ func (ct Contains) MarshalJSON() ([]byte, error) { type NotContains map[string]any -func (nct NotContains) ToSql() (sql string, args []any, err error) { - lk := squirrel.NotLike{} - for f, v := range mapFields(nct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(nct) { - return mapRoleExpr(squirrel.Like(lk), true).ToSql() - } - if isTagExpr(nct) { - return mapTagExpr(squirrel.Like(lk), true).ToSql() - } - return lk.ToSql() -} +func (NotContains) criteriaExpression() {} func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) @@ -165,19 +99,7 @@ func (nct NotContains) MarshalJSON() ([]byte, error) { type StartsWith map[string]any -func (sw StartsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%s%%", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (StartsWith) criteriaExpression() {} func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) @@ -185,19 +107,7 @@ func (sw StartsWith) MarshalJSON() ([]byte, error) { type EndsWith map[string]any -func (sw EndsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%%%s", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (EndsWith) criteriaExpression() {} func (sw EndsWith) MarshalJSON() ([]byte, error) { return marshalExpression("endsWith", sw) @@ -205,20 +115,7 @@ func (sw EndsWith) MarshalJSON() ([]byte, error) { type InTheRange map[string]any -func (itr InTheRange) ToSql() (sql string, args []any, err error) { - and := squirrel.And{} - for f, v := range mapFields(itr) { - s := reflect.ValueOf(v) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v) - } - and = append(and, - squirrel.GtOrEq{f: s.Index(0).Interface()}, - squirrel.LtOrEq{f: s.Index(1).Interface()}, - ) - } - return and.ToSql() -} +func (InTheRange) criteriaExpression() {} func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) @@ -226,13 +123,7 @@ func (itr InTheRange) MarshalJSON() ([]byte, error) { type InTheLast map[string]any -func (itl InTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(itl, false) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +func (InTheLast) criteriaExpression() {} func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) @@ -240,50 +131,19 @@ func (itl InTheLast) MarshalJSON() ([]byte, error) { type NotInTheLast map[string]any -func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(nitl, true) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +func (NotInTheLast) criteriaExpression() {} func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } -func inPeriod(m map[string]any, negate bool) (Expression, error) { - var field string - var value any - for f, v := range mapFields(m) { - field, value = f, v - break - } - str := fmt.Sprintf("%v", value) - v, err := strconv.ParseInt(str, 10, 64) - if err != nil { - return nil, err - } - firstDate := startOfPeriod(v, time.Now()) - - if negate { - return Or{ - squirrel.Lt{field: firstDate}, - squirrel.Eq{field: nil}, - }, nil - } - return squirrel.Gt{field: firstDate}, nil -} - func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } type InPlaylist map[string]any -func (ipl InPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, false) -} +func (InPlaylist) criteriaExpression() {} func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) @@ -291,41 +151,12 @@ func (ipl InPlaylist) MarshalJSON() ([]byte, error) { type NotInPlaylist map[string]any -func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, true) -} +func (NotInPlaylist) criteriaExpression() {} func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("notInPlaylist", ipl) } -func inList(m map[string]any, negate bool) (sql string, args []any, err error) { - var playlistid string - var ok bool - if playlistid, ok = m["id"].(string); !ok { - return "", nil, errors.New("playlist id not given") - } - - // Subquery to fetch all media files that are contained in given playlist - // Only evaluate playlist if it is public - subQuery := squirrel.Select("media_file_id"). - From("playlist_tracks pl"). - LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistid}, - squirrel.Eq{"playlist.public": 1}}) - subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() - - if err != nil { - return "", nil, err - } - if negate { - return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil - } else { - return "media_file.id IN (" + subQText + ")", subQArgs, nil - } -} - func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 5f756f97d..c93e8f2b2 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -3,7 +3,6 @@ package criteria_test import ( "encoding/json" "fmt" - "time" . "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" @@ -17,182 +16,6 @@ var _ = BeforeSuite(func() { }) var _ = Describe("Operators", func() { - rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local) - rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local) - - DescribeTable("ToSQL", - func(op Expression, expectedSql string, expectedArgs ...any) { - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal(expectedSql)) - gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...)) - }, - Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), - Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1), - Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), - Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1), - Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2), - Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), - Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), - Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), - Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), - Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), - Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), - Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd), - Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart), - Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), - - // InPlaylist and NotInPlaylist are special cases - Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+ - "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+ - "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - - Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Album annotation fields - Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true), - Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5), - Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart), - Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart), - Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart), - Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Artist annotation fields - Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3), - Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), - Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5), - Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart), - Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart), - Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart), - Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Tag tests - Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"), - Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"), - Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), - Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), - - // Artist roles tests - Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"), - Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), - ) - - // TODO Validate operators that are not valid for each field type. - XDescribeTable("ToSQL - Invalid Operators", - func(op Expression, expectedError string) { - _, _, err := op.ToSql() - gomega.Expect(err).To(gomega.MatchError(expectedError)) - }, - Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"), - ) - - Describe("Custom Tags", func() { - It("generates valid SQL", func() { - AddTagNames([]string{"mood"}) - op := EndsWith{"mood": "Soft"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) - }) - It("casts numeric comparisons", func() { - AddNumericTags([]string{"rate"}) - op := Lt{"rate": 6} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) - gomega.Expect(args).To(gomega.HaveExactElements(6)) - }) - It("skips unknown tag names", func() { - op := EndsWith{"unknown": "value"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - It("supports releasetype as multi-valued tag", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"releasetype": "soundtrack"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) - }) - It("supports albumtype as alias for releasetype", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"albumtype": "live"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%live%")) - }) - It("supports albumtype alias with Is operator", func() { - AddTagNames([]string{"releasetype"}) - op := Is{"albumtype": "album"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("album")) - }) - It("supports albumtype alias with IsNot operator", func() { - AddTagNames([]string{"releasetype"}) - op := IsNot{"albumtype": "compilation"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("compilation")) - }) - }) - - Describe("Custom Roles", func() { - It("generates valid SQL", func() { - AddRoles([]string{"producer"}) - op := EndsWith{"producer": "Eno"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) - }) - It("skips unknown roles", func() { - op := Contains{"groupie": "Penny Lane"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - }) - - DescribeTable("ToSql idempotency", - func(expr Expression) { - sql1, args1, err1 := expr.ToSql() - sql2, args2, err2 := expr.ToSql() - - gomega.Expect(err1).ToNot(gomega.HaveOccurred()) - gomega.Expect(err2).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql2).To(gomega.Equal(sql1)) - gomega.Expect(args2).To(gomega.Equal(args1)) - }, - Entry("tag expression", Is{"genre": "Rock"}), - Entry("role expression", Contains{"artist": "Beatles"}), - Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}), - ) - DescribeTable("JSON Marshaling", func(op Expression, jsonString string) { obj := And{op} diff --git a/model/criteria/walk.go b/model/criteria/walk.go new file mode 100644 index 000000000..62aaf97f8 --- /dev/null +++ b/model/criteria/walk.go @@ -0,0 +1,72 @@ +package criteria + +import "fmt" + +type Visitor func(Expression) error + +func Walk(expr Expression, visit Visitor) error { + if expr == nil { + return nil + } + if err := visit(expr); err != nil { + return err + } + switch e := expr.(type) { + case All: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Any: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + return nil + default: + return fmt.Errorf("unknown criteria expression type %T", expr) + } + return nil +} + +// Fields returns field values for leaf expressions only. +// Use Walk to traverse All and Any expressions before calling Fields. +func Fields(expr Expression) map[string]any { + switch e := expr.(type) { + case Is: + return map[string]any(e) + case IsNot: + return map[string]any(e) + case Gt: + return map[string]any(e) + case Lt: + return map[string]any(e) + case Before: + return map[string]any(e) + case After: + return map[string]any(Gt(e)) + case Contains: + return map[string]any(e) + case NotContains: + return map[string]any(e) + case StartsWith: + return map[string]any(e) + case EndsWith: + return map[string]any(e) + case InTheRange: + return map[string]any(e) + case InTheLast: + return map[string]any(e) + case NotInTheLast: + return map[string]any(e) + case InPlaylist: + return map[string]any(e) + case NotInPlaylist: + return map[string]any(e) + default: + return nil + } +} diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go new file mode 100644 index 000000000..91438f095 --- /dev/null +++ b/model/criteria/walk_test.go @@ -0,0 +1,64 @@ +package criteria + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +type unknownExpression struct{} + +func (unknownExpression) criteriaExpression() {} + +var _ = Describe("Walk", func() { + It("visits the expression tree depth-first", func() { + expr := All{ + Contains{"title": "love"}, + Any{ + Is{"album": "best of"}, + Gt{"rating": 3}, + }, + } + + var visited []string + err := Walk(expr, func(expr Expression) error { + visited = append(visited, fmt.Sprintf("%T", expr)) + return nil + }) + + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(visited).To(gomega.Equal([]string{ + "criteria.All", + "criteria.Contains", + "criteria.Any", + "criteria.Is", + "criteria.Gt", + })) + }) + + It("stops when the visitor returns an error", func() { + expectedErr := fmt.Errorf("stop") + + err := Walk(All{Contains{"title": "love"}}, func(Expression) error { + return expectedErr + }) + + gomega.Expect(err).To(gomega.MatchError(expectedErr)) + }) + + It("returns fields for leaf expressions", func() { + gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"})) + gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"})) + }) + + It("returns nil fields for group expressions", func() { + gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil()) + }) + + It("returns an error for unknown expression types", func() { + err := Walk(unknownExpression{}, func(Expression) error { return nil }) + + gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression")) + }) +}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go new file mode 100644 index 000000000..2f7b73885 --- /dev/null +++ b/persistence/criteria_sql.go @@ -0,0 +1,487 @@ +package persistence + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + squirrel "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/criteria" +) + +type smartPlaylistJoinType int + +const ( + smartPlaylistJoinNone smartPlaylistJoinType = 0 + smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota + smartPlaylistJoinArtistAnnotation +) + +func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { + return j&other != 0 +} + +type smartPlaylistField struct { + expr string + order string + joinType smartPlaylistJoinType +} + +type smartPlaylistCriteria struct { + criteria criteria.Criteria +} + +func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria { + return smartPlaylistCriteria{criteria: c} +} + +var smartPlaylistFields = map[string]smartPlaylistField{ + "title": {expr: "media_file.title"}, + "album": {expr: "media_file.album"}, + "hascoverart": {expr: "media_file.has_cover_art"}, + "tracknumber": {expr: "media_file.track_number"}, + "discnumber": {expr: "media_file.disc_number"}, + "year": {expr: "media_file.year"}, + "date": {expr: "media_file.date"}, + "originalyear": {expr: "media_file.original_year"}, + "originaldate": {expr: "media_file.original_date"}, + "releaseyear": {expr: "media_file.release_year"}, + "releasedate": {expr: "media_file.release_date"}, + "size": {expr: "media_file.size"}, + "compilation": {expr: "media_file.compilation"}, + "missing": {expr: "media_file.missing"}, + "explicitstatus": {expr: "media_file.explicit_status"}, + "dateadded": {expr: "media_file.created_at"}, + "datemodified": {expr: "media_file.updated_at"}, + "discsubtitle": {expr: "media_file.disc_subtitle"}, + "comment": {expr: "media_file.comment"}, + "lyrics": {expr: "media_file.lyrics"}, + "sorttitle": {expr: "media_file.sort_title"}, + "sortalbum": {expr: "media_file.sort_album_name"}, + "sortartist": {expr: "media_file.sort_artist_name"}, + "sortalbumartist": {expr: "media_file.sort_album_artist_name"}, + "albumcomment": {expr: "media_file.mbz_album_comment"}, + "catalognumber": {expr: "media_file.catalog_num"}, + "filepath": {expr: "media_file.path"}, + "filetype": {expr: "media_file.suffix"}, + "codec": {expr: "media_file.codec"}, + "duration": {expr: "media_file.duration"}, + "bitrate": {expr: "media_file.bit_rate"}, + "bitdepth": {expr: "media_file.bit_depth"}, + "samplerate": {expr: "media_file.sample_rate"}, + "bpm": {expr: "media_file.bpm"}, + "channels": {expr: "media_file.channels"}, + "loved": {expr: "COALESCE(annotation.starred, false)"}, + "dateloved": {expr: "annotation.starred_at"}, + "lastplayed": {expr: "annotation.play_date"}, + "daterated": {expr: "annotation.rated_at"}, + "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, + "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "averagerating": {expr: "media_file.average_rating"}, + "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, + "mbz_album_id": {expr: "media_file.mbz_album_id"}, + "mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"}, + "mbz_artist_id": {expr: "media_file.mbz_artist_id"}, + "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, + "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, + "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "library_id": {expr: "media_file.library_id"}, + "random": {order: "random()"}, + "value": {expr: "value"}, +} + +func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { + if c.criteria.Expression == nil { + return squirrel.Expr("1 = 1"), nil + } + return exprSQL(c.criteria.Expression) +} + +func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { + switch e := expr.(type) { + case criteria.All: + and := squirrel.And{} + for _, child := range e { + cond, err := exprSQL(child) + if err != nil { + return nil, err + } + and = append(and, cond) + } + return and, nil + case criteria.Any: + or := squirrel.Or{} + for _, child := range e { + cond, err := exprSQL(child) + if err != nil { + return nil, err + } + or = append(or, cond) + } + return or, nil + case criteria.Is: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Eq(fields) + }, false) + case criteria.IsNot: + return isNotExpr(e) + case criteria.Gt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Lt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.Before: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.After: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Contains: + return likeExpr(e, "%%%v%%", false) + case criteria.NotContains: + return likeExpr(e, "%%%v%%", true) + case criteria.StartsWith: + return likeExpr(e, "%v%%", false) + case criteria.EndsWith: + return likeExpr(e, "%%%v", false) + case criteria.InTheRange: + return rangeExpr(e) + case criteria.InTheLast: + return periodExpr(e, false) + case criteria.NotInTheLast: + return periodExpr(e, true) + case criteria.InPlaylist: + return inList(e, false) + case criteria.NotInPlaylist: + return inList(e, true) + default: + return nil, fmt.Errorf("unknown criteria expression type %T", expr) + } +} + +func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Eq{"value": value}, true), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return squirrel.NotEq(fields), nil +} + +func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return makeCond(fields), nil +} + +func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + if negate { + lk := squirrel.NotLike{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil + } + lk := squirrel.Like{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil +} + +func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + and := squirrel.And{} + for field, value := range fields { + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) + } + and = append(and, + squirrel.GtOrEq{field: s.Index(0).Interface()}, + squirrel.LtOrEq{field: s.Index(1).Interface()}, + ) + } + return and, nil +} + +func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + var field string + var value any + for f, v := range fields { + field, value = f, v + break + } + days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64) + if err != nil { + return nil, err + } + firstDate := startOfPeriod(days, time.Now()) + if negate { + return squirrel.Or{ + squirrel.Lt{field: firstDate}, + squirrel.Eq{field: nil}, + }, nil + } + return squirrel.Gt{field: firstDate}, nil +} + +func startOfPeriod(numDays int64, from time.Time) string { + return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") +} + +func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { + playlistID, ok := values["id"].(string) + if !ok { + return nil, errors.New("playlist id not given") + } + subQuery := squirrel.Select("media_file_id"). + From("playlist_tracks pl"). + LeftJoin("playlist on pl.playlist_id = playlist.id"). + Where(squirrel.And{ + squirrel.Eq{"pl.playlist_id": playlistID}, + squirrel.Eq{"playlist.public": 1}, + }) + subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + return nil, err + } + if negate { + return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil + } + return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil +} + +func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + if info.IsRole { + return roleCond{role: info.Name, cond: cond, not: negate} + } + return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} +} + +type tagCond struct { + tag string + numeric bool + cond squirrel.Sqlizer + not bool +} + +func (e tagCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +type roleCond struct { + role string + cond squirrel.Sqlizer + not bool +} + +func (e roleCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) { + if len(values) != 1 { + return "", nil, criteria.FieldInfo{}, false + } + for field, value := range values { + info, ok := criteria.LookupField(field) + return field, value, info, ok + } + return "", nil, criteria.FieldInfo{}, false +} + +func sqlFields[T ~map[string]any](values T) (map[string]any, error) { + fields := make(map[string]any, len(values)) + for field, value := range values { + info, ok := criteria.LookupField(field) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) + } + sqlField, ok := fieldExpr(info.Name) + if !ok || sqlField == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + fields[sqlField] = value + } + return fields, nil +} + +func fieldExpr(name string) (string, bool) { + field, ok := smartPlaylistFields[strings.ToLower(name)] + return field.expr, ok +} + +func fieldJoinType(name string) smartPlaylistJoinType { + info, ok := criteria.LookupField(name) + if !ok { + return smartPlaylistJoinNone + } + field, ok := smartPlaylistFields[info.Name] + if !ok { + return smartPlaylistJoinNone + } + return field.joinType +} + +func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { + var joins smartPlaylistJoinType + _ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { + for field := range criteria.Fields(expr) { + joins |= fieldJoinType(field) + } + return nil + }) + return joins +} + +func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { + joins := c.ExpressionJoins() + for _, sortField := range sortFields(c.criteria.Sort) { + joins |= fieldJoinType(sortField) + } + return joins +} + +func (c smartPlaylistCriteria) OrderBy() string { + sortValue := c.criteria.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.criteria.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + dir := "asc" + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + if strings.HasPrefix(part, "-") { + dir = "desc" + } + part = strings.TrimSpace(part[1:]) + } + sortField := strings.ToLower(part) + mapped, ok := sortExpr(sortField) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", sortField) + continue + } + if order == "desc" { + if dir == "asc" { + dir = "desc" + } else { + dir = "asc" + } + } + fields = append(fields, mapped+" "+dir) + } + return strings.Join(fields, ", ") +} + +func sortFields(sortValue string) []string { + if sortValue == "" { + sortValue = "title" + } + parts := strings.Split(sortValue, ",") + fields := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-")) + if part != "" { + fields = append(fields, part) + } + } + return fields +} + +func sortExpr(sortField string) (string, bool) { + info, ok := criteria.LookupField(sortField) + if !ok { + return "", false + } + if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + return field.order, true + } + var mapped string + switch { + case info.IsTag: + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + case info.IsRole: + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + default: + field, ok := smartPlaylistFields[info.Name] + if !ok || field.expr == "" { + return "", false + } + mapped = field.expr + } + if info.Numeric { + mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) + } + return mapped, true +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go new file mode 100644 index 000000000..9fa5ff104 --- /dev/null +++ b/persistence/criteria_sql_test.go @@ -0,0 +1,170 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/model/criteria" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart playlist criteria SQL", func() { + BeforeEach(func() { + criteria.AddRoles([]string{"artist", "composer", "producer"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) + criteria.AddNumericTags([]string{"rate"}) + }) + + DescribeTable("expressions", + func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal(expectedSQL)) + Expect(args).To(HaveExactElements(expectedArgs...)) + }, + Entry("all group", + criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, + "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + Entry("any group", + criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, + "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), + Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), + Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), + Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), + Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), + Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), + Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), + Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), + Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), + Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + ) + + It("builds relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("annotation.play_date > ?")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("builds negated relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("returns an error for unknown fields", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where() + + Expect(err).To(MatchError("invalid field in criteria: unknown")) + }) + + Describe("sort", func() { + It("sorts by regular fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + + It("sorts by tag fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) + }) + + It("sorts by role fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) + }) + + It("casts numeric tags when sorting", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) + }) + + It("sorts by albumtype alias", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) + }) + + It("sorts by random", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) + }) + + It("sorts by multiple fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) + }) + + It("reverts order when order is desc", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) + }) + + It("ignores invalid sort fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + }) + + It("has SQL mappings for all non-tag/non-role criteria fields", func() { + for _, name := range criteria.AllFieldNames() { + info, ok := criteria.LookupField(name) + Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name) + if info.IsTag || info.IsRole { + continue + } + _, hasSQLField := smartPlaylistFields[info.Name] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) + } + }) + + Describe("joins", func() { + It("excludes sort-only joins from expression joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} + cSQL := newSmartPlaylistCriteria(c) + + Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("includes expression-based joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} + + Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("detects nested album and artist joins", func() { + c := criteria.Criteria{Expression: criteria.All{ + criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}}, + criteria.Any{criteria.Gt{"artistPlayCount": 10}}, + }} + + joins := newSmartPlaylistCriteria(c).RequiredJoins() + Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + + It("detects join types from sort fields with direction prefixes", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"} + + Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + }) +}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 8d1bbe0f8..6bf3ded30 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -228,6 +228,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Re-populate playlist based on Smart Playlist criteria rules := *pls.Rules + rulesSQL := newSmartPlaylistCriteria(rules) // If the playlist depends on other playlists, recursively refresh them first childPlaylistIds := rules.ChildPlaylistIds() @@ -240,14 +241,15 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { r.refreshSmartPlaylist(childPls) } - sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + orderBy := rulesSQL.OrderBy() + sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file").LeftJoin("annotation on ("+ "annotation.item_id = media_file.id"+ " AND annotation.item_type = 'media_file'"+ " AND annotation.user_id = ?)", usr.ID) // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rules.RequiredJoins() + requiredJoins := rulesSQL.RequiredJoins() sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) // Only include media files from libraries the user has access to @@ -256,7 +258,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Resolve percentage-based limit to an absolute number before applying criteria if rules.IsPercentageLimit() { // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rules.ExpressionJoins() + exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file"). LeftJoin("annotation on ("+ "annotation.item_id = media_file.id"+ @@ -264,7 +266,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { " AND annotation.user_id = ?)", usr.ID) countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) countSq = r.applyLibraryFilter(countSq, "media_file") - countSq = countSq.Where(rules) + cond, err := rulesSQL.Where() + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + countSq = countSq.Where(cond) var res struct{ Count int64 } err = r.queryOne(countSq, &res) @@ -279,7 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } // Apply the criteria rules - sq = r.addCriteria(sq, rules) + sq, err = r.addCriteria(sq, rules) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) _, err = r.executeSQL(insSql) if err != nil { @@ -310,14 +321,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return true } -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder { - if joins.Has(criteria.JoinAlbumAnnotation) { +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { sq = sq.LeftJoin("annotation AS album_annotation ON ("+ "album_annotation.item_id = media_file.album_id"+ " AND album_annotation.item_type = 'album'"+ " AND album_annotation.user_id = ?)", userID) } - if joins.Has(criteria.JoinArtistAnnotation) { + if joins.has(smartPlaylistJoinArtistAnnotation) { sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ "artist_annotation.item_id = media_file.artist_id"+ " AND artist_annotation.item_type = 'artist'"+ @@ -326,15 +337,20 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j return sq } -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { - sql = sql.Where(c) +func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) { + cSQL := newSmartPlaylistCriteria(c) + cond, err := cSQL.Where() + if err != nil { + return sql, err + } + sql = sql.Where(cond) if c.Limit > 0 { sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) } - if order := c.OrderBy(); order != "" { + if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) } - return sql + return sql, nil } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { From ca09070a6cbda9d72f426d2862ec7dd934abf2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 25 Apr 2026 14:59:06 -0400 Subject: [PATCH 196/596] feat(smartplaylists): relax playlist visibility in inPlaylist/notInPlaylist rules (#5411) * test(e2e): add end-to-end tests for smart playlists functionality Signed-off-by: Deluan <deluan@navidrome.org> * fix: enforce playlist visibility in smart playlist InPlaylist/NotInPlaylist rules Previously, the InPlaylist/NotInPlaylist smart playlist criteria only allowed referencing public playlists, regardless of who owned the smart playlist. This was too restrictive for owners referencing their own private playlists and for admins who should have unrestricted access. The fix passes the smart playlist owner's identity and admin status into the criteria SQL builder, so that: admins can reference any playlist, regular users can reference public playlists plus their own private ones, and inaccessible referenced playlists produce a warning instead of a hard error. Also prevents recursive refresh of child playlists the owner cannot access. * test(e2e): clarify user roles and fix playlist visibility tests Renamed testUser/otherUser to adminUser/regularUser to make the admin vs regular user distinction explicit in test code. Fixed three playlist visibility tests that were evaluating as admin (bypassing all access checks) instead of as a regular user, so the public playlist path is now actually exercised. All playlist operator tests now use explicit evaluateRuleAs calls with the appropriate user role. * fix: sync rulesSQL criteria after limitPercent resolution The rulesSQL struct captures a copy of rules at creation time. When limitPercent is resolved later, rules.Limit is updated but rulesSQL still holds the stale value. This caused percentage-based smart playlist limits to be silently ignored. Fix by updating rulesSQL.criteria after the resolution. * refactor: convert inList to a method on smartPlaylistCriteria The inList function already receives ownerID and ownerIsAdmin from the smartPlaylistCriteria caller. Making it a method lets it access those fields directly from the receiver, simplifying the signature and staying consistent with exprSQL which was already converted to a method. * refactor: simplify function signatures by removing type parameters in criteria_sql.go Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/criteria_sql.go | 65 ++++++++++------ persistence/criteria_sql_test.go | 28 +++++++ .../e2e/e2e_suite_test.go | 59 ++++++++++----- .../e2e/smartplaylist_test.go | 74 ++++++++++++++----- persistence/playlist_repository.go | 17 +++-- 5 files changed, 176 insertions(+), 67 deletions(-) rename {core/playlists => persistence}/e2e/e2e_suite_test.go (85%) rename {core/playlists => persistence}/e2e/smartplaylist_test.go (76%) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 2f7b73885..c75f6467b 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -8,7 +8,7 @@ import ( "strings" "time" - squirrel "github.com/Masterminds/squirrel" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) @@ -32,11 +32,24 @@ type smartPlaylistField struct { } type smartPlaylistCriteria struct { - criteria criteria.Criteria + criteria criteria.Criteria + ownerID string + ownerIsAdmin bool } -func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria { - return smartPlaylistCriteria{criteria: c} +func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { + cSQL := smartPlaylistCriteria{criteria: c} + for _, opt := range opts { + opt(&cSQL) + } + return cSQL +} + +func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { + return func(c *smartPlaylistCriteria) { + c.ownerID = ownerID + c.ownerIsAdmin = ownerIsAdmin + } } var smartPlaylistFields = map[string]smartPlaylistField{ @@ -109,15 +122,15 @@ func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { if c.criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } - return exprSQL(c.criteria.Expression) + return c.exprSQL(c.criteria.Expression) } -func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { switch e := expr.(type) { case criteria.All: and := squirrel.And{} for _, child := range e { - cond, err := exprSQL(child) + cond, err := c.exprSQL(child) if err != nil { return nil, err } @@ -127,7 +140,7 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { case criteria.Any: or := squirrel.Or{} for _, child := range e { - cond, err := exprSQL(child) + cond, err := c.exprSQL(child) if err != nil { return nil, err } @@ -171,15 +184,15 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { case criteria.NotInTheLast: return periodExpr(e, true) case criteria.InPlaylist: - return inList(e, false) + return c.inList(e, false) case criteria.NotInPlaylist: - return inList(e, true) + return c.inList(e, true) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } } -func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { +func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Eq{"value": value}, true), nil } @@ -190,7 +203,7 @@ func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } -func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { +func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil } @@ -201,7 +214,7 @@ func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel return makeCond(fields), nil } -func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) { +func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil } @@ -223,7 +236,7 @@ func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirre return lk, nil } -func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { +func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { fields, err := sqlFields(values) if err != nil { return nil, err @@ -242,7 +255,7 @@ func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { return and, nil } -func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { +func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { fields, err := sqlFields(values) if err != nil { return nil, err @@ -271,18 +284,26 @@ func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } -func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { playlistID, ok := values["id"].(string) if !ok { return nil, errors.New("playlist id not given") } + filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + if !c.ownerIsAdmin { + if c.ownerID == "" { + filters = append(filters, squirrel.Eq{"playlist.public": 1}) + } else { + filters = append(filters, squirrel.Or{ + squirrel.Eq{"playlist.public": 1}, + squirrel.Eq{"playlist.owner_id": c.ownerID}, + }) + } + } subQuery := squirrel.Select("media_file_id"). From("playlist_tracks pl"). LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistID}, - squirrel.Eq{"playlist.public": 1}, - }) + Where(filters) subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() if err != nil { return nil, err @@ -334,7 +355,7 @@ func (e roleCond) ToSql() (string, []any, error) { return cond, args, err } -func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) { +func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { if len(values) != 1 { return "", nil, criteria.FieldInfo{}, false } @@ -345,7 +366,7 @@ func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, return "", nil, criteria.FieldInfo{}, false } -func sqlFields[T ~map[string]any](values T) (map[string]any, error) { +func sqlFields(values map[string]any) (map[string]any, error) { fields := make(map[string]any, len(values)) for field, value := range values { info, ok := criteria.LookupField(field) diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 9fa5ff104..e06a4eccb 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,34 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), ) + Describe("playlist permissions", func() { + It("allows public or same-owner playlist references for regular users", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner("owner-id", false), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id")) + }) + + It("allows all playlist references for admins", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner("admin-id", true), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef")) + }) + }) + It("builds relative date expressions", func() { sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() Expect(err).ToNot(HaveOccurred()) diff --git a/core/playlists/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go similarity index 85% rename from core/playlists/e2e/e2e_suite_test.go rename to persistence/e2e/e2e_suite_test.go index e9a717d0a..ea0d0fea8 100644 --- a/core/playlists/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -53,14 +53,14 @@ var ( snapshotPath string snapshotTables []string - testUser = model.User{ + adminUser = model.User{ ID: "sp-test-user-1", UserName: "sptestuser", Name: "SP Test User", IsAdmin: true, } - otherUser = model.User{ + regularUser = model.User{ ID: "sp-test-user-2", UserName: "spotheruser", Name: "SP Other User", @@ -147,25 +147,36 @@ func findMediaFileByTitle(title string) string { } func evaluateRule(jsonRule string) []string { - titles := evaluateRuleOrdered(jsonRule) + titles := evaluateRuleOrderedAs(adminUser, jsonRule) sort.Strings(titles) return titles } func evaluateRuleOrdered(jsonRule string) []string { + return evaluateRuleOrderedAs(adminUser, jsonRule) +} + +func evaluateRuleAs(owner model.User, jsonRule string) []string { + titles := evaluateRuleOrderedAs(owner, jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string { + userCtx := request.WithUser(GinkgoT().Context(), owner) var rules criteria.Criteria err := json.Unmarshal([]byte(jsonRule), &rules) Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) pls := &model.Playlist{ Name: "test-smart-playlist", - OwnerID: testUser.ID, + OwnerID: owner.ID, Rules: &rules, } - err = ds.Playlist(ctx).Put(pls) + err = ds.Playlist(userCtx).Put(pls) Expect(err).ToNot(HaveOccurred()) - loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) titles := make([]string, len(loaded.Tracks)) @@ -198,12 +209,20 @@ func createPrivatePlaylist(owner model.User, titles ...string) string { } func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, true, jsonRule) +} + +func createPrivateSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, false, jsonRule) +} + +func createSmartPlaylist(owner model.User, public bool, jsonRule string) string { var rules criteria.Criteria Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) pls := &model.Playlist{ Name: "ref-smart-playlist", OwnerID: owner.ID, - Public: true, + Public: public, Rules: &rules, } Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) @@ -211,7 +230,7 @@ func createPublicSmartPlaylist(owner model.User, jsonRule string) string { } var _ = BeforeSuite(func() { - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) tmpDir := GinkgoT().TempDir() dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") @@ -226,28 +245,28 @@ var _ = BeforeSuite(func() { initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - userWithPass := testUser + userWithPass := adminUser userWithPass.NewPassword = "password" Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) - otherUserWithPass := otherUser - otherUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed()) + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName) + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) Expect(err).ToNot(HaveOccurred()) - testUser.Libraries = loadedUser.Libraries + adminUser.Libraries = loadedUser.Libraries - loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName) + loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName) Expect(err).ToNot(HaveOccurred()) - otherUser.Libraries = loadedOther.Libraries + regularUser.Libraries = loadedOther.Libraries - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), @@ -315,7 +334,7 @@ func restoreDB() { } func setupTestDB() { - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) DeferCleanup(configtest.SetupConfig()) conf.Server.MusicFolder = "fake:///music" conf.Server.DevExternalScanner = false diff --git a/core/playlists/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go similarity index 76% rename from core/playlists/e2e/smartplaylist_test.go rename to persistence/e2e/smartplaylist_test.go index 6e31c6787..658030de5 100644 --- a/core/playlists/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -1,8 +1,10 @@ package e2e import ( + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" ) var _ = Describe("Smart Playlists", func() { @@ -245,46 +247,82 @@ var _ = Describe("Smart Playlists", func() { Describe("Playlist operators", func() { It("matches tracks in a public regular playlist", func() { - refID := createPublicPlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Come Together", "So What")) }) It("matches tracks not in a public regular playlist", func() { - refID := createPublicPlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`) + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) }) It("recursively refreshes a referenced smart playlist owned by the same user", func() { - smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) Expect(results).To(ConsistOf("So What")) }) It("does not refresh a referenced smart playlist owned by another user", func() { - smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) Expect(results).To(BeEmpty()) }) - It("does not match tracks from a private playlist", func() { - refID := createPrivatePlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) - Expect(results).To(BeEmpty()) + It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() { + smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) + + loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedA.Tracks).To(BeEmpty()) + Expect(loadedA.EvaluatedAt).To(BeNil()) + + loadedB, err := ds.Playlist(ctx).Get(smartBID) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedB.EvaluatedAt).To(BeNil()) }) - It("matches tracks in a public playlist owned by another user", func() { - refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + It("matches tracks from a private playlist owned by the same user", func() { + refID := createPrivatePlaylist(regularUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("allows admin-owned smart playlists to reference private playlists owned by other users", func() { + refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody") + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Bohemian Rhapsody")) }) - It("does not match tracks from a private playlist owned by another user", func() { - refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + It("does not match tracks from a private playlist owned by another regular user", func() { + refID := createPrivatePlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(BeEmpty()) }) + + It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() { + hook, cleanup := tests.LogHook() + defer cleanup() + + refID := createPrivatePlaylist(adminUser, "Come Together") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + + Expect(hook.LastEntry()).ToNot(BeNil()) + Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) + Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner")) + Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID)) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 6bf3ded30..11e76fa8b 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/pocketbase/dbx" ) @@ -228,13 +227,17 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Re-populate playlist based on Smart Playlist criteria rules := *pls.Rules - rulesSQL := newSmartPlaylistCriteria(rules) + rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin)) // If the playlist depends on other playlists, recursively refresh them first childPlaylistIds := rules.ChildPlaylistIds() for _, id := range childPlaylistIds { childPls, err := r.Get(id) if err != nil { + if errors.Is(err, model.ErrNotFound) { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + continue + } log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) return false } @@ -283,10 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) rules.Limit = resolvedLimit rules.LimitPercent = 0 + rulesSQL.criteria = rules } // Apply the criteria rules - sq, err = r.addCriteria(sq, rules) + sq, err = r.addCriteria(sq, rulesSQL) if err != nil { log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return false @@ -337,15 +341,14 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j return sq } -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) { - cSQL := newSmartPlaylistCriteria(c) +func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { cond, err := cSQL.Where() if err != nil { return sql, err } sql = sql.Where(cond) - if c.Limit > 0 { - sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) + if cSQL.criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset)) } if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) From 9824102efb5ec05990f85886e3bea65353653415 Mon Sep 17 00:00:00 2001 From: Daniele Massa <90097496+DanieleMassa@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:18:51 +0200 Subject: [PATCH 197/596] fix(ui): completed Italian translation (#5407) Co-authored-by: Daniele Massa <x@danielemassa.org> --- resources/i18n/it.json | 459 ++++++++++++++++++++++++++++++++--------- 1 file changed, 362 insertions(+), 97 deletions(-) diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 11fadb46b..b91c04064 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -10,32 +10,48 @@ "playCount": "Riproduzioni", "title": "Titolo", "artist": "Artista", + "composer": "Compositore", "album": "Album", "path": "Percorso", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", "size": "Dimensioni", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", - "discSubtitle": "Sottotitoli disco", + "bitDepth": "Profondità di bit", + "sampleRate": "Frequenza di campionamento", + "albumGain": "Guadagno album", + "trackGain": "Guadagno traccia", + "channels": "Canali", + "disc": "Disco %{discNumber}", + "discSubtitle": "Sottotitolo disco", "starred": "Preferita", "comment": "Commento", "rating": "Valutazione", "quality": "Qualità", "bpm": "BPM", "playDate": "Ultima riproduzione", - "channels": "Canali", - "createdAt": "" + "createdAt": "Data di aggiunta", + "grouping": "Raggruppamento", + "mood": "Umore", + "participants": "Partecipanti aggiuntivi", + "tags": "Tag aggiuntivi", + "mappedTags": "Tag mappati", + "rawTags": "Tag grezzi", + "missing": "Mancante" }, "actions": { "addToQueue": "Aggiungi alla coda", "playNow": "Riproduci adesso", "addToPlaylist": "Aggiungi alla playlist", + "showInPlaylist": "Mostra nella playlist", "shuffleAll": "Riproduci casualmente", "download": "Scarica", "playNext": "Riproduci come successivo", - "info": "Informazioni" + "info": "Informazioni", + "instantMix": "Mix istantaneo" } }, "album": { @@ -46,29 +62,38 @@ "duration": "Durata", "songCount": "Tracce", "playCount": "Riproduzioni", + "size": "Dimensione", "name": "Nome", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", + "date": "Data di registrazione", + "originalDate": "Originale", + "releaseDate": "Data di pubblicazione", + "releases": "Pubblicazione |||| Pubblicazioni", + "released": "Pubblicato", "updatedAt": "Ultimo aggiornamento", "comment": "Commento", "rating": "Valutazione", - "createdAt": "Data di creazione", - "size": "Dimensione", - "originalDate": "", - "releaseDate": "Data di pubblicazione", - "releases": "Pubblicazione |||| Pubblicazioni", - "released": "Pubblicato" + "createdAt": "Data di aggiunta", + "recordLabel": "Etichetta", + "catalogNum": "Numero di catalogo", + "releaseType": "Tipo", + "grouping": "Raggruppamento", + "media": "Media", + "mood": "Umore", + "missing": "Mancante" }, "actions": { "playAll": "Riproduci", "playNext": "Riproduci come successivo", "addToQueue": "Aggiungi alla coda", + "share": "Condividi", "shuffle": "Riproduci casualmente", - "addToPlaylist": "Aggiungi alla Playlist", + "addToPlaylist": "Aggiungi alla playlist", "download": "Scarica", - "info": "Informazioni", - "share": "Condividi" + "info": "Informazioni" }, "lists": { "all": "Tutti", @@ -86,10 +111,33 @@ "name": "Nome", "albumCount": "Album", "songCount": "Numero tracce", + "size": "Dimensione", "playCount": "Riproduzioni", "rating": "Valutazione", "genre": "Genere", - "size": "Dimensione" + "role": "Ruolo", + "missing": "Mancante" + }, + "roles": { + "albumartist": "Artista Album |||| Artisti Album", + "artist": "Artista |||| Artisti", + "composer": "Compositore |||| Compositori", + "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", + "lyricist": "Paroliere |||| Parolieri", + "arranger": "Arrangiatore |||| Arrangiatori", + "producer": "Produttore |||| Produttori", + "director": "Direttore |||| Direttori", + "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "mixer": "Mixer |||| Mixer", + "remixer": "Remixer |||| Remixer", + "djmixer": "DJ Mixer |||| DJ Mixer", + "performer": "Esecutore |||| Esecutori", + "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" + }, + "actions": { + "topSongs": "Brani più ascoltati", + "shuffle": "Riproduci casualmente", + "radio": "Radio" } }, "user": { @@ -97,31 +145,39 @@ "fields": { "userName": "Nome utente", "isAdmin": "Amministratore", - "lastLoginAt": "Ultimo accesso", + "lastLoginAt": "Ultimo login", + "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", "name": "Nome", "password": "Password", - "createdAt": "Creato a", + "createdAt": "Creato il", "changePassword": "Cambiare la password?", "currentPassword": "Password Attuale", "newPassword": "Nuova Password", - "token": "Token" + "token": "Token", + "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso" + "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { "created": "Utente creato", "updated": "Utente aggiornato", "deleted": "Utente eliminato" }, + "validation": { + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", - "clickHereForToken": "Clicca qui per ottenere il tuo token" + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "clickHereForToken": "Clicca qui per ottenere il tuo token", + "selectAllLibraries": "Seleziona tutte le librerie", + "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" } }, "player": { - "name": "Client |||| Client", + "name": "Lettore |||| Lettori", "fields": { "name": "Nome", "transcodingId": "Transcodifica", @@ -130,7 +186,7 @@ "userName": "Nome utente", "lastSeen": "Ultimo accesso", "reportRealPath": "Mostra percorso reale", - "scrobbleEnabled": "" + "scrobbleEnabled": "Invia scrobble ai servizi esterni" } }, "transcoding": { @@ -157,45 +213,203 @@ "path": "Importa da" }, "actions": { - "selectPlaylist": "Aggiungi tracce alla playlist:", - "addNewPlaylist": "Aggiungi \"%{name}\"", + "selectPlaylist": "Seleziona una playlist:", + "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", + "saveQueue": "Salva la coda nella playlist", "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata" + "makePrivate": "Rendi Privata", + "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", + "pressEnterToCreate": "Premi Invio per creare una nuova playlist", + "removeFromSelection": "Rimuovi dalla selezione" }, "message": { "duplicate_song": "Aggiungere i duplicati", - "song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?" + "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", + "noPlaylistsFound": "Nessuna playlist trovata", + "noPlaylists": "Nessuna playlist disponibile" } }, "radio": { "name": "Radio |||| Radio", "fields": { "name": "Nome", - "streamUrl": "", - "homePageUrl": "", - "updatedAt": "", - "createdAt": "" + "streamUrl": "URL dello stream", + "homePageUrl": "URL della pagina web", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" }, "actions": { - "playNow": "" + "playNow": "Riproduci adesso" } }, "share": { - "name": "", + "name": "Condivisione |||| Condivisioni", "fields": { - "username": "", - "url": "", - "description": "", - "contents": "", - "expiresAt": "", - "lastVisitedAt": "", - "visitCount": "", - "format": "", - "maxBitRate": "", - "updatedAt": "", - "createdAt": "", - "downloadable": "" + "username": "Condiviso da", + "url": "URL", + "description": "Descrizione", + "downloadable": "Consenti i download?", + "contents": "Contenuti", + "expiresAt": "Scade il", + "lastVisitedAt": "Ultima visita", + "visitCount": "Visite", + "format": "Formato", + "maxBitRate": "Bitrate massimo", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "File mancante |||| File mancanti", + "empty": "Nessun file mancante", + "fields": { + "path": "Percorso", + "size": "Dimensione", + "libraryName": "Libreria", + "updatedAt": "Scomparso il" + }, + "actions": { + "remove": "Rimuovi", + "remove_all": "Rimuovi tutti" + }, + "notifications": { + "removed": "File mancanti rimossi" + } + }, + "library": { + "name": "Libreria |||| Librerie", + "fields": { + "name": "Nome", + "path": "Percorso", + "remotePath": "Percorso remoto", + "lastScanAt": "Ultima scansione", + "songCount": "Tracce", + "albumCount": "Album", + "artistCount": "Artisti", + "totalSongs": "Tracce", + "totalAlbums": "Album", + "totalArtists": "Artisti", + "totalFolders": "Cartelle", + "totalFiles": "File", + "totalMissingFiles": "File mancanti", + "totalSize": "Dimensione totale", + "totalDuration": "Durata", + "defaultNewUsers": "Predefinita per i nuovi utenti", + "createdAt": "Creata il", + "updatedAt": "Aggiornata il" + }, + "sections": { + "basic": "Informazioni di base", + "statistics": "Statistiche" + }, + "actions": { + "scan": "Scansiona la libreria", + "quickScan": "Scansione rapida", + "fullScan": "Scansione completa", + "manageUsers": "Gestisci accesso utenti", + "viewDetails": "Visualizza dettagli" + }, + "notifications": { + "created": "Libreria creata con successo", + "updated": "Libreria aggiornata con successo", + "deleted": "Libreria eliminata con successo", + "scanStarted": "Scansione della libreria avviata", + "quickScanStarted": "Scansione rapida avviata", + "fullScanStarted": "Scansione completa avviata", + "scanError": "Errore durante l'avvio della scansione. Controlla i log", + "scanCompleted": "Scansione della libreria completata" + }, + "validation": { + "nameRequired": "Il nome della libreria è obbligatorio", + "pathRequired": "Il percorso della libreria è obbligatorio", + "pathNotDirectory": "Il percorso della libreria deve essere una directory", + "pathNotFound": "Percorso della libreria non trovato", + "pathNotAccessible": "Il percorso della libreria non è accessibile", + "pathInvalid": "Percorso della libreria non valido" + }, + "messages": { + "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "scanInProgress": "Scansione in corso...", + "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" + } + }, + "plugin": { + "name": "Plugin |||| Plugin", + "fields": { + "id": "ID", + "name": "Nome", + "description": "Descrizione", + "version": "Versione", + "author": "Autore", + "website": "Sito web", + "permissions": "Permessi", + "enabled": "Abilitato", + "status": "Stato", + "path": "Percorso", + "lastError": "Errore", + "hasError": "Errore", + "updatedAt": "Aggiornato il", + "createdAt": "Installato il", + "configKey": "Chiave", + "configValue": "Valore", + "allUsers": "Consenti tutti gli utenti", + "selectedUsers": "Utenti selezionati", + "allLibraries": "Consenti tutte le librerie", + "selectedLibraries": "Librerie selezionate", + "allowWriteAccess": "Consenti accesso in scrittura" + }, + "sections": { + "status": "Stato", + "info": "Informazioni sul plugin", + "configuration": "Configurazione", + "manifest": "Manifest", + "usersPermission": "Permessi utenti", + "libraryPermission": "Permesso libreria" + }, + "status": { + "enabled": "Abilitato", + "disabled": "Disabilitato" + }, + "actions": { + "enable": "Abilita", + "disable": "Disabilita", + "disabledDueToError": "Correggi l'errore prima di abilitare", + "disabledUsersRequired": "Seleziona gli utenti prima di abilitare", + "disabledLibrariesRequired": "Seleziona le librerie prima di abilitare", + "addConfig": "Aggiungi configurazione", + "rescan": "Riscansiona" + }, + "notifications": { + "enabled": "Plugin abilitato", + "disabled": "Plugin disabilitato", + "updated": "Plugin aggiornato", + "error": "Errore durante l'aggiornamento del plugin" + }, + "validation": { + "invalidJson": "La configurazione deve essere un JSON valido" + }, + "messages": { + "configHelp": "Configura il plugin usando coppie chiave-valore. Lascia vuoto se il plugin non richiede configurazione.", + "configValidationError": "Validazione della configurazione fallita:", + "schemaRenderError": "Impossibile visualizzare il modulo di configurazione. Lo schema del plugin potrebbe non essere valido.", + "clickPermissions": "Clicca su un permesso per i dettagli", + "noConfig": "Nessuna configurazione impostata", + "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", + "noUsers": "Nessun utente selezionato", + "permissionReason": "Motivo", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", + "noLibraries": "Nessuna libreria selezionata", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", + "requiredHosts": "Host richiesti" + }, + "placeholders": { + "configKey": "chiave", + "configValue": "valore" } } }, @@ -206,12 +420,13 @@ "confirmPassword": "Conferma la password", "buttonCreateAdmin": "Crea amministratore", "auth_check_error": "Per favore accedi per continuare", - "user_menu": "Profile", + "user_menu": "Profilo", "username": "Nome utente", "password": "Password", "sign_in": "Accedi", "sign_in_error": "Autenticazione fallita, per favore riprova", - "logout": "Disconnetti" + "logout": "Disconnetti", + "insightsCollectionNote": "Navidrome raccoglie dati di utilizzo anonimi per\nmigliorare il progetto. Clicca [qui] per saperne di più\ne per disattivarlo se lo desideri" }, "validation": { "invalidChars": "Per favore usa solo lettere e numeri", @@ -226,13 +441,14 @@ "oneOf": "Deve essere uno di: %{options}", "regex": "Deve rispettare il formato (espressione regolare): %{pattern}", "unique": "Deve essere unico", - "url": "" + "url": "Deve essere un URL valido" }, "action": { "add_filter": "Aggiungi un filtro", "add": "Aggiungi", "back": "Indietro", "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Annulla", "clear_input_value": "Cancella", "clone": "Duplica", @@ -244,7 +460,7 @@ "list": "Elenco", "refresh": "Aggiorna", "remove_filter": "Rimuovi questo filtro", - "remove": "Remove", + "remove": "Rimuovi", "save": "Salva", "search": "Cerca", "show": "Mostra", @@ -255,17 +471,16 @@ "open_menu": "Apri menù", "close_menu": "Chiudi menù", "unselect": "Deseleziona", - "skip": "Saltare i duplicati", - "bulk_actions_mobile": "", - "share": "", - "download": "" + "skip": "Salta", + "share": "Condividi", + "download": "Scarica" }, "boolean": { - "true": "Si", + "true": "Sì", "false": "No" }, "page": { - "create": "Aggiungi %{name}", + "create": "Crea %{name}", "dashboard": "Pannello di controllo", "edit": "%{name} #%{id}", "error": "Qualcosa è andato storto", @@ -274,7 +489,7 @@ "not_found": "Non trovato", "show": "%{name} #%{id}", "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi invitare un amico?" + "invite": "Vuoi aggiungerne uno?" }, "input": { "file": { @@ -308,17 +523,17 @@ "loading": "La pagina si sta caricando, solo un momento per favore", "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", - "yes": "Si", - "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?" + "yes": "Sì", + "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" }, "navigation": { "no_results": "Nessun risultato trovato", "no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.", - "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall’intervallo", - "page_out_from_end": "Non è possibile andare oltre l’ultima pagina", + "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall'intervallo", + "page_out_from_end": "Non è possibile andare oltre l'ultima pagina", "page_out_from_begin": "Non è possibile andare prima della prima pagina", "page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}", - "page_rows_per_page": "Righe per pagina:", + "page_rows_per_page": "Elementi per pagina:", "next": "Successivo", "prev": "Precedente", "skip_nav": "Passa al contenuto" @@ -334,7 +549,7 @@ "i18n_error": "Impossibile caricare la traduzione per la lingua selezionata", "canceled": "Azione annullata", "logged_out": "La sessione è scaduta, per favore accedi di nuovo.", - "new_version": "Una nuova versione è disponibile! Ricarica la pagina" + "new_version": "Una nuova versione è disponibile! Ricarica la pagina." }, "toggleFieldsMenu": { "columnsToDisplay": "Colonne da mostrare", @@ -344,39 +559,58 @@ } }, "message": { - "note": "Note", - "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.", - "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", + "uploadCover": "Carica copertina", + "removeCover": "Rimuovi copertina", + "coverUploaded": "Copertina aggiornata", + "coverRemoved": "Copertina rimossa", + "coverUploadError": "Errore durante il caricamento della copertina", + "coverRemoveError": "Errore durante la rimozione della copertina", + "note": "NOTA", + "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l'interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l'opzione %{config}.", + "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi di sistema dalle impostazioni di transcodifica tramite l'interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", "songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist", - "noPlaylistsAvailable": "Nessuna playlist", + "noSimilarSongsFound": "Nessuna traccia simile trovata", + "startingInstantMix": "Caricamento del Mix istantaneo...", + "noTopSongsFound": "Nessun brano più ascoltato trovato", + "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "remove_missing_title": "Rimuovi i file mancanti", + "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_title": "Rimuovi tutti i file mancanti", + "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", - "lastfmLinkFailure": "Non è stato possible collegare Last.fm", + "lastfmLinkFailure": "Non è stato possibile collegare Last.fm", "lastfmUnlinkSuccess": "Lo scrobbling è stato disabilitato e Last.fm è stato disconnesso", "lastfmUnlinkFailure": "Non è stato possibile scollegare Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente: %{user}", + "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz disconnesso e scrobbling disabilitato", + "listenBrainzUnlinkFailure": "Non è stato possibile disconnettere ListenBrainz", "openIn": { "lastfm": "Apri in Last.fm", "musicbrainz": "Apri in MusicBrainz" }, "lastfmLink": "Per saperne di più...", - "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente %{user}", - "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "", - "listenBrainzUnlinkFailure": "", - "downloadOriginalFormat": "", - "shareOriginalFormat": "", - "shareDialogTitle": "", - "shareBatchDialogTitle": "", - "shareSuccess": "", - "shareFailure": "", - "downloadDialogTitle": "", - "shareCopyToClipboard": "" + "shareOriginalFormat": "Condividi nel formato originale", + "shareDialogTitle": "Condividi %{resource} '%{name}'", + "shareBatchDialogTitle": "Condividi 1 %{resource} |||| Condividi %{smart_count} %{resource}", + "shareCopyToClipboard": "Copia negli appunti: Ctrl+C, Invio", + "shareSuccess": "URL copiato negli appunti: %{url}", + "shareFailure": "Errore durante la copia dell'URL %{url} negli appunti", + "downloadDialogTitle": "Scarica %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Scarica nel formato originale" }, "menu": { "library": "Libreria", + "librarySelector": { + "allLibraries": "Tutte le librerie (%{count})", + "multipleLibraries": "%{selected} di %{total} librerie", + "selectLibraries": "Seleziona librerie", + "none": "Nessuna" + }, "settings": "Impostazioni", "version": "Versione", "theme": "Tema", @@ -387,21 +621,22 @@ "language": "Lingua", "defaultView": "Vista Predefinita", "desktop_notifications": "Notifiche desktop", + "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", + "listenBrainzScrobbling": "Esegui lo scrobbling tramite ListenBrainz", + "replaygain": "Modalità ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", "gain": { - "none": "", - "album": "", - "track": "" + "none": "Disabilitato", + "album": "Usa guadagno album", + "track": "Usa guadagno traccia" } } }, "albumList": "Album", - "about": "Info", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise" + "sharedPlaylists": "Playlist Condivise", + "about": "Info" }, "player": { "playListsText": "Coda", @@ -432,29 +667,59 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste" + "featureRequests": "Richieste", + "lastInsightsCollection": "Ultima raccolta dati", + "insights": { + "disabled": "Disabilitato", + "waiting": "In attesa" + } + }, + "tabs": { + "about": "Info", + "config": "Configurazione" + }, + "config": { + "configName": "Nome configurazione", + "environmentVariable": "Variabile d'ambiente", + "currentValue": "Valore attuale", + "configurationFile": "File di configurazione", + "exportToml": "Esporta configurazione (TOML)", + "downloadToml": "Scarica configurazione (TOML)", + "exportSuccess": "Configurazione esportata negli appunti in formato TOML", + "exportFailed": "Impossibile copiare la configurazione", + "devFlagsHeader": "Flag di sviluppo (soggetti a modifiche/rimozione)", + "devFlagsComment": "Queste sono impostazioni sperimentali e potrebbero essere rimosse in versioni future" } }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate", - "quickScan": "Scansione veloce", - "fullScan": "Scansione completa", - "serverUptime": "Periodo di attività", - "serverDown": "OFFLINE" + "totalScanned": "Cartelle scansionate totali", + "quickScan": "Rapida", + "fullScan": "Completa", + "selectiveScan": "Selettiva", + "serverUptime": "Periodo di attività del server", + "serverDown": "OFFLINE", + "scanType": "Ultima scansione", + "status": "Errore di scansione", + "elapsedTime": "Tempo trascorso" + }, + "nowPlaying": { + "title": "In riproduzione", + "empty": "Nessuna riproduzione in corso", + "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera", + "title": "Scorciatoie da Tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", "prev_song": "Traccia Precedente", "next_song": "Traccia Successiva", + "current_song": "Vai alla traccia corrente", "vol_up": "Alza il Volume", "vol_down": "Abbassa il Volume", - "toggle_love": "Aggiungi questa traccia ai preferiti", - "current_song": "" + "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} +} \ No newline at end of file From 81a17f6bbb933fe03bca12ffe3c51244b3b452da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 25 Apr 2026 20:27:38 -0400 Subject: [PATCH 198/596] =?UTF-8?q?fix(search):=20normalization=20for=20no?= =?UTF-8?q?n-NFKD=20Unicode=20letters=20(=C3=B8,=20=C3=A6,=20=C5=93,=20?= =?UTF-8?q?=C3=9F)=20(#5413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(search): transliterate non-ASCII letters symmetrically in FTS5 path Songs and artists with letters like ø, æ, œ, ß were unsearchable. The query path in server/subsonic/searching.go transliterates with sanitize.Accents (Øystein → Oystein), but the FTS5 tokenizer's remove_diacritics 2 only strips NFKD-decomposable marks — atomic letters with built-in strokes/ligatures survive tokenization, so the query side and index side disagreed. Apply sanitize.Accents on both sides: - normalizeForFTS now also emits an ASCII-transliterated form for each word, so search_normalized contains the variant the query produces. - buildFTS5Query transliterates the unquoted portion of the input so every caller (Subsonic, REST fullTextFilter) gets the same handling. Quoted phrases stay as typed, preserving phrase matches against the original title/artist columns. Existing libraries pick up the fix as records are re-scanned; users can trigger a manual full rescan to refresh older entries. * fix(search): cache transliteration and add ß/quoted-phrase test coverage Address review feedback: call sanitize.Accents once per word and reuse the result for both the punct-stripped and accent-only paths. Add missing test entries for ß→ss transliteration and quoted Unicode phrase preservation. --------- Co-authored-by: Claude <noreply@anthropic.com> --- persistence/sql_search_fts.go | 43 +++++++++++++++++++++--------- persistence/sql_search_fts_test.go | 18 +++++++++++-- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 1d4116b5d..e9b961d91 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" . "github.com/Masterminds/squirrel" + "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -44,24 +45,33 @@ var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) // fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) -// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word, -// and returns a space-separated string of words that changed after stripping (deduplicated). -// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC". +// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of +// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) +// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed +// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — +// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree +// without an explicit transliterated entry here. func normalizeForFTS(values ...string) string { seen := make(map[string]struct{}) var result []string + add := func(orig, variant string) { + if variant == "" || variant == orig { + return + } + lower := strings.ToLower(variant) + if _, ok := seen[lower]; ok { + return + } + seen[lower] = struct{}{} + result = append(result, variant) + } for _, v := range values { for _, word := range strings.Fields(v) { - stripped := fts5PunctStrip.ReplaceAllString(word, "") - if stripped == "" || stripped == word { - continue - } - lower := strings.ToLower(stripped) - if _, ok := seen[lower]; ok { - continue - } - seen[lower] = struct{}{} - result = append(result, stripped) + transliterated := sanitize.Accents(word) + // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. + add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) + // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). + add(word, transliterated) } } return strings.Join(result, " ") @@ -158,6 +168,13 @@ func buildFTS5Query(userInput string) string { result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:] } + // Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …) + // so the query matches the ASCII variants emitted by normalizeForFTS at index time. + // FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without + // this step queries for words containing these letters can miss. Quoted phrases are + // left untouched so they continue to match the original text in title/artist columns. + result = sanitize.Accents(result) + // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive: // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens) result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index d0e26c8e3..b54e5856a 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -37,7 +37,13 @@ var _ = DescribeTable("buildFTS5Query", Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), + Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"), + Entry("transliterates ø to o", "Øystein", "Oystein*"), + Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"), + Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"), + Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"), + Entry("transliterates ß to ss", "Straße", "Strasse*"), + Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), @@ -75,11 +81,19 @@ var _ = DescribeTable("normalizeForFTS", Entry("strips dots and concatenates", "REM", "R.E.M."), Entry("strips slash", "ACDC", "AC/DC"), Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged words", "", "The Beatles"), + Entry("skips unchanged ASCII words", "", "The Beatles"), Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), Entry("strips apostrophe from word", "N", "Guns N' Roses"), Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), + Entry("transliterates ø to o", "Bjork", "Bjørk"), + Entry("transliterates Ø to O", "Oystein", "Øystein"), + Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), + Entry("transliterates Latin diacritics", "cafe", "café"), + Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), + Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), + Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), + Entry("transliterates ß to ss", "Strasse", "Straße"), ) var _ = DescribeTable("containsCJK", From 5d1c9530abbe4bdd772e80a8ba3dff785e6789a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 25 Apr 2026 20:54:02 -0400 Subject: [PATCH 199/596] feat(cli): add pls export/import subcommands for bulk playlist management (#5412) * refactor: rename ImportFile to ImportFromFolder in playlists service * feat: add ImportFile method with library/folder resolution * feat: allow sync flag upgrade on re-import of non-synced playlists * feat: add pls export subcommand with bulk and single export Add `navidrome pls export` command that supports: - Single playlist export to stdout (-p flag only) - Single playlist export to directory (-p and -o flags) - Bulk export of all playlists to a directory (-o flag only) - Filtering by user (-u flag) - Automatic filename sanitization and collision detection Also extracts findPlaylist helper from runExporter for reuse. * feat: add pls import subcommand with sync flag support * fix: improve error message for export without output directory * test: add tests for ImportFile sync flag and sync upgrade behavior * refactor: streamline export and import logic by removing redundant comments and improving library path matching Signed-off-by: Deluan <deluan@navidrome.org> * feat: update ImportFile method to include sync flag for playlist imports Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement fetchPlaylists function to streamline playlist retrieval Signed-off-by: Deluan <deluan@navidrome.org> * feat: replace inline filename sanitization with centralized utility function Signed-off-by: Deluan <deluan@navidrome.org> * feat: refactor playlist import logic to consolidate sync handling and improve method signatures Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback on playlist import/export - Fix duplicate playlist creation on non-sync re-import: only reconcile sync flag when the playlist was actually persisted (has an ID) - Distinguish "not in any library" from real errors in resolveFolder using a sentinel error, so DB/folder errors propagate instead of falling back to ImportM3U - Use bufio.Scanner in countM3UTrackLines instead of reading entire file * feat: replace bufio.Scanner with UTF8Reader and LinesFrom utility for improved file reading Signed-off-by: Deluan <deluan@navidrome.org> * fix: record path for outside-library imports to prevent duplicates Files outside all libraries now go through updatePlaylist with the absolute path recorded, so re-importing the same file updates the existing playlist instead of creating a duplicate. * refactor: name guard condition in updatePlaylist for readability Extracted the compound boolean expression into a named local variable `alreadyImportedAndNotSynced` to make the intent of the early-return guard clearer at a glance. * add godocs Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/pls.go | 224 ++++++++++++++++++++++++++---- core/archiver.go | 11 +- core/playlists/import.go | 110 +++++++++++++-- core/playlists/import_test.go | 214 ++++++++++++++++++++++++---- core/playlists/parse_m3u.go | 13 +- core/playlists/playlists.go | 5 +- scanner/phase_4_playlists.go | 2 +- scanner/phase_4_playlists_test.go | 6 +- utils/str/sanitize_strings.go | 16 +++ 9 files changed, 516 insertions(+), 85 deletions(-) diff --git a/cmd/pls.go b/cmd/pls.go index 9b94c9e8f..95cbe4eec 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -7,11 +7,19 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" + "strings" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/spf13/cobra" ) @@ -20,6 +28,7 @@ var ( outputFile string userID string outputFormat string + syncFlag bool ) type displayPlaylist struct { @@ -41,6 +50,15 @@ func init() { listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]") plsCmd.AddCommand(listCommand) + + exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID") + exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory") + exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") + plsCmd.AddCommand(exportCommand) + + importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)") + importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced") + plsCmd.AddCommand(importCommand) } var ( @@ -60,72 +78,165 @@ var ( runList(cmd.Context()) }, } + + exportCommand = &cobra.Command{ + Use: "export", + Short: "Export playlists to M3U files", + Long: "Export one or more Navidrome playlists to M3U files", + Run: func(cmd *cobra.Command, args []string) { + runExport(cmd.Context()) + }, + } + + importCommand = &cobra.Command{ + Use: "import [files...]", + Short: "Import M3U playlists", + Long: "Import one or more M3U files as Navidrome playlists", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runImport(cmd.Context(), args) + }, + } ) -func runExporter(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) +func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists { + options := model.QueryOptions{Sort: sort} + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + options.Filters = squirrel.Eq{"owner_id": user.ID} + } + pls, err := ds.Playlist(ctx).GetAll(options) + if err != nil { + log.Fatal(ctx, "Failed to retrieve playlists", err) + } + return pls +} + +func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { + playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}}) + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if len(playlists) > 0 { playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } } } if playlist == nil { - log.Fatal("Playlist not found", "name", playlistID) + log.Fatal("Playlist not found", "name", nameOrID) } + return playlist +} + +func runExporter(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + playlist := findPlaylist(ctx, ds, playlistID) pls := playlist.ToM3U8() if outputFile == "-" || outputFile == "" { println(pls) return } - - err = os.WriteFile(outputFile, []byte(pls), 0600) + err := os.WriteFile(outputFile, []byte(pls), 0600) if err != nil { log.Fatal("Error writing to the output file", "file", outputFile, err) } } +func runExport(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + + if playlistID != "" && outputFile == "" { + playlist := findPlaylist(ctx, ds, playlistID) + println(playlist.ToM3U8()) + return + } + + if outputFile == "" { + log.Fatal("Output directory (-o) is required for bulk export or when filtering by user") + } + + info, err := os.Stat(outputFile) + if err != nil || !info.IsDir() { + log.Fatal("Output path must be an existing directory", "path", outputFile) + } + + if playlistID != "" { + pls := findPlaylist(ctx, ds, playlistID) + filename := str.SanitizeFilename(pls.Name) + ".m3u" + path := filepath.Join(outputFile, filename) + err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600) + if err != nil { + log.Fatal("Error writing playlist", "file", path, err) + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + return + } + + allPls := fetchPlaylists(ctx, ds, "name") + + nameCounts := make(map[string]int) + for _, pls := range allPls { + nameCounts[str.SanitizeFilename(pls.Name)]++ + } + + exported := 0 + for _, pls := range allPls { + plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + if err != nil { + log.Error("Error loading playlist tracks", "playlist", pls.Name, err) + continue + } + + sanitized := str.SanitizeFilename(pls.Name) + filename := sanitized + ".m3u" + if nameCounts[sanitized] > 1 { + shortID := pls.ID + if len(shortID) > 6 { + shortID = shortID[:6] + } + filename = sanitized + "_" + shortID + ".m3u" + } + + path := filepath.Join(outputFile, filename) + err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600) + if err != nil { + log.Error("Error writing playlist", "file", path, err) + continue + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + exported++ + } + fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile) +} + func runList(ctx context.Context) { if outputFormat != "csv" && outputFormat != "json" { log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) } ds, ctx := getAdminContext(ctx) - options := model.QueryOptions{Sort: "owner_name"} - - if userID != "" { - user, err := getUser(ctx, userID, ds) - if err != nil { - log.Fatal(ctx, "Error retrieving user", "username or id", userID) - } - options.Filters = squirrel.Eq{"owner_id": user.ID} - } - - playlists, err := ds.Playlist(ctx).GetAll(options) - if err != nil { - log.Fatal(ctx, "Failed to retrieve playlists", err) - } + allPls := fetchPlaylists(ctx, ds, "owner_name") if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) _ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"}) - for _, playlist := range playlists { + for _, playlist := range allPls { _ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)}) } w.Flush() } else { - display := make(displayPlaylists, len(playlists)) - for idx, playlist := range playlists { + display := make(displayPlaylists, len(allPls)) + for idx, playlist := range allPls { display[idx].Id = playlist.ID display[idx].Name = playlist.Name display[idx].OwnerId = playlist.OwnerID @@ -137,3 +248,62 @@ func runList(ctx context.Context) { fmt.Printf("%s\n", j) } } + +func runImport(ctx context.Context, files []string) { + ds, ctx := getAdminContext(ctx) + + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + ctx = request.WithUser(ctx, *user) + } + + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + + for _, file := range files { + absPath, err := filepath.Abs(file) + if err != nil { + log.Error("Error resolving path", "file", file, err) + fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err) + continue + } + + totalLines := countM3UTrackLines(absPath) + + imported, err := pls.ImportFile(ctx, absPath, syncFlag) + if err != nil { + log.Error("Error importing playlist", "file", absPath, err) + fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err) + continue + } + + matched := len(imported.Tracks) + if totalLines > 0 { + notFound := totalLines - matched + fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound) + } else { + fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched) + } + } +} + +func countM3UTrackLines(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + + count := 0 + reader := ioutils.UTF8Reader(file) + for line := range slice.LinesFrom(reader) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + count++ + } + return count +} diff --git a/core/archiver.go b/core/archiver.go index 8305c4f6c..96cc2c31e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Archiver interface { @@ -87,7 +88,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc if isMultiDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file) + return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { @@ -126,7 +127,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { - plsName := sanitizeName(name) + plsName := str.SanitizeFilename(name) w, err := z.CreateHeader(&zip.FileHeader{ Name: plsName + ".m3u", Modified: mfs[0].UpdatedAt, @@ -156,11 +157,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) if format != "" && format != "raw" { ext = format } - return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext) -} - -func sanitizeName(target string) string { - return strings.ReplaceAll(target, "/", "_") + return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { diff --git a/core/playlists/import.go b/core/playlists/import.go index 4462554c7..9d3ecabc5 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -3,6 +3,7 @@ package playlists import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -17,14 +18,89 @@ import ( "golang.org/x/text/unicode/norm" ) -func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) { + absPath, err := filepath.Abs(absolutePath) + if err != nil { + return nil, fmt.Errorf("resolving absolute path: %w", err) + } + + dir := filepath.Dir(absPath) + filename := filepath.Base(absPath) + + folder, err := s.resolveFolder(ctx, dir) + if err != nil && !errors.Is(err, errNotInLibrary) { + return nil, err + } + if err == nil { + pls, err := s.importFromFolder(ctx, folder, filename, sync) + if err != nil { + return nil, err + } + if pls.ID != "" && pls.Sync != sync { + pls.Sync = sync + if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil { + return nil, putErr + } + } + return pls, nil + } + + log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath) + pls, err := s.newSyncedPlaylist(dir, filename) + if err != nil { + return nil, fmt.Errorf("reading playlist file: %w", err) + } + pls.Sync = sync + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("opening playlist file: %w", err) + } + defer file.Close() + + reader := ioutils.UTF8Reader(file) + if err := s.parseM3U(ctx, pls, nil, reader); err != nil { + return nil, err + } + if err := s.updatePlaylist(ctx, pls, sync); err != nil { + return nil, err + } + return pls, nil +} + +var errNotInLibrary = fmt.Errorf("path not in any library") + +func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) { + libs, err := s.ds.Library(ctx).GetAll() + if err != nil { + return nil, err + } + matcher := newLibraryMatcher(libs) + lib, ok := matcher.findLibrary(dir) + if !ok { + return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) + } + + folder, err := s.ds.Folder(ctx).GetByPath(lib, dir) + if err != nil { + return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err) + } + folder.LibraryPath = lib.Path + return folder, nil +} + +func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + return s.importFromFolder(ctx, folder, filename, false) +} + +func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) { pls, err := s.parsePlaylist(ctx, filename, folder) if err != nil { log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) return nil, err } log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) - err = s.updatePlaylist(ctx, pls) + err = s.updatePlaylist(ctx, pls, forceSync) if err != nil { log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) } @@ -74,27 +150,31 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold return pls, err } -func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { - owner, _ := request.UserFrom(ctx) - - // Try to find existing playlist by path. Since filesystem normalization differs across - // platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match - // playlists that may have been imported on a different platform. - pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) +// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode +// normalization forms to handle cross-platform filesystem differences. +func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) { + pls, err := s.ds.Playlist(ctx).FindByPath(path) if errors.Is(err, model.ErrNotFound) { - // Try alternate normalization form - altPath := norm.NFD.String(newPls.Path) - if altPath == newPls.Path { - altPath = norm.NFC.String(newPls.Path) + altPath := norm.NFD.String(path) + if altPath == path { + altPath = norm.NFC.String(path) } - if altPath != newPls.Path { + if altPath != path { pls, err = s.ds.Playlist(ctx).FindByPath(altPath) } } + return pls, err +} + +func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error { + owner, _ := request.UserFrom(ctx) + + pls, err := s.findByPathNormalized(ctx, newPls.Path) if err != nil && !errors.Is(err, model.ErrNotFound) { return err } - if err == nil && !pls.Sync { + alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync + if alreadyImportedAndNotSynced { log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) return nil } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 53855d781..f2866fb60 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() { ctx = request.WithUser(ctx, model.User{ID: "123"}) }) - Describe("ImportFile", func() { + Describe("ImportFromFolder", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -59,7 +59,7 @@ var _ = Describe("Playlists - Import", func() { Describe("M3U", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "pls1.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Tracks).To(HaveLen(2)) @@ -69,19 +69,19 @@ var _ = Describe("Playlists - Import", func() { }) It("parses playlists using LF ending", func() { - pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists using CR ending (old Mac format)", func() { - pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists with UTF-8 BOM marker", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("Test Playlist")) @@ -90,7 +90,7 @@ var _ = Describe("Playlists - Import", func() { }) It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("UTF-16 Test Playlist")) @@ -101,7 +101,7 @@ var _ = Describe("Playlists - Import", func() { It("parses #EXTALBUMARTURL with HTTP URL", func() { conf.Server.EnableM3UExternalAlbumArt = true - pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) Expect(pls.Tracks).To(HaveLen(2)) @@ -121,7 +121,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -139,7 +139,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg"))) }) @@ -158,7 +158,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -177,7 +177,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -195,7 +195,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -212,7 +212,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -229,7 +229,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -247,7 +247,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -275,12 +275,38 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.UploadedImage).To(Equal("existing-id.jpg")) Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg")) }) + It("skips non-synced playlist on re-import (respects user's choice)", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: plsFile, + Sync: false, + OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + // updatePlaylist skips the non-synced playlist, so the returned + // playlist has no ID (was never persisted/updated). + Expect(pls.ID).To(BeEmpty()) + }) + It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) @@ -301,7 +327,7 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -309,7 +335,7 @@ var _ = Describe("Playlists - Import", func() { Describe("NSP", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(Equal(pls)) Expect(pls.OwnerID).To(Equal("123")) @@ -322,17 +348,17 @@ var _ = Describe("Playlists - Import", func() { }) It("returns an error if the playlist is not well-formed", func() { tests.SkipOnWindows("line-ending differences affect JSON error offset") - _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp") + _, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp") Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'")) }) It("parses NSP with public: true and creates public playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Public Playlist")) Expect(pls.Public).To(BeTrue()) }) It("parses NSP with public: false and creates private playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Private Playlist")) Expect(pls.Public).To(BeFalse()) @@ -340,7 +366,7 @@ var _ = Describe("Playlists - Import", func() { It("uses server default when public field is absent", func() { conf.Server.DefaultPlaylistPublicVisibility = true - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true @@ -386,7 +412,7 @@ var _ = Describe("Playlists - Import", func() { Path: "", Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u") Expect(err).ToNot(HaveOccurred()) // Should update existing playlist, not create new one @@ -441,7 +467,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -462,7 +488,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should only find abc.mp3, not outside.mp3 Expect(pls.Tracks).To(HaveLen(1)) @@ -499,7 +525,7 @@ var _ = Describe("Playlists - Import", func() { Name: "subfolder", // The folder name } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -542,7 +568,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library @@ -593,7 +619,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should have BOTH tracks, not just one @@ -616,6 +642,126 @@ var _ = Describe("Playlists - Import", func() { }) }) + Describe("ImportFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} + }) + + It("resolves file inside a library and imports it", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", + LibraryID: 1, + LibraryPath: tmpDir, + Path: "", + Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" + plsFile := filepath.Join(tmpDir, "my-playlist.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("My Playlist")) + Expect(pls.Tracks).To(HaveLen(2)) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeTrue()) + }) + + It("records path for files outside all libraries", func() { + tmpDir := GinkgoT().TempDir() + libDir := filepath.Join(tmpDir, "music") + Expect(os.Mkdir(libDir, 0755)).To(Succeed()) + mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" + plsFile := filepath.Join(tmpDir, "external.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("External Playlist")) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=false", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=true", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeTrue()) + }) + + It("upgrades non-synced playlist to synced on re-import with sync=true", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", Name: "Existing", Path: plsFile, + Sync: false, OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).To(Equal("existing-id")) + Expect(pls.Sync).To(BeTrue()) + }) + }) + Describe("ImportM3U", func() { var repo *mockedMediaFileFromListRepo BeforeEach(func() { @@ -925,3 +1071,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi } return mfs, nil } + +type mockFolderRepoForImport struct { + model.FolderRepository + folder *model.Folder +} + +func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) { + if m.folder != nil { + return m.folder, nil + } + return nil, model.ErrNotFound +} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index b9f5c92a2..a64c337c9 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -163,17 +163,26 @@ type libraryMatcher struct { // findLibraryForPath finds which library contains the given absolute path. // Returns library ID and path, or 0 and empty string if not found. func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { + lib, ok := lm.findLibrary(absolutePath) + if !ok { + return 0, "" + } + return lib.ID, filepath.Clean(lib.Path) +} + +// findLibrary checks if the absolute path is under any of the library paths. +func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { // Check sorted libraries (longest path first) to find the best match for i, cleanLibPath := range lm.cleanedPaths { // Check if absolutePath is under this library path if strings.HasPrefix(absolutePath, cleanLibPath) { // Ensure it's a proper path boundary (not just a prefix) if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i].ID, cleanLibPath + return lm.libraries[i], true } } } - return 0, "" + return model.Library{}, false } // newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index a0086cd2d..3da24706c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -42,10 +42,11 @@ type Playlists interface { RemoveImage(ctx context.Context, playlistID string) error // Import - ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) + ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) + ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) - // REST adapters (follows Share/Library pattern) + // REST adapters NewRepository(ctx context.Context) rest.Repository TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index ab5f77ae0..f726343f2 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -100,7 +100,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. continue } // BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc) - pls, err := p.pls.ImportFile(p.ctx, folder, f.Name()) + pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name()) if err != nil { continue } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 06e6fa686..0e01a7549 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -97,9 +97,9 @@ var _ = Describe("phasePlaylists", func() { _ = os.WriteFile(file1, []byte{}, 0600) _ = os.WriteFile(file2, []byte{}, 0600) - pls.On("ImportFile", mock.Anything, folder, "playlist1.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). Return(&model.Playlist{}, nil) - pls.On("ImportFile", mock.Anything, folder, "playlist2.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u"). Return(&model.Playlist{}, nil) _, err := phase.processPlaylistsInFolder(folder) @@ -134,7 +134,7 @@ type mockPlaylists struct { playlists.Playlists } -func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { args := p.Called(ctx, folder, filename) return args.Get(0).(*model.Playlist), args.Error(1) } diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index c121aefe7..11f828270 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -52,6 +52,22 @@ func SanitizeHTML(text string) string { return policy.Sanitize(html.UnescapeString(text)) } +var filenameReplacer = strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", +) + +func SanitizeFilename(name string) string { + return filenameReplacer.Replace(name) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) From 0ab10e819f85b7146726b82906525215130d0d38 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 26 Apr 2026 10:43:54 -0400 Subject: [PATCH 200/596] refactor: simplify criteria Expression interface Replaced the Fields() type switch with a fields() method on the Expression interface, eliminating the need to update a central switch when adding new expression types. Removed the now-redundant criteriaExpression() marker method since fields() alone suffices to restrict the interface. Extracted a conjunction interface for the ChildPlaylistIds() lookup used by All and Any. --- model/criteria/criteria.go | 4 +- model/criteria/operators.go | 81 ++++++++++++++++++++----------------- model/criteria/walk.go | 37 +---------------- model/criteria/walk_test.go | 2 +- 4 files changed, 47 insertions(+), 77 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 1e161c9f2..e204e6972 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -9,7 +9,7 @@ import ( ) type Expression interface { - criteriaExpression() + fields() map[string]any } type Criteria struct { @@ -53,7 +53,7 @@ func (c Criteria) ChildPlaylistIds() []string { return nil } - if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok { + if parent, ok := c.Expression.(conjunction); ok { return parent.ChildPlaylistIds() } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 6f911b12f..983c6aa1a 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -2,12 +2,17 @@ package criteria import "time" +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +type conjunction interface { + ChildPlaylistIds() []string +} + type ( All []Expression And = All ) -func (All) criteriaExpression() {} +func (All) fields() map[string]any { return nil } func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -22,7 +27,7 @@ type ( Or = Any ) -func (Any) criteriaExpression() {} +func (Any) fields() map[string]any { return nil } func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -35,128 +40,128 @@ func (any Any) ChildPlaylistIds() (ids []string) { type Is map[string]any type Eq = Is -func (Is) criteriaExpression() {} - func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } +func (is Is) fields() map[string]any { return is } + type IsNot map[string]any -func (IsNot) criteriaExpression() {} - -func (in IsNot) MarshalJSON() ([]byte, error) { - return marshalExpression("isNot", in) +func (isn IsNot) MarshalJSON() ([]byte, error) { + return marshalExpression("isNot", isn) } -type Gt map[string]any +func (isn IsNot) fields() map[string]any { return isn } -func (Gt) criteriaExpression() {} +type Gt map[string]any func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt map[string]any +func (gt Gt) fields() map[string]any { return gt } -func (Lt) criteriaExpression() {} +type Lt map[string]any func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before map[string]any +func (lt Lt) fields() map[string]any { return lt } -func (Before) criteriaExpression() {} +type Before map[string]any func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) } -type After Gt +func (bf Before) fields() map[string]any { return bf } -func (After) criteriaExpression() {} +type After Gt func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) } -type Contains map[string]any +func (af After) fields() map[string]any { return af } -func (Contains) criteriaExpression() {} +type Contains map[string]any func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) } -type NotContains map[string]any +func (ct Contains) fields() map[string]any { return ct } -func (NotContains) criteriaExpression() {} +type NotContains map[string]any func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) } -type StartsWith map[string]any +func (nct NotContains) fields() map[string]any { return nct } -func (StartsWith) criteriaExpression() {} +type StartsWith map[string]any func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) } +func (sw StartsWith) fields() map[string]any { return sw } + type EndsWith map[string]any -func (EndsWith) criteriaExpression() {} - -func (sw EndsWith) MarshalJSON() ([]byte, error) { - return marshalExpression("endsWith", sw) +func (ew EndsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("endsWith", ew) } -type InTheRange map[string]any +func (ew EndsWith) fields() map[string]any { return ew } -func (InTheRange) criteriaExpression() {} +type InTheRange map[string]any func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) } -type InTheLast map[string]any +func (itr InTheRange) fields() map[string]any { return itr } -func (InTheLast) criteriaExpression() {} +type InTheLast map[string]any func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) } -type NotInTheLast map[string]any +func (itl InTheLast) fields() map[string]any { return itl } -func (NotInTheLast) criteriaExpression() {} +type NotInTheLast map[string]any func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } +func (nitl NotInTheLast) fields() map[string]any { return nitl } + func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } type InPlaylist map[string]any -func (InPlaylist) criteriaExpression() {} - func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) } +func (ipl InPlaylist) fields() map[string]any { return ipl } + type NotInPlaylist map[string]any -func (NotInPlaylist) criteriaExpression() {} - -func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { - return marshalExpression("notInPlaylist", ipl) +func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { + return marshalExpression("notInPlaylist", nipl) } +func (nipl NotInPlaylist) fields() map[string]any { return nipl } + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/walk.go b/model/criteria/walk.go index 62aaf97f8..acaf48289 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -32,41 +32,6 @@ func Walk(expr Expression, visit Visitor) error { return nil } -// Fields returns field values for leaf expressions only. -// Use Walk to traverse All and Any expressions before calling Fields. func Fields(expr Expression) map[string]any { - switch e := expr.(type) { - case Is: - return map[string]any(e) - case IsNot: - return map[string]any(e) - case Gt: - return map[string]any(e) - case Lt: - return map[string]any(e) - case Before: - return map[string]any(e) - case After: - return map[string]any(Gt(e)) - case Contains: - return map[string]any(e) - case NotContains: - return map[string]any(e) - case StartsWith: - return map[string]any(e) - case EndsWith: - return map[string]any(e) - case InTheRange: - return map[string]any(e) - case InTheLast: - return map[string]any(e) - case NotInTheLast: - return map[string]any(e) - case InPlaylist: - return map[string]any(e) - case NotInPlaylist: - return map[string]any(e) - default: - return nil - } + return expr.fields() } diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go index 91438f095..2e0f12f8d 100644 --- a/model/criteria/walk_test.go +++ b/model/criteria/walk_test.go @@ -9,7 +9,7 @@ import ( type unknownExpression struct{} -func (unknownExpression) criteriaExpression() {} +func (unknownExpression) fields() map[string]any { return nil } var _ = Describe("Walk", func() { It("visits the expression tree depth-first", func() { From 1bd736dae99db4cee344d26c797ddc2d7c86f7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 26 Apr 2026 14:49:59 -0400 Subject: [PATCH 201/596] refactor: centralize criteria sort parsing and extract smart playlist logic (#5415) * test: add tests for recordingdate alias resolution in smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * refactor: update FieldInfo structure and simplify fieldMap initialization Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move sort parsing logic from persistence to criteria package Extracted sort field parsing, validation, and direction handling from persistence/criteria_sql.go into model/criteria/sort.go. The new OrderByFields method on Criteria parses the Sort/Order strings into validated SortField structs (field name + direction), resolving aliases and handling +/- prefixes and order inversion. The persistence layer now consumes these parsed fields and only handles SQL expression mapping. This centralizes sort parsing to enforce consistent implementations. * refactor: standardize field access in smartPlaylistCriteria structure Signed-off-by: Deluan <deluan@navidrome.org> * refactor: add ResolveLimit method to Criteria Moved the percentage-limit resolution logic from playlist_repository into Criteria.ResolveLimit, replacing the 3-line mutate-after-query pattern with a single method call. The method preserves LimitPercent rather than zeroing it, since IsPercentageLimit already returns false once Limit is set, making the clear redundant and lossy. * refactor: improve child playlist loading and error handling in refresh logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor: extract smart playlist logic to dedicated files Moved refreshSmartPlaylist, addSmartPlaylistAnnotationJoins, and addCriteria methods from playlist_repository.go to a new smart_playlist_repository.go file. Extracted all smart playlist tests to smart_playlist_repository_test.go. Added DeferCleanup to the "valid rules" test to fix ordering flakiness when Ginkgo randomizes test execution across files. * refactor: break refreshSmartPlaylist into smaller focused methods Split the monolithic refreshSmartPlaylist method into discrete helpers for readability: shouldRefreshSmartPlaylist for guard checks, refreshChildPlaylists for recursive dependency refresh, resolvePercentageLimit for count-based limit resolution, buildSmartPlaylistQuery for assembling the SELECT with joins, and addMediaFileAnnotationJoin to DRY up the repeated annotation join clause. * refactor: deduplicate child playlist IDs in Criteria Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify withSmartPlaylistOwner to accept model.User Replaced separate ownerID string and ownerIsAdmin bool parameters with a single model.User struct, reducing the field count in smartPlaylistCriteria and making the option function signature clearer. Updated all call sites and tests accordingly. * fix: handle empty sort fields and propagate child playlist load errors OrderByFields now falls back to [{title, asc}] when all user-supplied sort fields are invalid, preventing empty ORDER BY clauses that would produce invalid SQL in row_number() window functions. Also restored the original behavior where a DB error loading child playlists aborts the parent smart playlist refresh, by making refreshChildPlaylists return a bool. * refactor: log warning when no valid sort fields are found Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- model/criteria/criteria.go | 20 +- model/criteria/criteria_test.go | 47 ++ model/criteria/fields.go | 160 +++--- model/criteria/sort.go | 62 ++ model/criteria/sort_test.go | 103 ++++ persistence/criteria_sql.go | 90 +-- persistence/criteria_sql_test.go | 8 +- persistence/e2e/smartplaylist_test.go | 5 + persistence/playlist_repository.go | 156 ----- persistence/playlist_repository_test.go | 511 ----------------- persistence/smart_playlist_repository.go | 203 +++++++ persistence/smart_playlist_repository_test.go | 531 ++++++++++++++++++ 12 files changed, 1069 insertions(+), 827 deletions(-) create mode 100644 model/criteria/sort.go create mode 100644 model/criteria/sort_test.go create mode 100644 persistence/smart_playlist_repository.go create mode 100644 persistence/smart_playlist_repository_test.go diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index e204e6972..31d208d08 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,6 +4,7 @@ package criteria import ( "encoding/json" "errors" + "slices" "github.com/navidrome/navidrome/log" ) @@ -42,6 +43,16 @@ func (c Criteria) EffectiveLimit(totalCount int64) int { return 0 } +// ResolveLimit converts a percentage-based limit into an absolute Limit using +// the given totalCount. It is a no-op when a fixed Limit is already set or when +// no percentage limit is configured. +func (c *Criteria) ResolveLimit(totalCount int64) { + if !c.IsPercentageLimit() { + return + } + c.Limit = c.EffectiveLimit(totalCount) +} + // IsPercentageLimit returns true when the criteria uses a valid percentage-based // limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it). func (c Criteria) IsPercentageLimit() bool { @@ -53,11 +64,14 @@ func (c Criteria) ChildPlaylistIds() []string { return nil } - if parent, ok := c.Expression.(conjunction); ok { - return parent.ChildPlaylistIds() + parent, ok := c.Expression.(conjunction) + if !ok { + return nil } - return nil + ids := parent.ChildPlaylistIds() + slices.Sort(ids) + return slices.Compact(ids) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index e0940a509..092cfd36a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -177,6 +177,39 @@ var _ = Describe("Criteria", func() { }) }) + Describe("ResolveLimit", func() { + It("resolves percentage to absolute limit preserving LimitPercent", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + + It("does nothing when Limit is already set", func() { + c := Criteria{Limit: 50, LimitPercent: 10} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(50)) + }) + + It("does nothing when no limit is configured", func() { + c := Criteria{} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(0)) + }) + + It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() { + c := Criteria{LimitPercent: 1} + c.ResolveLimit(5) + gomega.Expect(c.Limit).To(gomega.Equal(1)) + }) + + It("is idempotent when called twice", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + }) + Describe("IsPercentageLimit", func() { It("returns true when LimitPercent is set and Limit is 0", func() { c := Criteria{LimitPercent: 10} @@ -269,5 +302,19 @@ var _ = Describe("Criteria", func() { ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("deduplicates repeated playlist IDs", func() { + sharedID := uuid.NewString() + goObj = Criteria{ + Expression: All{ + InPlaylist{"id": sharedID}, + Any{ + InPlaylist{"id": sharedID}, + NotInPlaylist{"id": sharedID}, + }, + }, + } + ids := goObj.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.Equal([]string{sharedID})) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 30541a945..20c0048b3 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,90 +2,83 @@ package criteria import "strings" -// FieldInfo describes a criteria field without tying it to persistence details. +// FieldInfo contains semantic metadata about a criteria field type FieldInfo struct { Name string IsTag bool IsRole bool Numeric bool + alias string } -var fieldMap = map[string]*fieldMetadata{ - "title": {name: "title"}, - "album": {name: "album"}, - "hascoverart": {name: "hascoverart"}, - "tracknumber": {name: "tracknumber"}, - "discnumber": {name: "discnumber"}, - "year": {name: "year"}, - "date": {name: "date", alias: "recordingdate"}, - "originalyear": {name: "originalyear"}, - "originaldate": {name: "originaldate"}, - "releaseyear": {name: "releaseyear"}, - "releasedate": {name: "releasedate"}, - "size": {name: "size"}, - "compilation": {name: "compilation"}, - "missing": {name: "missing"}, - "explicitstatus": {name: "explicitstatus"}, - "dateadded": {name: "dateadded"}, - "datemodified": {name: "datemodified"}, - "discsubtitle": {name: "discsubtitle"}, - "comment": {name: "comment"}, - "lyrics": {name: "lyrics"}, - "sorttitle": {name: "sorttitle"}, - "sortalbum": {name: "sortalbum"}, - "sortartist": {name: "sortartist"}, - "sortalbumartist": {name: "sortalbumartist"}, - "albumcomment": {name: "albumcomment"}, - "catalognumber": {name: "catalognumber"}, - "filepath": {name: "filepath"}, - "filetype": {name: "filetype"}, - "codec": {name: "codec"}, - "duration": {name: "duration"}, - "bitrate": {name: "bitrate"}, - "bitdepth": {name: "bitdepth"}, - "samplerate": {name: "samplerate"}, - "bpm": {name: "bpm"}, - "channels": {name: "channels"}, - "loved": {name: "loved"}, - "dateloved": {name: "dateloved"}, - "lastplayed": {name: "lastplayed"}, - "daterated": {name: "daterated"}, - "playcount": {name: "playcount"}, - "rating": {name: "rating"}, - "averagerating": {name: "averagerating", numeric: true}, - "albumrating": {name: "albumrating"}, - "albumloved": {name: "albumloved"}, - "albumplaycount": {name: "albumplaycount"}, - "albumlastplayed": {name: "albumlastplayed"}, - "albumdateloved": {name: "albumdateloved"}, - "albumdaterated": {name: "albumdaterated"}, - "artistrating": {name: "artistrating"}, - "artistloved": {name: "artistloved"}, - "artistplaycount": {name: "artistplaycount"}, - "artistlastplayed": {name: "artistlastplayed"}, - "artistdateloved": {name: "artistdateloved"}, - "artistdaterated": {name: "artistdaterated"}, - "mbz_album_id": {name: "mbz_album_id"}, - "mbz_album_artist_id": {name: "mbz_album_artist_id"}, - "mbz_artist_id": {name: "mbz_artist_id"}, - "mbz_recording_id": {name: "mbz_recording_id"}, - "mbz_release_track_id": {name: "mbz_release_track_id"}, - "mbz_release_group_id": {name: "mbz_release_group_id"}, - "library_id": {name: "library_id", numeric: true}, +var fieldMap = map[string]FieldInfo{ + "title": {Name: "title"}, + "album": {Name: "album"}, + "hascoverart": {Name: "hascoverart"}, + "tracknumber": {Name: "tracknumber"}, + "discnumber": {Name: "discnumber"}, + "year": {Name: "year"}, + "date": {Name: "date", alias: "recordingdate"}, + "originalyear": {Name: "originalyear"}, + "originaldate": {Name: "originaldate"}, + "releaseyear": {Name: "releaseyear"}, + "releasedate": {Name: "releasedate"}, + "size": {Name: "size"}, + "compilation": {Name: "compilation"}, + "missing": {Name: "missing"}, + "explicitstatus": {Name: "explicitstatus"}, + "dateadded": {Name: "dateadded"}, + "datemodified": {Name: "datemodified"}, + "discsubtitle": {Name: "discsubtitle"}, + "comment": {Name: "comment"}, + "lyrics": {Name: "lyrics"}, + "sorttitle": {Name: "sorttitle"}, + "sortalbum": {Name: "sortalbum"}, + "sortartist": {Name: "sortartist"}, + "sortalbumartist": {Name: "sortalbumartist"}, + "albumcomment": {Name: "albumcomment"}, + "catalognumber": {Name: "catalognumber"}, + "filepath": {Name: "filepath"}, + "filetype": {Name: "filetype"}, + "codec": {Name: "codec"}, + "duration": {Name: "duration"}, + "bitrate": {Name: "bitrate"}, + "bitdepth": {Name: "bitdepth"}, + "samplerate": {Name: "samplerate"}, + "bpm": {Name: "bpm"}, + "channels": {Name: "channels"}, + "loved": {Name: "loved"}, + "dateloved": {Name: "dateloved"}, + "lastplayed": {Name: "lastplayed"}, + "daterated": {Name: "daterated"}, + "playcount": {Name: "playcount"}, + "rating": {Name: "rating"}, + "averagerating": {Name: "averagerating", Numeric: true}, + "albumrating": {Name: "albumrating"}, + "albumloved": {Name: "albumloved"}, + "albumplaycount": {Name: "albumplaycount"}, + "albumlastplayed": {Name: "albumlastplayed"}, + "albumdateloved": {Name: "albumdateloved"}, + "albumdaterated": {Name: "albumdaterated"}, + "artistrating": {Name: "artistrating"}, + "artistloved": {Name: "artistloved"}, + "artistplaycount": {Name: "artistplaycount"}, + "artistlastplayed": {Name: "artistlastplayed"}, + "artistdateloved": {Name: "artistdateloved"}, + "artistdaterated": {Name: "artistdaterated"}, + "mbz_album_id": {Name: "mbz_album_id"}, + "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, + "mbz_artist_id": {Name: "mbz_artist_id"}, + "mbz_recording_id": {Name: "mbz_recording_id"}, + "mbz_release_track_id": {Name: "mbz_release_track_id"}, + "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {name: "releasetype", isTag: true}, + "albumtype": {Name: "releasetype", IsTag: true}, - "random": {name: "random"}, - "value": {name: "value"}, -} - -type fieldMetadata struct { - name string - isRole bool - isTag bool - alias string - numeric bool + "random": {Name: "random"}, + "value": {Name: "value"}, } // AllFieldNames returns the names of all registered criteria fields. @@ -100,15 +93,7 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { f, ok := fieldMap[strings.ToLower(name)] - if !ok { - return FieldInfo{}, false - } - return FieldInfo{ - Name: f.name, - IsTag: f.isTag, - IsRole: f.isRole, - Numeric: f.numeric, - }, true + return f, ok } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in @@ -119,7 +104,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &fieldMetadata{name: name, isRole: true} + fieldMap[name] = FieldInfo{Name: name, IsRole: true} } } @@ -138,7 +123,7 @@ func AddTagNames(tagNames []string) { } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &fieldMetadata{name: name, isTag: true} + fieldMap[name] = FieldInfo{Name: name, IsTag: true} } } } @@ -148,9 +133,10 @@ func AddNumericTags(tagNames []string) { for _, tagName := range tagNames { name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { - fm.numeric = true + fm.Numeric = true + fieldMap[name] = fm } else { - fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} + fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} } } } diff --git a/model/criteria/sort.go b/model/criteria/sort.go new file mode 100644 index 000000000..05b108cf9 --- /dev/null +++ b/model/criteria/sort.go @@ -0,0 +1,62 @@ +package criteria + +import ( + "strings" + + "github.com/navidrome/navidrome/log" +) + +type SortField struct { + Field string + Desc bool +} + +func (c Criteria) OrderByFields() []SortField { + sortValue := c.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]SortField, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + desc := false + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + desc = strings.HasPrefix(part, "-") + part = strings.TrimSpace(part[1:]) + } + info, ok := LookupField(part) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", part) + continue + } + if order == "desc" { + desc = !desc + } + fields = append(fields, SortField{Field: info.Name, Desc: desc}) + } + if len(fields) == 0 { + log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) + return []SortField{{Field: "title", Desc: false}} + } + return fields +} + +func (c Criteria) SortFieldNames() []string { + sortFields := c.OrderByFields() + names := make([]string, len(sortFields)) + for i, sf := range sortFields { + names[i] = sf.Field + } + return names +} diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go new file mode 100644 index 000000000..35db88549 --- /dev/null +++ b/model/criteria/sort_test.go @@ -0,0 +1,103 @@ +package criteria + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = Describe("OrderByFields", func() { + It("defaults to title ascending when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses a single field", func() { + c := Criteria{Sort: "title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses descending prefix", func() { + c := Criteria{Sort: "-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}})) + }) + + It("parses ascending prefix", func() { + c := Criteria{Sort: "+title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses multiple comma-separated fields", func() { + c := Criteria{Sort: "title,-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("inverts directions when Order is desc", func() { + c := Criteria{Sort: "-date,title", Order: "desc"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "date", Desc: false}, + {Field: "title", Desc: true}, + })) + }) + + It("skips invalid fields", func() { + c := Criteria{Sort: "bogus,title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("falls back to title when all fields are invalid", func() { + c := Criteria{Sort: "bogus,invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("resolves tag aliases (albumtype -> releasetype)", func() { + c := Criteria{Sort: "albumtype"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}})) + }) + + It("resolves field aliases (recordingdate -> date)", func() { + AddTagNames([]string{"recordingdate"}) + c := Criteria{Sort: "recordingdate"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}})) + }) + + It("handles the random field", func() { + c := Criteria{Sort: "random"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}})) + }) + + It("ignores invalid Order value", func() { + c := Criteria{Sort: "-title", Order: "invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}})) + }) + + It("handles whitespace in fields", func() { + c := Criteria{Sort: " title , -rating "} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("skips empty parts from trailing commas", func() { + c := Criteria{Sort: "title,,rating,"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: false}, + })) + }) +}) + +var _ = Describe("SortFieldNames", func() { + It("returns canonical field names", func() { + c := Criteria{Sort: "title,-rating,albumtype"} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"})) + }) + + It("defaults to title when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"})) + }) +}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index c75f6467b..1431f0d0e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -9,7 +9,7 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" ) @@ -32,23 +32,21 @@ type smartPlaylistField struct { } type smartPlaylistCriteria struct { - criteria criteria.Criteria - ownerID string - ownerIsAdmin bool + criteria.Criteria + owner model.User } func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { - cSQL := smartPlaylistCriteria{criteria: c} + cSQL := smartPlaylistCriteria{Criteria: c} for _, opt := range opts { opt(&cSQL) } return cSQL } -func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { +func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) { return func(c *smartPlaylistCriteria) { - c.ownerID = ownerID - c.ownerIsAdmin = ownerIsAdmin + c.owner = owner } } @@ -119,10 +117,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { - if c.criteria.Expression == nil { + if c.Criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } - return c.exprSQL(c.criteria.Expression) + return c.exprSQL(c.Criteria.Expression) } func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { @@ -290,13 +288,13 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return nil, errors.New("playlist id not given") } filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} - if !c.ownerIsAdmin { - if c.ownerID == "" { + if !c.owner.IsAdmin { + if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) } else { filters = append(filters, squirrel.Or{ squirrel.Eq{"playlist.public": 1}, - squirrel.Eq{"playlist.owner_id": c.ownerID}, + squirrel.Eq{"playlist.owner_id": c.owner.ID}, }) } } @@ -404,7 +402,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { var joins smartPlaylistJoinType - _ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { + _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { for field := range criteria.Fields(expr) { joins |= fieldJoinType(field) } @@ -415,69 +413,27 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { joins := c.ExpressionJoins() - for _, sortField := range sortFields(c.criteria.Sort) { - joins |= fieldJoinType(sortField) + for _, name := range c.Criteria.SortFieldNames() { + joins |= fieldJoinType(name) } return joins } func (c smartPlaylistCriteria) OrderBy() string { - sortValue := c.criteria.Sort - if sortValue == "" { - sortValue = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.criteria.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order) - order = "" - } - - parts := strings.Split(sortValue, ",") - fields := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { + sortFields := c.Criteria.OrderByFields() + parts := make([]string, 0, len(sortFields)) + for _, sf := range sortFields { + mapped, ok := sortExpr(sf.Field) + if !ok { continue } dir := "asc" - if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { - if strings.HasPrefix(part, "-") { - dir = "desc" - } - part = strings.TrimSpace(part[1:]) + if sf.Desc { + dir = "desc" } - sortField := strings.ToLower(part) - mapped, ok := sortExpr(sortField) - if !ok { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - fields = append(fields, mapped+" "+dir) + parts = append(parts, mapped+" "+dir) } - return strings.Join(fields, ", ") -} - -func sortFields(sortValue string) []string { - if sortValue == "" { - sortValue = "title" - } - parts := strings.Split(sortValue, ",") - fields := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-")) - if part != "" { - fields = append(fields, part) - } - } - return fields + return strings.Join(parts, ", ") } func sortExpr(sortField string) (string, bool) { diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e06a4eccb..e02032d9a 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -3,6 +3,7 @@ package persistence import ( "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -11,7 +12,7 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) criteria.AddNumericTags([]string{"rate"}) }) @@ -54,6 +55,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), + Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), @@ -63,7 +65,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("allows public or same-owner playlist references for regular users", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, - withSmartPlaylistOwner("owner-id", false), + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), ).Where() Expect(err).ToNot(HaveOccurred()) @@ -76,7 +78,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("allows all playlist references for admins", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, - withSmartPlaylistOwner("admin-id", true), + withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), ).Where() Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 658030de5..086e73703 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -197,6 +197,11 @@ var _ = Describe("Smart Playlists", func() { Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) }) + + It("resolves recordingdate alias to the date column", func() { + results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) }) Describe("Logic operators", func() { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 11e76fa8b..9bbc41c5c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -11,7 +11,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" @@ -201,161 +200,6 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec Columns(r.tableName+".*", "user.user_name as owner_name") } -func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { - // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config - if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) { - return false - } - - // Never refresh other users' playlists - usr := loggedUser(r.ctx) - if pls.OwnerID != usr.ID { - log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) - return false - } - - log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) - start := time.Now() - - // Remove old tracks - del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) - _, err := r.executeSQL(del) - if err != nil { - log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Re-populate playlist based on Smart Playlist criteria - rules := *pls.Rules - rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin)) - - // If the playlist depends on other playlists, recursively refresh them first - childPlaylistIds := rules.ChildPlaylistIds() - for _, id := range childPlaylistIds { - childPls, err := r.Get(id) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) - continue - } - log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) - return false - } - r.refreshSmartPlaylist(childPls) - } - - orderBy := rulesSQL.OrderBy() - sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). - From("media_file").LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - - // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) - - // Only include media files from libraries the user has access to - sq = r.applyLibraryFilter(sq, "media_file") - - // Resolve percentage-based limit to an absolute number before applying criteria - if rules.IsPercentageLimit() { - // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rulesSQL.ExpressionJoins() - countSq := Select("count(*) as count").From("media_file"). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) - countSq = r.applyLibraryFilter(countSq, "media_file") - cond, err := rulesSQL.Where() - if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) - return false - } - countSq = countSq.Where(cond) - - var res struct{ Count int64 } - err = r.queryOne(countSq, &res) - if err != nil { - log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) - return false - } - resolvedLimit := rules.EffectiveLimit(res.Count) - log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) - rules.Limit = resolvedLimit - rules.LimitPercent = 0 - rulesSQL.criteria = rules - } - - // Apply the criteria rules - sq, err = r.addCriteria(sq, rulesSQL) - if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) - return false - } - insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) - _, err = r.executeSQL(insSql) - if err != nil { - log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update playlist stats - err = r.refreshCounters(pls) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update when the playlist was last refreshed (for cache purposes) - now := time.Now() - updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(updSql) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - pls.EvaluatedAt = &now - - log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) - - return true -} - -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - return sq -} - -func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { - cond, err := cSQL.Where() - if err != nil { - return sql, err - } - sql = sql.Where(cond) - if cSQL.criteria.Limit > 0 { - sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset)) - } - if order := cSQL.OrderBy(); order != "" { - sql = sql.OrderBy(order) - } - return sql, nil -} - func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 88cb5f697..cfabd0983 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,17 +1,11 @@ package persistence import ( - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -128,379 +122,6 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Context("Smart Playlists", func() { - var rules *criteria.Criteria - BeforeEach(func() { - rules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "love"}, - }, - } - }) - Context("valid rules", func() { - Specify("Put/Get", func() { - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - - savedPls, err := repo.Get(newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedPls.Rules).To(Equal(rules)) - }) - }) - - Context("invalid rules", func() { - It("fails to Put it in the DB", func() { - rules = &criteria.Criteria{ - // This is invalid because "contains" cannot have multiple fields - Expression: criteria.All{ - criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, - }, - } - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) - }) - }) - - Context("child smart playlists", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - When("refresh delay has expired", func() { - It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) - - // Getting parent with refresh should recursively refresh the nested playlist - pls, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.EvaluatedAt).ToNot(BeNil()) - Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - - // Nested playlist should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - }) - }) - - When("refresh delay has not expired", func() { - It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - childEvaluatedAt := time.Now().Add(-30 * time.Minute) - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - // Parent has no EvaluatedAt, so it WILL refresh, but the child should not - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - - // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - // Parent should have been refreshed (its EvaluatedAt was nil) - Expect(parent.EvaluatedAt).ToNot(BeNil()) - Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Nested playlist should NOT have been refreshed (still within delay window) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) - }) - }) - }) - }) - - Describe("Playlist Track Sorting", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - Expect(repo.Delete(testPlaylistID)).To(BeNil()) - testPlaylistID = "" - } - }) - - It("sorts tracks correctly by album (disc and track number)", func() { - By("creating a playlist with multi-disc album tracks in arbitrary order") - newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} - // Add tracks in intentionally scrambled order - newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(newPls.ID, false) - tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) - Expect(err).ToNot(HaveOccurred()) - - By("verifying tracks are sorted by disc number then track number") - Expect(tracks).To(HaveLen(4)) - // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 - Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 - Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 - Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 - Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 - }) - }) - - Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - }) - - It("matches tracks from starred albums using albumLoved", func() { - // albumRadioactivity (ID "103") is starred in test fixtures - // Songs in album 103: 1003, 1004, 1005, 1006 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) - }) - - It("matches tracks from starred artists using artistLoved", func() { - // artistBeatles (ID "3") is starred in test fixtures - // Songs with ArtistID "3": 1001, 1002, 3002 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) - }) - - It("matches tracks with combined album and artist criteria", func() { - // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) - // artistLoved=true → songs with artist 3 (1001, 1002) - // Using Any: union of both sets - rules := &criteria.Criteria{ - Expression: criteria.Any{ - criteria.Is{"albumLoved": true}, - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) - }) - - It("returns no tracks when no albums/artists match", func() { - // No album has rating 5 in fixtures - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumRating": 5}, - }, - } - newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - Expect(pls.Tracks).To(BeEmpty()) - }) - }) - - Describe("Smart Playlists with Tag Criteria", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var songWithGrouping, songWithoutGrouping model.MediaFile - - BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) - - // Register 'grouping' as a valid tag for smart playlists - criteria.AddTagNames([]string{"grouping"}) - - // Create a song with the grouping tag - songWithGrouping = model.MediaFile{ - ID: "test-grouping-1", - Title: "Song With Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "test/grouping/song1.mp3", - Tags: model.Tags{ - "grouping": []string{"My Crate"}, - }, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) - - // Create a song without the grouping tag - songWithoutGrouping = model.MediaFile{ - ID: "test-grouping-2", - Title: "Song Without Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "test/grouping/song2.mp3", - Tags: model.Tags{}, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) - }) - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test media files - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() - }) - - It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { - By("creating a smart playlist that checks if grouping tag has any value") - // This is the workaround for issue #4728: using 'contains' with empty string - // generates SQL: value LIKE '%%' which matches any non-empty string - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track with grouping tag is matched") - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) - }) - - It("excludes tracks with a tag value using 'notContains' with empty string", func() { - By("creating a smart playlist that checks if grouping tag is NOT set") - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.NotContains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying the track with grouping is NOT in the playlist") - for _, track := range pls.Tracks { - Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) - } - - By("verifying the track without grouping IS in the playlist") - var foundWithoutGrouping bool - for _, track := range pls.Tracks { - if track.MediaFileID == songWithoutGrouping.ID { - foundWithoutGrouping = true - break - } - } - Expect(foundWithoutGrouping).To(BeTrue()) - }) - }) - Describe("Track Deletion and Renumbering", func() { var testPlaylistID string @@ -573,136 +194,4 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) - - Describe("Smart Playlists Library Filtering", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var lib2ID int - var restrictedUserID string - var uniqueLibPath string - - BeforeEach(func() { - db := GetDBXBuilder() - - // Generate unique IDs for this test run - uniqueSuffix := time.Now().Format("20060102150405.000") - restrictedUserID = "restricted-user-" + uniqueSuffix - uniqueLibPath = "/music/lib2-" + uniqueSuffix - - // Create a second library with unique name and path to avoid conflicts with other tests - _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) - Expect(err).ToNot(HaveOccurred()) - err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) - Expect(err).ToNot(HaveOccurred()) - - // Create a restricted user with access only to library 1 - _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - - // Create test media files in each library - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, db) - - // Song in library 1 (accessible by restricted user) - songLib1 := model.MediaFile{ - ID: "lib1-song", - Title: "Song in Lib1", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "lib1/song.mp3", - LibraryID: 1, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib1)).To(Succeed()) - - // Song in library 2 (NOT accessible by restricted user) - songLib2 := model.MediaFile{ - ID: "lib2-song", - Title: "Song in Lib2", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "lib2/song.mp3", - LibraryID: lib2ID, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib2)).To(Succeed()) - }) - - AfterEach(func() { - db := GetDBXBuilder() - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test data - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() - _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() - _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() - _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) - }) - - It("should only include tracks from libraries the user has access to (issue #4738)", func() { - db := GetDBXBuilder() - ctx := log.NewContext(GinkgoT().Context()) - - // Create the smart playlist as the restricted user - restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} - ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo := NewPlaylistRepository(ctx, db) - - // Create a smart playlist that matches all songs - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Gt{"playCount": -1}, // Matches everything - }, - } - newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} - Expect(restrictedRepo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track from library 1 is in the playlist") - var foundLib1Song, foundLib2Song bool - for _, track := range pls.Tracks { - if track.MediaFileID == "lib1-song" { - foundLib1Song = true - } - if track.MediaFileID == "lib2-song" { - foundLib2Song = true - } - } - Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") - Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") - - By("verifying playlist_tracks table only contains the accessible track") - var playlistTracksCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) - Expect(err).ToNot(HaveOccurred()) - // Count should only include tracks visible to the user (lib1-song) - // The count may include other test songs from library 1, but NOT lib2-song - var lib2TrackCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) - Expect(err).ToNot(HaveOccurred()) - Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") - - By("verifying SongCount matches visible tracks") - Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") - }) - }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go new file mode 100644 index 000000000..54f316152 --- /dev/null +++ b/persistence/smart_playlist_repository.go @@ -0,0 +1,203 @@ +package persistence + +import ( + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated +// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist +// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists +// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and +// configured refresh delay. + +// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + usr := loggedUser(r.ctx) + if !r.shouldRefreshSmartPlaylist(pls, usr) { + return false + } + + log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + start := time.Now() + + del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) + if _, err := r.executeSQL(del); err != nil { + log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + + if !r.refreshChildPlaylists(pls, rulesSQL) { + return false + } + + if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil { + return false + } + + sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID) + sq, err := r.addCriteria(sq, rulesSQL) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) + if _, err = r.executeSQL(insSql); err != nil { + log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + if err = r.refreshCounters(pls); err != nil { + log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + now := time.Now() + updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) + if _, err = r.executeSQL(updSql); err != nil { + log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) + return false + } + pls.EvaluatedAt = &now + + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + return true +} + +// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated +// time, and ownership. +func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool { + if !pls.IsSmartPlaylist() { + return false + } + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + return false + } + if pls.OwnerID != usr.ID { + log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + return false + } + return true +} + +// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. +// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { + childPlaylistIds := rulesSQL.ChildPlaylistIds() + if len(childPlaylistIds) == 0 { + return true + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + if err != nil { + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + return false + } + + found := make(map[string]struct{}, len(childPlaylists)) + for i := range childPlaylists { + found[childPlaylists[i].ID] = struct{}{} + r.refreshSmartPlaylist(&childPlaylists[i]) + } + for _, id := range childPlaylistIds { + if _, ok := found[id]; !ok { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + } + } + return true +} + +// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit. +func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { + if !rulesSQL.IsPercentageLimit() { + return nil + } + + exprJoins := rulesSQL.ExpressionJoins() + countSq := Select("count(*) as count").From("media_file") + countSq = r.addMediaFileAnnotationJoin(countSq, userID) + countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.applyLibraryFilter(countSq, "media_file") + + cond, err := rulesSQL.Where() + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return err + } + countSq = countSq.Where(cond) + + var res struct{ Count int64 } + if err = r.queryOne(countSq, &res); err != nil { + log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + return err + } + + rulesSQL.ResolveLimit(res.Count) + log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) + return nil +} + +// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, +// including necessary joins for annotations and library filtering. +func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { + orderBy := rulesSQL.OrderBy() + sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + From("media_file") + sq = r.addMediaFileAnnotationJoin(sq, userID) + + requiredJoins := rulesSQL.RequiredJoins() + sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID) + sq = r.applyLibraryFilter(sq, "media_file") + return sq +} + +// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include +// user-specific annotations in the smart playlist criteria evaluation. +func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder { + return sq.LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", userID) +} + +// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on +// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } + return sq +} + +// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the +// smart playlist criteria. +func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { + cond, err := cSQL.Where() + if err != nil { + return sql, err + } + sql = sql.Where(cond) + if cSQL.Criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) + } + if order := cSQL.OrderBy(); order != "" { + sql = sql.OrderBy(order) + } + return sql, nil +} diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go new file mode 100644 index 000000000..207fe0c36 --- /dev/null +++ b/persistence/smart_playlist_repository_test.go @@ -0,0 +1,531 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("PlaylistRepository - Smart Playlists", func() { + var repo model.PlaylistRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + }) + + Context("Smart Playlists", func() { + var rules *criteria.Criteria + BeforeEach(func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "love"}, + }, + } + }) + Context("valid rules", func() { + Specify("Put/Get", func() { + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + savedPls, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedPls.Rules).To(Equal(rules)) + }) + }) + + Context("invalid rules", func() { + It("fails to Put it in the DB", func() { + rules = &criteria.Criteria{ + // This is invalid because "contains" cannot have multiple fields + Expression: criteria.All{ + criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, + }, + } + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) + }) + }) + + Context("child smart playlists", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + When("refresh delay has expired", func() { + It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + // Nested playlist has not been evaluated yet + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) + + // Getting parent with refresh should recursively refresh the nested playlist + pls, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.EvaluatedAt).ToNot(BeNil()) + Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Parent should have tracks from the nested playlist + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + + // Nested playlist should now have been refreshed (EvaluatedAt set) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + }) + }) + + When("refresh delay has not expired", func() { + It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + childEvaluatedAt := time.Now().Add(-30 * time.Minute) + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + // Parent has no EvaluatedAt, so it WILL refresh, but the child should not + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + // Getting parent with refresh should NOT recursively refresh the nested playlist + parent, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + // Parent should have been refreshed (its EvaluatedAt was nil) + Expect(parent.EvaluatedAt).ToNot(BeNil()) + Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Nested playlist should NOT have been refreshed (still within delay window) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) + }) + }) + }) + }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) + + Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + }) + + It("matches tracks from starred albums using albumLoved", func() { + // albumRadioactivity (ID "103") is starred in test fixtures + // Songs in album 103: 1003, 1004, 1005, 1006 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) + }) + + It("matches tracks from starred artists using artistLoved", func() { + // artistBeatles (ID "3") is starred in test fixtures + // Songs with ArtistID "3": 1001, 1002, 3002 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) + }) + + It("matches tracks with combined album and artist criteria", func() { + // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) + // artistLoved=true → songs with artist 3 (1001, 1002) + // Using Any: union of both sets + rules := &criteria.Criteria{ + Expression: criteria.Any{ + criteria.Is{"albumLoved": true}, + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) + }) + + It("returns no tracks when no albums/artists match", func() { + // No album has rating 5 in fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumRating": 5}, + }, + } + newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + Expect(pls.Tracks).To(BeEmpty()) + }) + }) + + Describe("Smart Playlists with Tag Criteria", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var songWithGrouping, songWithoutGrouping model.MediaFile + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + + // Register 'grouping' as a valid tag for smart playlists + criteria.AddTagNames([]string{"grouping"}) + + // Create a song with the grouping tag + songWithGrouping = model.MediaFile{ + ID: "test-grouping-1", + Title: "Song With Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song1.mp3", + Tags: model.Tags{ + "grouping": []string{"My Crate"}, + }, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + + // Create a song without the grouping tag + songWithoutGrouping = model.MediaFile{ + ID: "test-grouping-2", + Title: "Song Without Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song2.mp3", + Tags: model.Tags{}, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + }) + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test media files + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() + }) + + It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { + By("creating a smart playlist that checks if grouping tag has any value") + // This is the workaround for issue #4728: using 'contains' with empty string + // generates SQL: value LIKE '%%' which matches any non-empty string + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track with grouping tag is matched") + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) + }) + + It("excludes tracks with a tag value using 'notContains' with empty string", func() { + By("creating a smart playlist that checks if grouping tag is NOT set") + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.NotContains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the track with grouping is NOT in the playlist") + for _, track := range pls.Tracks { + Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) + } + + By("verifying the track without grouping IS in the playlist") + var foundWithoutGrouping bool + for _, track := range pls.Tracks { + if track.MediaFileID == songWithoutGrouping.ID { + foundWithoutGrouping = true + break + } + } + Expect(foundWithoutGrouping).To(BeTrue()) + }) + }) + + Describe("Smart Playlists Library Filtering", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var lib2ID int + var restrictedUserID string + var uniqueLibPath string + + BeforeEach(func() { + db := GetDBXBuilder() + + // Generate unique IDs for this test run + uniqueSuffix := time.Now().Format("20060102150405.000") + restrictedUserID = "restricted-user-" + uniqueSuffix + uniqueLibPath = "/music/lib2-" + uniqueSuffix + + // Create a second library with unique name and path to avoid conflicts with other tests + _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) + Expect(err).ToNot(HaveOccurred()) + err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) + Expect(err).ToNot(HaveOccurred()) + + // Create a restricted user with access only to library 1 + _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + + // Create test media files in each library + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, db) + + // Song in library 1 (accessible by restricted user) + songLib1 := model.MediaFile{ + ID: "lib1-song", + Title: "Song in Lib1", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib1/song.mp3", + LibraryID: 1, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib1)).To(Succeed()) + + // Song in library 2 (NOT accessible by restricted user) + songLib2 := model.MediaFile{ + ID: "lib2-song", + Title: "Song in Lib2", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib2/song.mp3", + LibraryID: lib2ID, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib2)).To(Succeed()) + }) + + AfterEach(func() { + db := GetDBXBuilder() + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test data + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() + _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() + _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() + _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) + }) + + It("should only include tracks from libraries the user has access to (issue #4738)", func() { + db := GetDBXBuilder() + ctx := log.NewContext(GinkgoT().Context()) + + // Create the smart playlist as the restricted user + restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} + ctx = request.WithUser(ctx, restrictedUser) + restrictedRepo := NewPlaylistRepository(ctx, db) + + // Create a smart playlist that matches all songs + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Gt{"playCount": -1}, // Matches everything + }, + } + newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} + Expect(restrictedRepo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track from library 1 is in the playlist") + var foundLib1Song, foundLib2Song bool + for _, track := range pls.Tracks { + if track.MediaFileID == "lib1-song" { + foundLib1Song = true + } + if track.MediaFileID == "lib2-song" { + foundLib2Song = true + } + } + Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") + Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") + + By("verifying playlist_tracks table only contains the accessible track") + var playlistTracksCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) + Expect(err).ToNot(HaveOccurred()) + // Count should only include tracks visible to the user (lib1-song) + // The count may include other test songs from library 1, but NOT lib2-song + var lib2TrackCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") + + By("verifying SongCount matches visible tracks") + Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") + }) + }) +}) From fd930eefd73df9a62c1c1b21370a2389c783fcee Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 26 Apr 2026 16:36:57 -0400 Subject: [PATCH 202/596] feat(plugins): add LibraryID to TrackInfo Add LibraryID field to TrackInfo so plugins with library filesystem access can determine which library a track belongs to. This lets plugins resolve the full filesystem path by combining the library's root path with the track's relative path. LibraryID is gated behind the same filesystem access permission check as Path. --- plugins/capabilities/scrobbler.go | 3 +++ plugins/scrobbler_adapter.go | 1 + plugins/scrobbler_adapter_test.go | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 34cf60015..ed8a4fb6c 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 4f7cd4661..02c2b2889 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -130,6 +130,7 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo MBZReleaseTrackID: mf.MbzReleaseTrackID, } if p.hasLibraryFilesystemAccess(mf.LibraryID) { + ti.LibraryID = int32(mf.LibraryID) ti.Path = mf.Path } return ti diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 0ee229022..c56d8a900 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -259,19 +259,25 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }, } - It("includes Path when the plugin has filesystem access to the track's library", func() { + It("includes LibraryID and Path when the plugin has filesystem access to the track's library", func() { p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac")) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(Equal(int32(1))) + Expect(ti.Path).To(Equal("/music/test.flac")) }) - It("omits Path when the plugin lacks filesystem permission", func() { + It("omits LibraryID and Path when the plugin lacks filesystem permission", func() { p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) }) - It("omits Path when the track's library is not in the allowed set", func() { + It("omits LibraryID and Path when the track's library is not in the allowed set", func() { p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) }) }) }) From a756cad1dc0a94ee7456b9a765672cbd4ddaa3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 26 Apr 2026 17:34:39 -0400 Subject: [PATCH 203/596] test: enable artwork tests on Windows (#5416) * fix(test): enable artwork tests on Windows by using OS-aware path assertions Replace hardcoded forward-slash path expectations with filepath.FromSlash() so assertions match OS-native separators on Windows. Removes all 8 SkipOnWindows("#TBD-path-sep-artwork") guards from artwork unit tests. * test: add comment explaining forward-slash paths in test fixtures --- core/artwork/artwork_internal_test.go | 19 +++++++------------ core/artwork/reader_artist_test.go | 7 ++----- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 12a7085e8..7a48fa620 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -41,6 +41,7 @@ var _ = Describe("Artwork", func() { MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, } + // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} @@ -80,12 +81,11 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/test.mp3"))) }) It("returns ErrUnavailable if embed path is not available", func() { ffmpeg.Error = errors.New("not available") @@ -104,7 +104,6 @@ var _ = Describe("Artwork", func() { }) }) It("returns external cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"front.png"}, @@ -113,7 +112,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/front.png"))) }) It("returns ErrUnavailable if external file is not available", func() { folderRepo.result = []model.Folder{} @@ -135,13 +134,12 @@ var _ = Describe("Artwork", func() { }) DescribeTable("CoverArtPriority", func(priority string, expected string) { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.CoverArtPriority = priority aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(path).To(Equal(filepath.FromSlash(expected))) }, Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), @@ -213,13 +211,12 @@ var _ = Describe("Artwork", func() { }) DescribeTable("ArtistArtPriority", func(priority string, expected string) { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.ArtistArtPriority = priority aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(path).To(Equal(filepath.FromSlash(expected))) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -251,22 +248,20 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.mp3"))) }) It("returns embed cover if successfully extracted by ffmpeg", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) Expect(err).ToNot(HaveOccurred()) r, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) data, _ := io.ReadAll(r) Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal("tests/fixtures/test.ogg")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.ogg"))) }) It("returns album cover if cannot read embed artwork", func() { // Force fromTag to fail diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 220c7554f..33dc6ed57 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -62,13 +61,12 @@ var _ = Describe("artistArtworkReader", func() { When("artist has only one album", func() { It("returns the parent folder", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) @@ -88,14 +86,13 @@ var _ = Describe("artistArtworkReader", func() { When("the album paths contain same prefix", func() { It("returns the common prefix", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), filepath.FromSlash("/music/artist/album2"), } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) From 5d1c1157b5bde16c2b0ff6017bfe4a20bdbb6e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 26 Apr 2026 18:16:14 -0400 Subject: [PATCH 204/596] refactor(artwork): migrate readers to storage.MusicFS and add e2e suite (#5379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(artwork): add e2e suite documenting album/disc resolution Adds core/artwork/e2e/ with a real-tempdir + scanner harness that exercises artwork resolution end-to-end. Covers album and disc kinds; pending (PIt) cases document two known bugs in reader_album.go for regression-guard flipping once they are fixed. * refactor(artwork): add libraryFS helper to resolve MusicFS for a library * test(artwork): tighten libraryFS test isolation and add scheme-error case * test(artwork): update libraryFS test description to match implementation * refactor(artwork): convert fromExternalFile to use fs.FS Add a temporary fromExternalFileAbs shim so existing absolute-path callers still compile; the shim is removed once all readers are migrated. * refactor(artwork): make fromExternalFileAbs a thin delegator Introduce a minimal osDirectFS adapter so the shim no longer duplicates the matching loop. Both will be removed in Task 9. * refactor(artwork): convert fromTag to taglib.OpenStream over fs.FS Add a temporary fromTagAbs shim so existing absolute-path callers still compile; removed in Task 9. Reuses the osDirectFS adapter from Task 2. * refactor(artwork): defer fs.File close until after taglib reads finish Mirror the lifetime pattern used by adapters/gotaglib/gotaglib.go: keep the underlying fs.File open until taglib.File is closed, and pass WithFilename so format detection doesn't rely on content sniffing. * docs(artwork): note ffmpeg's path-based API limitation * refactor(artwork): migrate album reader to MusicFS - Add libFS (storage.MusicFS) field to albumArtworkReader; resolved once at construction time via libraryFS() - Switch fromCoverArtPriority from abs-path shims to FS-based fromTag/fromExternalFile; only fromFFmpegTag retains absolute path - Build imgFiles as library-relative forward-slash paths in loadAlbumFoldersPaths using path.Join(f.Path, f.Name, img) - Guard embedAbs so that an empty EmbedArtPath never produces a non-empty absolute path (prevents accidental ffmpeg invocation) - Register testfile:// storage scheme in artwork test suite to provide an os.DirFS-backed MusicFS without requiring the taglib extractor - Update test assertions from filepath.FromSlash(abs) to bare forward-slash relative strings * fix(artwork): use path package in compareImageFiles for forward-slash relative paths * refactor(artwork): migrate disc reader to MusicFS Replace os.Open absolute-path access with libFS.Open on library-relative forward-slash paths. Rename discFolders→discFoldersRel, split firstTrackPath into firstTrackRelPath (for fromTag) and firstTrackAbsPath (for fromFFmpegTag), and switch path.Dir/Base/Ext for forward-slash safety. * refactor(artwork): build discFoldersRel directly and guard empty first track * refactor(artwork): migrate mediafile reader to MusicFS * refactor(artwork): migrate artist album-art lookup to MusicFS * refactor(artwork): remove temporary path-based shims All readers now use the FS-based fromTag and fromExternalFile directly, so the absolute-path adapters and the osDirectFS helper that backed them can go away. * test(artwork): rewrite e2e suite to use storagetest.FakeFS Switches from real-tempdir + local storage to FakeFS via the storage registry. Adds a proper multi-disc scenario using the disc tag, which previously required curated MP3 fixtures we did not have. * test(artwork): use maps.Copy in trackFile tag merge Lint cleanup: replace the manual map-copy loop flagged by mapsloop. * test(artwork): reuse tests.MockFFmpeg in e2e harness Replace the hand-rolled noopFFmpeg stub with tests.NewMockFFmpeg, which already satisfies the full ffmpeg.FFmpeg interface and won't drift when new methods are added. Also tie imageBytes to imageFile so they cannot silently disagree on the on-disk encoding. * test(artwork): add e2e scenarios from artwork documentation Covers the behaviors documented at https://www.navidrome.org/docs/usage/library/artwork/: - Album: folder.*/front.* fallbacks and priority order with cover.*. - Disc: cd*.* match, cover.* inside disc folder, DiscArtPriority="" skip path, the documented multi-disc layout, and the discsubtitle keyword. - MediaFile: disc-level fallback for multi-disc tracks and album-level fallback for single-disc tracks (doc section "MediaFiles" items 2-3). - Artist: album/artist.* lookup via libFS (passes). The artist-folder branch is XIt-marked because fromArtistFolder still calls os.DirFS directly on an absolute path and can't read from a FakeFS-backed library — migrating that to storage.MusicFS is a follow-up. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(artwork): scope artist folder traversal to library root Route fromArtistFolder reads through storage.MusicFS and bound the parent-directory walk at the library root. This keeps artwork resolution scoped to the configured library and unblocks FakeFS-backed e2e scenarios that depend on the artist folder. Also consolidate the libraryFS + core.AbsolutePath pairing (used by three readers) into a single libraryFSAndRoot helper. * test(artwork): add ASCII file-tree diagrams to e2e scenarios Each It/PIt block now shows the on-disk layout it exercises, with arrows indicating which file wins (or should win, for the known-bug PIt cases). Makes scenarios readable at a glance without having to parse the MapFS map. * test(artwork): add e2e tests for playlist and radio artwork resolution Signed-off-by: Deluan <deluan@navidrome.org> * test(artwork): enhance e2e tests with real MP3 fixtures for embedded artwork Signed-off-by: Deluan <deluan@navidrome.org> * test(ffmpeg): add support for animated WebP encoder detection and fallback handling Signed-off-by: Deluan <deluan@navidrome.org> * test(artwork): cover additional edge cases in e2e suite Add high-value scenarios uncovered by the existing specs: - Album: three-way basename tie (unsuffixed wins), unknown pattern in CoverArtPriority is skipped, embedded-first with no embedded art falls through. - Disc: discsubtitle with no matching image falls through. - Artist: ArtistArtPriority can reach images via album/<pattern>. - Playlist: generates a 2x2 tiled cover from album art when the playlist has no uploaded/sidecar/external image. New helper realPNG() produces real taglib/image-decodable bytes so the tiled-cover test can exercise the generator's decode + compose path. * test(artwork): refactor image upload logic in e2e tests for consistency Signed-off-by: Deluan <deluan@navidrome.org> * test(ffmpeg): simplify animated WebP encoder check by removing context parameter Signed-off-by: Deluan <deluan@navidrome.org> * fix(artwork): normalize rel path for fs.Glob on Windows filepath.Rel returns backslash-separated paths on Windows, but fs.Glob and path.Join require forward slashes. Convert with filepath.ToSlash after computing the relative path and use path.Dir for the parent walk so the artist-folder lookup works cross-platform. * fix(ffmpeg): retry animated WebP probe on transient failure The probe previously used the caller's request context inside sync.Once, so a single cancelled first request would permanently disable animated WebP for the rest of the process. Switch to a mutex + probed flag, use a fresh background context with its own timeout, and only cache the result when the probe actually succeeds. * test(ffmpeg): reset ffOnce so ConvertAnimatedImage test is order-independent The ConvertAnimatedImage stand-in test sets ffmpegPath directly but does not reset ffOnce. If ffmpegCmd() has not been called earlier in the test process, the next call inside hasAnimatedWebPEncoder runs ffOnce.Do and re-resolves the real ffmpeg binary, overwriting the stand-in and breaking the test. Reset ffOnce and conf.Server.FFmpegPath alongside the other globals to pin resolution to the stand-in. * test(artwork): unblock Windows CI — forward-slash fs paths and suite-level DB lifetime The internal artwork test planted a Windows absolute path (backslashes) into Folder.Path and then fed it through libFS.Open, which fs.ValidPath rejects. Rooting the testfile library at the temp dir directly and using filepath.ToSlash keeps the path model library-relative and forward-slash, matching production. The e2e suite opened a per-spec DB in a per-spec TempDir, but the go-sqlite3 singleton kept the file open across specs. Ginkgo's per-spec TempDir cleanup then tried to unlink a file still held by that handle — fine on POSIX, fails on Windows. Moving the DB to a suite-level tempdir and closing it in AfterSuite avoids the race. * test(artwork): keep Windows drive letters intact in testfile library URLs url.Parse on `testfile://C:/path` reads `C` as the host and the path loses the drive letter, so Windows libFS lookups go to `/path` and fail. testFileLibPath now prepends a `/` when the OS path has no leading slash, and the testfile constructor strips that extra slash back off before handing the path to os.Stat / os.DirFS. * refactor(artwork): consolidate libFS + root into libraryView helper Collapses the per-reader libFS/libPath/rootFolder/firstTrackAbsPath fields into a single libraryView{FS, absRoot} with an Abs(rel) method. Also folds the two library lookups (ds.Library.Get + core.AbsolutePath) into one, and uses mf.Path directly instead of stripping libRoot off an absolute path. * refactor(ffmpeg): replace hasAnimatedWebPEncoder with encoderProbe for state management Signed-off-by: Deluan <deluan@navidrome.org> * fix: escape artist folder names in artwork glob Escape glob metacharacters in the library-relative artist folder path before composing the fs.Glob pattern for artist image lookup. This preserves literal folder names such as Artist [Live] while keeping the configured filename pattern behavior unchanged, and adds a regression test for bracketed artist folders. Signed-off-by: Deluan <deluan@navidrome.org> * fix(artwork): correct test path assertions after MusicFS migration Source functions (fromTag, fromExternalFile) now return forward-slash fs.FS-relative paths, so test assertions should compare against plain forward-slash strings, not filepath.FromSlash(). The artistArtPriority test needs filepath.FromSlash() on the suffix because findImageInFolder returns OS-native absolute paths via filepath.Join. * fix(artwork): normalize path separators in artistArtPriority assertion The two table entries exercise different code paths: entry 1 goes through fromArtistFolder (returns OS-native paths via filepath.Join), while entry 2 goes through fromExternalFile (returns forward-slash fs.FS paths). Using filepath.FromSlash on the expected value only works for entry 1. Normalize the actual path to forward slashes with filepath.ToSlash so a single HaveSuffix assertion works for both code paths on all platforms. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/artwork/artwork_internal_test.go | 33 +- core/artwork/artwork_suite_test.go | 54 ++++ core/artwork/e2e/album_test.go | 354 +++++++++++++++++++++ core/artwork/e2e/artist_test.go | 167 ++++++++++ core/artwork/e2e/disc_test.go | 276 ++++++++++++++++ core/artwork/e2e/helpers_test.go | 184 +++++++++++ core/artwork/e2e/mediafile_test.go | 110 +++++++ core/artwork/e2e/playlist_test.go | 158 +++++++++ core/artwork/e2e/radio_test.go | 42 +++ core/artwork/e2e/suite_test.go | 106 ++++++ core/artwork/e2e/testdata/embedded_art.mp3 | Bin 0 -> 64223 bytes core/artwork/library_fs.go | 44 +++ core/artwork/library_fs_test.go | 45 +++ core/artwork/reader_album.go | 50 +-- core/artwork/reader_album_test.go | 35 +- core/artwork/reader_artist.go | 90 ++++-- core/artwork/reader_artist_test.go | 49 ++- core/artwork/reader_disc.go | 55 ++-- core/artwork/reader_disc_test.go | 137 ++++---- core/artwork/reader_mediafile.go | 11 +- core/artwork/sources.go | 48 ++- core/artwork/sources_internal_test.go | 92 ++++++ core/ffmpeg/ffmpeg.go | 66 ++++ core/ffmpeg/ffmpeg_test.go | 55 ++++ 24 files changed, 2084 insertions(+), 177 deletions(-) create mode 100644 core/artwork/e2e/album_test.go create mode 100644 core/artwork/e2e/artist_test.go create mode 100644 core/artwork/e2e/disc_test.go create mode 100644 core/artwork/e2e/helpers_test.go create mode 100644 core/artwork/e2e/mediafile_test.go create mode 100644 core/artwork/e2e/playlist_test.go create mode 100644 core/artwork/e2e/radio_test.go create mode 100644 core/artwork/e2e/suite_test.go create mode 100644 core/artwork/e2e/testdata/embedded_art.mp3 create mode 100644 core/artwork/library_fs.go create mode 100644 core/artwork/library_fs_test.go create mode 100644 core/artwork/sources_internal_test.go diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 7a48fa620..c95371959 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -37,9 +37,13 @@ var _ = Describe("Artwork", func() { conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" folderRepo = &fakeFolderRepo{} + libRepo := &tests.MockLibraryRepo{} + repoRoot, _ := os.Getwd() + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ds = &tests.MockDataStore{ MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, + MockedLibrary: libRepo, } // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} @@ -85,7 +89,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/test.mp3"))) + Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) }) It("returns ErrUnavailable if embed path is not available", func() { ffmpeg.Error = errors.New("not available") @@ -112,7 +116,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/front.png"))) + Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) }) It("returns ErrUnavailable if external file is not available", func() { folderRepo.result = []model.Folder{} @@ -139,7 +143,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash(expected))) + Expect(path).To(Equal(expected)) }, Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), @@ -195,9 +199,12 @@ var _ = Describe("Artwork", func() { Describe("artistArtworkReader", func() { Context("Multiple covers", func() { BeforeEach(func() { + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, }} ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ arMultipleCovers, @@ -216,7 +223,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash(expected))) + Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -252,7 +259,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.mp3"))) + Expect(path).To(Equal("tests/fixtures/test.mp3")) }) It("returns embed cover if successfully extracted by ffmpeg", func() { aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) @@ -261,7 +268,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) data, _ := io.ReadAll(r) Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.ogg"))) + Expect(path).To(Equal("tests/fixtures/test.ogg")) }) It("returns album cover if cannot read embed artwork", func() { // Force fromTag to fail @@ -460,7 +467,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ alCover, }) @@ -548,7 +558,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) conf.Server.CoverArtPriority = "cover.png" diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index dfd66e5e5..d42d7f3e4 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,9 +1,17 @@ package artwork import ( + "io/fs" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" "testing" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Artwork Suite") } + +// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. +// ReadTags is not used by albumArtworkReader, so it is left as a stub. +type osDirFS struct{ fs.FS } + +func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } + +// testFileScheme is the URL scheme registered to expose a tempdir as a +// storage.MusicFS for artwork integration tests. +const testFileScheme = "testfile" + +// testFileLibPath builds a `testfile://` library URL for the given absolute +// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading +// slash after ToSlash, which makes url.Parse treat the drive letter as a +// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the +// registered constructor below strips that leading slash back off. +func testFileLibPath(absPath string) string { + p := filepath.ToSlash(absPath) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return testFileScheme + "://" + p +} + +func init() { + // Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by + // integration tests that need real files but not the taglib extractor. + storage.Register(testFileScheme, func(u url.URL) storage.Storage { + root := u.Path + // Undo the leading slash added by testFileLibPath on Windows so that + // os.Stat / os.DirFS receive a native path like `C:\foo`. + if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' { + root = root[1:] + } + return &osDirStorage{root: filepath.FromSlash(root)} + }) +} + +type osDirStorage struct{ root string } + +func (s *osDirStorage) FS() (storage.MusicFS, error) { + if _, err := os.Stat(s.root); err != nil { + return nil, err + } + return osDirFS{os.DirFS(s.root)}, nil +} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go new file mode 100644 index 000000000..3d5523afd --- /dev/null +++ b/core/artwork/e2e/album_test.go @@ -0,0 +1,354 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + +var _ = Describe("Album artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("an album has a single folder with cover.jpg at the album root", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← matched by cover.* + It("returns the album-root cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder + // files first. Flip from PIt to It once it prefers shorter/parent paths. + When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← currently wins (bug) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg + // └── cover.jpg ← should win (album-root fallback) + PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/cover.jpg": imageFile("disc1"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(al.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two disc subfolders as one multi-disc album") + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; + // the lexicographic full-path tiebreaker in compareImageFiles ranks + // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". + // Flip from PIt to It once compareImageFiles prefers shorter/parent paths. + When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← currently wins (bug) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── folder.jpg ← should win (album-root fallback) + PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/folder.jpg": imageFile("album-root"), + "Artist/Album/CD1/folder.jpg": imageFile("disc1"), + "Artist/Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder + // lookup whenever an album lives entirely under a single subfolder, so an + // album-root cover is never considered. Flip from PIt to It once the guard + // accepts single-folder albums whose parent isn't already in the folder set. + When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ └── 01 - Track.mp3 + // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) + PIt("uses the parent-folder cover (currently ignored — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded") + // └── cover.jpg + It("returns the embedded image", func() { + conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + "Artist/Album/cover.jpg": imageFile("external"), + }) + scan() + // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("CoverArtPriority lists external first but no external file is present", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded") + It("falls through to embedded artwork", func() { + conf.Server.CoverArtPriority = "external, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("the only cover file uses uppercase extension and a different case in its name", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── Cover.JPG ← matched case-insensitively by cover.* + It("matches case-insensitively against cover.*", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/Cover.JPG": imageFile("case-insensitive"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) + }) + }) + + When("two cover files have basenames that tie under the natural-sort tiebreaker", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // └── cover.1.jpg + It("prefers the file without a numeric suffix", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("secondary"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("the album has no cover and CoverArtPriority lists only file patterns", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) + It("returns ErrUnavailable", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) + Expect(err).To(HaveOccurred()) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#albums + // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external". + When("only folder.jpg is present (cover.* and front.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── folder.jpg ← matched by folder.* + It("falls through to folder.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("only front.jpg is present (cover.* and folder.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── front.jpg ← matched by front.* + It("falls through to front.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) + }) + }) + + When("cover.*, folder.*, and front.* all exist in the same folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (cover.* is first in priority) + // ├── folder.jpg + // └── front.jpg + It("prefers cover.* (first in CoverArtPriority)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("only folder.* and front.* exist (priority order check)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── folder.jpg ← wins (folder.* comes before front.*) + // └── front.jpg + It("prefers folder.* over front.*", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("three cover files tie by basename and differ only by numeric suffix", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // ├── cover.1.jpg + // └── cover.2.jpg + It("selects the unsuffixed file first regardless of numeric-suffix order", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.2.jpg": imageFile("second"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("first"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("CoverArtPriority contains an unknown pattern before a matching one", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← wins (unknown "bogus.*" is skipped) + It("skips the unknown pattern and falls through to the matching one", func() { + conf.Server.CoverArtPriority = "bogus.*, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("embedded is first in CoverArtPriority but the track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (no embedded picture) + // └── cover.jpg ← wins (embedded skipped, falls through) + It("falls through to the next priority entry", func() { + conf.Server.CoverArtPriority = "embedded, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go new file mode 100644 index 000000000..d959b1d60 --- /dev/null +++ b/core/artwork/e2e/artist_test.go @@ -0,0 +1,167 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#artists +// Default ArtistArtPriority is "artist.*, album/artist.*, external". +var _ = Describe("Artist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the artist folder contains an artist.jpg", func() { + // Artist/ + // ├── artist.jpg ← matched by artist.* + // └── Album/ + // └── 01 - Track.mp3 + It("returns the artist.* image from the artist folder", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("artist.* only exists inside an album folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("falls through to album/artist.* and returns that image", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("both the artist folder and an album folder have an artist.* image", func() { + // Artist/ + // ├── artist.jpg ← wins (artist.* before album/artist.*) + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg + It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("an artist has an uploaded image and a matching artist.* file", func() { + // <DataFolder>/ + // └── artwork/ + // └── artist/ + // └── <id>_upload.jpg ← wins (uploaded image beats the priority chain) + // Library: + // Artist/ + // ├── artist.jpg (ignored — uploaded image comes first) + // └── Album/ + // └── 01 - Track.mp3 + It("prefers the uploaded image over any priority-chain match", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + ar := soleArtist() + + uploaded := ar.ID + "_upload.jpg" + writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) + ar.UploadedImage = uploaded + Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) + + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) + }) + }) + + When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("resolves the pattern against the artist's album image files", func() { + conf.Server.ArtistArtPriority = "album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { + // <ArtistImageFolder>/ + // └── Artist.jpg ← matched by artist name (image-folder source) + // Library: + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no artist.* present in library) + It("returns the image from the configured artist image folder", func() { + imgFolder := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) + conf.Server.ArtistImageFolder = imgFolder + conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" + + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) + }) + }) +}) + +func soleArtist() model.Artist { + GinkgoHelper() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"artist.name": "Artist"}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(artists) == 0 { + Fail("sole artist not found") + return model.Artist{} + } + return artists[0] +} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go new file mode 100644 index 000000000..7569cbc32 --- /dev/null +++ b/core/artwork/e2e/disc_test.go @@ -0,0 +1,276 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Disc artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the album is single-disc with a disc1.jpg in the only folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── disc1.jpg ← matched by disc*.* + It("returns the disc1.jpg image (matched as disc*.*)", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc1-image"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) + }) + }) + + When("the album has no per-disc image and no album cover", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) + It("returns ErrUnavailable for the disc lookup", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + _, err := readArtworkOrErr(discID) + Expect(err).To(HaveOccurred()) + }) + }) + + When("the album has no per-disc image but has an album cover", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← album-level fallback (no disc art present) + It("falls back to the album cover", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("multiple disc images exist in the same folder (disc1 vs disc10)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── disc1.jpg ← matches request for disc 1 + // └── disc10.jpg + It("matches the requested disc number, not a higher-numbered one", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc-one"), + "Artist/Album/disc10.jpg": imageFile("disc-ten"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) + }) + }) + + When("a multi-disc album has per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg ← matches request for disc 1 + // └── CD2/ + // ├── 01 - Track.mp3 + // └── disc2.jpg ← matches request for disc 2 + It("returns the requested disc's image", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art + // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded". + When("a disc subfolder has a cd2.png image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cd2.png ← matched by cd*.* for disc 2 + It("matches via the cd*.* pattern", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← matched by cover.* inside disc folder + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("falls through to cover.* inside the disc folder", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) + }) + }) + + When("DiscArtPriority is the empty string", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg (ignored — DiscArtPriority is empty) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cd2.png (ignored — DiscArtPriority is empty) + // └── cover.jpg ← used for every disc (album-level fallback) + It("skips every disc-level source and returns the album cover", func() { + conf.Server.DiscArtPriority = "" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + for _, n := range []int{1, 2} { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), + "disc %d should use the album cover when DiscArtPriority is empty", n) + } + }) + }) + + When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ ├── disc1.jpg ← matched by disc*.* for disc 1 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // ├── disc2/ + // │ ├── cd2.png ← matched by cd*.* for disc 2 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // └── cover.jpg (album-level fallback, unused here) + It("matches the per-disc image for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), + "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), + "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), + "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/disc2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) + Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword + It("selects the subtitle-named image", func() { + conf.Server.DiscArtPriority = "discsubtitle" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) + }) + }) + + When("discsubtitle is set but no image filename matches the subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── cover.jpg ← wins (discsubtitle has no match, falls through) + It("falls through to the next priority entry", func() { + conf.Server.DiscArtPriority = "discsubtitle, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go new file mode 100644 index 000000000..e3abca097 --- /dev/null +++ b/core/artwork/e2e/helpers_test.go @@ -0,0 +1,184 @@ +package artworke2e_test + +import ( + "bytes" + "context" + _ "embed" + "errors" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "net/url" + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that +// contains a valid MP3 stream with an embedded picture. Used in the +// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't +// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the +// scanner still populates EmbedArtPath via the JSON-tagged track, and the +// artwork reader gets real bytes when it calls libFS.Open. +// +//go:embed testdata/embedded_art.mp3 +var realMP3WithEmbeddedArt []byte + +// embeddedArtBytes is the exact image payload that the artwork reader will +// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can +// assert byte-for-byte equality — if this ever differs it means the reader +// pulled from a different source. +var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative +// path so libFS.Open returns an MP3 stream taglib can parse. +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} +} + +// placeholderBytes returns the bundled album-placeholder image bytes — the +// same stream the artwork reader emits when every source falls through. +func placeholderBytes() []byte { + GinkgoHelper() + r, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with +// the given bytes, matching the on-disk layout expected by +// model.UploadedImagePath. +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dir := filepath.Dir(model.UploadedImagePath(entity, filename)) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) +} + +func newNoopFFmpeg() *tests.MockFFmpeg { + ff := tests.NewMockFFmpeg("") + ff.Error = errors.New("noop") + return ff +} + +// trackFile builds a FakeFS MP3 entry with optional tag overrides. +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// imageFile builds a label-keyed image entry. The bytes are deterministic +// per-label so tests can assert which file won. +func imageFile(label string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("image:" + label)} +} + +// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by +// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). +func realPNG(label string) *fstest.MapFile { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + // Derive a deterministic color per label. + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +// imageBytes returns the bytes that imageFile(label) writes. +func imageBytes(label string) []byte { return imageFile(label).Data } + +// setLayout populates fakeFS with the given map. Call after setupHarness. +// All paths must be forward-slash and relative (no leading "/"). +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func readArtwork(artID model.ArtworkID) []byte { + GinkgoHelper() + r, _, err := aw.Get(ctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return b +} + +func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { + r, _, err := aw.Get(ctx, artID, 0, false) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// noopProvider implements external.Provider with not-found returns so the +// "external" priority entry never produces a result. +type noopProvider struct{} + +func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go new file mode 100644 index 000000000..1f43a3827 --- /dev/null +++ b/core/artwork/e2e/mediafile_test.go @@ -0,0 +1,110 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles +// Navidrome resolves mediafile artwork in this order: +// 1. Embedded image from the mediafile itself +// 2. For multi-disc albums, disc-level artwork +// 3. Album cover art +// +// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) +// is covered by the existing embedded-art album tests (which currently +// Skip under FakeFS). The tests below cover (2) and (3): the fallback +// chain for tracks without embedded art. +var _ = Describe("MediaFile artwork fallback", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a multi-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 ← track requested + // │ └── disc2.jpg ← wins (disc-level before album-level) + // └── cover.jpg + It("falls back to the disc-level artwork (not the album cover)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) + }) + }) + + When("a single-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (album-level fallback, no disc subfolder) + It("falls back to the album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + mf := mediafileOn("Artist/Album/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ └── 01 - Track.mp3 + // ├── CD2/ + // │ └── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (no disc image → album-level fallback) + It("falls through from disc to album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) +}) + +func mediafileOn(relPath string) model.MediaFile { + GinkgoHelper() + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Like{"media_file.path": relPath}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(mfs) == 0 { + Fail("mediafile not found: " + relPath) + return model.MediaFile{} + } + return mfs[0] +} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go new file mode 100644 index 000000000..d28efca8e --- /dev/null +++ b/core/artwork/e2e/playlist_test.go @@ -0,0 +1,158 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Playlist artwork resolves in this priority order: +// 1. Uploaded image (<DataFolder>/artwork/playlist/<file>) +// 2. Sidecar image next to the .m3u file (same basename, any image ext) +// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) +// 4. Generated 2x2 tiled cover from the playlist's albums +// 5. Album placeholder image +// +// The library FS is FakeFS, but uploaded/sidecar/local-external images are +// real files on disk — the reader reads them via os.Open, so the tests +// place them in a real tempdir under DataFolder. +var _ = Describe("Playlist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a playlist has an uploaded image", func() { + // <DataFolder>/ + // └── artwork/ + // └── playlist/ + // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) + + pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) + }) + }) + + When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() { + // <tempdir>/ + // ├── MyList.m3u + // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive) + It("returns the sidecar image", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) + }) + }) + + When("a playlist's sidecar uses a different extension case", func() { + // <tempdir>/ + // ├── MyList.m3u + // └── MyList.PNG ← matched case-insensitively + It("matches case-insensitively", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) + }) + }) + + When("a playlist has an ExternalImageURL pointing to a local file", func() { + // <tempdir>/ + // └── cover.jpg ← absolute path stored in ExternalImageURL + It("returns the local file regardless of EnableM3UExternalAlbumArt", func() { + conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) + }) + }) + + When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { + // (no local files — http source is gated off, reader falls through to placeholder) + It("skips the URL and falls through to the bundled placeholder", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no images and no tracks", func() { + // (reader falls all the way through to the bundled album placeholder) + It("returns the album placeholder", func() { + pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { + // Library: + // Artist/ + // ├── AlbumA/ + // │ ├── 01 - Track.mp3 + // │ └── cover.png (real PNG — wins as tile 1 source) + // └── AlbumB/ + // ├── 01 - Track.mp3 + // └── cover.png (real PNG — wins as tile 2 source) + // Playlist "pl-7" references tracks from both albums, so the reader + // generates a 2x2 tiled cover from 2 distinct album art tiles (the + // tiled generator mirrors when it has fewer than 4 unique tiles). + It("generates a tiled cover from album art", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), + "Artist/AlbumA/cover.png": realPNG("albumA"), + "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), + "Artist/AlbumB/cover.png": realPNG("albumB"), + }) + scan() + + // Pull the scanned mediafile IDs so we can attach them to the playlist. + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(2)) + + pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} + pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + + data := readArtwork(pl.CoverArtID()) + // The tiled cover is a PNG-encoded 600x600 image (tileSize const). + // Exact bytes vary (random album order), so assert format + non-trivial size. + Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) + Expect(len(data)).To(BeNumerically(">", 1000)) + }) + }) +}) + +func putPlaylist(pl model.Playlist) model.Playlist { + GinkgoHelper() + if pl.OwnerID == "" { + pl.OwnerID = "admin-1" + } + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + return pl +} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go new file mode 100644 index 000000000..73ee5f377 --- /dev/null +++ b/core/artwork/e2e/radio_test.go @@ -0,0 +1,42 @@ +package artworke2e_test + +import ( + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a radio has an uploaded image", func() { + // <DataFolder>/ + // └── artwork/ + // └── radio/ + // └── rd-1_logo.jpg ← matched by UploadedImagePath() + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) + + rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) + }) + }) + + When("a radio has no uploaded image", func() { + // (no files on disk — reader has no sources to fall back to) + It("returns ErrUnavailable", func() { + rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + _, err := readArtworkOrErr(artID) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go new file mode 100644 index 000000000..9ce0edb8b --- /dev/null +++ b/core/artwork/e2e/suite_test.go @@ -0,0 +1,106 @@ +package artworke2e_test + +import ( + "context" + "path/filepath" + "testing" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork E2E Suite") +} + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +var ( + ctx context.Context + ds *tests.MockDataStore + aw artwork.Artwork + fakeFS *storagetest.FakeFS +) + +// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps +// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup +// can't unlink a file with a live handle on Windows. A suite-level tempdir +// combined with an AfterSuite close avoids the lock conflict. +var suiteDBTempDir string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() +}) + +var _ = AfterSuite(func() { + db.Close(GinkgoT().Context()) +}) + +func setupHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + // Reuse the suite-level DB path so the singleton connection keeps working + // across specs (see suiteDBTempDir comment). + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = tempDir + conf.Server.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call + conf.Server.EnableExternalServices = false + + db.Db().SetMaxOpenConns(1) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + db.Init(ctx) + DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) +} + +func scan() { + GinkgoHelper() + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..18cb906749e047afda734b62c1ffcdd89ca72ead GIT binary patch literal 64223 zcmd421yodD7dUzeNdb`(kWvJc8fJzW1_h)+kOo0=3ZxrFK#&xXE)kRl2?-HN!JtH1 zq`SL2{}+G$zIyLl>wD|1_13$?y|eGW`|RHPoS8W&%fW~N03Z~l(^1xef)EJ+5NX@n zSpYg3@*?0p8F(-4<YMXU0_bSTs{BO(K!rshfR41P40um^Mqz_-wFQT$YlDb$;3*;u z1$5-JWI^ye_#6rmfeS)J1)(B<j;y*Gh&_{|WskK6@9%1W_e9_y_)tdUE_efSAQFMX z&UAo-w`UTZ(0~&L(2>{I0TJXN!UoNW25dl-hCB>JQGzI}y&W0=FCbD*T?<5>i55Yj z5P~8izeUSwss6!2Ay9~*2viXETcI<VRKFS6o^i$6+y3Tz<_~HR{o6XuzrCQXerDAf z9xcELlK!EWRlj@2<^p))?JO*HT<tUzr8NKu04l9^HsT^U;tY*<<`jVn!Gyq56wuMv z*Z=cOPFYJ{RtMA@XNd++P=mjoF@WRLwUiYN<fXL$&>As82vQI#2K*7o1U?1@0<!kD zws<=i5sE(m=>NaQpAp!&J6U4E7&_ZKxd5QrHh;P9F9LRuKucajRoXyNT3O8$jH>Qk zQw@1(6#ycH5`q3h$^W3!(UO)``I`<dD)JA3od3@6FS;|<fS`yFT#OSZ^S5eO|DEzL z$-gQ2LH~$|iu@-2V<<OB3>vB|2XhnQ{A1+*jn&!)s%I(khnkQ8(0O3#%xUQi;s8M2 z&ceph`2pu2BJ#RF03XQv(W6KILG5bi?10Bwnp@&={~V#K{hL3SUId^vrzZe#dSVEM z83Ks^z6$-m8Us4O1Hck+1~`9TDS&^{Kmj2@6r?f+<N-Ut0<if_@c>ls5AXXRZ%~{6 zcf4Ie-p+snfCsQ3e{=A}12|B|pR6zgEw*)aw!~tb(3W<OO>He7;hjt^aeyey93zT` z;slWp5riNdio^?|MG-JT97;q?1ceqghl!#A1RN^@!y`lm%^^6nARLWB38G;Th#*n~ zj~9huATT5X{kJ6ae@Mbv{gwra!a)!)a}gLu5Gg8#5`^Q;v4Us}(p(UWfZ!20j0h46 zM*=Ve41qvGu!1l#QFBlquxQ0lSg;7p%|%6U2n-f3iuiA$obZ2(!Xj}XSCptAN)!ec zgkzB)7bF&xhetz1ATT%riZuts@EANE<SK}Th+zfcNEkv8g@S_mz>s1{2nLVE<8XhA z!u*F-U?Xs_cg8z`W?{~35*33BnnRHw8yF5FC}s`^dks_!C5D5+aBw^nKtbUU7zzy) zgrKmXn{iN-APNIF7ZinHv7&G|9EpSd!z}E7$U-|gq3taG3Ktv!5k-sO;DTZ}5mC@R zXqX@d2Sx&gg+lOfs5uxB1OS0R%|U;O2_li0GarJ6i$THYB1F(AET|w>1pbdI|4(5! zOD8<m#r}70n4{n@5iu}ANEi+eItT?9#NgpbK`}8b0s%_ELCoO*28+iaMZvTX5fg<8 z!Z9$MAO?m)31Xl~a|jk~j)jS#{`TU33Ii+3(hmQZW#$MN9Al2a3E~h4Fvp;xI8crl z8f<-N91I0RL6M>;019f4fTQt(I8kthfy414f+&axS`Y(fD-MBzV$gW}e-rk1Q#{Lh ztQg!J0T(kDM2Mlma6rJopdna{AWj4(iUCEVVOS^tK_juEVqj^Az+s?9qHu^H298I8 zUWGy+VqjgEBmS!!9h~fOuGqgU!=aEOSTP(D%q|p|AYe@iqOmX$uzFBPs0bPZ6U89_ za|jxOf*`?2!l0mKFfcdJqM#cQI8YrtP7H}b{KJi4UH@A#gL5D#>`&0p=3)pjBnku0 zfN+ts3^5nPpmAV2ilNYO92O-42jc<5nS&;R(r{qnfk8tdK^KDI!$QH7#law=__O|g zSN3fF#Dmf-TrFMDPJn|28V&pd#$W(9do0=pY&&is{vR-iH@9(h!2=HFAo>sJ24WrX zPUiMbw)itf|9*Q$_^0n%o{9WxMpaYSx(jeZejjk?9{`5@1Bl7}0g&*&01+7K-vJQ? zIOI1*L{|1M00Nbh`HKbwz~-o|ab}@CXyM-h102Am$G_ZQek*g9qyQfMFE9oGdkp}t z27bR`0J%GYXAsT`{Z9}B{9U>LXx~3!%pY3`i2iA-_FWB`zioy5t*po&044JW_{$d% zY1rTPfQaArz-8oR|1#`v%o*wbg86%jAawbUtolc^&I$$!7CQz8uB%YyXfZ*!xj9(i zc(6px5#}(Q2m%9^1D=!buhh5>K%p=k1O~wfV$8wJf@7g*K`;dof?&5tVla59C>90# ze?|?6ikM^I;Isg;1^XVzR}d{C0wy^gj}Q@sBEgyz{a<J}`F`i)bpVSIL5Z4!^8);= z4Zy(y1uFmnmWeqGoa=B96b>fF$@f=IUk4Bnw1~MF44g%<V5@+m%n^cO2n0k>42}~8 z+Z+-j3WfZG_5VivH*0APWmy2YD+DeAEN#&icwq-{bw_l1f(6sk4J<w<Fh_zl)D)>G zSSUb{N?Az`T#Nvp&rULMSq*3n*?=o%fY?I%t~3A?hf^M*&z9tzSS7G}fX6%l;NuSf z2O!F44gg%i73zW+0EoW?049f|O3ho~19Ar?18~K3F8J&uuzm3QjGEL%`K~<aG!YXu z-37O8<wOu=;v%o_qJ@6Q2^NUCr463b#oYnVY3bmC1t`w06r8^gs2BxI3=V~(;5Y~z zeEst<7r{cH2t0^@i9+EBIM^7$mHF9mb_FRZ%w9>n0VR=OR21ca)3aB~r|f8Ofw!eB zC#~~nVA+^7Mz4OGBW9)xO(31|E!TyTGew6s^M;DBFTJ#8#FcVJIb|h`j9h5ivlH73 zgPj)y^!+F<UNltA@5@Bybh(^t9?lpJ8}BAo!0bov&AxGNA1|ufoN2Av9C_{Gz59@P zbK=eoN?XCLE2;M=jk&kbsqGYADs#LtWt792owl8$Ui=4TG>VO1UlOJDzMYC6R7uOI z?og8vrhA;-(d)l*p?NaDd?rt3^;ugc*2T`GS}gW*DmFlf@t~#yOAc|%#n`Qqe=?lA zlzR7gauai-G(e61;88c*`1OLpv|V41+-Z)&_=$|i-M39HK891hkc?xet-LcwoZ5d< zZ1GX&Im<D>@=A~YywE55;qwfiP`JPtdU2(>(E#?g+v(w%LFv{|zDfm?F4Y?lW`(0$ zseyV6#BFMCzZz7qB<b4f>;KS=m-@EK?FO^CApT1+rb|}s;(<qMm8|Hii;a$Y@5n9g zw#_c(2PbX6Y&Tf>mSlWDVWB-Tx%A2&mFVG9vY(W-H*7H4R9k<kg66wOdlF~iQ|s|p z<95VT+>CO5geF?5EEJ!F>5;SLlc>C3fh8ZKQH$O*7STP9Zj(<c9jmmAHs6=kU1kia zP`&7EB{<6%=HXJ<K{ApbWy(V7_1Thpwmd57*J7PPFRNJ9)VVghK2~pR_5zhh-R0!j z{2$wxHn!=ZVTFh|1ydoDC{-&8<B68FJos4tF@2lDiC_iQoA+D_x4s!v@z+WGdRR(Z z)UZqTZoE}!-Pdnm)t{a0$gIR!Y!MM(l6R6=Nm8gi#JVC<l@k)`BU-CGM?P|%?&@(G zE#+a5{`>P5?*g3>E23?7`yyUcwQna(9Lw!Q<O-yVLf3LSMn*1A^1Av<vMt#PkN7Yh z1(#GWCt$aVw+3vJwku;xtU~?l{CU$gk-}Wc33I%=DXv>t`<!h&)#IU(aTr^t0|&h{ z|79MrOI`|@pDHS26;vlCmoZy{K~8S<RU@Bs9`Dzptm9a5SM#^g+DI+m@A*?SGl8Zu zHS5_=rl<z=vY+tvcGz9L{6ezo;>e5TjOQJ^`6IV^%XSOGRM>WD+SJ5<?pPNk?NcDI zmb_brll3H7=2Oq7LdpROk!9@s{TE)!4Kt(?S76`$;++u|&Q><W`R|M+$5~TtKN<YQ z`8yh&?sX-(wyB)w<{E7(Jm9eS_9ljw;WPJ!$jz6DjNI|pkg|8Udv~1N+C(R6?j+dx z9?Jwh-V77;85e>+sp6cbnGDIM<@6M{%Jf%+ONN)ckG%{m+)=tDn_DigJrZA#wf{+o z{&6*O_bVZXV{E6)*SUjXhGgQNRz+0RmTp_yn}r)8_p5uPc(*TFd994)tDgJm;T5L* ztdfZ<`;vuAq6z2GjNrKULXgB4gT=_C*BYXd`bW=QwKgNp&$;;tW$hj^NBa{od-n~0 zRgFj(wr8}T!g77;d=gbeHbvztOWbuzsj<c1zpbFHv}2xXVV=AGkhU_sT0d58#%5x` zfqlfaN~~(FJxN#UTJbM^v3nV^h>8hip50IP<|-1*kIdB5nevj{BK>zNpXlE^2`WLv zbJv`w?IlHAH{Kv|zlo!7$)7X7opnh{BPpmUGSYdr{GodUTVNvH6N6Mw)~}_~3Z<<N z4502SHf@!|bq4LDj&7Y=3A{>WQ_MzbYB1IJO+~>q^L9t&U#?E|Nk+*FaWrpHb&(mz zQ)90F5(_Q)+33`Bh)96QJlM(N@MLr!J5886Z^r7?^=t1iB9tSa{B5Z#$^P&S%I4N+ zWU(=$Tv}y&G(M`=dzH#KQ>+S)Pb`<Uyl2`W@`}7$`1W+=O78nH@$t~5E1Cfpru*-G zmo?GXeCwi@R5llcC01-xW16(Ty5-Ron$Cxv>9u1#T2Mz>_ldsjjL+e-_G>KMQCO9+ z@Z?&*|6!Xn_U;cI<dY(}4ev1IfrQ3udY8!Yhm#S^b;CJ>PfJq6LyD>0(_KxT@(gv- z8754TFJ$Jf``K?Cm$OJwSCp!kNi`u@`RLFi8k0rj_qCE+s|3y&ENIk}zjE<|=raX1 zNryY>RaP74aLYu)YPJHI)jvHc<Gz$G!3*b7&Vl|IR7UER(pr=tNeq=b$Ud^;gKFe# zBuA9A3Tyc`IJORa19C7&SDQ3rQ|yf|zg+wPLq4f?Z>U|Nf-uwDSqBVsTOhgnLpeV4 zcpVFM#7bMcehE<y7gPxdaT%9M$b8ZG?e!<+H`g_me0^V2|IDcRg@IUPZ%yZG(zLzW zijs$KhT&FtKHn?1!8gPFZC}61Z!kqsIR3&tQgKAg)SFQTLS?i+@C;=@RA{oju28Y{ zN*@czyj6(IE~)1cOR7jT+bYo!`WYTo8fV5lFKy~#S`@(IP{=&bUvu$WG6gA7c*O@6 z^alAo{*;k~CF<1A!5{T#coNscx$a&r&5+Iwj|dhO;p4n$?0=8){oC#15aO}`f{eV* zmSplVo~|Bi-1|%3bQcRa8Xg$dJd<Idf0ZcrsHop)?_jO9bBS>Cp$gLEJin_~Ex}p~ zi%V8MQ^&b&I@_a0dF+RU?_cG~CcMABylce17cI?EH{|zzq}1ut-mi1O-G!^NNyeLO z7Sq;RJxlmFvx#@#UuZR4^3R}nB7&;!7k^|z5P+0m(%|s9DUUm^d9{`x4QEuSiC(0; zh(DU6Pn%tRD$*}R*i4^8;-6-NWAfuvJRD)JCEW8BCM&=9Q)*Y?G%tSiRx`^AI!ff$ z%0{)D(=+G6gJUMC+l4$b+&oHTj$acu7RS4aXtU0ZoD=b;54q|>saO7e@!Bj;n$lbO zoxlQWPxz98!D{R};>2h<BciV;0HJOq8@^dq+w~;+>HTvIm>q$pb4D+3ymw=sCE<3v z<vGCS|0+&<o=JF8n0M>uvcCp}MF@?w{H6Liz~kdg#gU=ILsD*ox69{dEqGjC2A5Im z%S>~$K5ry>nfF?C@=cuniL8qa&DS)t@CM3T_bz;9n!h$9?XvD1Q0Hvd{d&W5<16B% zQnfcV(*@-b!MfmfUExuK*v-ktYMal_`}Q85za|2%ZX7I9)JEa2XU3F%9Rhaef9`Jz z^b@gXFk{s0Z;tiN&1NLqS?zKDkh#)wxE0(h)T{ZXe;Cbtnj;gQWBoX>Pab=LAvHof zHHKQ~#r$3cO+viV6&fF<nBC=Q#mJ=YUYgGeiGjrg?7vuApJgwnki=Zzrfwi(dleS$ z_ujIQU~dn>+G@S`{J!n6vnS{I>hMa+n`^8Hq|1oCr9!a&r};1QNe1Q(rrClP@wLmL z0{t{~bL6SKeNF=ztI~pdHeFK7;_=!O$Isp+3SFdbRv4==IPpn@${AYowYvtZH2Dqe zN-blm1USs<D_Nz73+DTHnmQ>;Z_&x!xN&2lyM5;Y+=^MB(Q01K=H<|uUiM|VFK`>E zkbd0V+#;Jc29sz%OEuHz^~00~8p*ifw$Tm<xF$%wZYr7E`nHIqNY=uM93wuLL*&x# zZqc!{_UryA^UY3|iImnJW<H2G57sv$j%7jaYir}Swkd&hr_^emO(;hINwQe@QOnVt z<W3^|b8-X5hKz~F9sMFBtyeT?9M_MNTx;|^-2)xteky(~vrgt>IBhr`x7cGl5=t}a zUEWeC(Dss>lO3e$(6v@Y#NLaUJD>86XOHU=ZT+6d(8UuX0_64gZF|jJj!eXs6dX*b zC^gtLQ=4-vBaxMnw80PO;ULMUp(3qOChG+!V_K(1B_aetS(oT27QQ_u6I0=o_A4uA zFT9v9OweZZXfC1Us?LW9azK82Ku}cl<i>qpejV!sHO8ruO4vEF!nc#Du$%P`uciZu zEPZIx>S<qZ48Hcg!>&P<Ts4;8%i+4{3TZZ!j|(1X$-h3_^dWqE1u$#oIseVz*H)lY zm%$2}s!tVibc5>Rpph_PyB(iqZo&%#8FaKH0PVpMRp(#MufMSCDeS<eYSx_{o~mm( z?z7}n+*wK9Vjqk+nlQV}SJu*fH)T@@M=q@VzO<a9`{OI9;9T>7xEd0BKIJl}<0aS9 z&&ZYdac!pQX!P}!isg(J;tk`CTjw}*0^Sn{VJkl-XC)iH4kw9!e#e5ami4?9I!=Xg zw<nxeN<%~52h;44@bFTPcK?fX8y}|E3fc}zKbh4RBe&LX-&L}#9KZ1+q=7Z_eST=M zP5=99!!o;D!ouOB-lxZ&+Y7%uzXeixPBw&k_7)h^*4M6k@AY?Os$8udEVaI8lBLRg zco*PV;_`0IzuU3mG+cR0@u}q7o8{@~r0a`?D`?Hg42Mh?a^Z;UT(WZ*Y3WS6@r@Y% zdtu@160ZvHmp<pX^(bPH;$$a)$z&{DA#}pPz`#vNb5%Z}gh0qEK&G(3Ut#N)*5y}~ zQcZRXWbc|viE2BgT)0Dx%-SL&Md9TNp)Bk5AxxW}Ja(OWeMMF)7llTOQstXUe{7FX z9mor7*!om;tDcW;QR#CDGQHu7YUt|BOfhP!C+O(dTkI`tj~6s-2xq(5@GUM~F?>Uj z-QB<Uonf!NFD96wVX<ez?H9?O)_&tym!m-qiC-3vQ-`O_ZbZ<nnTBO97Tp}J%)KO( z^WIY;(nr<!)01P*o_vEyR0L8wdGMz<(O5vPNsv00*_+5VV<zyE9k!%fm;w<jG*5g> zqoba+`o+>F@<E4Bp=s?K-upf844L)`Bio6;y-`}F0-oqggnAVRK(GF~l|0;vJry@P zUwScw&>ce1!SF+MJ#q?u<&swIljFUW;VSp{WLK2E4z^NleW488jcVfdgk#^k7a<?Y zsT|5S!ZoT_x{j}VUtx<L-6Hg|h~?A(l31;h6rh~tY*NQtbn1vNN#<=y%BynGZQr31 zd0TAtwm!Q_fl{4RnxgC}wA@LJ=EuBL8TX|bUp(Xcv@>As{{F{OzZ}b@=oXKI0NGRH z?md1!%F*%Ds}xu$IV88DPor`G?cz@!wMF<1NFD9nn*ZsZ+w{Tm=Yv@Sx+&Wm*Uk7B z;%7P^v@Kf9zm?Z{r3w_0uzB+_v=<P)(X8bzo@nsLygE5PY`j)Ce+P1}D**s1UF0JP zsZwfAcU+Ft53dJy#!R#9%x%v3zpV@s+czZ`a2k2XR_i#~aCEp@JDbBJmJuCgE<M=8 zleduL*uXRdJiOt}e6*y%l$>$>Zqh0bPdK%J^m7CFrE6x6Bkr?oZ*n(w2JJV&)Lxt) z`C0rn>l8CgQvPm?WG{#=U#(`L<CV@S2iv``3sQl;jID!?Bh~J=e&JV!Dh>*LY+=zE z=HZ4a^N{@#n|thW4sRh9<u~4h4FMF5*LClSk0shhQ<!<d(dyilzCeGJd_-o!J$1i^ zcl|>JqcTwb4UL72nHkT?!nenAuS_me=32eH%S`mh&6xemPHL#P@u8kkjVDT-iNH<i zkl`~cY`BK-fQ9<QA?Dr7f^StS1F{enjRxRFud4x1angJD%^Br{rT4KM-bazMQ&SI` zzBXi!l5~{mS~a8%Z(~lo4C4DMF3E?`MW{f&^Y~S2hzip;^qG5!lIhZCW+Vz2yq6pO z6+Q7PiwXJx8KTt~;M_q>qfuyxf7|*2pIcxV`0lijODk6+Rp!B5o7drvyxC78QtGMJ zSE7@@^6t%wT6I4=x(R;aG~qV>@T*LCoUo~htjmtkV_QIu^%v_Hg^iDtKLxZzA|to? z!a8h_xUFA1HU#?Q=+SJ$!@V_hjl;Oio7b=3ZPyoHXB50&7k(Kv;mM9|c<>Vb**sp2 z)huFXsl-Qq@O$|;ph9Ddf|~z?rc$Q9k5Ag=x~bPTgZ=P8HKkV~!gjCM)O*=Qs9o~V zW-nqR<16u}Ttf@E7mi=XQS>V@tn`u%a>cbOrkn%pCY4g)fUY1;(OC0Dq`ljM?{&%t zMF^gZx||N#+n*ZLu|)ZrCl~e0q69-Meyp93Gp{dyee<E4xsBA(8j=0rM_aS!(L2k7 zo3cUFsKj2>Y*I98T!W1#w79pJKvK`ibs*!rBbq+juZf}bt%K!@gD)n9Z^Q=P3<!`t zXcX1=z6bR_Ir`j?Tu8|%;D9J(hMD8k^e*wCw+BvWSk16Y3#rfD)+W-JjWp=Py!zQf zujqfaVU+tRda+tUh<L1`IhOzCAfTJ4H|?>r6s#_`HH4aXrPjE&@0AxQ9g}Al_N~AO ziO8hr`YH67?Bz$*Ycq@^o||`h6?QsgX(G?Fn%%sy|0_LQasg+@IjUun_iN3JB*+07 z6?6LnVCTECg7Bj1aAuJhx_uJDa|!W@FU3l%RnNvp;tq>kbVzD;d{pVvCY``@I-yvV z!`>~K>2g!g^#Ho7tnddB8{*H73ymtEzJoyp`o&i+p6DF6cBIO_ZlOBw(p)MZPGmTl zTqDiX{XEQ|Fxo&RcS7T{L*wBvUXs^^Ak?dN&O3XK2;d=!4x#GsG|7=T=kz$QG2x^F zU;N<!D91KZ^D2<=1uTd|_R=<5VC{UTS)doxW<J^uBG~VIb2eT^h&qQCye@8{A8UI_ zg71EmLevLHHSid;I<kJ6_hPlZe2e&(*c_GFrjJ#rX4vA50j+oUmuk*6H4r9h8!<5P zF<_6`q8=2aY2P=|9Xz1u&}v{|GBs@$6Z5Z9ZSmz3+j-PDWCvXMUdd--KcxNT*r2OM zB7($Z+=^(dBiHwS-2iaot;`StFuC~g?hPXivx#dtETqB)jjW$vQ^Gy8ZODiY42?I9 z9k}_1u3Fc;>~?*6|D(-3&JLV}sT$8!9>BKC=ayE|C^|m)M~@L!+$2_<`?30B{Nz#* z{ncHKGTC)DQ*%$+&x*PnzO_diD!dAaM{8p(H;GNx!xHDBsB5tleX_&PW-e44E2A(a zm(ov!s4j5{_`g?0$!`vZy0t~8!u`81e61t6uJlYu4Xwc`X&fZY;jvUaMeP^X`$8q) zm)`&+u2WCEKdD27jbu1Y*<V+wFoP`W*7G2L?nwTY(3McTG*RoG7oQW1n<RE0C<W4% zm3hrqq`l+VoGAR31HtKRB($jv4^6Q$-X(23xpwchUX=7CSpX1Un@0tt-+j#IABIaA zygT#E%tm3#|F-J`KV_nX#K*Q$pGh9c?yHFPnUiVX4x(mDVrE%8x<AG&P0%5$4%{Pj zc&$wmk&Nc)ecM?7gSX#oJBlhmBK_#v?K?m5M0L#6pYT5z#a-Rcn^VZyPLKpCTpnel z_OkM(@|+Icld{+0&h6d1ZsT+KTH|H*bE_zH0VT$5Wr*1_%EIbM;_=qABU>X%%xziA zs)ByhIJdALAMkR))g(<gSk8(6HNs~`J9Nfv{{BUj=bpp%b|*_Sf---hJB-No${W`L z#+XajgOk#-M5?cSLI4v@HGDOX<hQQ9Xy*T^rQ%=pl$9t|CgMq>*rPn;XPk|XBy&9z z(F382R&N+HmD;jjdDW>mj(1xTd*xG}Y*|}5Z)JVtlD_FBkb}ihx>{$jGxusR=Qj&o z6%J+`qNMJ&B3a{K?xll}UVAe8X6#*&O-jdSjvVCYoN<k-JXL8a4cb>3^#;wE*G?lL zx}kTMm3uU31%O0FrS4}d-yNfk$*0Q6Oe>pfc(fd{I(Fzu;`5g8V<z7=WKDbryfj61 z;ezd=oQ(n1QI2&v;&UH0KCs+zt=PCDa|*M{G;yQ|r2f?>7}VJyT|PX`^9$lbW~}Q# zB;fS?iyC<Y;N|xs>YB<%sdaBw{(7N736B#^s%R5%(-hy@8!%v$xfp8K&iD4Vw!Kfl zc&XAIhHD>`eEDNkC7$N^j(oY_%ljaNd)R<t1;+3tZ*e(<$myA2)DYsk3d1$TcUi2> zD0iXQ&NTVLy5rkS-<~^hn)VshY^jLvH>Ky$c;<=F!!Bn!&fxS#Spk=+rpW7^Fu=Ln z-1Dl-c4plt?+cLRwD~dEm<dwn<%0@56gigA{mve}h!5GJFeywT=1C|BjrB^mc^K<` zPQ@(emPTdPLe|OYOWn#t@4Rxg^|I@yZx37Ns|Xx83x0;-#K7e%#kuTB!Y84H^xyY1 zGc8mEBGggO76g8YzM{&AuZ{ajPebM141v0)+fkk943S*QY9o87K1v0=K8OrcDC|h! zOrUNMTZ{5MIl3Wbg=v0Y#Fu8~=Iqn@yzcbw&E7fc?iW-y?1;K`4_{=~^V-@!w)g$= z!{vLljQ;WVVlVH|9=LK<EJXR3-dYOwdFq$`zQ=I3M7#K=t&nE;ug4jwr?rVrS$VCP zrqiUrG%Zcn50}r;*I#p<X(6YQ$d!GV=C5rqrloT@#O~?kvgFNE>|Nb2dOUEYw1H<+ zR!d!?b~i!hB>xB-(UWyZ;zP(}-`7M4nSJDchrovDV-WqMu+GvGuQgX`%@7i0R%Icr zb|zW&pYWADoLYX~VH(6O&x&YE??mL%8^5|xZE}8GIeAJbi|9lMlkkkk?ux73-X*$# zo!hxA@%JhrPh>m{t{(hQ$_GEbq9(OAtJU^jRifS$4gk}!`#y5p=T$Op%H5))*KzPn zdvEhASNCaU>!iJAT5yxLo?cwV__tXk7x&MnBi+TW2HLkJco&MrlZ-cf2Yc>Eom(f) zii~ZRj=;3VP({$Z;x`_tEcyg~@QHjiR$X1KL_~c-`{`?2@Doqu92NcV&#in7Usm0g z2VzxUF~A0hn7--KMrKu{(5^^*1qy=i>q`BqCR2z{h$FsmpE>U`v52AJnojOQOCc_r zMDdnXD!-V<-9sp;vd*;gb1f>yP$pMXy|E9f&M1RlE}NI-`+<I$@EfEohg#;gK1XGo zeXV3g!T#6BsAk*TRLtWJi#+rL>||4)jnF^#`)IJ>&Q5zjfEFJcEnq5sBT6Z>T}5)z z-l+!n!0pXt?sD(Bd4&cOuauRmW`o(PjVj#69`9AVdFl&><0=w?$f6LAvHbhdb*z$m ztDO(OWIZtIT@Ce#36&#>^t{qbv#T8*BrIU0_H%@|(Rz~$dlvzuNqfW+?Hn1;*!lRP zkuCx`zr;@hX?%q*JAETl`8NEMDMlqd@k5y83MjgfnCb1jXZvBO-OzB%oVupF28wUw zNkRl2XB8#Kom0bBMGjR>uV1zW)p{>Yk;c(qlTqn2iJ^HR++Ru{%cnl^7jg%>osF9X zGW^^Kd?)U7Z5N&QxtGnn@zijNmZE-r-Sx{jtB7NwTY477OE}(6j5MJdAf<hI{jCHs z#dukq_RL_LIZ@1ogJ35!f09p~c?_|tN(X>+@DlWS9W7B%2muG{bMML1HP{jL6Q=3F z-sKneJFG5K^plE{^bp}_gIBfuHC|Sl-Px@bS~S?xh;tF&2kMI~?~jRoZ-XjcTk@f| z60~I}Y3I6KIzEulW|+J`-RTon8WYaW`PDLiNp$^r?z=#_9V@v9s)NRw8)Rb0{+OXF zTY2mr*&PCm8jN$bgn6I1GI4>2)&`f<C89N0Uo**to6j>Bzk5I<>=g^0b#Hmv-9tDP z@pzXc7E>?vV>>2L+K4{sx*FSU9}Sn{ALzN%>p;yfv#=@YQwF8<0}Hvws%hG0WJpmh zn@bdC?eKYdYtMD(P0D*(>W2)sF%7flq6~RYRotG6kk_ZBKFe9ZdP}X}3{gNcaq<|q zz?I4iYjjH92?S`?0^>X>-rio+G`IJ<{K%GN(0JSQl{LfntNP`VV=P_mr*@3Kp#Gl& zGWehB<;sZO_R=q?MCH;4^J9-XB$Y9FtQ?;_vb`{pl4OO=zm%E=lTR2R%cP5<#8Rh4 z0;+{QeYXzJe?(qT>BoI$-OVum_Hor{w%Y&G@n&Xa$7*SC6laWwi657kX0%~(7~2-1 zzX2iuZuamtFITguPyG9evLB~+bL#uhzWZgd2Hah`o=-k$Ht>erbUJ6zN}9u}%6EJ` ziSKThNf>V(&0s1vPmMQDA&%s~qs)W-sed`+Lx*~4^!&D>eG>X$=h>VOVbL8wGcxTM z+OYeUwfkySFWuN8Ec-EukA+f<Cb6lR`bpgPZgS~dtSHcE#`!6-2|jxlVbtZg84;0n z4aX!uNYW!_k#cx-`u-j?p__`n+mhplY{`lEv3}AtxmIW3kArTP?nSrnJQqLjshbEt zHE-%l+rBXLiq0^%V$+tMOlL6b(f*bnpD!=7H}fHj(-sxbnsy@oL!WR;F11SL=2k|D z+9>Db*t$?rP!-X~08Vlh8@*SW&4=kxQ;gYc1zAQGhA~ZCT+jrU&xGtFtS7<%oJr_K z@Rb|>B;C@^aPSAD3U(_I8mm~_!gEE5Vb4?+h~sXf)aI-+zRsf(KdsB=Oa}_lzcY(` z{ct?cH$$(zYBS``uHm6|s*i%YBbgWKetE1$4qIrgl}!5bgmzc_()|j%a)t0V0YMUH z#DM`)m6l98jr%9D0<C`t{S&EBoLPv<2MdjgXCEL53VHfTz8meOo!@kCYf={7?EWan zO`Xw><NQV@MSp>Yq~+rB<70!U@6fI~zYo)@q?w=nm7g3dzjr6@p%e03boX_V?<Fqe zBmB%b5m9(MsH<R?Q$;36U<-D!!>T1Dp_PT8=_zfet$cs($JPhIf#(M%loVIf>_xV$ z*}WZ3h%1DQ5chSKuBI~RN8JhQkl==oWq6yrVJPiy-X`)%MP(Ll4Sq=o?%=p>YIdJ} z_x_PDY16`+Q&Oko4>Gv#(#g|xr5j6Wwd@WL?&?SVAai<P*ARcA8gc%48IsCcTVLId zBPoCdMQimSwwOyme#(tSGUM#ml*dG>F}`|r-E2RkTn2q-DFSZRPUqk8T5x50Pk(WW z^bODOCCFk4(jEIEJMGEf$5Dcb&9~pF#7V37wqgSJ4|KY-wC>!1_D6=_-Wm`u>Cc+1 ze-J9e)bZ>mG`}AEEX#W(tDhaB_F=JxbvpZwa?Z6a{;p8M9_u?V7Gg<00ql7)?||jF z8_lv6lwCx7M=oWvC2|v<M#`+V3<lB<oAB1jFQ`gSI74v2J?iJ&=Qfk`%+8rO=zcdA z`l8eql^N_pUzzMO=|iy>b?0Uop}pm$d|SMJr!W+=Sulf~mPn{hH}+2MC^2}Y95fd? z&jb|D(Y8NI??G=!Cs1D`bih?Y4vtC{L#|j=X<wuqhDPsaX0FewnDQjXb~;?kdQJHA z*K2}aY9kg0=d|aPkJ323NHI_6BK%9<T?omX)6QQc*DH@S=7^i=3YZ@dX3*IyzD<^u zh`vhlq*JTkio)LUvQ8v9$q+%T*_dG_?=wP|IDuC1N9>_l><oLvG4-5tKWk~|i-?k! z83kFp-`<Z$sOl}d=G~01*Xt}I9A^7QCO%eV-Gj7M?B;>q8Hfb8Fr;xjWma~bB&~gN zg^DAP{5G4I|JQOUozsK>BUsnoW@bf){fo7kVS6>(wp2Nnq{d#7f~h*uao_pdKV^1z z-QkKZEI%A_c($n0Nht!ctajgV8jtxPj|gr(R|%A0mNZ?AJxz7=W$ft<(8HTt@zvRN zZn)%f`?+>#lthd0ZMU!&Q-A_ncp-OBO*@-;C?ey;_yNj(Cm=JUL%xBAjpSU{Tb+JD zNvMJhJ#h|z-d`g{tGcAWnDz4EB6N7LSwZy69sF^)&-bw}QA6r2`bdA*BIkEa_8Jto z9ZdW0;>XV~<j_j6oWu;R1s`m1Ne5;zzg>dCJUIyhA8%$UYp0%yKB-CxWLRp-aZn;5 zzI@)nN7yHXBf%c>_USoh7oR3N?r4u7S)rbu47EL`W1r01*Y3S1e^790HO)TubIBtP zC6MI)tGcvH_~2Q`osp#FX~NXRd`6C6YCabpUH#Z%ke}j$3D0B=*x8s_R9GPM>OA;N z#xdYc+cL5@CR8ewQTv%2{fem(sx+s!Na}ZkdrjAOxrOzhka?^KA!<l0a<uxA_|{`` z&Chp^AEjKtl=4IgAndD<o-t`xDsey6^NWPQz0bNY$$gQnfo2z;LX%2agy<9#x+gE~ zQYesJ*d|$(@UfF-dhD!fv|)24`ttf0c5c2O1Z<zb2(u}B5~#BulvgVvx0VdktxdZU z(G!_j9oJ;S#h=pqET1p;tedwzynoH_%FP#60uIm2@m0P?W}&O=D{4-6u&Fd+gJk<u zk_&DT5kxVZl>!VdZ!(9k&g@lZ@G1(`olj8C>A8R7Ln^Xlis=?Nd|R@`h^Wm>hp&SG zPGDdJ_f{b$ChC<-E<6dTH{wxFsx|a$zn4G$Nrv@N$sGxl^4w}dMjshX0D0PVxj2oa zu=fq@o2VaN`t0y8fq*`drvsfg+o$rivP)NYxll6+bW@@Zf`(4~m1+8xPs3-kgM8Eu ztx}Uq<n*uvKoHS`3YI92?39V2r^%iAQQvgrda~Zrn`1ZA;$ScRjaOBRQm;j?yeRn2 zJaP-Fctif%qe3!?xTgx?9#4Y53x%BRpZTa0ZWkDTDYlGHlwtSd4XZ71qm9RPwL1#N zPx~#4xWeRC(QaFBJiD4hiUAL}#$0khfrXIs`4tA|L*8h75ECqcb8DhLC*@^TbjJ_> zk?*+lH8yackI|-X>O;BNuE>2EXJK-B|NCDY{jNl;%h+E$U`kboDids^b}Xwl^oHcQ zOI|qc|2Vf=5O%U`nuZOfd;DaJMN)2F>4XF4;ELF379|9CL|r@Edxb;0;Ki{{c<>DX z|EEC&+zuxQh3SaE#YM!#MN#|^h&Tko^z^j#-v-#)<1EeH|N8**Aw@@U0P~pyZF_T< zhiE4}@bKY7Axk@JXDr$QFJ$jzk+^b;1tbEL<<;a0WXybj?^`2Kf`YpS|Jt`^<$$*U zpP%-fjsX|$C@3oc1Ox<t68Hj62LV}tl8lUkjFggsoPz2+B^B-Ei?lQ}v{#r}=r3RA z=D)$m&C4qYy^R!vNI-ab#Z*xevhqsGO8f|QT{Q)r+lor^r=LL<g!+W~1O&9eX&t}{ z5CBA=2!b=M{yGSW&k>Ojkb)02F93vuga9!SG08bnGEy>-kPsjuragC&ON35ZljI88 zksb=udK!6ECbjqr0~~?r>{=t$b#ifyd0)cFEh_6gzJ5(xM-S^8oz5f|C4V{qkex}A z0*;<v`5zAM=w+rEZp{Bcc|}>W^9e@bf1&*UnyEp0koi<0LXurRy{9sv)*-T%l%Hw8 zO|<(In9#eo+eS-En?BDX8414Af^YwTX}Oah)QVg^L(|ed49zVpIcdL;!k9K3aq}cL zi(YPcr&t$O<ogy_J96%@E)3+M|2bBDdEL;~!_LS?0KX1}Ub4}vC74m3Bd$I&^chd4 zA$|HHlkJwx;>!|GcMTsYn}a0v{-X*0wy{Q1FUd$h>x;D)!<3|6CLCG&)slXfXUtL% zofB+qDqI){Y9I4_wgE$2u{#vV?O9jDlNup55I5OWug@I^mVDlMVZ^|e+fY+aof=qg zqtG~xI+XvmzMS<2`SoW!5hP-p<`gJ=xXAmX#Zyw7kY~4ep|BzUd}>f9l=|7|@lRFe z8rnz6#Z$TA8EbiuBg9J)-dOK?$Q7Pb;G*t$P0QMnkOmY+-cr8!)-+H5s3LrVM+mu@ z>;03X#>&Ga_f5~bt_FjGiVx7iW)9!JS-+!A%G@SuU}SNl*lpDWS1-HmlAY=#zP;RK zK6R+u2u*ZbH~Qp$<G!Y16BWU=&}I9}^>bpOJH4~I%M<A|GVh87*3WxN!oCVj97(Y` zIyt^P-*W#XD>0>*G-6cdR;Bt)#Br#uxAh}TWuUf0-&M0M7)$#H`-DWv2A`2$hoaB4 zlVScgYCRs-jR7x6yl<-?cX_)ho+LV+0w+?ZKv(i`z)t(~iXPKW=Nl4>O$`d9JA_h1 zl8&mE1={Gv+9}?4>KEz8J~w=+HD$xVTX?;5wJ+EY`RSFe%@b5h%3@%jLL;`Ngyafu z&Ulixu)hZbevgsj-4jmQ<VgM4wcUc0=Gv{n@KazNloxZd<o(kQl-hU-9B3aFp8|Kw z&-R0IoC5ReD=qv2*6%z7Za%#B@*w(mrd?Ft3E8|T@;m8}<+EE4`$xAuyk|aqd6sz6 z>n-)=6tI}|{y|6h`20t)Cv^@7+Ntf|KQ7XYkY{Qh>Yu>c>$aGR%lZUl?X}e}6yM-o zi8&WETiE8l7hJoPJ?Ln<W3G4vBYaGQm0GTn>fZ34hEFc7GG<dO7M1%5f8D%Y$ff9V z_)&N}X8Wivr{U4vA*Nu1U`J|_S5-_4eL=ZJd$fFaPXVm=@6l4z@Xv{oKc;_$H!!}4 z&R6XgD_i|;nso{grSGW@EvY!b3hSz^1_VTl_WW|bnv7M8t=ox&_|x!}es(*|6)!#l zcw8or<Q&yRqoehwGs>><?^<hSaztE=`<+~-qoQ8LC-Lj{+wsSnjE^~NX%j=UG!_-~ zL=jyh^6o(Im^6p~W+!}4n65DB^7JR>h32~VzD(WQ+Ittyw<Olop8_g-rYDf&28_W@ z_$JJ{uYKN|5{v{L%V??h)(d}i>90uB_eoEFi0hiAWsD|nZax{aNKQOy@kUP8E)7ae zC_km8d*ppP@i<Frn%VDnOlbet0C@@3%fwx~SH$~aUvRCBIl6vRn~A!{nl*<vEp5Mq zgHysh=mV;}UgXg|YHvD9fOcZhL^Noz_D2fQ7;zmK>}IbrhEu>C=lu=Bo3aBt87w(g z=RMI@my%GVL@y)BE;CL@#W+eo;XtoUR%xj(n}(~SW|$2w<q_s*%dwBOuKr7q!hUvm zD4zl!L>@h4G0DDBNXdB$Z0B`wAN9tZ@VM{KiAkNb?sx~U%XSS0briSP2M&ctZJh!u zZ=Sd|oVcnVSRc+5ew6Au5j=T+3e;d)Ygm`hxtfKs)))0k5hz=AmtBjA9!huj467?X zieDOW9ARQDBM-MKnd7E8*&1DmDCukCX*MNTe-uSs_4ShtSPjYV4tW^54MVMUz3g?L zyJ&X%N)d4(y+Tgn*J_W@hcD_%mN{n3dyBD8`W#Z!YSqNsK02^7+F-mgzp$wvw~h`+ zo=A^-AGP|O0^?PO??;$QZZXat3x6mtoY#HO)V_B9K=a1oU2{EDdSDXC_#S+D{%slZ znYssM*bDWy^HaS48RcJVEw?;J(aWj@Txt}Bw8Rp*EtmGZK6T)wrr4QIftEh06V}=) zj;33}r$9oH_Yu<D&ADl;8j<fIP&jb;=4+kUcUB)5CH<^(f`6yUzfK+Aq1!w5{XJ~e ztU;3#$;&Tk>AVL{k`$@bT|YOFo&pU##CP8?ENv&0&)!5#e;I$~(vJwwf3hI#CUyPi zv-JgSsyz#$%wh-DW#jO9rzb*QQn})uGxTy`9pWVipPvFHP^s~kbR8bdr+{t$Np|!p za6LOYI(Lw-R4}Jm{{^#w+Ow<0Z^A>nlRN^!?sr~}@^M6xLM(@FyY=lYdnwt?B#F>d z;N#LMpxtx|#QY@WO&OCqRPlF|q<-A@eXAOM3Uo`a5^UZ*k#S!m7iUx5V*i<(l0|w> z_Nd{Fb?L10Ve7s}-k!RY_@uXZ_Oa;k=Dk6Vd2hy3z`%PHy0&i9){@Q`%4+X;-)7F{ zH96(Hr^iy(m+W`XA$xx5T-;&5%5j){ao=n?^?P6U{0_ClEl4LC8=Dzm<M=W|+8DYf z!wM||HV@{htkq)gt`jgEFQgn_#(AY5wG8aQ4ots-owlx_Y}xeqDi>Xs18ZW4!%`c0 zl&2hhjRKclR^A2Y3lc<tG{^Os8PHiAt$VA7i1xiGGdZ~tjt+2&vkc!?FsqaMeMEVs z9o4<~4XP{3TfFdK+0sG1FV}5-$hp#8Q}JvcLVPrv6zuK0R`sZnKApPYY1@+Xzy~&i z@XzGE1Cm^(^ppMWaF>*_YqAH=2@979@vSRV-`3WPSVJBC5t#J7M|+>=ruxL^lCQBL zyvb!l9FuBbQG!1loBiS{Bb+i-rgA^|#9TR>dEY17)_eCSaICeE&q=)a6QJ*B0cx-7 z8Z$C3M_yo}XTtVn<v(XFEq$}#9n|nxjrA*Cz@e&5z4Jqw<}XcUjH}gwS##d`G_to; zRXl|y+nDOsORNsI^f^o_SEOjW9^WrVAcQ~ajVK~18w>a4)uVL_@TPSUCC$^*28ju7 zy34J$ZN}ksuiIb+|JR-@On0qllka=s;!jMUq}ht~p<&>SNt)MT$bdTUMY!0KtSkQ} zsabFUUB@A-X5RDudVyWXN3QCwVIKA|0j3Gu<g>y3L&H15e}yn;1$MFhgLgurNrU%U z-xhn>bDVbyWZ<qm7B=U%xgi$BT<+8v9=6q^zBq`9rI2tT=bgT_G`fS`uVn)}Wk0xB zQ6+np`0aprbXnvS;A^?`?Dr9+{=xKR<Zm$8B53{m8>U1P6Sl0pJPESRZDM!7d4%cB z{t-S;)uCH&*rG-aiau<MY){!bositddI*|UA1MY8NlswW==S2Es5c$jCR9mMzUX68 z#*z4&QWCv13ie!UY&`VpcOJD9HII8rF-ei4lBv`6;ZryAF4hNkYIm>hs7WdB+&dbr zml}yajND4bd$%5GAJ0ow@Xs25Hf0DTVfQj%z5LE~p;_YbIl3!X9yo;YREuo%=OMhV zGPXFTzhh(>YJYW$F1V1q-6k-<k0sG+n$*;oVs)owE_m)Zd8csyDiJRwKkK|srBr7- zB9yvWCvuH*n}guil-y{^0M_Dt@K;YN93j8W8@qQ(m@lFykwc$@#go5Pa2QUTxeBfq zaEcW&x3u4H#a`(t;<`EAzs%SAgt>8)lA*|HeX(u4ZK#obI85)b>054cOp$iKQL?+; z#7nu8)OSKeQ*PNi>Zua34ZE*ac#{;5``%qAopEd2xih34-k<#9rGEML*xJHif@98T z^{9K_Hg+9$#dOS5{*fYcUAtAQZ_o@Mbs1mr48}`s`h>LZp1)(h>Tdo(`3@-`b;uK# z^!rb7=G<#5*WW)p0$nEQ#oKSX=}mtMJXT*(+z2X(@#uCi${TWHMm<@bV33wdO0O5| zxyQKHHA<cTSRuDD)7?7XleDmIJK?9HkE5y`vz=LM+TdJsgB7!tLq0;fkDEi;VqUWO z6iD35KJ4;#l8jy|+^>D^{fT$?>b&F+?>}g>j}_rUpGWVuwlUY2<2^67vDS+~?X)AH z3~x)d731R-&A$JeYF%EjrvP$YeK+G|>R%tI-V9YZq@wttiEX;P^mMv}>ukpO*JZ_D z3yuGJ{g=7rzta5m;QwDt1;)l|T(c_sNQk^FJm1jT!8}?h+>6*UR>tCZ#HVzvhBa3- z*a};edcWQcX`Os)JolO|$GqKqMY^=6jHT}ejY0eV`FFt+xU}x${Aq5Qq)v5=y>+CT zEn$`9yUJ^-IrYbJEn_`=pJEs6dQz^bdUqz?Rd$-{`2`t;Xs;a2b}oGa`#%{@Pnp#u zXk=gI#}d2tjp83~xmMiOt2XHh!G&Q$bz8bnly<G`V2`rY_cp_xC79(vn4O92dtM{q z4a?Xh)^8JVewr(xJ%-swQ5FLR-Z759;+xW4qt!QLlNR#D-XIbWLdC;3V0GXJu5Cx} zQ-De`d_$y;jCcPYQ}#BCp7(}g-Nmm#&n8_KYZQ{bPp-BJU2wc*8(I7Ir_d>|;T0b~ zxoNKDNtd1}@kHt)Zk@(L=rdm1NnigWN;*kktmmG*-d?VJ&~57vV-8B?dOZXsX#;b- z17fyieHs_w2<5G0r$^RUe&_r7x)m;@pV?0O>n0#MHZv@|_oG#rT(!t{!+g4{>W-7k z0xGYS^|btGxjkC7mgKl6pFltQpp@|EyVZ7&lkea6Y@hSrT9946ztPz}SIs^{9LV-j z{NeI(Ny?a;M`BH>a0<bwN#*h8>IOs2m2@LN>lU@}suODeiKOczrvPRB-O#0d0#pGF zgty*!pNe^>jomuua$iQxR*GwHe(+a&Z?#LD{Z|~{M}_|Q6ge+`OdfEh!GV5jS*+jS z-3PU)zrRNK_b(Mp$1NX9#A#%Pnq4H#*t%5VI&EoaH1IS1;eL~GV7D{)f8oWex<_)k z893oNmw19zzGKT?#=+=2;ps#CZZQ3|A23IR&()&ci2WnEcL9-J6n*ZOo20ww4zeDf z7f5tourbOFh^5WD5H{L#F>!5X93|7NTkutKIhK9l*0jx%Y1&9go0`OH%W`)PPK)$$ zW`z#^?~4zNo!#}NK0e6hE2+<Wxu>!!oM5WlJ#{bCR*$C|c~f>uF{AkTvy5SxjR>`$ zEsujsU&z0W=Y6>-J`h-hlJ40R=ow_)Q`S0a84G(|*Vckb;C*T7I-=1~JQ{+8dFgbp zm%zfhtqOTM2ikPebK=`?U<2o^Epzj9h-$3L=DWj(A^og#4BG?Q{K-%INpvS-_8#j0 z=nx^Md8&y*PZNJwO%Zj9UMEyeGS{@kFobZ8e!J~WZc@~c0<ch%9TB{GB=*_ZaA0v; zg>Q>Zyv;~1Dc8DY@L?;KsFthbZDZNj@m=jZwuRFYo<5^H&8@mM*K{wag?*4vKPgc% zo?s$=Kun(6$3|D%kJ85TOgpJ~u@PuI!7c7I;YxGkqJ~-;<FPA#$0_P*fel$*-`t?| z*foiVdp1v;0{F3oqAH`->m;ttxee8VibHaw-Y(vuZMx@n7uVq#%T1d^g=<M6DIa3) z2FxL6yezD3kd;03&V0S<+P!<t72D5WmG%j~D{Jr4`D>nk7R-GpzaRW()_)dUvNYr2 zaFl1Gd!G5WnHN&RdM%$CrP=&VlH^WkEW1;zx*bD^!UL?y@ei`)y|&%8=NtM{n8SEp zJyTxxH2kQab<}p~1+VE7*uDOfYg-;+46{a~_mcOR-z5hr7|RC_hpfaA6;AT6Wl7s} zImPR_yLdnncP6}q0@m`UDxTzQ?=jE*VqP!&Tyfj==9JX8@#`4e;J2Dun1+6k_Q?BN zb=&%7eV(`cuQV%lUTwSI^v&}H={rD!28vvdoo}vUUw{Gvm20b}GPh>mj<>wkzdmfg zO5@*4!BIV>9%N?lqhYE<Zoj=kNpN-k)<9hSBqP&E?8E)7Fo@ySAS+{U=6?Itq>rZB z-QPUNpeN@^SKc`DB$EtYczqp1wy}J}@tU<7iui#}ZINnQDXHdmY40XoHZmxXL2cl( zGzWnIx`&!eXh73!w4g$@>KnwagpT+6K{wMk){XvA^=7d!GWE2~>_|AJb;tJn9y5jY z+Q@F+sNODpa?;cmuEv@QLf}U?-{DQA!p!ihNU(!zw`?zT&U!Ox?QQYEEWShja_jV3 zlI_f_Bn2bh{?R^1%(9mO5tUB=M{}+d;zjHZ`7Xn`Tloul`nI&S?-qy)qZU@J2z3i? zHFgMl-Pzl|%J1W`)xQ~;mi@lRtnwXG!Y8SQDU<2^^87^$&k>7hzM+}MS?v4A32lE` z$-jT;$P+})_$cnx!aAVYQcGSIkZ3L%68U@pC%G*+@>s4tI}B5*>Z9+Gx?1bsUN^xX z`p{=dH)0M^i@u23b0cMb8QN-a@P$Tmt$W&-!BKi;S;!>CedWGUV%`V6Hu<E=*=<RG z_1wPwd#v`@yWdjzJK!B2@8>i0;~xEr-0m&kTaaHexvn*sYU1U^8ebA_5TEeyS(xB8 zL6gJ{-i}ki=F^u)v3n`k%nZY3N7O|mYR+v7DkTme)oKFeCex;04LK!W-L31WlMQ#; z?-{1~YJ;t<l)UKHb7|APWhi<jenb0H+uIYdcSi|$M1vtrU|_q~dmMJ0dX*z6$g$@m zv8P;1!?!}dBFg_m*LOz4*>-J9B$0-MkRV7RIuQg>W)RWKXro4n8odriPjt~4y+$u% zw9!j+MsK4Nof&N~I{B>i-S1lO^RD~3{$1CPS!>pLo_inr*vCH3iqMH-Z}R@xR=R~( z3ykJ5m8kCVb5yE<f9Spyg;j_CSA>Dv5YsovJA>-_j4rPZ<um#mTgg|ORiiq%E<&WW zG<!JVc?a=df(ON}6dygA>2I+sf1&e=xQ=BP5bARRPv6>~Q0B^dNVK0&9uWCG?DoZs z>oh${pjJcBnioA?<wE;_vDdQ6X#(T615s_WK%l`MDep%BvYMh-{!SrD4{h%bn?0t` zs%V2_;uSxzA-0miOp<by1q{58zcSunPi-~DEQgQiI@9SfzARq2&<lRl@8bMcPbX8Q zx`ap;?R&I5wVhME<8idbQpem}?|p`vVDD?;uKO>$B9f?ihO=t&*TV~xX9wC+%JFb` z)|A#W*`ai!8?yc`qN#+w%VKUtD7Rwbie(ogd}(4K_)&qgs109(W!*FlMDnESD#bE_ zFwS!$JxCswJgi#sP(g>YUFsj$_%!Ek45{gX!&Sxa{LR<N#oD+zYf?VmUEWgVVy4)o z)p6(-k`%i7w9|C9_2MtEfW~k^dX3hrN}7O~DZzA_@Iqk?TC{tk2z=(KMtq<gftzuc z`{cl(vrXK<CiIvwxyW>~WDH?Qh)7JEf<}x6ntD+YO;kwSRebg^r9O>Nu!hB)u}Y!s zIN?phM<q#)Kk1*^*WY@@*Egs|MM*?DUrNiuW81B)W5h7?3Wsx6eq*2Dkrj>pks%8k z>w=+0C+hwY#!cRUT=}?iL=B4pSL)QCj}ol<)$kP0fql9yvIWU!>Ffrf-O&U9A1a!d zY?Ma+?CN`TW4(N;&>{s(0gK$=c>FFAt7x0na-AeRi{0zht>Bv#zVOdV3Jgy$B+*vi zN$Ihxqvssd_*!p2G%|^@sUq<DT$}`{%4WQ;a=WPV)}(RcniTtFh*vyG5meyPN3b94 zIuetq8Qmf`bm0%z^9Bq#eOx0l&zJ2Km6>Hu3fa1F1?oAN>zwSq+W9!1x{*FqB$N@^ zX5VA$A&7(@)5XU9|GNLPEb|Okzf8wJZ;C$V<zozQM$E;Y(O&cFTvPOPZOcRDt_+oe zVcyZvk;g@)q>9ny2MG?mMZY>sL6CDklOuZ8U#BbZ#CN2kYHBXb!|qg?uO^M$kX%7A z8G+YCD_r~6jNz&9$c*zx_}%GMg$UJVicK1+><QhTjEs`uc2sv=X!%{pM39YpXDxBK z&nO#+GH+9-^1|FH*+I41y?rGXWiYn!(SeRaa^m!eUDNTRT@UP!{+;zP@UCkB9VdXs zKua-#R=dN&i*rD-ew|J?1vc5!Jy?UuAN{hnEp5C2^4Qv9ThkcQ4cHXhI!x4b&@2j_ z|9!^;u@9)>J>7U(NjIbI?$*0o6TcBTv;s=e8!x*x{Dic$tL1C5em4zEUOZ>N1exZj zcW^vsNqFfMGaaKO)r%dy>;fXS13Km(NZcHPSO%;r1~fTIsv2kCXS4Kc%1O_z(O+Ca z+uBc_GXW;`kW-9a09Q6mSa?4&ZPtp;CWF&1r&*$!8`;&UDU!X^+){ZW|G_YYt-S72 zy4k_@!;GDz>7$1PU}X>MJriu-amX>skw-oBxq?<kP~X}+0M&<gfXCpvXU^AUdpwR6 zwx$prFZ9>uG>^1i+#N_4Av1(^>XnZ!VtX#P<Dl@tkvm{q{XS%_f8+DVnOm@D5ylu$ z{+ghsxZFT8`&Ntg7j?a0Wk~b7OFjicr11~a#kmsIGR#Dd|I?lgmi2#j$p0dWZuZRd zF=R)+f;yA5ZjW%Qu1R03P@}s*ThnZ9msuf#MoQ9bn}~9JWwC&+5JxV$dHW}2wLSPe zn`&B({qfP=s5EDQc#NwhKGtqa`N(0q*++21fE3Vaxj=QWzTKvi-O*J#NE6?Pk95yx zHr9`g`7;eRW+3i+llJ~l1(!U>z*$h!R%02)e}CI77sJtQxTIgbt9ElxfRYsScWBDT z$<m7ekN4USq~3}3lx%UltVa8&pI_!@Khhc2kD-uVH$C75k#bt+i``S%T6>2ECm3#n z!X8fd3sa#6DnwbQ(<MS{M$og?=V4G8puSHVbeA>V$&FTnWoAWt+^#e~wt+8&xx;-z z!Y<CL|1ZIY03e~aBH~R@VM%)SDQJWnj*L!{LK;q@T!+ep^dF2oDn|f7{k*%#+J-p{ zRFA}$mPy9r?rE6+qeqS|J7`;+wT9e)QjU{aS$6ZWz@_;+sy5hw#(~EwR^Lzh(wlrF z;&vD-)1W#<RJ(U&%ZPQ>T}VXE(cvC%`BjG1JARYAtCiBG@5z+*b1vDNQ#(uOAlRap zmaEX^npLeAr@exUNb7x7B|I((+Xi#ez`mRD+s$8^bYc%4=Q&h4czNoh8<(DT?meL3 zSyYIVnEKsNZlqbYW(f~2LS!1~iQE*5xR@S$Uo?H1vZF7N%XmQ|!eYc$OqeT)2la>A z;RyQOc#mG3=QQe(=~poVE|5W&>c1n@>^2b)*_awZtA~3?r3|tzIR;cjx9nMTlJ^IF zFRQUc$q$!29=ELC1ib~tmtn^ch(WKsN%?y~jXLCR9WAwXZ`Ztd5=GqcqEDBR$^Nw} z+SsGG8JUj7(LcxykFR?X*pQI$CX%ZOA=`UEL&#yGexqUB>^lJ^^2*F)KQT~B(=82( zK8jtsR8WhFiyl&=`*RZ#<w|~j^cL6Q*yuJ!0*w(WADp<$cZx~^*fP^%n(EJ}tVr99 z&4gx4zwYk!U~5obUG)nu1(QDf-n^{Y;CFDM*b&JW!%t=<vn)+!EovLEbs>tlRK_D! zxZS!qp|u*{EI9OH4>FPfK_s&d5(2(E%aBvo)QT>k%6d^VSORI6peOI$J4Zyx^5<0z z=8$d`vL&k8YUnUMEhz7*wx9IU{DUz8c8}rkH8AZluJ%Q{;JxYCS5v?3)>u}JebDjE z_CqS1Sy+I#5~Y+bd`Ihos}sa^jGAK);49d*b{^ga`kW7mSrx}8TN#(01C1cy(njA^ z^0BIXZ&Utv#j20EX-!?bkoLZeUdyr!8y_cCiZZ`3biBd?)%YrcJs!b0nn;42Hdr@I zN3&$awxv}H1EN*TAB{HQZw1{@<vG+EQPmb&EK{Oxe+eSz1+|UcdXUT^s7KLo;`AmT zs<tAYj1E&J6$4doo^-wA7>cBjNpY6-i6wxcUq!>)Gd#@bAISQj#joW)n<QFaTPr|i z!n1y$4_o;teic$(A>@9|GeN1+VR<Xc4j*`NnJ>{=qwPtfZKKo}&OR?W{bB=~`}4f1 zYi)rhv2G&lOCYP;)@1+rAjeaLC68cHlQ2C~2K|Ex@%6i{Gc^l-t{>SgEgHKE;_cBe zoK?4(-bE`2k(jzuNjZRE4uY5)MzYq07AN_Gk8`%-JMn4nQ3dX4m-=e?`ttyj3Qo%k zAE`&G1g|1imiP~NihDaZB>JI3k=g;@cc_^(0}T1@T$!c+3^H#@puse8{%VC2P7Sa- zLChP4O6%Q2bheAOG)jjbmTw*K9vWD>d5lHTMY>d67Pl}U=0&jcjt6m~*~|(pROTcX zf>;-#nCz-aOor?^f_P9c@x``xbCqYK|KX*qJ|@@*Oz79QSbFri$ink+21Sxr=Q=%I zrVo`w;GbDn3SBL08q$?@=7ujGi&P02(Wpc6MYVM%lA`-zb93!xmdwN-ZurqBL`pII z#^0>DxZfx@P!yMVhs%F2129^#Rxx^BuQ%q(7vA?<5b1UTHnQQ0Uq?-eaUL>Bcftuc zL!U<r_AoS`lg^n@Wa1wCcKy=(E|TdIuNKIdE4ldVp*-#Jrf7v)9rq?l?NI-=yH&|q zv;os|Uv$*`BYHI!W0f!)jtd(o#uzuL*#j?Ld>2fUr1B5d_n+k7hmTU=sRXU@avEy8 z;Pi-8R7RZ18zkpk7N%lg?)HK~u6;wm#L4Ihz5ht-P_@6@X~12`%mn?-_@98L{D)jp z1G47K%Z{uLH${*kD*IFG_|qvA`fIfp9l-pk#~I}rmZQl)om+ke?_5ap9PV?ApZI3& z*vwNlZ*ukZb;NSQMCil96N7%w^?*|Y#ZD^=$#W@Dw8}Xa%VKP<8|0-;;kQJ&<{q-e zuZpT*tz^obC9ELwf`-h^bjQ94yc0k6n)aoVV52TQW&Y#5X1%9<TO#>SP>8obExm1Q zf=X*<wEu`0F3&-&{}L4Yjo+FzZ#-)0JJI|3wdND5cazlqCKP{zS+2n2*gYdF?VKa| z7+SU&4pI*f*#^6Nx{PRx##o7Ji4fcA@Ak;5NsYFsBK)_4EY4Ndgtx|_AC8*`ah>j+ zB6B%UKKQV1+V9gXRBuLS^PINavz#cMHZ^e4-5NiNuhk?mxEW`>wSGp0!L;7aeK{{i z&<7hXXd;+9gMosx%-Ol8p&$V<KHizA(tfQywCP4H0(vH02z%%@=?Jdi8&6VQb2Nw_ zV0r85A|#gtp?N<Cc3>JHZNIct`*rbtvQ6&nc4>G}9X^RTB{oe1oI1nNRt69XA&~qq zrE;B@U_7+MdKzhujHW|7-Wy7Q3UfYNepU_`7cy!o@$6__PSEy1@22|tq4?HV{=u)G z{}Y~!{uysV&-dikX_}B+t^fL3e0zN6k#~i_&D>V4xoFUJ?Ctik;^FS&kfwm{qJAJH zME~yf=ZJ9Nd<R?G4B#a!b{t^{Fzpu!`n+x#bIJUdpi!bpZgN_axc6M2owBP6(*ZE8 z_l}H$;->XD&L>0_`<0m6(*vk#<xoW+9%x~_Wes73;X!36Pw*OcYyp31QtLWqPb(Qx z%sx{cIjMcTM-prLD{b=I<_l+Llvg+9PvY9DXz+?8fV8Bc9gE-!m|dbQ?Y9yP_4K?c zHrKB*`yO;?e~Kx+7k6Dasq$WrhO9Mvcst~oNbrYFSs~tPQL-kUN4%TIulkIIaN3%p zblJ^~xt5N0g}oB^ZcyM_I)lb&v!R$I%^5xpYtwLJR@?AOk%ZV-Ev6>?-eW=8L!i?U z32o<O@G8=+`qU*~O5#JM)?#~!o)ZD4A4o$kL0$Hndi?#Wp0}DgT{gUIgttz)S(U_D zfOXN3(PjP?mhB+h#*eT?HYq8Lvn<`Vdr-Zr@WXM?+vCKf4>M<!?_aBszMo88>EyO8 zQBhe}@~g=|<%xXe!Pc%|3cRXcH(py%{^IdPQ==`f8nW(sl?>ZKZ1MSv{>p<}=?y8} zGy(S_B5HG%(Vxm6evceNoZ?e?lUgLT*O<zG@r`-=tSpIaP?vM`lrT`P>5*g6X8#Ub zXO1mrih65xWFBV{o(?q5F=%oI9l>g_J{xR@bJ^*c*5E^;1#R2LnF`i1r+c(o0&&$z z^zw+d*LveFiw%a?_avSHg~{7_6cw9mkJE_H<?+v}YAXVBdrq(iqEE4cV1F$;vTM_Y z;k|{DbI-g|+9dffEKJ4$;pTq%d9d%#(wv^JpVyfHJW|%XIGj^{p`J8r4@!lHmdzLR zfXGS4`UMt#`S`Ea(f4X-g$76}_TR!hr02e1oH>nWIR^!3vc3Xd_AuP&TR@g56SL9Y z^;=Vi)QL&1r;V$=o`H?cJPjGcc^5Gz(}Lv#9ry+0L2P$p6xkm1!Pa4#*g5@^iFq}z zrq9h<CX+o{PJUxPB_b_h@W|lFwFC6SA5d9QD(_Iij|n3<X~_}QYc=V>Rd9HRjqXd$ z=<J%)Z4fBDBh-E_rNq`N699A?-}l&ZXX8uzkvT9iLPNfSN{FqT%o!Y-?lLX7=raD< zNNJz)RzQsLt5>@>?$r+n2ccH`ogY*YzhT^EI}B+VNlFpH2N^sf9@bWHCDXO3t+h2? zpugi{Y8H_P!CBARF!I-g4uR{Qmb69ud*X=FVC`F@m+n?(a(l$=`&eoSffM;`Rw9&A zqo}{5(uKg?eMq<?Xe+u7D)H|3r76ol2=sq7LvOyMH1`{Mz1xoCD9XC^HZttU?Z_aI zx_oKPD0c7NNnty0U^BxN?_KMU8-@3w$o)6=#F25}^3b#^%h_z!Q%~mZqHQuB?%vgf z^Kf6Y0KutC*@W)WqP=Y`y2z_Q3xaqy4K904%%Q76;0DK>OLvd11$#B-GTnz(QHj2D zL<^jtkG1`N#n{;Y2>R2wfW?FO_%tMKd0Ns=%6-SRY}MtgFX>454}-ekIEg=GDyfco zptIonFo~4ihs3%hda?bHs|)s%7m?hnrnZ7^ltwVZT$Ag2Uz26J_@Uh6Kc-KD8k$FY za+pK3ftz}MhvWfqO)hRG+l8Cb`6Xo?tCKpB2(81zWGzb=C9ZxZXx^axFM%LwUR`-z z;7^vd$>{j9VnJ<n#uP<A)XuuMqo&ck`7>0-ezq4ssnW$YxqQGXHTYZepsCX??TG<s z&_jtlDnz&5qi6^P5Pq=DVuNmBMQhfVxkt~Ko1lZCRjv!hx@wQSMn4E=gav@zTvg=4 z;}`lb5;JQh>D4!>W|HSw2wRjYn!)@!S>g=tJ6pgBKT|EVmOJ^d?N@UKWMHWTO1C8` zm4{~Y50kKMo9txwpxK(yEdzc<-)|lr(4)izuAeboEtT81XgfMq#2$wptwnOJ?I%V5 zD|isNf>}*6ye>bJz0VI{7vQ@w0&ZOQQ#-q{c`5hnFnBm<EjHJU)tHx%cZqo3DL&8e zh-(d=q}w*o3V_;Z2#p*@+dh{vw6cO1)p%*5T)O@e6#2r<Rq!$GBSF8<sAgulQ;Ybj z*#yRY_<%9%?*RSHbm$I1%7uC6QoMGsYQy)Bt5(tojux1o+ElNX=Y-Hj7?Hy|lM55z z3m-bm!P5mm2z{U%y#JRVa}g@uXqAw7YYi*HOkt{Psc&m*oH=`N0IN-F7S1N)%lh?} zMIu^)1=ClFK$$<0dHcYXR<S@SL|>15YwMK_#}hKZK!KeKE*|#+Zs!tLUFEtqo71Go z@AZ*^prmk_-D@d}XJYqUYFhd|_Anm39bZ)Tg6F3&^oQxp#Pn`yAAw6HJ~E2Paco&l z<L<*<$?&ghc8bIyJAhR4hgI_vvQty(vo9PcvZj=N4+VX;@Gp)QE8~xh8>plR5$_#N z&y#hS++u5W|DN?`O2^3<f-M8`7IbqOh);DU24$ghJzM*m?zJ70v&}rS$SCH=b60YY zk<xV!TRno{MIUU`=DM^5zNAnV3^|}b>u(`B87_EIKVrP7_B>*Ad{)`!wz$yIe*=iM zoPrwjcEde53fI0S@`H3g`q1Ro{NQ2D6=AK~cm(jCev&&<Bi#Fqr<LvMT73@E2i&6N znLDSU%l5!dsM2Lo<y1Cw=P`%87K_jZVZm95*X5&|KNIJc|Ca!&i6cv^ea2^~@|Cdn zi_)9`i#iQGe+~REfyV02>A(z6WGKuj&`2mB(E*YS-B~)dC1S?x_4MfeRMM;mt6V(s zTvJFDC8sS<Ypb|zW#@l66!HgI^WKXQ(+%^Ch>Sy_I$Vsk?l`hc9l<s|SRBc}`Ytqm zKE>t=?TR#+rV8-E3uhWMhtOc#?}JU~G~B~xG5H5;wT?A6yDu9uptz^}R$C5Jy%-<Z zy}=x=C@|9hK=)HToqxzkc?7%qA0o9}hNW3YN_1pAM@jrAS>~~P;}1AcSk>uejuVld z3jq{l?ia1cchTM8#6pV)xbSsi2}tVW$Et_CK=4_|^0(ZCg9@4KD33w?`;URQT|Pv* zdaGJ#frrT`C+pH;RUA;+LnT?ZTwMA~?%PA6wuA;z$8$5PvV^0?X7CcIv!v+|TlCu4 zP0*o<3DddM{ZZ;mQFxW-W@o{Zz|K>VvIZVr%+L=1=kWe#GoM`Tdwrf{KwLCRC}ITx z#cZo3L{OiD$MiU9eFVjE+)$`ot=jbWg`CO2RY%sgo-dI)At(NOHw9sV=DYa)#?~A2 zHD0-5>T#UIjwZ-phkVUhuirK+2p>mFD9F#G=g&5U0!^&P<GL($SbD_bBMZOMK5oze z`7fuxmLbAh>tnphM~%#zWQyH%pIynv4*fC911eHkH4z`fPSYMZQcN-+)P0sjMOR+L zgAI$$W$xJ4oMv!dlfUamq*9i|^r2P;6b{dH<z?(9HpBO$7?Y?Ps~^OFyM<wGE|PeC zdM?9gn2>uld=+xQ8_&%eliL1EK)b2S!8;ZL#lK(mR<{>Hj`>tLwu;q-OQ8_TaNI$P zMTnUqrJnMwC^}2srA5_>6(*n}2<+EZpMAD@#2cCmlJpF-Tm$Ye`b2IB)Y_5lm;Tz0 zE}9@^Cq?tlj-(zN|16?4C_757P2klK0l^C`ocrGNRg;N(fU<;kWrCw@vzG+<B<kWE zv2OyW*U0Z-&~fj&>!#>QSvL-~v968bF??D;{XFRPE3|%l2j+ZJ`0if<>U8-CV$(N= z_g+KBHhBMAV_Tb$uJ3_ZYheArw#bZ?tz}vvbN$}ee5(W8u6<jjWKiDt(YH>vFGv@* z8Q~|8cg!#ByXk{9<T551)B<36!l#MV<hpbnSOdZAuuZmi)g(>$anjU!*hGdVSICi7 z5ZDxmmH#-owj#8GyQ6D)CI8I&DPII5m0T%-y)fZayQa>lPVQIbbCgF(Jj?#~>R)vE z+u|6~oVkwvZ2p*AcjGL^Psjt6C^U_qS$6(x@^!~RdAI-cw%qvS(ibgQnn$jPh%(T6 zvKK#|h=Im=JMsI!EcL_O#L~UDfR2}$n;9t3E1Ig_HM}n#|76_vr)8AZk)g<X^CQ-F zs}dy>Q}(|7xA@Cf*;--h;T6934+$|f`zdY%9*m}H@dqNxA|*65<f3ni4tcC9?^B3f zxJM@F=g9FHC@-fIqjX9g?~|hq-&=p7_PlFtl%15(cXOh!We%~_ffR=VyyWA1s=Ks# z>#tr@)5?-v=^m!x2|(pQDl+CaAygeKu;9=o-Su5s4Itl;mTgG1!=+yf!8YFV2SnBJ zuBX3@Nr*q_li1!(sY%uDDa@uztKB0^uxB8c@~_8|Y=LOf$0H$U<l{z`klwK=<}p%( z_*k7^Ft(O`{C>?Nu9r>^r>rUqrWjT@mT~^&z{rj#2QKSqJTt7lQ=KSp%#?U~eA}Bh z?ZpWzsq9)!AFTR?VwlJ>jkeo1@HnsrM^DuhX~uWjSoP(d{*vq%2R2Aaku=*1`T2;> zJ$XwN7!Z&sAcnmNhOxHRJgNJ=R{d+1FLpv<B`vymwBPYGNe5E`m`r=`m0LEu1Q00^ z64lS(X6UGG`EKYAU&D_M@pf|{-akUmyZx*-c<=QY=l*r5a%0%NkK#*~C%Y@kSpphj z)bw!s6I2$s5{bjb{HhH8OOU&r6L{)3cB%-Rd(zCh=nvKNQvXYU6<nD~)Y{RWC7jTm zP&mS9mvh^~+bIZP$hmEWbHzG*YAU@eQ^WmozH1@6N<oc!w@{t-MVz!y^winFE+yx( zHuRaMZE<c;{X*>Y*YPRXcvhda^<MwkKyu2QNM(}`=S{Y)+?Gin1*&pDpT)A8)cTaj z5SFDoaWSFfwT<lZ>sw|;(56A0sD%}Y&zajyQu<)$yQ`-6v%f6FcH{a)<guILBX6k^ z{(UL`5<GO)wI=!goz6+oq9mN1V*0$YWti7NS?_1mT>kL&nfddd4iAC#lys(_XWP8V zJ6qD=;29s?FZnn+$ED*>RaaajJ?m=o;OhK!CJj4c1?G1YTh17jAgMDYn|Mbyur7se zE%Dl`fj@|lBjceKw=CWfdv8W#<;L*-=5O5#+FLlixJNJUNyiLym6MKge~3@Ny0Oqf z37$>ue99*;d1m|5D5A)lm(jsF{EtQJw5kU^C^~Mtv=3`V+ewM`T0^&z4>}~%A#qPG zisqZ0$gRxPvc~$*vTSdDhY2}jPnD)#r@U1?huFDT%oIa5AKZfdfC19PZYw1Rd~kaQ z;#O5B6%Fc_*+<Kt71VB0yoNTg-gYM;x1_uVdHv`(1!1p+K&jcjrVHDA%Umsp4A7kE z%kD+LB>fr-#uUBo(;I|r9EfV}Pz~(RS0(lK9kK}SoEI0i+5Eo7|5VSpVGEvQ@hrM> z#+giQTc$jEAzxsYB+uMT7i{>$eTc847A94*E~I{Rm`*OheNX|m(pO&?GGLW{Q~l{j zmuhhn>(Ro=g?ajNH4T>rAXo^P?X~tl+K>ONa*{>p=~7dy<sB&l*G?X}Fgs}s(!+w? z<9ZN#8nbV7iZJtHR9|jTOGBA+yd(qxZfLLE-OD<Y;kF#&tIBQP<C3EHZ)#tpVf{-W zo@C4Smw*zzK4!G$X33MP->l#s&1$kwT{(zliFer=J0wrGk9gfyCz0+xgo*B_*43v8 z+vZ_wDu*8;M!ywp7@;37aHvhj9W5SH<+q=fO)IoKb;W~&b`#PYMe37YWZ$C)H(ZnV ztm~QI9ny)b1b?04obqHIe4X~`BNDGt<(PJ*aSuAkE+unC?WOd`&M-2y!xO^x=|_Zc zVcOwczo!rMHQ{2+dgqD1Me(6K7%hv4Cesq0YTO;15;~q%Ix#K8l4oLko9Jcc<;7)A zbg{YXeU~foojv(R%1PZq`B?>7w(B6&H(V;Lg|~Dny5jz}hmUptKHQr+ao~pgAaokp z@7H5gT(Kb}AWfV+C7(8$)GnLyx6-+1p`n=?zNg2CBvjk|1FBr&U5fIckm@h=TGKR^ z@HT@+Fvf!AR%iQHhZs_fbgK%}e1W)wdqxIc&Qp{{1L-FSC${+*1BMjVXRi+MTklVi z8w_tnS-@zRbkvO^`u{)w$%$LoTUu53HGYz2`|&}bt^}DJ6r$&M^@QX`0Y8?;rD!0{ zeSvZhS;*A~>ntWon<C9Mvb>vYZvhqGP9+|1PDN^qA`DdOX4`wS;?xp(p6mUz@UN(u zlb-qU$JO|kC{=muVa0%rhV%{Z-BZ0Dd0+-K-feNX_R1AWr-}`-Oo67S;imvhfmEg2 zc8`SwxRgm%A8&%6;;kN@@2d7Wqu>S8$jO3rnj|W@x)+>Mk_<^w-wEB1(oF->Mg5M_ zA>w%1t5}#I#Y|vorZb|{20}0R)nSsoBfn$bHnR|@gAu=~P`B@U&+|g#a#MZ#dpSb; z_r!o{Th%KVffWjJF0SCtL4iR9tAg4uK9?IX>6~jlH)PrSOOTI?>T>tAY|}=u+td40 zs*O8Xf2ax4ObG5XE}SjnY1W`+YBaQdHLX%ky1<}Ex)XouR=FuA_!ciRUw<@?e}NX_ zgxnc^1xblhLoL;et#BNA%w%ZSnYWsC%i>bg$C4`Ln@%!_8;i$L(^PzF+UBC)69uWN zO|?GNKI}dqC*tq7RBuAx?$~sX6+!z@$UhyUtKbKgA0{Ffx;pPaD33TBQekh%|7b4k z2toJ{EVC6G9sGDe>-t~1t7Cr>z~E!I1_Hz<fO(d<UMR6Pz!T<Q#`-uvJ;L5ckJJF= zctv4u5|!QCZQ-6$R2FC%SFrgBGFM---$2C9K78%}Z75+m7jno{!3H=2ED5T$TUHE@ z&-688R;rpWUl!c;`Fwt&M+)k4Swe5Dv@nTLQUFLULbh0z@Avf?M>nTdlZ;`%7HhAp z#A(tyyPi^Y{jLjYuM@P<|9y6u=dCYHKpEY$lGlT|)4r@PKvFVw98XDWBX#7>+i#-J zRdx7`QUF9=Ci*}|n4L2yn=qfK{In19qwY<$ND)fYaNCplvuD_shJ>^vsWHQo7`WnO zjsI_bm-=m2tdOSsLMbg&&%ODAjM<j1u-$P7!C=|{p<!Dy-$Ln9JiOy<+h*kJG%$8z zA1PfnruI~F;^=UiCT^>{_e)BdV93LC&c};IvLp0mieWFkizVk;<+->Q5!bQ9MgD7) zy1Rh{?n9Enm*YQ_RvjH<eq?4qBW3p+w18zdQUUGM+7(#Jyncz-o#OCLDZ_eH%8?~x zcqqwJ?2sC3S8xx%#^%GqTgCyZcLp+n2m4F=9@Muk<)dF6Cr&yfS8%dN=)!J_NJ3n? z=1&~kHV25?$ZKR^N+B~cDmC-H0U!<YrCSasV^&aE?Q1;sK&S5;;$_nZwixL~P-^>N zxpfuAVv_KGX^2_aL!_!FoQaWJEvX}=mGWda5qwIa#(+%u+!7s#pH7?o`H|qERekob z-=x#8b))jfwc#2U2Lg6&pfk&8q-AH}W_MM&k~wK*9K3o(AokwO^&4GxN7Nx2zgU`8 z43G)4<%<r3etE7k|I*TGvTLh}pyp*>^PcwjbWj3*26gG@F8eqv{n@X<Bz3ziwLE%3 zo65~WMCWH#qqFxT8Qe}={XW^{`VKhE<HAliiPdG8SZmEV;c&>=2KAEWA1z7bdsUbF zM=(XZBb)I#{3bbu*`5FT@V&mo*fMF!m@Q;kgGk+dhvqS<?Bk-lFU8?greFK-->Bag zo~Gw@=e5bY3@>_Ruo?rL!@<|5dJngmOKZjz`cw3RMz-%1d~FHJK`&WPodJn+<12EC zNyh<<moG1@mRC)=1d%V$n&s!&Cxt1A*;@@S=f2ArigX(nB4Txrg_ZCqF?=!@$3~W? zyz<T2y?>CoW86Ru8Oo+MSyJhuoi!GV??Q4PlqmSQ2KNhAzp$&FtNHoIaDPC(;qj~} zRqts4#Uq2LASXCgthZZT5HD8jpyJ&5s**W$75o)JS#5V8VgtS0pxM?f+^=HorY4lu z-87RQ3jlYaKW*s;MwYC&C?*L2g6}$ipK_kdwl$5{{?$RiOrsgaE_i3pvpJ2)bf>>P zuU<*zAK3PPT4HYEV5_7!{y1j_Zzr>6Mlt1T&DnmA8hvke;+G~BO_0Y;=EFBPKcVJj znDL|*MyO&*jc(M2(nYy@$X5q9eVtq8{+tY0LinII-Jp@sq0yS70KS2~OQZO?aFEsu zA|%oEPF|Xb_`LHa0>c4vlf9x&g#-CDM{E{tv2_miwdq1X)*GaYj(K*00;B9$x59>J z2fSGOh64IuJ?n&J%%>=8gGaCJ>iyqll&0Ju-8m9wB}G%{aE8S2;7dgoR6#a)2$KUE za&%KuMNf;Fl+aS}^5k2>S;QAEFFWcKoAFf(UJEKcD=pB;47bLTd5LeNZ-8WOQ^b%h z8?kWWZ73CxHEz-_jfz6-j@3|q|4bUTva>c_RmiSi5p`hQzqF1Ln|Gh&aypD&I<s(J zMi%th3YI;c{hSuEb*d!Rr(PY4>WA9UdEl>N&3S%GvKq5;s$S%Kt@DGA$J%#D@;ihc zWG^~~IxR{xeGd_OnLnhrJ^=-r8-tyMHG)%f($h?ZE0PY>X-^pi4wKnoVIOt1zZ$}b zZ>CdCaH;GRsvAbKQ({8lXZ?-+okoR0WgCb>x*yW7x%V^LdK*$0hZJ2@a4@c+oR+4( zVQbNZKTI~${o6TqdP9XjTFZMm78qLU{f_qzVWs83OzPpib%o`Q_p@ZOWO<)95e<D~ zXor8e?*F3)M%+xeiH>rvvf`!MBZ5kf6hqOIgAOAt!we^Kba#U{j)?Zt2-nb}DJjpr zl7!{m>efIEU=-+6L=$7UF`LuX-A54(N~42ZPdf+`+V&H#vz_ns5r(K{CbYhmP8QI9 zyPW81Q&0(u#7tFCekt{x2SfPr%bA-#4@|jtO3V5ZbA`jxW7npp1#E}qYP<?&lJ4|0 z)=zvjRTgrB${M8O(LE=G<(ZvQ{)6Xtj|!_B*dMybkV+gJ#dEub?-?RqFCb|tf()+A zJ%r#@I+7FhF`S3#4mgYI8R?Loa{$O-@ZC~a^~34oC9mH^4u>!jpxDx^&g`7cCP|y? zZ&VG%?b&9%ZRfq_gBhG$Y=)uGkLL?ClhDVZW$<h}bN``V$+SejubtBGng_l&cGTif z8>Wj91kWYipSaD=`D<=|9q|zL#S`vSq1Obn^-Y7MkDLbgbggb^*op~);`#>Q2W*8_ zCy6o6QuXfrx6GvY72`ElQ;67JU+JBsZT5mz_t9E<6m?0@AhsE^A5Cyz)bl%ra4o_( z62F0b!p?WSxtJ0^T_;|y-;lY1EKbU>6fL_rdXMrL1Ym!D>QriMoW9;v({hIsl929# zk;TZSI&A9hxR|XMdQfh?HcedWw9Ki#@B@P7X5C+z(Y&H{S4vRPeMHw>e<{F%G$k*j z`=#3G(cZEm`0q{9BVf-8I5r`Q(^X0RuC`K@E5Kua#)?hnc<t&y=@d!&aJG^0U}o<a z6+tka9jv}c0C^jca1qb5K(MX3aJg#P)@k-8?7KX4^6N+jeNYo~B2t4nJ|!#7s;vmO zXPGxS0rX)2dOdvxYwmck{zcyHl7Tj8v*HO_>bJ&1zRsz5;c*g4d)+5~(xrErUR5za zRSWf7eJbSoJwE=}Rs}angSu2o5!oqhL!ZlqNlZu1+W(9h-7SWFJL0Sx?02SoqrowP zAwtunI#I8Hc<&UUs-JkB`E|^66&1&%f0`xk@!a?#(EAa;V6*;Ucl*o^4eG9W&RC2* z!<JdqAW|`$McZIq%;xv%`G`CplJ?b#R&$-WLz^J<QCex2S)sMgSsyQj#8zD@UN^)T zSs=4rG?q5`rBI79reU4UN4>A=3+Tu0udemix?asUH9~6C$6|)qUYoD7U&q|j17c7o zPCQw~mI7Oa>j(4f&3)l1QcB>h^Z9DgCuTNDkMy3a$FtGi*_q6TX83c!^*ao&vE(gW ztfQAjIPUMOw>R{`ggtUDtMu7C6^Fj_LT9MZCH0^YBazD->(BZs`g7mNq!Bq=M~FL) z0I`hs3YG1+!p7nm-NdvzDmPc4kYGyH#v3Awo|p0DeOlGZ`gBKfrxGrQm9t3h`)>ET zP@3Iy#pCkx(!UY`<YO1)dM>7GZV7y18Xv?KGg42y?{aC3Vh;G`-Y;Iq1f14yg$dH{ zmQP((6c#hkFV=@<jQ`qG?GkwMYk;X}F-u<~jjn{<KvUD0z)Jwp<)ZM8u5W2YuLp#g zs%)@FSt&n^0ce)&8IZIg1>dbgu4|yZR}r0c^7|JH#X;F1q`eaN&k>~kfvL??&Rm5V z1PjlM@N@l^)M^LE5ec;(j4&}ipr`2g1Mab9pH;t{_80mfbTbo$XTz1UyFw1Q=$LN~ zFFJofbiw5FqK7gn=|kC9{1*Wyb~PhLqY^l$3+-)b@0ep^*dNZRxlpZeU-x3t)av}( zGcHrV3%C07F}<_xPs=eCNO{)EZ|bh99=1+hOVwH;q>tIaRYo39*Vq^J(7HvRaxKn* zb^wgSvTV4bOhTDcX^!jSjVx_xX+EboQL7uF?+b}zEw^}FRMM&S#EU7OF=AR~&mPYI z;la@kAWaZrYNYMuLSrLz*RzE+JjMS^1N>>!-J|vU=uw=b{d2mX)FVsCpe*AGGnb<F zLgf~Td1*&)(FdAux+->^O9Y~Tf%eU#T%QT(Bnk5^x(&5xeP<!;n{Ty_%$Qr0iQ-7z z{F=!4<stF7ch&i3#a=GA2%YLz*Z52bhn+d{7kESW=NeL64vHe`T@IoV_x80zi&+3v z^@O~qSw2*Rz8~tT&rsa|iZ}q~WnjL#GjS@3hpo37&xfq1oU?E|?{}pC5-g~jMhEC2 zf5e~mZC<^Q9(E|X%RaDA)9+5TzfX-k*Z8$^Qb)k#C_<U)vQab}YfwaEe2}WiGn1M- z@m$%vl4H25<>7&-0=~w^E~omNy(||PdG-xLe)C%#PXik&f{TTUNdnNfn=^Y;ljlL9 zo}clACUP8#8kE_rNv?R1BB*ACNeOOdxKhcwb&7)PPN*<<m;gV?)4@=m0!WoIu_$wg z(%UCr@^Tp@TDpu!+MU={^uZj;&Ufnv9<so3Gl8EPSF)HfmjWMfALSpbC|X8vPy<t7 z@IPmGE=JSB*MV(merP*m?LPv5UewG<!b)M>HP@3t_gMP)bojv3F4)eDZf3h2HvT>H zivk@(q$I@w++U{|%R=jV{TS6GM;D58+NNugNj-bC<SWai3lZJ{fGW9+U~H(+jV#-S zw-2zqZcgC9J^g`z`^qBu5zFyo%5ZB9en-T6<8dFqI+!VBHV#_%h(A&w>LBh=j~o7V zJPOjnz&?HCp_yj0W(3Tn^AT)Ku6|kES0)sHh!uQV+W0$C%tKZ~8Ac+5g|jTm*8Hf| zBz>%9IWjf9Cu|zft#1u^oZZ}Hrn^C-6>bqJ<~rs>HN?QoNClmvp#5>2l!QL5W_SEP zhSK<Vps_95yQ78PSbrQtKtZA^OD&v!`Df1erHAKdQ8eH!=bx^5t<Jh8m8#FDbDXTI zbQiKf+lGwicizORrFdI^KCK4!n3@`VBKc!t@&o)yM+7$cS!v=xtG*|fd_T>iqZLqU z3e?AqSBD-&P0XNKb_wRT7f3$YOhFDFWxv6%3}-9!fQ*Qsiio@h8TTGwj*X61wm=YH zYGXmkf-ZW)iY(_<aU4+{88VSQj&Nme$jD_)q$Rbzf1tmu$MWFG_th@l-^=uJ%AAOp zk*V0-sz&?GszW$S>HPimO%sE_)s+Jzp&WpwWY-Ng;ctbR27LIO-{JPdr0D4Wb^TS0 zmLNp=N8i1SganBeXw~uKu;-AOLjCMfZ~1PzFl+3Gq2>3}hu9XuA19M`jhCGvSCvSy zsdyf1M=tK^^q;;3b%lI>C`6gSKFwu*({i0n$and?sp?~|JC~JV4uobNP9DFbJkC+F zUg=zH?}q`m(pvfBJ;0uP)C?ZeaV74wbyj!1jT>XtQ(JPJMK^mwu~pv$*dqN;E%>4^ z1hmes3y@AphHuy^;b|U?YKiSs-(u{nkN=P4VW-<Kuhy@7M>|W>tvEF3ogbv6J`;i| zDN9>u{KG!|=MWKd)n5Xp<7dl)nX1)?>ijS4^vpN=`Ug-&mPbcv0opy;+i(LcE&ZSu zPNV!>Z`^we!{|EDLHY}-+h<iEP;LN`h{UA-GFw|U)*=nJDOjnA8}jUjl|4+<*1DJv zs{xTMw04!$;I*i%Xd;LYN&(2)ow1zJ?&V`{w3H}|D7?_kQ3ueazKF0G+H94=R^-|7 zc!2VWsQ(ZjscU)H-zo=s-S%yF;ie5d2f+g%*7Q^(G6$1Bso`_+@6YwVm5B6A@r3vO z(2O}!lDc6Nw0k*=iR8e^WnN=9ysYDM1`Ra_R@~MpD1Mb&y6D_>%%Uz3{UbrGUt72O zBK%wl;`OF$eum?NGQw;!I>qe_gZ84R(wIPZV~#RnZuAt*oH4mS9qR}#@BAuhrdr<= z-x_PI`EE9fip2_2&&~Z2@FE3<J2{eE(EA@#Y3l>oDkAJ3+{t(?Dmn=68K|XD=^a+U z{MKz+6CxDj^1-2{y7^*Y<)_;{2fW0AL<Us7vIy^%2Xsc=9&d#9K4UkSBFIgj7m(fA zjJY_4{8*|l5E*^B?Rg?NBg>HDl==_m`_C~Ckj=FsUB|LuSGCId>t?&uD~2t0b9s?> zc8&RnHNolS@8=@F+jpawd0o_KEfbD7%*D7Y@h*%?q+TD#*X<vn75P$KXhWMfh<fT{ zG<k}4N=QpIOHS3unSNPO5P1ABHTb2b*Vq?Y<OQx`M4funJ)VA<U3Sm4I=@?dyt(Bm z`{UO2?=Q}iX6ZxTq)XrbokmND{`PRI$!pMrXt!z`=H^bbPM6c?EbtycaMr7<0l=vK zhG4e@uO629zS0%LlciO$)_Mm-2Vw0{X6uVe<b`_4i5ClwRGyMtS;SUNF3dS?J*dvi zk8j#Q%?2$jspY4+;JN2VIRYx1i>#R^G}DCzr%KP0T<3P7kHd6RUSCJaO%LRaUQb6S zg*oh;1RHwLYrpjLxSN@M3*iPyz*WrBzp7JREb#_P9}hWvFD|1yo+=uCK$qt1<BYo= zMq8p>v*TD#?uB*hfpvMyaq7pgmxpBYUJAhseeOqTliR=5i8q>hc&6NA2#$_Yb@iLP z$#-0lCKS|fWJHamCEg4+&Ie~ava8$K64ViAQ2i&}{%<3$n@<3Na&4~gl$Z$w|FHG9 z=mn3NneB-?##)&t?4=p0<wrCR2{=qduM{7MX0NJEcIUO8@LifyE|W@Z;S!>bro3C? zuu5hC__?rt5HLS09(+JDI`AdE;prc>z6ufU&GxF~N!@&&+5Q`%-^IKeL5Dg61$Xyw zuZ8l6@a7eD5RM-{m>K4|clp71)x@%3VtkkYsJE7^inKKWdGr&`?x&UNat^Q33i&n3 zmI}ka^#7^J<PD8oc4Bzi>`%^-@{*YaVi1;<;DNlmRi230Niv+a@ts@}5b(BR;N^9S zF0s~~g(Z?})8^g%(b<dSkYXAPl{RQ!15O%`W=OZ_Z=DEXRd)?5w%vC-d+(jgylKMI zKd{;=Ok4#RXNZ%QMy7VHXgyMK(1~)t(d^wh)Of_w5HqZNa?j$cZiH^~_}lj^D`jWb zTbNTRgDctMjoalniiN55J4ZJZO}IVYv|xOx1Z-xn<U4n8_UI4XI*0D-%~0TAYb!%b zVvGg}|L+8%GTPNvrU5U#qcs3CyF9Oz^io}4CcvXNP`_K1bKDhMM@ArI+!A*-chU6r z<rDXwe^(#k{}QZTgsj;2Uv{D1a>58cG#qAR(y_KzYJ%sUWxRnZtk_a$;Bi{mWto7b z1fJd0P{_#_y4W4JFN)S<DvBW(nw*M{_847yTAB&ns@0?QZ$*w;={gSV>Cdg+d$lNS z%Emqm^B@HB^|kN`)5q0GNeYRep8)7|5duV>1^tmHu~jKw1m!gyf#=~Rg_jk`^pzBM zHq#lN{R&cEf@-R-@JgMML#{&Df#WFheh_TPQCb$2ZbHXEQFUgi^A!xU{mlmGx{wo$ zpLz0da8j?^BFIcW$~(q#;YfrR4r1)E=`LJA&7IDifa|e{xJcsV()h?6r}qx1J2j>P zy48{bg+j_=Z#@NGdr>`eoJM2=46QQi(#k8FQ<ANa_mShbF&`jU4u`<k&q!QedHgwx zjKkFc5gee8wEP}@T4$yep$GTY&x=d_2dp{zSWP>?YoFw~57kC3aWBC-^NS4yugJ%T z*gGAl1Rp)ExYvd3<7undzI}@jMV>tQ&^Mm9oLI%sz{DB5CP+5uBD+p&Li>og6cur0 z4j{z5t$PuX@(Pq}#4pmAqjdF_`hTqb{!b1pJ|?FuDVakeDezjaxQF)uX8MGCN5I=8 zGwdz1A1B)3+o>|>MQ840txM*R*uiNe#Z4Oew(_Bc$4sNQrRcdCgx`eMJL6d;DVnfx zHxWHYsOjUo2#KPyMfMeVRFAKEPft{EqAMJzn)%W~Zq(?-Ltj*hN^>RJ*_8?n3vZlF zWtP`@=;YB|2cy_y6w-b!z7iU&<+qD1&FlQ~+WO(pK-L;Rce!PjJ6Yx#U4<a*bwN=a z!5PL_x9ulwf;~V53DlOG)aa{DK=9IrB$?Szl*2i6t!*>8#6OzleeBe1V*doM_>7~f z$MvzHS4<FGP<`x;!48~!j(}O=9dDF{dvaTWN5H1SFv)QNmC5P~8h}<CCk;FOwXyv1 zIE7Vb@CVt<E5$E<+8$ihBlg?$-X3voEqB{0U`?D94Rk1>4{gQIH+SRf%hzna(H*RF zcI~qFS#EOQmAZHWF#AjJM1VSpSY-EJd>7zat%sM;L02ovozAw&oTxB*rs>D?A+;!u z$Wm`T+8@mFPpu1IOhJ*8R&UU}8V%|qO#&N&DLqpJkUc@d$(p8?Hi$x?n3yu)aYAUs zjHwvNsq`?mwBb8K<)ik(e_7z{V^QYKX()56u3!|YM_<XYF&8UVRI<C9dB|3rjIq{_ zfOBc{F9BLtG%Pa6k{DJF&`fcUx2U0uB1UVgkv7gVBA4T4vZUM(F7X$pp=1p%R(<P0 zv8tpZR4Wsb_~ZnrS{JqwKVVhEy7$4N8)dv#&M-On<pm5jfeaL-8}<AZx1D@l^1XMM zgF>mXBirstS=uBxe6k{a)Oo}7;P9pE-qeRepC?scppgTz)M@1Jqd1BN6<ra{H;N5u zflv$C`goPCv66lS)6cP__97>b=Jl}d6~S8YBuaZ*W4DEBePLS#b=Mk>AqkFu-o9S3 zm-CvWu@SLYmD@AdZ}Da~nb9-9qZ%_|Ri5??KO!{e#6AVCCcxac9kU9YHEn#K$NdtU z>AJzPy0OX&*Cw9LXE^{?rcLs^%Bf`|;(a=oWbS{O1br>l)Y?xombC|{1$$H8Qw$|# zxq3w#hP%|gRk%?R6|dp(eQ`gDPgot|7*!9r7<BPR3SJi+9Kz0IMYS@xqI|*bPg~w= zI6>6lPrPZ7K)AUfgl1<{M%_Ig6Vwl-nkcU;qG2ze8oc$19(oSNmaIiDku_6ZK;~Bd zNu&QqKsN3{s_3#|a_Ud4!B>e(??M$Was}=J#3pxOU2?`c&n*i?s%6s*n6RMDBHbs) zHzS+P%oJ5VJR(7R9;3#-rgMJD5e7#x<d_S=xFTY<VAnmjc<&wgH>r&pbe#DTyVYnt z#7}T=(WDbA&`_Rm1~qjGp)<y~D{XZoFIDWeY^I^kw8La>|9Nm=?>-1#;v86}EVJ94 z9=;}<nbzRGLzcV=Z5Ui&OyUbD9Tdkd<{Ww|VYqGh+_sF#&pcNhcKM@VWm1#TGXt-| zAZ1CZ>h|H9EPPU`q+Rx${3ANOcmT8CGQYO1I6v6~jH#;Jdm&*w&tt6^*@cTi*!0p9 z&0<pS=Xxuys(fgvcp8AS3}AhOz+53g+)$OTauveZRkUG8*{S^d2tuz#F9`c4w+QO@ zxDAlXt07eIpz@niuA9Ole6J42Cps+Q_c%K_UOATG$!VBSJf1pn4@*nZ#X!*Yy$#B< zI!w-u@j?0a>%gE!k;v3@)^&5|P4YtWl{eGXgz*F0x26WGmBYo{Lu^Q)A{{@#mSq~u zagdQ6CX0F`&PtDUS0vZWcwjfLW=#nFG+0+9Dg$7vIhfz(Ja%uLW{d6Llk5DkLDcF3 z2M#JnS}6qB$Y+(Oc%)=Z`ZJ9xKU}e(1@9?EG_v5?5W2q7!dM$TbD%Mj3aU!rA8oXf zYAG>ih)#<Wk?O7R^+Yl&@5d(h&9pb?Z1>Kmxd{0w301df;gcpE-Z5J_3B6K<?7qWu zjMz%Q+m-eSVwr>0XiIvO%qlY%Fx0HDBB(6S0+L8Fw&*OZ$itVL<C;3oDkAc%$E5Db z|4ixda^G_tv^5XVe8(t}7!El|C+E2t<L7L7{fxY_klHOB$Y`kAKd?SFY(FS$YZO-~ zc%PQ0ZZ5mK(y8m+8B!b2lP+H3V0J8f(IwSQbLUXls*X41ho;H*7B0cNQ?v}85$=I+ zoZB;3SK-EmJo9o>Nw3phSR4uUok97NM~iRm?OH?WwUvj_r_!+!(uH{1jV}t!bw5>S z*hrZ!`_rYw5ixx)PBYWOPu{5amsi+5M@R_?kqPqOwRvyUFJV`s#@>_~4-fs2zrq0o zf98ZS-!ylvk2B<RVYXR$%%v?Aj=3lu!l;zPuVZV#)l*pQopcZrok7@zk~9mu!#W+P zIa6j)+>9$io~!+2kdCw9K7@JFDPU%u`i|$|#>aoyh5r<@qz6q?4<u>dvXL8lHDU*> z90zC;_#FS_rk!}=C%gjhur{y*vENU--z~*-WA?{QD{jo8(VO{A4y%Ik3EQ;FPNcIO zyoj*iZQH6*+JJdI!$`L-eXz?~%#aYAPHMx7z+zk86gPSEQtmQH|1^S6OH39J9>6#V zU)}L+@Z?=|&-j*q+ppA$D#+G`=ejpD>CWc(Gi=^yo!QTd@brU(BZ_dV4Tg?cfz8qx zzDpwLN=$?~?j0+JkW682Y3`$bGXtj_K<+3-D_$ieV*OXO_EZ2h%~87W49A6|V|+Sf zmCzJiPslCgx1d-Dlj>H(3YIU_EO^rS4i#$iJ7Ud8b=sZ69j7M^@skN{R#zk~^c1^B z5)|tUHQHOXL6!Nh)w9+dCP*>I>Bgc&49_Lvl4XY(deZ&-UXtG2OIbbBm@B)+d~+Sg z<)v6%y~{270z=nK&m0_%&(3$JLQ^~_R!J~UC$aQIRWa)~P9*qLE_KO<uXdE~|FQSp zQB4N_g7*^=LV(bVbV9E}=p94vy(4JoUFls3RX}>L(h(Gqs({ja73oC;=^)YtRLYC@ z{`Nh4-*eBNvuF3+y?ghUf1cz#lP5FZ`Q%$>zBAK3zUw_uCP6WUy-6$igBxNs{Uj)H ziPwL1p=$x!Z=d{X(rjkfI4DM}xY})M0pk0Zt5<7o{d;g*%BRnlcAM4HwEOT+L)dN` zZmxma|9)H}dX+O`A^m**LmfcGcj_mDf8_d7>Q()9@-|23BYWl1dB?&tTxNmXK$o|h z#~h!IQhw8`)hA~;GbGx+PnhG*>0$2P{V_K<AF^wf6zel5=A^-~ol&$o(2%^u^PMSP zSzN#Ady45fpv8S5vj1w>NHut2to(^IVN!2h9d^$5ffAHPx<O<jiaW1zqj6>ny6PW` zs=5Kra3%fIFLx`^llh(%Ke&Tb&#EP)_n>_Z4mlRj=t}adSZbIpG%+Tp9~4hFzqIRa zetXoEUYFWt9UO5K!0j?%_h@Ax__Ea3^i%%GcT=TF*-wRJE!Y}V1Ql}!U;TRVc(m>2 zwzg#)UVgh!iP1A}DZne1I(_kprv5T(Xjg;jT&FAGD*|U+nid7r6Xtgwryq)|i^@=b z2<i#FUEZH1ZC(IoVa|XcIFb|7#+2%YT|kL4E*o2wr(e!n(>}Z~cn2Pjzg?8hJ#Se# zASx{OR@}s>D*V99rRZ4jp*kxzO$9^l!-obE_hLtA%dZSIaBZac?=_E(3%)Jr(JrH& zn(FWhjFJ0jo)vlk_{g&D#gBj(KGw%qnYF}gd;Fs#|LqL4m`oPxmsr?tj@)#j_Q^D# z2t2Rztb4x_F*d4hU?P~)ci92NsFr+vI<xZfrGR95lwfOI8n;wJ<WfN5kNoyjJ+cB8 zE)3(VLhp395A?K#m=0eT$xn?AMyBxf_6`D~E;Ah`!DiLIE?CLWFWa~s9#7d`_?d_9 zs!TKHZGSW#Q7ArJ@{l|zW4x<oKA-*K+6FJ_7thlhK<%dq!>caC-q3xc_w9Wy?m0ZY zSl_2f#P-3<TS8AN2ahC_jDzy~b(W;-*71y@J=GXxhN)CN_Z8u&Wr4fn!7r;s@}4rB z#&g*mNbH=v01FriRvA8WCz|=0htsDe2Yx9Zs1q8<Q0H<#=*{;<&v`lHst+Z#5YA)L zb9gVE2AiDct@#Lc*#1J3!&7|tv^YQi*Qnp^_I%X0>01&F(q)AAx+!8MO&S^gcqV+H zchIKju4u<~o}=XdD01|Ic3}ixVb0(?&kL)Q7dAxv-EA2x#qY8%iP9Q-Dt_N15!F?z z^bREA7vQ!v<*5zh{A?{(!0oM(>^jTi2MHWl2>bB0uCR#1Tqu`Nx9ZK1$lR~&BVO%S ze{^+b7!2OB#rD4TK!czBPDZFVZC2FWt18vjEX!RDavAe#D)7r4aQ6K7hkCu9dGLt4 zCH&;B^>`aJ<jm3d%Xv|Rg}^aPJMQ9o+M-+Ixq9N5QT+nlaN-W_(w|q}toIXaDK>QP ztZhl$0NisrZ6CWQvj#(W+8S5NTUpyP4mLJ6EOo-;76K)4z%{Ic1&)Hv0lM5K=XVP8 zKG6)w=1Z5}k6m<FNC-iho@V%mH1LOq#Cp7L`UPbL@!1C1r6;Mm20kl~*%8`bd;CQD zt$gOp^Aq}AL+ZR&z#G5YU9^G;c`qb10xI1$b}16o{G=ADsxHO)>t1#EZf-*MH0>K| zzeXfRtIg9E1RLzgUc59PPm$oY5YY9R3)C)&7PlTX7vKnzx-5%(B;k9vjm>+y^`PS3 zqVVKm-tUQCi(q$NnLsg}+s~J;JQUxiZu{H1Sl%xAxlPF)$|P@c+4g@vX$cN-t&G3c zy~yCfYP#sTRPec~%Op8#7m6{nS<(XM`QQ<SJUAh@t*I|a4?57FJPIlZRMZ_FLM@v; z*z0Rb(UX(4_7u&S`Tf0gdtPZ;PHFSu%=hM_I<xe&3hCuKFG|L|q^7rMXhMINmS_IM zTvYzAPw`1=%iM`+@w2p90sv#g*1zi#{};9V?_U4yfBfGq^3R9<mB7CZGyYqG`u~jU z{kMw#^P$gANN#s$_G#x_4{2FW^Srn8c<6S{`TYi6;`#mK1eHH;eyOlr&0LlwUw?n9 z_s^35ze(wL^fmLJmrch7H}n7HypDff|0#igO5mRo_*V(s{Hgo<CKl&?=z(eqHHiIC zW{n(cocSx<Dhy*6albPUsSD~DpbZZ-XI-`TiJFt6elHJb3J@G`Iz?Vs<vX_$y9>Aj z@{F7?NtkVCwmW8p&a@RyWNaG*AxReM@)yJ=yx{!e&)(6(g|-fiXYnW2#LY=kSowC3 z_YuMWDZQX<{gYakot-O0A)TB6*5g18P7H~J8DX~)>g!B+P-75MA9)0ShJ7qjWnkeP zpbI*WC4Pk;NLN}=y0WM1Xt6@L+Dj&wP}##ef0+5^wBv@MGeS&9Nw+&eiadDG(fhDB zMonRAmDKe@W9BzHBoW<3`?9#gs{j8K@%;DI^DlDc7+@doeYj~Sg6^B~p$!!)c!2vW z{M$OlKk^ni>7@U@C;n$FO<X99@xtO(5aob5MH2%n{L#tQ-AH=;L*cm9B(ySdlO!gP zDcT$J%FO8$8x%6d_0lSfF!F*%*+Uh{Lc@_pqnrIip$kXwmDN{n+mEHa5AlFj9KqUF z|6YTp+uOt*u_GV_Dy%A)Uu-s?6Vjv$!!I{d_4t8+;r@R<asC6ji(BOQ0}u>Mx5yE( zf>-_(xhL5FMDAa@Txdg5_fURSCTCz^AOoiVj>s<JL-d&-G}N)UFE}zxX!UKfBN%&C zx`sKuCWx2qi%M;hFuoFHF{v&mA`x;?{)(1F6<HCcPe_)B3Y%e&R(9g}sEPD11yeeX zq!UD!QW?r|;oy!T<XngtScFqC|M@ukuUhmE<Q8G?!>KxoX^SXtwH&zu261axSEJn{ zXm7Qg-hWm6|I)Wm3*uj*XdOI!k<JHvsFLp`_u<b{tRi9~^3K^FZObxLh{9_m>f`kA z)y$<&a6O@nmNav=(y4G(L)NWk9zVImpK>(_TkNDf!8&}yT=<*M^kGJ+W}7Y=^haV! zL5C^#SuU^0X|UjBHYD(fI^wdj7M2?IIw$FoN}G2E-X`t1Y<2fOeU>ZyPa*z)MGizn zAQmnjfMhBvKXf2shv*7_C9qoWh=S(cBA4}_qM`pojli#tADQZG%I?^h$z|kK5;PH9 zbg<R;m3Ou9346M`y6~L31%F++q_nG8Ipq_wvClNtw%YF9%s}a0#9Y&N$klXjBc+gc ztSc8m1S`$}o|T~iabv8$bcCJpsiIoPLm}CG(=}X6f?cArTKa8!s&COWzU53V<fQ?y zp*P#nshBqVCz7>&q%E}L|3l6AkMZSyAV>AD$T5E_zcsAv5OKl3zFfz%sK18wpU45i zys9Otg|E<!3bb*>16-Y9G_Tc0Wz}FO!B6Q-{B!QyJ&CcRV|xS35!EC%2#1Imr{)Bw zkZfLDidP>AM6e--$nkN_!%+8N6Nai4+0q}>nGIQSedai^zO~tt=qg7>T36`4Q;lF# zjEzYhZYXAxmrh>_!|>6r{KtUge{L=QVOWoDeL3+?1pRIJ;BXs0e7W`I)*u+)TVE~? z{m<|LfaqJSvPSyR=q_u6t;>nT*Valb!+<;TVe-QKYQ7-y3MAZ0-vD#Ji7hrudMz;_ zAeb0vAtEIy3e>29TNENa!D8t-%-sA)2kJ-t5iOIz8x#C5blXE=&x@${NYDb-k)bcM zgyP=YbXhuKj9*9Y6MEaB{^xe|f1IcN1G(eB$ORzii*Av-4Ikpz|7y8gUvBYM%O(HE zsqO!+n(!f#hUtuvJ{vFf6jwOcX>?(b3H(}znLagB%Wy7in#!IiB@s$(tOQv$0Hclm zK>5VgHa&_6OI}OBAg@PL5gV&e-In1F9*e9FzN<4Qp6Mp5ss^~HKkAh{ga#XxHXzMM zt{hxWIX9(CUfc1%tCjzYe)k{9&EozGax|f${}QocIB&y;b<E?)zv06_wHzSOiBxQ& z0LXDI%r^MQ@fGqL9Rfw8A!J+>Nzxli@2|?73Imi&OV{|%tG<km4m&IjM^12Sio>A; zgjK_?+CK3WIqX`gh0XTANAi`O>6t0%I1qewlzEGe95s#EL9Sxh6GmilEczR%sd?wx zx2LY%Y|Pm1Zcqyb&oc{`)U;-}a+vTAcHP`ueY&~1Txk9DgU#G-PjvB4`_0W^j*?nr z7?y|p4!&XEh6oLM03@$#8>PO^-Z5cjX#7-{nlLOBQ05Yto{(bcTHrvwv+3<=WRT+^ z5}2^IQFE3KE$%`eN)h9VcYk*N+(+8?f={tgT5BW(Z0epB@GO>6!1Xli;758!KSRQi zcB=RBu;bOVq*iIo8WF;UherBHt|~H))&D7{nQ|iXR7Gc_($r&<5}CXzk&ucvb$jbu z_lWdq!Oaz&6?11{;Zx3kMGjjwSON^y@T?%pL|VZf-LJjEqlf~)U)zi1YYqX^+r^N8 z9#omi|Fk?rQmfl*GU-e6K$3!sM%1%Sr~~#9T}Nl%ni<~DT^wPzkL%DdbI)%!yT7`0 zH94itNRNxw2$8!#H$8p~+D#TsI(~gnksb{p35+H;9T^jO4v8daOUk~#ZX&K83la99 zPUo&=VSl_jI)u#kN_)L8Cf6~}K{O^2ZD^6<ttlOG6+Pb5#AIxU^qR9(u&7Bom_9@0 zto8h$GOcc&FuNp-aenv#6>2l1fBeNKxT4|3?)ez=2jYU^w25MaGC2R!6RDv53ZB&E z_Q}lbbkfDQKvIz%yo<MxlXxhKXJl7pQROR5+()hT<1RUlT{GRWJd3g~MnUd6ug83| zgl25=^R*+py>vOo!sR4Ghz9HquZWM_#hDcxoOO{*ahlv~p%3O#wOZZvdFl2H^!M;Y z*9CTWV81`~F1$6!=rf{E-Mar`j-fBisi`13-9VnfmyZ6SRFDuWwxTo=`Q{}ZZ<N#4 z{6&oP_z(~63}bse7By6s@#}^E(YoRvylR@N7c3O<^<Pz9TBNE`_@AJQJ>E3GY2Gc; z-e<A;EJ8JBmTfux5U=+5Mj)H1BfWqKVW+y#^9^(vbd$Rj->F@a_+(ONdRKYT`Isqz zh?BZz64RCC0D}}#MH6?p#$`UM&<B_>JnqOeX!uKZe1+h6I^});Iag=GlDvqliX(+l zd%3PM(?`Ul!l6E1bHTxdOOPVE*Ez+hF?gn3L-(?2h?<sEhN8zL#jUzuCK8b{qrb$c zlQIi?%*en=#)O?+sP55{{Y6d+19eMMi85UQru1=vVRC?kz~R=d!+?%yZ53LOfP+0^ zAYd~t$99^QM$)Q3Cz3uo&j-diel&M6P3NH%uthZ{!QjB{QqLqeuwy6g>P0F-Ak(kK zl#J92V2;3v`1S1ON{!Yt;uAA^PRS<W<`0$Z<C~j{;MFxS&No})X9(mr4Q49(h%^)h zjzCAOpvPgj5}}43#Da7W?=UB$P;?Zr2m~D&%Kf5{97;h56k8V^b<QGlV{r?;_Zh{T zn!IJ#)Kxe!C5g8kpU`Ec&B@G&XDFAz&fFcxNX(h!-shpqx|UE%W<>6V7+*=w(9;7} zB1~7UU8`36Xt<-ug!0sFqO%NFq4*9G*w{E!mT8)5z2OO7oVD}V>MEopT*?sa`Mwe+ z!ZNNjUDefYY)I7ly_+%SZVPmCuW9FuK>M{4j>*l<we6_`l(77_6N6Z}QU%PKkOSzL zM%sf>NPa4EOqM%YWCyV+*y#8A^Y%w&ha}_TOf;dhA+ZOK6svH1GPN8e>q+vV;}gl< z@@RuZa3i<j@1!5Yd^crlpWgMJCf*%p7MwSsZBvYz&-D;`*eIJV>o2suYLlKtQc);c zERVNSqd%$J=NSEZeGIQE?%umk-4<HCpW)Lov5^w7p}Oce^fn<cek=w?Gdi#Mf*;Eq zL=Qtm&DFEbw%C3)lC<}hd;Q*<)n4rOU{?OyWjdLUzQ~WYqzRq4YocY%e!S2CAPKc7 zGE*Kkp;5wSgtZ!92TWK?)3Udh#?@HP%C8LD$Yp=8O;bdO`h>2-8kvemMo7$sd`n42 zr29v)8_>na!-&IxzsM0`AZmA2!E-d$fosi7eqePH+iX@Hw3FOn66^FA0?y7AcY`0@ zN<Lz~OHU2Q9=<r)4Drg}m+8*aV?Q0OB&r)&Dm9$;t~Pt$t-$F}Yf(*~^s<SiDInxy z!C*tc%ginhJm7{_^r6SfP7zGy#?g@W=L6aLPZr<bOb46=$r^Tan_ic=<!!%XJSM?e zttfwir_33<sU2o7Q($kNu}k&f^fBL@Cn7`pPnB;0LHC7KOHk0K^AW)l=49DOcHY=P ziR@De%nMf4{;q^nI_YtC!X`u{bg;rulZF{tNN9&uHWs6W@a!qf;i5^?k+oDx8&e$i z5K5V)C#{zFWp@V=FWOkOiyaEqT>U9%Kp-SZrY7)EsvClkMGC=epfz;JFvvKu5*1r0 z$(xZLa;J39utCxd1Jq}8XQymq>3SE2#?QE;19=4tN(KA&bU9|dIf9?5G%>CNy3!V& zG*hoYf-;3eR;zNV5yD&1sIbl=q3^F!ipE2GKWWkRs0tb6t=&aGcKK>aKYUoJT&i6P zaMj#YN-yij9ZFV>Um|<ZSzWaqy}GJ>9eZ?QRFB})x?eObjl9cA!`f$xCmUwU?Tq{q zgTChI`hI<4-e>Q7`11p7VLav=Gr&on7MqVTPZ_45X<)d6VD%cpBXt3#6JhbRs<M+w zE1zE#$N88LCtAsl(l6r`Ro>ADunfVvdiqFbdLfGAc7cb4^@N{sk#8Y@E_N0WipuA4 zmFGO(Be@F6wquiSTd()v$u4^OG-bLni4UX1gJ8kp{Z`NPQ(C$RR*Wr&97JC*W3XLR zk4;KrKXG;{jM4C^7XL*~003{@Q6rnD?ub+6A`V!y(91ev<wv`O{)sOQ{ef7qTsVF# zrYAl?obrglBrRy&{!f3aCt+QGGpnKVDrX;|!Ydmri%ou}O>06{_B&{QC`bs<6@=p$ zQBVLW2u4aU>n0r$<Lh-}2O{p92RLn&Prj2qc`W<kNH{KSUaN#|Xa9k3$&|XfQkmHc z`%kknu(9Rk%fYn_6|uu14fAuk=VUlM%@#?mD{f3zQn_luOOKWBuxqEY{ZKuBZ_{>h z8Fz*=zx(I`7@CdKXNLtZ&$DZ>`(g|gETXZRqO=E4{^&Lwc03Fx8UU0bs7KhKV$~=` zZZ?x3hREHX!tB|FrQD32`pplC*HTwj3GG4pvR)UKoZgjD&3(@{CN9jWtIX5szBF0B z=XC9BVTv3r)X!Ak7fx5Nl6*%&>G8Qdja%F2gzTrP9nF-C-kY>X&;1o8ov7S5HPNw7 zGzOa{z2r7{B;_<9Ycw3E4}Zni2M@ra?n6&PA`~Eza@-&0PWg>3NixDp2CY^<HIR;) zKM5}Vvkd0!`>lNk4ppojtYt<l^krH-A3<{0C-@ZgruORer4vge4+e2lfAr`_?g=CK zf?2HM(oMdYG#Y9jMth6olG_kQJ`{9NFu3!LRFDE>0B3?BWr5BWp1YO}FQlna8s*zk z#PImV&@C29haE1ysZTN*Y!cp>Pa=7PZa9$Joxu4XdKKS#Yn7)y8b}>;$u{{XMU3r- z<F3tA7G?9z!<k3<8K)V7FtNSXwC%o-=>o?m`yZax>ovUjWAgjH%n$x{vUmK|1<wZ3 zp|WoFeV@y1NxF=(6BNh)BBu>N)aV9owVeLbN?ZY-H475&b`E}9r_i&E4eBrPNSHJ` zXyW=wBo-!YReTnXpFO=FSmqZS_~1{UOKwQsmU$n(=Dwm&aV=R$MvIE|g1hy+;YX$d z$qlY2tj#P!PlN>Te&cTEQ5#D>wQHahJIEyDv_Pf0$bTi4@A_u3lX(W0eizlJx@C~u zUIhSX<0K(N@l;CPA3~vU8)c>vflw$57T|_d#n!E1!~y0pEBJYBc-0T0APHJ*Q4K00 zztdHgOz{%-_R4*8g8Vm@etJ)AxBuW<c=%g)koQ8_Y|{>R)1jeTN|PAc{oGM61`a(^ zN=Oi_-Au8XU^VQm)oA3sKlD&My}H--{A<Xmh;Xr|2fqP;p))Ea?0Tnlmq&Y<gDBcS zONasvBO8$<=>ZkiFrYD%u5;YZ!NO&HzUISdXZ|U54y>U!M)@D?H{SO1FKfTsl!$-f zv0q1%aYOPn&~8%b3BjZg6C_L4lsj2mbEW8g#8}z$%GhWvGI3F_dW*c3^U8~*3?7by z>)7Cpm~AZhnQ~UgQNAWpwe%Cp9**jA?{tK!q0k+wpmE0z7#1}`7?8g!u1*M%Q878M zA377yevvhEVtHUbbNAyPgRSqAqS6x-QhhJqT77Mh=U~&9|5nSjzR;zN8OdqBQeKQ! ztIqu3CcVt^W+l>{>NCbi+^s_Nhs}Iv@tCz?iUVP!GtyZ#UycIb{JE}!`Q1L;5gMyH zIr~RlU#!MRqHsIpQCNLgC_-)kg~3t)a3ajzUohaF1^0(w;e_4nFT}Vm2fd^-l1Cbu z^)bJ(*Lu5O?d)3}0+VC<i<}z(anMkG{)y%=X5+7x)6{bfU?y^OQin&ghVI3ELymFY z#WT;JQx|4GV;vuWPa3V(+<RQXpM^W~?$ahgh}TcK`sA0h*>x&j&~HFMNZZqi%(J(b zEJJO-|7?$iif9VQeCaHch{qzd?M5)had!@M1{25O166z=cAOPc$C9iSjp*i)R+Q1O z;8UwCrp>a~gyZHj<^z0gJ(VTXI{`Tv4p{{|qS3Dg5(myG`nfKyn8fonzVca}X})J& zl`1QLwz$Xf&6$q6b@_3@!Sa!;&2=BjetG;y@Yju(Vq$KWeajaw*!Hp`8O_2E&$Gk} z1+u?A%`yRpMsNyHafTL!5)(%&NTEfVL5QiO!DUlp^QBUOY*y(almc=Dw<~+othdJt z-(2ucNeQPq4U0igZ^@Lz#mBf|@}(WZ#CjDC;NW8vs+Q_{>vg4Dh;QlSy%WFl8@Y*_ z=$q`f2iH>{>aT7D-rwkuo}q*~xq$kftX{Z4F)Ee{17(V+aLVEcr4olnVh|k=tZoc} z9h*%kR=B&CS^mhZM|V<Nugss*P1p4?V_M#K6P~|X7hCUtwVzrHG&?9LTNYklIFQYM zR4Y6-^7+D?wCL<Bp<U)_>cX#&$1Ysq#il!H2|Z@)Ue6<}<jG5q*D_ryMU&piS@Un5 ze~H|Ks(Z1vIB=_Cp4}19rB=9S1xH@s+Q1G!BXzLckrX5ViX2PhtSvosJREZo_4I0F zR=hj?P38HAWbgOKUq<tsZbn}1g*CQcZ#|v;v5_2i7|$kdY%+C8`aL&QiZuT;u>6WE zbwWi_)2x+}d3{~UGQ{)PA^YY}4G@ZfBwDC~M`%6Mcw&Hn@JD)dxvV<2PV%bMwsKqX z9ui>r^0u?0X8YhTcVuS{_ckx1I~Kn)<)r_**_S^9vq_{_8e_m5J@*Ax5cM*UQUNjQ zdTNXtk{SpH5bO*%EC4`-<OK;iFtN|s5UI>=>TVdtjVR3g*!Ml7^hw|r`&Bt1kU?gg z<LoR|Zno<awbc0v$|&8*lW{a6`C%3xLdzHJZPsaoCxM-9X8c@QTV{Z_R6f-?KuK_> zLa|e2*E8IY%8hb0d-W}^T}0X@swcrwwE)V!=4oR=SPotxM#HfcQI}{qxDN{Op+%ux z9m?7;aSqM2Z|R2L^`!&~XZT)ko-OfLG|xOOw2pI(Q&v^>nQ>XjKX%?-e^^UBsiAt4 zt|DMzGclng7VOvQ`>cc{_Fm$>_fYfEdS^lE?3wR4UeNZ{hj*<aDMOM>s&oF#=H96M zntPksekSA~QG#PkAyg_5s#`A*2r2>9h$AIIs|aEks2&86BH;iw+M7Ou9YEf>V?^A0 zyB9Iv;%Ta-RqTf-=~r(Vz3j{$n;_jj%!s{<4W*0r>zQ2O{%RM=d~uMdS453HO6w(} zB6)W|Na-<)43W_B$PMpMaO-rEMmNoDr)wO}yt=`_@X*rb4fN?|%7lwuoASLe$#)`| z9M^3&e57BI_jOPBnZxiPPB9&LRM?>&p*1ja0C307f&fTyEQ%fhY6mnS{(3({(3fPt zl&J}OAHvU@an*QYO6Rn}1fv;rgPyKKh`MfV>rgQIrD{fRrRsT9F{iNLyQ=2o+I-0; zhY>x_$20R^12Sc}9~343iz^omK&E_E!Ut$QGlsc<0PrINf}aQ?gj<&pW9eVwMs)<p z*02sI-b-)HT&yhnU(GnFS9#dTnB34m6dBXG{uNiA!8$hQs5KUMIHec{=K$?>9+GI1 zgCaWIp%C;_^dnFo7-g&p#vf^{bA^O46*dghA)m~xt58ked5IG{#q3tqph6}l8vfQ9 zr<VFR5y>ZI2_42Or}VbLUuA)s?z^)2k%X$*mD|CB>e}W-DNdzhMzf3dp07Vf_~jl1 z*HK(u>6G`(<^(9Tf62)g<T}t`IpNhkJ&;SDP{<C!`&C_QOhXa(E&Px#iyt%}2C!w} z0`3)*SriyK44N<%uo8mo_7QCuiny?z_+M)pFXsQ`cbmLFB4xb=i@PHdwP$XyKzO~Y zyQr2gRP7Y}!z9ACJZ1S(wLIV4)}PZ}J^<BK=x^ZvPQTit-0X$=!+FO&&&q=lpvK3y za#8zp&P~X-=Pm+UAD+I`S^je>Sg#1i`Ya=S$O+a@0U>2zskkc%zBm}50g8oYM-u{^ zd~d$fXQr|T62X0!%NGug@3T;Sq}qN_E?arBdHpMW*KH`3*`;d@Sr`S|pRP9kSRIS| zWjgxB64ffTpZ7wss?D_)lL71f<2MRSiGogYn~^PBy=z~-xoefz^;L$jnJ2yc_@k^` z(dfQ`P-*{jCN;9K$K80^WNSI$-)B;Blu~J+s>MmVU=YfktY_qXYQsnIqo0JyKcbJm z8OX9|j@@SRv7hBpBTOT$a@WGGqu!c<=kV)v4DTb@dWtqJQ**JF?O>WpHRR9ov$QJW zc(kWI**_1gD9P3dXMOcF{A*V+03<CyCA^Q;JsiA4?6an>cT|rcqH-HDZd?bg5j5{3 zsg|wa>Tc0uYC%hnm}=htIY~XQXJm5_1}GFBGtUYK@pe<zJV0^+;n+xaBoy!h4#knI zw-<ME20#?ABM(iC%Y>lg^SYjI-)++wbpBAn!E@GUll`vkzKC>mWaRApK|9&l0%v_x zkN(vaH)*oA>$OJn`ET{7f=!P^Z)Sd%C7!63y|TY#mTvzwky_>ulsxKIrOG#%dG_;e zRl3930k*)po`H0zuxK22ssd@2fF7g-v_go~!?FPoAS5pM))~Y`VuK+352`9>Sh?(z z1vltNyvf#sT&2_+iGr%kp7ttl1i!ml>(v13n=xfCe<#Q;^a;|UmBs$Nl~Q8xKu@!o zKQw!wU(M^aw)+>eoyP?(xn!T>BC|hMc;{8?#e0iAv>VdGm2^YV`ZT)ixY@qG_uijV zpj`uuYfC76NZ75S+b=+mO@@UQLn?subfMZO019yabR{+!x{qWbdrmY&x0<qOF^H`$ z7*E$G4gJz`(Qad=dLrJ7t2@7UrOB>b_L#81chv)%cjH{XREdz3x*l&7OnsPsg(Xwk zj<dOWdm2)2;XC47p**f^p(n|FKZE0bpwi7udmn?d2Ha%gSV+XDIb{j>yS;lM6eDmD zc_%Nl4@FJ531A>!n|*hO#zdyco0BYJddkzB9O!~s);Gl$W(_r&E4`%-vR}_pFLNe1 z!B$w<35%I#<6@r_J&;=3zEdUaz&(>?-FcSV7M|Vt&`VapvQN(H6o1|BE%b`%@k_&S zstO%#dm7>#EjU8B1V`mBa<KqJ*<2;Oo7OWsfojFS`;kHX4IL5OtxLOUt@B%aGgq_o z2#s#NhWcF;*1jLyiFW-n;yT`qUU;yIIL@SFoE%={4n^VuURVG>GsttbHez?Zf<ti~ z#Z7>H7c$jx%);{RLIA4hI%bB@?c{8F9Uaa#XeOvz6IN+bEeqnSH41f}cLP#wrW86M zHT8yKj~cnRwD(nrHD2$8;cKd;Al^&GM7YZ@)>dxd2w4(UPsiXcI^fXCjGW7s=}rC6 ze=o!}nefQH^^!V{bSb-yx~QD=G#eJ`5d$NKU^yBBZ~z7GP{maT?lRvmf!^-npjMS7 z=s4CC`lz&d?+n0&j-g+8P)G<YX^kh<RKVgPDMtoxRi8EWe5b78_z8V)OGB6CO$K|P zMo}|;;Ot%4EFQkWWTj#VS7K;e6C2#<HyESMXBIM%o%quJLA59U<w;4Zmypk|9}KcO zAt<ENgA&9Bt00_i66cs51|k6fY@mS?stiRTEJdo<bs7ipBJ+a8w0u;Ci)TraCxf<* z`X0%qeRO#?`qq6hy0*s&S2ee+gqTqPwZ*5bHekD}(rYJ8PNk&3ZDJVFVrW=uGJ40> zW;b8OP=uMuCgkOJiKYRqT-t}6=#LkM)hcR%8?%vGj8a6rLu|8rlySj__5?dMHs~m* zXx9+>t9V!k>4Q!b6FC+b3xTI1ssm>L!kEDx_NpdvW@W5F(>;paHEpAQ1vsR2<FP`O zLga$X;#sM4$?W#MyDyu#jt2tnzEJpB1W#q&COGLVewg*#jjw?ON#P%iiBPN7RWZb+ zuf&sG5lnLy;Zv`F{TI1g!<rk?|5wY!;s)Fr)&~YwTMR_JZbRaTe=)3LFK>u``CPIj zG_7zfmJ`MBS#>x;>8j+N3PrF{vq(~CHy+|4atliVjG@8CY7QNMPGnXz|3HG}U{3(K z&~A~HD+QT`E2yS+{q|?<Kd}W_L2IwiB>JOgW!BS=#b~H=HQcDpIJ%Fx8-}7MOD$Z} zik_Fd!DyG}p^}<C9{4BET-H5K6sAhr=l5L9`9i$Hb9iOH=Kidb%CHZV%+}HTzI!T2 z_oP;tI4Z*mrKU_;(PzKTAy1r%B!<{P3uTZ6ARABzoDi!Xix^Z*FW=_P#O*+qT<mo6 z;Q_nYtNZsD83W4s)$g*5|GcYOdMbg(C*D;ef|Kq)E7UcrKk<2PhJyEIh&xkgIEsH2 zED%NAuDOZ}^Jp3U>AmIFaC4JJ_p41h;d@ByImu~xC(n;J2c&BRVz(nQ2r6WWiVcC@ zDUu(rL<Eh8WPzVRP-n;y!c@4sFR}oVijRH(-jUhDTOpz3CBR|K6f?WuH!(F@B<)V2 zlQ~=PcGPs{hU-zU&}u>d*=39J)7~J7?&`Y$GdF;(B;QRXj^;M%r~s{l$3!!e7>3AZ zX^w2vWC7{QOpwYe-psc7?DM=c83OYu-)7o9H=r{zmYzy4kr=`b?*mjjKJsglgco*g zi)!-9HFX&hk8p!b;k4!OLTQ~HlHRcHJLRr|=Wn_V#El4Cn`z%ZZjVgx%DeU=+Kdzk z>4~z8Z>=4j$#lAsZDbp$=K;wMWjs=}FYSV?L=($)ZvumKX1wIAlP7%4pvoPnMfY>i zYpu#4=c&cN$i)GW=iF)v3pD7|EnKD`77Nn7cy^;50P6{UX;do)^p>F<k500&;)kc$ z+yY>S8eB<@*HXrGxoX2Lu!1{mB}X1YFr6@|5n_;TcLeJQEr>OX4urZuM}t@j0gM3% zO{jW?Bto@@LEySV3P?_7Zd5cVSzIAa-)u|J23t_0cyD`l=4adcN<6411OVz$P(pI@ zIAM`W!L<l%L(?Qa_sJ0ijj1IYbRkmkQo>U*!1L0Kk7qXM-OM7%IQLrCKn0bZqh3^P ziMtgc7gQir9WBbRDJ1WY#xR!Sc2rsIlW!6N3B1Hm+}lBzLeN?v?7ji0keI(Efy4tw zQA9!a(!)>Ls=~TK?`%D#HE+Yz;z#a7-+j)=S<z440+9>9l|2Kpq(IMDR{AZ@MGX3! zUhhSa_Xv3Ug?h(q>~G`0-5tESKCkZ|ewgJqy34umQoVfR^1b&A%6P-ZC-l32;_r;P z6ze;evfGs=zw6LQD?=g_16Y6rNY#Y%MSz(JJ36d%g|~U(fs`OLjDWP;7`i~~9;Zx| zozm@iDF)f|N-`aH&zAV0V-}}hXP;gehAYt+_*#o&s&pz$bJyHiI+ZJ}I%RB!ee#vL z=C+{A8+F^)zDr4Zvj(N*trJ>q`uRMThGgbyeA8UFGe7_Kbz3)(1KXGuYdQuvcP~VG zNE5*rz;}rND|;xJ3@9;b1x=hqV@L><zynrb1SGeWt0ZVrW`P?HTEi~oap$%Wqd1kR zxeX_X-l>Wfcr4z1$+U*gHiL-gw9F0p*o+QOw1?Hm=kG!&8w^kWA{U8)Y$s{Y7!?99 zCU6&q8UU(mIc_s204$)&rg9enc&AzK%0YDAV1G@~{LnVwSQ`Gue6}tll`A8?YUKA5 zwcDxXfAhNJ^vOa2Yp(2Ik<uzcOUR)>sd&e*cs7`W)gP{w<t~5+LyhmCz|`nD>|SE3 zS@4RgNoWKygwhZSL+JvcHRt{Q@!e32IED^~la_d#k`$((ge8m(l~@n;u5ji}<={&n zC01M#et@>kIMYwEd*l4Zh<lJPMNQJFFxFl({;iVsf!<WUvl97TIZA!U(%jNm=g9|e z=d%rW|J3b1bZ(pC+b^s9z9V%Y->_)^M*HUU_PnL5jK+Gyoa*oG*YQ5w)sfCd4}Vc2 za0niZQ%ABOJhEs)<BIyAI!dP0984%{i>A<MT~X+hL3gSTPyh|l0L3MVg5^KL88L{u zLZmzv1_F<k_KOimkK+)7F&p3n8ZfgQ%&|}fUDz+3#FwsxonpJm`pf-ud^wK~=R6z2 zx!jJmg<om!+}kiUo2t6372H5_pKIJa*{3D#jhc~COC{H1OW}FTJH=)D`A~05N?YT> zw@N;5Sl=22CL8%U8GIdUeG%YuuWr10bXw=;{&w}7B|poXGjF5mh1hhHYrJYO5fRzX z#Wn58?m$%q)roGtq*NiDHA;Ceo^UWd#D)m91^}K+JspK4OiEGM$j2zoF8JLJDs)4{ z9KTJ22QfT`<Zv#X@p)f4JKu9X+tK(+DcJ$WaV_#kgM4L@i?$O=Y*NnN^Hl?tdkbl$ zl<$4ac$S*~d<};8;}fy&-_$h-XmR=7>{|XsP6>cG%M4}u6atZ)0iOE}0LjPu?EG$9 zxW8iT)RsQ3FpNq@ws&-4+3pFij2WP(J={m?#rs$H22$p_#|`^Yj=iYDE_dR((k@jh zz*HacEls9Sulu3^?5eDg#%?>ITA0kNsoJ8gMn-00j(fWYmC=)=Ant)hOR^X<M>AGS z9Q~9^HLhF-2{(0n<(HKslUkx~IQkBTVX68~)P{u8-`j7NcPDS`z~4e02Xh`I2*h6q zQF~zqQ#qTc(qebQb*-qH!)g;t3Q`<V-^j&T6^Fr-jswaXxvurmw(m5?S-SR3O<;9D zxaJ-kt!j{%`Z%_{D;%WuD$790e1GpWea^~gKg#4DVx?Et*4zj#^c8w1WB&aKrR6}~ z;vbjCgAd;=Hu_!vyt(mW{EZ`Z6S90IbMtnl!w%DFD16p%F_}!fa%i|P1&j*y^mbm1 zC(ZS#k0p&DkX2Rkcs7_<-qz?HgB*4)0~sy3Q>&u8v0i^T!++P_G0lN>j2luCG;&*a z5;FtV9v~6?=p@e0GC0lzNsgN1D`JTl;=+m|)X*1?urNR?wyowZSU)qw#OB=cat_-J z3|jKq)D3sl6)GAf%NP!Ss#0$n;ib~6z^R-{)I){?)NsPw<$(5A6_CPOr*%Ko^t6YK zs~uDnbb7TDpJY6!jViUt?k%)uh(6IeKRrpBx7y5M(J{?4z6!ZabejnFyj~;6C-JfG zP{~+(`qtCib!TqDng6_HxzJ2)_VsC)C4lWCwZ1Ci{Yjknwb<=n&ec*lT1F^BUXWZ5 zImIs=35ft_QT{%HCJ7sr5$PJ8rnvTYI2n5VMa~j{;EE2Bwa~1D*){9?v1k$%zUI{4 zb_x?^wjKNu@1AIxz0GG}K5c0<!dR*KB~$ibu;|4TxTgtKTta^7HAJ}tM+YA_w1ek) zp`s&qiMl8*u`qfGp}=PssPg%Te&+j$f{-@>miMlc%#&DSbYw&cYHde<S&p{TjFEB1 zYfC80%h%?OBq6wYT`MU*rxHt4U<rq|Dzdhik|*hHJqd1qS3+8`XYV}aYd^C|*@)NB zzw1j<@~7^i^tVQlNaV?#XhNpro?hH%{Z-7uC=VXV4T+<2>)lp@-%q#9zuLD|ZB1*x zy)MK~;#S$}%<J1~;2>yV-${7Mkl9zw@D1gkH6!E-2QX_&YFey`(pzZe8L_f6)cQ~U zu6hZVji2Qq%(|M&&Xz#Gn_2nq_VipCeac~6-IkD?fzQ$}*dQ+I**6p*GjsA`Wl8&i zpd{hO>LX8{%rS>V90iUIge~(K+r%vCbI#YIynb)YBl*6Q8@2CJftsBz*z`I*xhjr# zLSJXsyUNA%!?;Jq3gY%`@X*TeF*2#naTwu@ja*9yqEueL!k<cZj09tu`DxoVisa)$ z{-Q|1qr1!gjXAD3Q51P`PKDbw-r^hxUTn?*Q^FNN;?G;g@9_#qDu%x6r5F|^%8I-B zA*_`Y)){8Y&6swRiAHEd+mt*uIjvQD#q{KW)|udD!$*6}j+f4;x}57KstCuNLaZ!T zX)10;Dj_*{c69a>DF$WiMM*FN!e%B-SLpbhzhO==q`qUBA<Cg1#ggZO(sQf;jJT5T z47@WFi8!6w9}dzuTo(JGwdWk_caU282a%Dw|1XC12?i2DrH0=U(2=5~hVWC+)T{c$ zuD$INrcblpyF}n&n2~ssh`|f`-OjRiej;-6(CzBi8^aC8_9r?I3hG~!HGS|7`SA01 z61FW?X29eMS948v79rQbbEk{}WZFk3q`i%<A>IS*uu$e;!tLdFl~iJ68rg4RYAly@ zPRej|kHwE`HfcR&CU)ZLm38G;*QO8VarqY`rMWzYsv<UPW)-;j)$bS)B?^|>P^AT7 zck#<oC|(+-CdL?s|0aFb`uH7jtD$(kB5b?DOS@=b?+LlvLa#<>sPnq!jcDsn!J;B@ zD=oqCQ-;)0Quv7DZK@2SMweG3be^`BE6^I7LhfD>COO_pKh#m=CH-1nuyg-yRU{M5 z_uJ{uv!<CXhmT{d&CH(MhG~R8C6nCFTP|DsuAxvt#4&cChg60Ywyhzt`F>a9)X$&Z zFO1ILQC)qQ_FLp_E8O10V-#F2dGe?I`_)azpT&YKs4npC*qb`V&4StE+wZ5}etYQx zkFqP!fV@lDi(Y$J3s|$JTNEQM5XS(OqN8CBjR}sE|0<y|L{)9fFYuxwuQ<~@63=<{ zB70xt4r1INK^}m@CFwkz8&5LYC1#+YW;QoX21N=O$enaRiBTOxS*n6b-JCVZ*BPbx zCU)Cyc-pBAZP^`F-}%}UA2iBuGePefM5>XQEu479)epoGe>$9<sL>@tCP@j1GeJOQ zza)Ug2tp(s-Q3EN{x_>MrDyebj4tE#wM@d$j4@hI&HEXz=j%A-XzSS)qXMO$<x=YW zvLkw(oOdkK(qC@zd2ZN^XnBlp4MJ5)CrOT^%0b6|`>W-`Fpy}fL9$!q;&FZn_<=1* zYiyWr4J#fxb6xHSfk0<NogdOL;&8TX@NVio<Lr(%BQ3v`e#&pdum)spMcLVTttad; z^nGcV4tsG<dp4#iLT)cED=Ci}VF_}lW@x-;7zT?vGO6h3o99~DE^nM%;oW}HwCjX2 zyJXF!jM?e%JCunkMq}lq*RtL^%e`0+Ms9cSg$@o+eW&a3l5+U=IBxLu=6dhrw)&Cm z$M4OsTOF#-^NSL<W+Run8GnEJUjOye-i?iy$>r1LPZTw^d)YYAPO-OZ3@|v!#T}y5 zUbxNCS<Vo$U!mf6vy3qn4o4mwg{p1D?!a)@r{Xom;e@<O>qc@9t9-{e5od$Dg^3o^ z_Y<&cvuAis(kA9ocR0Mt%LrG=Pr9uwWyh!<BR8>YmuVyOO@e8CwKN~hewD3x4jw7x zH#?n7P##~Vz(M%))7|7c74IDM?}gMhzrMa_u@yUq1Qfg|J}V)=;Y|u)B?%2t+Azxn zAh*E^6z=3Ap5S^cL1HVGm7Nn7S_NXg74hUtl$9Svv-q=YmOJ`^N6ckiTof$RJOu31 zesTH^{SVx{FX1O|8k#`{b{n86A%&m(l~Y;S{HEPwXD1K$HZ{{X?e(O}bAB8;NS~hw z$BS0_8aOZ1hwO)&oE&;J8{TXG(;O6E+M|IbMy9PRbXS$Eps2ei1?)<Cau*C%MwO_M z<0Miw36OZ0?Zm~DsfyPitdR+@ijH&-ZJhz!mLulou58UE?5K9Vr*5$`H-=dq$)uzt zS$fH>{zsjX%lj&17whE7KQ8a(kuHBbXk6}iD6`7g=Up!F7r6)k(tJlXd@-Oa*@g?@ z_gGwa+k=&FeNf((jaBYTd_Zo1(b<CHudmO!8Qb44E&tAJ=Q$NOdN>erv%mFaS98z? zO)`_UU!OH0N#|rv-H}A~&M8+_KZ}Nv3Xg*-8W(awfmkv<*OgfPUL?z0J2jeBJ(ElB z8b2g*&S%^$rxlR>Q%&?X&nno9N|cT$4U15iH`Q(k7AChcj{P>Uvz9aN4vffAbvsVQ zh0>*2lKi!Evzbt=e{o~=tk{GKXXfa_fmVQ?o?RiyuWaS6tW{aMWMAr<e5wVv+$57o z1u&LUI60`W3&{ti-D9kVTHq4k?#I5=_<3Au%Bxo+#OboHt7WCZl{S6-_{nhgnwl4H z2;(0M`GA7vEkiAZ((Ii^=@{da!I`c$!uj)zX$82;)Wc(D#mF|Bg2QgRn_u43^rcbE z#b0h>N4)%cO-y_=#_42&_;#{p+Rp=Tj>@aIiLsWbQ)11+U)H`i4}Hg3RI`>`0ENkK zd&JQ(ZK7f_L>-ARFJ9tVA?o?2Qt(_lRP$OWUMJSwp2rvz)KaCYcIBmkX+J}$Z0#@{ zqplL1wV(TmkoL2-eO(oP_)BH7oY(v2K+XX3bL<Jjr(uOk|M>NTgRz<5KCL;j>R-G! zXIINtv)4T!=z`6vxs}t3Yagd;<%NrCj{}o4T{TrSJ{1Nhiw-A?6}Le2DV}>d={@lo zmT@w3mrqkIZ?a#xImF+eKL|L;x{>I-bB~2(ZO8(uJ9|!cS)jqk8(;qFp{>R{kCgSt z(fu9uyc9G?)|>gNN3m2&)eL3wKh!7~i1IkBZ4A@Xh^xElA}{_l{zdK?0O6*%HLO-$ zu?|kR>2emtA9LA6K4b7)xYw~hB0(jp0HDi_aRx&JJ>}hF_HDKwx&KPD!UJq{IfW51 zAUTjbnzfZw3DYK+c~()lg)Igz<KT8wAqu1_;LmySntp2SXjA7!leEe-`rz)PtdGiO z@ptfE82+{`s*bO}Vc_f;zB0NFd1b$1V63wr{`hj2{N25`Da$u!TluHKuA-k#E`6`I zeCnw>mxZAesazyH$Rcn(=MfaiPu2}9WFW*&a=!x(6{mv?R?q=lLv!|)3wl<agxxO) z?z_+3kFCWxPZRDv2dB{#y$LUKmZId3($G}jMS1q4u%4hl(Vv*Ed*Fi3(=MuV=v+rw zWvt$CutX+@FX65+XTSZ>)`v|KS_ukd>li#(SdU-Mrip)&J~_0XNZ9Rqr9vekl*u*I z0q6sqfDmg6Y6K+@MmVOy^MNF3Sx3wSx)eYk^J6-ju&_|7aH-J4-tgdGmSpm1t_;pT zNwA?esUd30nnH!Z){wVH(Li$-D?`7&!dBNg(t2(zwJ4jgPcS`c%#Z?ZVnAHo-%PSJ zJ#gl_SLQ@DECW~TtYsS;8>+hBGObWPp_?PCKR)DKL^3jB`NI0cpkeLuO<ruQG2i}P zRmkP;ba9!Jlx6!01SrHnjS`xuC9ykf;LDiLJK$p5arh4yz)G$xjuocM3Bq*4NQhPr zJX<(o0j{KKgE`9)@1#|AI|?PMRc${#BLjg~R^%#4T_ujY&+^7fnPZE-q%|B1^wmDt ze&uvD6w4l!tZnC+uY$)F)ta1^o3BVX5XnZO9p~IU-^lc3|1WYG0JcVwTD2((cuk6{ z^fxK^qa3Tu76vOUyHw%hI4HKyiX@WTSU;PDVyl?`cwi{%Yq|#g&2c>2tKp@2)0^Lu z8fJf+vn5w@NS5Ut)0u6$oRu1zoiN@?MS=|qQ&r5u#nfUBxv98-%{UxXBo}~kH#_B4 zg<1=#QOn8`oe%bjiC84Og^}UGh%lY?KTHe1A$8$UD-Z@M5l0f%XK{oZ$v~FlgxF5H zqpHkmG*RhHFhIG;s|95suMuYTdT4V;A&GK6!N2z<4UqEr-ksph-&&%8fA<&TxGySG zy>L@a3>4kb#0#hBvhA?IiNg(b?{vp`O+^yQO@EtwgAI^#t75Mb6^RiuVMzVxAlZ&v zo-rp*!GmC^WGQ93kfM*|q}KLpH7CKUFjcdPyELzTtF23(JCtr)(rp!D?x7z?zh!@W z?*g=#%ue5uhog23Kc;J;PMlBLW7qs-W_TPpyk+n8he<DXM(Z`>&-Rgn_A@#HH6`KU zOB+qF5`5&sc|<vMuiG0o!q|a9Qo|tt0sujx*kFa08=`L3NflUAzVD3P-~0Jrn_7Jd zS^sA8@qzr%rtX#gG2Mq;zQ*e!dO|F$FgU6*z3e@sT(W<+w*1yIUFMq=G8KGgERQO8 zqs<gf-j^`b58RpRi<V$I#RqA=i&cX{S{??1vy>Bsso7z0iQAE%EXPpXa7Q9=Xi|qN zfWbgafiPkamhI{t=Hm(t#?)%pUws84*9&K3jkzuz=QlZ)x+RJ(<d4sDgW35u-JW;_ zT1d%cq+gQONqp=)ULB};wQ%#9WA^iNHRXGofA&t^T>eEa7=Q@#s^QPkpkvv05Vskc z28ufzw;7sYZW*k9eYp)Y^VFELul-wXUxO=NPdECuwO`+aT)!3vz{Dekj!jDCoZ(~k zn-ow!g-cr$ZWQi{s1*%f6fR00EsM<#S&^jzD?>4GmMjW<WYZ(8CB;aSM~PjtkKpAv zafbEx^dEB#``!K4eh{Rwup!7g_?w=7dYEsbb&Tn3Qi#@*S=+=b!pulk`iusN2aUje zPC<-^6*<Ak@RC72u!nxOIJunMW9MB?Qf$J#<QMa%ErSd1S*J~lM(*SCekK=*^N6V! z@~4WU?}+I7LLWy1jC6d2j}V|f!Uxuf@d}WoDF7A)hINRQ`-AsWI)A30ncZn=2B+!A ztr>619LQmb4x$l>Sy}_fo;*kU$Aj;aeH1;}T75$^?v=Q`H*1^{c)E*X8?Jvr%)uQ` z!TB8on>p(=8SuRv9>xy*e({hb0Dgvi{3Xt5<k-Zw#pcQH9~hB&gpi1*o&dRskRfx3 zz;A2?7*ZdOdjdfMOrSbq5E1~*D29*9Dd1bF8cOpB1jv|pei{q=Il8<2*r|2L?WX<5 zS>jdEWxsWHW_7i<aMb(F11gF2>{YT&%H3e$WVU0$ftg7+Wqq?hlk&^Yv<1%d^u@Q8 z%zrORx;}ouCBu*}E(Lo7^G|0nr4W`sg}M=P|4aa!L_(SBg(j4cb^KmB#X17J*lRe4 zvNW(mOcXI@AP%GnVBAnikbX*#v6^P7LJApuLT#l8LO<hIdrpm*Ec?Na4-Nr+M?YMZ z8fBOlnwmbS=V*S6%=aG~QGrDlOwn7<2-n0r8=G2M|3&V0TE7YXH+RWB8;%PEtUV$% zjA!Pz9aMK^VAb0r@EFV1e_8Nj;Ck%X_}2#G?U6&@?u;a;JTP6C$RfZ;s4}%2hYcz) zSpUD3Cnn%Y2p+0MY1V#@{_MC6w*aqqG|+0&V7%en$}pe;jmGX<EewhWFeVs)|J95h zc2oD#0G|j`P(_K)hOt`|#~q!_0}<)!<@b+Q-}>^5T6F!%R*1S*F-=fxRG&)&-UW$~ zkGh{LgllddRJpY$j#@s*cftBKr^cVQyl4lzd~csT8IWK0@@Oh}`DS@De|Z}KV8G{^ zDa``Xh#@yT0x$t*FGf}qPp+Y}0xH*Ki1x>g!p2yj#jy$tABn>VkB9K+HDa;eQ!mD_ z#j6$m8ho%}{CMtVbE`vCP5;9`fh1G|?b5TOE`p+}546=oAfEqEd)F1!)Yh$cLI?p8 z2u+G~0#X75M5P@KJwQ~NfCuTJD@8z1I2uBSfb=Fm5}H9N2NdZT1S!%&v(bwIR6Ho6 z6d^aBJI>>IzvGU7KCQji%baU`d#<_m`sSR1*H6g4(P8f0s)@=k*f)G>gv6xY|NKz( zsJ`Zp%@Z@9q>am{=MQEMrVnl8$_{omkHvldM>Q>1?oc5c2h4|K0tz4>j4sqY;~3@w zm`LN30pfy10Tj_kAe{;j>Fe4Jd}+R&5A(fBZZ1WRmH5qUv<B8G)Y{9<uU*j4nwQC3 z^;!*NDjB^K=s5@{FEGsdm_x03eZLpy`7sBcO?=&mKpHky&6g4NNEi6li$Wz@Fshi` zCsw}2juWf;cxUtBUSHSY682%*u0#@+wGF^yXu6spPP!&Akbyvel1@urNaoAaJbvvj zmzj}AuYT6vIXp<L3$~5blyzamm331j)MGNuJ+R@AtJMb6)_6GNgq)yzVgu>;4v0MW zUY(YVR;m;0C&`~cBoU(UJB~$O3QkXCK`!=+O>mmL5Nl(-z+aKj@h7M(@ZGtsIjLfo zBbl+2N7~IwZ*F$UJklMn^?@aS2x_JjtP%m~X!z%}p99tA_~Kl}!6bV!p$8SmNoF@d zcW-f%AqEf{gku;&i=^S$XwncjIG}vJjJwt!QtCM99^`iA>8a;U*PLE4qh2Zb2QeQl z*njT|Ptw{Sn-6_fT}04li%H=!RFRbcrB+%5do;e3ju*6$ykh<2?AhGQrfwE_jFXJZ zE(Z~qnY_SQ&Ya+crr$;@6i+6c%$6c7Y1&*Um!lYJL~xH26*0V!Y!0j%MgSrJAVo0< zP&u%O022Wio8#9c%nclzr(r7(r=h44SHxmMR5DzC^cEK+j_oWU>YOA(9UkB&-L~EN z^ft{OAUD@Nhs|cY=2pJGdbBdgRGkj+4Vrv3T=|~dS8#X{U9vD7^?AH*&vp9fL(o>3 z)L)Wow`pvOi*Mk=i5NZ86yhu2HnvS|Y4~E=7C+dO2!dqVgL;P5u9iA0OdvWvKy1Bm z+k&r<E1z!&Awv`Y1CtPcK_KWMA4%58YL~@Aii4XC%10k)Eqiz-zWFfi&JZDm$dsW7 zD!SVJf+r<jia1bdww$>s(tRG7);<v{$2GN{%fs%TrqVkCS|K?-bHj$2j^$Q%455q^ z>cfXV-bOhVtDfx*?4Oz=n0tcjo<d*xnM_J?qD;SqD|1UDiyIhRzr>cqSVZtAm+D8I zCJrg~0Ccbxpv{wkL8<kKHX3Iq))~`bl0t&G_7(~RAEHsC*smRZrC&%dHQlpP1VgQ( zc=7#mawLF#I0FZDECrgcb0S%x9KUvq>~HUhT#kw;qYfe<Zg^>G*^pi66uDnaR(pe> z7~3$6sSA-E)qegWO!}5>!>li<of-o-^Ux*<_BgJ0_gdeOD~KH5mTlyF@iN@qGg*f! zquiK0)KrL941Hm$Gn%T2b9nQhac+V6wODt3q=Ctt&R__b_*pWKq($q6lM})CqA7_O zav{9nYitHxP(&$SCdQZ54s|gON(KXDJGNvjUJ>A`>k+*N=KzP4Ca9|EUJbVsvT*z^ zd7EKz=K4|9wVKy=JM%eP5pthO<auI4H`3w~_We3bhQ6s3rgDC}Z?_tcUYR6m4D4K5 zKs|6h|9Cgu>9*X>d)8}j)Bcz{bG3QbOf@L*GxKd>gF}IjtocKY2O>}Jwx;{)@q!FU z1$Ytum_0=aE>?_+MUsiuFphi;i?>D*UH}B_4n)hs`clOlyDQWQjz7FpwO8aP#+My( z3vaEZwjMF+7|um9yDK5|$q&u;ul2vY>zGFCJ4d)~e$g16#y4oF*ITQ0^kMu2WpXQI z!`vtg8BRyv%bO~Kwfz=rrOyinzPICK;fwv)AuCsM4^)wkG9w#e!*FmA_Pa6cOMKDF zQ_xL#L%YMd<4d1DM@3zXI+r#i!_n6M%{uFhZRoBU*TSjBLfe6)J==POPa$F460HTV zJvqnzKEWvcnzpXb_4sFV2RvOnK1ma<cN%tne!f+xA+vLCqW%tF-MQ|lYag9ix3Un) zr*sMZ#}bi^Qe}yvuiyz#qI*Z=Ie&PtK+j^$*S@u@2To;&sXN`gXWRN#j2;x6q8gj! zx9$IeoHM|-tAk4&5?hXr^AimBw6M~Ca3aXfoP-lurL3g|=fTDrmNh4C@CRMmhm6c8 zw-gRO$@vi#ER2^-xYTh&i(P{GOsD!=bK2+oM|$kHj@njtK~=5G_Yb~^t^5>zI^RTR zk)yDG623Vx<YMdmW<|X{%jelSi~uVEj3!$)n68Y0fccPYDKs(7pR%^>sy5;_Aqg7Z z?Fi@R(?xH6MptS-9{Ej*rX5aSFz>gkv<a)^ydsxjpa9|Ru#mIom5|P~@$Zz)rzlRE z?z>9(@66>o871Db``+~;_0+y|_2GSg8ZeE7*OF1aZnsBl+4M)Qk_JI<XecP27x+e- z;wFQM6<CmGDbi(23|K`=zvD-~ImbT`u;F4Bh4S4toNi7VyJG*9!85fsuUao7Rhbw0 zb4_+zZjIGq7EfB%*zPNmx$quoahJO>%#f%lqV_~TNl1ESnHoty(kAsR8D0$FOuOZ# zAZ%&4kL9U8%zMnzh$V&1`P~%v4bfd_W`Scpn3R*Gjt3Fxk#KA}$5s}vd7!?`h~w70 zJ**-^7{N$&UPhy=kWTq!+wscs!euJOoF4B+W5U<)dI7KH({uETiNd!5Kh&J4_#N)~ zTMzfe#3K?q)oQ1ihN$n`Lw`MYndfaoD?N+&{^Q0$^I3Hr=0d@@{emMN*q+D0rVvb< zB8$dD;T)t)H+W|N(0hey+=0~a=!MTeg2G1vd+jD{`4dPk|JiESD_KoZq_9s>w7x*G zA*kSB(Wcp<I{c{j8a45|@%gb0Tf(I0T*2ny)e{X9nGE~W)<d%cb$(I<*{S2}f1e(R z5H(?(+?eR|6f!3e<{4-MS(2?c4oJ~Fjxu0{axPz^9#*#AmCCNJ=6V(s5!4>KynX+0 zj|fA0Jqct8_MrV%GZcJfy1-~F7)pdI#xEAbR=`VKM1c|j1UK*-`=L<jgmgKTUV|@P zf9QWR+e9$H=G6}yYR;*Xo@Xn%%y3h8!>)~-$WMqrx`{e5B19%v>MEytIkgueq$F<N z_O5Vo|8#h+JzAx0$MK-~=zGqqgGXjx179&__VTOqNf}20uV1$_s~n+$?sh8BY?!~- zzKEMgHJD^TOcE@>06N579xzrEYKsvkR0m70eV4yJt(xkZ=)aQi;izV5ww2jA{|cgK zWavNRVCaC3Hjhr-cDx^OB<AH_fIe&-zBIp9vTa{xUiLoPWUSupkM_TwMRpA9JTh(f zzxCIvA3LS(YuNH{OlD(Yq!KTq^N*-!`&tsFMyz%VS~&Q&U}>-h7F^C|rK${6#}K*L zi1uX<tL3ZD>8H~lC)k>3N7Ob}-4G~N&T$`3*1M|oaYZ9@NGI=L4)fu&GNRreMk0xx zF*dy$A~p5fYp1aAo~=+X5;;@dPO~#y>zK&gM()hWK-vJ`<D#7O1c#4&s0HOT&{rEn zUb(&L%3Y<-y>fvLhaVjL?8bd^NDS7MIx@9D{+SJo!Zh$^Anjfx2b_x*X9pRE5f#M& zZB?O;Nj?4aEy)i)eSw38Ps$yu2ecWU$1g_P+Ae>dICCx_2jdR0o0UKrqi1EIy{S_M z-R#Q{nYNi7d}G;_<9<*2S8wE?D?H|fLSBcSg;t8@2il+F9$4)!(E!%Yq`-ecE{(+r zjW%Or(dCNNleT^_G*h@GfAP>mnpXK%5G_Scxa{;IKB=tC^~ASx__XC`^blz+{9}V8 zdmkK0!D2{@5H?sPv_f^K@>j8_M{YL|>>ySG6q^Ew$a6&+!_Eb$Si3)mrEtf_#%b74 zvMh>6Ym<{++OB&(f4$yRH0P@KX<(y<nsBT(<Hj12TC`XD-s~iX=`nNG3%9N15KrLp zYU*W#%B%47je1AO<boa}yzEnq&1+Wb@Gng-a{Cs&&+ee7*1c$U%HO}^RiqI$_J#R^ z$Z{k29)wo^o;*2HFsJ*cJ6r)YjNyiJbz_r`levj_nlxaQ!vmm1Lw~1dmZB7ekjk6f zUTg1KRUs-tEn|^3P{NO!%!O1^3KZ{7FDv@^r-f?Kt@n0m2EBz{`MSMJALnqHPqc4u zJ=e)>$m&=Lx1;P$obcE|)jEVa<X$sWeLHk|XVMQ<=xbuI8doM(pz!np8ckeP?aBb8 zV~9~pqC4m>e50aXkR=Shz&ZA9+^#_iC?+Q37%K(H56g)vRb&@M9qV192D2@7XWS`z zdF5!T<D@sI=474xgg3v-8J7(u`_j<$1_!}><f*GOC7Wwbb)WAu9K0CK4!+ww(lr{5 zBFM|#{+2CIk2^lj?gCk#{@#7XS=Pp9;o6;h32eut)Vp4b9CfZH>irnothFYosEPgu zB|`?dM3o@OEkhCWSf$uqt$1P*t&|haON&baa;&7mVubVceH2>yu!dPX+HPjbR7du$ z4M#}X3rpR}g#hy=WBP`X{XU_DV`KXFg(uNA%-W%kx)%E*mc8;?J-;Bwx?*iQH=ueG z0A~$~BI~6AR|^A^R#yJZG)}QunGT@HZG|7gB*!S0w>ztY;_d#NZQ5-7Bi8gdqw`H? zN@Eu3DEmg|{nvXBn9Oreo3H=?24QJ#7!f!W0H96Sm@F{O7f7;tCys%Eh^6|Di6IJ5 zI5)8cmU%s606>bUQRe_?fy$TJdx{o=My2OT{#+lGlhwH0$byi*^IT~GwI|sOPKqx_ z%NY$CXU$#G=@_1M=P1qR8IM;ibFR&;nQ-&f84T;^ME#bQrOu;bweRh{LT?!m5#vWy zuUeK*6}v<@Ea3Ccgbj}jC6HrCCQF|@oDKLlWC_=pKj~ffs=NzBxS+7(RgY!ApgjD` z`8UD_<qLn6`#1`8#gA7dz>arxc^4tn#H34H&)_>!PQWcIr;2+AI$TW1WXsBFo#f2O zj@DnLTimQLB3w{#3=sfTUz8?bGeifDaf=#B07MuU_(e-Hznfbrm7B;_XCn^4!H8BZ z;1ubZ6qV|gm1uB*+gG*r_rc&NoH1BDfxh~<EfY_p<NLMm!^Y9&sE^+FhDZ9Pzws}< z)3H!2pb#E~n#Yv>P;Xg6B{npgEcNb;2iTrGBN^X^rO`9xFY~YOXsYYcUz<-Ass(OT z6$X!I82qyDAIlHH`(cy#mUsyA%y~cI)INoW#%<2#|JYsRou*{~nGd{II%$l&s9yDc z)!I5atS!5sP&jp<A(J(HprNULT#Z2FJry}>Ml^isd=cK1`>xk_F7Nu!sWi*Fz6&TF zC}pD(TzX7P7uvm@at}NNM~D7hkwk<DN!B#Edkp~ct8k^86@U>@Y@YW$3G2`1)Xi$w zcpY&y)7HJn{{1C?_IN7AJD2L8;N6O{pLPhyZN20o@&Q^BW{eTjmq(E5^yMYFT8tx2 zbP;f9iM+0ShtS{8X|#!$YTFJWM-_BbtOfMZ)+M+4#fjKwq(_L`2#GcdivaHkcChY2 zpK`ZXJx&^REy}D8e=}r}kz#_b;RF<LvBr~^ihe(Z!c|nmDnC%XD^=2GJ;f_<4FsE( z_m;{wdG7@EMEWY5C0hE<=9$EtzsE@|FH2>2TeR;!ABRz?ipL1<R=rs1c47~cql9<k z*`sZ}e~_MX?@1gjR)L=h-`Q1hV?k^Q&whu=Bt2DO)tXVH$8h7G>$45h20=8M8}Mm6 z_zfD8B}PuIvW)&_mktjGe|k<3B=Zp~R<OfxasE7#VMeqGO?yr_mftM`;|g8@y9u05 z5*PdsVs^~pe7A8%PsXteN-G!?T#4O`UqGLtkWYzrM?rm*h@MuL!-!T{&Zbhga3ZsO zCB{zErvpmY%_C_n9RL54UtW&&b+$pz#J<5~2ufFNMO_3NIttG75*Bd=V5ja`aTbsE zzw+M|_`k9Ob_Qm)L4iL1u8zU=Fu%~Sf15YB>JiRzuKyyM7!9p|nfl+?V7~tW<0N0n literal 0 HcmV?d00001 diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go new file mode 100644 index 000000000..ff557294e --- /dev/null +++ b/core/artwork/library_fs.go @@ -0,0 +1,44 @@ +package artwork + +import ( + "context" + "path/filepath" + + "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/model" +) + +// libraryView bundles the MusicFS for a library with its absolute root path, +// so readers can open library-relative paths through FS and compose absolute +// paths (for ffmpeg, which is path-based) via Abs. +type libraryView struct { + FS storage.MusicFS + absRoot string +} + +// Abs returns the absolute path for a library-relative path. Returns "" for an +// empty rel so callers (fromFFmpegTag) can treat it as "no path available". +func (v libraryView) Abs(rel string) string { + if rel == "" { + return "" + } + return filepath.Join(v.absRoot, rel) +} + +// loadLibraryView resolves the MusicFS and absolute root path in a single +// library lookup. +func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) { + lib, err := ds.Library(ctx).Get(libID) + if err != nil { + return libraryView{}, err + } + s, err := storage.For(lib.Path) + if err != nil { + return libraryView{}, err + } + fs, err := s.FS() + if err != nil { + return libraryView{}, err + } + return libraryView{FS: fs, absRoot: lib.Path}, nil +} diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go new file mode 100644 index 000000000..acf08fda3 --- /dev/null +++ b/core/artwork/library_fs_test.go @@ -0,0 +1,45 @@ +package artwork + +import ( + "context" + + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadLibraryView", Ordered, func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeAll(func() { + storagetest.Register("fake", &storagetest.FakeFS{}) + }) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}} + }) + + It("returns a view for a library backed by registered storage", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) + + lib, err := loadLibraryView(ctx, ds, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(lib.FS).ToNot(BeNil()) + Expect(lib.absRoot).To(Equal("fake:///music")) + }) + + It("returns an error when the library does not exist", func() { + _, err := loadLibraryView(ctx, ds, 999) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when the library path uses an unregistered scheme", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) + _, err := loadLibraryView(ctx, ds, 2) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 35d489b6c..8d7e14fd0 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -7,14 +7,13 @@ import ( "errors" "fmt" "io" - "path/filepath" + "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" @@ -24,12 +23,12 @@ import ( type albumArtworkReader struct { cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string - rootFolder string + a *artwork + provider external.Provider + album model.Album + updatedAt *time.Time + imgFiles []string // library-relative, forward-slash, no leading slash + lib libraryView } func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { @@ -41,13 +40,17 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) + if err != nil { + return nil, err + } a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""), + a: artwork, + provider: provider, + album: *al, + updatedAt: imagesUpdateAt, + imgFiles: imgFiles, + lib: lib, } a.cacheKey.artID = artID if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { @@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath) - ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath)) + embedRel := a.album.EmbedArtPath + ff = append(ff, + fromTag(ctx, a.lib.FS, embedRel), + fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), + ) case pattern == "external": ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern)) + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) } } return ff @@ -132,13 +138,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo var imgFiles []string var updatedAt time.Time for _, f := range folders { - path := f.AbsolutePath() - paths = append(paths, path) + paths = append(paths, f.AbsolutePath()) if f.ImagesUpdatedAt.After(updatedAt) { updatedAt = f.ImagesUpdatedAt } + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") for _, img := range f.ImageFiles { - imgFiles = append(imgFiles, filepath.Join(path, img)) + imgFiles = append(imgFiles, path.Join(rel, img)) } } @@ -179,8 +185,8 @@ func compareImageFiles(a, b string) int { b = strings.ToLower(b) // Extract base filenames without extensions - baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) - baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b)) + baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) + baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) // Compare base names first, then full paths if equal return cmp.Or( diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index a8a0eae3e..03412b6d9 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,7 +3,6 @@ package artwork import ( "context" "errors" - "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() { // Files should be sorted by base filename without extension, then by full path // "back" < "cover", so back.jpg comes first // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) - Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) - Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) + Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) + Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) }) It("prioritizes files without numeric suffixes", func() { @@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) }) It("handles case-insensitive sorting", func() { @@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) }) It("includes images from parent folder for multi-disc albums", func() { @@ -151,8 +150,8 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(*imagesUpdatedAt).To(Equal(expectedAt)) Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) }) It("does not query parent when parent ID is already in album folders", func() { @@ -179,7 +178,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (parent already in folder set) Expect(repo.getCallCount).To(Equal(0)) }) @@ -209,7 +208,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) // Get should not have been called (different parents) Expect(repo.getCallCount).To(Equal(0)) }) @@ -232,7 +231,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (single folder, no parent lookup) Expect(repo.getCallCount).To(Equal(0)) }) @@ -290,7 +289,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) Expect(repo.getCallCount).To(Equal(1)) }) }) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 96ba08b8f..37b7b6dee 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "slices" "strings" @@ -35,6 +36,7 @@ type artistReader struct { artistFolder string imgFiles []string imgFolderImgPath string // cached path from ArtistImageFolder lookup + lib libraryView } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A if err != nil { return nil, err } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) + if err != nil { + return nil, err + } + } a := &artistReader{ a: artwork, provider: provider, artist: *ar, artistFolder: artistFolder, imgFiles: imgFiles, + lib: lib, } // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can // change _after_ retrieving from external sources, making the key invalid @@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin case pattern == "image-folder": ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): - ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + if a.lib.FS != nil { + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + } default: - ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern)) + ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) } } return ff } -func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { +// fromArtistFolder walks up from artistFolder toward libPath looking for a +// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth +// and the library root: once we reach libPath (or if artistFolder is outside +// libPath), the walk stops. All reads go through libFS, which keeps artwork +// resolution scoped to the configured library. +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may + // return backslash separators on Windows. + rel = filepath.ToSlash(rel) current := artistFolder for range maxArtistFolderTraversalDepth { - if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { - return reader, path, nil + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil } - - parent := filepath.Dir(current) - if parent == current { - break + if rel == "." { + break // reached library root; don't traverse above it } - current = parent + rel = path.Dir(rel) + current = filepath.Dir(current) } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) } } -func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder) - fsys := os.DirFS(folder) - matches, err := fs.Glob(fsys, pattern) +// findImageInFolder globs libFS at relFolder for pattern and returns the first +// matching image. absFolder is used only for the returned display path and log +// messages so callers see absolute-looking paths consistent with the rest of +// the artwork pipeline. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err) + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) return nil, "", err } @@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos // suffixes (e.g., artist.jpg before artist.1.jpg) slices.SortFunc(imagePaths, compareImageFiles) - // Try to open files in sorted order for _, p := range imagePaths { - filePath := filepath.Join(folder, p) - f, err := os.Open(filePath) + f, err := libFS.Open(p) if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + log.Warn(ctx, "Could not open cover art file", "file", p, err) continue } - return f, filePath, nil + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() } func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 33dc6ed57..e2a1f2094 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "io/fs" "os" "path/filepath" "time" @@ -117,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() { var ( ctx context.Context tempDir string + libFS fs.FS testFunc sourceFunc ) BeforeEach(func() { ctx = context.Background() tempDir = GinkgoT().TempDir() + libFS = os.DirFS(tempDir) }) When("artist folder contains matching image", func() { @@ -134,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds and returns the image", func() { @@ -151,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() { }) }) + When("artist folder name contains glob metacharacters", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "Artist [Live]") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("treats the folder path literally when globbing through the library fs", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("bracketed artist image")) + reader.Close() + }) + }) + When("artist folder is empty but parent contains image", func() { BeforeEach(func() { // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ @@ -163,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(parentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in parent directory", func() { @@ -191,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(grandparentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in grandparent directory", func() { @@ -220,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("prioritizes the closest (artist folder) image", func() { @@ -246,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns the first valid image file in sorted order", func() { @@ -273,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { @@ -301,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "*.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") }) It("sorts case-insensitively", func() { @@ -327,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() { // Create non-matching files Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns an error", func() { @@ -346,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() { artistDir := filepath.Join(tempDir, "artist") Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("handles root boundary gracefully", func() { @@ -367,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() { restrictedFile := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("logs warning and continues searching", func() { @@ -397,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds artist.jpg in artist folder for single album artist", func() { diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 30d4968e1..de0a765f0 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -5,7 +5,7 @@ import ( "crypto/md5" "fmt" "io" - "os" + "path" "path/filepath" "strconv" "strings" @@ -13,7 +13,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,10 +23,11 @@ type discArtworkReader struct { a *artwork album model.Album discNumber int - imgFiles []string - discFolders map[string]bool + imgFiles []string // library-relative, forward-slash, no leading slash + discFoldersRel map[string]bool // library-relative folder paths isMultiFolder bool - firstTrackPath string + firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs + lib libraryView updatedAt *time.Time } @@ -57,18 +57,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - // Build disc folder set and find first track - discFolders := make(map[string]bool) - var firstTrackPath string + lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + if err != nil { + return nil, err + } + + // Build disc folder set and find first track. mf.Path is already library-relative. + var firstTrackRel string allFolderIDs := make(map[string]bool) for _, mf := range mfs { allFolderIDs[mf.FolderID] = true - if firstTrackPath == "" { - firstTrackPath = mf.Path + if firstTrackRel == "" { + firstTrackRel = filepath.ToSlash(mf.Path) } } - // Resolve folder IDs to absolute paths + // Resolve folder IDs to library-relative paths + discFoldersRel := make(map[string]bool) if len(allFolderIDs) > 0 { folderIDs := make([]string, 0, len(allFolderIDs)) for id := range allFolderIDs { @@ -81,7 +86,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } for _, f := range folders { - discFolders[f.AbsolutePath()] = true + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") + discFoldersRel[rel] = true } } @@ -92,9 +98,10 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID album: *al, discNumber: discNumber, imgFiles: imgFiles, - discFolders: discFolders, + discFoldersRel: discFoldersRel, isMultiFolder: isMultiFolder, - firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath), + firstTrackRel: firstTrackRel, + lib: lib, updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID @@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath)) + ff = append(ff, + fromTag(ctx, d.lib.FS, d.firstTrackRel), + fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)), + ) case pattern == "external": // Not supported for disc art, silently ignore case pattern == "discsubtitle": @@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range d.imgFiles { - _, name := filepath.Split(file) - stem := strings.TrimSuffix(name, filepath.Ext(name)) + name := path.Base(file) + stem := strings.TrimSuffix(name, path.Ext(name)) if !strings.EqualFold(stem, subtitle) { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -214,8 +224,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string return func() (io.ReadCloser, string, error) { var fallbacks []string for _, file := range d.imgFiles { - _, name := filepath.Split(file) - name = strings.ToLower(name) + name := strings.ToLower(path.Base(file)) match, err := filepath.Match(pattern, name) if err != nil { log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) @@ -230,7 +239,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string if num != d.discNumber { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -239,14 +248,14 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string } } - if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] { + if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] { continue } fallbacks = append(fallbacks, file) } for _, file := range fallbacks { - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index 7b633342f..8264ee27b 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -74,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + // createFile creates the file on disk and returns its library-relative forward-slash path. + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath + } + + // removeFile removes a library-relative file from disk. + removeFile := func(relPath string) { + Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed()) } It("matches file with disc number in single-folder album", func() { f1 := createFile("album/disc1.jpg") f2 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -101,9 +108,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("matches file without number in single-folder album (shared disc art)", func() { f1 := createFile("album/cover.png") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.*") @@ -118,9 +126,10 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/shellac.png") makeReader := func(discNum int) *discArtworkReader { return &discArtworkReader{ - discNumber: discNum, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNum, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } } @@ -139,9 +148,10 @@ var _ = Describe("Disc Artwork Reader", func() { f2 := createFile("album/disc1.jpg") f3 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1, f2, f3}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 2, + imgFiles: []string{f1, f2, f3}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -163,9 +173,10 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/cover.png") f2 := createFile("album/disc1.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") @@ -191,9 +202,10 @@ var _ = Describe("Disc Artwork Reader", func() { createFile("album/disc2.jpg"), } reader := &discArtworkReader{ - discNumber: discNumber, - imgFiles: files, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -210,12 +222,13 @@ var _ = Describe("Disc Artwork Reader", func() { It("tries the next fallback candidate when the first one cannot be opened", func() { f1 := createFile("album/cover.jpg") f2 := createFile("album/cover.png") - // Remove f1 so os.Open will fail on it; f2 should still win. - Expect(os.Remove(f1)).To(Succeed()) + // Remove f1 so Open will fail on it; f2 should still win. + removeFile(f1) reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.*") @@ -234,15 +247,16 @@ var _ = Describe("Disc Artwork Reader", func() { // that first file is unreadable. f1 := createFile("album/stale/cover.png") f2 := createFile("album/cover.png") - Expect(os.Remove(f1)).To(Succeed()) + removeFile(f1) reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, - discFolders: map[string]bool{ - filepath.Join(tmpDir, "album"): true, - filepath.Join(tmpDir, "album/stale"): true, + discFoldersRel: map[string]bool{ + "album": true, + "album/stale": true, }, isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.png") @@ -260,9 +274,10 @@ var _ = Describe("Disc Artwork Reader", func() { createFile("album/disc2.jpg"), } reader := &discArtworkReader{ - discNumber: discNumber, - imgFiles: files, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, pattern) @@ -282,10 +297,11 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/cd1/disc.jpg") f2 := createFile("album/cd2/disc.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -300,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", func() { // disc2.jpg in cd1 folder should match disc 2, not disc 1 f1 := createFile("album/cd1/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 2, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -317,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("does not match disc2.jpg when looking for disc 1", func() { f1 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -339,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath } It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() { @@ -351,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -366,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 2, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks") @@ -381,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -394,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -407,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() { Describe("discArtworkReader", func() { Describe("fromDiscArtPriority", func() { - var reader *discArtworkReader + var ( + reader *discArtworkReader + tmpDir string + ) BeforeEach(func() { + tmpDir = GinkgoT().TempDir() reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFolders: map[string]bool{"/music/album/cd2": true}, + discNumber: 2, + isMultiFolder: true, + discFoldersRel: map[string]bool{"music/album/cd2": true}, imgFiles: []string{ - "/music/album/cd1/disc.jpg", - "/music/album/cd2/disc.jpg", - "/music/album/cd2/disc2.jpg", + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", }, - firstTrackPath: "/music/album/cd2/track1.flac", + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } }) diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index cf25c8f5d..eac3c5e70 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -15,6 +15,7 @@ type mediafileArtworkReader struct { a *artwork mediafile model.MediaFile album model.Album + lib libraryView } func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { @@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, + lib: lib, } a.cacheKey.artID = artID a.cacheKey.lastUpdate = mf.UpdatedAt @@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time { func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { var ff []sourceFunc if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - path := a.mediafile.AbsolutePath() ff = []sourceFunc{ - fromTag(ctx, path), - fromFFmpegTag(ctx, a.a.ffmpeg, path), + fromTag(ctx, a.lib.FS, a.mediafile.Path), + fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), } } // For multi-disc albums, fall back to disc artwork first; for single-disc albums, diff --git a/core/artwork/sources.go b/core/artwork/sources.go index d830593fc..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -5,9 +5,9 @@ import ( "context" "fmt" "io" + "io/fs" "net/http" "net/url" - "os" "path/filepath" "reflect" "regexp" @@ -53,7 +53,7 @@ func (f sourceFunc) String() string { return name } -func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc { +func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range files { _, name := filepath.Split(file) @@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc if !match { continue } - f, err := os.Open(file) + f, err := libFS.Open(file) if err != nil { log.Warn(ctx, "Could not open cover art file", "file", file, err) continue } - return f, file, err + return f, file, nil } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } @@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{ regexp.MustCompile(`(?i).*cover.*`), } -func fromTag(ctx context.Context, path string) sourceFunc { +func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { return func() (io.ReadCloser, string, error) { - if path == "" { + if relPath == "" { return nil, "", nil } - f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast)) + f, err := libFS.Open(relPath) if err != nil { return nil, "", err } + rs, ok := f.(io.ReadSeeker) + if !ok { + f.Close() + return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath) + } + tf, err := taglib.OpenStream(rs, + taglib.WithReadStyle(taglib.ReadStyleFast), + taglib.WithFilename(relPath), + ) + if err != nil { + f.Close() + return nil, "", err + } + // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() + defer tf.Close() - images := f.Properties().Images + images := tf.Properties().Images if len(images) == 0 { - return nil, "", fmt.Errorf("no embedded image found in %s", path) + return nil, "", fmt.Errorf("no embedded image found in %s", relPath) } - imageIndex := findBestImageIndex(ctx, images, path) - data, err := f.Image(imageIndex) + imageIndex := findBestImageIndex(ctx, images, relPath) + data, err := tf.Image(imageIndex) if err != nil || len(data) == 0 { - return nil, "", fmt.Errorf("could not load embedded image from %s", path) + return nil, "", fmt.Errorf("could not load embedded image from %s", relPath) } - return io.NopCloser(bytes.NewReader(data)), path, nil + return io.NopCloser(bytes.NewReader(data)), relPath, nil } } @@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str return 0 } +// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess +// and cannot read from arbitrary fs.FS implementations; piping via stdin is a +// non-trivial refactor with stream/seek implications. +// +// TODO(artwork-musicfs): when the storage backing the library is not local +// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source +// func to return (nil, "", nil) so callers fall through cleanly. func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { return func() (io.ReadCloser, string, error) { if path == "" { diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go new file mode 100644 index 000000000..4282575a5 --- /dev/null +++ b/core/artwork/sources_internal_test.go @@ -0,0 +1,92 @@ +package artwork + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("fromExternalFile", func() { + It("opens a matching file via the library FS", func() { + fsys := fstest.MapFS{ + "Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("cover-bytes"))) + Expect(path).To(Equal("Artist/Album/cover.jpg")) + }) + + It("returns an error when no file matches", func() { + fsys := fstest.MapFS{ + "Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*") + _, _, err := f() + Expect(err).To(HaveOccurred()) + }) + + It("skips files that fail to open and tries the next match", func() { + fsys := fstest.MapFS{ + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + // "missing/cover.jpg" is in candidates but not in the FS — should be skipped. + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("a"))) + Expect(path).To(Equal("a/cover.jpg")) + }) +}) + +var _ = Describe("fromTag", func() { + It("opens an embedded image via fs.FS", func() { + fsys := os.DirFS("tests/fixtures/artist/an-album") + f := fromTag(GinkgoT().Context(), fsys, "test.mp3") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("test.mp3")) + b, _ := io.ReadAll(r) + Expect(b).ToNot(BeEmpty()) + }) + + It("returns nil reader when the relative path is empty", func() { + f := fromTag(GinkgoT().Context(), os.DirFS("."), "") + r, _, err := f() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + }) + + It("errors when the FS file is not seekable", func() { + fsys := nonSeekableFS{data: []byte("garbage")} + f := fromTag(GinkgoT().Context(), fsys, "x.mp3") + _, _, err := f() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not seekable")) + }) +}) + +// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file. +type nonSeekableFS struct{ data []byte } + +func (n nonSeekableFS) Open(name string) (fs.File, error) { + return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil +} + +type nonSeekableFile struct{ r *bytes.Reader } + +func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) } +func (n *nonSeekableFile) Close() error { return nil } +func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index abeda5c9e..80790c8d6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -57,6 +58,11 @@ func New() FFmpeg { return &ffmpeg{} } +// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the +// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to +// detect this specific case and fall back to static resize. +var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp") + const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" @@ -86,6 +92,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max if err != nil { return nil, err } + if !animWebP.has(cmdPath, "libwebp_anim") { + return nil, ErrAnimatedWebPUnsupported + } args := []string{cmdPath, "-i", "pipe:0"} if maxSize > 0 { @@ -98,6 +107,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max return e.start(ctx, args, reader) } +// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word +// match of encoder name. The output has rows like " V....D libwebp_anim ..." +// where the name is the 2nd whitespace-separated field. +func parseEncodersOutput(out []byte, name string) bool { + for line := range strings.SplitSeq(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == name { + return true + } + } + return false +} + func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err @@ -538,6 +560,49 @@ func ffmpegCmd() (string, error) { return ffmpegPath, ffmpegErr } +type encoderProbeState uint8 + +const ( + encoderProbeUnknown encoderProbeState = iota + encoderProbeAvailable + encoderProbeUnavailable +) + +type encoderProbe struct { + mu sync.Mutex + state encoderProbeState +} + +func (p *encoderProbe) has(cmdPath, encoder string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.state { + case encoderProbeAvailable: + return true + case encoderProbeUnavailable: + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec + if err != nil { + log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err) + return false + } + + if parseEncodersOutput(out, encoder) { + p.state = encoderProbeAvailable + return true + } + + p.state = encoderProbeUnavailable + log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images", + "path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)") + return false +} + // These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead. var ( ffOnce sync.Once @@ -545,4 +610,5 @@ var ( ffmpegErr error probeOnce sync.Once probeAvail bool + animWebP encoderProbe ) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 01b284172..1649015d9 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,8 +3,10 @@ package ffmpeg import ( "context" "os" + "os/exec" "path/filepath" "runtime" + "strings" sync "sync" "testing" "time" @@ -693,4 +695,57 @@ var _ = Describe("ffmpeg", func() { }) }) }) + + Describe("parseEncodersOutput", func() { + const sample = `Encoders: + V..... = Video + ------ + V....D apng APNG (Animated Portable Network Graphics) image + V....D libwebp_anim libwebp WebP image (codec webp) + V....D libwebp libwebp WebP image (codec webp) + A....D aac AAC (Advanced Audio Coding) +` + It("returns true when the encoder is present", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue()) + }) + It("returns false when the encoder is absent", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse()) + }) + It("does not match partial names", func() { + // libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word. + stripped := `Encoders: + V....D libwebp libwebp WebP image (codec webp) +` + Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse()) + }) + It("handles empty output", func() { + Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse()) + }) + }) + + Describe("ConvertAnimatedImage", func() { + // Point ffmpegCmd at a stand-in binary that produces empty `-encoders` + // output so hasAnimatedWebPEncoder returns false. /usr/bin/true is + // portable across POSIX systems. + It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() { + truePath, err := exec.LookPath("true") + if err != nil { + Skip("true(1) not available") + } + origPath, origErr := ffmpegPath, ffmpegErr + ffmpegPath = truePath + ffmpegErr = nil + defer func() { + ffmpegPath, ffmpegErr = origPath, origErr + }() + + ff := &ffmpeg{} + _, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75) + Expect(err).To(MatchError(ErrAnimatedWebPUnsupported)) + }) + }) }) From e6680c904b52cd8481a8f29530816ed07728ebb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 27 Apr 2026 12:20:27 -0400 Subject: [PATCH 205/596] fix(playlists): allow toggling auto-import and avoid unnecessary artwork reloads (#5421) * fix(playlists): allow toggling auto-import (sync) via REST API The updatePlaylistEntity handler was not applying the sync field from incoming requests, causing the auto-import toggle in the UI to have no effect. Apply the sync value for file-backed playlists only. * fix(playlists): enhance update logic for playlist metadata and sync toggle Signed-off-by: Deluan <deluan@navidrome.org> * fix(playlists): address code review feedback - Add pointer equality short-circuit in rulesEqual before reflect.DeepEqual - Guard against empty ID in Put's partial-update path - Only apply Sync when it actually differs from current value, preventing zero-value overwrites from partial payloads * fix(playlists): remove unused parameters from Update method Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/playlists/rest_adapter.go | 54 ++++++++++++++++++---- core/playlists/rest_adapter_test.go | 70 +++++++++++++++++++++++++++++ model/playlist.go | 2 +- persistence/playlist_repository.go | 9 +++- tests/mock_playlist_repo.go | 2 +- 5 files changed, 125 insertions(+), 12 deletions(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3fecda0d5..c9b7c4ea6 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -3,9 +3,11 @@ package playlists import ( "context" "errors" + "reflect" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" ) @@ -32,8 +34,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) +func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -77,7 +79,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -93,11 +95,45 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { return rest.ErrPermissionDenied } - // Apply ownership change (admin only) - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID + + contentChanged := entity.Name != current.Name || + entity.Comment != current.Comment || + (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || + !rulesEqual(current.Rules, entity.Rules) + + if contentChanged { + if entity.OwnerID != "" { + current.OwnerID = entity.OwnerID + } + current.Rules = entity.Rules + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + } + return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - // Apply smart playlist rules update - current.Rules = entity.Rules - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) + + // Only sync/public changed — skip updatedAt so cover art URLs stay stable + var cols []string + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + cols = append(cols, "sync") + } + if current.Public != entity.Public { + current.Public = entity.Public + cols = append(cols, "public") + } + if len(cols) == 0 { + return nil + } + return s.ds.Playlist(ctx).Put(current, cols...) +} + +func rulesEqual(a, b *criteria.Criteria) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return reflect.DeepEqual(a, b) } diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 097bc6310..90d22327a 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -142,6 +142,76 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) + It("allows toggling sync for file-backed playlists", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["file-pls"] = &model.Playlist{ + ID: "file-pls", + Name: "File Playlist", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "File Playlist", Sync: false} + err := repo.Update("file-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("does not allow setting sync on non-file-backed playlists", func() { + mockPlsRepo.Data["manual-pls"] = &model.Playlist{ + ID: "manual-pls", + Name: "Manual Playlist", + OwnerID: "user-1", + Path: "", + Sync: false, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Manual Playlist", Sync: true} + err := repo.Update("manual-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) + }) + + It("does not bump updatedAt when only public changes", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["pls-pub"] = &model.Playlist{ + ID: "pls-pub", + Name: "My Playlist", + OwnerID: "user-1", + Public: false, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "My Playlist", Public: true} + err := repo.Update("pls-pub", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("bumps updatedAt when name changes along with sync", func() { + mockPlsRepo.Data["file-pls2"] = &model.Playlist{ + ID: "file-pls2", + Name: "Old Name", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "New Name", Sync: false} + err := repo.Update("file-pls2", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("New Name")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) diff --git a/model/playlist.go b/model/playlist.go index e2f93993d..dc549f039 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -123,7 +123,7 @@ type PlaylistRepository interface { ResourceRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) - Put(pls *Playlist) error + Put(pls *Playlist, cols ...string) error Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9bbc41c5c..4152505d2 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -97,8 +97,15 @@ func (r *playlistRepository) Delete(id string) error { return r.delete(And{Eq{"id": id}, r.userFilter()}) } -func (r *playlistRepository) Put(p *model.Playlist) error { +func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { pls := dbPlaylist{Playlist: *p} + if len(cols) > 0 { + if pls.ID == "" { + return errors.New("playlist id is required for partial update") + } + _, err := r.put(pls.ID, pls, cols...) + return err + } if pls.ID == "" { pls.CreatedAt = time.Now() } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9bdc52152..9b38ea5b5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -45,7 +45,7 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { +func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } From 259c1a9484482fcc13d77812089fb3be454a5202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 27 Apr 2026 17:50:09 -0400 Subject: [PATCH 206/596] feat(subsonic): add sonicSimilarity extension as plugin capability (#5419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): add sonicSimilarity capability types Defines the SonicSimilarity plugin capability interface with GetSonicSimilarTracks and FindSonicPath methods, along with their request/response types. * feat(sonic): add core sonic similarity service Implements the Sonic service with HasProvider, GetSonicSimilarTracks, and FindSonicPath, delegating to the PluginLoader and using the Matcher for index-preserving library resolution. * test(sonic): add sonic service unit tests Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with mock plugin loader and provider, verifying error propagation and successful match resolution via the library matcher. * feat(matcher): add MatchSongsToLibraryMap for index-preserving matching Adds a new method alongside MatchSongsToLibrary that returns a map[int]MediaFile keyed by input song index rather than a flat slice, enabling callers to correlate similarity scores back to the original position in the results. * fix(sonic): check provider availability before MediaFile lookup Avoids unnecessary DB call when no plugin is available, and ensures the correct error path is tested. * feat(plugins): add sonic similarity adapter Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the plugin system to the core sonic service via Extism plugin functions. Reuses existing songRefsToAgentSongs helper for SongRef conversion. * feat(plugins): add LoadSonicSimilarity to plugin manager Adds Manager.LoadSonicSimilarity method following the pattern of LoadLyricsProvider, enabling the core sonic service to load a SonicSimilarityAdapter from a named plugin. * feat(subsonic): add sonicMatch response type Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results. * feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types. * feat(subsonic): advertise sonicSimilarity extension when plugin available Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter. * feat(subsonic): wire sonic similarity service into router Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go. * fix(e2e): add sonic parameter to subsonic.New call in e2e tests * test(subsonic): add sonicSimilarity extension advertisement tests Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin. * feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints Handlers return ErrorDataNotFound when no sonic service is configured, preventing nil panics. E2e tests verify both endpoints return proper error responses when no plugin is available. * fix(subsonic): return HTTP 404 when no sonic similarity plugin available Endpoints are always registered but return 404 when no provider is available, rather than a subsonic error code 70. * refactor: clean up sonic similarity code after review Extract shared helpers to reduce duplication across the sonic similarity implementation: loadAllMatches in matcher consolidates the 4-phase matching pipeline, songRefToAgentSong eliminates per-iteration slice allocation in the adapter, sonicMatchResponse deduplicates response building in handlers, and a package-level constant replaces raw capability name strings in core/sonic. * fix empty response shapes Signed-off-by: Deluan <deluan@navidrome.org> * test(plugins): add testdata plugin and e2e tests for sonic similarity Add a test-sonic-similarity WASM plugin that implements both GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity PDK. The plugin returns deterministic test data with decreasing similarity scores and supports error injection via config. Adapter tests verify the full round-trip through the WASM plugin including error handling. Also includes regenerated PDK code from make gen. * docs: update README to include new capabilities and usage examples for plugins Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): enhance sonic similarity tests with additional scenarios and mock provider Signed-off-by: Deluan <deluan@navidrome.org> * fix: address PR review feedback for sonic similarity Fix incorrect field names in README documentation ({from, to} → {startSong, endSong}) and remove unnecessary XML serialization test from e2e suite since OpenSubsonic endpoints only use JSON. * refactor: rename Matcher methods for conciseness Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to MatchSongsIndexed. The Matcher receiver already establishes the "to library" context, making that suffix redundant, and "Indexed" better describes the intent (preserving input ordering) than "Map" which describes the data structure. * refactor: standardize variable naming for media files in sonic path methods Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify plugin loading by introducing adapter constructors Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/wire_gen.go | 6 +- cmd/wire_injectors.go | 3 + core/external/provider.go | 4 +- core/matcher/matcher.go | 69 +- core/matcher/matcher_test.go | 121 +++- core/sonic/sonic.go | 130 ++++ core/sonic/sonic_suite_test.go | 17 + core/sonic/sonic_test.go | 146 ++++ plugins/README.md | 651 +++++++++--------- plugins/capabilities/lyrics.yaml | 6 + plugins/capabilities/scrobbler.yaml | 6 + plugins/capabilities/sonic_similarity.go | 32 + plugins/capabilities/sonic_similarity.yaml | 92 +++ plugins/lyrics_adapter.go | 4 + plugins/manager.go | 64 +- plugins/metadata_agent.go | 36 +- plugins/pdk/go/lyrics/lyrics.go | 3 + plugins/pdk/go/lyrics/lyrics_stub.go | 3 + plugins/pdk/go/scrobbler/scrobbler.go | 3 + plugins/pdk/go/scrobbler/scrobbler_stub.go | 3 + .../pdk/go/sonicsimilarity/sonicsimilarity.go | 136 ++++ .../sonicsimilarity/sonicsimilarity_stub.go | 71 ++ .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../rust/nd-pdk-capabilities/src/lyrics.rs | 4 + .../rust/nd-pdk-capabilities/src/scrobbler.rs | 4 + .../src/sonicsimilarity.rs | 141 ++++ plugins/scrobbler_adapter.go | 14 + plugins/sonic_similarity_adapter.go | 92 +++ plugins/sonic_similarity_adapter_test.go | 110 +++ plugins/testdata/test-sonic-similarity/go.mod | 16 + plugins/testdata/test-sonic-similarity/go.sum | 14 + .../testdata/test-sonic-similarity/main.go | 71 ++ .../test-sonic-similarity/manifest.json | 7 + server/e2e/e2e_suite_test.go | 1 + server/e2e/subsonic_sonic_similarity_test.go | 272 ++++++++ server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 6 + server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/opensubsonic.go | 8 +- server/subsonic/opensubsonic_test.go | 87 ++- server/subsonic/playlists_test.go | 4 +- ... SonicMatches with data should match .JSON | 25 + ...s SonicMatches with data should match .XML | 10 + ...nicMatches without data should match .JSON | 8 + ...onicMatches without data should match .XML | 1 + server/subsonic/responses/responses.go | 6 + server/subsonic/responses/responses_test.go | 29 + server/subsonic/searching_test.go | 2 +- server/subsonic/sonic_similarity.go | 69 ++ server/subsonic/transcode_test.go | 6 +- 51 files changed, 2159 insertions(+), 461 deletions(-) create mode 100644 core/sonic/sonic.go create mode 100644 core/sonic/sonic_suite_test.go create mode 100644 core/sonic/sonic_test.go create mode 100644 plugins/capabilities/sonic_similarity.go create mode 100644 plugins/capabilities/sonic_similarity.yaml create mode 100644 plugins/pdk/go/sonicsimilarity/sonicsimilarity.go create mode 100644 plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs create mode 100644 plugins/sonic_similarity_adapter.go create mode 100644 plugins/sonic_similarity_adapter_test.go create mode 100644 plugins/testdata/test-sonic-similarity/go.mod create mode 100644 plugins/testdata/test-sonic-similarity/go.sum create mode 100644 plugins/testdata/test-sonic-similarity/main.go create mode 100644 plugins/testdata/test-sonic-similarity/manifest.json create mode 100644 server/e2e/subsonic_sonic_similarity_test.go create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML create mode 100644 server/subsonic/sonic_similarity.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f66df2e75..0939eef4d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" @@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } @@ -219,7 +221,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index d87a8d6d3..bb5c5b5f3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -43,9 +44,11 @@ var allProviders = wire.NewSet( metrics.GetPrometheusInstance, db.Db, plugins.GetManager, + sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/core/external/provider.go b/core/external/provider.go index 7e8aaba1c..4f3295cc7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode } if err == nil && len(songs) > 0 { - return e.matcher.MatchSongsToLibrary(ctx, songs, count) + return e.matcher.MatchSongs(ctx, songs, count) } // Fallback to existing similar artists + top songs algorithm @@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT } } - mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count) + mfs, err := e.matcher.MatchSongs(ctx, songs, count) if err != nil { return nil, err } diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index cf6c99e28..54c2a368e 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// MatchSongs matches agent song results to local library tracks using a multi-phase // matching algorithm that prioritizes accuracy over recall. // // # Algorithm Overview @@ -107,25 +107,58 @@ func New(ds model.DataStore) *Matcher { // // Returns up to 'count' MediaFiles from the library that best match the input songs, // preserving the original order from the agent. Songs that cannot be matched are skipped. -func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - idMatches, err := m.loadTracksByID(ctx, songs) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by title: %w", err) +func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + if len(songs) == 0 { + return nil, nil } - return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil +} + +// MatchSongsIndexed matches agent song results to local library tracks and returns a map +// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. +// This preserves original indices, allowing callers to correlate results back to the input slice. +func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { + if len(songs) == 0 { + return nil, nil + } + + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + + result := make(map[int]model.MediaFile, len(songs)) + for i, t := range songs { + if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { + result[i] = mf + } + } + return result, nil +} + +func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { + byID, err = m.loadTracksByID(ctx, songs) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) + } + byMBID, err = m.loadTracksByMBID(ctx, songs, byID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) + } + byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + return byID, byMBID, byISRC, byTitle, nil } // songMatchedIn checks if a song has already been matched in any of the provided match maps. diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 8996cf71d..42e3ec88d 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -75,7 +75,7 @@ var _ = Describe("Matcher", func() { Return(artistTracks, nil).Maybe() } - Describe("MatchSongsToLibrary", func() { + Describe("MatchSongs", func() { Context("matching by direct ID", func() { It("matches songs with an ID field to MediaFiles by ID", func() { conf.Server.Matcher.FuzzyThreshold = 100 @@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() { } expectIDPhase(model.MediaFiles{idMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-1")) @@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() { } expectMBIDPhase(model.MediaFiles{mbidMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-mbid")) @@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() { } expectISRCPhase(model.MediaFiles{isrcMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-isrc")) @@ -142,7 +142,7 @@ var _ = Describe("Matcher", func() { ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", } setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-title")) @@ -157,7 +157,7 @@ var _ = Describe("Matcher", func() { ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-fuzzy")) @@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() { {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, } setupTitleOnlyExpectations(differentTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) }) @@ -189,7 +189,7 @@ var _ = Describe("Matcher", func() { ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("br-live")) @@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() { ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", } setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) Expect(result[0].ID).To(Equal("br")) @@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() { } expectIDPhase(model.MediaFiles{idMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-id")) @@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() { {ID: "c", Title: "Song C", Artist: "Artist"}, } setupTitleOnlyExpectations(tracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 2) + result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) }) @@ -256,13 +256,60 @@ var _ = Describe("Matcher", func() { Context("empty input", func() { It("returns empty results for no songs", func() { - result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5) + result, err := m.MatchSongs(ctx, []agents.Song{}, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) }) }) }) + Describe("MatchSongsIndexed", func() { + It("returns index-keyed map of matched songs", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Song One", Artist: "Artist A"}, + {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, + {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} + mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} + + expectIDPhase(model.MediaFiles{mf1, mf2}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-1")) + Expect(result[1].ID).To(Equal("track-2")) + _, exists := result[2] + Expect(exists).To(BeFalse()) + }) + + It("preserves original indices when some songs don't match", func() { + songs := []agents.Song{ + {Name: "Unknown Song", Artist: "Unknown Artist"}, + {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} + + expectIDPhase(model.MediaFiles{mf1}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + _, exists := result[0] + Expect(exists).To(BeFalse()) + Expect(result[1].ID).To(Equal("track-1")) + }) + + It("returns empty map for empty input", func() { + result, err := m.MatchSongsIndexed(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + Describe("specificity level matching", func() { BeforeEach(func() { conf.Server.Matcher.FuzzyThreshold = 100 @@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(3)) @@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -495,7 +542,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -515,7 +562,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -535,7 +582,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -556,7 +603,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -577,7 +624,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -603,7 +650,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -620,7 +667,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -640,7 +687,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -657,7 +704,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -677,7 +724,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -694,7 +741,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -711,7 +758,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -737,7 +784,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -757,7 +804,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(3)) @@ -778,7 +825,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) - result, err := m.MatchSongsToLibrary(ctx, songs, 2) + result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go new file mode 100644 index 000000000..19eb69c65 --- /dev/null +++ b/core/sonic/sonic.go @@ -0,0 +1,130 @@ +package sonic + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const capabilitySonicSimilarity = "SonicSimilarity" + +type SimilarResult struct { + Song agents.Song + Similarity float64 +} + +type SimilarMatch struct { + MediaFile model.MediaFile + Similarity float64 +} + +type Provider interface { + GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error) + FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error) +} + +type PluginLoader interface { + PluginNames(capability string) []string + LoadSonicSimilarity(name string) (Provider, bool) +} + +type Sonic struct { + ds model.DataStore + pluginLoader PluginLoader + matcher *matcher.Matcher +} + +func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic { + return &Sonic{ + ds: ds, + pluginLoader: pluginLoader, + matcher: matcher, + } +} + +func (s *Sonic) HasProvider() bool { + return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 +} + +func (s *Sonic) loadProvider() (Provider, error) { + names := s.pluginLoader.PluginNames(capabilitySonicSimilarity) + if len(names) == 0 { + return nil, model.ErrNotFound + } + provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0]) + if !ok { + return nil, model.ErrNotFound + } + return provider, nil +} + +func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) { + songs := make([]agents.Song, len(results)) + for i, r := range results { + songs[i] = r.Song + } + + matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs) + if err != nil { + return nil, fmt.Errorf("matching songs to library: %w", err) + } + + var matches []SimilarMatch + for i, r := range results { + if mf, ok := matchMap[i]; ok { + matches = append(matches, SimilarMatch{ + MediaFile: mf, + Similarity: r.Similarity, + }) + } + } + return matches, nil +} + +func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + mf, err := s.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("getting media file %s: %w", id, err) + } + + results, err := provider.GetSonicSimilarTracks(ctx, mf, count) + if err != nil { + log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} + +func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + startMF, err := s.ds.MediaFile(ctx).Get(startID) + if err != nil { + return nil, fmt.Errorf("getting start media file %s: %w", startID, err) + } + endMF, err := s.ds.MediaFile(ctx).Get(endID) + if err != nil { + return nil, fmt.Errorf("getting end media file %s: %w", endID, err) + } + + results, err := provider.FindSonicPath(ctx, startMF, endMF, count) + if err != nil { + log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} diff --git a/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go new file mode 100644 index 000000000..7058e5de9 --- /dev/null +++ b/core/sonic/sonic_suite_test.go @@ -0,0 +1,17 @@ +package sonic_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSonic(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Sonic Suite") +} diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go new file mode 100644 index 000000000..81739b726 --- /dev/null +++ b/core/sonic/sonic_test.go @@ -0,0 +1,146 @@ +package sonic_test + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockPluginLoader struct { + names []string + provider sonic.Provider + loadOk bool +} + +func (m *mockPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return m.provider, m.loadOk +} + +type mockProvider struct { + similarResults []sonic.SimilarResult + similarErr error + pathResults []sonic.SimilarResult + pathErr error +} + +func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.similarResults, m.similarErr +} + +func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.pathResults, m.pathErr +} + +var _ = Describe("Sonic", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + loader *mockPluginLoader + service *sonic.Sonic + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{} + loader = &mockPluginLoader{} + }) + + Describe("HasProvider", func() { + It("returns false when no plugins available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeFalse()) + }) + + It("returns true when a plugin is available", func() { + loader.names = []string{"test-plugin"} + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeTrue()) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when media file not found", func() { + loader.names = []string{"test-plugin"} + loader.provider = &mockProvider{} + loader.loadOk = true + ds.MockedMediaFile = &tests.MockMediaFileRepo{} + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10) + Expect(err).To(HaveOccurred()) + }) + + It("returns matched results from plugin", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{ + similarResults: []sonic.SimilarResult{ + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + }, + } + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).ToNot(HaveOccurred()) + Expect(matches).To(HaveLen(1)) + Expect(matches[0].MediaFile.ID).To(Equal("song-2")) + Expect(matches[0].Similarity).To(Equal(0.85)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when plugin call fails", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{pathErr: errors.New("plugin error")} + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/plugins/README.md b/plugins/README.md index e37a94d7a..048cf549d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,8 +1,8 @@ # Navidrome Plugin System -Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access. +Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access. -The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). +The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). **Essential Extism Resources:** - [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture @@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Capabilities](#capabilities) - [MetadataAgent](#metadataagent) - [Scrobbler](#scrobbler) + - [Lyrics](#lyrics) + - [SonicSimilarity](#sonicsimilarity) + - [TaskWorker](#taskworker) - [Lifecycle](#lifecycle) + - [SchedulerCallback](#schedulercallback) + - [WebSocketCallback](#websocketcallback) - [Host Services](#host-services) - - [HTTP Requests](#http-requests) + - [HTTP](#http) - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) + - [Task](#task) - [WebSocket](#websocket) - [Library](#library) - [Artwork](#artwork) @@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing: 1. **`manifest.json`** – Plugin metadata (name, author, version, permissions) 2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions -### Plugin Package Structure - -``` -my-plugin.ndp (zip archive) -├── manifest.json # Required: Plugin metadata -└── plugin.wasm # Required: Compiled WebAssembly module -``` - ### Plugin Naming Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field: @@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example: "version": "1.0.0", "description": "What this plugin does", "website": "https://example.com", + "config": { + "schema": { ... }, + "uiSchema": { ... } + }, "permissions": { "http": { "reason": "Fetch metadata from external API", @@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` +**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` + +#### Config Definition + +The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI: + +```json +{ + "config": { + "schema": { + "type": "object", + "properties": { + "api_key": { "type": "string", "title": "API Key" }, + "max_retries": { "type": "integer", "default": 3 } + }, + "required": ["api_key"] + }, + "uiSchema": { + "api_key": { "ui:widget": "password" } + } + } +} +``` + #### Experimental Features Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: @@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ```json { - "name": "Threaded Plugin", - "author": "Author Name", - "version": "1.0.0", "experimental": { "threads": { "reason": "Required for concurrent audio processing" @@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ## Capabilities -Capabilities define what your plugin can do. They're automatically detected based on which functions you export. +Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities. ### MetadataAgent -Provides artist and album metadata. Export one or more of these functions: +Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. -| Function | Input | Output | Description | -|---------------------------|----------------------------|----------------------------------|----------------------| -| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | -| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | -| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | -| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | -| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | -| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | -| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | -| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | - -**Example:** - -```go -type ArtistInput struct { - ID string `json:"id"` - Name string `json:"name"` - MBID string `json:"mbid,omitempty"` -} - -type BiographyOutput struct { - Biography string `json:"biography"` -} - -//go:wasmexport nd_get_artist_biography -func ndGetArtistBiography() int32 { - var input ArtistInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return 1 - } - - // Fetch biography from your data source... - output := BiographyOutput{Biography: "Artist biography..."} - pdk.OutputJSON(output) - return 0 -} -``` +| Function | Input | Output | Description | +|-----------------------------------|----------------------------|----------------------------------|--------------------------| +| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | +| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | +| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | +| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | +| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | +| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | +| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | +| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | +| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track | +| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album | +| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist | To use the plugin as a metadata agent, add it to your config: @@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config: Agents = "lastfm,spotify,my-plugin" ``` +**Example (using Go PDK package):** + +```go +package main + +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" + +type myPlugin struct{} + +func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { + return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil +} + +func init() { metadata.Register(&myPlugin{}) } +func main() {} +``` + +**Example (raw wasmexport):** + +```go +//go:wasmexport nd_get_artist_biography +func ndGetArtistBiography() int32 { + var input ArtistInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return 1 + } + pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."}) + return 0 +} +``` + ### Scrobbler -Integrates with external scrobbling services. Export one or more of these functions: +Integrates with external scrobbling services. All three methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|----------------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|------------------------------|-----------------------|--------|-----------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | -> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes. +> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. **Manifest permission:** @@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error ```go import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" -// Return error using predefined constants return scrobbler.ScrobblerErrorNotAuthorized return scrobbler.ScrobblerErrorRetryLater return scrobbler.ScrobblerErrorUnrecoverable ``` +### Lyrics + +Provides lyrics for tracks. The single method is **required**. + +| Function | Input | Output | Description | +|-------------------------|-------------------------------|------------------------------------|-----------------| +| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics | + +Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages. + +### SonicSimilarity + +Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**. + +| Function | Input | Output | Description | +|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------| +| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks | +| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs | + +Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0). + +### TaskWorker + +Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. + +| Function | Input | Output | Description | +|---------------------|---------------------------------------------|---------|----------------------| +| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task| + +The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success. + ### Lifecycle -Optional initialization callback. Export this function to run code when your plugin loads: +Optional initialization callback. Called once after the plugin fully loads. | Function | Input | Output | Description | |--------------|-------|------------|--------------------------------| | `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads | -Useful for initializing connections, scheduling recurring tasks, etc. +Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading. + +### SchedulerCallback + +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. + +| Function | Input | Output | Description | +|---------------------------|----------------------------------------------|--------|-----------------------------| +| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event | + +### WebSocketCallback + +Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service. + +| Function | Input | Description | +|----------------------------------|---------------------------------|----------------------------------| +| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | +| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | +| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | +| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | --- ## Host Services -Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest. +Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest. -### HTTP Requests +### Go PDK Setup -Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests. +All host service examples below use the generated Go SDK. Add this to your `go.mod`: + +``` +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go +``` + +Then import: + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/host" +``` + +### HTTP + +Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. **Manifest permission:** @@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis } ``` +**Host functions:** + +| Function | Parameters | Returns | +|-------------|----------------------------------------------------------|----------------------------------| +| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | + **Usage:** ```go -req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data") -req.SetHeader("Authorization", "Bearer " + apiKey) -resp := req.Send() - -if resp.Status() == 200 { - data := resp.Body() - // Process response... +resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: "https://api.example.com/data", + Headers: map[string]string{"Authorization": "Bearer " + apiKey}, +}) +if resp.StatusCode == 200 { + // Process resp.Body } ``` ### Scheduler -Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events. +Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events. **Manifest permission:** @@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call | `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback | | `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task | -**Callback function:** +**Usage:** ```go -type SchedulerCallbackInput struct { - ScheduleID string `json:"scheduleId"` - Payload string `json:"payload"` - IsRecurring bool `json:"isRecurring"` -} - -//go:wasmexport nd_scheduler_callback -func ndSchedulerCallback() int32 { - var input SchedulerCallbackInput - pdk.InputJSON(&input) - - // Handle the scheduled task based on payload - pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID) - return 0 -} -``` - -**Scheduling tasks (using generated SDK):** - -Add the generated SDK to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -Then import and use: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Schedule one-time task in 60 seconds scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "") @@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID) ### Cache -Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace. +In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart. **Manifest permission:** @@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own **TTL:** Pass `0` for the default (24 hours), or specify seconds. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Cache a value for 1 hour host.CacheSetString("api-response", responseData, 3600) -// Retrieve (check Exists before using Value) -result, err := host.CacheGetString("api-response") -if result.Exists { - data := result.Value +// Retrieve (returns value, exists, error) +value, exists, err := host.CacheGetString("api-response") +if exists { + // Use value } ``` -> **Note:** Cache is in-memory only and cleared on server restart. - ### KVStore -Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database. +Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`. **Manifest permission:** @@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its } ``` -**Permission options:** - `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB +**Key constraints:** Maximum 256 bytes, must be valid UTF-8. + **Host functions:** -| Function | Parameters | Description | -|--------------------------|--------------|-----------------------------------| -| `kvstore_set` | `key, value` | Store a byte value | -| `kvstore_get` | `key` | Retrieve a byte value | -| `kvstore_delete` | `key` | Delete a value | -| `kvstore_has` | `key` | Check if key exists | -| `kvstore_list` | `prefix` | List keys matching prefix | -| `kvstore_getstorageused` | - | Get current storage usage (bytes) | +| Function | Parameters | Description | +|-----------------------------|--------------------------|-----------------------------------| +| `kvstore_set` | `key, value` | Store a byte value | +| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration | +| `kvstore_get` | `key` | Retrieve a byte value | +| `kvstore_getmany` | `keys` | Retrieve multiple values at once | +| `kvstore_has` | `key` | Check if key exists | +| `kvstore_list` | `prefix` | List keys matching prefix | +| `kvstore_delete` | `key` | Delete a value | +| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix | +| `kvstore_getstorageused` | – | Get current storage usage (bytes) | -**Key constraints:** -- Maximum key length: 256 bytes -- Keys must be valid UTF-8 strings - -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Store a value (as raw bytes) token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`) -_, err := host.KVStoreSet("oauth:spotify", token) +host.KVStoreSet("oauth:spotify", token) + +// Store with TTL (auto-expires after 1 hour) +host.KVStoreSetWithTTL("session:abc", sessionData, 3600) // Retrieve a value -result, err := host.KVStoreGet("oauth:spotify") -if result.Exists { +value, exists, err := host.KVStoreGet("oauth:spotify") +if exists { var tokenData map[string]string - json.Unmarshal(result.Value, &tokenData) + json.Unmarshal(value, &tokenData) } -// List all keys with prefix -keysResult, err := host.KVStoreList("user:") -for _, key := range keysResult.Keys { - // Process each key -} +// Batch retrieve +results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"}) + +// List and delete by prefix +keys, err := host.KVStoreList("user:") +host.KVStoreDeleteByPrefix("user:") // Check storage usage -usageResult, err := host.KVStoreGetStorageUsed() -fmt.Printf("Using %d bytes\n", usageResult.Bytes) - -// Delete a value -host.KVStoreDelete("oauth:spotify") +usage, err := host.KVStoreGetStorageUsed() +fmt.Printf("Using %d bytes\n", usage) ``` -> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`. +### Task + +Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. + +**Manifest permission:** + +```json +{ + "permissions": { + "taskqueue": { + "reason": "Process audio analysis in the background", + "maxConcurrency": 2 + } + } +} +``` + +**Host functions:** + +| Function | Parameters | Description | +|---------------------|---------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +**Usage:** + +```go +// Create a queue with retry configuration +host.TaskCreateQueue("analysis", host.QueueConfig{ + Concurrency: 2, + MaxRetries: 3, + BackoffMs: 1000, +}) + +// Enqueue a task +taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`)) + +// Check task status +info, err := host.TaskGet(taskID) +fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt) +``` ### WebSocket -Establish persistent WebSocket connections to external services. +Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events. **Manifest permission:** @@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services. **Host functions:** -| Function | Parameters | Description | -|------------------------|---------------------------------|-------------------| -| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | -| `websocket_sendtext` | `connectionId, message` | Send text message | -| `websocket_sendbinary` | `connectionId, data` | Send binary data | -| `websocket_close` | `connectionId, code?, reason?` | Close connection | +| Function | Parameters | Description | +|----------------------------|---------------------------------|-------------------| +| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | +| `websocket_sendtext` | `connectionId, message` | Send text message | +| `websocket_sendbinary` | `connectionId, data` | Send binary data | +| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection | -**Callback functions (export these to receive events):** +**Usage:** -| Function | Input | Description | -|----------------------------------|---------------------------------|----------------------------------| -| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | -| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | -| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | -| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +```go +connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "") +host.WebSocketSendText(connID, `{"op": 1, "d": null}`) +host.WebSocketCloseConnection(connID, 1000, "done") +``` ### Library @@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via ```go import "os" -// Read a file from library 1 content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") - -// List directory contents entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem. +> **Security:** Filesystem access is read-only and restricted to configured library paths only. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK: +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Get a specific library -resp, err := host.LibraryGetLibrary(1) -if err != nil { - // Handle error -} -library := resp.Result +library, err := host.LibraryGetLibrary(1) +fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs) // Get all libraries -resp, err := host.LibraryGetAllLibraries() -for _, lib := range resp.Result { - // lib is of type host.Library +libraries, err := host.LibraryGetAllLibraries() +for _, lib := range libraries { fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs) } ``` @@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). | `artwork_gettrackurl` | `id, size` | Artwork URL | | `artwork_getplaylisturl` | `id, size` | Artwork URL | +**Usage:** + +```go +url, err := host.ArtworkGetAlbumUrl("album-id", 300) +``` + ### SubsonicAPI Call Navidrome's Subsonic API internally (no network round-trip). @@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip). } ``` -> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API. +> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. -**Host function:** +**Host functions:** -| Function | Parameters | Returns | -|--------------------|------------|---------------| -| `subsonicapi_call` | `uri` | JSON response | +| Function | Parameters | Returns | +|-----------------------|------------|--------------------------------| +| `subsonicapi_call` | `uri` | JSON response string | +| `subsonicapi_callraw` | `uri` | Content type + binary response | **Usage:** ```go -// The URI must include the 'u' parameter with the username -response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") +// JSON response +response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") + +// Binary response (e.g., cover art, streams) +contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username") ``` ### Config -Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings). +Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration. > **Note:** This service is always available and does not require a manifest permission. @@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh | `config_getint` | `key` | `value, exists` | | `config_keys` | `prefix` | Array of matching key names | -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get a string configuration value +// Get a configuration value value, exists := host.ConfigGet("api_key") -if exists { - // Use the value -} // Get an integer configuration value count, exists := host.ConfigGetInt("max_retries") // List all keys with a prefix (useful for user-specific config) keys := host.ConfigKeys("user:") -for _, key := range keys { - // key might be "user:john", "user:jane", etc. -} // List all configuration keys allKeys := host.ConfigKeys("") @@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("") ### Users -Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information. +Access user information for the users that the plugin has been granted access to. **Manifest permission:** @@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to } ``` -**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways: +**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access: 1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings 2. **Select specific users** – Choose individual users from the user list @@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled. | Function | Parameters | Returns | |------------------|------------|-----------------------| | `users_getusers` | – | Array of User objects | +| `users_getadmins`| – | Array of admin Users | **User object fields:** @@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled. > **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins. -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get all users the plugin has access to users, err := host.UsersGetUsers() -if err != nil { - pdk.Log(pdk.LogError, "Failed to get users: " + err.Error()) - return -} - for _, user := range users { pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")") - if user.IsAdmin { - pdk.Log(pdk.LogInfo, " - Administrator") - } } -``` -**Rust example:** - -```rust -use nd_pdk_host::users::get_users; - -let users = get_users()?; -for user in users { - println!("User: {} ({})", user.user_name, user.name); -} -``` - -**Python example:** - -```python -from host.nd_host_users import users_get_users - -users = users_get_users() -for user in users: - print(f"User: {user['userName']} ({user['name']})") +admins, err := host.UsersGetAdmins() ``` --- @@ -834,20 +898,20 @@ if !ok { } ``` +For more advanced access (listing keys, integer values), use the [Config](#config) host service. + --- ## Building Plugins ### Supported Languages -Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details. +Plugins can be written in any language that Extism supports. We recommend: -We recommend: - -- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk) -- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk) -- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk) -- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk) +- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support. +- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk). +- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages. +- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk). ### Go with TinyGo (Recommended) @@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm #### Using Go PDK Packages -Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: +Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: ```go package main -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" -) +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" type myPlugin struct{} @@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil } -func init() { - metadata.Register(&myPlugin{}) -} - +func init() { metadata.Register(&myPlugin{}) } func main() {} ``` @@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0 replace github.com/navidrome/navidrome => ../../.. ``` -Available capability packages: +**Available capability packages:** -| Package | Import Path | Description | -|-------------|----------------------------|--------------------------------------| -| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | -| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | -| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | -| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | -| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | -| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) | +| Package | Import Path | Description | +|-------------------|--------------------------------------|--------------------------------------| +| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | +| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | +| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers | +| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery | +| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing | +| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | +| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | +| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | +| `host` | `plugins/pdk/go/host` | Host service SDK (all services) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm #### Using Rust PDK -The Rust PDK provides generated type-safe wrappers for both capabilities and host services: - ```toml # Cargo.toml [dependencies] @@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports ```rust use nd_pdk::host::{cache, scheduler, library}; -// Cache a value for 1 hour cache::set_string("my_key", "my_value", 3600)?; - -// Schedule a recurring task scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; - -// Access library metadata let libs = library::get_all_libraries()?; ``` -See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples. +See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation. ### Python (with extism-py) @@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` +**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). + ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: @@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples. -### Using Host Service SDKs - -Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`. - -**For Go plugins:** Import the SDK as a Go module: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" -``` - -Add to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -See [pdk/go/README.md](pdk/go/README.md) for detailed documentation. - -**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations). - -**Recommendations:** - -- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem. -- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety. -- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages. - --- ## Examples See [examples/](examples/) for complete working plugins: -| Plugin | Language | Capabilities | Host Services | Description | -|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------| -| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | -| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | -| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) | +| Plugin | Language | Capabilities | Host Services | Description | +|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------| +| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | +| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | +| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | +| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | --- - ## Security Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only +2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features. +6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed - --- ## Runtime Management @@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive. -If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes. +If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes. ### Enabling/Disabling Plugins @@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis - **In-flight requests** – When reloading, existing requests complete before the new version takes over - **Config changes** – Changes to the plugin configuration in the UI are applied immediately -- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded +- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded \ No newline at end of file diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 4ac907559..04dd283dd 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -102,6 +102,12 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. path: type: string description: |- diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index f62da1745..8ada5f7e4 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -128,6 +128,12 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. path: type: string description: |- diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go new file mode 100644 index 000000000..aadb9396e --- /dev/null +++ b/plugins/capabilities/sonic_similarity.go @@ -0,0 +1,32 @@ +package capabilities + +// SonicSimilarity provides audio-similarity based track discovery. +// +//nd:capability name=sonicsimilarity required=true +type SonicSimilarity interface { + //nd:export name=nd_get_sonic_similar_tracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + + //nd:export name=nd_find_sonic_path + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml new file mode 100644 index 000000000..cba97d9b0 --- /dev/null +++ b/plugins/capabilities/sonic_similarity.yaml @@ -0,0 +1,92 @@ +version: v1-draft +exports: + nd_get_sonic_similar_tracks: + input: + $ref: '#/components/schemas/GetSonicSimilarTracksRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json + nd_find_sonic_path: + input: + $ref: '#/components/schemas/FindSonicPathRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json +components: + schemas: + FindSonicPathRequest: + properties: + startSong: + $ref: '#/components/schemas/SongRef' + endSong: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - startSong + - endSong + - count + GetSonicSimilarTracksRequest: + properties: + song: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - song + - count + SongRef: + description: SongRef is a reference to a song with metadata for matching. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: Artist is the artist name. + artistMbid: + type: string + description: ArtistMBID is the MusicBrainz artist ID. + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: Duration is the song duration in seconds. + required: + - name + SonicMatch: + properties: + song: + $ref: '#/components/schemas/SongRef' + similarity: + type: number + format: float + required: + - song + - similarity + SonicSimilarityResponse: + properties: + matches: + type: array + items: + $ref: '#/components/schemas/SonicMatch' + required: + - matches diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 43ebc0e4b..c66b027d7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -21,6 +21,10 @@ func init() { ) } +func newLyricsPlugin(p *plugin) *LyricsPlugin { + return &LyricsPlugin{name: p.name, plugin: p} +} + // LyricsPlugin adapts a WASM plugin with the Lyrics capability. type LyricsPlugin struct { name string diff --git a/plugins/manager.go b/plugins/manager.go index 0c7c91ed8..0e9419bfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" @@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string { return names } -// LoadMediaAgent loads and returns a media agent plugin by name. -// Returns false if the plugin is not found or doesn't have the MetadataAgent capability. func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) { - return nil, false - } - - // Create a new metadata agent adapter for this plugin - return &MetadataAgent{ - name: plugin.name, - plugin: plugin, - }, true + return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent) } -// LoadScrobbler loads and returns a scrobbler plugin by name. -// Returns false if the plugin is not found or doesn't have the Scrobbler capability. func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) { - return nil, false - } - - // Build user ID map for fast lookups - userIDMap := make(map[string]struct{}) - for _, id := range plugin.allowedUserIDs { - userIDMap[id] = struct{}{} - } - - // Create a new scrobbler adapter for this plugin with user authorization config - return &ScrobblerPlugin{ - name: plugin.name, - plugin: plugin, - allowedUserIDs: plugin.allowedUserIDs, - allUsers: plugin.allUsers, - userIDMap: userIDMap, - }, true + return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -// LoadLyricsProvider loads and returns a lyrics provider plugin by name. func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) +} + +func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin) +} + +func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) { m.mu.RLock() - plugin, ok := m.plugins[name] + p, ok := m.plugins[name] m.mu.RUnlock() - if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) { - return nil, false + var zero T + if !ok || !hasCapability(p.capabilities, cap) { + return zero, false } - - return &LyricsPlugin{ - name: plugin.name, - plugin: plugin, - }, true + return newAdapter(p), true } // PluginInfo contains basic information about a plugin for metrics/insights. diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 451ef26d1..52542300c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/utils/slice" ) // CapabilityMetadataAgent indicates the plugin can provide artist/album metadata. @@ -44,6 +45,10 @@ func init() { ) } +func newMetadataAgent(p *plugin) *MetadataAgent { + return &MetadataAgent{name: p.name, plugin: p} +} + // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } +// songRefToAgentSong converts a single SongRef to agents.Song +func songRefToAgentSong(s capabilities.SongRef) agents.Song { + return agents.Song{ + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artist: s.Artist, + ArtistMBID: s.ArtistMBID, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: uint32(s.Duration * 1000), + } +} + // songRefsToAgentSongs converts a slice of SongRef to agents.Song func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { - songs := make([]agents.Song, len(refs)) - for i, s := range refs { - songs[i] = agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), - } - } - return songs + return slice.Map(refs, songRefToAgentSong) } // Verify interface implementations at compile time diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 188371fee..6696bc4a1 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 91eec4997..fb3e3fb1a 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -65,6 +65,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index e16bfed4b..d27ae3a9c 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -92,6 +92,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 86a71af03..9e6f706ac 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -89,6 +89,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go new file mode 100644 index 000000000..7a2681f93 --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package sonicsimilarity + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} // Internal implementation holders +var ( + sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error) +) + +// Register registers a sonicsimilarity implementation. +// All methods are required. +func Register(impl SonicSimilarity) { + sonicSimilarTracksImpl = impl.GetSonicSimilarTracks + findSonicPathImpl = impl.FindSonicPath +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_get_sonic_similar_tracks +func _NdGetSonicSimilarTracks() int32 { + if sonicSimilarTracksImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetSonicSimilarTracksRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := sonicSimilarTracksImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_find_sonic_path +func _NdFindSonicPath() int32 { + if findSonicPathImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input FindSonicPathRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := findSonicPathImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go new file mode 100644 index 000000000..28d86301c --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -0,0 +1,71 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package sonicsimilarity + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ SonicSimilarity) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 85375b525..b0361a3a3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -10,5 +10,6 @@ pub mod lyrics; pub mod metadata; pub mod scheduler; pub mod scrobbler; +pub mod sonicsimilarity; pub mod taskworker; pub mod websocket; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index fcfe553f8..0a861a1ed 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -102,6 +102,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, /// Path is the full path to the track file, relative to the library root. /// Only included if the plugin has library permission with filesystem access for the track's library. #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index dd42e6803..348460374 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -122,6 +122,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, /// Path is the full path to the track file, relative to the library root. /// Only included if the plugin has library permission with filesystem access for the track's library. #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs new file mode 100644 index 000000000..eb2868929 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -0,0 +1,141 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// FindSonicPathRequest represents the FindSonicPathRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FindSonicPathRequest { + #[serde(default)] + pub start_song: SongRef, + #[serde(default)] + pub end_song: SongRef, + #[serde(default)] + pub count: i32, +} +/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSonicSimilarTracksRequest { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub count: i32, +} +/// SongRef is a reference to a song with metadata for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} +/// SonicMatch represents the SonicMatch data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicMatch { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub similarity: f64, +} +/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicSimilarityResponse { + #[serde(default)] + pub matches: Vec<SonicMatch>, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into<String>) -> Self { + Self { message: message.into() } + } +} + +/// SonicSimilarity requires all methods to be implemented. +/// SonicSimilarity provides audio-similarity based track discovery. +pub trait SonicSimilarity { + /// GetSonicSimilarTracks + fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result<SonicSimilarityResponse, Error>; + /// FindSonicPath + fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result<SonicSimilarityResponse, Error>; +} + +/// Register all exports for the SonicSimilarity capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_sonicsimilarity { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_sonic_similar_tracks( + req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest> + ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + #[extism_pdk::plugin_fn] + pub fn nd_find_sonic_path( + req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest> + ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 02c2b2889..302f5e1da 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -30,6 +30,20 @@ func init() { ) } +func newScrobblerPlugin(p *plugin) *ScrobblerPlugin { + userIDMap := make(map[string]struct{}) + for _, id := range p.allowedUserIDs { + userIDMap[id] = struct{}{} + } + return &ScrobblerPlugin{ + name: p.name, + plugin: p, + allowedUserIDs: p.allowedUserIDs, + allUsers: p.allUsers, + userIDMap: userIDMap, + } +} + // ScrobblerPlugin is an adapter that wraps an Extism plugin and implements // the scrobbler.Scrobbler interface for scrobbling to external services. type ScrobblerPlugin struct { diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go new file mode 100644 index 000000000..ff399680e --- /dev/null +++ b/plugins/sonic_similarity_adapter.go @@ -0,0 +1,92 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const CapabilitySonicSimilarity Capability = "SonicSimilarity" + +const ( + FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks" + FuncFindSonicPath = "nd_find_sonic_path" +) + +func init() { + registerCapability( + CapabilitySonicSimilarity, + FuncGetSonicSimilarTracks, + FuncFindSonicPath, + ) +} + +func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin { + return &SonicSimilarityPlugin{name: p.name, plugin: p} +} + +type SonicSimilarityPlugin struct { + name string + plugin *plugin +} + +func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.GetSonicSimilarTracksRequest{ + Song: mediaFileToSongRef(mf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncGetSonicSimilarTracks, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.FindSonicPathRequest{ + StartSong: mediaFileToSongRef(startMf), + EndSong: mediaFileToSongRef(endMf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncFindSonicPath, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { + ref := capabilities.SongRef{ + ID: mf.ID, + Name: mf.Title, + MBID: mf.MbzRecordingID, + Artist: mf.Artist, + ArtistMBID: mf.MbzArtistID, + Album: mf.Album, + AlbumMBID: mf.MbzAlbumID, + Duration: mf.Duration, + } + if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { + ref.ISRC = isrcs[0] + } + return ref +} + +func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult { + results := make([]sonic.SimilarResult, len(matches)) + for i, m := range matches { + results[i] = sonic.SimilarResult{ + Song: songRefToAgentSong(m.Song), + Similarity: m.Similarity, + } + } + return results +} + +var _ sonic.Provider = (*SonicSimilarityPlugin)(nil) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go new file mode 100644 index 000000000..daea04761 --- /dev/null +++ b/plugins/sonic_similarity_adapter_test.go @@ -0,0 +1,110 @@ +//go:build !windows + +package plugins + +import ( + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SonicSimilarityPlugin", Ordered, func() { + var ( + manager *Manager + provider sonic.Provider + ) + + BeforeAll(func() { + manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension) + + var ok bool + provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + Describe("PluginNames", func() { + It("reports the sonic similarity capability", func() { + names := manager.PluginNames(string(CapabilitySonicSimilarity)) + Expect(names).To(ContainElement("test-sonic-similarity")) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns similar tracks from the plugin", func() { + mf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.9)) + Expect(results[2].Similarity).To(Equal(0.8)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns a path between two tracks from the plugin", func() { + startMf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + endMf := &model.MediaFile{ + ID: "track-2", + Title: "Tomorrow Never Knows", + Artist: "The Beatles", + } + + results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.95)) + Expect(results[2].Similarity).To(Equal(0.9)) + }) + }) +}) + +var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { + var ( + errorManager *Manager + errorProvider sonic.Provider + ) + + BeforeAll(func() { + errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{ + "test-sonic-similarity": { + "error": "simulated plugin error", + }, + }, "test-sonic-similarity"+PackageExtension) + + var ok bool + errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + It("returns error from GetSonicSimilarTracks", func() { + mf := &model.MediaFile{ID: "track-1", Title: "Test"} + _, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from FindSonicPath", func() { + startMf := &model.MediaFile{ID: "track-1", Title: "Start"} + endMf := &model.MediaFile{ID: "track-2", Title: "End"} + _, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) +}) diff --git a/plugins/testdata/test-sonic-similarity/go.mod b/plugins/testdata/test-sonic-similarity/go.mod new file mode 100644 index 000000000..6265288f9 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.mod @@ -0,0 +1,16 @@ +module test-sonic-similarity + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-sonic-similarity/go.sum b/plugins/testdata/test-sonic-similarity/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go new file mode 100644 index 000000000..e315bff15 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -0,0 +1,71 @@ +// Test plugin for Navidrome sonic similarity integration tests. +// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "errors" + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" +) + +func init() { + sonicsimilarity.Register(&testSonicSimilarity{}) +} + +type testSonicSimilarity struct{} + +func checkConfigError() error { + errMsg, hasErr := pdk.GetConfig("error") + if !hasErr || errMsg == "" { + return nil + } + return errors.New(errMsg) +} + +func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "similar-track-" + strconv.Itoa(i+1), + Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), + Artist: input.Song.Artist, + }, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "path-track-" + strconv.Itoa(i+1), + Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), + Artist: input.StartSong.Artist, + }, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func main() {} diff --git a/plugins/testdata/test-sonic-similarity/manifest.json b/plugins/testdata/test-sonic-similarity/manifest.json new file mode 100644 index 000000000..a55c4021f --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Test Sonic Similarity", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for sonic similarity integration testing", + "capabilities": ["SonicSimilarity"] +} diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4ad9e3daa..4b003167a 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -519,6 +519,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(nil), decider, + nil, ) } diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go new file mode 100644 index 000000000..3a010bc67 --- /dev/null +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -0,0 +1,272 @@ +package e2e + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service +// backed by the given provider and the shared e2e DataStore. +func buildSonicRouter(provider sonic.Provider) *subsonic.Router { + loader := &mockSonicPluginLoader{provider: provider} + m := matcher.New(ds) + sonicSvc := sonic.New(ds, loader, m) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + return subsonic.New( + ds, + noopArtwork{}, + &spyStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + nil, // scanner + events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + noopPlayTracker{}, + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), + decider, + sonicSvc, + ) +} + +// doSonicReq makes a request through a sonic-enabled router and returns the parsed response. +func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder. +func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return w +} + +var _ = Describe("Sonic Similarity Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Context("without sonic similarity plugin", func() { + Describe("getSonicSimilarTracks", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("getSonicSimilarTracks", "id", "any-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("findSonicPath", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Context("with sonic similarity plugin", func() { + var ( + sonicRouter *subsonic.Router + comeTogether model.MediaFile + something model.MediaFile + ) + + BeforeEach(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + comeTogether = songs[0] + + songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Something"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + something = songs[0] + + provider := &mockSonicProvider{ + similarIDs: []string{something.ID, comeTogether.ID}, + pathIDs: []string{comeTogether.ID, something.ID}, + } + sonicRouter = buildSonicRouter(provider) + }) + + Describe("getSonicSimilarTracks", func() { + It("returns similar tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Something")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Come Together")) + Expect(matches[1].Similarity).To(Equal(0.9)) + }) + + It("respects the count parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(*resp.SonicMatches).To(HaveLen(1)) + }) + + It("returns an error for a missing id parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent song ID", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns correct JSON structure", func() { + w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + matches := *wrapper.Subsonic.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Similarity).To(BeNumerically(">", 0)) + Expect(matches[0].Entry.Id).ToNot(BeEmpty()) + }) + }) + + Describe("findSonicPath", func() { + It("returns a path between two tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Come Together")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Something")) + Expect(matches[1].Similarity).To(Equal(0.95)) + }) + + It("returns an error for a missing startSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a missing endSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent start song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", "non-existent-id", + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent end song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", "non-existent-id", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + }) +}) + +// mockSonicProvider returns results using IDs from the real test library, +// so that the matcher can resolve them back to actual MediaFiles. +type mockSonicProvider struct { + similarIDs []string + pathIDs []string +} + +func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.similarIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return results, nil +} + +func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.pathIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return results, nil +} + +type mockSonicPluginLoader struct { + provider sonic.Provider +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" && m.provider != nil { + return []string{"mock-sonic"} + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) { + if m.provider != nil { + return m.provider, true + } + return nil, false +} diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index ae4ef9bb9..220376b15 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e91c02aa4..5b6565260 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -52,12 +53,14 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + sonic *sonicsvc.Sonic } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, + sonic *sonicsvc.Sonic, ) *Router { r := &Router{ ds: ds, @@ -75,6 +78,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea metrics: metrics, lyrics: lyrics, transcodeDecision: transcodeDecision, + sonic: sonic, } r.Handler = r.routes() return r @@ -121,6 +125,8 @@ func (api *Router) routes() http.Handler { h(r, "getTopSongs", api.GetTopSongs) h(r, "getSimilarSongs", api.GetSimilarSongs) h(r, "getSimilarSongs2", api.GetSimilarSongs2) + hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks) + hr(r, "findSonicPath", api.FindSonicPath) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index e110e2b93..1c513edf4 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7f64fb47f..589a609da 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 353cf1077..6c54d36d0 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,12 +8,18 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, } + if api.sonic != nil && api.sonic.HasProvider() { + extensions = append(extensions, responses.OpenSubsonicExtension{ + Name: "sonicSimilarity", Versions: []int32{1}, + }) + } + response.OpenSubsonicExtensions = &extensions return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 92d1c3e84..d98599f8f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -5,12 +5,28 @@ import ( "net/http" "net/http/httptest" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +type mockSonicPluginLoader struct { + names []string +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonicsvc.Provider, bool) { + return nil, false +} + var _ = Describe("GetOpenSubsonicExtensions", func() { var ( router *subsonic.Router @@ -18,29 +34,64 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { r *http.Request ) - BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + JustBeforeEach(func() { w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) - It("should return the correct OpenSubsonicExtensions", func() { - router.ServeHTTP(w, r) + Context("without sonic similarity plugin", func() { + BeforeEach(func() { + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + }) - // Make sure the endpoint is public, by not passing any authentication - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + router.ServeHTTP(w, r) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) + // Make sure the endpoint is public, by not passing any authentication + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(5), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 6 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(6), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + )) + }) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 3f2a2068e..1d5f6a70a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -258,7 +258,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON new file mode 100644 index 000000000..b22f18e83 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON @@ -0,0 +1,25 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [ + { + "entry": { + "id": "1", + "isDir": false, + "title": "Bohemian Rhapsody" + }, + "similarity": 0.95 + }, + { + "entry": { + "id": "2", + "isDir": false, + "title": "We Will Rock You" + }, + "similarity": 0.78 + } + ] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML new file mode 100644 index 000000000..14d17d00d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML @@ -0,0 +1,10 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> + <sonicMatch> + <entry id="1" isDir="false" title="Bohemian Rhapsody"></entry> + <similarity>0.95</similarity> + </sonicMatch> + <sonicMatch> + <entry id="2" isDir="false" title="We Will Rock You"></entry> + <similarity>0.78</similarity> + </sonicMatch> +</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON new file mode 100644 index 000000000..75562871f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML new file mode 100644 index 000000000..184228a0e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML @@ -0,0 +1 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response> diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index f0bb26f66..c2e863b0f 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -62,6 +62,7 @@ type Subsonic struct { LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` + SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` } const ( @@ -441,6 +442,11 @@ type TopSongs struct { Song []Child `xml:"song,omitempty" json:"song,omitempty"` } +type SonicMatch struct { + Entry Child `xml:"entry" json:"entry"` + Similarity float64 `xml:"similarity" json:"similarity"` +} + type PlayQueue struct { Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` Current string `xml:"current,attr,omitempty" json:"current,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 15f2da9c6..9910ced14 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1114,4 +1114,33 @@ var _ = Describe("Responses", func() { }) }) + Describe("SonicMatches", func() { + Context("without data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{ + {Entry: Child{Id: "1", Title: "Bohemian Rhapsody", IsDir: false}, Similarity: 0.95}, + {Entry: Child{Id: "2", Title: "We Will Rock You", IsDir: false}, Similarity: 0.78}, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + }) diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index ab40a726f..9a9c6af6f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/sonic_similarity.go b/server/subsonic/sonic_similarity.go new file mode 100644 index 000000000..3640a116b --- /dev/null +++ b/server/subsonic/sonic_similarity.go @@ -0,0 +1,69 @@ +package subsonic + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) GetSonicSimilarTracks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + count := p.IntOr("count", 10) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func (api *Router) FindSonicPath(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + startSongID, err := p.String("startSongId") + if err != nil { + return nil, err + } + endSongID, err := p.String("endSongId") + if err != nil { + return nil, err + } + count := p.IntOr("count", 25) + + matches, err := api.sonic.FindSonicPath(ctx, startSongID, endSongID, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func sonicMatchResponse(ctx context.Context, matches []sonic.SimilarMatch) *responses.Subsonic { + response := newResponse() + resp := make(responses.Array[responses.SonicMatch], len(matches)) + for i, m := range matches { + resp[i] = responses.SonicMatch{ + Entry: childFromMediaFile(ctx, m.MediaFile), + Similarity: m.Similarity, + } + } + response.SonicMatches = &resp + return response +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 15ba168d7..adc7b7600 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -27,7 +27,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) w = httptest.NewRecorder() }) @@ -284,7 +284,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -301,7 +301,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", From 0fe08bfa74d5f28d42e4cfb5acbad3fb44e5cc22 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:21:32 -0600 Subject: [PATCH 207/596] feat(ui): add Not Starred filter option (#5362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add Not Starred filter option - #5108 Signed-off-by: Daniel Banariba <banaribad@gmail.com> * fix(ui): apply notStarred translation to playlistTrack resource - #5108 Signed-off-by: Daniel Banariba <banaribad@gmail.com> * refactor(ui): use NullableBooleanInput for starred filter - #5108 Replace QuickFilter approach with NullableBooleanInput per maintainer review feedback. Single tri-state filter (Yes/No/Any) instead of two separate buttons + dataProvider translation. Matches the existing pattern used by the 'missing' filter. Signed-off-by: Daniel Banariba <banaribad@gmail.com> --------- Signed-off-by: Daniel Banariba <banaribad@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/album/AlbumList.jsx | 4 +--- ui/src/artist/ArtistList.jsx | 4 +--- ui/src/song/SongList.jsx | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 28a24981c..0b8c256df 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -20,7 +20,6 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, - QuickFilter, Title, useAlbumsPerPage, useResourceRefresh, @@ -152,10 +151,9 @@ const AlbumFilter = (props) => { <NullableBooleanInput source="compilation" /> <NumberInput source="year" /> {config.enableFavourites && ( - <QuickFilter + <NullableBooleanInput source="starred" label={<FavoriteIcon fontSize={'small'} />} - defaultValue={true} /> )} {isAdmin && <NullableBooleanInput source="missing" />} diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 6c526a5a5..4559aa8e6 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -24,7 +24,6 @@ import { ArtistContextMenu, CoverArtAvatar, List, - QuickFilter, useGetHandleArtistClick, RatingField, useSelectedFields, @@ -88,10 +87,9 @@ const ArtistFilter = (props) => { <SearchInput id="search" source="name" alwaysOn /> <SelectInput source="role" choices={roles} alwaysOn /> {config.enableFavourites && ( - <QuickFilter + <NullableBooleanInput source="starred" label={<FavoriteIcon fontSize={'small'} />} - defaultValue={true} /> )} {isAdmin && <NullableBooleanInput source="missing" />} diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 98684132f..d928af549 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -20,7 +20,6 @@ import { SongContextMenu, SongDatagrid, SongInfo, - QuickFilter, SongTitleField, SongSimpleList, RatingField, @@ -119,10 +118,9 @@ const SongFilter = (props) => { /> </ReferenceArrayInput> {config.enableFavourites && ( - <QuickFilter + <NullableBooleanInput source="starred" label={<FavoriteIcon fontSize={'small'} />} - defaultValue={true} /> )} {isAdmin && <NullableBooleanInput source="missing" />} From 5c4f0298a66fc0a081902cd9faafa11ae534efd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 27 Apr 2026 19:36:57 -0400 Subject: [PATCH 208/596] fix(sharing): validate JWT expiration and share existence on stream endpoint (#5426) * fix(sharing): validate JWT expiration and share existence on stream endpoint The public stream endpoint (/public/s/{token}) was using TokenAuth.Decode() which only verifies the JWT signature but skips exp claim validation. This allowed expired share stream URLs to remain functional indefinitely. Additionally, deleting a share did not revoke previously issued stream tokens since the handler never performed a server-side share lookup. Fixed by switching decodeStreamInfo() to use auth.Validate() which properly checks the exp claim, and by embedding the share ID ("sid") in stream tokens so the handler can verify the share still exists. Old tokens without the sid claim remain backward compatible but still benefit from expiration validation. * fix(sharing): check share expiration on stream requests Replace the lightweight Exists() check with Get() + expiration validation, so that shares whose ExpiresAt was updated to an earlier time after token issuance are also rejected (410 Gone). Reuses the existing checkShareError handler for consistent error responses. --- core/auth/claims.go | 8 ++ core/auth/claims_test.go | 9 ++ server/public/handle_shares.go | 1 + server/public/handle_streams.go | 22 ++- server/public/handle_streams_test.go | 196 +++++++++++++++++++++++++++ tests/mock_share_repo.go | 13 ++ 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 server/public/handle_streams_test.go diff --git a/core/auth/claims.go b/core/auth/claims.go index c0e4dea7f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -21,6 +21,7 @@ type Claims struct { ID string // "id" - artwork/mediafile ID Format string // "f" - audio format BitRate int // "b" - audio bitrate + ShareID string // "sid" - share ID for share stream tokens } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any { if c.BitRate != 0 { m["b"] = c.BitRate } + if c.ShareID != "" { + m["sid"] = c.ShareID + } return m } @@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims { c.BitRate = int(bf) } } + var sid string + if err := token.Get("sid", &sid); err == nil { + c.ShareID = sid + } return c } diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index cf6b07263..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -28,6 +28,7 @@ var _ = Describe("Claims", func() { Expect(m).NotTo(HaveKey("id")) Expect(m).NotTo(HaveKey("f")) Expect(m).NotTo(HaveKey("b")) + Expect(m).NotTo(HaveKey("sid")) }) It("includes expiration and issued-at when set", func() { @@ -52,6 +53,12 @@ var _ = Describe("Claims", func() { Expect(m).To(HaveKeyWithValue("f", "mp3")) Expect(m).To(HaveKeyWithValue("b", 192)) }) + + It("includes share ID claim when set", func() { + c := auth.Claims{ShareID: "abc1234567"} + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("sid", "abc1234567")) + }) }) Describe("ClaimsFromToken", func() { @@ -84,6 +91,7 @@ var _ = Describe("Claims", func() { ID: "al-456", Format: "opus", BitRate: 128, + ShareID: "abc1234567", } token, _, err := tokenAuth.Encode(original.ToMap()) Expect(err).NotTo(HaveOccurred()) @@ -91,6 +99,7 @@ var _ = Describe("Claims", func() { c := auth.ClaimsFromToken(token) Expect(c.Issuer).To(Equal("ND")) Expect(c.ID).To(Equal("al-456")) + Expect(c.ShareID).To(Equal("abc1234567")) Expect(c.Format).To(Equal("opus")) Expect(c.BitRate).To(Equal(128)) }) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 24ecff1d6..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -102,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string { ID: id, Format: s.Format, BitRate: s.MaxBitRate, + ShareID: s.ID, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index daa09c375..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -4,11 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + if info.shareID != "" { + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + } + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -63,17 +77,14 @@ type shareTrackInfo struct { id string format string bitrate int + shareID string } func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - token, err := auth.TokenAuth.Decode(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } - if token == nil { - return shareTrackInfo{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } @@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { id: c.ID, format: c.Format, bitrate: c.BitRate, + shareID: c.ShareID, }, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go new file mode 100644 index 000000000..222d5ef1a --- /dev/null +++ b/server/public/handle_streams_test.go @@ -0,0 +1,196 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockStreamer struct { + req stream.Request + called bool +} + +func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) { + m.called = true + m.req = r + return nil, errors.New("mock: not implemented") +} + +var _ = Describe("decodeStreamInfo", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("decodes a valid token with all fields", func() { + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(192)) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("accepts a token without exp (non-expiring share)", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects a token without an id claim", func() { + claims := auth.Claims{ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an invalid token string", func() { + _, err := decodeStreamInfo("not-a-valid-token") + Expect(err).To(HaveOccurred()) + }) + + It("handles tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123", Format: "opus"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("opus")) + Expect(info.shareID).To(BeEmpty()) + }) +}) + +var _ = Describe("encodeMediafileShare", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("includes the share ID in the token", func() { + exp := P(time.Now().Add(time.Hour)) + s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} + token := encodeMediafileShare(s, "mf-999") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareABC")) + Expect(info.id).To(Equal("mf-999")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(320)) + }) + + It("creates a non-expiring token when share has no expiry", func() { + s := model.Share{ID: "shareXYZ", ExpiresAt: nil} + token := encodeMediafileShare(s, "mf-111") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareXYZ")) + Expect(info.id).To(Equal("mf-111")) + }) +}) + +var _ = Describe("handleStream", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var streamer *mockStreamer + var pub *Router + + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + streamer = &mockStreamer{} + pub = &Router{ds: ds, streamer: streamer} + }) + + makeRequest := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil) + w := httptest.NewRecorder() + pub.handleStream(w, r) + return w + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo + + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("returns 400 for an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 when share has been deleted", func() { + shareRepo.ID = "other-share" + claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when share has been set to expired", func() { + shareRepo.ID = "share123" + expired := time.Now().Add(-time.Hour) + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 500 when share lookup fails", func() { + shareRepo.Error = errors.New("db error") + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("skips share check for tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for an invalid token", func() { + w := makeRequest("not-a-valid-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 22eb9446c..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -44,3 +44,16 @@ func (m *MockShareRepo) Exists(id string) (bool, error) { } return id == m.ID, nil } + +func (m *MockShareRepo) Get(id string) (*model.Share, error) { + if m.Error != nil { + return nil, m.Error + } + if id != m.ID { + return nil, model.ErrNotFound + } + if s, ok := m.Entity.(*model.Share); ok { + return s, nil + } + return &model.Share{ID: id}, nil +} From 3e25ca38684d5e883db9bb416ea1ba61070dca93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 27 Apr 2026 20:19:28 -0400 Subject: [PATCH 209/596] test: enable Subsonic response snapshot tests on Windows (#5427) * fix(test): enable Subsonic response snapshot tests on Windows Replaced cupaloy with a simple custom snapshot matcher that normalizes CRLF line endings before comparison. The tests were skipped on Windows via a //go:build unix tag because Git for Windows checks out snapshot files with CRLF, while Go's xml/json.MarshalIndent always produces LF, causing direct string comparison to fail. The new matcher reads snapshot files with os.ReadFile and normalizes \r\n to \n before comparing. Also added a .gitattributes in the .snapshots directory to enforce LF checkout, and removed the now-unused cupaloy dependency. * fix(test): add UPDATE_SNAPSHOTS support to custom snapshot matcher Restore the ability to update snapshots via `make snapshots` (UPDATE_SNAPSHOTS=true), which was lost when replacing cupaloy with the custom matcher. --- go.mod | 3 +- go.sum | 5 +- .../responses/.snapshots/.gitattributes | 1 + .../responses/responses_suite_test.go | 63 +++++++++++++++---- server/subsonic/responses/responses_test.go | 5 -- 5 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/.gitattributes diff --git a/go.mod b/go.mod index b7dbb9eeb..b1d7d3516 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.3 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 @@ -42,6 +41,7 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.3.0 + github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 @@ -112,7 +112,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect diff --git a/go.sum b/go.sum index 29b979413..6ce31dc22 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -205,9 +203,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= diff --git a/server/subsonic/responses/.snapshots/.gitattributes b/server/subsonic/responses/.snapshots/.gitattributes new file mode 100644 index 000000000..44b4224b1 --- /dev/null +++ b/server/subsonic/responses/.snapshots/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index e10957af2..7fc10aa15 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -1,14 +1,17 @@ package responses import ( + "fmt" + "os" + "path/filepath" "strings" "testing" - "github.com/bradleyjkemp/cupaloy/v2" "github.com/navidrome/navidrome/log" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/types" + "github.com/pmezard/go-difflib/difflib" ) func TestSubsonicApiResponses(t *testing.T) { @@ -18,25 +21,61 @@ func TestSubsonicApiResponses(t *testing.T) { } func MatchSnapshot() types.GomegaMatcher { - c := cupaloy.New(cupaloy.FailOnUpdate(false)) - return &snapshotMatcher{c} + return &snapshotMatcher{} } type snapshotMatcher struct { - c *cupaloy.Config + diff string } -func (matcher snapshotMatcher) Match(actual any) (success bool, err error) { - actualJson := strings.TrimSpace(string(actual.([]byte))) - err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) - success = err == nil - return +func (matcher *snapshotMatcher) Match(actual any) (success bool, err error) { + actualBytes, ok := actual.([]byte) + if !ok { + return false, fmt.Errorf("MatchSnapshot expects []byte, got %T", actual) + } + + name := ginkgo.CurrentSpecReport().FullText() + actualStr := strings.TrimSpace(string(actualBytes)) + + snapshotPath := filepath.Join(".snapshots", name) + + // Support UPDATE_SNAPSHOTS=true for `make snapshots` + if _, update := os.LookupEnv("UPDATE_SNAPSHOTS"); update { + err := os.MkdirAll(".snapshots", os.ModePerm) + if err != nil { + return false, err + } + return true, os.WriteFile(snapshotPath, []byte(actualStr+"\n"), 0600) + } + + expected, err := os.ReadFile(snapshotPath) + if err != nil { + return false, err + } + + // Normalize line endings so snapshots work on Windows (CRLF) and Unix (LF) + normalizedExpected := strings.ReplaceAll(string(expected), "\r\n", "\n") + normalizedExpected = strings.TrimSpace(normalizedExpected) + + if actualStr == normalizedExpected { + return true, nil + } + + diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(normalizedExpected), + B: difflib.SplitLines(actualStr), + FromFile: "snapshot", + ToFile: "actual", + Context: 3, + }) + matcher.diff = diff + return false, nil } -func (matcher snapshotMatcher) FailureMessage(_ any) (message string) { - return "Expected to match saved snapshot\n" +func (matcher *snapshotMatcher) FailureMessage(_ any) (message string) { + return "Expected to match saved snapshot\n" + matcher.diff } -func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) { +func (matcher *snapshotMatcher) NegatedFailureMessage(_ any) (message string) { return "Expected to not match saved snapshot\n" } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 9910ced14..ba86deaf2 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1,8 +1,3 @@ -//go:build unix - -// TODO Fix snapshot tests in Windows -// Response Snapshot tests. Only run in Linux and macOS, as they fail in Windows -// Probably because of EOL char differences package responses_test import ( From a4c1fa63787136e3fa9c86d63c31e3956fa1bba0 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 27 Apr 2026 20:24:01 -0400 Subject: [PATCH 210/596] chore(deps): update dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 8 ++++---- go.sum | 34 ++++++++++++++++------------------ 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index b1d7d3516..a4c0c014b 100644 --- a/go.mod +++ b/go.mod @@ -34,16 +34,16 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.0.13 + github.com/lestrrat-go/jwx/v3 v3.1.0 github.com/mattn/go-sqlite3 v1.14.42 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/ginkgo/v2 v2.28.2 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.3.0 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.0 + github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -114,7 +114,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect diff --git a/go.sum b/go.sum index 6ce31dc22..3e665ba02 100644 --- a/go.sum +++ b/go.sum @@ -139,8 +139,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -167,14 +167,14 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= -github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= +github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -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-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -193,8 +193,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= +github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= @@ -207,16 +207,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/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/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= -github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -318,8 +318,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -419,11 +417,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= -modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= From d5ba61adf818e70f1d3d415066f6f342d70ac528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 27 Apr 2026 21:45:13 -0400 Subject: [PATCH 211/596] fix(ui): prevent autoplay when clearing the play queue (#5430) When clearing the queue, the reducer resets to initialState which lacks an autoPlay field (undefined). The autoPlay option was computed as true because undefined !== false, causing the music player library to attempt playback of the first song before internal state was fully cleared. Added a queue.length > 0 guard to the autoPlay calculation so it is never true when the queue is empty, regardless of other state flags. Fixes #5331 --- ui/src/audioplayer/Player.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index eba3b82d7..5599b9e1d 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -210,6 +210,7 @@ const Player = () => { audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: + playerState.queue.length > 0 && playerState.autoPlay !== false && (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, From 46b4dcd5f67182611c077607601491d9d95944c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 28 Apr 2026 19:40:08 -0400 Subject: [PATCH 212/596] feat(smartplaylist): add isMissing and isPresent operators (#5436) * feat(smartplaylist): add IsMissing and IsPresent operator types Add two new Expression types for detecting absent/present tags and roles in smart playlist criteria. Includes JSON marshal/unmarshal support and Walk visitor registration. * test(smartplaylist): add JSON marshal/unmarshal tests for isMissing/isPresent * feat(smartplaylist): add SQL generation for isMissing/isPresent operators Tags check json_tree(media_file.tags) for key existence. Roles check json_tree(media_file.participants) for key existence. Regular DB column fields are rejected with an error. * test(smartplaylist): add e2e tests for isMissing/isPresent operators Tests cover tag presence/absence with selective matching (grouping), universal absence (lyricist role), universal presence (composer role), and combined operator usage. * refactor(smartplaylist): use strconv.ParseBool in IsTruthy Replace hand-rolled string truthiness check with strconv.ParseBool, which correctly handles standard boolean strings and rejects unrecognized values as false. * refactor(smartplaylist): clarify missingExpr parameter naming Rename defaultNegate to checkAbsence and extract truthy local for readability. The XNOR logic (checkAbsence == truthy) is now easier to follow: isMissing passes true, isPresent passes false. * refactor(smartplaylist): reuse jsonExpr in missingExpr, improve errors - tagCond/roleCond now handle nil cond (existence-only check) - missingExpr delegates to jsonExpr(info, nil, negate) instead of building SQL manually - Better error messages: unknown fields now report the field name --- model/criteria/json.go | 4 +++ model/criteria/operators.go | 35 +++++++++++++++++++- model/criteria/operators_test.go | 4 +++ model/criteria/walk.go | 2 +- persistence/criteria_sql.go | 46 +++++++++++++++++++++++---- persistence/criteria_sql_test.go | 30 +++++++++++++++++ persistence/e2e/e2e_suite_test.go | 4 +-- persistence/e2e/smartplaylist_test.go | 41 ++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 10 deletions(-) diff --git a/model/criteria/json.go b/model/criteria/json.go index f6ab56eda..18a664988 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -69,6 +69,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { return InPlaylist(m) case "notinplaylist": return NotInPlaylist(m) + case "ismissing": + return IsMissing(m) + case "ispresent": + return IsPresent(m) } return nil } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 983c6aa1a..7def18934 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,6 +1,9 @@ package criteria -import "time" +import ( + "strconv" + "time" +) // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -162,6 +165,36 @@ func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { func (nipl NotInPlaylist) fields() map[string]any { return nipl } +type IsMissing map[string]any + +func (im IsMissing) MarshalJSON() ([]byte, error) { + return marshalExpression("isMissing", im) +} + +func (im IsMissing) fields() map[string]any { return im } + +type IsPresent map[string]any + +func (ip IsPresent) MarshalJSON() ([]byte, error) { + return marshalExpression("isPresent", ip) +} + +func (ip IsPresent) fields() map[string]any { return ip } + +func IsTruthy(v any) bool { + switch val := v.(type) { + case bool: + return val + case float64: + return val != 0 + case string: + b, err := strconv.ParseBool(val) + return err == nil && b + default: + return v != nil + } +} + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index c93e8f2b2..bfdca3e31 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -46,5 +46,9 @@ var _ = Describe("Operators", func() { Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`), Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`), Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`), + Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`), + Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`), + Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), + Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) }) diff --git a/model/criteria/walk.go b/model/criteria/walk.go index acaf48289..7445c2aef 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -24,7 +24,7 @@ func Walk(expr Expression, visit Visitor) error { return err } } - case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent: return nil default: return fmt.Errorf("unknown criteria expression type %T", expr) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 1431f0d0e..8fca6b2a2 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -185,6 +185,10 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz return c.inList(e, false) case criteria.NotInPlaylist: return c.inList(e, true) + case criteria.IsMissing: + return missingExpr(e, true) + case criteria.IsPresent: + return missingExpr(e, false) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } @@ -201,6 +205,22 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } +func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { + field, value, info, ok := singleField(values) + if !ok { + if len(values) != 1 { + return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field") + } + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if !info.IsTag && !info.IsRole { + return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + } + + negate := checkAbsence == criteria.IsTruthy(value) + return jsonExpr(info, nil, negate), nil +} + func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil @@ -327,11 +347,18 @@ type tagCond struct { } func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - if e.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag) } - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) if e.not { cond = "not " + cond } @@ -345,8 +372,15 @@ type roleCond struct { } func (e roleCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + } if e.not { cond = "not " + cond } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e02032d9a..a2212c4e5 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,26 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // isMissing — tags + Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isMissing tag [false]", criteria.IsMissing{"genre": false}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isMissing — roles + Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, + "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, + "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + // isPresent — tags + Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isPresent tag [false]", criteria.IsPresent{"genre": false}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isPresent — roles + Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, + "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, + "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), ) Describe("playlist permissions", func() { @@ -115,6 +135,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError("invalid field in criteria: unknown")) }) + It("returns an error when isMissing is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + + It("returns an error when isPresent is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index ea0d0fea8..f42292f02 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -116,9 +116,9 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 086e73703..a844dc982 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -330,4 +330,45 @@ var _ = Describe("Smart Playlists", func() { }) }) + + Describe("isMissing/isPresent operators", func() { + It("isMissing finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent false finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing returns all tracks for a tag nobody has", func() { + results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent returns all tracks for a role everyone has", func() { + results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("combines isMissing with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) From d9dac44456187beb98331811a64c287dc3767f6d Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 28 Apr 2026 19:46:12 -0400 Subject: [PATCH 213/596] feat(smartplaylist): add ReplayGain fields to criteria system Expose the four ReplayGain database columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) as first-class numeric criteria fields for smart playlists. This allows users to filter tracks by ReplayGain values, e.g. finding tracks with album gain below a threshold. --- model/criteria/fields.go | 4 ++++ persistence/criteria_sql.go | 4 ++++ persistence/criteria_sql_test.go | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 20c0048b3..37b4cc017 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -72,6 +72,10 @@ var fieldMap = map[string]FieldInfo{ "mbz_recording_id": {Name: "mbz_recording_id"}, "mbz_release_track_id": {Name: "mbz_release_track_id"}, "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, + "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, + "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, + "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 8fca6b2a2..123ec29eb 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -111,6 +111,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "rgalbumgain": {expr: "media_file.rg_album_gain"}, + "rgalbumpeak": {expr: "media_file.rg_album_peak"}, + "rgtrackgain": {expr: "media_file.rg_track_gain"}, + "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, "value": {expr: "value"}, diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index a2212c4e5..c93465735 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // ReplayGain fields + Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), + Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), + Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0), // isMissing — tags Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), From 0fd9c6df2eaf653186228d59a4a9a460b6db69d8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 28 Apr 2026 20:22:48 -0400 Subject: [PATCH 214/596] refactor(smartplaylist): clarify FieldInfo naming in criteria package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename FieldInfo.Name to Alias (only meaningful for backward-compat entries like albumtype→releasetype) and FieldInfo.alias to tagAlias (tag names from mappings.yml that resolve to existing fields). Add a Name() method that derives the canonical name from the map key, eliminating 60+ redundant Name declarations where the value matched the key. LookupField now populates the private name field automatically. Signed-off-by: Deluan <deluan@navidrome.org> --- model/criteria/fields.go | 171 +++++++++++++++++-------------- model/criteria/fields_test.go | 12 +-- model/criteria/sort.go | 2 +- persistence/criteria_sql.go | 16 +-- persistence/criteria_sql_test.go | 4 +- 5 files changed, 111 insertions(+), 94 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 37b4cc017..44d43457b 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,87 +2,94 @@ package criteria import "strings" -// FieldInfo contains semantic metadata about a criteria field +// FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Name string + Alias string // If set, this field is a backward-compat alias for another canonical name IsTag bool IsRole bool Numeric bool - alias string + + tagAlias string // If set, a tag name from mappings.yml that resolves to this field + name string // Canonical name, populated by LookupField from the map key +} + +// Name returns the canonical field name (the map key used to register this field). +func (f FieldInfo) Name() string { + return f.name } var fieldMap = map[string]FieldInfo{ - "title": {Name: "title"}, - "album": {Name: "album"}, - "hascoverart": {Name: "hascoverart"}, - "tracknumber": {Name: "tracknumber"}, - "discnumber": {Name: "discnumber"}, - "year": {Name: "year"}, - "date": {Name: "date", alias: "recordingdate"}, - "originalyear": {Name: "originalyear"}, - "originaldate": {Name: "originaldate"}, - "releaseyear": {Name: "releaseyear"}, - "releasedate": {Name: "releasedate"}, - "size": {Name: "size"}, - "compilation": {Name: "compilation"}, - "missing": {Name: "missing"}, - "explicitstatus": {Name: "explicitstatus"}, - "dateadded": {Name: "dateadded"}, - "datemodified": {Name: "datemodified"}, - "discsubtitle": {Name: "discsubtitle"}, - "comment": {Name: "comment"}, - "lyrics": {Name: "lyrics"}, - "sorttitle": {Name: "sorttitle"}, - "sortalbum": {Name: "sortalbum"}, - "sortartist": {Name: "sortartist"}, - "sortalbumartist": {Name: "sortalbumartist"}, - "albumcomment": {Name: "albumcomment"}, - "catalognumber": {Name: "catalognumber"}, - "filepath": {Name: "filepath"}, - "filetype": {Name: "filetype"}, - "codec": {Name: "codec"}, - "duration": {Name: "duration"}, - "bitrate": {Name: "bitrate"}, - "bitdepth": {Name: "bitdepth"}, - "samplerate": {Name: "samplerate"}, - "bpm": {Name: "bpm"}, - "channels": {Name: "channels"}, - "loved": {Name: "loved"}, - "dateloved": {Name: "dateloved"}, - "lastplayed": {Name: "lastplayed"}, - "daterated": {Name: "daterated"}, - "playcount": {Name: "playcount"}, - "rating": {Name: "rating"}, - "averagerating": {Name: "averagerating", Numeric: true}, - "albumrating": {Name: "albumrating"}, - "albumloved": {Name: "albumloved"}, - "albumplaycount": {Name: "albumplaycount"}, - "albumlastplayed": {Name: "albumlastplayed"}, - "albumdateloved": {Name: "albumdateloved"}, - "albumdaterated": {Name: "albumdaterated"}, - "artistrating": {Name: "artistrating"}, - "artistloved": {Name: "artistloved"}, - "artistplaycount": {Name: "artistplaycount"}, - "artistlastplayed": {Name: "artistlastplayed"}, - "artistdateloved": {Name: "artistdateloved"}, - "artistdaterated": {Name: "artistdaterated"}, - "mbz_album_id": {Name: "mbz_album_id"}, - "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, - "mbz_artist_id": {Name: "mbz_artist_id"}, - "mbz_recording_id": {Name: "mbz_recording_id"}, - "mbz_release_track_id": {Name: "mbz_release_track_id"}, - "mbz_release_group_id": {Name: "mbz_release_group_id"}, - "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, - "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, - "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, - "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, - "library_id": {Name: "library_id", Numeric: true}, + "title": {}, + "album": {}, + "hascoverart": {}, + "tracknumber": {}, + "discnumber": {}, + "year": {}, + "date": {tagAlias: "recordingdate"}, + "originalyear": {}, + "originaldate": {}, + "releaseyear": {}, + "releasedate": {}, + "size": {}, + "compilation": {}, + "missing": {}, + "explicitstatus": {}, + "dateadded": {}, + "datemodified": {}, + "discsubtitle": {}, + "comment": {}, + "lyrics": {}, + "sorttitle": {}, + "sortalbum": {}, + "sortartist": {}, + "sortalbumartist": {}, + "albumcomment": {}, + "catalognumber": {}, + "filepath": {}, + "filetype": {}, + "codec": {}, + "duration": {}, + "bitrate": {}, + "bitdepth": {}, + "samplerate": {}, + "bpm": {}, + "channels": {}, + "loved": {}, + "dateloved": {}, + "lastplayed": {}, + "daterated": {}, + "playcount": {}, + "rating": {}, + "averagerating": {Numeric: true}, + "albumrating": {}, + "albumloved": {}, + "albumplaycount": {}, + "albumlastplayed": {}, + "albumdateloved": {}, + "albumdaterated": {}, + "artistrating": {}, + "artistloved": {}, + "artistplaycount": {}, + "artistlastplayed": {}, + "artistdateloved": {}, + "artistdaterated": {}, + "mbz_album_id": {}, + "mbz_album_artist_id": {}, + "mbz_artist_id": {}, + "mbz_recording_id": {}, + "mbz_release_track_id": {}, + "mbz_release_group_id": {}, + "rgalbumgain": {Numeric: true}, + "rgalbumpeak": {Numeric: true}, + "rgtrackgain": {Numeric: true}, + "rgtrackpeak": {Numeric: true}, + "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {Name: "releasetype", IsTag: true}, + "albumtype": {Alias: "releasetype", IsTag: true}, - "random": {Name: "random"}, - "value": {Name: "value"}, + "random": {}, + "value": {}, } // AllFieldNames returns the names of all registered criteria fields. @@ -96,7 +103,15 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { - f, ok := fieldMap[strings.ToLower(name)] + key := strings.ToLower(name) + f, ok := fieldMap[key] + if ok { + if f.Alias != "" { + f.name = f.Alias + } else { + f.name = key + } + } return f, ok } @@ -108,7 +123,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = FieldInfo{Name: name, IsRole: true} + fieldMap[name] = FieldInfo{IsRole: true} } } @@ -120,14 +135,16 @@ func AddTagNames(tagNames []string) { if _, ok := fieldMap[name]; ok { continue } - for _, fm := range fieldMap { - if fm.alias == name { + for key, fm := range fieldMap { + if fm.tagAlias == name { + fm.Alias = key + fm.tagAlias = "" fieldMap[name] = fm break } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = FieldInfo{Name: name, IsTag: true} + fieldMap[name] = FieldInfo{IsTag: true} } } } @@ -140,7 +157,7 @@ func AddNumericTags(tagNames []string) { fm.Numeric = true fieldMap[name] = fm } else { - fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} + fieldMap[name] = FieldInfo{IsTag: true, Numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index ecbeb5857..f60666788 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -11,14 +11,14 @@ var _ = Describe("fields", func() { field, ok := LookupField("Title") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + gomega.Expect(field.Name()).To(gomega.Equal("title")) }) - It("resolves aliases to their semantic field name", func() { + It("resolves aliases to their canonical field name", func() { field, ok := LookupField("albumtype") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.Name()).To(gomega.Equal("releasetype")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -26,7 +26,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("value") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("value")) + gomega.Expect(field.Name()).To(gomega.Equal("value")) }) It("finds registered tag names", func() { @@ -35,7 +35,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_mood") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_mood")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -56,7 +56,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_producer") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go index 05b108cf9..e38fe0551 100644 --- a/model/criteria/sort.go +++ b/model/criteria/sort.go @@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField { if order == "desc" { desc = !desc } - fields = append(fields, SortField{Field: info.Name, Desc: desc}) + fields = append(fields, SortField{Field: info.Name(), Desc: desc}) } if len(fields) == 0 { log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 123ec29eb..4fd8e62e6 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -338,9 +338,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { - return roleCond{role: info.Name, cond: cond, not: negate} + return roleCond{role: info.Name(), cond: cond, not: negate} } - return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} + return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate} } type tagCond struct { @@ -412,7 +412,7 @@ func sqlFields(values map[string]any) (map[string]any, error) { if info.IsTag || info.IsRole { return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) } - sqlField, ok := fieldExpr(info.Name) + sqlField, ok := fieldExpr(info.Name()) if !ok || sqlField == "" { return nil, fmt.Errorf("invalid field in criteria: %s", field) } @@ -431,7 +431,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { if !ok { return smartPlaylistJoinNone } - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok { return smartPlaylistJoinNone } @@ -479,17 +479,17 @@ func sortExpr(sortField string) (string, bool) { if !ok { return "", false } - if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" { return field.order, true } var mapped string switch { case info.IsTag: - mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')" case info.IsRole: - mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')" default: - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok || field.expr == "" { return "", false } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index c93465735..40d21c9bf 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -194,8 +194,8 @@ var _ = Describe("Smart playlist criteria SQL", func() { if info.IsTag || info.IsRole { continue } - _, hasSQLField := smartPlaylistFields[info.Name] - Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) + _, hasSQLField := smartPlaylistFields[info.Name()] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name()) } }) From 57fc85f434111fa7d943e099ba89161166175021 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 28 Apr 2026 20:44:54 -0400 Subject: [PATCH 215/596] refactor(smartplaylist): remove unused 'value' field and clarify 'random' usage Signed-off-by: Deluan <deluan@navidrome.org> --- model/criteria/fields.go | 2 +- model/criteria/fields_test.go | 7 ------- persistence/criteria_sql.go | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 44d43457b..b35913f28 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -88,8 +88,8 @@ var fieldMap = map[string]FieldInfo{ // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Pseudo-field for random sorting "random": {}, - "value": {}, } // AllFieldNames returns the names of all registered criteria fields. diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index f60666788..270a14473 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -22,13 +22,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) - It("finds special fields", func() { - field, ok := LookupField("value") - - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name()).To(gomega.Equal("value")) - }) - It("finds registered tag names", func() { AddTagNames([]string{"task3_mood"}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 4fd8e62e6..9a99cfa8e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -117,7 +117,6 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, - "value": {expr: "value"}, } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { From bdea9ed6a1bbcfdf6c251f117225559b05c07671 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:48:59 -0600 Subject: [PATCH 216/596] fix(ui): show album tile actions on keyboard focus (#5434) * fix(ui): show album tile actions on keyboard focus - #4836 The album grid tile bar (containing play/heart/context-menu buttons) had opacity:0 by default and only became visible on mouse :hover. Keyboard users tabbing through the album grid never saw which tile was focused and could not discover the available actions. Mirrors the existing :hover rule with :focus-within, which matches when the link itself or any descendant (e.g. the play button) has focus. Signed-off-by: Daniel Banariba <banaribad@gmail.com> * fix(ui): also disable pointer events on hidden album tile bar - #4836 Per review feedback (@gemini-code-assist): the tileBar's buttons remained clickable even at opacity:0, causing accidental Play/Menu triggers when a user clicked what looked like the album cover. Set 'pointerEvents: none' on the base tileBar and restore 'auto' on the same selector that turns it visible. Signed-off-by: Daniel Banariba <banaribad@gmail.com> --------- Signed-off-by: Daniel Banariba <banaribad@gmail.com> --- ui/src/album/AlbumGridView.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 9717618fa..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -34,6 +34,7 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, + pointerEvents: 'none', textAlign: 'left', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', @@ -78,8 +79,9 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar': { + '&:hover $tileBar, &:focus-within $tileBar': { opacity: 1, + pointerEvents: 'auto', }, }, albumLink: { From 2307a64da759157ed3adc1033f58e314eda882ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 29 Apr 2026 15:36:39 -0400 Subject: [PATCH 217/596] fix(ui): start new album from track 1 after closing player (#5441) When a user played an album, advanced a few tracks, closed the player, then played a different album, playback started mid-album at the previous track index instead of track 1. The root cause was in reduceSyncQueue: the hasPendingSwitch check compared playIndex against savedPlayIndex, but after clearQueue both reset to 0, making the check falsely conclude no switch was pending. This caused PLAYER_SYNC_QUEUE to prematurely clear the playIndex and clear flags before the music player library could act on them. Fix: also treat clear=true as a signal that a track switch is pending, since it means a new queue was just loaded. --- ui/src/reducers/playerReducer.js | 14 +++-- ui/src/reducers/playerReducer.test.js | 82 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 466a3ec87..d6ab7484b 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -164,13 +164,15 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // Only keep clear and playIndex alive when there is an actual pending - // track switch (playIndex differs from savedPlayIndex). This lets - // PLAYER_PLAY_TRACKS selections survive the sync, while allowing - // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to - // reset immediately and avoid restarting playback. + // Keep clear and playIndex alive when there is a pending track switch. + // A switch is pending when playIndex is set AND either: + // - playIndex differs from savedPlayIndex, OR + // - clear is true (a new queue was loaded, e.g. after clearQueue + playTracks) + // The clear check handles the edge case where both playIndex and + // savedPlayIndex are 0 (close player then play a new album from track 1). const hasPendingSwitch = - state.playIndex != null && state.playIndex !== state.savedPlayIndex + state.playIndex != null && + (state.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 10e9512d7..110ce8c53 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -96,6 +96,88 @@ describe('playerReducer', () => { }) }) + describe('play new album after closing player (issue #5440)', () => { + it('SYNC_QUEUE preserves pending playIndex=0 after clearQueue', () => { + // Scenario: user plays album A, advances to track 3, closes player, + // then plays album B. After clearQueue, savedPlayIndex=0. + // PLAYER_PLAY_TRACKS sets playIndex=0. SYNC_QUEUE must NOT clear it. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, // reset by clearQueue + clear: true, + volume: 1, + } + + const action = { + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo: {}, + audioLists: stateAfterClearThenPlay.queue, + }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for wrong track preserves pending playIndex=0 after clearQueue', () => { + // The music player fires onAudioPlay for the old track (at index 3) + // before switching to the new track at index 0. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + { trackId: 'b4', uuid: 'u4', name: 'B Song 4' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player reports track at index 3 as current (stale callback) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u4', name: 'B Song 4', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for correct track consumes pending playIndex=0', () => { + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player confirms it switched to track at index 0 + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u1', name: 'B Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { From 2b9f32699348d520fc96acbd74be24b12702b02a Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 30 Apr 2026 16:55:10 -0400 Subject: [PATCH 218/596] chore(docker): add app directory to PATH in Dockerfile --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 105656afb..ad1e2a41c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,6 +164,7 @@ RUN touch /.nddockerenv EXPOSE ${ND_PORT} WORKDIR /app +ENV PATH="/app:${PATH}" ENTRYPOINT ["/app/navidrome"] From 94eb6c522b63198bdc4565442d86918ad43156e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 30 Apr 2026 23:04:05 -0400 Subject: [PATCH 219/596] feat(subsonic): implement playbackReport OpenSubsonic extension (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(req): add Float64Or helper for parsing float query params * feat(scrobbler): extend NowPlayingInfo with state/position/rate fields * feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble * feat(responses): add state/positionMs/playbackRate to NowPlayingEntry * feat(subsonic): add reportPlayback endpoint handler * feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response * feat(subsonic): register playbackReport OpenSubsonic extension * test(e2e): add reportPlayback endpoint e2e tests * refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication - Add state constants and exported ValidStates map - Extract remainingTTL() helper (was duplicated 3x) - Merge playing/paused switch cases into single branch - Use Get instead of GetWithParticipants for non-stopped states - Guard NowPlayingCount broadcast with count-change detection - Use cache entry for NowPlaying dispatch instead of extra DB query - Remove redundant Position field from NowPlayingInfo * refactor(scrobbler): skip DB query in playing/paused when playMap has entry * fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache Signed-off-by: Deluan <deluan@navidrome.org> * refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo Signed-off-by: Deluan <deluan@navidrome.org> * fix(play_tracker): check all ReportPlayback errors in tests Replace _ = with explicit error assertions to avoid masking failures in intermediate calls. Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback Replace noopPlayTracker with a real PlayTracker backed by the E2E database. E2E tests now verify the full round-trip: reportPlayback creates/updates/removes entries visible via getNowPlaying, including state, positionMs, and playbackRate fields. Export NewPlayTracker constructor for use outside the scrobbler package. * fix(play_tracker): account for playback rate in TTL and detect track switches The remainingTTL function now divides remaining time by the playback rate, so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves the TTL). Zero/negative rates default to 1.0. The playing/paused case now checks if the cached MediaFile ID matches the reported mediaId, falling back to a DB fetch when the client switches tracks without sending stopped/starting. Adds parameterized tests for remainingTTL covering rate variations and edge cases. * fix(subsonic): validate positionMs and playbackRate in reportPlayback Reject negative positionMs values and invalid playbackRate values (NaN, Inf, zero, negative) at the API boundary before they reach TTL and position estimation math. Returns clear error messages for each case. * feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters Signed-off-by: Deluan <deluan@navidrome.org> * refactor(play_tracker): replace NowPlaying method with ReportPlayback calls Signed-off-by: Deluan <deluan@navidrome.org> * refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/scrobbler/play_tracker.go | 175 ++++-- core/scrobbler/play_tracker_test.go | 514 ++++++++++++++---- server/e2e/e2e_suite_test.go | 28 +- server/e2e/subsonic_media_annotation_test.go | 111 ++++ server/e2e/subsonic_sonic_similarity_test.go | 3 +- server/subsonic/album_lists.go | 13 +- server/subsonic/api.go | 1 + server/subsonic/media_annotation.go | 72 ++- server/subsonic/media_annotation_test.go | 118 +++- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 62 ++- ...es NowPlaying with data should match .JSON | 23 + ...ses NowPlaying with data should match .XML | 5 + ...NowPlaying without data should match .JSON | 8 + ... NowPlaying without data should match .XML | 3 + server/subsonic/responses/responses.go | 11 +- server/subsonic/responses/responses_test.go | 36 ++ utils/cache/simple_cache.go | 5 + utils/req/req.go | 12 + utils/req/req_test.go | 17 + 20 files changed, 1002 insertions(+), 216 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d1338ca39..462fc85ba 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -3,7 +3,7 @@ package scrobbler import ( "context" "maps" - "sort" + "slices" "sync" "time" @@ -17,13 +17,30 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) +const ( + StateStarting = "starting" + StatePlaying = "playing" + StatePaused = "paused" + StateStopped = "stopped" +) + +var ValidStates = map[string]bool{ + StateStarting: true, + StatePlaying: true, + StatePaused: true, + StateStopped: true, +} + type NowPlayingInfo struct { - MediaFile model.MediaFile - Start time.Time - Position int - Username string - PlayerId string - PlayerName string + MediaFile model.MediaFile + Start time.Time + Username string + PlayerId string + PlayerName string + State string + PositionMs int64 + PlaybackRate float64 + LastReport time.Time } type Submission struct { @@ -31,6 +48,16 @@ type Submission struct { Timestamp time.Time } +type ReportPlaybackParams struct { + MediaId string + PositionMs int64 + State string + PlaybackRate float64 + IgnoreScrobble bool + ClientId string + ClientName string +} + type nowPlayingEntry struct { ctx context.Context userId string @@ -39,9 +66,9 @@ type nowPlayingEntry struct { } type PlayTracker interface { - NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) Submit(ctx context.Context, submissions []Submission) error + ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } // PluginLoader is a minimal interface for plugin manager usage in PlayTracker @@ -72,8 +99,12 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug }) } -// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by -// the GetPlayTracker function above +// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton, +// returned by the GetPlayTracker function above. This constructor is exported for testing. +func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { + return newPlayTracker(ds, broker, pluginManager) +} + func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { m := cache.NewSimpleCache[string, NowPlayingInfo]() p := &playTracker{ @@ -193,36 +224,104 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } -func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) - if err != nil { - log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) - return err +func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { + if rate <= 0 { + rate = 1.0 } + remainingMs := float64(int64(durationSec*1000)-positionMs) / rate + remainingSec := max(int(remainingMs/1000), 0) + return time.Duration(remainingSec+5) * time.Second +} +func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error { + player, _ := request.PlayerFrom(ctx) user, _ := request.UserFrom(ctx) - info := NowPlayingInfo{ - MediaFile: *mf, - Start: time.Now(), - Position: position, - Username: user.UserName, - PlayerId: playerId, - PlayerName: playerName, + clientId := params.ClientId + client := params.ClientName + + now := time.Now() + prevCount := p.playMap.Len() + + switch params.State { + case StateStarting: + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info := NowPlayingInfo{ + MediaFile: *mf, + Start: now, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + if err != nil { + log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StatePlaying, StatePaused: + info, getErr := p.playMap.Get(clientId) + if getErr != nil || info.MediaFile.ID != params.MediaId { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info = NowPlayingInfo{ + MediaFile: *mf, + Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + } + } + info.State = params.State + info.PositionMs = params.PositionMs + info.PlaybackRate = params.PlaybackRate + info.LastReport = now + ttl := 30 * time.Minute + if params.State == StatePlaying { + ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) + } + err := p.playMap.AddWithTTL(clientId, info, ttl) + if err != nil { + log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StateStopped: + if !params.IgnoreScrobble && player.ScrobbleEnabled { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + trackDurationMs := int64(mf.Duration * 1000) + threshold := min(trackDurationMs*50/100, 240_000) + if params.PositionMs >= threshold { + err = p.incPlay(ctx, mf, now) + if err != nil { + log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) + } + p.dispatchScrobble(ctx, mf, now) + } + } + p.playMap.Remove(clientId) } - // Calculate TTL based on remaining track duration. If position exceeds track duration, - // remaining is set to 0 to avoid negative TTL. - remaining := max(int(mf.Duration)-position, 0) - // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. - ttl := time.Duration(remaining+5) * time.Second - _ = p.playMap.AddWithTTL(playerId, info, ttl) - if conf.Server.EnableNowPlaying { + if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - player, _ := request.PlayerFrom(ctx) - if player.ScrobbleEnabled { - p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) + + if !params.IgnoreScrobble && player.ScrobbleEnabled && + (params.State == StateStarting || params.State == StatePlaying) { + if info, err := p.playMap.Get(clientId); err == nil { + p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) + } } + return nil } @@ -296,9 +395,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t * func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { res := p.playMap.Values() - sort.Slice(res, func(i, j int) bool { - return res[i].Start.After(res[j].Start) + slices.SortFunc(res, func(a, b NowPlayingInfo) int { + return b.Start.Compare(a.Start) }) + for i := range res { + if res[i].State == StatePlaying { + elapsed := time.Since(res[i].LastReport).Milliseconds() + estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate) + trackDurationMs := int64(res[i].MediaFile.Duration * 1000) + res[i].PositionMs = min(estimated, trackDurationMs) + } + } return res, nil } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..64b3d3ee0 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -20,9 +20,6 @@ import ( . "github.com/onsi/gomega" ) -// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests -// Moved to top-level scope to avoid linter issues - type mockPluginLoader struct { mu sync.RWMutex names []string @@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) }) - Describe("NowPlaying", func() { - It("sends track to agent", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - Expect(fake.GetUserID()).To(Equal("u-1")) - Expect(fake.GetTrack().ID).To(Equal("123")) - Expect(fake.GetTrack().Participants).To(Equal(track.Participants)) - }) - It("does not send track to agent if user has not authorized", func() { - fake.Authorized = false - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if player is not enabled to send scrobbles", func() { - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if artist is unknown", func() { - track.Artist = consts.UnknownArtist - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - - It("stores position when greater than zero", func() { - pos := 42 - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos)) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].Position).To(Equal(pos)) - }) - - It("sends event with count", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - eventList := eventBroker.getEvents() - Expect(eventList).ToNot(BeEmpty()) - evt, ok := eventList[0].(*events.NowPlayingCount) - Expect(ok).To(BeTrue()) - Expect(evt.Count).To(Equal(1)) - }) - - It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(eventBroker.getEvents()).To(BeEmpty()) - }) - - It("passes user to scrobbler via context (fix for issue #4787)", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - // Verify the username was passed through async dispatch via context - Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser")) - }) - }) - Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) - _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) + ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one", + }) + ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) playing, err := tracker.GetNowPlaying(ctx) @@ -336,6 +263,379 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("ReportPlayback", func() { + const defaultClientId = "client-1" + + BeforeEach(func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true}) + }) + + It("creates entry on starting and removes on stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].MediaFile.ID).To(Equal("123")) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing[0].State).To(Equal("paused")) + Expect(playing[0].PositionMs).To(Equal(int64(30000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("starting replaces existing entry for same player", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("multiple players have independent sessions", func() { + ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true}) + + ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true}) + + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + + err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2", + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(2)) + }) + + Describe("auto-scrobble", func() { + It("scrobbles on stopped when positionMs >= 50% of track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + Expect(artist1.PlayCount).To(Equal(int64(1))) + }) + + It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() { + longTrack := model.MediaFile{ + ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1", + Duration: 600, + Participants: map[model.Role]model.ParticipantList{ + model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, + }, + } + _ = ds.MediaFile(ctx).Put(&longTrack) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(longTrack.PlayCount).To(Equal(int64(1))) + }) + + It("does NOT scrobble when positionMs below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("scrobbles twice for two separate sessions of same song", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(2))) + }) + + It("dispatches to external scrobblers on auto-scrobble", func() { + fake.ScrobbleCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + }) + + Describe("position estimation", func() { + It("estimates position for playing state based on elapsed time", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000))) + }) + + It("does NOT estimate for paused", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(10000))) + }) + + It("does NOT estimate for starting", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("respects playbackRate", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(100 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + // At 2x speed, 100ms real time = ~200ms playback time + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100))) + }) + + It("caps estimated position at track duration", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000 + }) + + }) + + Describe("resilience (no prior starting)", func() { + It("playing without prior starting creates entry with Start approx now - positionMs", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + expectedStart := time.Now().Add(-30 * time.Second) + Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second)) + }) + + It("paused without prior starting creates entry", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("paused")) + }) + + It("stopped without prior starting auto-scrobbles if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("stopped without prior starting does NOT scrobble if below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("external scrobbler dispatch", func() { + It("dispatches NowPlaying on starting", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("dispatches NowPlaying on playing", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("does NOT dispatch on paused", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ignoreScrobble=true", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ScrobbleEnabled=false", func() { + fake.nowPlayingCalled.Store(false) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + }) + }) + Describe("Plugin scrobbler logic", func() { var pluginLoader *mockPluginLoader var pluginFake *fakeScrobbler @@ -354,27 +654,32 @@ var _ = Describe("PlayTracker", func() { }) It("registers and uses plugin scrobbler for NowPlaying", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("removes plugin scrobbler if not present anymore", func() { - // First call: plugin present - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) pluginFake.nowPlayingCalled.Store(false) - // Remove plugin pluginLoader.SetNames([]string{}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - // Should not be called since plugin was removed + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("calls both builtin and plugin scrobblers for NowPlaying", func() { fake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false) - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) @@ -550,6 +855,24 @@ var _ = Describe("PlayTracker", func() { }) }) +var _ = DescribeTable("remainingTTL", + func(durationSec float32, positionMs int64, rate float64, expected time.Duration) { + Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected)) + }, + Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second), + Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second), + Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second), + Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second), + Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second), + Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second), + Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second), + Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second), + Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second), + Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second), + Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second), + Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second), +) + type fakeScrobbler struct { Authorized bool nowPlayingCalled atomic.Bool @@ -577,17 +900,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } -func (f *fakeScrobbler) GetPosition() int { - return int(f.position.Load()) -} - -func (f *fakeScrobbler) GetUsername() string { - if p := f.username.Load(); p != nil { - return *p - } - return "" -} - func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4b003167a..7ce8de2e6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } -// noopPlayTracker implements scrobbler.PlayTracker -type noopPlayTracker struct{} - -func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { - return nil -} - -func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { - return nil, nil -} - -func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { - return nil -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -513,7 +497,7 @@ func setupTestDB() { s, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go index 0f7e92083..ec9b070de 100644 --- a/server/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + + Describe("ReportPlayback", Ordered, func() { + var songID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("returns error when required params are missing", func() { + resp := doReq("reportPlayback") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for invalid state", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "invalid", + ) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("starting report creates a getNowPlaying entry", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.Status).To(Equal(responses.StatusOK)) + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("starting")) + }) + + It("playing report updates getNowPlaying state and position", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "playing", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("playing")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000))) + }) + + It("paused report freezes position in getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "paused", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("paused")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000))) + }) + + It("stopped report removes entry from getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "90000", + "state", "stopped", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(BeEmpty()) + }) + + It("accepts mediaType=podcast without error", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "podcast", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("accepts optional playbackRate and ignoreScrobble", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "5000", + "state", "playing", + "playbackRate", "1.5", + "ignoreScrobble", "true", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5)) + }) + }) }) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 3a010bc67..40161470b 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" @@ -42,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { nil, // scanner events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 56cf469c5..275665b02 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -214,11 +214,14 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { return responses.NowPlayingEntry{ - Child: childFromMediaFile(ctx, np.MediaFile), - UserName: np.Username, - MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything - PlayerName: np.PlayerName, + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerName: np.PlayerName, + State: np.State, + PositionMs: np.PositionMs, + PlaybackRate: np.PlaybackRate, } }) return response, nil diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 5b6565260..0bbfcb83e 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -144,6 +144,7 @@ func (api *Router) routes() http.Handler { h(r, "star", api.Star) h(r, "unstar", api.Unstar) h(r, "scrobble", api.Scrobble) + h(r, "reportPlayback", api.ReportPlayback) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 39bc83fa9..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "math" "net/http" "time" @@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi } log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) - return err + return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: trackId, + PositionMs: int64(position) * 1000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) +} + +func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + mediaId, err := p.String("mediaId") + if err != nil { + return nil, err + } + mediaType, err := p.String("mediaType") + if err != nil { + return nil, err + } + positionMs, err := p.Int64("positionMs") + if err != nil { + return nil, err + } + if positionMs < 0 { + return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative") + } + state, err := p.String("state") + if err != nil { + return nil, err + } + + if !scrobbler.ValidStates[state] { + return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state) + } + + playbackRate := p.Float64Or("playbackRate", 1.0) + if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 { + return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number") + } + ignoreScrobble := p.BoolOr("ignoreScrobble", false) + + ctx := r.Context() + if mediaType != "song" { + log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId) + } + + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mediaId, + PositionMs: positionMs, + State: state, + PlaybackRate: playbackRate, + IgnoreScrobble: ignoreScrobble, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err) + return nil, err + } + + return newResponse(), nil } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1c513edf4..f5cf434e0 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying", func() { + It("registers a NowPlaying via ReportPlayback", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - Expect(playTracker.Playing).To(HaveLen(1)) - Expect(playTracker.Playing).To(HaveKey("player-1")) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12")) + Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1")) }) }) }) + + Describe("ReportPlayback", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when positionMs is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when state is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid state value", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative positionMs", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for NaN playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for Inf playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for zero playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("accepts mediaType=podcast without error", func() { + r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + resp, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + _, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0)) + Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse()) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1")) + Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - Playing map[string]string - Error error -} - -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { - if f.Error != nil { - return f.Error - } - if f.Playing == nil { - f.Playing = make(map[string]string) - } - f.Playing[playerId] = trackId - return nil + Submissions []scrobbler.Submission + ReportedPlayback []scrobbler.ReportPlaybackParams + Error error } func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { @@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm return nil } +func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error { + if f.Error != nil { + return f.Error + } + f.ReportedPlayback = append(f.ReportedPlayback, params) + return nil +} + var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) type fakeEventBroker struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 6c54d36d0..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -14,6 +14,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, + {Name: "playbackReport", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index d98599f8f..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,42 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - Context("with sonic similarity plugin", func() { - BeforeEach(func() { - sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) - }) - - It("should return 6 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -90,6 +61,37 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 7 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(7), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON new file mode 100644 index 000000000..f99cb62af --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON @@ -0,0 +1,23 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "Song", + "username": "testuser", + "minutesAgo": 2, + "playerId": 1, + "playerName": "TestPlayer", + "state": "playing", + "positionMs": 120000, + "playbackRate": 1.5 + } + ] + } +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML new file mode 100644 index 000000000..ae05b68d0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML @@ -0,0 +1,5 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> + <nowPlaying> + <entry id="1" isDir="false" title="Song" username="testuser" minutesAgo="2" playerId="1" playerName="TestPlayer" state="playing" positionMs="120000" playbackRate="1.5"></entry> + </nowPlaying> +</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON new file mode 100644 index 000000000..ab8f793fa --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": {} +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML new file mode 100644 index 000000000..763b4b583 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML @@ -0,0 +1,3 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> + <nowPlaying></nowPlaying> +</subsonic-response> diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c2e863b0f..b0b2b8752 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -358,10 +358,13 @@ type Starred2 struct { type NowPlayingEntry struct { Child - UserName string `xml:"username,attr" json:"username"` - MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` - PlayerId int32 `xml:"playerId,attr" json:"playerId"` - PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + UserName string `xml:"username,attr" json:"username"` + MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerId int32 `xml:"playerId,attr" json:"playerId"` + PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + State string `xml:"state,attr" json:"state"` + PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` + PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` } type NowPlaying struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ba86deaf2..ee98a3daa 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1109,6 +1109,42 @@ var _ = Describe("Responses", func() { }) }) + Describe("NowPlaying", func() { + BeforeEach(func() { + response.NowPlaying = &NowPlaying{} + }) + + Describe("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + response.NowPlaying.Entry = []NowPlayingEntry{{ + Child: Child{Id: "1", Title: "Song", IsDir: false}, + UserName: "testuser", + MinutesAgo: 2, + PlayerId: 1, + PlayerName: "TestPlayer", + State: "playing", + PositionMs: 120000, + PlaybackRate: 1.5, + }} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + Describe("SonicMatches", func() { Context("without data", func() { BeforeEach(func() { diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index cac41be7b..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface { AddWithTTL(key K, value V, ttl time.Duration) error Get(key K) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) + Remove(key K) Keys() []K Values() []V Len() int @@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error return nil } +func (c *simpleCache[K, V]) Remove(key K) { + c.data.Delete(key) +} + func (c *simpleCache[K, V]) Get(key K) (V, error) { item := c.data.Get(key) if item == nil { diff --git a/utils/req/req.go b/utils/req/req.go index f9fa5724b..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool { } return v } + +func (r *Values) Float64Or(param string, def float64) float64 { + v, err := r.String(param) + if err != nil { + return def + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def + } + return f +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index e710365bd..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() { }) }) + Describe("Float64Or", func() { + It("returns parsed float value", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5)) + }) + + It("returns default when param is missing", func() { + r := req.Params(httptest.NewRequest("GET", "/test", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + + It("returns default when param is not a valid float", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + }) + Describe("ParamBoolPtr", func() { Context("value is true", func() { BeforeEach(func() { From 556f345a10ed600aefd75810b2412c37f05bf8b2 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 1 May 2026 14:22:51 -0400 Subject: [PATCH 220/596] chore(lint): upgrade golangci-lint, fix/ignore new errors Signed-off-by: Deluan <deluan@navidrome.org> --- Makefile | 2 +- server/subsonic/api_test.go | 2 +- server/subsonic/media_retrieval.go | 2 +- server/subsonic/middlewares.go | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index ad96afd31..e303017c7 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.11.1 +GOLANGCI_LINT_VERSION ?= v2.12.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 3b88704b1..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() { It("updates status pointer when an error occurs", func() { pointer := int32(0) - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet r = r.WithContext(ctx) payload.Status = responses.StatusFailed diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 3faae1650..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -37,7 +37,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses log.Warn(ctx, "User needs an email for gravatar to work", "username", username) return api.getPlaceHolderAvatar(w, r) } - http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) //nolint:gosec // URL is not constructed from user input return nil, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 2d8b1fd94..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -199,7 +199,7 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - cookie := &http.Cookie{ + cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, @@ -239,7 +239,9 @@ func playerIDCookieName(userName string) string { return cookieName } -const subsonicErrorPointer = "subsonicErrorPointer" +type contextKey string + +const subsonicErrorPointer contextKey = "subsonicErrorPointer" func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { From 7e16b6acb5c11e283fcd320a0abb82372a8ab0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 1 May 2026 15:27:32 -0400 Subject: [PATCH 221/596] feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add UIPlaybackReportInterval setting * feat(server): expose playbackReportIntervalMs to UI config * feat(ui): add playbackReportIntervalMs config default * feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer * feat(ui): replace scrobble logic with reportPlayback state machine in Player * refactor(ui): simplify Player heartbeat using useInterval hook - Replace manual setInterval/clearInterval with existing useInterval hook - Extract shared reportPlaybackUrl helper to deduplicate URL construction - Use ref for currentTrackId in beforeunload to stabilize effect deps - Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime * feat(ui): redesign NowPlaying panel with Discord-style layout Show album art with play/pause overlay icon, track title, artist, album name, progress bar with position/duration, and username. * fix(ui): adjust NowPlaying panel height to fit 3 entries * fix(ui): send stopped on player close and tab close while paused - onBeforeDestroy now sends reportPlayback stopped before clearing queue - beforeunload sends stopped beacon regardless of pause state * feat(ui): animate NowPlaying progress bar with 1s client-side tick * fix(ui): account for playbackRate in NowPlaying progress interpolation * fix(ui): use timestamp-based interpolation for smooth NowPlaying progress Replace tick counter with fetchedAt timestamp so the progress bar advances smoothly without resetting on each server poll. * fix(ui): fix NowPlaying progress bar not animating Pass `now` (Date.now()) as a prop that changes every tick, so React.memo'd components actually re-render each second. * fix(ui): prevent progress bar reset on NowPlaying poll Set fetchedAt and now atomically on fetch so the elapsed offset starts at zero and the server's already-estimated positionMs is used as the base without a visible jump. * fix(ui): stamp entries with fetch time to prevent progress bar reset Embed _fetchedAt timestamp directly into each entry object so the position and its reference timestamp are always in the same state update, eliminating the React 17 multi-setState batching race. * fix(server): estimate position for starting state in GetNowPlaying GetNowPlaying was only estimating elapsed position for the "playing" state, returning raw positionMs=0 for "starting". Since the UI player sends "starting" once and then doesn't update until the 60s heartbeat, NowPlaying polls returned 0 for up to a minute, causing the progress bar to reset on every poll. * fix(ui): send playing immediately after starting to enable position estimation The server only estimates elapsed position for "playing" state in GetNowPlaying. The Player was sending "starting" once and then not updating until the 60s heartbeat, leaving the server state as "starting" with positionMs=0 for up to a minute. Now the Player follows up "starting" with an immediate "playing" call, transitioning the server state so position estimation works from the first poll. * fix(subsonic): fix getNowPlaying returning same playerId for all entries PlayerId was never incremented in the map callback, so every entry got playerId=1. This caused the UI to use duplicate React keys, mixing up rendered entries between players. Also use a stable composite key in the UI instead of the sequential playerId. * fix(ui): only send stopped beacon when tab is actually closing Move the reportPlaybackBeacon call from beforeunload to pagehide. beforeunload fires before the confirmation dialog, so cancelling the close would still send stopped. pagehide only fires when the page is actually being unloaded. * fix(ui): revert to beforeunload for stopped beacon pagehide does not fire reliably in Chrome when closing tabs. Use beforeunload instead — if the user cancels the close dialog, the heartbeat will re-register the NowPlaying entry on its next tick. * fix(ui): use synchronous XHR for stopped report on tab close Replace sendBeacon with synchronous XMLHttpRequest in beforeunload. This blocks the page from closing until the server acknowledges the stopped state, ensuring the NowPlaying entry is always removed. * fix(ui): fix confirmation dialog and use fetch keepalive for tab close - Move e.preventDefault() before the stopped report so the dialog always shows regardless of XHR errors - Use fetch with keepalive:true instead of sync XHR (more reliable, non-blocking, survives page teardown) - Fall back to sendBeacon if fetch throws * fix(ui): prevent heartbeat from re-adding entry after stopped on tab close Set a stoppedRef flag in beforeunload so the heartbeat interval skips sending playing reports after stopped has been sent. Without this, the heartbeat could re-register the NowPlaying entry after the stopped event removed it. * fix(ui): include client unique ID header in stopped report on tab close Root cause: reportPlaybackSync (fetch keepalive) did not include the X-ND-Client-Unique-Id header. Regular reportPlayback calls via httpClient include this header, and the server uses it as the playMap key. Without the header, the stopped call fell back to player.ID as the key, which didn't match the entry added with the UUID key. The playMap.Remove targeted the wrong key, so the entry persisted. Fix: export clientUniqueId from httpClient and include it as a header in the fetch keepalive request. * fix(ui): use pagehide for stopped report to avoid premature send beforeunload fires before the confirmation dialog, so the stopped event was sent even when the user cancelled closing. Move the stopped report to pagehide, which only fires when the page is actually being unloaded (after confirmation). * feat(server): broadcast NowPlaying SSE on every state change Previously, the SSE broadcast only fired when the NowPlaying count changed. Now it fires on every reportPlayback call (starting, playing, paused, stopped), so the NowPlaying panel gets instant updates for state transitions and position changes. The UI reducer stores a nowPlayingLastUpdate timestamp alongside the count, ensuring every SSE event triggers a re-fetch even when the count is unchanged (e.g., pause/resume). * fix(ui): clamp NowPlaying position to prevent negative time display * fix(ui): debounce NowPlaying fetches to prevent progress bar trembling During track changes, rapid SSE events (stopped, starting, playing) triggered multiple refetches within milliseconds, each resetting the interpolation base and causing the progress bar to oscillate. Skip fetches within 1 second of the previous fetch. * feat(ui): report playback position on seek Send a reportPlayback(playing) call when the user seeks/scrubs in the player, so the NowPlaying panel and server position stay in sync immediately instead of waiting for the next 60s heartbeat. * refactor: code review cleanup - Export clientUniqueIdHeader from httpClient, use in subsonic layer - Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList - Fix onBeforeDestroy nested dep (read isRadio from ref instead) - Only broadcast SSE on state transitions, not heartbeat position updates - Only enqueue NowPlaying to external scrobblers on state transitions Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): use trailing-edge debounce for NowPlaying fetch Replace the leading-edge throttle (which fetched on the first event and blocked subsequent ones) with a trailing-edge debounce (300ms). During track transitions, the burst of events (stopped → starting → playing) now collapses into a single fetch after the burst settles, showing the new track immediately instead of briefly showing empty. * fix(ui): only show overlay on NowPlaying artwork when paused Signed-off-by: Deluan <deluan@navidrome.org> * refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync * refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive The function was never synchronous — it uses fetch with keepalive:true, which is fire-and-forget. The name now reflects the actual behavior. * style: format code with prettier * test: add tests for reportPlayback SSE broadcast and UI changes - play_tracker: verify SSE broadcast on every state transition and that broadcasts are skipped when EnableNowPlaying is false - activityReducer: verify nowPlayingLastUpdate timestamp is set - subsonic/index: verify reportPlayback URL construction Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): prevent NowPlaying from fetching every second when panel is open fetchList had unstable identity because it depended on doFetch (which depended on notify/dispatch). Each 1s setNow re-render recreated the callback chain, re-triggering the useEffect that calls fetchList. Use a ref for the fetch logic so fetchList has a stable identity with empty deps. * fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel The fetch dispatched nowPlayingCountUpdate on every result, which updated nowPlayingLastUpdate in Redux, which triggered the SSE effect to call fetchList again — creating a fetch loop. Fix: remove dispatch from fetch results. The badge count uses entries.length (from local state) when entries are loaded, falling back to Redux count (from SSE) when they aren't. SSE events remain the only trigger for nowPlayingLastUpdate, breaking the loop. * fix(ui): clear NowPlaying entries on panel close so badge uses SSE count * style: format code with prettier * fix: address code review feedback - Fix currentTime truthiness check to handle position 0 correctly - Report actual player state (playing/paused) on seek instead of always sending 'playing' - Remove idx from React key to avoid reorder issues - Add debounce timer cleanup on unmount - Keep entries on panel close so badge stays accurate from polling - Fix test description to match actual assertion * fix(ui): keep NowPlaying badge count accurate from polling Add a separate nowPlayingCountSync action that updates the Redux count without setting nowPlayingLastUpdate (which would trigger the SSE effect and cause a fetch loop). Polling results now sync the badge count via this action, so the badge stays accurate even when SSE is unavailable. * style: format code with prettier --------- Signed-off-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 2 + consts/consts.go | 11 +- core/scrobbler/play_tracker.go | 3 +- core/scrobbler/play_tracker_test.go | 63 +++++ server/serve_index.go | 1 + server/serve_index_test.go | 1 + server/subsonic/album_lists.go | 3 +- ui/src/actions/serverEvents.js | 6 + ui/src/audioplayer/Player.jsx | 170 ++++++++----- ui/src/config.js | 1 + ui/src/dataProvider/httpClient.js | 4 +- ui/src/dataProvider/index.js | 4 +- ui/src/layout/NowPlayingPanel.jsx | 306 +++++++++++++++++------- ui/src/layout/NowPlayingPanel.test.jsx | 50 ++-- ui/src/reducers/activityReducer.js | 8 + ui/src/reducers/activityReducer.test.js | 17 ++ ui/src/subsonic/index.js | 33 ++- ui/src/subsonic/index.test.js | 30 +++ 18 files changed, 517 insertions(+), 196 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 916efe70b..1c4829d82 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -95,6 +95,7 @@ type configOptions struct { EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool + UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -776,6 +777,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) + viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) diff --git a/consts/consts.go b/consts/consts.go index 3db0b831a..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -67,11 +67,12 @@ const ( ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 + DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 462fc85ba..161d04d7d 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -240,7 +240,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP client := params.ClientName now := time.Now() - prevCount := p.playMap.Len() switch params.State { case StateStarting: @@ -311,7 +310,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.playMap.Remove(clientId) } - if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { + if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 64b3d3ee0..734f8f66f 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -371,6 +371,69 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(HaveLen(2)) }) + Describe("SSE broadcast on state change", func() { + BeforeEach(func() { + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + }) + + It("broadcasts NowPlayingCount on every state change", func() { + // starting -> count should be 1 + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts := eventBroker.getEvents() + Expect(evts).To(HaveLen(1)) + Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) + + // playing -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(2)) + Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) + + // paused -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(3)) + Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) + + // stopped -> count should be 0 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(4)) + Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) + }) + + It("does NOT broadcast when EnableNowPlaying is false", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + Describe("auto-scrobble", func() { It("scrobbles on stopped when positionMs >= 50% of track", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ diff --git a/server/serve_index.go b/server/serve_index.go index 734aabc70..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 31bca02cf..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) It("sanitizes entity-encoded welcomeMessage as html", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 275665b02..2a2338d89 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -213,11 +213,12 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response.NowPlaying = &responses.NowPlaying{} var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), UserName: np.Username, MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerId: i, PlayerName: np.PlayerName, State: np.State, PositionMs: np.PositionMs, diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 5599b9e1d..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useInterval } from '../common' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -41,9 +42,11 @@ const Player = () => { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { useEffect(() => { const handleBeforeUnload = (e) => { - // Check there's a current track and is actually playing/not paused if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() - e.returnValue = '' // Chrome requires returnValue to be set + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -227,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const state = audioInstance?.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackId, posMs, state) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -275,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -309,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -369,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -397,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/config.js b/ui/src/config.js index f4d60dfba..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { useTranslate, Link, useNotify } from 'react-admin' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + }, + avatarContainer: { + position: 'relative', + flexShrink: 0, + width: theme.spacing(8), + height: theme.spacing(8), }, avatar: { - width: theme.spacing(6), - height: theme.spacing(6), + width: '100%', + height: '100%', cursor: 'pointer', + borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, + stateOverlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + borderRadius: theme.spacing(0.5), + pointerEvents: 'none', + }, + stateIcon: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 18, + }, + entryContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }, + trackTitle: { + fontWeight: 600, + fontSize: '0.875rem', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + trackDetail: { + fontSize: '0.75rem', + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + artistLink: { + cursor: 'pointer', + color: theme.palette.text.secondary, + fontSize: '0.75rem', + '&:hover': { + textDecoration: 'underline', + }, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + marginTop: theme.spacing(0.5), + }, + progressTime: { + fontSize: '0.65rem', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + }, + progressBar: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: theme.palette.action.disabledBackground, + '& .MuiLinearProgress-bar': { + borderRadius: 2, + }, + }, + userInfo: { + fontSize: '0.65rem', + color: theme.palette.text.disabled, + marginTop: theme.spacing(0.25), + }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + const isPaused = nowPlayingEntry.state === 'paused' + const isPlaying = + nowPlayingEntry.state === 'playing' || + nowPlayingEntry.state === 'starting' + const basePositionMs = nowPlayingEntry.positionMs || 0 + const rate = nowPlayingEntry.playbackRate || 1 + const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) + const interpolatedMs = isPlaying + ? basePositionMs + elapsedSinceFetch * rate + : basePositionMs + const durationMs = (nowPlayingEntry.duration || 0) * 1000 + const clampedMs = Math.max(0, interpolatedMs) + const positionMs = + durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs + const positionSec = positionMs / 1000 + const durationSec = nowPlayingEntry.duration || 0 + const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 + const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId + const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return ( - <ListItem key={nowPlayingEntry.playerId} className={classes.listItem}> - <ListItemAvatar> + <ListItem className={classes.listItem}> + <div className={classes.avatarContainer}> <Link to={`/album/${nowPlayingEntry.albumId}/show`} onClick={onLinkClick} @@ -134,30 +219,58 @@ const NowPlayingItem = React.memo( loading="lazy" /> </Link> - </ListItemAvatar> - <ListItemText - primary={ - <div className={classes.primaryText}> - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - <Link - to={getArtistLink( - nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId, - )} - className={classes.artistLink} - onClick={onLinkClick} - > - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - </Link> - ) : ( - <span> - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - </span> - )} -  - {nowPlayingEntry.title} + {isPaused && ( + <div className={classes.stateOverlay}> + <FaPause className={classes.stateIcon} /> </div> - } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} + </div> + <div className={classes.entryContent}> + <Typography + className={classes.trackTitle} + title={nowPlayingEntry.title} + > + {nowPlayingEntry.title} + </Typography> + {artistId ? ( + <Link + to={getArtistLink(artistId)} + className={classes.artistLink} + onClick={onLinkClick} + > + {artistName} + </Link> + ) : ( + <Typography className={classes.trackDetail}> + {artistName} + </Typography> + )} + <Typography + className={classes.trackDetail} + title={nowPlayingEntry.album} + > + {nowPlayingEntry.album} + </Typography> + <div className={classes.progressRow}> + <span className={classes.progressTime}> + {formatDuration(positionSec)} + </span> + <LinearProgress + className={classes.progressBar} + variant="determinate" + value={Math.min(progress, 100)} + /> + <span className={classes.progressTime}> + {formatDuration(durationSec)} + </span> + </div> + <Typography className={classes.userInfo}> + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + </Typography> + </div> </ListItem> ) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, album: PropTypes.string, + state: PropTypes.string, + positionMs: PropTypes.number, + duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( - ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( <NowPlayingItem - key={nowPlayingEntry.playerId} + key={`${nowPlayingEntry.username}-${nowPlayingEntry.playerName}`} nowPlayingEntry={nowPlayingEntry} onLinkClick={onLinkClick} getArtistLink={getArtistLink} + now={now} /> ))} </List> @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { entries: PropTypes.arrayOf(PropTypes.object).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) + const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) const streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` }, []) - const fetchList = useCallback( - () => - subsonic - .getNowPlaying() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - const nowPlayingEntries = data.nowPlaying?.entry || [] - setEntries(nowPlayingEntries) - // Also update the count in Redux store - dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) - } else { - throw new Error( - data.error?.message || 'Failed to fetch now playing data', - ) - } + const fetchTimerRef = useRef(null) + const doFetchRef = useRef() + doFetchRef.current = () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + const fetchTime = Date.now() + setEntries( + nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), + ) + dispatch(nowPlayingCountSync({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + const fetchList = useCallback(() => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + fetchTimerRef.current = setTimeout(() => { + fetchTimerRef.current = null + doFetchRef.current() + }, 300) + }, []) + + useEffect(() => { + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) - // Refresh when count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [lastUpdate, open, fetchList, serverUp]) + + // Update current time every second when open to animate progress bars + useInterval(() => setNow(Date.now()), open ? 1000 : null) // Periodic refresh when panel is open (10 seconds) useInterval( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('<NowPlayingPanel />', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('<NowPlayingPanel />', () => { </Provider>, ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('<NowPlayingPanel />', () => { </Provider>, ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('<NowPlayingPanel />', () => { </Provider>, ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('<NowPlayingPanel />', () => { </Provider>, ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('<NowPlayingPanel />', () => { </Provider>, ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('<NowPlayingPanel />', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('<NowPlayingPanel />', () => { </Provider>, ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 8238e395a..d61ed6018 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -3,6 +3,7 @@ import { EVENT_SCAN_STATUS, EVENT_SERVER_START, EVENT_NOW_PLAYING_COUNT, + EVENT_NOW_PLAYING_COUNT_SYNC, EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -17,6 +18,7 @@ const initialState = { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, // Timestamp of last reconnection } @@ -45,6 +47,12 @@ export const activityReducer = (previousState = initialState, payload) => { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index a750694f4..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -194,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +}) From 13c48b38a0737236b79af02b4a7bd42cb6ee1b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 1 May 2026 19:21:48 -0400 Subject: [PATCH 222/596] fix(smartplaylists): coerce string booleans in smart playlist rules (#5450) * fix(criteria): coerce string booleans in smart playlist rules - #4826 When clients (e.g. Feishin) send boolean values as strings ("true"/"false") in smart playlist JSON rules, the SQL comparison fails because SQLite stores booleans as 0/1 integers. For example, `COALESCE(annotation.starred, false) = 'true'` never matches. This adds a `boolean` flag to mapped fields and coerces string values to native Go bools in `mapFields`, so squirrel generates correct SQL parameters. Signed-off-by: mango766 <mango766@users.noreply.github.com> Signed-off-by: easonysliu <easonysliu@tencent.com> * fix(criteria): implement boolean string coercion for smart playlist rules Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: mango766 <mango766@users.noreply.github.com> Signed-off-by: easonysliu <easonysliu@tencent.com> Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: easonysliu <easonysliu@tencent.com> --- model/criteria/fields.go | 13 +- model/criteria/fields_test.go | 1 + model/criteria/json.go | 36 +++++ model/criteria/operators.go | 19 +-- model/criteria/operators_test.go | 75 ++++++++++ persistence/criteria_sql.go | 6 +- persistence/criteria_sql_test.go | 5 + persistence/smart_playlist_repository_test.go | 65 +++++++++ server/e2e/subsonic_playlists_test.go | 130 ++++++++++++++++++ 9 files changed, 325 insertions(+), 25 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b35913f28..9eafff7ab 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -8,6 +8,7 @@ type FieldInfo struct { IsTag bool IsRole bool Numeric bool + Boolean bool tagAlias string // If set, a tag name from mappings.yml that resolves to this field name string // Canonical name, populated by LookupField from the map key @@ -21,7 +22,7 @@ func (f FieldInfo) Name() string { var fieldMap = map[string]FieldInfo{ "title": {}, "album": {}, - "hascoverart": {}, + "hascoverart": {Boolean: true}, "tracknumber": {}, "discnumber": {}, "year": {}, @@ -31,8 +32,8 @@ var fieldMap = map[string]FieldInfo{ "releaseyear": {}, "releasedate": {}, "size": {}, - "compilation": {}, - "missing": {}, + "compilation": {Boolean: true}, + "missing": {Boolean: true}, "explicitstatus": {}, "dateadded": {}, "datemodified": {}, @@ -54,7 +55,7 @@ var fieldMap = map[string]FieldInfo{ "samplerate": {}, "bpm": {}, "channels": {}, - "loved": {}, + "loved": {Boolean: true}, "dateloved": {}, "lastplayed": {}, "daterated": {}, @@ -62,13 +63,13 @@ var fieldMap = map[string]FieldInfo{ "rating": {}, "averagerating": {Numeric: true}, "albumrating": {}, - "albumloved": {}, + "albumloved": {Boolean: true}, "albumplaycount": {}, "albumlastplayed": {}, "albumdateloved": {}, "albumdaterated": {}, "artistrating": {}, - "artistloved": {}, + "artistloved": {Boolean: true}, "artistplaycount": {}, "artistlastplayed": {}, "artistdateloved": {}, diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 270a14473..5b6f53341 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -52,5 +52,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) + }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index 18a664988..ca47ceb95 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -3,6 +3,7 @@ package criteria import ( "encoding/json" "fmt" + "strconv" "strings" ) @@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { if err != nil { return nil } + normalizeBoolFields(m) switch opName { case "is": return Is(m) @@ -70,13 +72,47 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { case "notinplaylist": return NotInPlaylist(m) case "ismissing": + normalizeAllBoolFields(m) return IsMissing(m) case "ispresent": + normalizeAllBoolFields(m) return IsPresent(m) } return nil } +func normalizeAllBoolFields(m map[string]any) { + for k, v := range m { + m[k] = normalizeBoolValue(v) + } +} + +func normalizeBoolFields(m map[string]any) { + for field, value := range m { + info, ok := LookupField(field) + if ok && info.Boolean { + m[field] = normalizeBoolValue(value) + } + } +} + +func normalizeBoolValue(v any) any { + switch val := v.(type) { + case string: + if b, err := strconv.ParseBool(val); err == nil { + return b + } + case float64: + if val == 1 { + return true + } + if val == 0 { + return false + } + } + return v +} + func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression { var items unmarshalConjunctionType err := json.Unmarshal(rawValue, &items) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 7def18934..3ddd77f8b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,9 +1,6 @@ package criteria -import ( - "strconv" - "time" -) +import "time" // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -181,20 +178,6 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func IsTruthy(v any) bool { - switch val := v.(type) { - case bool: - return val - case float64: - return val != 0 - case string: - b, err := strconv.ParseBool(val) - return err == nil && b - default: - return v != nil - } -} - func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index bfdca3e31..17c4272ba 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -31,6 +31,7 @@ var _ = Describe("Operators", func() { }, Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`), Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`), + Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`), Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`), Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`), Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`), @@ -51,4 +52,78 @@ var _ = Describe("Operators", func() { Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) + + Describe("Boolean string coercion at unmarshal time (issue #4826)", func() { + It("coerces string 'true' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces string 'false' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("does not coerce string values for non-boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"})) + }) + + It("coerces numeric 1 to bool true for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces numeric 0 to bool false for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("coerces in nested any/all groups", func() { + var c Criteria + err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + all := c.Expression.(All) + nested := all[1].(Any) + gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces isMissing string 'true' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true})) + }) + + It("coerces isMissing numeric 0 to bool false", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false})) + }) + + It("coerces isPresent string 'false' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false})) + }) + + It("coerces isPresent numeric 1 to bool true", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 9a99cfa8e..a1bae3170 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -220,7 +220,11 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) } - negate := checkAbsence == criteria.IsTruthy(value) + b, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) + } + negate := checkAbsence == b return jsonExpr(info, nil, negate), nil } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 40d21c9bf..ae2695a4d 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -149,6 +149,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) }) + It("returns an error when isMissing has a non-boolean value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() + Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 207fe0c36..7bc705385 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -281,6 +281,71 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(pls.Tracks).To(BeEmpty()) }) + + It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() { + // songComeTogether (ID "1002") is starred in test fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ContainElement("1002")) + Expect(len(pls.Tracks)).To(BeNumerically(">=", 1)) + }) + + It("returns same results for string and bool loved values (issue #4826)", func() { + boolRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": true}, + }, + }, + } + boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules} + Expect(repo.Put(&boolPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(boolPls.ID) }) + + stringRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules} + Expect(repo.Put(&stringPls)).To(Succeed()) + testPlaylistID = stringPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + boolResult, err := repo.GetWithTracks(boolPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + stringResult, err := repo.GetWithTracks(stringPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + boolIDs := make([]string, len(boolResult.Tracks)) + for i, t := range boolResult.Tracks { + boolIDs[i] = t.MediaFileID + } + stringIDs := make([]string, len(stringResult.Tracks)) + for i, t := range stringResult.Tracks { + stringIDs[i] = t.MediaFileID + } + Expect(stringIDs).To(ConsistOf(boolIDs)) + }) }) Describe("Smart Playlists with Tag Criteria", func() { diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 3468979f4..466e68cf0 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -517,4 +517,134 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) }) }) + + Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() { + var songID string + var boolPlaylistID, stringPlaylistID, nestedPlaylistID string + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Star the song via the Subsonic API + resp := doReq("star", "id", songID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Force immediate refresh for all smart playlists + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + // Create smart playlist with boolean true + boolPls := &model.Playlist{ + Name: "Bool Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + boolPlaylistID = boolPls.ID + + // Create smart playlist with string "true" + stringPls := &model.Playlist{ + Name: "String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + stringPlaylistID = stringPls.ID + + // Create smart playlist with string "true" in nested any group (exact issue #4826 scenario) + nestedPls := &model.Playlist{ + Name: "Nested String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }}, + } + Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed()) + nestedPlaylistID = nestedPls.ID + }) + + It("smart playlist with bool loved=true returns starred song", func() { + resp := doReq("getPlaylist", "id", boolPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() { + boolResp := doReq("getPlaylist", "id", boolPlaylistID) + stringResp := doReq("getPlaylist", "id", stringPlaylistID) + + Expect(stringResp.Status).To(Equal(responses.StatusOK)) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + + It("nested any group with string loved='true' returns starred song (issue #4826)", func() { + resp := doReq("getPlaylist", "id", nestedPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("isPresent with string 'true' matches songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Present String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + }) + + It("isMissing with string 'true' excludes songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Missing String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(Equal(int32(0))) + }) + + It("isMissing with string 'true' returns same results as bool true", func() { + boolPls := &model.Playlist{ + Name: "Genre Missing Bool", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + + stringPls := &model.Playlist{ + Name: "Genre Missing String2", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + + boolResp := doReq("getPlaylist", "id", boolPls.ID) + stringResp := doReq("getPlaylist", "id", stringPls.ID) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + }) }) From ae0e0c89d9b41e6467359f02a13755b73781864a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 2 May 2026 16:14:53 -0400 Subject: [PATCH 223/596] feat(plugins): add PlaybackReport to scrobbler capability (#5452) * feat(plugins): add PlaybackReport to Scrobbler interface and all implementations * feat(plugins): add PlaybackReport worker and dispatch in PlayTracker * feat(plugins): add PlaybackReportRequest to plugin scrobbler capability * chore(plugins): regenerate PDK files with PlaybackReport * feat(plugins): add PlaybackReport to test scrobbler plugin * feat(plugins): add PlaybackReport to plugin scrobbler adapter * refactor(plugins): fix double DB fetch in StateStopped and batch getActiveScrobblers - Hoist mf from scrobble branch so PlaybackReport reuses it instead of fetching again from DB - Call getActiveScrobblers once per drain batch instead of per-entry * chore(plugins): include generated scrobbler schema with PlaybackReport * fix(plugins): skip PlaybackReport for plugins that don't export it Plugins detected as scrobblers only need to export one scrobbler function. Older plugins that don't export nd_scrobbler_playback_report would cause noisy error logs on every reportPlayback call. Now errFunctionNotFound and errNotImplemented are treated as no-ops. * refactor: rename NowPlayingInfo to PlaybackReport Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename stopNowPlayingWorker to stopBackgroundWorkers Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move NowPlaying and PlaybackReport logic to separate worker files Signed-off-by: Deluan <deluan@navidrome.org> * refactor(scrobbler): rename NowPlayingInfo to PlaybackSession and add expired state Rename NowPlayingInfo struct to PlaybackSession to better reflect its role as a complete playback session representation. Add UserId field to make sessions self-contained, removing redundant userId parameters from PlaybackReport interface method and internal dispatch functions. Introduce StateExpired internal state that fires when a session cache entry expires without an explicit stop, ensuring plugins always receive a terminal event regardless of client behavior. * fix(scrobbler): update playback state description to include 'expired' Signed-off-by: Deluan <deluan@navidrome.org> * fix(scrobbler): resolve data race in OnExpiration callback Capture conf.Server.EnableNowPlaying at construction time instead of reading it from the background ttlcache eviction goroutine. The previous code raced with test config cleanup that writes to the same field concurrently. * fix(scrobbler): return error when media file lookup fails in StateStopped Simplify the MediaFile population logic in the stopped case to return an error if the track cannot be found. A stop report with an empty MediaFile is useless to plugins, and returning the error allows clients to retry or alert the user when auto-scrobble is enabled. * refactor(scrobbler): use session data directly in PlaybackReport adapter Use info.Username from PlaybackSession instead of extracting it from context in the plugin adapter, since the session is now self-contained. Add debug/trace logging for session expiration and enqueue the expired report with a user-enriched context so downstream handlers can identify the user. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/lastfm/agent.go | 4 + adapters/listenbrainz/agent.go | 4 + core/scrobbler/buffered_scrobbler.go | 8 + core/scrobbler/interfaces.go | 1 + core/scrobbler/nowplaying_worker.go | 78 +++++++ core/scrobbler/play_tracker.go | 159 +++++++-------- core/scrobbler/play_tracker_test.go | 191 +++++++++++++++--- core/scrobbler/playbackreport_worker.go | 64 ++++++ plugins/capabilities/scrobbler.go | 26 ++- plugins/capabilities/scrobbler.yaml | 44 ++++ plugins/pdk/go/scrobbler/scrobbler.go | 53 ++++- plugins/pdk/go/scrobbler/scrobbler_stub.go | 24 ++- .../rust/nd-pdk-capabilities/src/scrobbler.rs | 41 +++- plugins/scrobbler_adapter.go | 29 ++- plugins/scrobbler_adapter_test.go | 56 +++++ plugins/testdata/test-scrobbler/main.go | 14 ++ server/subsonic/album_lists.go | 2 +- server/subsonic/media_annotation_test.go | 2 +- 18 files changed, 680 insertions(+), 120 deletions(-) create mode 100644 core/scrobbler/nowplaying_worker.go create mode 100644 core/scrobbler/playbackreport_worker.go diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index b3e89a9dc..02c198120 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { return err == nil && sk != "" } +func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 019c6e9f4..826a9672e 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin return songs, nil } +func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index be36e1f24..67593e9eb 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob return nil } +func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + s, ok := b.loader() + if !ok { + return errors.New("scrobbler not available") + } + return s.PlaybackReport(ctx, info) +} + func (b *bufferedScrobbler) sendWakeSignal() { // Don't block if the previous signal was not read yet select { diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index f8567e91b..8a18bb37e 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -23,6 +23,7 @@ type Scrobbler interface { IsAuthorized(ctx context.Context, userId string) bool NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error Scrobble(ctx context.Context, userId string, s Scrobble) error + PlaybackReport(ctx context.Context, info PlaybackSession) error } type Constructor func(ds model.DataStore) Scrobbler diff --git a/core/scrobbler/nowplaying_worker.go b/core/scrobbler/nowplaying_worker.go new file mode 100644 index 000000000..1bacec689 --- /dev/null +++ b/core/scrobbler/nowplaying_worker.go @@ -0,0 +1,78 @@ +package scrobbler + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { + p.npMu.Lock() + defer p.npMu.Unlock() + ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing + p.npQueue[playerId] = nowPlayingEntry{ + ctx: ctx, + userId: userId, + track: track, + position: position, + } + p.sendNowPlayingSignal() +} + +func (p *playTracker) sendNowPlayingSignal() { + // Don't block if the previous signal was not read yet + select { + case p.npSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) nowPlayingWorker() { + defer close(p.workerDone) + for { + select { + case <-p.shutdown: + return + case <-time.After(time.Second): + case <-p.npSignal: + } + + p.npMu.Lock() + if len(p.npQueue) == 0 { + p.npMu.Unlock() + continue + } + + // Keep a copy of the entries to process and clear the queue + entries := p.npQueue + p.npQueue = make(map[string]nowPlayingEntry) + p.npMu.Unlock() + + // Process entries without holding lock + for _, entry := range entries { + p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) + } + } +} + +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } + allScrobblers := p.getActiveScrobblers() + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, userId) { + continue + } + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) + err := s.NowPlaying(ctx, userId, t, position) + if err != nil { + log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) + continue + } + } +} diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 161d04d7d..bdb261ef2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -22,6 +22,7 @@ const ( StatePlaying = "playing" StatePaused = "paused" StateStopped = "stopped" + StateExpired = "expired" ) var ValidStates = map[string]bool{ @@ -31,9 +32,10 @@ var ValidStates = map[string]bool{ StateStopped: true, } -type NowPlayingInfo struct { +type PlaybackSession struct { MediaFile model.MediaFile Start time.Time + UserId string Username string PlayerId string PlayerName string @@ -65,8 +67,13 @@ type nowPlayingEntry struct { position int } +type playbackReportEntry struct { + ctx context.Context + info PlaybackSession +} + type PlayTracker interface { - GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) + GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) Submit(ctx context.Context, submissions []Submission) error ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } @@ -81,7 +88,7 @@ type PluginLoader interface { type playTracker struct { ds model.DataStore broker events.Broker - playMap cache.SimpleCache[string, NowPlayingInfo] + playMap cache.SimpleCache[string, PlaybackSession] builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -91,6 +98,10 @@ type playTracker struct { npSignal chan struct{} shutdown chan struct{} workerDone chan struct{} + prQueue []playbackReportEntry + prMu sync.Mutex + prSignal chan struct{} + prWorkerDone chan struct{} } func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { @@ -106,7 +117,7 @@ func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { - m := cache.NewSimpleCache[string, NowPlayingInfo]() + m := cache.NewSimpleCache[string, PlaybackSession]() p := &playTracker{ ds: ds, playMap: m, @@ -118,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug npSignal: make(chan struct{}, 1), shutdown: make(chan struct{}), workerDone: make(chan struct{}), + prSignal: make(chan struct{}, 1), + prWorkerDone: make(chan struct{}), } - if conf.Server.EnableNowPlaying { - m.OnExpiration(func(_ string, _ NowPlayingInfo) { + enableNowPlaying := conf.Server.EnableNowPlaying + m.OnExpiration(func(_ string, info PlaybackSession) { + log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state", + info.State, "username", info.Username, "userId", info.UserId) + if enableNowPlaying { broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) - }) - } + } + ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username}) + if info.State != StateStopped { + log.Trace("Enqueueing PlaybackReport for expired session", "session", info) + info.State = StateExpired + info.LastReport = time.Now() + p.enqueuePlaybackReport(ctx, info) + } + }) var enabled []string for name, constructor := range constructors { @@ -138,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } log.Debug("List of builtin scrobblers enabled", "names", enabled) go p.nowPlayingWorker() + go p.playbackReportWorker() return p } -// stopNowPlayingWorker stops the background worker. This is primarily for testing. -func (p *playTracker) stopNowPlayingWorker() { +// stopBackgroundWorkers stops the background workers. This is primarily for testing. +func (p *playTracker) stopBackgroundWorkers() { close(p.shutdown) - <-p.workerDone // Wait for worker to finish + <-p.workerDone // Wait for nowPlaying worker to finish + <-p.prWorkerDone // Wait for playbackReport worker to finish } // pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers. @@ -247,9 +272,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } - info := NowPlayingInfo{ + info := PlaybackSession{ MediaFile: *mf, Start: now, + UserId: user.ID, Username: user.UserName, PlayerId: clientId, PlayerName: client, @@ -260,8 +286,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) if err != nil { - log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } + p.enqueuePlaybackReport(ctx, info) case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) @@ -270,9 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } - info = NowPlayingInfo{ + info = PlaybackSession{ MediaFile: *mf, Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + UserId: user.ID, Username: user.UserName, PlayerId: clientId, PlayerName: client, @@ -286,17 +314,21 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if params.State == StatePlaying { ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } + log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) err := p.playMap.AddWithTTL(clientId, info, ttl) if err != nil { - log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } + p.enqueuePlaybackReport(ctx, info) case StateStopped: + var loadedMF *model.MediaFile if !params.IgnoreScrobble && player.ScrobbleEnabled { mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } + loadedMF = mf trackDurationMs := int64(mf.Duration * 1000) threshold := min(trackDurationMs*50/100, 240_000) if params.PositionMs >= threshold { @@ -307,6 +339,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.dispatchScrobble(ctx, mf, now) } } + stoppedInfo := PlaybackSession{ + UserId: user.ID, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + if info, getErr := p.playMap.Get(clientId); getErr == nil { + stoppedInfo.MediaFile = info.MediaFile + stoppedInfo.Start = info.Start + } else { + mf := loadedMF + if mf == nil { + var mfErr error + mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if mfErr != nil { + return mfErr + } + } + stoppedInfo.MediaFile = *mf + } + p.enqueuePlaybackReport(ctx, stoppedInfo) p.playMap.Remove(clientId) } @@ -324,77 +381,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { - p.npMu.Lock() - defer p.npMu.Unlock() - ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing - p.npQueue[playerId] = nowPlayingEntry{ - ctx: ctx, - userId: userId, - track: track, - position: position, - } - p.sendNowPlayingSignal() -} - -func (p *playTracker) sendNowPlayingSignal() { - // Don't block if the previous signal was not read yet - select { - case p.npSignal <- struct{}{}: - default: - } -} - -func (p *playTracker) nowPlayingWorker() { - defer close(p.workerDone) - for { - select { - case <-p.shutdown: - return - case <-time.After(time.Second): - case <-p.npSignal: - } - - p.npMu.Lock() - if len(p.npQueue) == 0 { - p.npMu.Unlock() - continue - } - - // Keep a copy of the entries to process and clear the queue - entries := p.npQueue - p.npQueue = make(map[string]nowPlayingEntry) - p.npMu.Unlock() - - // Process entries without holding lock - for _, entry := range entries { - p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) - } - } -} - -func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { - if t.Artist == consts.UnknownArtist { - log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) - return - } - allScrobblers := p.getActiveScrobblers() - for name, s := range allScrobblers { - if !s.IsAuthorized(ctx, userId) { - continue - } - log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) - err := s.NowPlaying(ctx, userId, t, position) - if err != nil { - log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) - continue - } - } -} - -func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { +func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { res := p.playMap.Values() - slices.SortFunc(res, func(a, b NowPlayingInfo) int { + slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) for i := range res { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 734f8f66f..684b887fe 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -48,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore - var tracker PlayTracker + var tracker *playTracker var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album @@ -71,7 +71,7 @@ var _ = Describe("PlayTracker", func() { }) eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests + tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests track = model.MediaFile{ ID: "123", @@ -96,12 +96,12 @@ var _ = Describe("PlayTracker", func() { AfterEach(func() { // Stop the worker goroutine to prevent data races between tests - tracker.(*playTracker).stopNowPlayingWorker() + tracker.stopBackgroundWorkers() }) It("does not register disabled scrobblers", func() { - Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) - Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) + Expect(tracker.builtinScrobblers).To(HaveKey("fake")) + Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) Describe("GetNowPlaying", func() { @@ -138,8 +138,8 @@ var _ = Describe("PlayTracker", func() { Describe("Expiration events", func() { It("sends event when entry expires", func() { - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond) Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) eventList := eventBroker.getEvents() evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) @@ -150,10 +150,48 @@ var _ = Describe("PlayTracker", func() { It("does not send event when disabled", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond) Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) }) + + It("sends expired playback report when session expires", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-3", + PlayerName: "test-player", + State: StatePlaying, + PositionMs: 5000, + } + _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond) + Eventually(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).ShouldNot(BeNil()) + report := fake.LastPlaybackReport.Load() + Expect(report.State).To(Equal(StateExpired)) + Expect(report.MediaFile.ID).To(Equal("123")) + Expect(report.PlayerId).To(Equal("player-3")) + }) + + It("does not send expired report when session was already stopped", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-4", + PlayerName: "test-player", + State: StateStopped, + PositionMs: 180000, + } + _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond) + Consistently(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).Should(BeNil()) + }) }) Describe("Submit", func() { @@ -375,7 +413,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake + tracker.builtinScrobblers["fake"] = fake }) It("broadcasts NowPlayingCount on every state change", func() { @@ -420,7 +458,7 @@ var _ = Describe("PlayTracker", func() { It("does NOT broadcast when EnableNowPlaying is false", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake + tracker.builtinScrobblers["fake"] = fake err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, @@ -697,6 +735,96 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) }) + + Describe("PlaybackReport dispatch", func() { + It("dispatches PlaybackReport for starting state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return fake.PlaybackReportCalled.Load() + }).Should(BeTrue()) + + info := fake.LastPlaybackReport.Load() + Expect(info).ToNot(BeNil()) + Expect(info.MediaFile.ID).To(Equal("123")) + Expect(info.State).To(Equal(StateStarting)) + Expect(info.PositionMs).To(Equal(int64(0))) + Expect(info.PlaybackRate).To(Equal(1.0)) + Expect(info.PlayerId).To(Equal("client-1")) + Expect(info.PlayerName).To(Equal("Test Player")) + }) + + It("dispatches PlaybackReport for playing state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePlaying)) + Expect(info.PositionMs).To(Equal(int64(30000))) + Expect(info.PlaybackRate).To(Equal(1.5)) + }) + + It("dispatches PlaybackReport for paused state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePaused)) + Expect(info.PositionMs).To(Equal(int64(45000))) + }) + + It("dispatches PlaybackReport for stopped state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StateStopped)) + Expect(info.PositionMs).To(Equal(int64(100000))) + }) + }) }) Describe("Plugin scrobbler logic", func() { @@ -712,8 +840,8 @@ var _ = Describe("PlayTracker", func() { tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) // Bypass buffering for both built-in and plugin scrobblers - tracker.(*playTracker).builtinScrobblers["fake"] = fake - tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake + tracker.builtinScrobblers["fake"] = fake + tracker.pluginScrobblers["plugin1"] = pluginFake }) It("registers and uses plugin scrobbler for NowPlaying", func() { @@ -830,7 +958,7 @@ var _ = Describe("PlayTracker", func() { }) AfterEach(func() { - pTracker.stopNowPlayingWorker() + pTracker.stopBackgroundWorkers() }) It("uses the new plugin instance after reload (simulating config update)", func() { @@ -937,15 +1065,17 @@ var _ = DescribeTable("remainingTTL", ) type fakeScrobbler struct { - Authorized bool - nowPlayingCalled atomic.Bool - ScrobbleCalled atomic.Bool - userID atomic.Pointer[string] - username atomic.Pointer[string] - track atomic.Pointer[model.MediaFile] - position atomic.Int32 - LastScrobble atomic.Pointer[Scrobble] - Error error + Authorized bool + nowPlayingCalled atomic.Bool + ScrobbleCalled atomic.Bool + PlaybackReportCalled atomic.Bool + userID atomic.Pointer[string] + username atomic.Pointer[string] + track atomic.Pointer[model.MediaFile] + position atomic.Int32 + LastScrobble atomic.Pointer[Scrobble] + LastPlaybackReport atomic.Pointer[PlaybackSession] + Error error } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -998,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) return nil } +func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + f.PlaybackReportCalled.Store(true) + if f.Error != nil { + return f.Error + } + uid := info.UserId + f.userID.Store(&uid) + f.LastPlaybackReport.Store(&info) + return nil +} + func _p(id, name string, sortName ...string) model.Participant { p := model.Participant{Artist: model.Artist{ID: id, Name: name}} if len(sortName) > 0 { @@ -1053,3 +1194,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { return m.wrapped.Scrobble(ctx, userId, s) } + +func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + return m.wrapped.PlaybackReport(ctx, info) +} diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go new file mode 100644 index 000000000..78ca6e0f7 --- /dev/null +++ b/core/scrobbler/playbackreport_worker.go @@ -0,0 +1,64 @@ +package scrobbler + +import ( + "context" + + "github.com/navidrome/navidrome/log" +) + +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { + p.prMu.Lock() + defer p.prMu.Unlock() + ctx = context.WithoutCancel(ctx) + p.prQueue = append(p.prQueue, playbackReportEntry{ + ctx: ctx, + info: info, + }) + p.sendPlaybackReportSignal() +} + +func (p *playTracker) sendPlaybackReportSignal() { + select { + case p.prSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) playbackReportWorker() { + defer close(p.prWorkerDone) + for { + select { + case <-p.shutdown: + return + case <-p.prSignal: + } + + p.prMu.Lock() + if len(p.prQueue) == 0 { + p.prMu.Unlock() + continue + } + entries := p.prQueue + p.prQueue = nil + p.prMu.Unlock() + + allScrobblers := p.getActiveScrobblers() + for _, entry := range entries { + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) + } + } +} + +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, info.UserId) { + continue + } + log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs) + err := s.PlaybackReport(ctx, info) + if err != nil { + log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err) + continue + } + } +} diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index ed8a4fb6c..4918d5e8f 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -5,7 +5,7 @@ package capabilities // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. // //nd:capability name=scrobbler required=true type Scrobbler interface { @@ -20,6 +20,10 @@ type Scrobbler interface { // Scrobble submits a completed scrobble to the scrobbling service. //nd:export name=nd_scrobbler_scrobble Scrobble(ScrobbleRequest) error + + // PlaybackReport sends a playback state report to the scrobbling service. + //nd:export name=nd_scrobbler_playback_report + PlaybackReport(PlaybackReportRequest) error } // IsAuthorizedRequest is the request for authorization check. @@ -96,6 +100,26 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 8ada5f7e4..9d5cfed30 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -18,6 +18,11 @@ exports: input: $ref: '#/components/schemas/ScrobbleRequest' contentType: application/json + nd_scrobbler_playback_report: + description: PlaybackReport sends a playback state report to the scrobbling service. + input: + $ref: '#/components/schemas/PlaybackReportRequest' + contentType: application/json components: schemas: ArtistRef: @@ -59,6 +64,45 @@ components: - username - track - position + PlaybackReportRequest: + description: PlaybackReportRequest is the request for playback report notifications. + properties: + username: + type: string + description: Username is the username of the user. + track: + $ref: '#/components/schemas/TrackInfo' + description: Track is the track being played. + state: + type: string + description: State is the current playback state (starting/playing/paused/stopped/expired). + positionMs: + type: integer + format: int64 + description: PositionMs is the current playback position in milliseconds. + playbackRate: + type: number + format: float + description: PlaybackRate is the playback speed (1.0 = normal). + playerId: + type: string + description: PlayerId is the unique client identifier. + playerName: + type: string + description: PlayerName is the human-readable player name. + timestamp: + type: integer + format: int64 + description: Timestamp is the Unix timestamp when this report was generated. + required: + - username + - track + - state + - positionMs + - playbackRate + - playerId + - playerName + - timestamp ScrobbleRequest: description: ScrobbleRequest is the request for submitting a scrobble. properties: diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index d27ae3a9c..0d045e597 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -52,6 +52,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -106,7 +126,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -114,11 +134,14 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // Internal implementation holders var ( - isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) - nowPlayingImpl func(NowPlayingRequest) error - scrobbleImpl func(ScrobbleRequest) error + isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) + nowPlayingImpl func(NowPlayingRequest) error + scrobbleImpl func(ScrobbleRequest) error + playbackReportImpl func(PlaybackReportRequest) error ) // Register registers a scrobbler implementation. @@ -127,6 +150,7 @@ func Register(impl Scrobbler) { isAuthorizedImpl = impl.IsAuthorized nowPlayingImpl = impl.NowPlaying scrobbleImpl = impl.Scrobble + playbackReportImpl = impl.PlaybackReport } // NotImplementedCode is the standard return code for unimplemented functions. @@ -201,3 +225,24 @@ func _NdScrobblerScrobble() int32 { return 0 } + +//go:wasmexport nd_scrobbler_playback_report +func _NdScrobblerPlaybackReport() int32 { + if playbackReportImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input PlaybackReportRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + if err := playbackReportImpl(input); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 9e6f706ac..b35e7c40e 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -49,6 +49,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -103,7 +123,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -111,6 +131,8 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // NotImplementedCode is the standard return code for unimplemented functions. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 348460374..1e9c51375 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -62,6 +62,35 @@ pub struct NowPlayingRequest { #[serde(default)] pub position: i32, } +/// PlaybackReportRequest is the request for playback report notifications. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackReportRequest { + /// Username is the username of the user. + #[serde(default)] + pub username: String, + /// Track is the track being played. + #[serde(default)] + pub track: TrackInfo, + /// State is the current playback state (starting/playing/paused/stopped/expired). + #[serde(default)] + pub state: String, + /// PositionMs is the current playback position in milliseconds. + #[serde(default)] + pub position_ms: i64, + /// PlaybackRate is the playback speed (1.0 = normal). + #[serde(default)] + pub playback_rate: f64, + /// PlayerId is the unique client identifier. + #[serde(default)] + pub player_id: String, + /// PlayerName is the human-readable player name. + #[serde(default)] + pub player_name: String, + /// Timestamp is the Unix timestamp when this report was generated. + #[serde(default)] + pub timestamp: i64, +} /// ScrobbleRequest is the request for submitting a scrobble. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -158,7 +187,7 @@ impl Error { /// ListenBrainz, or custom scrobbling backends. /// /// All methods are required - plugins implementing this capability must provide -/// all three functions: IsAuthorized, NowPlaying, and Scrobble. +/// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. pub trait Scrobbler { /// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error>; @@ -166,6 +195,8 @@ pub trait Scrobbler { fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>; /// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>; + /// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error>; } /// Register all exports for the Scrobbler capability. @@ -197,5 +228,13 @@ macro_rules! register_scrobbler { $crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?; Ok(()) } + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_playback_report( + req: extism_pdk::Json<$crate::scrobbler::PlaybackReportRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scrobbler::Scrobbler::playback_report(&plugin, req.into_inner())?; + Ok(()) + } }; } diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 302f5e1da..8abdccf07 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "errors" "strings" "github.com/navidrome/navidrome/core/scrobbler" @@ -16,9 +17,10 @@ const CapabilityScrobbler Capability = "Scrobbler" // Scrobbler function names (snake_case as per design) const ( - FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" - FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" - FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" + FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" + FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report" ) func init() { @@ -27,6 +29,7 @@ func init() { FuncScrobblerIsAuthorized, FuncScrobblerNowPlaying, FuncScrobblerScrobble, + FuncScrobblerPlaybackReport, ) } @@ -182,5 +185,25 @@ func mapScrobblerError(err error) error { } } +// PlaybackReport sends a playback state report to the scrobbler +func (s *ScrobblerPlugin) PlaybackReport(ctx context.Context, info scrobbler.PlaybackSession) error { + input := capabilities.PlaybackReportRequest{ + Username: info.Username, + Track: mediaFileToTrackInfo(s.plugin, &info.MediaFile), + State: info.State, + PositionMs: info.PositionMs, + PlaybackRate: info.PlaybackRate, + PlayerId: info.PlayerId, + PlayerName: info.PlayerName, + Timestamp: info.LastReport.Unix(), + } + + err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerPlaybackReport, input) + if errors.Is(err, errFunctionNotFound) || errors.Is(err, errNotImplemented) { + return nil + } + return mapScrobblerError(err) +} + // Verify interface implementation at compile time var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index c56d8a900..56a452742 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -229,6 +229,62 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }) }) + Describe("PlaybackReport", func() { + It("successfully calls the plugin", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Album: "Test Album", + Artist: "Test Artist", + AlbumArtist: "Test Album Artist", + Duration: 180, + TrackNumber: 1, + DiscNumber: 1, + Participants: model.Participants{ + model.RoleArtist: {{Artist: model.Artist{ID: "artist-1", Name: "Test Artist"}}}, + model.RoleAlbumArtist: {{Artist: model.Artist{ID: "album-artist-1", Name: "Test Album Artist"}}}, + }, + }, + Username: "testuser", + PlayerId: "player-1", + PlayerName: "Test Player", + State: "playing", + PositionMs: 30000, + PlaybackRate: 1.0, + LastReport: time.Now(), + } + + err := s.PlaybackReport(ctxWithUser(), info) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("when plugin returns error", Ordered, func() { + var retryScrobbler scrobbler.Scrobbler + + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, + }, "test-scrobbler"+PackageExtension) + + var ok bool + retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrRetryLater", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, + State: "playing", + LastReport: time.Now(), + } + err := retryScrobbler.PlaybackReport(ctxWithUser(), info) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + }) + }) + Describe("PluginNames", func() { It("returns plugin names with Scrobbler capability", func() { names := scrobblerManager.PluginNames("Scrobbler") diff --git a/plugins/testdata/test-scrobbler/main.go b/plugins/testdata/test-scrobbler/main.go index d9c142d51..a8cee4a4e 100644 --- a/plugins/testdata/test-scrobbler/main.go +++ b/plugins/testdata/test-scrobbler/main.go @@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error { return nil } +// PlaybackReport receives a playback state report. +func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error { + if err := checkConfigError(); err != nil { + return err + } + + artistName := "" + if len(input.Track.Artists) > 0 { + artistName = input.Track.Artists[0].Name + } + pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State) + return nil +} + // checkConfigError checks if the plugin is configured to return an error. // If "error" config is set, it returns the appropriate ScrobblerError. // Error types: "not_authorized", "retry_later", "unrecoverable" diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 2a2338d89..0d82c8be9 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -212,7 +212,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.NowPlaying = &responses.NowPlaying{} var i int32 - response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry { i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index f5cf434e0..487335d1a 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -193,7 +193,7 @@ type fakePlayTracker struct { Error error } -func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { +func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) { return nil, f.Error } From a00152397e0807ec906768d79f3e619adf43b3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 2 May 2026 19:48:44 -0400 Subject: [PATCH 224/596] fix(artwork): prefer album-root images over disc-subfolder images for multi-disc albums (#5451) Fixed two bugs in album cover art resolution for multi-disc layouts: 1. compareImageFiles now sorts by path depth (shallower first) when basenames tie, so album-root images like Artist/Album/cover.jpg are preferred over disc-subfolder images like Artist/Album/CD1/cover.jpg. 2. commonParentFolder now includes the parent folder for single-disc-subfolder albums, with a Path != "." guard to avoid pulling artist-folder images. Closes #5376 --- core/artwork/e2e/album_test.go | 12 +++--- core/artwork/reader_album.go | 48 +++++++++++++--------- core/artwork/reader_album_test.go | 68 +++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 30 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 3d5523afd..370844e34 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -39,7 +39,7 @@ var _ = Describe("Album artwork resolution", func() { // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder - // files first. Flip from PIt to It once it prefers shorter/parent paths. + // files first. When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { // Artist/ // └── Album/ @@ -50,7 +50,7 @@ var _ = Describe("Album artwork resolution", func() { // │ ├── 01 - Track.mp3 // │ └── cover.jpg // └── cover.jpg ← should win (album-root fallback) - PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() { + It("prefers the album-root cover over per-disc covers", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), @@ -71,7 +71,6 @@ var _ = Describe("Album artwork resolution", func() { // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; // the lexicographic full-path tiebreaker in compareImageFiles ranks // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". - // Flip from PIt to It once compareImageFiles prefers shorter/parent paths. When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { // Artist/ // └── Album/ @@ -82,7 +81,7 @@ var _ = Describe("Album artwork resolution", func() { // │ ├── 01 - Track.mp3 // │ └── folder.jpg // └── folder.jpg ← should win (album-root fallback) - PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() { + It("prefers the album-root folder.jpg over per-disc folder.jpg", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), @@ -100,15 +99,14 @@ var _ = Describe("Album artwork resolution", func() { // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder // lookup whenever an album lives entirely under a single subfolder, so an - // album-root cover is never considered. Flip from PIt to It once the guard - // accepts single-folder albums whose parent isn't already in the folder set. + // album-root cover is never considered. When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { // Artist/ // └── Album/ // ├── disc1/ // │ └── 01 - Track.mp3 // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) - PIt("uses the parent-folder cover (currently ignored — bug)", func() { + It("uses the parent-folder cover for single-disc-subfolder albums", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 8d7e14fd0..cf5497641 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -118,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo folderIDSet[id] = true } - // For multi-disc albums (2+ folders), check if all folders share a common parent - // that is not already included. This finds cover art in the album root folder - // (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/"). - // We skip single-folder albums to avoid pulling images from the artist folder. + // Check if all folders share a common parent that is not already included. + // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" + // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). + // For single-folder albums, the parent is only included when the folder has no + // images of its own (indicating a disc subfolder needing parent artwork). if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { - parentFolder, err := ds.Folder(ctx).Get(commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) - } else if err != nil { - return nil, nil, nil, err - } - if parentFolder != nil { - folders = append(folders, *parentFolder) + if len(folders) >= 2 || !anyFolderHasImages(folders) { + parentFolder, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + } else if err != nil { + return nil, nil, nil, err + } + if parentFolder != nil && parentFolder.Path != "." { + folders = append(folders, *parentFolder) + } } } @@ -156,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +func anyFolderHasImages(folders []model.Folder) bool { + for _, f := range folders { + if len(f.ImageFiles) > 0 { + return true + } + } + return false +} + // commonParentFolder returns the shared parent folder ID when all folders have the // same parent and that parent is not already in folderIDSet. Returns "" otherwise. func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { - if len(folders) < 2 { + if len(folders) == 0 { return "" } parentID := folders[0].ParentID @@ -174,11 +186,8 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str return parentID } -// compareImageFiles compares two image file paths for sorting. -// It extracts the base filename (without extension) and compares case-insensitively. -// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". -// Note: This function is called O(n log n) times during sorting, but in practice albums -// typically have only 1-20 image files, making the repeated string operations negligible. +// compareImageFiles sorts image paths by: base filename (natural order), +// then path depth (shallower first), then full path (stable tiebreaker). func compareImageFiles(a, b string) int { // Case-insensitive comparison a = strings.ToLower(a) @@ -188,9 +197,10 @@ func compareImageFiles(a, b string) int { baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) - // Compare base names first, then full paths if equal + // Compare base names first, then prefer shallower paths, then full path as tiebreaker return cmp.Or( natural.Compare(baseA, baseB), + cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")), natural.Compare(a, b), ) } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index 03412b6d9..b8f4f2dfa 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -213,9 +213,42 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(0)) }) - It("does not query parent for single-folder albums", func() { - // A single-folder album's parent is typically the artist folder, - // which should not be searched for cover art + It("does not include top-level parent for multi-folder albums", func() { + // Two album parts under the same artist folder — parent is artist-level + repo.result = []model.Folder{ + { + ID: "folder1", + Path: ".", + Name: "AlbumPart1", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: ".", + Name: "AlbumPart2", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ImageFiles: []string{"artist.jpg"}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { ID: "folder1", @@ -232,10 +265,37 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - // Get should not have been called (single folder, no parent lookup) Expect(repo.getCallCount).To(Equal(0)) }) + It("includes parent images for single-disc-subfolder albums", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: "Artist", + Name: "Album", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("propagates non-ErrNotFound errors from parent folder lookup", func() { repo.result = []model.Folder{ { From 52099ce91f3936885694749ad7a39ddf33e26925 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 3 May 2026 11:26:07 -0400 Subject: [PATCH 225/596] test: fix flaky watcher and scheduler tests on Windows CI Replace timing-sensitive time.Sleep synchronization with proper Eventually/Consistently assertions in watcher tests, and increase Eventually timeouts from 200ms to 500ms. Add FlakeAttempts(3) to the inherently timing-dependent tests. For the scheduler test, increase the Eventually timeout from 1s to 5s for the cron job execution check. --- scanner/watcher_test.go | 85 ++++++++++++++++--------------------- scheduler/scheduler_test.go | 5 ++- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index a4016d470..9795129b0 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -74,23 +74,22 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("creates separate targets for different folders", func() { + It("creates separate targets for different folders", FlakeAttempts(3), func() { // Send notifications for different folders w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} - // Wait for watcher to process and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) - // Verify two targets + // Verify targets calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) - - // Extract folder paths folderPaths := make(map[string]bool) for _, target := range calls[0].Targets { Expect(target.LibraryID).To(Equal(1)) @@ -107,7 +106,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -117,20 +116,15 @@ var _ = Describe("Watcher", func() { }) It("deduplicates folder and file within same folder", func() { - // Send notification for a folder + // Send multiple notifications for the same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send notification for same folder (as if file change was detected there) - // In practice, watchLibrary() would walk up from file path to folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send another for same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -151,32 +145,27 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("resets timer on each change (debouncing)", func() { + It("resets timer on each change (debouncing)", FlakeAttempts(3), func() { // Send first notification w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time to ensure timer doesn't fire - time.Sleep(20 * time.Millisecond) - - // No scan should have been triggered yet - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + // Verify no scan fires during a window shorter than the debounce wait + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) // Send another notification (resets timer) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time again - time.Sleep(20 * time.Millisecond) + // Again, no scan should fire within a short window + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) - // Still no scan - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) - - // Wait for full timer to expire after last notification (plus margin) - time.Sleep(60 * time.Millisecond) - - // Now scan should have been triggered + // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -189,7 +178,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -211,7 +200,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -223,13 +212,12 @@ var _ = Describe("Watcher", func() { It("deduplicates empty and dot paths", func() { // Send notifications with empty and dot paths w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -264,20 +252,19 @@ var _ = Describe("Watcher", func() { It("creates separate targets for different libraries", func() { // Send notifications for both libraries w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} - // Wait for scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) - - // Verify two targets for different libraries - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different + calls := mockScanner.GetScanFoldersCalls() libraryIDs := make(map[int]bool) for _, target := range calls[0].Targets { libraryIDs[target.LibraryID] = true diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go index 1a134a7f3..8ae69c19b 100644 --- a/scheduler/scheduler_test.go +++ b/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler import ( "testing" + "time" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" @@ -28,7 +29,7 @@ var _ = Describe("Scheduler", func() { s.c.Stop() // Stop the scheduler after tests }) - It("adds and executes a job", func() { + It("adds and executes a job", FlakeAttempts(3), func() { done := make(chan struct{}) id, err := s.Add("@every 50ms", func() { @@ -38,7 +39,7 @@ var _ = Describe("Scheduler", func() { Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeZero()) - Eventually(done).Should(BeClosed()) + Eventually(done, 5*time.Second).Should(BeClosed()) }) It("adds a job with random ~ syntax", func() { From dd2b6865b00d669f0c6193501ccfc9934f19ccba Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 3 May 2026 12:34:00 -0400 Subject: [PATCH 226/596] chore(deps): update Go dependencies in go.mod and go.sum Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 18 +++++++++--------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index a4c0c014b..36218ba6b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.26.0 +go 1.26 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a @@ -35,12 +35,12 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.0 - github.com/mattn/go-sqlite3 v1.14.42 + github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.2 - github.com/onsi/gomega v1.39.1 - github.com/pelletier/go-toml/v2 v2.3.0 + github.com/onsi/ginkgo/v2 v2.28.3 + github.com/onsi/gomega v1.40.0 + github.com/pelletier/go-toml/v2 v2.3.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 @@ -70,7 +70,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -81,7 +81,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -93,7 +93,7 @@ require ( github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -138,7 +138,7 @@ require ( golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 3e665ba02..90e0ec040 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= +github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -175,8 +175,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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= @@ -193,12 +193,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= -github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= +github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -409,8 +409,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From a34a4abbc1a79c90a7e4f535c646cb75daf12913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 4 May 2026 13:59:05 -0400 Subject: [PATCH 227/596] chore(ci): update GitHub Actions to latest major versions (#5462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): update GitHub Actions to latest major versions Update actions/cache v4→v5, actions/github-script v3→v7, actions/stale v9→v10, docker/login-action v3→v4, docker/setup-buildx-action v3→v4, and docker/metadata-action v5→v6. The github-script upgrade also migrates Octokit API calls from github.* to github.rest.* namespace (required since v5). * fix(ci): address review feedback on GitHub Actions update Pass github_token to docker/metadata-action@v6 to avoid API rate limiting. Fix github-script pagination to use the correct Octokit paginate.iterator pattern (pass endpoint method, not awaited response). --- .github/actions/prepare-docker/action.yml | 9 +++++---- .github/workflows/download-link-on-pr.yml | 13 ++++++------- .github/workflows/pipeline.yml | 2 +- .github/workflows/stale.yml | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 760a0528b..7fe0b9148 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -53,13 +53,13 @@ runs: - name: Login to Docker Hub if: inputs.hub_username != '' && inputs.hub_password != '' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ inputs.hub_username }} password: ${{ inputs.hub_password }} - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,12 +67,13 @@ runs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Extract metadata for Docker image id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: + token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 38b7b8a86..076f963d4 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v3 + - uses: actions/github-script@v7 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml @@ -19,8 +19,7 @@ jobs: const pull_user_id = ${{github.event.sender.id}}; const issue_number = await (async () => { - const pulls = await github.pulls.list({owner, repo}); - for await (const {data} of github.paginate.iterator(pulls)) { + for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) { for (const pull of data) { if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) { return pull.number; @@ -34,7 +33,7 @@ jobs: return core.error(`No matching pull request found`); } - const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id}); + const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); if (!artifacts.length) { return core.error(`No artifacts found`); } @@ -43,12 +42,12 @@ jobs: body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } - const {data: comments} = await github.issues.listComments({repo, owner, issue_number}); + const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); - await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); + await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); } else { core.info(`Creating a comment`); - await github.issues.createComment({repo, owner, issue_number, body}); + await github.rest.issues.createComment({repo, owner, issue_number, body}); } diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 09fca2572..6f858a5a7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -145,7 +145,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 69e6ac99e..33c8fadbd 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -28,7 +28,7 @@ jobs: This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: operations-per-run: 999 days-before-issue-stale: 180 From f48416685f8b223ce4ff437629433e7b53915b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 4 May 2026 17:26:39 -0400 Subject: [PATCH 228/596] fix(artwork): fix stale cache and top-level album artwork for multi-disc albums (#5457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(artwork): include top-level album folders in parent cover art lookup The Path != "." guard added in #5451 was too aggressive — it excluded any folder with Path=".", which includes top-level album folders (not just the library root). Changed to ParentID != "" which correctly excludes only the actual library root folder. Fixes #5456 * fix: correct comment in test — album is under library root, not artist root * test: add ascii tree diagram to top-level album e2e test * test: replace internal bug references with issue link in e2e comments Signed-off-by: Deluan <deluan@navidrome.org> * test: add e2e test matching reporter's exact library layout (#5456) Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs using the reporter's actual folder names to verify artwork resolution works for non-top-level album folders too. * fix(scanner): use a syntectic admin user when no admin user is found Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache When Phase 3 corrects an album's FolderIDs (or any other field), bump UpdatedAt to the current time. This ensures the artwork cache key changes, invalidating any stale artwork that was resolved and cached during Phase 1 when the album had incomplete folder data. * fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt semantics) and instead includes album.ImportedAt in the artwork cache key computation. Since ImportedAt is bumped to time.Now() on every album Put, any Phase 3 correction naturally invalidates cached artwork that was resolved mid-scan with incomplete folder data. * fix(artwork): simplify lastUpdate logic using TimeNewest utility Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/artwork/e2e/album_test.go | 49 ++++++++++++---- core/artwork/e2e/disc_test.go | 95 +++++++++++++++++++++++++++++++ core/artwork/reader_album.go | 10 ++-- core/artwork/reader_album_test.go | 59 ++++++++++++++++--- core/artwork/reader_disc.go | 8 +-- core/auth/auth.go | 2 +- 6 files changed, 193 insertions(+), 30 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 370844e34..e765e1b1b 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -37,15 +37,15 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; - // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder - // files first. + // https://github.com/navidrome/navidrome/issues/5376 + // cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { // Artist/ // └── Album/ // ├── CD1/ // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← currently wins (bug) + // │ └── cover.jpg ← should not win // ├── CD2/ // │ ├── 01 - Track.mp3 // │ └── cover.jpg @@ -68,15 +68,15 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; - // the lexicographic full-path tiebreaker in compareImageFiles ranks - // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". + // https://github.com/navidrome/navidrome/issues/5376 + // folder.jpg basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { // Artist/ // └── Album/ // ├── CD1/ // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← currently wins (bug) + // │ └── folder.jpg ← should not win // ├── CD2/ // │ ├── 01 - Track.mp3 // │ └── folder.jpg @@ -97,15 +97,14 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder - // lookup whenever an album lives entirely under a single subfolder, so an - // album-root cover is never considered. + // https://github.com/navidrome/navidrome/issues/5376 + // Single-subfolder albums must still consider the parent folder's images. When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { // Artist/ // └── Album/ // ├── disc1/ // │ └── 01 - Track.mp3 - // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) + // └── cover.jpg ← should win (parent-folder fallback) It("uses the parent-folder cover for single-disc-subfolder albums", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ @@ -119,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() { }) }) + // https://github.com/navidrome/navidrome/issues/5456 + When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() { + // Album/ (top-level folder, Path=".") + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── cover.jpg ← should win (album-root) + It("prefers the album-root cover.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Album/cover.jpg": imageFile("album-root"), + "Album/CD1/folder.jpg": imageFile("disc1"), + "Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { // Artist/ // └── Album/ diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go index 7569cbc32..667079458 100644 --- a/core/artwork/e2e/disc_test.go +++ b/core/artwork/e2e/disc_test.go @@ -1,6 +1,7 @@ package artworke2e_test import ( + "fmt" "testing/fstest" "github.com/navidrome/navidrome/conf" @@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() { }) }) + // Reproduces https://github.com/navidrome/navidrome/issues/5456 + // Deeply nested layout matching the reporter's actual structure. + When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Genre/Artist/Album/ ← album root with cover.jpg + // ├── cover.jpg ← album-level cover + // ├── Disc 01 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── ... (12 discs) + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + discNames := []string{ + "Disc 01 (Birth of the Dead - The Studio Sides)", + "Disc 02 (Birth of the Dead - The Live Sides)", + "Disc 03 (The Grateful Dead)", + "Disc 04 (Anthem of the Sun)", + "Disc 05 (Aoxomoxoa)", + "Disc 06 (Live; Dead)", + "Disc 07 (Workingman's Dead)", + "Disc 08 (American Beauty)", + "Disc 09 (Grateful Dead)", + "Disc 10 (Europe '72)", + "Disc 11 (Europe '72)", + "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", + } + layout := fstest.MapFS{ + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), + } + for i, name := range discNames { + discNum := i + 1 + prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := range discNames { + discNum := i + 1 + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), + "disc %d should use its own folder.jpg", discNum) + } + }) + }) + + // https://github.com/navidrome/navidrome/issues/5456 + // Top-level album variant — album folder at library root (Path="."). + When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Album/ (top-level, Path=".") + // ├── cover.jpg ← album-level cover + // ├── Disc 01/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── Disc 03/ + // ├── 01 - Track.mp3 + // └── folder.jpg + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + layout := fstest.MapFS{ + "Album/cover.jpg": imageFile("album-root-cover"), + } + for i := 1; i <= 3; i++ { + prefix := fmt.Sprintf("Album/Disc %02d/", i) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := 1; i <= 3; i++ { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), + "disc %d should use its own folder.jpg", i) + } + }) + }) + When("discsubtitle is set but no image filename matches the subtitle", func() { // Artist/ // └── Album/ diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index cf5497641..73ba9b5ee 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" ) @@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar lib: lib, } a.cacheKey.artID = artID - if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { - a.cacheKey.lastUpdate = *a.updatedAt - } else { - a.cacheKey.lastUpdate = al.UpdatedAt + a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdateAt != nil { + a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) } return a, nil } @@ -131,7 +131,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo } else if err != nil { return nil, nil, nil, err } - if parentFolder != nil && parentFolder.Path != "." { + if parentFolder != nil && parentFolder.ParentID != "" { folders = append(folders, *parentFolder) } } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index b8f4f2dfa..1cf039bee 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "parentFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg", "back.jpg"}, } @@ -213,14 +214,14 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(0)) }) - It("does not include top-level parent for multi-folder albums", func() { - // Two album parts under the same artist folder — parent is artist-level + It("does not include library root parent for multi-folder albums", func() { + // Two album parts directly under the library root — parent is the root itself repo.result = []model.Folder{ { ID: "folder1", Path: ".", Name: "AlbumPart1", - ParentID: "artistFolder", + ParentID: "rootFolder", ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}, }, @@ -228,16 +229,17 @@ var _ = Describe("Album Artwork Reader", func() { ID: "folder2", Path: ".", Name: "AlbumPart2", - ParentID: "artistFolder", + ParentID: "rootFolder", ImagesUpdatedAt: now, ImageFiles: []string{}, }, } repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ImageFiles: []string{"artist.jpg"}, + ID: "rootFolder", + Path: "", + Name: ".", + ParentID: "", + ImageFiles: []string{"unrelated.jpg"}, } _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) @@ -248,6 +250,46 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("includes top-level album folder for multi-disc albums", func() { + // Album folder directly under library root, with disc subfolders + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Album", + Name: "Disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + { + ID: "folder2", + Path: "Album", + Name: "Disc2", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: ".", + Name: "Album", + ParentID: "rootFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(3)) + Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) + Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { @@ -283,6 +325,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg"}, } diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index de0a765f0..0f648c987 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" ) type discArtworkReader struct { @@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID - if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) { - r.cacheKey.lastUpdate = *r.updatedAt - } else { - r.cacheKey.lastUpdate = al.UpdatedAt + r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdatedAt != nil { + r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) } return r, nil } diff --git a/core/auth/auth.go b/core/auth/auth.go index a75111b35..7b3511bdf 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { } else { log.Error(ctx, "No admin user found!", err) } - u = &model.User{} + u = &model.User{IsAdmin: true, UserName: "admin"} } ctx = request.WithUsername(ctx, u.UserName) From 5b85b2839a432bbe521d3c56d6b4f10657af623e Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 4 May 2026 18:04:11 -0400 Subject: [PATCH 229/596] fix(scanner): fix error when importing playlists without an admin user Signed-off-by: Deluan <deluan@navidrome.org> --- scanner/phase_4_playlists.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index f726343f2..d52743966 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin { + if !u.IsAdmin || u.ID == "" { log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ "Please create an admin user first, and then update the playlists for them to be imported") return nil From 39f8eec8d27b2eee3da289f4736267984f6d339a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 5 May 2026 18:49:24 -0400 Subject: [PATCH 230/596] fix(ui): add Rescan button to plugin list empty state (#5471) * feat(ui): add Rescan button to plugin list empty state When no plugins are installed and the folder watcher fails to detect new plugins, users had no way to trigger a rescan. Extract RescanButton into a shared component and render it in a custom empty state for the plugin list. * refactor(ui): address review feedback for plugin empty state - Pass label translation key directly to RA Button (auto-translates) - Use within() from Testing Library instead of querySelector for scoped queries with better error messages --- ui/src/plugin/PluginList.jsx | 46 ++++++++++++++++++++++-------- ui/src/plugin/PluginList.test.jsx | 47 ++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 67af85b81..346e1ceae 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react' import { Button, Datagrid, + Empty, TextField, TopToolbar, useNotify, @@ -10,7 +11,13 @@ import { useTranslate, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' -import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' +import { + useMediaQuery, + Tooltip, + Chip, + Typography, + Box, +} from '@material-ui/core' import { MdError, MdRefresh } from 'react-icons/md' import { List, DateField, SimpleList, useResourceRefresh } from '../common' import { httpClient } from '../dataProvider' @@ -72,8 +79,7 @@ const ManifestField = ({ source }) => { return <Typography variant="body2">{manifest[source] || '-'}</Typography> } -const PluginListActions = () => { - const translate = useTranslate() +const RescanButton = () => { const notify = useNotify() const refresh = useRefresh() const [loading, setLoading] = useState(false) @@ -92,20 +98,37 @@ const PluginListActions = () => { }) }, [notify, refresh]) + return ( + <Button + onClick={handleRescan} + disabled={loading} + label="resources.plugin.actions.rescan" + data-testid="rescan-button" + > + <MdRefresh /> + </Button> + ) +} + +const PluginListActions = () => { return ( <TopToolbar> - <Button - onClick={handleRescan} - disabled={loading} - label={translate('resources.plugin.actions.rescan')} - data-testid="rescan-button" - > - <MdRefresh /> - </Button> + <RescanButton /> </TopToolbar> ) } +const PluginEmpty = () => { + return ( + <> + <Empty /> + <Box textAlign="center" mt={2}> + <RescanButton /> + </Box> + </> + ) +} + const PluginList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() @@ -118,6 +141,7 @@ const PluginList = (props) => { exporter={false} bulkActionButtons={false} actions={<PluginListActions />} + empty={<PluginEmpty />} > {isXsmall ? ( <SimpleList diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx index 0ed41b98c..c2e0414eb 100644 --- a/ui/src/plugin/PluginList.test.jsx +++ b/ui/src/plugin/PluginList.test.jsx @@ -1,5 +1,11 @@ import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' const mockNotify = vi.fn() @@ -34,6 +40,7 @@ vi.mock('react-admin', async () => { TopToolbar: ({ children }) => ( <div data-testid="top-toolbar">{children}</div> ), + Empty: () => <div data-testid="ra-empty">No resources</div>, Datagrid: ({ children }) => <div data-testid="datagrid">{children}</div>, TextField: ({ source }) => <span data-testid={`text-${source}`} />, } @@ -42,9 +49,10 @@ vi.mock('react-admin', async () => { // Mock common components vi.mock('../common', async () => { return { - List: ({ children, actions, ...props }) => ( + List: ({ children, actions, empty, ...props }) => ( <div data-testid="list"> {actions} + {empty && <div data-testid="empty-state">{empty}</div>} {children} </div> ), @@ -94,14 +102,16 @@ describe('PluginList', () => { expect(screen.getByTestId('datagrid')).toBeInTheDocument() }) - it('renders the rescan button', () => { + it('renders the rescan button in the toolbar', () => { render(<PluginList />) - expect(screen.getByTestId('rescan-button')).toBeInTheDocument() + const toolbar = screen.getByTestId('top-toolbar') + expect(within(toolbar).getByTestId('rescan-button')).toBeInTheDocument() }) it('calls rescan endpoint when rescan button is clicked', async () => { render(<PluginList />) - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -114,7 +124,8 @@ describe('PluginList', () => { it('calls refresh after successful rescan', async () => { render(<PluginList />) - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -127,7 +138,8 @@ describe('PluginList', () => { mockHttpClient.mockRejectedValue(new Error('Network error')) render(<PluginList />) - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -137,4 +149,25 @@ describe('PluginList', () => { }) }) }) + + it('renders a rescan button in the empty state', () => { + render(<PluginList />) + const emptyState = screen.getByTestId('empty-state') + expect(emptyState).toBeInTheDocument() + expect(within(emptyState).getByTestId('rescan-button')).toBeInTheDocument() + }) + + it('empty state rescan button triggers rescan', async () => { + render(<PluginList />) + const emptyState = screen.getByTestId('empty-state') + const rescanButton = within(emptyState).getByTestId('rescan-button') + + fireEvent.click(rescanButton) + + await waitFor(() => { + expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', { + method: 'POST', + }) + }) + }) }) From 97c01c0614f7ee4ef39f0368173220e2657f89ea Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 5 May 2026 18:55:59 -0400 Subject: [PATCH 231/596] fix(plugins): add debug logs to syncPlugins for troubleshooting Log skipped entries, total entries vs plugins found, and DB state to help diagnose why sync may not find new plugins. Signed-off-by: Deluan <deluan@navidrome.org> --- plugins/manager_sync.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 2e024ca37..f97069e74 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -138,11 +138,13 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { filesOnDisk := make(map[string]string) // name -> path for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) { + log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } name := strings.TrimSuffix(entry.Name(), PackageExtension) filesOnDisk[name] = filepath.Join(folder, entry.Name()) } + log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) // Get all plugins from DB repo := m.ds.Plugin(adminCtx) @@ -154,6 +156,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { for i := range dbPlugins { pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i] } + log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB)) now := time.Now() From 5f0245ea845b398bf11deef2dcdaec4ecd97ae85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 6 May 2026 00:12:50 -0400 Subject: [PATCH 232/596] fix(server): prevent artwork throttle token starvation on slow clients (#5472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): prevent artwork throttle token starvation on slow clients Replace Chi's ThrottleBacklog middleware for artwork endpoints with a custom RequestThrottle that releases processing tokens before writing the HTTP response. Previously, a slow or stalled client could hold a throttle token indefinitely during io.Copy, exhausting all 2-4 slots and blocking artwork requests for all users (reported after 15+ days uptime). The new approach buffers artwork into memory while holding the token, releases it immediately, then writes the buffered response. A 30-second per-request write deadline (SetWriteTimeout) prevents stalled writes from blocking indefinitely. Throttle exhaustion is now logged with context for operator visibility. * refactor(server): simplify throttle to middleware with same API as Chi Restructure RequestThrottle from a DI-injected type into a drop-in middleware function with the same signature as Chi's ThrottleBacklog. Handlers are reverted to their original simple form (no throttle awareness), and the middleware is applied at route definition time just like before. This eliminates the DI dependency, removes the artworkThrottle field from both Router structs, and consolidates SetWriteTimeout into the throttle file. When limit <= 0, the middleware returns a passthrough so callers don't need a guard. Signed-off-by: Deluan <deluan@navidrome.org> * feat(server): add opt-out flag for buffered artwork throttle Add DevArtworkThrottleBuffered config (default true) that controls whether the new buffered ThrottleBacklog middleware is used. When set to false, it falls back to Chi's original middleware, giving users a safety valve in case the buffered implementation causes issues. Signed-off-by: Deluan <deluan@navidrome.org> * test(server): clean up throttle tests for clarity and speed Consolidate duplicate router setup into runTwoRequests() and slowClientTest() helpers. Replace time.Sleep-based token holding with channel synchronization, reducing suite time from ~7s to ~1.5s. Remove redundant test, fix duplicate comment block, and add comment explaining why slowTestWriter can't embed httptest.ResponseRecorder. * fix: release artwork throttle tokens on panic Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic. * fix: align buffered throttle response behavior Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling. * refactor(server): remove setWriteTimeout from throttle middleware SetWriteDeadline only constrains the server's Write syscall, not how fast the client reads from the TCP buffer. For artwork-sized responses (up to ~500KB), the kernel accepts the entire write immediately even over real network interfaces due to TCP buffer auto-tuning. Verified by testing with a stalled client over both loopback and en0 — the deadline never triggers. The actual protection comes from buffering + early token release, which is already in place. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 2 + server/public/public.go | 11 +- server/subsonic/api.go | 11 +- server/subsonic/media_retrieval_test.go | 16 +- server/throttle_backlog.go | 150 +++++++++++++ server/throttle_backlog_test.go | 266 ++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 server/throttle_backlog.go create mode 100644 server/throttle_backlog_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 1c4829d82..d93024c8a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -134,6 +134,7 @@ type configOptions struct { DevArtworkMaxRequests int DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration + DevArtworkThrottleBuffered bool DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -861,6 +862,7 @@ func setViperDefaults() { viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) + viper.SetDefault("devartworkthrottlebuffered", true) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/server/public/public.go b/server/public/public.go index 5e3407c19..18867e1c4 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -5,14 +5,12 @@ import ( "path" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" @@ -43,13 +41,8 @@ func (pub *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Group(func(r chi.Router) { - if conf.Server.DevArtworkMaxRequests > 0 { - log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, - "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", - conf.Server.DevArtworkThrottleBacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - } + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) r.HandleFunc("/img/{id}", pub.handleImages) }) if conf.Server.EnableSharing { diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 0bbfcb83e..1ca364449 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,7 +9,6 @@ import ( "regexp" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" @@ -190,14 +189,8 @@ func (api *Router) routes() http.Handler { hr(r, "getTranscodeStream", api.GetTranscodeStream) }) r.Group(func(r chi.Router) { - // configure request throttling - if conf.Server.DevArtworkMaxRequests > 0 { - log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, - "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", - conf.Server.DevArtworkThrottleBacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - } + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) hr(r, "getCoverArt", api.GetCoverArt) }) r.Group(func(r chi.Router) { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 589a609da..27d1edb84 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -78,16 +78,13 @@ var _ = Describe("MediaRetrievalController", func() { When("client disconnects (context is cancelled)", func() { It("should not call the service if cancelled before the call", func() { - // Create a request ctx, cancel := context.WithCancel(context.Background()) r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) - cancel() // Cancel the context before the call + cancel() - // Call the GetCoverArt method _, err := router.GetCoverArt(w, r) - // Expect no error and no call to the artwork service Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvId).To(Equal("")) Expect(artwork.recvSize).To(Equal(0)) @@ -96,17 +93,14 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should not return data if cancelled during the call", func() { - // Create a request with a context that will be cancelled ctx, cancel := context.WithCancel(context.Background()) - defer cancel() // Ensure the context is cancelled after the test (best practices) + defer cancel() r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) - artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service + artwork.ctxCancelFunc = cancel - // Call the GetCoverArt method _, err := router.GetCoverArt(w, r) - // Expect no error and the service to have been called Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvId).To(Equal("34")) Expect(artwork.recvSize).To(Equal(128)) @@ -344,7 +338,7 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, s c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { - c.ctxCancelFunc() // Simulate context cancellation + c.ctxCancelFunc() return nil, time.Time{}, context.Canceled } return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil @@ -363,9 +357,7 @@ func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, return data, nil } - // Hardcoded support for lyrics sorting result := slices.Clone(data) - // Sort by presence of lyrics, then by updated_at. Respect the order specified in opts. slices.SortFunc(result, func(a, b model.MediaFile) int { diff := cmp.Or( cmp.Compare(a.Lyrics, b.Lyrics), diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go new file mode 100644 index 000000000..c3672fd1e --- /dev/null +++ b/server/throttle_backlog.go @@ -0,0 +1,150 @@ +package server + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" +) + +var ( + ErrThrottleCapacityExceeded = errors.New("throttle: capacity exceeded") + ErrThrottleTimeout = errors.New("throttle: backlog timeout") +) + +type requestThrottle struct { + tokens chan struct{} + backlogTokens chan struct{} + backlogTimeout time.Duration +} + +// ThrottleBacklog creates a Chi-compatible middleware that limits concurrent +// request processing. Unlike Chi's ThrottleBacklog, it buffers the handler's +// response while holding the token, releases it, then flushes the buffer to +// the client with a write deadline. This prevents slow clients from holding +// throttle capacity. +// +// Because it buffers the entire response in memory, this middleware should only +// be used for endpoints that return small responses (e.g., artwork images). Do +// not use it for audio streaming or download endpoints. +func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + if !conf.Server.DevArtworkThrottleBuffered { + return middleware.ThrottleBacklog(limit, backlogLimit, backlogTimeout) + } + t := &requestThrottle{ + tokens: make(chan struct{}, limit), + backlogTokens: make(chan struct{}, limit+backlogLimit), + backlogTimeout: backlogTimeout, + } + for range limit { + t.tokens <- struct{}{} + } + for range limit + backlogLimit { + t.backlogTokens <- struct{}{} + } + return t.handler +} + +func (t *requestThrottle) handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + release, err := t.acquire(ctx) + if err != nil { + switch { + case errors.Is(err, ErrThrottleCapacityExceeded): + log.Warn(ctx, "Request throttle capacity exceeded", "path", r.URL.Path) + case errors.Is(err, ErrThrottleTimeout): + log.Warn(ctx, "Request throttle backlog timeout", "path", r.URL.Path) + } + http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + + buf := &bufferedResponseWriter{header: make(http.Header)} + func() { + defer release() + next.ServeHTTP(buf, r) + }() + + for k, v := range buf.header { + w.Header()[k] = v + } + if buf.code > 0 { + w.WriteHeader(buf.code) + } + if _, err := w.Write(buf.body.Bytes()); err != nil { + log.Warn(ctx, "Error writing throttled response", err) + } + }) +} + +func (t *requestThrottle) acquire(ctx context.Context) (release func(), err error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.backlogTokens: + default: + return nil, ErrThrottleCapacityExceeded + } + + select { + case <-t.tokens: + return t.releaseFunc(), nil + default: + } + + timer := time.NewTimer(t.backlogTimeout) + select { + case <-timer.C: + t.backlogTokens <- struct{}{} + return nil, ErrThrottleTimeout + case <-ctx.Done(): + timer.Stop() + t.backlogTokens <- struct{}{} + return nil, ctx.Err() + case <-t.tokens: + timer.Stop() + return t.releaseFunc(), nil + } +} + +func (t *requestThrottle) releaseFunc() func() { + var once sync.Once + return func() { + once.Do(func() { + t.tokens <- struct{}{} + t.backlogTokens <- struct{}{} + }) + } +} + +type bufferedResponseWriter struct { + header http.Header + body bytes.Buffer + code int +} + +func (w *bufferedResponseWriter) Header() http.Header { + return w.header +} + +func (w *bufferedResponseWriter) Write(b []byte) (int, error) { + return w.body.Write(b) +} + +func (w *bufferedResponseWriter) WriteHeader(code int) { + if w.code != 0 { + return + } + w.code = code +} diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go new file mode 100644 index 000000000..eb181b4ce --- /dev/null +++ b/server/throttle_backlog_test.go @@ -0,0 +1,266 @@ +package server + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ThrottleBacklog", func() { + It("is a passthrough when limit is 0", func() { + m := ThrottleBacklog(0, 10, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("ok")) + }) + + It("returns 429 when capacity is exceeded", func() { + _, secondStatus := runTwoRequests(ThrottleBacklog(1, 0, time.Second)) + Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) + }) + + It("returns 429 when backlog times out", func() { + _, secondStatus := runTwoRequests(ThrottleBacklog(1, 1, 50*time.Millisecond)) + Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) + }) + + It("releases capacity when the handler panics", func() { + m := ThrottleBacklog(1, 0, time.Second) + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(m) + r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { + panic("boom") + }) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/panic", nil) + r.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("ok")) + }) + + It("preserves response headers and status code", func() { + m := ThrottleBacklog(2, 0, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("body")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/jpeg")) + Expect(w.Header().Get("Cache-Control")).To(Equal("public")) + Expect(w.Body.String()).To(Equal("body")) + }) + + It("uses the first response status code", func() { + m := ThrottleBacklog(2, 0, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("body")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Body.String()).To(Equal("body")) + }) + + It("never exceeds the concurrency limit", func() { + const limit = 3 + const goroutines = 20 + m := ThrottleBacklog(limit, goroutines, 5*time.Second) + + var concurrent atomic.Int32 + var maxConcurrent atomic.Int32 + + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + cur := concurrent.Add(1) + for { + old := maxConcurrent.Load() + if cur <= old || maxConcurrent.CompareAndSwap(old, cur) { + break + } + } + time.Sleep(5 * time.Millisecond) + concurrent.Add(-1) + _, _ = w.Write([]byte("ok")) + }) + + var wg sync.WaitGroup + for range goroutines { + wg.Go(func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + }) + } + + wg.Wait() + Expect(maxConcurrent.Load()).To(BeNumerically("<=", limit)) + }) + + // Regression: with only 1 token, a slow client blocking during response + // writing must NOT prevent other requests from being served. Chi's original + // ThrottleBacklog holds the token for the entire handler lifecycle including + // io.Copy, causing starvation. The buffered implementation releases it first. + Context("when a client is slow to read the response", func() { + slowClientTest := func(m func(http.Handler) http.Handler) (*chi.Mux, chan struct{}, chan struct{}) { + handlerReached := make(chan struct{}, 1) + router := chi.NewRouter() + router.Use(m) + router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + select { + case handlerReached <- struct{}{}: + default: + } + _, _ = io.Copy(w, strings.NewReader("image data")) + }) + + unblocked := make(chan struct{}) + slow := newSlowTestWriter(unblocked) + + reqDone := make(chan struct{}) + go func() { + defer close(reqDone) + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(slow, req) + }() + <-handlerReached + + return router, unblocked, reqDone + } + + It("does not starve concurrent requests with buffered middleware", func() { + router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) + + Eventually(func() int { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + return w.Code + }, 2*time.Second, 10*time.Millisecond).Should(Equal(http.StatusOK)) + + close(unblocked) + Eventually(reqDone, 2*time.Second).Should(BeClosed()) + }) + + It("starves concurrent requests with Chi's original middleware", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevArtworkThrottleBuffered = false + + router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + + close(unblocked) + Eventually(reqDone, 2*time.Second).Should(BeClosed()) + }) + }) +}) + +// runTwoRequests sends two concurrent requests through a throttled router. The +// first request holds the token until the second has been dispatched. +func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { + held := make(chan struct{}) + release := make(chan struct{}) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + select { + case held <- struct{}{}: + default: + } + <-release + _, _ = w.Write([]byte("ok")) + }) + + done := make(chan int) + go func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + done <- w.Code + }() + <-held + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + secondStatus = w.Code + + close(release) + firstStatus = <-done + return firstStatus, secondStatus +} + +// slowTestWriter implements http.ResponseWriter without embedding +// httptest.ResponseRecorder. This is necessary because ResponseRecorder +// promotes io.ReaderFrom, which io.Copy prefers over Write — bypassing +// our blocking Write and defeating the slow-client simulation. +type slowTestWriter struct { + header http.Header + body bytes.Buffer + code int + unblocked chan struct{} +} + +func newSlowTestWriter(unblocked chan struct{}) *slowTestWriter { + return &slowTestWriter{header: make(http.Header), unblocked: unblocked} +} + +func (w *slowTestWriter) Header() http.Header { return w.header } + +func (w *slowTestWriter) WriteHeader(code int) { w.code = code } + +func (w *slowTestWriter) Write(p []byte) (int, error) { + <-w.unblocked + return w.body.Write(p) +} From 82f101a7b2ae4519c2256dcd9f27473bc61544e8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 6 May 2026 00:16:09 -0400 Subject: [PATCH 233/596] ci: fix input parameter name for GitHub token Signed-off-by: Deluan <deluan@navidrome.org> --- .github/actions/prepare-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 7fe0b9148..b8cde4aaf 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -73,7 +73,7 @@ runs: id: meta uses: docker/metadata-action@v6 with: - token: ${{ inputs.github_token }} + github-token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | From 59c1454ffb1ead0f9105c55f79e914a11b616412 Mon Sep 17 00:00:00 2001 From: IEEE-754 <253034919+IEEE-754@users.noreply.github.com> Date: Wed, 6 May 2026 20:49:13 +0800 Subject: [PATCH 234/596] fix(ui): update zh-Hant.json (#5470) --- resources/i18n/zh-Hant.json | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 93951a311..92b4af3d0 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -180,7 +180,7 @@ "name": "名稱", "transcodingId": "轉碼", "maxBitRate": "最大位元率", - "client": "客戶端", + "client": "用戶端", "userName": "使用者名稱", "lastSeen": "上次上線", "reportRealPath": "回報實際路徑", @@ -333,7 +333,7 @@ } }, "plugin": { - "name": "插件 |||| 插件", + "name": "外掛 |||| 外掛", "fields": { "id": "ID", "name": "名稱", @@ -359,7 +359,7 @@ }, "sections": { "status": "狀態", - "info": "插件資訊", + "info": "外掛資訊", "configuration": "設定", "manifest": "資訊清單", "usersPermission": "使用者權限", @@ -379,29 +379,29 @@ "rescan": "重新掃描" }, "notifications": { - "enabled": "插件已啟用", - "disabled": "插件已停用", - "updated": "插件已更新", - "error": "更新插件時發生錯誤" + "enabled": "外掛已啟用", + "disabled": "外掛已停用", + "updated": "外掛已更新", + "error": "更新外掛時發生錯誤" }, "validation": { "invalidJson": "設定必須是有效的 JSON" }, "messages": { - "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", + "configHelp": "使用鍵值對設定外掛。若外掛無需設定則留空。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", - "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", + "allUsersHelp": "啟用後,外掛將可存取所有使用者,包含未來建立的使用者。", "noUsers": "未選擇使用者", "permissionReason": "原因", - "usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。", - "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", + "usersRequired": "此外掛需要存取使用者資訊。請選擇外掛可存取的使用者,或啟用「允許所有使用者」。", + "allLibrariesHelp": "啟用後,外掛將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", - "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", + "librariesRequired": "此外掛需要存取媒體庫資訊。請選擇外掛可存取的媒體庫,或啟用「允許所有媒體庫」。", "requiredHosts": "必要的 Hosts", "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" + "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -452,7 +452,7 @@ "delete": "刪除", "edit": "編輯", "export": "匯出", - "list": "列表", + "list": "清單", "refresh": "重新整理", "remove_filter": "清除此條件", "remove": "移除", @@ -497,9 +497,9 @@ "upload_single": "拖曳單個圖片上傳或點擊選擇一個" }, "references": { - "all_missing": "未找到參考數據", - "many_missing": "至少有一條參考數據不再可用", - "single_missing": "關聯的參考數據不再可用" + "all_missing": "未找到參考資料", + "many_missing": "至少有一條參考資料不再可用", + "single_missing": "關聯的參考資料不再可用" }, "password": { "toggle_visible": "隱藏密碼", @@ -514,7 +514,7 @@ "delete_content": "您確定要刪除該項目?", "delete_title": "刪除 %{name} #%{id}", "details": "詳細資訊", - "error": "發生客戶端錯誤,您的請求無法完成", + "error": "發生用戶端錯誤,您的請求無法完成", "invalid_form": "提交內容無效,請檢查錯誤", "loading": "正在載入頁面,請稍候", "no": "否", @@ -564,19 +564,19 @@ "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", - "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", - "lastfmLinkFailure": "無法連接 Last.fm", - "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", - "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", + "lastfmLinkSuccess": "已成功連結 Last.fm 並開啟音樂記錄", + "lastfmLinkFailure": "無法連結 Last.fm", + "lastfmUnlinkSuccess": "已取消與 Last.fm 的連結並停用音樂記錄", + "lastfmUnlinkFailure": "無法取消與 Last.fm 的連結", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" }, "lastfmLink": "查看更多…", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連結 ListenBrainz 並開啟音樂記錄", + "listenBrainzLinkFailure": "無法連結 ListenBrainz:%{error}", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連結並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連結", "downloadOriginalFormat": "下載原始格式", "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} \ No newline at end of file +} From b18dfb474aa52dc3f155d1ad17508a3d8592e4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 6 May 2026 10:03:24 -0400 Subject: [PATCH 235/596] fix(transcoding): don't apply server-side override on getTranscodeDecision (#5473) * fix(transcoding): don't apply server-side transcoding override on getTranscodeDecision The getTranscodeDecision endpoint was incorrectly applying server-side player transcoding overrides (forced format and MaxBitRate cap), which replaced the client's declared capabilities with synthetic profiles. This caused the endpoint to ignore what the client can actually play and return decisions for formats the client never requested (e.g. AAC when the client only supports FLAC/opus/mp3). The override is now gated behind an ApplyServerOverride flag in TranscodeOptions, which is only set by the legacy stream endpoint where this behavior is expected. Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move server-side transcoding override to ResolveRequest Moved the server-side player transcoding override logic (forced format and MaxBitRate cap) from MakeDecision into ResolveRequest, where the legacy stream context is handled. This makes MakeDecision a pure function that only operates on the ClientInfo it receives, removing the ApplyServerOverride flag and all context-sniffing from the decision engine. Tests moved accordingly to legacy_client_test.go. * test(e2e): update transcode decision tests for server override removal Updated e2e tests to reflect that getTranscodeDecision no longer applies server-side player overrides (MaxBitRate cap and forced transcoding profile). The player MaxBitRate tests now verify the endpoint ignores the player cap and relies solely on client-declared capabilities. * test(e2e): assert opus default bitrate when player cap is ignored Added bitrate assertion to verify the player MaxBitRate cap is truly ignored: the target bitrate should be the opus format default (128kbps), not the player cap (320kbps). --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/stream/decider.go | 12 --- core/stream/decider_test.go | 135 +------------------------- core/stream/legacy_client.go | 16 ++- core/stream/legacy_client_test.go | 104 ++++++++++++++++++++ server/e2e/subsonic_transcode_test.go | 95 ++++-------------- 5 files changed, 141 insertions(+), 221 deletions(-) diff --git a/core/stream/decider.go b/core/stream/decider.go index cde12f0f3..d6e48497c 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -59,18 +59,6 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, decision.SourceStream = buildSourceStream(mf, probe) src := &decision.SourceStream - // Check for server-side player transcoding override - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { - modified := *clientInfo - modified.MaxAudioBitrate = player.MaxBitRate - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 8b58f3323..f74953258 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1042,8 +1042,8 @@ var _ = Describe("Decider", func() { }) }) - Context("Server-side player transcoding override", func() { - It("forces transcoding when override targets a different format", func() { + Context("Server-side context is ignored by MakeDecision", func() { + It("ignores transcoding override in context", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) ci := &ClientInfo{ Name: "TestClient", @@ -1051,148 +1051,21 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } - // Set server override in context overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("allows direct play when source matches forced format and bitrate is within cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - Expect(decision.CanTranscode).To(BeFalse()) - }) - - It("transcodes when source bitrate exceeds the forced cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("uses player MaxBitRate over transcoding DefaultBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(320)) - }) - - It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) - Expect(decision.TargetBitrate).To(Equal(160)) - }) - - It("does not apply override when no transcoding is in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - // No override in context — client profiles used as-is - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) - }) - - Context("Player MaxBitRate cap", func() { - It("applies player MaxBitRate cap when client has no limit", func() { + It("ignores player MaxBitRate in context", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ Name: "TestClient", DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - // Source bitrate 1000 > player cap 320, so direct play is not possible - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - // Lossless→lossy should use MaxAudioBitrate (320) as target, not format default - Expect(decision.TargetBitrate).To(Equal(320)) - }) - - It("uses client limit when it is more restrictive than player MaxBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) - ci := &ClientInfo{ - Name: "TestClient", - MaxAudioBitrate: 256, - MaxTranscodingAudioBitrate: 256, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) - - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins - Expect(decision.TargetBitrate).To(Equal(256)) - }) - - It("does not cap when player MaxBitRate is 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index d6e929ec8..9dd6179a0 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -7,12 +7,11 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // buildLegacyClientInfo translates legacy Subsonic stream/download parameters // into a ClientInfo for use with MakeDecision. -// It does NOT read request.TranscodingFrom(ctx) — that is handled by -// MakeDecision's applyServerOverride. func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} @@ -65,6 +64,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + + // Apply server-side player transcoding override before making the decision + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { + modified := *clientInfo + modified.MaxAudioBitrate = player.MaxBitRate + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + } + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) if err != nil { log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index de1eb1339..ce7b38650 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -187,6 +188,109 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Offset).To(Equal(30)) }) + Context("Server-side player transcoding override", func() { + It("forces transcoding when override targets a different format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) + + It("allows direct play when source matches forced format and bitrate is within cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("transcodes when source bitrate exceeds the forced cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) + + It("uses player MaxBitRate over transcoding DefaultBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(320)) + }) + + It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) + Expect(req.BitRate).To(Equal(160)) + }) + + It("does not apply override when no transcoding is in context", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + }) + + Context("Player MaxBitRate cap", func() { + It("applies player MaxBitRate cap when client has no limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(320)) + }) + + It("uses client limit when it is more restrictive than player MaxBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(256)) + }) + + It("does not cap when player MaxBitRate is 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + }) + Context("fallback for unknown format", func() { It("falls back to DefaultDownsamplingFormat", func() { DeferCleanup(configtest.SetupConfig()) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index 6041cd013..ae3d6208c 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -396,68 +396,30 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap", func() { - It("forces transcode when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is ignored", func() { + It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps, client has no bitrate limit but player cap is 320 + // FLAC is 900kbps, player cap is 320, but getTranscodeDecision + // ignores server-side overrides — client profiles are used as-is resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - // Target bitrate should be capped at player's 320kbps = 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("does not affect direct play when source bitrate is under player MaxBitRate", func() { - setPlayerMaxBitRate(500) // 500 kbps cap - - // MP3 is 320kbps, under the 500kbps player cap → direct play - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) }) - It("uses client limit when more restrictive than player MaxBitRate", func() { - setPlayerMaxBitRate(500) // 500 kbps player cap - - // Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500 - // FLAC is 900kbps → exceeds both limits → transcode - resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Client limit (320kbps) is more restrictive → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("uses player MaxBitRate when more restrictive than client limit", func() { + It("uses only client limit, not player MaxBitRate", func() { setPlayerMaxBitRate(192) // 192 kbps player cap // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 - // FLAC is 900kbps → transcode at 192kbps + // but getTranscodeDecision ignores player cap → client limit (320kbps) applies resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Player limit (192kbps) is more restrictive → 192000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - - It("has no effect when player MaxBitRate is 0", func() { - setPlayerMaxBitRate(0) // No player cap - - // FLAC with flac+mp3 client → direct play (no bitrate constraint) - resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + // Only client limit (320kbps) applies → 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) }) @@ -513,56 +475,37 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate + client limits combined", func() { - It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() { + Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { + It("does not inject maxAudioBitrate from player cap", func() { setPlayerMaxBitRate(320) // opusTranscodeClient has no client bitrate limits - // Player cap injects maxAudioBitrate=320 - // FLAC (900kbps) → exceeds 320 → transcode to opus - // Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320 + // Player cap is 320, but getTranscodeDecision ignores it + // FLAC (900kbps) → can't direct play → transcode to opus using format default resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // maxAudioBitrate=320 used as fallback → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Bitrate should be opus format default (128kbps), not player cap (320kbps) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) }) - It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() { + It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate - // Player cap injects maxAudioBitrate=320 - // FLAC (900kbps) → exceeds 320 → transcode to mp3 - // Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) + // Player cap is 320, but getTranscodeDecision ignores it + // Only client maxTranscodingAudioBitrate=192 applies resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate=192 is preferred → 192000 bps + // maxTranscodingAudioBitrate=192 → 192000 bps Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) - - It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() { - setPlayerMaxBitRate(128) - - // Get decision: FLAC (900kbps) with player cap 128 → transcode - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Stream using the token - w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) }) }) From 569de4cd23b491e2719eba57fc25d61d0be9e305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 6 May 2026 11:03:11 -0400 Subject: [PATCH 236/596] fix(test): prevent flaky deadlock in throttle backlog test (#5474) The `held` channel in `runTwoRequests` was unbuffered, creating a race condition with the `select/default` send in the handler. Under CI load (slow runner, -race, -shuffle=on), the handler goroutine could reach the select before the test goroutine blocked on `<-held`, causing the send to silently fall through to `default` and deadlocking both goroutines permanently. Buffer the channel (capacity 1) so the send always succeeds regardless of goroutine scheduling order. --- server/throttle_backlog_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go index eb181b4ce..4d8567db5 100644 --- a/server/throttle_backlog_test.go +++ b/server/throttle_backlog_test.go @@ -209,7 +209,7 @@ var _ = Describe("ThrottleBacklog", func() { // runTwoRequests sends two concurrent requests through a throttled router. The // first request holds the token until the second has been dispatched. func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { - held := make(chan struct{}) + held := make(chan struct{}, 1) release := make(chan struct{}) r := chi.NewRouter() r.Use(m) From f12e75aa11079f094d8874e0d91f6e37126a33e2 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 10 May 2026 18:34:45 -0300 Subject: [PATCH 237/596] feat(subsonic): add groupings field to OpenSubsonic Child response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the ID3 grouping tag in OpenSubsonic responses as an array of strings, per opensubsonic/open-subsonic-api#232. The grouping tag was already being extracted and stored in MediaFile.Tags via mappings.yaml aliases (GRP1, GROUPING, ©grp, wm/contentgroupdescription), so this change only adds the field to the response struct and populates it in both song and album child builders. Signed-off-by: Deluan <deluan@navidrome.org> --- server/subsonic/helpers.go | 2 ++ .../Responses AlbumList with OS data should match .JSON | 5 ++++- .../Responses AlbumList with OS data should match .XML | 1 + ...ponses AlbumWithSongsID3 with data should match .JSON | 9 +++++++-- ...sponses AlbumWithSongsID3 with data should match .XML | 2 ++ .../Responses Child with data should match .JSON | 9 +++++++-- .../Responses Child with data should match .XML | 2 ++ ...es Child without data should match OpenSubsonic .JSON | 3 ++- server/subsonic/responses/responses.go | 1 + server/subsonic/responses/responses_test.go | 3 +++ 10 files changed, 31 insertions(+), 6 deletions(-) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 74d57ade4..8a9eb7110 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -266,6 +266,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op child.BitDepth = int32(mf.BitDepth) child.Genres = toItemGenres(mf.Genres) child.Moods = mf.Tags.Values(model.TagMood) + child.Groupings = mf.Tags.Values(model.TagGrouping) child.DisplayArtist = mf.Artist child.Artists = artistRefs(mf.Participants[model.RoleArtist]) child.DisplayAlbumArtist = mf.AlbumArtist @@ -375,6 +376,7 @@ func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubson child.MusicBrainzId = al.MbzAlbumID child.Genres = toItemGenres(al.Genres) child.Moods = al.Tags.Values(model.TagMood) + child.Groupings = al.Tags.Values(model.TagGrouping) child.DisplayArtist = al.AlbumArtist child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist]) child.DisplayAlbumArtist = al.AlbumArtist diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index 8491a577b..9d9ae2195 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -56,7 +56,10 @@ "displayAlbumArtist": "Display album artist", "contributors": [], "displayComposer": "", - "explicitStatus": "explicit" + "explicitStatus": "explicit", + "groupings": [ + "Soundtrack" + ] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML index 5d9e83f96..d39fe2e7d 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML @@ -9,6 +9,7 @@ <artists id="artist-2" name="Artist 2"></artists> <albumArtists id="album-artist-1" name="Artist 1"></albumArtists> <albumArtists id="album-artist-2" name="Artist 2"></albumArtists> + <groupings>Soundtrack</groupings> </album> </albumList> </subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 07678407a..a07914344 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -165,7 +165,11 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ] }, { "id": "2", @@ -210,7 +214,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index f7b23cb4e..6c96391bc 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -32,6 +32,8 @@ <contributors role="role2" subRole="subrole4"> <artist id="2" name="artist2"></artist> </contributors> + <groupings>Soundtrack</groupings> + <groupings>Live</groupings> </song> <song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320"> <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index d20a6d48c..4c0ea6c68 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -110,7 +110,11 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ] }, { "id": "", @@ -141,7 +145,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 1d307b0b9..ddceb67d4 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -24,6 +24,8 @@ <contributors role="composer"> <artist id="4" name="composer2"></artist> </contributors> + <groupings>Soundtrack</groupings> + <groupings>Live</groupings> </child> <child id="" isDir="false" title=""> <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index 25284295e..9a9ab1ff6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -28,7 +28,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ], "id": "", diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b0b2b8752..4165669df 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -189,6 +189,7 @@ type OpenSubsonicChild struct { Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"` DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` + Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` } type Songs struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ee98a3daa..0b72fab19 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -224,6 +224,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1", "ISRC-2"}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, + Groupings: []string{"Soundtrack", "Live"}, ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ @@ -320,6 +321,7 @@ var _ = Describe("Responses", func() { Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, + Groupings: []string{"Soundtrack", "Live"}, ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", @@ -424,6 +426,7 @@ var _ = Describe("Responses", func() { ItemGenre{Name: "Genre 2"}, }, Moods: []string{"mood1", "mood2"}, + Groupings: []string{"Soundtrack"}, DisplayArtist: "Display artist", Artists: Array[ArtistID3Ref]{ ArtistID3Ref{Id: "artist-1", Name: "Artist 1"}, From e55a35544bcf14f2f494edfdd39cccc28bef9e93 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 11 May 2026 20:33:14 -0300 Subject: [PATCH 238/596] chore(deps): update TagLib to 2.3 Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 36218ba6b..7fe9ba59d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a require ( github.com/Masterminds/squirrel v1.5.4 @@ -53,7 +53,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.0 + github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 diff --git a/go.sum b/go.sum index 90e0ec040..29eec8620 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= 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= @@ -279,8 +279,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= -github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= From 2b3b879c57d3560bdaecf4559869b60254981e16 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 11 May 2026 20:34:04 -0300 Subject: [PATCH 239/596] chore(deps): update dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 26 +++++++++++++------------- go.sum | 52 ++++++++++++++++++++++++++-------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 7fe9ba59d..6a0acf2d6 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.1.0 + github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 @@ -58,12 +58,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.39.0 - golang.org/x/net v0.53.0 + golang.org/x/image v0.40.0 + golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 + golang.org/x/sys v0.44.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -81,7 +81,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/fsnotify/fsnotify v1.10.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -89,11 +89,11 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 29eec8620..0c550e47d 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= -github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -106,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -167,8 +167,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= -github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= @@ -316,17 +316,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -338,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -364,11 +364,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -377,8 +377,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -389,8 +389,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -400,8 +400,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 24e526e09ac5a2d3254dbbafa80bb70203a6b21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 13 May 2026 17:17:20 -0300 Subject: [PATCH 240/596] fix(transcoding): place -ss before -i for fast input seeking (#5492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ffmpeg -ss (seek/offset) parameter before -i in all transcoding commands so ffmpeg uses input seeking instead of output seeking. Per the ffmpeg docs, placing -ss before -i seeks at the demuxer level by keyframe (very fast), and since FFmpeg 2.1 it is also frame-accurate when transcoding. The previous placement after -i caused ffmpeg to decode and discard all audio up to the seek point, which was unnecessarily slow — especially problematic for lengthy files (4+ hours). Both code paths are updated: buildDynamicArgs (for default formats) and createFFmpegCommand (for custom templates without %t). A database migration updates existing default commands in the transcoding table. --- consts/consts.go | 8 +-- core/ffmpeg/ffmpeg.go | 19 +++++-- core/ffmpeg/ffmpeg_test.go | 19 ++++--- .../20260513173954_move_ss_before_input.go | 55 +++++++++++++++++++ tests/mock_transcoding_repo.go | 4 +- 5 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 db/migrations/20260513173954_move_ss_before_input.go diff --git a/consts/consts.go b/consts/consts.go index bf32006d6..edd8f2b54 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -153,25 +153,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 80790c8d6..3225ff150 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -394,12 +395,13 @@ func isDefaultCommand(format, command string) bool { // including all transcoding parameters (bitrate, sample rate, channels). func buildDynamicArgs(opts TranscodeOptions) []string { cmdPath, _ := ffmpegCmd() - args := []string{cmdPath, "-i", opts.FilePath} + args := []string{cmdPath} if opts.Offset > 0 { args = append(args, "-ss", strconv.Itoa(opts.Offset)) } + args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") if codec, ok := formatCodecMap[opts.Format]; ok { @@ -491,11 +493,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string for _, s := range fixCmd(cmd) { if strings.Contains(s, "%s") { + if offset > 0 && !strings.Contains(cmd, "%t") { + // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) + // instead of decoding all frames up to the offset (slow). + insertAt := len(args) + for i := len(args) - 1; i >= 0; i-- { + if args[i] == "-i" { + insertAt = i + break + } + } + args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset)) + } s = strings.ReplaceAll(s, "%s", path) args = append(args, s) - if offset > 0 && !strings.Contains(cmd, "%t") { - args = append(args, "-ss", strconv.Itoa(offset)) - } } else { s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 1649015d9..562fd9100 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -47,15 +47,15 @@ var _ = Describe("ffmpeg", func() { }) Context("when command has time offset param", func() { It("creates a valid command line with offset", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"})) + args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) Context("when command does not have time offset param", func() { - It("adds time offset after the input file name", func() { + It("adds time offset before the input file name", func() { args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"})) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) }) @@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() { Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -165,8 +165,9 @@ var _ = Describe("ffmpeg", func() { Offset: 30, }) Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.mp3", + "ffmpeg", "-ss", "30", + "-i", "/music/file.mp3", "-map", "0:a:0", "-c:a", "libmp3lame", "-b:a", "192k", diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go new file mode 100644 index 000000000..c16583aa0 --- /dev/null +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput) +} + +// ssSeekPairs maps old commands (output seeking) to new commands (input seeking). +// Index 0 = old (after -i), index 1 = new (before -i). +var ssSeekPairs = [][2]string{ + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 796e84111..641daca8a 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -19,9 +19,9 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil case "flac": - return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil case "aac": - return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound } From 8f0b4930ff8835cd5a730e984a9a33a9e9463fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 13 May 2026 17:44:22 -0300 Subject: [PATCH 241/596] refactor(conf): replace eager dir creation with lazy Dir type (#5495) * feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/backup.go | 4 +- cmd/svc.go | 8 +- conf/configtest/configtest.go | 6 +- conf/configuration.go | 76 +++++++-------- conf/configuration_test.go | 17 +--- conf/dir.go | 76 +++++++++++++++ conf/dir_test.go | 127 ++++++++++++++++++++++++++ core/artwork/benchmark_e2e_test.go | 2 +- core/artwork/e2e/suite_test.go | 2 +- core/artwork/reader_artist_test.go | 2 +- core/artwork/reader_radio_test.go | 2 +- core/image_upload_test.go | 2 +- core/metrics/insights.go | 12 +-- core/playlists/playlists_test.go | 4 +- core/stream/media_streamer_test.go | 5 +- db/backup.go | 8 +- db/backup_test.go | 4 +- db/db.go | 2 + go.mod | 2 +- model/artist_test.go | 2 +- model/image.go | 2 +- model/radio_test.go | 2 +- persistence/artist_repository_test.go | 2 +- plugins/host_artwork_test.go | 2 +- plugins/host_cache_test.go | 2 +- plugins/host_config_test.go | 2 +- plugins/host_kvstore.go | 2 +- plugins/host_kvstore_test.go | 6 +- plugins/host_library_test.go | 4 +- plugins/host_scheduler_test.go | 2 +- plugins/host_subsonicapi_test.go | 2 +- plugins/host_taskqueue.go | 2 +- plugins/host_taskqueue_test.go | 8 +- plugins/host_users_test.go | 2 +- plugins/host_websocket_test.go | 2 +- plugins/manager.go | 14 +-- plugins/manager_watcher.go | 4 +- plugins/plugins_suite_test.go | 4 +- resources/embed.go | 2 +- scanner/external.go | 4 +- server/nativeapi/config.go | 2 +- utils/cache/benchmark_test.go | 2 +- utils/cache/file_caches.go | 2 +- utils/cache/file_caches_test.go | 4 +- 44 files changed, 317 insertions(+), 126 deletions(-) create mode 100644 conf/dir.go create mode 100644 conf/dir_test.go diff --git a/cmd/backup.go b/cmd/backup.go index ab73f7537..c02f3a19f 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -75,7 +75,7 @@ var ( func runBackup(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } idx := strings.LastIndex(conf.Server.DbPath, "?") @@ -104,7 +104,7 @@ func runBackup(ctx context.Context) { func runPrune(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } if backupCount != -1 { diff --git a/cmd/svc.go b/cmd/svc.go index 89ca08056..cc8d6bb54 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service { options["Restart"] = "on-failure" options["SuccessExitStatus"] = "1 2 8 SIGKILL" options["UserService"] = false - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() options["SystemdScript"] = systemdScript if conf.Server.LogFile != "" { options["LogOutput"] = false } else { options["LogOutput"] = true - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() } svcConfig := &service.Config{ UserName: installUser, @@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command { println("Installing service with:") println(" working directory: " + executablePath()) println(" music folder: " + conf.Server.MusicFolder) - println(" data folder: " + conf.Server.DataFolder) + println(" data folder: " + conf.Server.DataFolder.String()) if conf.Server.LogFile != "" { println(" log file: " + conf.Server.LogFile) } else { - println(" logs folder: " + conf.Server.DataFolder) + println(" logs folder: " + conf.Server.DataFolder.String()) } if cfgFile != "" { conf.Server.ConfigFile, err = filepath.Abs(cfgFile) diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go index b947e6263..cd0ac41ed 100644 --- a/conf/configtest/configtest.go +++ b/conf/configtest/configtest.go @@ -2,9 +2,7 @@ package configtest import "github.com/navidrome/navidrome/conf" +// TODO Remove this redirection and call SnapshotConfig directly from tests func SetupConfig() func() { - oldValues := *conf.Server - return func() { - conf.Server = &oldValues - } + return conf.SnapshotConfig() } diff --git a/conf/configuration.go b/conf/configuration.go index d93024c8a..6fff1641a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,6 +2,7 @@ package conf import ( "cmp" + "encoding/json" "fmt" "net/url" "os" @@ -14,6 +15,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" + "github.com/go-viper/mapstructure/v2" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -29,8 +31,8 @@ type configOptions struct { UnixSocketPerm string EnforceNonRootUser bool MusicFolder string - DataFolder string - CacheFolder string + DataFolder Dir + CacheFolder Dir DbPath string LogLevel string LogFile string @@ -229,7 +231,7 @@ type jukeboxOptions struct { type backupOptions struct { Count int - Path string + Path Dir Schedule string } @@ -247,7 +249,7 @@ type inspectOptions struct { type pluginsOptions struct { Enabled bool - Folder string + Folder Dir CacheSize string AutoReload bool LogLevel string @@ -287,6 +289,22 @@ var ( hooks []func() ) +// SnapshotConfig returns a function that restores Server to its current state. +// Uses JSON round-tripping so Dir fields get fresh sync.Once values. +func SnapshotConfig() func() { + snapshot, err := json.Marshal(Server) + if err != nil { + panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err)) + } + return func() { + var restored configOptions + if err := json.Unmarshal(snapshot, &restored); err != nil { + panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err)) + } + Server = &restored + } +} + func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() @@ -307,7 +325,13 @@ func Load(noConfigDump bool) { mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - err := viper.Unmarshal(&Server) + err := viper.Unmarshal(&Server, viper.DecodeHook( + mapstructure.ComposeDecodeHookFunc( + mapstructure.TextUnmarshallerHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + )) if err != nil { logFatal("Error parsing config:", err) } @@ -317,48 +341,28 @@ func Load(noConfigDump bool) { logFatal(err) } - err = os.MkdirAll(Server.DataFolder, os.ModePerm) - if err != nil { - logFatal("Error creating data path:", err) - } - - if Server.CacheFolder == "" { - Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") - } - err = os.MkdirAll(Server.CacheFolder, os.ModePerm) - if err != nil { - logFatal("Error creating cache path:", err) - } - - err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) - if err != nil { - logFatal("Error creating artwork path:", err) + if Server.CacheFolder.String() == "" { + Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache")) } if Server.Plugins.Enabled { - if Server.Plugins.Folder == "" { - Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") - } - err = os.MkdirAll(Server.Plugins.Folder, 0700) - if err != nil { - logFatal("Error creating plugins path:", err) + if Server.Plugins.Folder.String() == "" { + Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700) + } else { + Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700) } } Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { - Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) - } - - if Server.Backup.Path != "" { - err = os.MkdirAll(Server.Backup.Path, os.ModePerm) - if err != nil { - logFatal("Error creating backup path:", err) - } + Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath) } out := os.Stderr if Server.LogFile != "" { + if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { + logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) + } out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) @@ -636,7 +640,7 @@ func validateScanSchedule() error { } func validateBackupSchedule() error { - if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { + if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { Server.Backup.Schedule = "" return nil } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 5d4e73fad..9c25a0d19 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -186,27 +186,12 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error reading config file"))) }) - It("is called when DataFolder is not writable", func() { - viper.SetDefault("datafolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating data path"))) - }) - - It("is called when CacheFolder is not writable", func() { - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("cachefolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating cache path"))) - }) - It("is called when LogFile path is not writable", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) Expect(func() { conf.Load(true) - }).To(PanicWith(ContainSubstring("Error opening log file"))) + }).To(PanicWith(ContainSubstring("Error creating log file directory"))) }) It("is called when BaseURL is invalid", func() { diff --git a/conf/dir.go b/conf/dir.go new file mode 100644 index 000000000..8ed43039b --- /dev/null +++ b/conf/dir.go @@ -0,0 +1,76 @@ +package conf + +import ( + "fmt" + "os" + "sync" +) + +// Dir wraps a directory path and lazily creates the directory on first use. +// The directory is created at most once; if creation fails, the error is +// permanently cached (sync.Once semantics). Dir is not safe for mutation +// after Path() has been called. +type Dir struct { + path string + perm os.FileMode + once sync.Once + err error +} + +// NewDir creates a new Dir with the given path and default permissions (os.ModePerm). +func NewDir(path string) Dir { + return Dir{path: path, perm: os.ModePerm} +} + +// NewDirWithPerm creates a new Dir with the given path and permissions. +func NewDirWithPerm(path string, perm os.FileMode) Dir { + return Dir{path: path, perm: perm} +} + +// String returns the raw path without creating the directory. Satisfies fmt.Stringer. +func (d *Dir) String() string { + return d.path +} + +// Path creates the directory on first call (via sync.Once) and returns the path. +func (d *Dir) Path() (string, error) { + d.once.Do(func() { + if d.path == "" { + return + } + d.err = os.MkdirAll(d.path, d.perm) + if d.err != nil { + d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err) + } + }) + return d.path, d.err +} + +// MustPath calls Path() and calls logFatal on error. +func (d *Dir) MustPath() string { + path, err := d.Path() + if err != nil { + logFatal("creating directory:", err) + } + return path +} + +// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) +// prints the path string instead of the internal struct fields. +func (d Dir) GoString() string { //nolint:govet + return fmt.Sprintf("%q", d.path) +} + +// MarshalText returns the raw path bytes. No side effects. +func (d *Dir) MarshalText() ([]byte, error) { + return []byte(d.path), nil +} + +// UnmarshalText sets the path from bytes. No side effects. +func (d *Dir) UnmarshalText(text []byte) error { + d.path = string(text) + if d.perm == 0 { + d.perm = os.ModePerm + } + return nil +} diff --git a/conf/dir_test.go b/conf/dir_test.go new file mode 100644 index 000000000..2dd4250bc --- /dev/null +++ b/conf/dir_test.go @@ -0,0 +1,127 @@ +package conf_test + +import ( + "os" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Dir", func() { + Describe("NewDir", func() { + It("creates a Dir with the given path without side effects", func() { + d := conf.NewDir("/some/path") + Expect(d.String()).To(Equal("/some/path")) + }) + }) + + Describe("String", func() { + It("returns the raw path without creating the directory", func() { + d := conf.NewDir("/nonexistent/path/that/should/not/be/created") + Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created")) + }) + }) + + Describe("Path", func() { + It("creates the directory and returns the path on first call", func() { + dir := GinkgoT().TempDir() + target := dir + "/subdir/nested" + d := conf.NewDir(target) + + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("returns the same result on subsequent calls (sync.Once)", func() { + dir := GinkgoT().TempDir() + target := dir + "/once" + d := conf.NewDir(target) + + path1, err1 := d.Path() + path2, err2 := d.Path() + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(path1).To(Equal(path2)) + }) + + It("returns an error when directory cannot be created", func() { + f := GinkgoT().TempDir() + blocker := f + "/blocker" + By("creating a file that blocks directory creation") + Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) + invalid := blocker + "/subdir" + + d := conf.NewDir(invalid) + _, pathErr := d.Path() + Expect(pathErr).To(HaveOccurred()) + }) + + It("returns empty path and no error for empty path", func() { + d := conf.NewDir("") + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(BeEmpty()) + }) + }) + + Describe("MustPath", func() { + It("returns the path when directory is created successfully", func() { + dir := GinkgoT().TempDir() + target := dir + "/mustpath" + d := conf.NewDir(target) + + path := d.MustPath() + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("calls logFatal on error", func() { + var fatalMsg []any + restore := conf.SetLogFatal(func(args ...any) { + fatalMsg = args + panic("logFatal called") + }) + DeferCleanup(restore) + + f := GinkgoT().TempDir() + "/blocker" + Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed()) + invalid := f + "/subdir" + + d := conf.NewDir(invalid) + Expect(func() { d.MustPath() }).To(Panic()) + Expect(fatalMsg).ToNot(BeEmpty()) + }) + }) + + Describe("MarshalText", func() { + It("returns the raw path bytes without side effects", func() { + d := conf.NewDir("/marshal/path") + b, err := d.MarshalText() + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(Equal("/marshal/path")) + }) + }) + + Describe("UnmarshalText", func() { + It("sets the path from bytes without side effects", func() { + d := conf.NewDir("") + err := d.UnmarshalText([]byte("/unmarshal/path")) + Expect(err).ToNot(HaveOccurred()) + Expect(d.String()).To(Equal("/unmarshal/path")) + }) + + It("allows round-trip marshal/unmarshal", func() { + d1 := conf.NewDir("/round/trip") + b, err := d1.MarshalText() + Expect(err).ToNot(HaveOccurred()) + + var d2 conf.Dir + err = d2.UnmarshalText(b) + Expect(err).ToNot(HaveOccurred()) + Expect(d2.String()).To(Equal(d1.String())) + }) + }) +}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index c27964018..393cbb473 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID // Configure cache conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) conf.Server.CoverArtQuality = 75 conf.Server.CoverArtPriority = "cover.*" diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 9ce0edb8b..733e2e98c 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -63,7 +63,7 @@ func setupHarness() { // Reuse the suite-level DB path so the singleton connection keeps working // across specs (see suiteDBTempDir comment). conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) conf.Server.MusicFolder = fakeLibPath conf.Server.DevExternalScanner = false conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index e2a1f2094..50ca3a2ce 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) // Create the artwork/artist directory Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go index 1f5bc9084..37ce1d827 100644 --- a/core/artwork/reader_radio_test.go +++ b/core/artwork/reader_radio_test.go @@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) diff --git a/core/image_upload_test.go b/core/image_upload_test.go index d13a04775..265f60a95 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) svc = core.NewImageUploadService() }) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f069d3fb6..bcd0343c2 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package") packageFileData, err := os.ReadFile(packageFilename) if err == nil { data.OS.Package = string(packageFileData) @@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data { // FS info data.FS.Music = getFSInfo(conf.Server.MusicFolder) - data.FS.Data = getFSInfo(conf.Server.DataFolder) - if conf.Server.CacheFolder != "" { - data.FS.Cache = getFSInfo(conf.Server.CacheFolder) + data.FS.Data = getFSInfo(conf.Server.DataFolder.String()) + if conf.Server.CacheFolder.String() != "" { + data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String()) } - if conf.Server.Backup.Path != "" { - data.FS.Backup = getFSInfo(conf.Server.Backup.Path) + if conf.Server.Backup.Path.String() != "" { + data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String()) } // Config info diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 52d5c88d8..95d7b3e6a 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -307,7 +307,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, @@ -371,7 +371,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a real image file on disk imgDir := filepath.Join(tmpDir, "artwork", "playlist") diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bc21e239..1bbf868fa 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -23,7 +23,8 @@ var _ = Describe("MediaStreamer", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches") + cacheDir, _ := os.MkdirTemp("", "file_caches") + conf.Server.CacheFolder = conf.NewDir(cacheDir) conf.Server.TranscodingCacheSize = "100MB" ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -34,7 +35,7 @@ var _ = Describe("MediaStreamer", func() { streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { - _ = os.RemoveAll(conf.Server.CacheFolder) + _ = os.RemoveAll(conf.Server.CacheFolder.String()) }) Context("NewStream", func() { diff --git a/db/backup.go b/db/backup.go index a34255d7e..806bef8e2 100644 --- a/db/backup.go +++ b/db/backup.go @@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05" func backupPath(t time.Time) string { return filepath.Join( - conf.Server.Backup.Path, + conf.Server.Backup.Path.MustPath(), fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), ) } @@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error { } func Prune(ctx context.Context) (int, error) { - files, err := os.ReadDir(conf.Server.Backup.Path) + backupDir, err := conf.Server.Backup.Path.Path() + if err != nil { + return 0, fmt.Errorf("backup directory not available: %w", err) + } + files, err := os.ReadDir(backupDir) if err != nil { return 0, fmt.Errorf("unable to read database backup entries: %w", err) } diff --git a/db/backup_test.go b/db/backup_test.go index aec43446d..5e8f877e6 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -60,7 +60,7 @@ var _ = Describe("database backups", func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) @@ -118,7 +118,7 @@ var _ = Describe("database backups", func() { BeforeEach(func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) diff --git a/db/db.go b/db/db.go index 0945d1a00..168c12122 100644 --- a/db/db.go +++ b/db/db.go @@ -38,6 +38,8 @@ func Db() *sql.DB { if Path == ":memory:" { Path = "file::memory:?cache=shared&_foreign_keys=on" conf.Server.DbPath = Path + } else { + conf.Server.DataFolder.MustPath() } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) diff --git a/go.mod b/go.mod index 6a0acf2d6..937cffbd5 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 @@ -84,7 +85,6 @@ require ( github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect diff --git a/model/artist_test.go b/model/artist_test.go index 5a24504eb..db897d3d5 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -14,7 +14,7 @@ var _ = Describe("Artist", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/model/image.go b/model/image.go index 68d8ae64c..30307fcea 100644 --- a/model/image.go +++ b/model/image.go @@ -13,5 +13,5 @@ func UploadedImagePath(entityType, filename string) string { if filename == "" { return "" } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) + return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename) } diff --git a/model/radio_test.go b/model/radio_test.go index dc421454e..860331f17 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -26,7 +26,7 @@ var _ = Describe("Radio", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index e2904466c..076a9da3b 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -840,7 +840,7 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) ctx := request.WithUser(GinkgoT().Context(), adminUser) repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 151a0d03c..ed8a0e810 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -47,7 +47,7 @@ var _ = Describe("ArtworkService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Initialize auth (required for token generation) diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 0f55bcfda..cf3973fc4 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -343,7 +343,7 @@ var _ = Describe("CacheService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index bd3368a67..b296d29fb 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -57,7 +57,7 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index c3f6ec734..2224b7485 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -54,7 +54,7 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm } // Create plugin data directory - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index e5d467f79..109ae8131 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -34,7 +34,7 @@ var _ = Describe("KVStoreService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing maxSize := "1KB" @@ -705,9 +705,9 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 5746a3bed..67f5f9b0f 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -263,7 +263,7 @@ var _ = Describe("LibraryService", Ordered, func() { // the service registration and configuration without full plugin execution DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) // Create mock &tests.MockLibraryRepo{} mockLibRepo := &tests.MockLibraryRepo{} @@ -357,7 +357,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin and library diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index 334d9b738..ca53aed56 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -51,7 +51,7 @@ var _ = Describe("SchedulerService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Create mock scheduler and timer registry diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 607f3a64b..6f7ff4dd3 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -44,7 +44,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock router and data store diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index 9f2ed85f6..eff73c822 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -82,7 +82,7 @@ type taskQueueServiceImpl struct { // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index c3ab8d119..8a58f1eb4 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -40,7 +40,7 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a mock manager with context managerCtx, cancel := context.WithCancel(ctx) @@ -853,10 +853,10 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") - conf.Server.DataFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache")) + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 1c0de7d03..42f6a3032 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -484,7 +484,7 @@ func createTestUsers(mockUserRepo *tests.MockedUserRepo) { // setupTestUsersConfig sets up common plugin configuration func setupTestUsersConfig(tmpDir string) { conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 83fca9898..e41cfbb82 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -51,7 +51,7 @@ var _ = Describe("WebSocketService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/manager.go b/plugins/manager.go index 0e9419bfd..67e0ee987 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "path/filepath" "runtime" "sync" @@ -124,7 +123,7 @@ func (m *Manager) Start(ctx context.Context) error { m.ctx, m.cancel = context.WithCancel(ctx) // Initialize wazero compilation cache for better performance - cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") + cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) var err error @@ -134,17 +133,12 @@ func (m *Manager) Start(ctx context.Context) error { return fmt.Errorf("creating wazero compilation cache: %w", err) } - folder := conf.Server.Plugins.Folder - if folder == "" { + if conf.Server.Plugins.Folder.String() == "" { log.Debug(ctx, "No plugins folder configured") return nil } - // Create plugins folder if it doesn't exist - if err := os.MkdirAll(folder, 0755); err != nil { - log.Error(ctx, "Failed to create plugins folder", "folder", folder, err) - return fmt.Errorf("creating plugins folder: %w", err) - } + folder := conf.Server.Plugins.Folder.MustPath() log.Info(ctx, "Starting plugin manager", "folder", folder) @@ -431,7 +425,7 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s // This synchronizes the database with the filesystem, discovering new plugins, // updating changed ones, and removing deleted ones. func (m *Manager) RescanPlugins(ctx context.Context) error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return fmt.Errorf("plugins folder not configured") } diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index 4f266bda1..b7022b46e 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -19,7 +19,7 @@ const debounceDuration = 2 * time.Second // startWatcher starts the file watcher for the plugins folder. // It watches for CREATE, WRITE, and REMOVE events on .wasm files. func (m *Manager) startWatcher() error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return nil } @@ -146,7 +146,7 @@ func (m *Manager) processPluginEvent(pluginName string) { delete(m.debounceTimers, pluginName) m.debounceMu.Unlock() - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() ndpPath := filepath.Join(folder, pluginName+PackageExtension) action := determinePluginAction(ndpPath) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 1799ba3ce..bb081988e 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -48,7 +48,7 @@ func TestPlugins(t *testing.T) { // Set CacheFolder globally so all tests (including those using // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = sharedCacheDir + conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) @@ -126,7 +126,7 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugins diff --git a/resources/embed.go b/resources/embed.go index 0386e6f79..040bb5d84 100644 --- a/resources/embed.go +++ b/resources/embed.go @@ -16,6 +16,6 @@ var embedFS embed.FS func FS() fs.FS { return merge.FS{ Base: embedFS, - Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), + Overlay: os.DirFS(path.Join(conf.Server.DataFolder.String(), "resources")), } } diff --git a/scanner/external.go b/scanner/external.go index 29ca90be6..393a9278c 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -45,8 +45,8 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod "scan", "--nobanner", "--subprocess", "--configfile", conf.Server.ConfigFile, - "--datafolder", conf.Server.DataFolder, - "--cachefolder", conf.Server.CacheFolder, + "--datafolder", conf.Server.DataFolder.String(), + "--cachefolder", conf.Server.CacheFolder.String(), } // Add targets if provided diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 02626a4ee..cfecfa663 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -97,7 +97,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Marshal the actual configuration struct to preserve original field names - configBytes, err := json.Marshal(*conf.Server) + configBytes, err := json.Marshal(conf.Server) if err != nil { log.Error(ctx, "Error marshaling config", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index 1fe448f84..e3fc08eda 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -28,7 +28,7 @@ func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileC b.Fatal(err) } b.Cleanup(configtest.SetupConfig()) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 5edc533f8..9788926d5 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -262,7 +262,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) h := fscache.NewLRUHaunterStrategy(lru) - cacheFolder = filepath.Join(conf.Server.CacheFolder, cacheFolder) + cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder) var fs *spreadFS log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size)) diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 72f4463d1..9a9a9444f 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -28,14 +28,14 @@ var _ = Describe("File Caches", func() { configtest.SetupConfig() _ = os.RemoveAll(tmpDir) }) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) }) Describe("NewFileCache", func() { It("creates the cache folder", func() { Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil()) - _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test")) + _, err := os.Stat(filepath.Join(conf.Server.CacheFolder.String(), "test")) Expect(os.IsNotExist(err)).To(BeFalse()) }) From 725f6ab34b1588880624af9ea64eb00bed75bcdc Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 18 May 2026 09:31:57 -0300 Subject: [PATCH 242/596] feat(i18n): add Estonian translation file Signed-off-by: Deluan <deluan@navidrome.org> --- resources/i18n/et.json | 723 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 resources/i18n/et.json diff --git a/resources/i18n/et.json b/resources/i18n/et.json new file mode 100644 index 000000000..d511c1246 --- /dev/null +++ b/resources/i18n/et.json @@ -0,0 +1,723 @@ +{ + "languageName": "eesti keel", + "resources": { + "song": { + "name": "Laul |||| Laulud", + "fields": { + "albumArtist": "Albumi esitaja", + "duration": "Kestus", + "trackNumber": "Nr", + "playCount": "Esituskordi", + "title": "Pealkiri", + "artist": "Esitaja", + "album": "Album", + "path": "Faili asukoht", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "size": "Faili suurus", + "updatedAt": "Uuendatud", + "bitRate": "Bitikiirus", + "discSubtitle": "Plaadi alapealkiri", + "starred": "Märgi lemmikuks", + "comment": "Kommentaar", + "rating": "Hinnang", + "quality": "Kvaliteet", + "bpm": "BPM", + "playDate": "Viimati esitatud", + "channels": "Kanaleid", + "createdAt": "Lisamise kuupäev", + "grouping": "Rühmitamine", + "mood": "Meeleolu", + "participants": "Täiendavad osalejad", + "tags": "Täiendavad sildid", + "mappedTags": "Tuvastatud sildid", + "rawTags": "Sildid töötlemata vaates", + "bitDepth": "Bitisügavus", + "sampleRate": "Diskreetmisagedus", + "missing": "Puudub", + "libraryName": "Kogumik", + "composer": "Helilooja", + "disc": "%{discNumber}. plaat", + "albumGain": "Albumikohane esitusvaljuse tundlikkus", + "trackGain": "Rajakohane esitusvaljuse tundlikkus" + }, + "actions": { + "addToQueue": "Esita hiljem", + "playNow": "Esita kohe", + "addToPlaylist": "Lisa esitusloendisse", + "shuffleAll": "Sega kõik", + "download": "Laadi alla", + "playNext": "Esita järgmisena", + "info": "Loo teave", + "showInPlaylist": "Näita esitusloendis", + "instantMix": "Kohene miks" + } + }, + "album": { + "name": "Album |||| Albumid", + "fields": { + "albumArtist": "Albumi esitaja", + "artist": "Esitaja", + "duration": "Kestus", + "songCount": "laulu", + "playCount": "Esituskordi", + "name": "Nimi", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "updatedAt": "Uuendatud", + "comment": "Kommentaar", + "rating": "Hinnangud", + "createdAt": "Lisamise kuupäev", + "size": "Suurus", + "originalDate": "Originaal", + "releaseDate": "Avaldatud", + "releases": "Väljalase ||| Väljalasked", + "released": "Avaldatud", + "recordLabel": "Plaadifirma", + "catalogNum": "Tunnus kataloogides", + "releaseType": "Tüüp", + "grouping": "Grupeerimine", + "media": "Meedium", + "mood": "Meeleolu", + "date": "Salvestuskuupäev", + "missing": "Puudu", + "libraryName": "Kogumik" + }, + "actions": { + "playAll": "Esita", + "playNext": "Esita järgmisena", + "addToQueue": "Esita hiljem", + "shuffle": "Sega lood", + "addToPlaylist": "Lisa esitusloendisse", + "download": "Laadi alla", + "info": "Albumi teave", + "share": "Jaga" + }, + "lists": { + "all": "Kõik", + "random": "Juhuslik", + "recentlyAdded": "Hiljuti lisatud", + "recentlyPlayed": "Hiljuti esitatud", + "mostPlayed": "Enimesitatud", + "starred": "Lemmikud", + "topRated": "Kõrgeima hinnanguga" + } + }, + "artist": { + "name": "Esitaja |||| Esitajad", + "fields": { + "name": "Nimi", + "albumCount": "Albumeid", + "songCount": "Lugusid", + "playCount": "Esituskordi", + "rating": "Hinnang", + "genre": "Žanr", + "size": "Suurus", + "role": "Roll", + "missing": "Puudub" + }, + "roles": { + "albumartist": "Albumi esitaja ||| Albumi esitajad", + "artist": "Esitaja ||| Esitajad", + "composer": "Helilooja ||| Heliloojad", + "conductor": "Dirigent ||| Dirigendid", + "lyricist": "Laulusõnade autor ||| Laulusõnade autorid", + "arranger": "Seade autor ||| Seade autorid", + "producer": "Produtsent ||| Produtsendid", + "director": "Lavastaja ||| Lavastajad", + "engineer": "Helirežissöör ||| Helirežissöörid", + "mixer": "Miksija ||| Miksijad", + "remixer": "Remiksija ||| Remiksijad", + "djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad", + "performer": "Esineja ||| Esinejad", + "maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad" + }, + "actions": { + "shuffle": "Sega", + "radio": "Raadio", + "topSongs": "Populaarsed lood" + } + }, + "user": { + "name": "Kasutaja |||| Kasutajad", + "fields": { + "userName": "Kasutajanimi", + "isAdmin": "On peakasutaja", + "lastLoginAt": "Viimane sisselogimine", + "updatedAt": "Uuendatud", + "name": "Nimi", + "password": "Salasõna", + "createdAt": "Loodud", + "changePassword": "Kas soovid salasõna muuta?", + "currentPassword": "Senine salasõna", + "newPassword": "Uus salasõna", + "token": "Tunnusluba", + "lastAccessAt": "Viimasti avatud", + "libraries": "Kogumikud" + }, + "helperTexts": { + "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", + "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks" + }, + "notifications": { + "created": "Kasutaja on lisatud", + "updated": "Kasutaja andmed on uuendatud", + "deleted": "Kasutaja on kustutatud" + }, + "message": { + "listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.", + "clickHereForToken": "Tunnusloa saamiseks klõpsi siin", + "selectAllLibraries": "Vali kõik kogumikud", + "adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele" + }, + "validation": { + "librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks" + } + }, + "player": { + "name": "Meediaesitaja |||| Meediaesitajad", + "fields": { + "name": "Nimi", + "transcodingId": "Teisendamine", + "maxBitRate": "Maksimaalne bitikiirus", + "client": "Klient", + "userName": "Kasutajanimi", + "lastSeen": "Viimati nähtud", + "reportRealPath": "Teata tegelikust asukohast", + "scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse" + } + }, + "transcoding": { + "name": "Teisendamine |||| Teisendamised", + "fields": { + "name": "Nimi", + "targetFormat": "Sihtvorming", + "defaultBitRate": "Vaikimisi bitikiirus", + "command": "Käsk" + } + }, + "playlist": { + "name": "Esitusloend ||| Esitusloendid", + "fields": { + "name": "Nimi", + "duration": "Kestus", + "ownerName": "Omanik", + "public": "Avalik", + "updatedAt": "Muudetud", + "createdAt": "Loodud", + "songCount": "Lood", + "comment": "Kommentaar", + "sync": "Automaatne import", + "path": "Impordi siit" + }, + "actions": { + "selectPlaylist": "Valo esitusloend:", + "addNewPlaylist": "Loo „%{name}\"“", + "export": "Ekspordi", + "makePublic": "Muuda avalikuks", + "makePrivate": "Muuda privaatseks", + "saveQueue": "Salvesta esitusjärjekord esitusloendina", + "searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...", + "pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi", + "removeFromSelection": "Eemalda valikust" + }, + "message": { + "duplicate_song": "Lisa topeltlood", + "song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?", + "noPlaylistsFound": "Esitusloendeid ei leidu", + "noPlaylists": "Esitusloendeid pole saadaval" + } + }, + "radio": { + "name": "Raadio ||| Raadiod", + "fields": { + "name": "Nimi", + "streamUrl": "Voogedastuse võrguaadress", + "homePageUrl": "Avalehe võrguaadress", + "updatedAt": "Uuendatud", + "createdAt": "Lisatud" + }, + "actions": { + "playNow": "Esita kohe" + } + }, + "share": { + "name": "Jagamine ||| Jagamised", + "fields": { + "username": "Seda jagas", + "url": "Võrguaadress", + "description": "Kirjeldus", + "contents": "Sisu", + "expiresAt": "Aegub", + "lastVisitedAt": "Viimati vaadatud", + "visitCount": "Külastusi", + "format": "Vorming", + "maxBitRate": "Maksimaalne bitikiirus", + "updatedAt": "Muudetud", + "createdAt": "Lisatud", + "downloadable": "Kas lubad allalaadimised?" + } + }, + "missing": { + "name": "Puuduv fail ||| Puuduvad failid", + "fields": { + "path": "Asukoht", + "size": "Suurus", + "updatedAt": "Kadumise aeg", + "libraryName": "Kogumik" + }, + "actions": { + "remove": "Eemalda", + "remove_all": "Eemalda kõik" + }, + "notifications": { + "removed": "Puuduv(ad) fail(id) on eemaldatud" + }, + "empty": "Puuduvaid faile pole" + }, + "library": { + "name": "Kogumik ||| Kogumikud", + "fields": { + "name": "Nimi", + "path": "Asukoht", + "remotePath": "Asukoht kaugseadmes", + "lastScanAt": "Viimane skaneerimine", + "songCount": "Lood", + "albumCount": "Albumid", + "artistCount": "Esitajad", + "totalSongs": "Lood", + "totalAlbums": "Albumid", + "totalArtists": "Esitajad", + "totalFolders": "Kaustad", + "totalFiles": "Failid", + "totalMissingFiles": "Puuduvad failid", + "totalSize": "Kogumaht", + "totalDuration": "Kestus", + "defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele", + "createdAt": "Lisatud", + "updatedAt": "Muudetud" + }, + "sections": { + "basic": "Põhiteave", + "statistics": "Statistika" + }, + "actions": { + "scan": "Skaneeri kogumikku", + "manageUsers": "Halda kasutajate õigusi", + "viewDetails": "Vaata üksikasju", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täismahuline skaneerimine" + }, + "notifications": { + "created": "Kogumiku loomine õnnestus", + "updated": "Kogumiku uuendamine õnnestus", + "deleted": "Kogumiku kustutamine õnnestus", + "scanStarted": "Kogumiku skaneerimine algas", + "scanCompleted": "Kogumiku skaneerimine lõppes", + "quickScanStarted": "Kiirskaneerimine algas", + "fullScanStarted": "Täismahuline skaneerimine algas", + "scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest" + }, + "validation": { + "nameRequired": "Pead sisestama kogumiku nime", + "pathRequired": "Pead sisestama kogumiku asukoha", + "pathNotDirectory": "Kogumiku asukoht peab olema kaust", + "pathNotFound": "Kogumiku asukoha kausta ei leidu", + "pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav", + "pathInvalid": "Vigane kogumiku asukoha kaust" + }, + "messages": { + "deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.", + "scanInProgress": "Skaneerimine on pooleli...", + "noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud" + } + }, + "plugin": { + "name": "Lisamoodul |||| Lisamoodulid", + "fields": { + "id": "Tunnus", + "name": "Nimi", + "description": "Kirjeldus", + "version": "Versioon", + "author": "Autor", + "website": "Veebisait", + "permissions": "Õigused", + "enabled": "Kasutusel", + "status": "Olek", + "path": "Asukoht", + "lastError": "Viga", + "hasError": "Viga", + "updatedAt": "Uuendatud", + "createdAt": "Paigaldatud", + "configKey": "Võti", + "configValue": "Väärtus", + "allUsers": "Luba kõiki kasutajaid", + "selectedUsers": "Valitud kasutajad", + "allLibraries": "Luba kõik kogumikud", + "selectedLibraries": "Valitud kogumikud", + "allowWriteAccess": "Luba kirjutusõigused" + }, + "sections": { + "status": "Olek", + "info": "Lisamooduli teave", + "configuration": "Seadistus", + "manifest": "Manifest", + "usersPermission": "Kasutajate õigused", + "libraryPermission": "Kogumike õigused" + }, + "status": { + "enabled": "Kasutusel", + "disabled": "Pole kasutusel" + }, + "actions": { + "enable": "Võta kasutusele", + "disable": "Eemalda kasutuselt", + "disabledDueToError": "Enne kasutuselevõtmist paranda viga", + "disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad", + "disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud", + "addConfig": "Lisa seadistus", + "rescan": "Skaneeri uuesti" + }, + "notifications": { + "enabled": "Lisamoodul on kasutusel", + "disabled": "Lisamoodul pole kasutusel", + "updated": "Lisamoodul on uuendatud", + "error": "Viga lisamooduli uuendamisel" + }, + "validation": { + "invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus" + }, + "messages": { + "configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.", + "clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust", + "noConfig": "Ühtegi seadistust pole määratud", + "allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.", + "noUsers": "Ühtegi kasutajat pole valitud", + "permissionReason": "Põhjus", + "usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.", + "allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.", + "noLibraries": "Ühtegi kogumikku pole valitud", + "librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.", + "requiredHosts": "Nõutavad hostid", + "configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:", + "schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.", + "allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus." + }, + "placeholders": { + "configKey": "võti", + "configValue": "väärtus" + } + } + }, + "ra": { + "auth": { + "welcome1": "Aitäh, et paigaldasite Navidrome'i!", + "welcome2": "Alustamiseks lisa peakasutaja", + "confirmPassword": "Korda salasõna", + "buttonCreateAdmin": "Loo admin", + "auth_check_error": "Jätkamiseks palun logi sisse", + "user_menu": "Profiil", + "username": "Kasutajanimi", + "password": "Salasõna", + "sign_in": "Logi sisse", + "sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti", + "logout": "Logi välja", + "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" + }, + "validation": { + "invalidChars": "Palun kasutage ainult tähti ja numbreid", + "passwordDoesNotMatch": "Salasõnad ei kattu", + "required": "Nõutav", + "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", + "maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi", + "minValue": "Väärtus peab olema vähemalt %{min}", + "maxValue": "Väärtus ei tohi olla enam, kui %{max}", + "number": "Sisend peab olema number", + "email": "Sisend peab korrektne e-posti aadress", + "oneOf": "Väärtus peab olema üks järgnevaist: %{options}", + "regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}", + "unique": "Sisend peab olema unikaalne", + "url": "Sisend peab olema korrektne võrguaadress" + }, + "action": { + "add_filter": "Lisa filter", + "add": "Lisa", + "back": "Mine tagasi", + "bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud", + "cancel": "Katkesta", + "clear_input_value": "Eemalda väärtus", + "clone": "Klooni", + "confirm": "Kinnita", + "create": "Loo", + "delete": "Kustuta", + "edit": "Muuda", + "export": "Ekspordi", + "list": "Loend", + "refresh": "Uuenda andmed", + "remove_filter": "Eemalda see filter", + "remove": "Eemalda", + "save": "Salvesta", + "search": "Otsi", + "show": "Näita", + "sort": "Järjesta", + "undo": "Võta tegevus tagasi", + "expand": "Laienda", + "close": "Sulge", + "open_menu": "Ava menüü", + "close_menu": "Sulge menüü", + "unselect": "Eemalda valik", + "skip": "Jäta vahele", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Jaga", + "download": "Laadi alla" + }, + "boolean": { + "true": "Jah", + "false": "Ei" + }, + "page": { + "create": "Loo %{name}", + "dashboard": "Töölaud", + "edit": "%{name} #%{id}", + "error": "Midagi läks valesti", + "list": "%{name}", + "loading": "Laadin", + "not_found": "Ei leidu", + "show": "%{name} #%{id}", + "empty": "Nimi on veel puudu - %{name}.", + "invite": "Kas sa sooviksid ühe sellise lisada?" + }, + "input": { + "file": { + "upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast." + }, + "image": { + "upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast." + }, + "references": { + "all_missing": "Viitenumbrite andmeid ei leidu.", + "many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.", + "single_missing": "Seotud viide ei tundu enam olema saadaval." + }, + "password": { + "toggle_visible": "Peida salasõna", + "toggle_hidden": "Näita salasõna" + } + }, + "message": { + "about": "Teave", + "are_you_sure": "Kas oled kindel?", + "bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?", + "bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet", + "delete_content": "Kas oled kindel, et soovid selle objekti kustutada?", + "delete_title": "Kustuta %{name} #%{id}", + "details": "Üksikasjad", + "error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.", + "invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi", + "loading": "Leht on just laadimisel, palun oota hetke", + "no": "Ei", + "not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.", + "yes": "Jah", + "unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?" + }, + "navigation": { + "no_results": "Tulemusi ei leidu", + "no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.", + "page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire", + "page_out_from_end": "Viimasest lehest ei saa edasi minna", + "page_out_from_begin": "Esimese lehe ette ei saa minna", + "page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}", + "page_rows_per_page": "Kirjeid lehel:", + "next": "Edasi", + "prev": "Tagasi", + "skip_nav": "Mine sisu juurde" + }, + "notification": { + "updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud", + "created": "Objekt on loodud", + "deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud", + "bad_item": "Vigane objekt", + "item_doesnt_exist": "Objekti pole olemas", + "http_error": "Viga suhtlemisel serveriga", + "data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.", + "i18n_error": "Vastava keele tõlget ei saa laadida", + "canceled": "Tegevus on tühistatud", + "logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.", + "new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kuvatavad veerud", + "layout": "Paigutus", + "grid": "Ruudustik", + "table": "Tabel" + } + }, + "message": { + "note": "MÄRGE", + "transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.", + "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.", + "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", + "noPlaylistsAvailable": "Pole saadaval", + "delete_user_title": "Kustuta kasutaja „%{name}“", + "delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?", + "notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud", + "notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli", + "lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse", + "lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud", + "lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja", + "lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud", + "openIn": { + "lastfm": "Ava Last.fm-is", + "musicbrainz": "Ava MusicBrainzis" + }, + "lastfmLink": "Lisateave...", + "listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}", + "listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja", + "listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud", + "downloadOriginalFormat": "Laadi alla algses vormingus", + "shareOriginalFormat": "Jaga algses vormingus", + "shareDialogTitle": "Jaga - %{resource} „%{name}“", + "shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}", + "shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}", + "shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale", + "downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv", + "remove_missing_title": "Eemalda puuduvad failid", + "remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "remove_all_missing_title": "Eemalda kõik puuduvad failid", + "remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "noSimilarSongsFound": "Sarnaseid lugusid ei leidu", + "noTopSongsFound": "Populaarsemaid lugusid ei leidu", + "startingInstantMix": "Laadin kohest miksi...", + "uploadCover": "Laadi kaanepilt üles", + "removeCover": "Eemalda kaanepilt", + "coverUploaded": "Kaanepilt on uuendatud", + "coverRemoved": "Kaanepilt on eemaldatud", + "coverUploadError": "Viga kaanepildi üleslaadimisel", + "coverRemoveError": "Viga kaanepildi eemaldamisel" + }, + "menu": { + "library": "Kogumik", + "settings": "Seaded", + "version": "Versioon", + "theme": "Teema", + "personal": { + "name": "Isiklik", + "options": { + "theme": "Teema", + "language": "Keel", + "defaultView": "Vaikimisi vaade", + "desktop_notifications": "Teavitused töölaual", + "lastfmScrobbling": "Kraasi Last.fm-i teenusesse", + "listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse", + "replaygain": "Esitusvaljuse tundlikkuse režiim", + "preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)", + "gain": { + "none": "Pole kasutusel", + "album": "Kasuta albumikohast esitusvaljuse tundlikkust", + "track": "Kasuta lookohast esitusvaljuse tundlikkust" + }, + "lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud" + } + }, + "albumList": "Albumid", + "about": "Rakenduse teave", + "playlists": "Esitusloendid", + "sharedPlaylists": "Jagatud esitusloendid", + "librarySelector": { + "allLibraries": "Kõik kogumikud (%{count})", + "multipleLibraries": "%{selected} / %{total} kogumikest", + "selectLibraries": "Vali kogumikud", + "none": "Puudub" + } + }, + "player": { + "playListsText": "Esitusjärjekord", + "openText": "Ava", + "closeText": "Sulge", + "notContentText": "Muusikat pole", + "clickToPlayText": "Klõpsa esitamiseks", + "clickToPauseText": "Klõpsa peatamiseks", + "nextTrackText": "Järgmine lugu", + "previousTrackText": "Eelmine lugu", + "reloadText": "Laadi uuesti", + "volumeText": "Helivaljus", + "toggleLyricText": "Näita/peida laulusõnad", + "toggleMiniModeText": "Minimeeri", + "destroyText": "Hävita", + "downloadText": "Laadi alla", + "removeAudioListsText": "Kustuta heliloendid", + "clickToDeleteText": "„%{name}“ kustutamiseks klõpsa", + "emptyLyricText": "Laulusõnu pole", + "playModeText": { + "order": "Oma järjekorras", + "orderLoop": "Korda", + "singleLoop": "Korda üks kord", + "shufflePlay": "Sega lood" + } + }, + "about": { + "links": { + "homepage": "Avaleht", + "source": "Lähtekood", + "featureRequests": "Arendusettepanekud", + "lastInsightsCollection": "Viimati kogutud statistika", + "insights": { + "disabled": "Pole kasutusel", + "waiting": "Ootel" + } + }, + "tabs": { + "about": "Teave", + "config": "Seadistus" + }, + "config": { + "configName": "Seadistuse nimi", + "environmentVariable": "Keskkonnamuutuja", + "currentValue": "Praegune väärtus", + "configurationFile": "Seadistusfail", + "exportToml": "Ekspordi seadistused (TOML-failina)", + "exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina", + "exportFailed": "Seadistuse kopeerimine ei õnnestunud", + "devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)", + "devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda", + "downloadToml": "Laadi seadistused alla (TOML-failina)" + } + }, + "activity": { + "title": "Tegevus", + "totalScanned": "Kokku skaneeritud kaustu", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täisskaneerimine", + "serverUptime": "Serveri katkematu tööaeg", + "serverDown": "POLE VÕRGUS", + "scanType": "Tüüp", + "status": "Skaneerimisviga", + "elapsedTime": "Möödunud aeg", + "selectiveScan": "Valikuline" + }, + "help": { + "title": "Navidrome'i kiirklahvid", + "hotkeys": { + "show_help": "Näita seda abiteavet", + "toggle_menu": "Lülita menüü külgriba sisse/välja", + "toggle_play": "Esita / Peata esitus", + "prev_song": "Eelmine lugu", + "next_song": "Järgmine lugu", + "vol_up": "Heli valjemaks", + "vol_down": "Heli vaiksemaks", + "toggle_love": "Lisa see lugu lemmikute hulka", + "current_song": "Mine esitamisel loo juurde" + } + }, + "nowPlaying": { + "title": "Hetkel esitamisel", + "empty": "Mitte midagi pole hetkel esitamisel", + "minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi" + } +} \ No newline at end of file From 339a6271f112fe10cab7d792aa3678d3065288a0 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 18 May 2026 12:50:30 +0000 Subject: [PATCH 243/596] chore(subsonic): only log response body on send error when at trace level or higher (#5501) --- server/subsonic/api.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1ca364449..fbab02794 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -375,6 +375,10 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub } 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) + if log.IsGreaterOrEqualTo(log.LevelTrace) { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + } else { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err) + } } } From 23252e06388ed3d200ebb8a54f21399e50ce26f9 Mon Sep 17 00:00:00 2001 From: VirtualWolf <VirtualWolf@users.noreply.github.com> Date: Wed, 20 May 2026 00:01:41 +1000 Subject: [PATCH 244/596] fix(ui): updated the AMusic theme to use the correct text colour for primary confirmation buttons (#5509) --- ui/src/themes/amusic.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index 74f7d3fd4..55205baf3 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -192,6 +192,11 @@ export default { paddingBottom: '1rem', }, }, + RaConfirm: { + confirmPrimary: { + color: '#fff', + }, + }, RaDeleteWithConfirmButton: { deleteButton: { color: '#fff !important', From a84f092d006ec80e9bd940af31a1fe235c1f5100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 19 May 2026 14:23:38 -0300 Subject: [PATCH 245/596] fix(subsonic): require admin access for Subsonic management endpoints (#5510) * fix: require admin for radio mutations Subsonic internet radio station mutation endpoints are admin-only in the Subsonic and OpenSubsonic specs, but the router only required an authenticated player. Add a reusable Subsonic admin middleware and apply it to create, update, and delete radio routes while leaving the list endpoint available to authenticated users. Cover the middleware and router behavior with unit and e2e tests. * fix: streamline admin-only routes for internet radio station management Signed-off-by: Deluan <deluan@navidrome.org> * fix: use admin-only middleware for starting scans Signed-off-by: Deluan <deluan@navidrome.org> * test: align start scan authorization coverage StartScan authorization now lives in the shared Subsonic admin middleware instead of the handler. Remove the obsolete direct handler unit assertion so the package tests reflect the route-level guard covered by middleware and e2e tests. * fix: require admin for getUsers The Subsonic getUsers endpoint exposes user-list semantics and should use the same shared admin middleware as other admin-only management endpoints. Apply the route-level guard while leaving getUser unchanged, and update the multi-user e2e coverage to expect regular users to receive an authorization failure. * test: cover admin-only Subsonic access Add e2e coverage that admins can still call getUsers after the route-level guard and that regular authenticated users can still list internet radio stations. These cases capture the access boundaries raised during PR review. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- server/e2e/subsonic_multiuser_test.go | 18 +++++--- server/e2e/subsonic_radio_test.go | 53 ++++++++++++++++++++++++ server/subsonic/api.go | 13 +++--- server/subsonic/library_scanning.go | 4 -- server/subsonic/library_scanning_test.go | 23 ---------- server/subsonic/middlewares.go | 17 ++++++++ server/subsonic/middlewares_test.go | 30 ++++++++++++++ 7 files changed, 121 insertions(+), 37 deletions(-) diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go index 4a5c35a7e..d8c5d3689 100644 --- a/server/e2e/subsonic_multiuser_test.go +++ b/server/e2e/subsonic_multiuser_test.go @@ -60,15 +60,23 @@ var _ = Describe("Multi-User Isolation", Ordered, func() { }) }) - Describe("getUsers for regular user", func() { - It("returns only the requesting user's info", func() { - resp := doReqWithUser(regularUser, "getUsers") + Describe("getUsers authorization", func() { + It("succeeds for admin user", func() { + resp := doReqWithUser(adminUser, "getUsers") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Users).ToNot(BeNil()) Expect(resp.Users.User).To(HaveLen(1)) - Expect(resp.Users.User[0].Username).To(Equal("regular")) - Expect(resp.Users.User[0].AdminRole).To(BeFalse()) + Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) + Expect(resp.Users.User[0].AdminRole).To(BeTrue()) + }) + + It("fails for regular user because getUsers is admin-only", func() { + resp := doReqWithUser(regularUser, "getUsers") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) }) }) }) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go index ce64c31a1..cd778fa79 100644 --- a/server/e2e/subsonic_radio_test.go +++ b/server/e2e/subsonic_radio_test.go @@ -46,6 +46,30 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(radioID).ToNot(BeEmpty()) }) + It("getInternetRadioStations remains available to regular users", func() { + resp := doReqWithUser(regularUser, "getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + + It("createInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "createInternetRadioStation", + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + It("updateInternetRadioStation modifies the station", func() { resp := doReq("updateInternetRadioStation", "id", radioID, @@ -64,6 +88,35 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) }) + It("updateInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) + Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) + }) + + It("deleteInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID)) + }) + It("deleteInternetRadioStation removes it", func() { resp := doReq("deleteInternetRadioStation", "id", radioID) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index fbab02794..7d15125b6 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -171,12 +171,12 @@ func (api *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getUser", api.GetUser) - h(r, "getUsers", api.GetUsers) + h(r.With(adminOnly), "getUsers", api.GetUsers) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getScanStatus", api.GetScanStatus) - h(r, "startScan", api.StartScan) + h(r.With(adminOnly), "startScan", api.StartScan) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -195,10 +195,13 @@ func (api *Router) routes() http.Handler { }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) - h(r, "createInternetRadioStation", api.CreateInternetRadio) - h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) h(r, "getInternetRadioStations", api.GetInternetRadios) - h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + r.Group(func(r chi.Router) { + r.Use(adminOnly) + h(r, "createInternetRadioStation", api.CreateInternetRadio) + h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + }) }) if conf.Server.EnableSharing { r.Group(func(r chi.Router) { diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index bac27f821..e6f64456d 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -40,10 +40,6 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorGeneric, "Internal error") } - if !loggedUser.IsAdmin { - return nil, newError(responses.ErrorAuthorizationFail) - } - p := req.Params(r) fullScan := p.BoolOr("fullScan", false) diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index c62c156bc..771fc3352 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -23,29 +23,6 @@ var _ = Describe("LibraryScanning", func() { }) Describe("StartScan", func() { - It("requires admin authentication", func() { - // Create non-admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "user-id", - IsAdmin: false, - }) - - // Create request - r := httptest.NewRequest("GET", "/rest/startScan", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return authorization error - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail)) - }) - It("triggers a full scan with no parameters", func() { // Create admin user ctx := request.WithUser(context.Background(), model.User{ diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 5832bb1de..837852d18 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -155,6 +155,23 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } } +func adminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loggedUser, ok := request.UserFrom(r.Context()) + if !ok { + sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) + return + } + + if !loggedUser.IsAdmin { + sendError(w, r, newError(responses.ErrorAuthorizationFail)) + return + } + + next.ServeHTTP(w, r) + }) +} + func validateCredentials(user *model.User, pass, token, salt, jwt string) error { valid := false diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index aba14a0aa..3f8c07a56 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -308,6 +308,36 @@ var _ = Describe("Middlewares", func() { }) }) + Describe("AdminOnly", func() { + It("passes admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + }) + + It("rejects non-admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="50"`)) + Expect(next.called).To(BeFalse()) + }) + + It("returns an internal error when user is missing from context", func() { + r := newGetRequest() + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="0"`)) + Expect(next.called).To(BeFalse()) + }) + }) + Describe("GetPlayer", func() { var mockedPlayers *mockPlayers var r *http.Request From 545a9ecc3c52f6523c10dbd178ca5f94f55c4288 Mon Sep 17 00:00:00 2001 From: nenadjokic <nenad.jokic@me.com> Date: Tue, 19 May 2026 19:50:34 +0200 Subject: [PATCH 246/596] fix(ui): update Serbian translation (#5444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): correct grammar errors in Serbian (sr) translation Eight objective grammatical / lexical errors in `resources/i18n/sr.json`. No stylistic or strategic re-wording — only corrections where the current string is grammatically wrong, contains a non-word, or breaks plural / case agreement. | Key | Before | After | Why | | --- | --- | --- | --- | | `resources.song.fields.bitDepth` | `Битова` | `Битска дубина` | "Битова" is genitive plural of "bit" ("of bits") and drops the "depth" semantics. Adjective+noun shape matches `Битски проток` already used for the adjacent "Bit rate" field | | `resources.song.fields.channels` | `Канала` | `Канали` | "Канала" is genitive plural ("of channels"); a column header needs nominative plural "Канали" | | `resources.radio.name` plural | `Радији` | `Радио-станице` | "Радији" is not a valid plural of "Радио" in Serbian. The standard plural for radio stations is "Радио-станице" | | `ra.input.file.upload_*` and `ra.input.image.upload_*` | `Упустите фајлове / слике …` | `Превуците фајлове / слике …` | "Упустити" means "to engage in / to indulge", not "to drop". For drag-and-drop UIs the standard Serbian verb is "Превуците" ("Drag") | | `ra.navigation.prev` | `Претход` | `Претх.` | "Претход" is not a word — looks like a truncated "Претходна" missing the period. Restored as a proper abbreviation | | `about.links.featureRequests` | `Захтеви за функцијама` | `Захтеви за функције` | Wrong case. Serbian "захтев за X" takes accusative ("захтев за помоћ"), not instrumental ("за функцијама") | | `player.clickToPauseText` / `clickToPlayText` | `Кликни за паузирање / пуштање` | `Кликните за паузирање / пуштање` | The rest of the file uses formal plural imperative ("Кликните…"). Only these two used the singular informal "Кликни", which broke the consistent register | JSON validated with `python3 -m json.tool`. No keys added, removed, or re-ordered — diff is purely value substitution. Stylistic and lexical modernization (e.g. "Уметник" → "Извођач" for music artist, filling the missing `library` / `plugin` / `nowPlaying` blocks added in upstream en.json) intentionally left for a follow-up PR after this baseline of objective fixes lands. * fix(i18n): fill missing keys in Serbian (sr) translation — 100% coverage The Serbian translation was at 70% of upstream `en.json` (389 of 553 keys). This commit fills all 164 missing keys, bringing coverage to 100%. The file is also re-ordered to match `en.json`'s key sequence so that future translation drift is easy to detect by diffing the two files side-by-side. This is the same regeneration pattern used by the previous maintainer's PR #3941. ## What was missing | Block | Keys | Notes | | --- | ---: | --- | | `resources.plugin` | 58 | Whole Plugin system block — settings, config schema, permissions, notifications | | `resources.library` | 43 | Whole Library management block — fields, scan actions, validation, notifications | | `about.config` + `about.tabs` | 12 | Configuration export feature (TOML) | | `message.*` | 11 | Cover-art upload/remove + Instant Mix + remove-all-missing | | `resources.song` | 9 | composer, sample rate, gain fields, instant mix, show-in-playlist | | `resources.playlist` | 6 | search-or-create UX, save-queue-to-playlist | | `resources.artist` | 5 | top songs / shuffle / radio actions, missing field, maincredit role | | `resources.user` | 5 | Per-user library access controls | | `menu.librarySelector` | 4 | Multi-library selector | | `activity` | 4 | selectiveScan, scanType, status, elapsedTime | | `nowPlaying` | 3 | Now Playing widget (title / empty / minutesAgo plural) | | `resources.album` | 2 | libraryName, missing | | `resources.missing` | 2 | remove_all action, libraryName field | ## Translation conventions followed - Stuck with the existing terminology already in `sr.json` for consistency — e.g. `Уметник` for "Artist", `Плејлиста` for "Playlist", `Жетон` for "Token". Whether `Уметник` → `Извођач` (music-context "performer") is a worthwhile rename is a separate question that deserves its own PR with a focused review surface; not in scope here. - Cyrillic throughout (matches `languageName: "српски"`). - Variable interpolation (`%{var}`) preserved exactly. - Pluralisation separator (` |||| `) preserved on plural-aware keys (`nowPlaying.minutesAgo`, `resources.library.name`, `resources.plugin.name`, `resources.artist.roles.maincredit`). ## Validation - JSON validated with `python3 -m json.tool` - Key-count parity check: 553 keys in `en.json` → 553 keys in `sr.json`, zero missing, zero extra. - Diff is +408 / -200 (line moves due to canonical ordering plus the net 164 new translations). All existing translations preserved verbatim. ## Builds on PR #5444 This branch sits on top of `i18n-sr-grammar-fixes` (PR #5444). If that PR merges first, this one auto-rebases cleanly. If this one merges first, PR #5444 has trivial conflicts in the same 7 strings (all already resolved here as part of regeneration). --- resources/i18n/sr.json | 654 +++++++++++++++++++++++++++-------------- 1 file changed, 431 insertions(+), 223 deletions(-) diff --git a/resources/i18n/sr.json b/resources/i18n/sr.json index 1cf7e39e7..cf0fc5d2f 100644 --- a/resources/i18n/sr.json +++ b/resources/i18n/sr.json @@ -4,45 +4,54 @@ "song": { "name": "Песма |||| Песме", "fields": { - "album": "Албум", "albumArtist": "Уметник албума", - "artist": "Уметник", - "bitDepth": "Битова", - "bitRate": "Битски проток", - "bpm": "BPM", - "channels": "Канала", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "discSubtitle": "Поднаслов диска", "duration": "Трајање", + "trackNumber": "#", + "playCount": "Пуштано", + "title": "Наслов", + "artist": "Уметник", + "composer": "Композитор", + "album": "Албум", + "path": "Путања фајла", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "size": "Величина фајла", + "updatedAt": "Ажурирано", + "bitRate": "Битски проток", + "bitDepth": "Битска дубина", + "sampleRate": "Учестаност узорковања", + "albumGain": "Појачање албума", + "trackGain": "Појачање нумере", + "channels": "Канали", + "disc": "Диск %{discNumber}", + "discSubtitle": "Поднаслов диска", + "starred": "Омиљено", + "comment": "Коментар", + "rating": "Рејтинг", + "quality": "Квалитет", + "bpm": "BPM", + "playDate": "Последње пуштано", + "createdAt": "Датум додавања", "grouping": "Груписање", - "mappedTags": "Мапиране ознаке", "mood": "Расположење", "participants": "Додатни учесници", - "path": "Путања фајла", - "playCount": "Пуштано", - "playDate": "Последње пуштано", - "quality": "Квалитет", - "rating": "Рејтинг", - "rawTags": "Сирове ознаке", - "size": "Величина фајла", - "starred": "Омиљено", "tags": "Додатне ознаке", - "title": "Наслов", - "trackNumber": "#", - "updatedAt": "Ажурирано", - "year": "Година" + "mappedTags": "Мапиране ознаке", + "rawTags": "Сирове ознаке", + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", - "playNext": "Пусти наредно", "playNow": "Пусти одмах", - "shuffleAll": "Измешај све" + "addToPlaylist": "Додај у плејлисту", + "showInPlaylist": "Прикажи у плејлисти", + "shuffleAll": "Измешај све", + "download": "Преузми", + "playNext": "Пусти наредно", + "info": "Прикажи инфо", + "instantMix": "Инстант микс" } }, "album": { @@ -50,46 +59,48 @@ "fields": { "albumArtist": "Уметник албума", "artist": "Уметник", - "catalogNum": "Каталошки број", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "date": "Датум снимања", "duration": "Трајање", + "songCount": "Песме", + "playCount": "Пуштано", + "size": "Величина", + "name": "Назив", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "date": "Датум снимања", + "originalDate": "Оригинално", + "releaseDate": "Објављено", + "releases": "Издање|||| Издања", + "released": "Објављено", + "updatedAt": "Ажурирано", + "comment": "Коментар", + "rating": "Рејтинг", + "createdAt": "Датум додавања", + "recordLabel": "Издавачка кућа", + "catalogNum": "Каталошки број", + "releaseType": "Тип", "grouping": "Груписање", "media": "Медијум", "mood": "Расположење", - "name": "Назив", - "originalDate": "Оригинално", - "playCount": "Пуштано", - "rating": "Рејтинг", - "recordLabel": "Издавачка кућа", - "releaseDate": "Објављено", - "releaseType": "Тип", - "released": "Објављено", - "releases": "Издање|||| Издања", - "size": "Величина", - "songCount": "Песме", - "updatedAt": "Ажурирано", - "year": "Година" + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", - "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", "playAll": "Пусти", "playNext": "Пусти наредно", + "addToQueue": "Пусти касније", "share": "Дели", - "shuffle": "Измешај" + "shuffle": "Измешај", + "addToPlaylist": "Додај у плејлисту", + "download": "Преузми", + "info": "Прикажи инфо" }, "lists": { "all": "Све", - "mostPlayed": "Најчешће пуштано", "random": "Насумично", "recentlyAdded": "Додато недавно", "recentlyPlayed": "Пуштано недавно", + "mostPlayed": "Најчешће пуштано", "starred": "Омиљено", "topRated": "Најбоље рангирано" } @@ -97,116 +108,136 @@ "artist": { "name": "Уметник |||| Уметници", "fields": { - "albumCount": "Број албума", - "genre": "Жанр", "name": "Назив", + "albumCount": "Број албума", + "songCount": "Број песама", + "size": "Величина", "playCount": "Пуштано", "rating": "Рејтинг", + "genre": "Жанр", "role": "Улога", - "size": "Величина", - "songCount": "Број песама" + "missing": "Недостаје" }, "roles": { "albumartist": "Уметник албума |||| Уметници албума", - "arranger": "Аранжер |||| Аранжери", "artist": "Уметник |||| Уметници", "composer": "Композитор |||| Композитори", "conductor": "Диригент |||| Диригенти", - "director": "Режисер |||| Режисери", - "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", - "engineer": "Инжењер |||| Инжењери", "lyricist": "Текстописац |||| Текстописци", - "mixer": "Миксер |||| Миксери", - "performer": "Извођач |||| Извођачи", + "arranger": "Аранжер |||| Аранжери", "producer": "Продуцент |||| Продуценти", - "remixer": "Ремиксер |||| Ремиксери" + "director": "Режисер |||| Режисери", + "engineer": "Инжењер |||| Инжењери", + "mixer": "Миксер |||| Миксери", + "remixer": "Ремиксер |||| Ремиксери", + "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", + "performer": "Извођач |||| Извођачи", + "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници" + }, + "actions": { + "topSongs": "Најбоље песме", + "shuffle": "Измешај", + "radio": "Радио" } }, "user": { "name": "Корисник |||| Корисници", "fields": { - "changePassword": "Измени лозинку?", - "createdAt": "Креирана", - "currentPassword": "Текућа лозинка", + "userName": "Корисничко име", "isAdmin": "Да ли је Админ", - "lastAccessAt": "Последњи приступ", "lastLoginAt": "Последња пријава", - "name": "Назив", - "newPassword": "Нова лозинка", - "password": "Лозинка", - "token": "Жетон", + "lastAccessAt": "Последњи приступ", "updatedAt": "Ажурирано", - "userName": "Корисничко име" + "name": "Назив", + "password": "Лозинка", + "createdAt": "Креирана", + "changePassword": "Измени лозинку?", + "currentPassword": "Текућа лозинка", + "newPassword": "Нова лозинка", + "token": "Жетон", + "libraries": "Библиотеке" }, "helperTexts": { - "name": "Измене вашег имена ће постати видљиве након следеће пријаве" + "name": "Измене вашег имена ће постати видљиве након следеће пријаве", + "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке" }, "notifications": { "created": "Корисник креиран", - "deleted": "Корисник обрисан", - "updated": "Корисник ажуриран" + "updated": "Корисник ажуриран", + "deleted": "Корисник обрисан" + }, + "validation": { + "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори" }, "message": { + "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.", "clickHereForToken": "Кликните овде да преузмете свој жетон", - "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон." + "selectAllLibraries": "Изабери све библиотеке", + "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама" } }, "player": { "name": "Плејер |||| Плејери", "fields": { - "client": "Клијент", - "lastSeen": "Последњи пут виђен", - "maxBitRate": "Макс. битски проток", "name": "Назив", - "reportRealPath": "Пријављуј реалну путању", - "scrobbleEnabled": "Шаљи скроблове на спољне сервисе", "transcodingId": "Транскодирање", - "userName": "Корисничко име" + "maxBitRate": "Макс. битски проток", + "client": "Клијент", + "userName": "Корисничко име", + "lastSeen": "Последњи пут виђен", + "reportRealPath": "Пријављуј реалну путању", + "scrobbleEnabled": "Шаљи скроблове на спољне сервисе" } }, "transcoding": { "name": "Транскодирање |||| Транскодирања", "fields": { - "command": "Команда", - "defaultBitRate": "Подразумевани битски проток", "name": "Назив", - "targetFormat": "Циљни формат" + "targetFormat": "Циљни формат", + "defaultBitRate": "Подразумевани битски проток", + "command": "Команда" } }, "playlist": { "name": "Плејлиста |||| Плејлисте", "fields": { - "comment": "Коментар", - "createdAt": "Креирана", - "duration": "Трајање", "name": "Назив", + "duration": "Трајање", "ownerName": "Власник", - "path": "Увоз из", "public": "Јавна", + "updatedAt": "Ажурирано", + "createdAt": "Креирана", "songCount": "Песме", + "comment": "Коментар", "sync": "Ауто-увоз", - "updatedAt": "Ажурирано" + "path": "Увоз из" }, "actions": { + "selectPlaylist": "Изабери плејлисту", "addNewPlaylist": "Креирај „%{name}”", "export": "Извези", - "makePrivate": "Учини приватном", + "saveQueue": "Сачувај ред у плејлисту", "makePublic": "Учини јавном", - "selectPlaylist": "Изабери плејлисту" + "makePrivate": "Учини приватном", + "searchOrCreate": "Претражите плејлисте или унесите назив за нову…", + "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту", + "removeFromSelection": "Уклони из избора" }, "message": { "duplicate_song": "Додај дуплиране песме", - "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?" + "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?", + "noPlaylistsFound": "Нема пронађених плејлиста", + "noPlaylists": "Нема доступних плејлиста" } }, "radio": { - "name": "Радио |||| Радији", + "name": "Радио |||| Радио-станице", "fields": { - "createdAt": "Креирана", - "homePageUrl": "URL почетне странице", "name": "Назив", "streamUrl": "URL тока", - "updatedAt": "Ажурирано" + "homePageUrl": "URL почетне странице", + "updatedAt": "Ажурирано", + "createdAt": "Креирана" }, "actions": { "playNow": "Пусти одмах" @@ -215,18 +246,18 @@ "share": { "name": "Дељење |||| Дељења", "fields": { - "contents": "Садржај", - "createdAt": "Креирано", + "username": "Поделио", + "url": "URL", "description": "Опис", "downloadable": "Допушта се преузимање?", + "contents": "Садржај", "expiresAt": "Истиче", - "format": "Формат", "lastVisitedAt": "Последњи пут посећено", + "visitCount": "Број посета", + "format": "Формат", "maxBitRate": "Макс. битски проток", "updatedAt": "Ажурирано", - "url": "URL", - "username": "Поделио", - "visitCount": "Број посета" + "createdAt": "Креирано" }, "notifications": {}, "actions": {} @@ -237,111 +268,246 @@ "fields": { "path": "Путања", "size": "Величина", + "libraryName": "Библиотека", "updatedAt": "Нестао дана" }, "actions": { - "remove": "Уклони" + "remove": "Уклони", + "remove_all": "Уклони све" }, "notifications": { "removed": "Фајл који недостаје, или више њих, је уклоњен" } + }, + "library": { + "name": "Библиотека |||| Библиотеке", + "fields": { + "name": "Назив", + "path": "Путања", + "remotePath": "Удаљена путања", + "lastScanAt": "Последње скенирање", + "songCount": "Песме", + "albumCount": "Албуми", + "artistCount": "Уметници", + "totalSongs": "Песме", + "totalAlbums": "Албуми", + "totalArtists": "Уметници", + "totalFolders": "Фасцикле", + "totalFiles": "Фајлови", + "totalMissingFiles": "Фајлови који недостају", + "totalSize": "Укупна величина", + "totalDuration": "Трајање", + "defaultNewUsers": "Подразумевано за нове кориснике", + "createdAt": "Креирана", + "updatedAt": "Ажурирана" + }, + "sections": { + "basic": "Основне информације", + "statistics": "Статистика" + }, + "actions": { + "scan": "Скенирај библиотеку", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "manageUsers": "Управљај приступом корисника", + "viewDetails": "Прикажи детаље" + }, + "notifications": { + "created": "Библиотека је успешно креирана", + "updated": "Библиотека је успешно ажурирана", + "deleted": "Библиотека је успешно обрисана", + "scanStarted": "Скенирање библиотеке је покренуто", + "quickScanStarted": "Брзо скенирање је покренуто", + "fullScanStarted": "Комплетно скенирање је покренуто", + "scanError": "Грешка при покретању скенирања. Проверите дневнике.", + "scanCompleted": "Скенирање библиотеке је завршено" + }, + "validation": { + "nameRequired": "Назив библиотеке је обавезан", + "pathRequired": "Путања библиотеке је обавезна", + "pathNotDirectory": "Путања библиотеке мора да буде фасцикла", + "pathNotFound": "Путања библиотеке није пронађена", + "pathNotAccessible": "Путања библиотеке није доступна", + "pathInvalid": "Неисправна путања библиотеке" + }, + "messages": { + "deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.", + "scanInProgress": "Скенирање је у току…", + "noLibrariesAssigned": "Овом кориснику нема додељених библиотека" + } + }, + "plugin": { + "name": "Додатак |||| Додаци", + "fields": { + "id": "ИД", + "name": "Назив", + "description": "Опис", + "version": "Верзија", + "author": "Аутор", + "website": "Веб-сајт", + "permissions": "Дозволе", + "enabled": "Омогућено", + "status": "Статус", + "path": "Путања", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Ажурирано", + "createdAt": "Инсталирано", + "configKey": "Кључ", + "configValue": "Вредност", + "allUsers": "Дозволи свим корисницима", + "selectedUsers": "Изабрани корисници", + "allLibraries": "Дозволи све библиотеке", + "selectedLibraries": "Изабране библиотеке", + "allowWriteAccess": "Дозволи приступ за упис" + }, + "sections": { + "status": "Статус", + "info": "Информације о додатку", + "configuration": "Конфигурација", + "manifest": "Манифест", + "usersPermission": "Дозволе корисника", + "libraryPermission": "Дозволе библиотеке" + }, + "status": { + "enabled": "Омогућено", + "disabled": "Онемогућено" + }, + "actions": { + "enable": "Омогући", + "disable": "Онемогући", + "disabledDueToError": "Поправите грешку пре омогућавања", + "disabledUsersRequired": "Изаберите кориснике пре омогућавања", + "disabledLibrariesRequired": "Изаберите библиотеке пре омогућавања", + "addConfig": "Додај конфигурацију", + "rescan": "Поново скенирај" + }, + "notifications": { + "enabled": "Додатак је омогућен", + "disabled": "Додатак је онемогућен", + "updated": "Додатак је ажуриран", + "error": "Грешка при ажурирању додатка" + }, + "validation": { + "invalidJson": "Конфигурација мора да буде исправан JSON" + }, + "messages": { + "configHelp": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.", + "configValidationError": "Провера исправности конфигурације није успела:", + "schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.", + "clickPermissions": "Кликните на дозволу за детаље", + "noConfig": "Конфигурација није постављена", + "allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.", + "noUsers": "Нема изабраних корисника", + "permissionReason": "Разлог", + "usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.", + "allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.", + "noLibraries": "Нема изабраних библиотека", + "librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.", + "allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.", + "requiredHosts": "Потребни хостови" + }, + "placeholders": { + "configKey": "кључ", + "configValue": "вредност" + } } }, "ra": { "auth": { - "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", - "buttonCreateAdmin": "Креирај админа", + "welcome1": "Хвала што сте инсталирали Navidrome!", + "welcome2": "За почетак, креирајте админ корисника", "confirmPassword": "Потврдите лозинку", - "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите", - "logout": "Одјави се", + "buttonCreateAdmin": "Креирај админа", + "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", + "user_menu": "Профил", + "username": "Корисничко име", "password": "Лозинка", "sign_in": "Пријави се", "sign_in_error": "Потврда идентитета није успела, покушајте поново", - "user_menu": "Профил", - "username": "Корисничко име", - "welcome1": "Хвала што сте инсталирали Navidrome!", - "welcome2": "За почетак, креирајте админ корисника" + "logout": "Одјави се", + "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите" }, "validation": { - "email": "Мора да буде исправна и-мејл адреса", "invalidChars": "Молимо вас да користите само слова и цифре", - "maxLength": "Мора да буде %{max} карактера или мање", - "maxValue": "Мора да буде %{max} или мање", - "minLength": "Мора да буде барем %{min} карактера", - "minValue": "Мора да буде барем %{min}", - "number": "Мора да буде број", - "oneOf": "Мора да буде једно од: %{options}", "passwordDoesNotMatch": "Лозинка се не подудара", - "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "required": "Неопходно", + "minLength": "Мора да буде барем %{min} карактера", + "maxLength": "Мора да буде %{max} карактера или мање", + "minValue": "Мора да буде барем %{min}", + "maxValue": "Мора да буде %{max} или мање", + "number": "Мора да буде број", + "email": "Мора да буде исправна и-мејл адреса", + "oneOf": "Мора да буде једно од: %{options}", + "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "unique": "Мора да буде јединствено", "url": "Мора да буде исправна URL адреса" }, "action": { - "add": "Додај", "add_filter": "Додај филтер", + "add": "Додај", "back": "Иди назад", "bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки", "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Откажи", "clear_input_value": "Обриши вредност", "clone": "Клонирај", - "close": "Затвори", - "close_menu": "Затвори мени", "confirm": "Потврди", "create": "Креирај", "delete": "Обриши", - "download": "Преузми", "edit": "Уреди", - "expand": "Развиј", "export": "Извези", "list": "Листа", - "open_menu": "Отвори мени", "refresh": "Освежи", - "remove": "Уклони", "remove_filter": "Уклони овај филтер", + "remove": "Уклони", "save": "Сачувај", "search": "Тражи", - "share": "Дели", "show": "Прикажи", - "skip": "Прескочи", "sort": "Сортирај", "undo": "Поништи", - "unselect": "Уклони избор" + "expand": "Развиј", + "close": "Затвори", + "open_menu": "Отвори мени", + "close_menu": "Затвори мени", + "unselect": "Уклони избор", + "skip": "Прескочи", + "share": "Дели", + "download": "Преузми" }, "boolean": { - "false": "Не", - "true": "Да" + "true": "Да", + "false": "Не" }, "page": { "create": "Креирај %{name}", "dashboard": "Контролна табла", "edit": "%{name} #%{id}", - "empty": "Још увек нема %{name}.", "error": "Нешто је пошло наопако", - "invite": "Желите ли да се дода?", "list": "%{name}", "loading": "Учитава се", "not_found": "Није пронађено", - "show": "%{name} #%{id}" + "show": "%{name} #%{id}", + "empty": "Још увек нема %{name}.", + "invite": "Желите ли да се дода?" }, "input": { "file": { - "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете." + "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете." }, "image": { - "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете." - }, - "password": { - "toggle_hidden": "Прикажи лозинку", - "toggle_visible": "Сакриј лозинку" + "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете." }, "references": { "all_missing": "Не могу да се нађу подаци референци.", "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", "single_missing": "Изгледа да придружена референца више није доступна." + }, + "password": { + "toggle_visible": "Сакриј лозинку", + "toggle_hidden": "Прикажи лозинку" } }, "message": { @@ -357,161 +523,203 @@ "loading": "Страница се учитава, сачекајте мало", "no": "Не", "not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.", - "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?", - "yes": "Да" + "yes": "Да", + "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?" }, "navigation": { - "next": "Наредна", - "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "no_results": "Није пронађен ниједан резултат", - "page_out_from_begin": "Не може да се иде испред странице 1", - "page_out_from_end": "Не може да се иде након последње странице", + "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "page_out_of_boundaries": "Број странице %{page} је ван опсега", + "page_out_from_end": "Не може да се иде након последње странице", + "page_out_from_begin": "Не може да се иде испред странице 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", "page_rows_per_page": "Ставки по страници:", - "prev": "Претход", + "next": "Наредна", + "prev": "Претх.", "skip_nav": "Прескочи на садржај" }, "notification": { - "bad_item": "Неисправни елемент", - "canceled": "Акција је отказана", + "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано", "created": "Елемент је креиран", - "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", - "http_error": "Грешка у комуникацији са сервером", - "i18n_error": "Не могу да се учитају преводи за наведени језик", + "bad_item": "Неисправни елемент", "item_doesnt_exist": "Елемент не постоји", + "http_error": "Грешка у комуникацији са сервером", + "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", + "i18n_error": "Не могу да се учитају преводи за наведени језик", + "canceled": "Акција је отказана", "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", - "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.", - "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано" + "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор." }, "toggleFieldsMenu": { "columnsToDisplay": "Колоне за приказ", - "grid": "Мрежа", "layout": "Распоред", + "grid": "Мрежа", "table": "Табела" } }, "message": { - "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", - "delete_user_title": "Брисање корисника ’%{name}’", - "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", - "downloadOriginalFormat": "Преузми у оригиналном формату", - "lastfmLink": "Прочитај још...", - "lastfmLinkFailure": "Last.fm није могао да се повеже", - "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", - "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", - "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", - "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", - "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", - "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", - "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", - "noPlaylistsAvailable": "Није доступна ниједна", + "uploadCover": "Отпреми омот", + "removeCover": "Уклони омот", + "coverUploaded": "Омот је ажуриран", + "coverRemoved": "Омот је уклоњен", + "coverUploadError": "Грешка при отпремању омота", + "coverRemoveError": "Грешка при уклањању омота", "note": "НАПОМЕНА", + "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", + "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.", + "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", + "noSimilarSongsFound": "Нису пронађене сличне песме", + "startingInstantMix": "Учитава се инстант микс…", + "noTopSongsFound": "Нису пронађене најбоље песме", + "noPlaylistsAvailable": "Није доступна ниједна", + "delete_user_title": "Брисање корисника ’%{name}’", + "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", + "remove_missing_title": "Уклони фајлове који недостају", + "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "remove_all_missing_title": "Уклони све фајлове који недостају", + "remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", "notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења", "notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола", + "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", + "lastfmLinkFailure": "Last.fm није могао да се повеже", + "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", + "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", + "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", + "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", "openIn": { "lastfm": "Отвори у Last.fm", "musicbrainz": "Отвори у MusicBrainz" }, - "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", - "remove_missing_title": "Уклони фајлове који недостају", + "lastfmLink": "Прочитај још...", + "shareOriginalFormat": "Подели у оригиналном формату", + "shareDialogTitle": "Подели %{resource} ’%{name}’", "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", - "shareDialogTitle": "Подели %{resource} ’%{name}’", - "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", - "shareOriginalFormat": "Подели у оригиналном формату", "shareSuccess": "URL је копиран у клипборд: %{url}", - "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", - "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", - "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања." + "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", + "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", + "downloadOriginalFormat": "Преузми у оригиналном формату" }, "menu": { - "about": "О", - "albumList": "Албуми", "library": "Библиотека", + "librarySelector": { + "allLibraries": "Све библиотеке (%{count})", + "multipleLibraries": "%{selected} од %{total} библиотека", + "selectLibraries": "Изабери библиотеке", + "none": "Ниједна" + }, + "settings": "Подешавања", + "version": "Верзија", + "theme": "Тема", "personal": { "name": "Лична", "options": { + "theme": "Тема", + "language": "Језик", "defaultView": "Подразумевани поглед", "desktop_notifications": "Десктоп обавештења", - "gain": { - "album": "Користи Album појачање", - "none": "Искључено", - "track": "Користи Track појачање" - }, - "language": "Језик", "lastfmNotConfigured": "Није подешен Last.fm API-кључ", "lastfmScrobbling": "Скроблуј на Last.fm", "listenBrainzScrobbling": "Скроблуј на ListenBrainz", - "preAmp": "ReplayGain претпојачање (dB)", "replaygain": "ReplayGain режим", - "theme": "Тема" + "preAmp": "ReplayGain претпојачање (dB)", + "gain": { + "none": "Искључено", + "album": "Користи Album појачање", + "track": "Користи Track појачање" + } } }, + "albumList": "Албуми", "playlists": "Плејлисте", - "settings": "Подешавања", "sharedPlaylists": "Дељене плејлисте", - "theme": "Тема", - "version": "Верзија" + "about": "О" }, "player": { - "clickToDeleteText": "Кликните да обришете %{name}", - "clickToPauseText": "Кликни за паузирање", - "clickToPlayText": "Кликни за пуштање", + "playListsText": "Ред за пуштање", + "openText": "Отвори", "closeText": "Затвори", + "notContentText": "Нема музике", + "clickToPlayText": "Кликните за пуштање", + "clickToPauseText": "Кликните за паузирање", + "nextTrackText": "Наредна нумера", + "previousTrackText": "Претходна нумера", + "reloadText": "Поново учитај", + "volumeText": "Јачина", + "toggleLyricText": "Укљ./Искљ. стихове", + "toggleMiniModeText": "Умањи", "destroyText": "Уништи", "downloadText": "Преузми", + "removeAudioListsText": "Обриши аудио листе", + "clickToDeleteText": "Кликните да обришете %{name}", "emptyLyricText": "Нема стихова", - "nextTrackText": "Наредна нумера", - "notContentText": "Нема музике", - "openText": "Отвори", - "playListsText": "Ред за пуштање", "playModeText": { "order": "По редоследу", "orderLoop": "Понови", - "shufflePlay": "Измешај", - "singleLoop": "Понови једну" - }, - "previousTrackText": "Претходна нумера", - "reloadText": "Поново учитај", - "removeAudioListsText": "Обриши аудио листе", - "toggleLyricText": "Укљ./Искљ. стихове", - "toggleMiniModeText": "Умањи", - "volumeText": "Јачина" + "singleLoop": "Понови једну", + "shufflePlay": "Измешај" + } }, "about": { "links": { - "featureRequests": "Захтеви за функцијама", "homepage": "Почетна страница", + "source": "Изворни кôд", + "featureRequests": "Захтеви за функције", + "lastInsightsCollection": "Последња колекција увида", "insights": { "disabled": "Искључено", "waiting": "Чека се" - }, - "lastInsightsCollection": "Последња колекција увида", - "source": "Изворни кôд" + } + }, + "tabs": { + "about": "О програму", + "config": "Конфигурација" + }, + "config": { + "configName": "Назив конфигурације", + "environmentVariable": "Променљива окружења", + "currentValue": "Тренутна вредност", + "configurationFile": "Конфигурациони фајл", + "exportToml": "Извези конфигурацију (TOML)", + "downloadToml": "Преузми конфигурацију (TOML)", + "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату", + "exportFailed": "Копирање конфигурације није успело", + "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)", + "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама" } }, "activity": { - "fullScan": "Комплетно скенирање", - "quickScan": "Брзо скенирање", - "serverDown": "ВАН МРЕЖЕ", - "serverUptime": "Сервер се извршава", "title": "Активност", - "totalScanned": "Укупан број скенираних фолдера" + "totalScanned": "Укупан број скенираних фолдера", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "selectiveScan": "Селективно", + "serverUptime": "Сервер се извршава", + "serverDown": "ВАН МРЕЖЕ", + "scanType": "Последње скенирање", + "status": "Грешка скенирања", + "elapsedTime": "Протекло време" + }, + "nowPlaying": { + "title": "Сада се пушта", + "empty": "Ништа се не пушта", + "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута" }, "help": { "title": "Navidrome пречице", "hotkeys": { - "current_song": "Иди на текућу песму", - "next_song": "Наредна песма", - "prev_song": "Претходна песма", "show_help": "Прикажи ову помоћ", - "toggle_love": "Додај ову нумеру у омиљене", "toggle_menu": "Укљ./Искљ. бочну траку менија", "toggle_play": "Пусти / Паузирај", + "prev_song": "Претходна песма", + "next_song": "Наредна песма", + "current_song": "Иди на текућу песму", + "vol_up": "Појачај", "vol_down": "Утишај", - "vol_up": "Појачај" + "toggle_love": "Додај ову нумеру у омиљене" } } } From efe9291db0159b925a03e8daed5e5e9b1b55fe36 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 19 May 2026 18:02:29 -0300 Subject: [PATCH 247/596] refactor: multiple syntax updates for Go 1.26 Signed-off-by: Deluan <deluan@navidrome.org> --- .gitignore | 3 +- adapters/gotaglib/end_to_end_test.go | 10 ++-- core/external/provider.go | 4 +- core/external/provider_artistimage_test.go | 6 +-- .../external/provider_updatealbuminfo_test.go | 5 +- .../provider_updateartistinfo_test.go | 7 ++- core/ffmpeg/ffmpeg.go | 3 +- core/inspect.go | 3 +- core/lyrics/lyrics_test.go | 7 ++- core/lyrics/sources_test.go | 13 +++-- core/playback/mpv/mpv.go | 3 +- core/playlists/parse_nsp.go | 3 +- core/playlists/playlists_test.go | 15 ++---- core/playlists/rest_adapter_test.go | 3 +- core/scrobbler/play_tracker_test.go | 3 +- core/share.go | 4 +- model/artwork_id_test.go | 3 +- model/lyrics_test.go | 52 ++++++++----------- model/metadata/map_mediafile_test.go | 5 +- model/metadata/metadata_test.go | 15 +++--- persistence/artist_repository.go | 3 +- persistence/genre_repository.go | 3 +- persistence/persistence_suite_test.go | 14 ++--- persistence/playqueue_repository.go | 3 +- persistence/radio_repository_test.go | 3 +- plugins/host_kvstore_test.go | 9 ++-- plugins/host_library_test.go | 45 ++++++---------- plugins/host_websocket.go | 3 +- plugins/manifest_test.go | 3 +- plugins/package_test.go | 3 +- scanner/controller.go | 3 +- scanner/external.go | 3 +- server/auth.go | 3 +- server/nativeapi/artists.go | 6 +-- server/nativeapi/queue_test.go | 23 ++++---- server/public/handle_streams_test.go | 6 +-- server/subsonic/api_test.go | 3 +- server/subsonic/browsing.go | 3 +- server/subsonic/helpers.go | 7 ++- server/subsonic/media_retrieval_test.go | 3 +- server/subsonic/playlists.go | 3 +- server/subsonic/responses/responses_test.go | 23 ++++---- server/subsonic/sharing.go | 8 +-- server/subsonic/users.go | 3 +- tests/mock_user_repo.go | 5 +- utils/cache/cached_http_client.go | 3 +- utils/cache/simple_cache.go | 3 +- utils/chrono/meter.go | 4 +- utils/gg/gg.go | 5 -- utils/gg/gg_test.go | 15 +----- utils/req/req.go | 6 +-- 51 files changed, 143 insertions(+), 251 deletions(-) diff --git a/.gitignore b/.gitignore index 73475a53a..fc8eaac69 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ AGENTS.md *.wasm *.ndp openspec/ +.agents go.work* -.worktrees/ \ No newline at end of file +.worktrees/ diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index 4a93f5b83..e7dd18ac1 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -8,7 +8,6 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") - return &mf + return new(metadata.ToMediaFile(1, "folderID")) } BeforeEach(func() { @@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() { Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) }, Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), - Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)), ) }) @@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() { DisplayTitle: "", Lang: code, Line: []model.Line{ - {Start: gg.P(int64(0)), Value: "This is"}, - {Start: gg.P(int64(2500)), Value: secondLine}, + {Start: new(int64(0)), Value: "This is"}, + {Start: new(int64(2500)), Value: secondLine}, }, Offset: nil, Synced: true, diff --git a/core/external/provider.go b/core/external/provider.go index 4f3295cc7..74dab4972 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl return album, err } - album.ExternalInfoUpdatedAt = P(time.Now()) + album.ExternalInfoUpdatedAt = new(time.Now()) album.ExternalUrl = info.URL if info.Description != "" { @@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = P(time.Now()) + artist.ExternalInfoUpdatedAt = new(time.Now()) err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 37d3fd81a..79612d651 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns cached URL and does not call agent when info is not expired", func() { // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - recentTime := time.Now().Add(-1 * time.Minute) cachedArtist := &model.Artist{ ID: "artist-cached", Name: "Cached Artist", LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: &recentTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), } mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") @@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns stale URL and enqueues refresh when info is expired", func() { // Arrange conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - expiredTime := time.Now().Add(-1 * time.Hour) staleArtist := &model.Artist{ ID: "artist-expired", Name: "Expired Artist", LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: &expiredTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), } mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 3dd8a587a..21824c93f 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://cached.com/album", Description: "Cached Desc", LargeImageUrl: "http://cached.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) @@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://expired.com/album", Description: "Expired Desc", LargeImageUrl: "http://expired.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index cc9506d1f..d783128fb 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ExternalUrl: "http://cached.url", Biography: "Cached Bio", LargeImageUrl: "http://cached_large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-similar-present", Name: "Similar Present"}, {ID: "ar-similar-absent", Name: "Similar Absent"}, @@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-expired", Name: "Expired Artist", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), SimilarArtists: model.Artists{ {ID: "ar-exp-similar", Name: "Expired Similar"}, }, @@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-similar-test", Name: "Similar Test Artist", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-sim-present", Name: "Similar Present"}, {ID: "", Name: "Similar Absent Raw"}, diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3225ff150..a3f6cd7d2 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -326,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { errMsg += ": " + stderrOutput diff --git a/core/inspect.go b/core/inspect.go index 751cf063f..01ec33760 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - . "github.com/navidrome/navidrome/utils/gg" ) type InspectOutput struct { @@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: P(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(libraryId, folderId)), } return result, nil diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 7e837782e..9ab732ad1 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,15 +31,15 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), Synced: true, }, } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index b3d502101..d1aefcb5d 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -74,15 +73,15 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), Synced: true, }, })) @@ -122,7 +121,7 @@ var _ = Describe("sources", func() { // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) }) @@ -137,9 +136,9 @@ var _ = Describe("sources", func() { // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) }) diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 035e18dd5..6696eca2a 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error { func (j *Executor) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index 56c80a950..a5b8b7c02 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R } err = json.Unmarshal(input, nsp) if err != nil { - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { + if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok { line, col := getPositionFromOffset(input, syntaxErr.Offset) return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 95d7b3e6a..f849a0a21 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() { It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("denies non-owner, non-admin from updating", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("returns error when playlist not found", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(Equal(model.ErrNotFound)) }) @@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() { It("allows metadata updates on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Smart" - err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 90d22327a..68461b259 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() { It("clears server-managed fields to prevent injection via REST API", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) - now := time.Now() pls := &model.Playlist{ Name: "Legit Playlist", Comment: "A comment", @@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() { Sync: true, UploadedImage: "injected-image-path", ExternalImageURL: "http://evil.example.com/ssrf", - EvaluatedAt: &now, + EvaluatedAt: new(time.Now()), } _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 684b887fe..5383244cd 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -1133,8 +1133,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession if f.Error != nil { return f.Error } - uid := info.UserId - f.userID.Store(&uid) + f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) return nil } diff --git a/core/share.go b/core/share.go index a6d06a018..eb9b63ae9 100644 --- a/core/share.go +++ b/core/share.go @@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error if !expiresAt.IsZero() && expiresAt.Before(time.Now()) { return nil, model.ErrExpired } - share.LastVisitedAt = P(time.Now()) + share.LastVisitedAt = new(time.Now()) share.VisitCount++ err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") @@ -95,7 +95,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { } s.ID = id if V(s.ExpiresAt).IsZero() { - s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration)) + s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..ad66f7bb5 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -11,8 +11,7 @@ import ( var _ = Describe("ArtworkID", func() { Describe("NewArtworkID()", func() { It("creates a valid parseable ArtworkID", func() { - now := time.Now() - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now) + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 382976872..644b85ad2 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -8,14 +8,13 @@ import ( var _ = Describe("ToLyrics", func() { It("should parse tags with spaces", func() { - num := int64(1551) lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Lang).To(Equal("eng")) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.DisplayArtist).To(Equal("An artist")) Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(&num)) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) }) It("Should ignore bad offset", func() { @@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() { }) It("should accept lines with no text and weird times", func() { - a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60) lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Hi there"}, - {Start: &b, Value: ""}, - {Start: &c, Value: "Test"}, - {Start: &d, Value: "late"}, + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, })) }) It("Should support multiple timestamps per line", func() { - a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51) lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Repeated"}, - {Start: &c, Value: ""}, - {Start: &d, Value: ""}, + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, })) }) It("Should support parsing multiline string", func() { - a, b := int64(0), int64(10*60*1000+1) lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is\na multiline\n\n[:0] string"}, - {Start: &b, Value: "This is\nalso one"}, + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, })) }) @@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() { }) It("Allows timestamp in middle of line if also at beginning", func() { - a, b := int64(0), int64(1000) lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is [00:00:00] be a synced file"}, - {Start: &b, Value: "Line 2"}, + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, })) }) It("Ignores lines in synchronized lyric prior to first timestamp", func() { - a := int64(0) lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Text"}, + {Start: new(int64(0)), Value: "Text"}, })) }) It("Handles all possible ms cases", func() { - a, b, c := int64(1), int64(10), int64(100) lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "a"}, - {Start: &b, Value: "b"}, - {Start: &c, Value: "c"}, + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, })) }) It("Properly sorts repeated lyrics out of order", func() { - a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51) lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Test"}, - {Start: &c, Value: "Not repeated"}, - {Start: &d, Value: "Repeated"}, - {Start: &e, Value: "Test"}, + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, })) }) }) diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index e3adf3fae..16142f526 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() { expected := model.LyricList{ {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: P(int64(0))}, - {Value: "English SYLT", Start: P(int64(2500))}, + {Value: "This is", Start: new(int64(0))}, + {Value: "English SYLT", Start: new(int64(2500))}, }, Synced: true}, {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 663e306c4..c9acdbce9 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -274,8 +273,8 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.2dB", "1.2dB", gg.P(1.2)), + Entry("0", "0", new(0.0)), + Entry("1.2dB", "1.2dB", new(1.2)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), Entry("NaN", "NaN", nil), @@ -285,9 +284,9 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.0", "1.0", gg.P(1.0)), - Entry("0.5", "0.5", gg.P(0.5)), + Entry("0", "0", new(0.0)), + Entry("1.0", "1.0", new(1.0)), + Entry("0.5", "0.5", new(0.5)), Entry("Invalid dB suffix", "0.7dB", nil), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), @@ -299,8 +298,8 @@ var _ = Describe("Metadata", func() { Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(5.0)), - Entry("-3776", "-3776", gg.P(-9.75)), + Entry("0", "0", new(5.0)), + Entry("-3776", "-3776", new(-9.75)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), ) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index e75a0e58c..cfdc499e0 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -219,7 +218,7 @@ func (r *artistRepository) Exists(id string) (bool, error) { func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} - dba.CreatedAt = P(time.Now()) + dba.CreatedAt = new(time.Now()) dba.UpdatedAt = dba.CreatedAt _, err := r.put(dba.ID, dba, colsToUpdate...) return err diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 53f324bf4..22443284f 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -14,9 +14,8 @@ type genreRepository struct { } func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { - genreFilter := model.TagGenre return &genreRepository{ - baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), + baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)), } } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ebc247d77..abc5c4b6a 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -103,7 +102,7 @@ var ( songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Path: p("kraft/radio/antenna.mp3"), - RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), + RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ ID: "1005", @@ -162,8 +161,6 @@ func p(path string) string { return filepath.FromSlash(path) } -// Initialize test DB -// TODO Load this data setup from file(s) var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) @@ -187,8 +184,7 @@ var _ = BeforeSuite(func() { alr := NewAlbumRepository(ctx, conn).(*albumRepository) for i := range testAlbums { - a := testAlbums[i] - err := alr.Put(&a) + err := alr.Put(new(testAlbums[i])) if err != nil { panic(err) } @@ -196,8 +192,7 @@ var _ = BeforeSuite(func() { arr := NewArtistRepository(ctx, conn) for i := range testArtists { - a := testArtists[i] - err := arr.Put(&a) + err := arr.Put(new(testArtists[i])) if err != nil { panic(err) } @@ -243,8 +238,7 @@ var _ = BeforeSuite(func() { rar := NewRadioRepository(ctx, conn) for i := range testRadios { - r := testRadios[i] - err := rar.Put(&r) + err := rar.Put(new(testRadios[i])) if err != nil { panic(err) } diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index c952b42b1..ba69ec746 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -89,8 +89,7 @@ func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue err := r.queryOne(sel, &res) - q := r.toModel(&res) - return &q, err + return new(r.toModel(&res)), err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 88a31ac49..828af8043 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -34,8 +34,7 @@ var _ = Describe("RadioRepository", func() { } for i := range testRadios { - r := testRadios[i] - err := repo.Put(&r) + err := repo.Put(new(testRadios[i])) if err != nil { panic(err) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 109ae8131..997409146 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -37,8 +37,7 @@ var _ = Describe("KVStoreService", func() { conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing - maxSize := "1KB" - service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) }) @@ -253,8 +252,7 @@ var _ = Describe("KVStoreService", func() { // Close and reopen the service (simulating restart) Expect(service.Close()).To(Succeed()) - maxSize := "1KB" - service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -452,8 +450,7 @@ var _ = Describe("KVStoreService", func() { closeCtx, closeCancel := context.WithCancel(ctx) defer closeCancel() - maxSize := "1KB" - svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) // Insert an expired key so cleanup has work to do diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 67f5f9b0f..eb5b17a02 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -35,8 +35,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetLibrary", func() { It("should return library metadata without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 1, @@ -67,8 +66,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return library metadata with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 2, @@ -93,8 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return error for non-existent library", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) @@ -107,8 +104,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetAllLibraries", func() { It("should return all libraries without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -130,8 +126,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return all libraries with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -152,10 +147,8 @@ var _ = Describe("LibraryService", Ordered, func() { }) Describe("Library Access Filtering", func() { - It("should only return libraries in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should only return libraries in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -173,10 +166,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results[0].Name).To(Equal("Jazz")) }) - It("should return error when getting a library not in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -192,10 +183,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(err.Error()).To(ContainSubstring("not accessible")) }) - It("should allow access to a library in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should allow access to a library in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -211,10 +200,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(result.Name).To(Equal("Jazz")) }) - It("should return empty list when no libraries are allowed and allLibraries is false", func() { - reason := "test" - // No libraries allowed - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl) + It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -229,10 +216,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results).To(HaveLen(0)) }) - It("should return all libraries when allLibraries is true regardless of allowed list", func() { - reason := "test" - // allLibraries=true should ignore the allowed list - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl) + It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 74238a422..eef1e6236 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -302,8 +302,7 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { closeCode := websocket.CloseNoStatusReceived closeReason := "" - var ce *websocket.CloseError - if errors.As(err, &ce) { + if ce, ok := errors.AsType[*websocket.CloseError](err); ok { closeCode = ce.Code closeReason = ce.Text } diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index c45a480eb..0d371d14a 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -140,11 +140,10 @@ var _ = Describe("Manifest", func() { }) It("returns true when threads feature has a reason", func() { - reason := "Required for concurrent processing" m := &Manifest{ Experimental: &Experimental{ Threads: &ThreadsFeature{ - Reason: &reason, + Reason: new("Required for concurrent processing"), }, }, } diff --git a/plugins/package_test.go b/plugins/package_test.go index fa76ddd94..3d3fec022 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -135,12 +135,11 @@ var _ = Describe("ndpPackage", func() { Describe("readManifest", func() { It("should read only the manifest without loading wasm", func() { ndpPath := filepath.Join(tmpDir, "test.ndp") - desc := "A test plugin" manifest := &Manifest{ Name: "Test Plugin", Author: "Test Author", Version: "1.0.0", - Description: &desc, + Description: new("A test plugin"), } wasmBytes := make([]byte, 1024*1024) // 1MB of zeros diff --git a/scanner/controller.go b/scanner/controller.go index 94248ffd0..175b92e26 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -17,7 +17,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/pl" "golang.org/x/time/rate" ) @@ -38,7 +37,7 @@ func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, br devExternalScanner: conf.Server.DevExternalScanner, } if !c.devExternalScanner { - c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) + c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) } return c } diff --git a/scanner/external.go b/scanner/external.go index 393a9278c..7f573fe3e 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -97,8 +97,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) { if err := cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr)) } else { _ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err)) diff --git a/server/auth.go b/server/auth.go index a7edaab0a..6a25f1406 100644 --- a/server/auth.go +++ b/server/auth.go @@ -135,7 +135,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { log.Warn(ctx, "Creating initial user", "user", username) - now := time.Now() caser := cases.Title(language.Und) initialUser := model.User{ ID: id.NewRandom(), @@ -144,7 +143,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password Email: "", NewPassword: password, IsAdmin: true, - LastLoginAt: &now, + LastLoginAt: new(time.Now()), } err := ds.User(ctx).Put(&initialUser) if err != nil { diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 1b78bb93e..daa918d00 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -45,8 +45,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { return err } ar.UploadedImage = filename - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } @@ -65,8 +64,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { return err } ar.UploadedImage = "" - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go index ef971ee68..0aad09718 100644 --- a/server/nativeapi/queue_test.go +++ b/server/nativeapi/queue_test.go @@ -9,7 +9,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,7 +31,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("POST /queue", func() { It("saves the queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -50,7 +49,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("saves an empty queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))} + payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -63,7 +62,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (negative)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -75,7 +74,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (too large)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -97,7 +96,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -166,7 +165,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("PUT /queue", func() { It("updates the queue fields", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}} - payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))} + payload := updateQueuePayload{Current: new(2), Position: new(int64(20))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -184,7 +183,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates only ids", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -198,7 +197,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates ids and current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -213,7 +212,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when new ids invalidate current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -225,7 +224,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when current out of bounds", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} - payload := updateQueuePayload{Current: gg.P(3)} + payload := updateQueuePayload{Current: new(3)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -246,7 +245,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Position: gg.P(int64(10))} + payload := updateQueuePayload{Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 222d5ef1a..f43d75a26 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,7 +88,7 @@ var _ = Describe("encodeMediafileShare", func() { }) It("includes the share ID in the token", func() { - exp := P(time.Now().Add(time.Hour)) + exp := new(time.Now().Add(time.Hour)) s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} token := encodeMediafileShare(s, "mf-999") info, err := decodeStreamInfo(token) @@ -164,8 +163,7 @@ var _ = Describe("handleStream", func() { It("returns 410 when share has been set to expired", func() { shareRepo.ID = "share123" - expired := time.Now().Add(-time.Hour) - shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))} claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreatePublicToken(claims) diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index f3053c8af..a2db4a0af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "golang.org/x/net/context" @@ -136,7 +135,7 @@ var _ = Describe("sendResponse", func() { It("should return a fail response", func() { payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 5b9c4f3c9..817238aaf 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -256,8 +256,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() - child := childFromMediaFile(ctx, *mf) - response.Song = &child + response.Song = new(childFromMediaFile(ctx, *mf)) return response, nil } diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 8a9eb7110..0b18b1bc7 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -217,7 +216,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = P(mf.BirthTime) + child.Created = new(mf.BirthTime) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -346,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() - child.Created = P(albumCreatedAt(al)) + child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -442,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = P(albumCreatedAt(album)) + dir.Created = new(albumCreatedAt(album)) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 27d1edb84..12c0dff56 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -294,7 +294,6 @@ var _ = Describe("MediaRetrievalController", func() { response, err := router.GetLyricsBySongId(r) Expect(err).ToNot(HaveOccurred()) - offset := int64(-100) compareResponses(response.LyricsList, responses.LyricsList{ StructuredLyrics: responses.StructuredLyrics{ { @@ -312,7 +311,7 @@ var _ = Describe("MediaRetrievalController", func() { Value: "You know the rules and so do I", }, }, - Offset: &offset, + Offset: new(int64(-100)), }, }, }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index a8c3da68c..7101f9f15 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -169,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 0b72fab19..3166df875 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -94,11 +93,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]Artist, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = Artist{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", } @@ -133,11 +131,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -158,11 +155,10 @@ var _ = Describe("Responses", func() { Context("with OpenSubsonic data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -211,12 +207,11 @@ var _ = Describe("Responses", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} child := make([]Child, 2) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, + Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), } child[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, @@ -225,7 +220,7 @@ var _ = Describe("Responses", func() { BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -246,7 +241,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } child[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.Directory.Child = child }) @@ -322,7 +317,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -342,7 +337,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } songs[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs @@ -804,7 +799,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.PlayQueueByIndex.Username = "user1" - response.PlayQueueByIndex.CurrentIndex = gg.P(0) + response.PlayQueueByIndex.CurrentIndex = new(0) response.PlayQueueByIndex.Position = 243 response.PlayQueueByIndex.Changed = time.Time{} response.PlayQueueByIndex.ChangedBy = "a_client" diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 9cc8d7097..a9ccfdca4 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -58,12 +58,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), ResourceIDs: strings.Join(ids, ","), } @@ -90,13 +88,11 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ ID: id, Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share) diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 8b7406b60..acf8de3e8 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -46,8 +46,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorAuthorizationFail) } response := newResponse() - user := buildUserResponse(loggedUser) - response.User = &user + response.User = new(buildUserResponse(loggedUser)) return response, nil } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index cc05829f6..7c7dadbc4 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -7,7 +7,6 @@ import ( "time" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" ) func CreateMockUserRepo() *MockedUserRepo { @@ -84,7 +83,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastLoginAt = gg.P(time.Now()) + usr.LastLoginAt = new(time.Now()) return nil } } @@ -94,7 +93,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastAccessAt = gg.P(time.Now()) + usr.LastAccessAt = new(time.Now()) return nil } } diff --git a/utils/cache/cached_http_client.go b/utils/cache/cached_http_client.go index 94d33100b..4eed243dd 100644 --- a/utils/cache/cached_http_client.go +++ b/utils/cache/cached_http_client.go @@ -75,8 +75,7 @@ func (c *HTTPClient) serializeReq(req *http.Request) string { } if req.Body != nil { bodyData, _ := io.ReadAll(req.Body) - bodyStr := base64.StdEncoding.EncodeToString(bodyData) - data.Body = &bodyStr + data.Body = new(base64.StdEncoding.EncodeToString(bodyData)) } j, _ := json.Marshal(&data) return string(j) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index 2f0ff4764..eb3c99995 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -9,7 +9,6 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - . "github.com/navidrome/navidrome/utils/gg" ) type SimpleCache[K comparable, V any] interface { @@ -119,7 +118,7 @@ func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Dur func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() - c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout))) + c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout))) } } diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go index 7b4786ed5..2a249f455 100644 --- a/utils/chrono/meter.go +++ b/utils/chrono/meter.go @@ -2,8 +2,6 @@ package chrono import ( "time" - - . "github.com/navidrome/navidrome/utils/gg" ) // Meter is a simple stopwatch @@ -13,7 +11,7 @@ type Meter struct { } func (m *Meter) Start() { - m.mark = P(time.Now()) + m.mark = new(time.Now()) } func (m *Meter) Stop() time.Duration { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 208fe2952..674cacf20 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -1,11 +1,6 @@ // Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog package gg -// P returns a pointer to the input value -func P[T any](v T) *T { - return &v -} - // V returns the value of the input pointer, or a zero value if the input pointer is nil. func V[T any](p *T) T { if p == nil { diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index 1d6dff484..a2dd8154f 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -16,22 +16,9 @@ func TestGG(t *testing.T) { } var _ = Describe("GG", func() { - Describe("P", func() { - It("returns a pointer to the input value", func() { - v := 123 - Expect(gg.P(123)).To(Equal(&v)) - }) - - It("returns nil if the input value is zero", func() { - v := 0 - Expect(gg.P(0)).To(Equal(&v)) - }) - }) - Describe("V", func() { It("returns the value of the input pointer", func() { - v := 123 - Expect(gg.V(&v)).To(Equal(123)) + Expect(gg.V(new(123))).To(Equal(123)) }) It("returns a zero value if the input pointer is nil", func() { diff --git a/utils/req/req.go b/utils/req/req.go index 6b6135e1a..2757fc3f5 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -38,8 +38,7 @@ func (r *Values) String(param string) (string, error) { func (r *Values) StringPtr(param string) *string { var v *string if _, exists := r.URL.Query()[param]; exists { - s := r.URL.Query().Get(param) - v = &s + v = new(r.URL.Query().Get(param)) } return v } @@ -48,8 +47,7 @@ func (r *Values) BoolPtr(param string) *bool { var v *bool if _, exists := r.URL.Query()[param]; exists { s := r.URL.Query().Get(param) - b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") - v = &b + v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")) } return v } From 03ac02d9648adcccf81236241e18c93016343e51 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 19 May 2026 18:23:36 -0300 Subject: [PATCH 248/596] refactor: more warnings clean up Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/deezer/client.go | 2 +- cmd/user.go | 4 ++-- conf/dir.go | 2 +- core/auth/auth_test.go | 3 +-- core/ffmpeg/ffmpeg_test.go | 2 +- core/playback/mpv/track.go | 2 +- model/criteria/export_test.go | 2 -- model/criteria/operators.go | 6 ------ persistence/radio_repository_test.go | 6 +----- plugins/manager_cache_test.go | 3 ++- scanner/metadata_old/metadata_internal_test.go | 2 +- utils/slice/slice_test.go | 2 +- 12 files changed, 12 insertions(+), 24 deletions(-) diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index 31150c673..d51f65dd9 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -1,7 +1,7 @@ package deezer import ( - bytes "bytes" + "bytes" "context" "encoding/json" "errors" diff --git a/cmd/user.go b/cmd/user.go index 1abf157b7..885edb53b 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -123,14 +123,14 @@ func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") // This cast is necessary for some platforms - password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert + password, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert + confirmation, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) diff --git a/conf/dir.go b/conf/dir.go index 8ed43039b..abbe72b77 100644 --- a/conf/dir.go +++ b/conf/dir.go @@ -57,7 +57,7 @@ func (d *Dir) MustPath() string { // GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) // prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { //nolint:govet +func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer return fmt.Sprintf("%q", d.path) } diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 761dd205c..3a3585e53 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -21,8 +21,7 @@ func TestAuth(t *testing.T) { } const ( - testJWTSecret = "not so secret" - oneDay = 24 * time.Hour + oneDay = 24 * time.Hour ) var _ = BeforeSuite(func() { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 562fd9100..2e2895738 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "runtime" "strings" - sync "sync" + "sync" "testing" "time" diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index 14170efd4..1038b9190 100644 --- a/core/playback/mpv/track.go +++ b/core/playback/mpv/track.go @@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool { func waitForSocket(path string, timeout time.Duration, pause time.Duration) error { start := time.Now() end := start.Add(timeout) - var retries int = 0 + var retries = 0 for { fileInfo, err := os.Stat(path) diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go index 9f3f3922b..e2109aa1a 100644 --- a/model/criteria/export_test.go +++ b/model/criteria/export_test.go @@ -1,5 +1,3 @@ package criteria -var StartOfPeriod = startOfPeriod - type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 3ddd77f8b..14a02ff4b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,7 +1,5 @@ package criteria -import "time" - // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { ChildPlaylistIds() []string @@ -142,10 +140,6 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { func (nitl NotInTheLast) fields() map[string]any { return nitl } -func startOfPeriod(numDays int64, from time.Time) string { - return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") -} - type InPlaylist map[string]any func (ipl InPlaylist) MarshalJSON() ([]byte, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 828af8043..05628ca41 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -11,10 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var ( - NewId string = "123-456-789" -) - var _ = Describe("RadioRepository", func() { var repo model.RadioRepository @@ -139,7 +135,7 @@ var _ = Describe("RadioRepository", func() { It("returns an existing item", func() { res, err := repo.Get(radioWithHomePage.ID) - Expect(err).To((BeNil())) + Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) diff --git a/plugins/manager_cache_test.go b/plugins/manager_cache_test.go index f985fcd84..3dbfa45ee 100644 --- a/plugins/manager_cache_test.go +++ b/plugins/manager_cache_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strconv" "time" "github.com/dustin/go-humanize" @@ -143,7 +144,7 @@ var _ = Describe("purgeCacheBySize", func() { // Create 5 files, 1MiB each (total 5MiB) for i := range 5 { - path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) + path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go index 2d21e07eb..aff1ede9c 100644 --- a/scanner/metadata_old/metadata_internal_test.go +++ b/scanner/metadata_old/metadata_internal_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tags", func() { var t *Tags BeforeEach(func() { t = &Tags{Tags: map[string][]string{ - "fbpm": []string{"141.7"}, + "fbpm": {"141.7"}, }} }) diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 65e5f0934..64cb89d53 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -134,7 +134,7 @@ var _ = Describe("Slice Utils", func() { count := 0 file, _ := os.Open(path) defer file.Close() - for _ = range slice.LinesFrom(file) { + for range slice.LinesFrom(file) { count++ } Expect(count).To(Equal(expected)) From e75ab3b0377d80691c86f547d60c778321a659aa Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 20 May 2026 19:33:42 -0300 Subject: [PATCH 249/596] fix(cli): restore int cast for syscall.Stdin on Windows On Windows, syscall.Stdin is syscall.Handle (uintptr), not int, so term.ReadPassword requires an explicit int() cast to compile. --- cmd/user.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index 885edb53b..1abf157b7 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -123,14 +123,14 @@ func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") // This cast is necessary for some platforms - password, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert + password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert + confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) From 74185dc6d1cf5676e90805929285302429aad009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 22 May 2026 18:00:13 -0300 Subject: [PATCH 250/596] fix(smartplaylists): optimize smart playlist performance for role and tag criteria (#5515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): optimize smart playlist role queries for large criteria (#5511) Role-based smart playlist criteria (artist, composer, etc.) now query the indexed media_file_artists join table instead of parsing JSON via json_tree() on every row. Multiple conditions for the same role within an OR group are merged into a single EXISTS subquery (batched at 200 to stay under SQLite's expression tree depth limit). A composite index (media_file_id, role) replaces the now-redundant single-column (media_file_id) index on media_file_artists. Benchmark (40k tracks, 500 patterns, 3 artists/track): - Merged join-table: 15ms (9.3x faster) - Merged json_tree: 30ms (4.6x faster) - Unmerged baseline: 137ms * refactor: simplify role condition SQL generation and benchmark Extract shared roleCondSQL/roleExistsSQL helpers to deduplicate the EXISTS template between roleCond and roleCondGroup. Use slices.Chunk for batching per project convention. Extract runBenchQuery helper to eliminate triplicated benchmark execution loop. * chore: raise roleCondBatchSize to 350 The empirical SQLite limit is 496 conditions per merged EXISTS subquery. Raising from 200 to 350 reduces the number of batches (e.g. 500 patterns now splits into 2 batches instead of 3). * fix(server): apply OR-merge optimization to tag conditions too Generalize mergeRoleConds into mergeJsonConds to also collapse multiple tag conditions for the same tag (e.g. genre) within OR groups. This gives the same ~5x speedup for tag-heavy smart playlists as the role optimization gives for artist-heavy ones. * refactor: benchmark uses real criteria pipeline instead of hand-built SQL The "Current" sub-benchmark now builds criteria.Criteria expressions and runs them through the actual newSmartPlaylistCriteria → Where() → ToSql() pipeline, validating the real production code path. The baseline still uses hand-built SQL representing the old json_tree approach. * fix: stabilize merged group ordering and close rows before error check Sort group keys in mergeJsonConds so the merged additions have deterministic order across runs, improving SQLite statement cache reuse. Move rows.Close() before rows.Err() in benchmark helper. --- ...add_media_file_artists_composite_index.sql | 9 + persistence/criteria_sql.go | 170 ++++++++++++- persistence/criteria_sql_benchmark_test.go | 236 ++++++++++++++++++ persistence/criteria_sql_test.go | 156 +++++++++++- 4 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 db/migrations/20260520211813_add_media_file_artists_composite_index.sql create mode 100644 persistence/criteria_sql_benchmark_test.go diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql new file mode 100644 index 000000000..f65050d80 --- /dev/null +++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role + ON media_file_artists (media_file_id, role); +DROP INDEX IF EXISTS media_file_artists_media_file_id; + +-- +goose Down +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id + ON media_file_artists (media_file_id); +DROP INDEX IF EXISTS media_file_artists_media_file_id_role; diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index a1bae3170..37e4ae340 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -3,7 +3,9 @@ package persistence import ( "errors" "fmt" + "maps" "reflect" + "slices" "strconv" "strings" "time" @@ -147,7 +149,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } or = append(or, cond) } - return or, nil + return mergeJsonConds(or), nil case criteria.Is: return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { return squirrel.Eq(fields) @@ -381,17 +383,173 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { var cond string var args []any - var err error if e.cond != nil { - cond, args, err = e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + innerSQL, innerArgs, err := roleCondSQL(e.cond) + if err != nil { + return "", nil, err + } + cond = roleExistsSQL(innerSQL) + args = append([]any{e.role}, innerArgs...) } else { - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" + args = []any{e.role} } if e.not { cond = "not " + cond } - return cond, args, err + return cond, args, nil +} + +// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name. +func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) { + sql, args, err := cond.ToSql() + if err != nil { + return "", nil, err + } + return strings.ReplaceAll(sql, "value", "artist.name"), args, nil +} + +// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery. +func roleExistsSQL(innerCond string) string { + return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+ + "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond) +} + +// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery +// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper +// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum. +const jsonCondBatchSize = 350 + +// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same +// field within an OR group into batched EXISTS subqueries with the conditions ORed inside. +// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically +// improving performance for smart playlists with many patterns. +func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + type condEntry struct { + index int + cond squirrel.Sqlizer + } + type group struct { + entries []condEntry + isRole bool + numeric bool + tag string + } + groups := make(map[string]*group) + for i, s := range or { + switch c := s.(type) { + case roleCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["role:"+c.role] + if !exists { + g = &group{isRole: true} + groups["role:"+c.role] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + case tagCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["tag:"+c.tag] + if !exists { + g = &group{tag: c.tag, numeric: c.numeric} + groups["tag:"+c.tag] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + } + } + + merged := false + remove := make(map[int]bool) + var additions []squirrel.Sqlizer + for _, key := range slices.Sorted(maps.Keys(groups)) { + g := groups[key] + if len(g.entries) < 2 { + continue + } + merged = true + for _, e := range g.entries { + remove[e.index] = true + } + conds := make([]squirrel.Sqlizer, len(g.entries)) + for i, e := range g.entries { + conds[i] = e.cond + } + if g.isRole { + role := key[len("role:"):] + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch}) + } + } else { + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch}) + } + } + } + + if !merged { + return or + } + + result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions)) + for i, s := range or { + if !remove[i] { + result = append(result, s) + } + } + result = append(result, additions...) + return result +} + +// roleCondGroup represents multiple role conditions for the same role, merged into +// a single EXISTS subquery for performance. +type roleCondGroup struct { + role string + conds []squirrel.Sqlizer +} + +func (g roleCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + allArgs := []any{g.role} + for _, c := range g.conds { + part, args, err := roleCondSQL(c) + if err != nil { + return "", nil, err + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + return cond, allArgs, nil +} + +// tagCondGroup represents multiple tag conditions for the same tag, merged into +// a single EXISTS subquery for performance. +type tagCondGroup struct { + tag string + numeric bool + conds []squirrel.Sqlizer +} + +func (g tagCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + var allArgs []any + for _, c := range g.conds { + part, args, err := c.ToSql() + if err != nil { + return "", nil, err + } + if g.numeric { + part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)") + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", + g.tag, strings.Join(innerParts, " OR ")) + return cond, allArgs, nil } func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go new file mode 100644 index 000000000..d901e9eda --- /dev/null +++ b/persistence/criteria_sql_benchmark_test.go @@ -0,0 +1,236 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/pocketbase/dbx" +) + +const ( + benchNumArtists = 1_000 + benchNumTracks = 40_000 + benchNumPatterns = 500 + benchArtistsPerTrack = 3 +) + +// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance +// between the current implementation (merged join-table via criteria pipeline) and +// the old baseline (unmerged json_tree subqueries). +func BenchmarkSmartPlaylistRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "contains artist" patterns in an OR group + anyExprs := make(criteria.Any, benchNumPatterns) + for i := range benchNumPatterns { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) { + benchmarkUnmergedJSONTree(b, ctx) + }) +} + +// benchmarkCriteriaPipeline runs the criteria through the actual production code path: +// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. +func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { + b.Helper() + + cSQL := newSmartPlaylistCriteria(expr) + + // Build the full query matching buildSmartPlaylistQuery + addCriteria + sq := squirrel.Select("media_file.id").From("media_file") + cond, err := cSQL.Where() + if err != nil { + b.Fatal(err) + } + sq = sq.Where(cond) + if expr.Limit > 0 { + sq = sq.Limit(uint64(expr.Limit)) + } + if order := cSQL.OrderBy(); order != "" { + sq = sq.OrderBy(order) + } + + query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + b.Fatal(err) + } + + runBenchQuery(b, ctx, query, args) +} + +// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS +// subqueries (the pre-optimization baseline). +func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" OR ") + } + sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)") + args = append(args, fmt.Sprintf("%%Artist %04d%%", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + +func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) { + b.Helper() + sqlDB := db.Db() + b.ResetTimer() + for range b.N { + rows, err := sqlDB.QueryContext(ctx, query, args...) + if err != nil { + b.Fatal(err) + } + for rows.Next() { + var id string + _ = rows.Scan(&id) + } + rows.Close() + if err := rows.Err(); err != nil { + b.Fatal(err) + } + } +} + +func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) { + b.Helper() + + sqlDB := db.Db() + + ur := NewUserRepository(ctx, conn) + if err := ur.Put(&user); err != nil { + b.Fatal(err) + } + if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil { + b.Fatal(err) + } + + tx, err := sqlDB.Begin() + if err != nil { + b.Fatal(err) + } + + // Create artists + artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)") + if err != nil { + b.Fatal(err) + } + for i := range benchNumArtists { + if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil { + b.Fatal(err) + } + } + artistStmt.Close() + + // Ensure folder exists + folderID := "bench-folder" + if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil { + b.Fatal(err) + } + + // Create media files with participants JSON, cycling through artists + mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id, + duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + b.Fatal(err) + } + + // Populate media_file_artists join table + mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)") + if err != nil { + b.Fatal(err) + } + + for i := range benchNumTracks { + trackID := fmt.Sprintf("track-%05d", i) + + // Assign benchArtistsPerTrack artists to each track, cycling through the pool + artistEntries := make([]map[string]string, benchArtistsPerTrack) + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistEntries[a] = map[string]string{ + "id": fmt.Sprintf("artist-%04d", artistIdx), + "name": fmt.Sprintf("Artist %04d", artistIdx), + } + } + primaryArtistIdx := i % benchNumArtists + primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx) + primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx) + + participants := map[string][]map[string]string{"artist": artistEntries} + participantsJSON, _ := json.Marshal(participants) + + if _, err := mfStmt.Exec( + trackID, + fmt.Sprintf("music/%s.mp3", trackID), + fmt.Sprintf("Track %05d", i), + "Bench Album", + primaryArtistName, + primaryArtistID, + "bench-album", + 180, 2024, 5000000, "mp3", + "{}", + string(participantsJSON), + "[]", + 1, folderID, trackID, "mp3", + ); err != nil { + b.Fatal(err) + } + + // Insert all artist associations into the join table + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistID := fmt.Sprintf("artist-%04d", artistIdx) + if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil { + b.Fatal(err) + } + } + } + mfStmt.Close() + mfaStmt.Close() + + if err := tx.Commit(); err != nil { + b.Fatal(err) + } + + b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns", + benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns) +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index ae2695a4d..5c8909e1c 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -1,6 +1,8 @@ package persistence import ( + "fmt" + "strings" "time" "github.com/navidrome/navidrome/model" @@ -56,9 +58,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), - Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), - Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"), // ReplayGain fields Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), @@ -70,9 +72,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isMissing — roles Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, - "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), // isPresent — tags Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), @@ -80,9 +82,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isPresent — roles Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, - "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), ) Describe("playlist permissions", func() { @@ -204,6 +206,146 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + Describe("JSON condition merging", func() { + It("merges multiple role conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"artist": "Pink Floyd"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%")) + }) + + It("does not merge role conditions from different roles", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("mfa.role = ?")) + // Two separate EXISTS since roles differ + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated role conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two separate "not exists" since they are negated + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large groups to avoid SQLite expression tree depth limit", func() { + // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups + anyExprs := make(criteria.Any, jsonCondBatchSize+1) + for i := range anyExprs { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + // First batch has jsonCondBatchSize patterns, second has 1 => total args: + // 2 roles + (jsonCondBatchSize + 1) patterns + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) + + It("merges role conditions while preserving non-role conditions", func() { + expr := criteria.Any{ + criteria.Contains{"title": "Love"}, + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.title LIKE ?")) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges multiple tag conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + criteria.Contains{"genre": "Punk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%")) + }) + + It("does not merge tag conditions from different tags", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"mood": "Happy"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated tag conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("merges role and tag conditions independently", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two merged EXISTS: one for roles, one for tags + Expect(strings.Count(sql, "exists")).To(Equal(2)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name + }) + }) + Describe("joins", func() { It("excludes sort-only joins from expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} From 8897ec918e3a3b540c95b1693367b40eaf1be280 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 22 May 2026 18:42:17 -0300 Subject: [PATCH 251/596] fix(subsonic): mark AlbumID3 songCount and created as required The Subsonic API spec defines songCount and created as required attributes on AlbumID3, but they were tagged with omitempty in our response struct, allowing them to be silently dropped from responses (e.g. when songCount was 0). Created was also a *time.Time, which compounded the omitempty behavior. Remove omitempty from both fields and change Created from *time.Time to time.Time so they are always serialized, matching the spec contract that clients rely on. The buildAlbumID3 helper and its tests are updated for the non-pointer Created, and the AlbumWithSongsID3 snapshots are regenerated to include the now-always-present fields. --- server/subsonic/helpers.go | 2 +- server/subsonic/helpers_test.go | 13 +++++-------- ...s AlbumWithSongsID3 with data should match .JSON | 2 ++ ...es AlbumWithSongsID3 with data should match .XML | 2 +- ...lbumWithSongsID3 without data should match .JSON | 4 +++- ...AlbumWithSongsID3 without data should match .XML | 2 +- ...ID3 without data should match OpenSubsonic .JSON | 2 ++ ...sID3 without data should match OpenSubsonic .XML | 2 +- server/subsonic/responses/responses.go | 4 ++-- 9 files changed, 18 insertions(+), 15 deletions(-) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 0b18b1bc7..e4c39e373 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -441,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = new(albumCreatedAt(album)) + dir.Created = albumCreatedAt(album) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index abf6116f3..2ae6eb28e 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -576,30 +576,27 @@ var _ = Describe("helpers", func() { t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) al := model.Album{ID: "a1", Name: "A", CreatedAt: t} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(t)) + Expect(dir.Created).To(Equal(t)) }) It("falls back to UpdatedAt when CreatedAt is zero", func() { updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(updated)) + Expect(dir.Created).To(Equal(updated)) }) It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(imported)) + Expect(dir.Created).To(Equal(imported)) }) - It("never leaves Created nil even when all timestamps are zero", func() { + It("leaves Created as zero time when all timestamps are zero", func() { al := model.Album{ID: "a4", Name: "A"} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) + Expect(dir.Created.IsZero()).To(BeTrue()) }) }) diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index a07914344..bff0bd20c 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -8,7 +8,9 @@ "id": "1", "name": "album", "artist": "artist", + "songCount": 0, "duration": 292, + "created": "0001-01-01T00:00:00Z", "genre": "rock", "userRating": 4, "genres": [ diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index 6c96391bc..16a7748aa 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -1,5 +1,5 @@ <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <album id="1" name="album" artist="artist" duration="292" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition"> + <album id="1" name="album" artist="artist" songCount="0" duration="292" created="0001-01-01T00:00:00Z" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition"> <genres name="rock"></genres> <genres name="progressive"></genres> <discTitles disc="1" title="disc 1"></discTitles> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index 14e96939e..030502618 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -7,6 +7,8 @@ "album": { "id": "", "name": "", - "duration": 0 + "songCount": 0, + "duration": 0, + "created": "0001-01-01T00:00:00Z" } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 868265347..0d3882080 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,3 @@ <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <album id="" name="" duration="0"></album> + <album id="" name="" songCount="0" duration="0" created="0001-01-01T00:00:00Z"></album> </subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON index 446368fa5..d3964663b 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -7,7 +7,9 @@ "album": { "id": "", "name": "", + "songCount": 0, "duration": 0, + "created": "0001-01-01T00:00:00Z", "userRating": 0, "genres": [], "musicBrainzId": "", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML index 868265347..0d3882080 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -1,3 +1,3 @@ <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <album id="" name="" duration="0"></album> + <album id="" name="" songCount="0" duration="0" created="0001-01-01T00:00:00Z"></album> </subsonic-response> diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 4165669df..dcb458932 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -251,10 +251,10 @@ type AlbumID3 struct { Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` Duration int32 `xml:"duration,attr" json:"duration"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + Created time.Time `xml:"created,attr" json:"created"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` From 0265ff3ad18b7a83c2ea20c62bad69ccb737aee8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 22 May 2026 22:22:23 -0300 Subject: [PATCH 252/596] fix(ui): report playback when restarting current track via prev The navidrome-music-player library rewinds the current track by directly mutating audio.currentTime when the Previous button is pressed with restartCurrentOnPrev (and other programmatic seek paths like singleLoop reset and mediaSession seek). It does not invoke its onAudioSeeked callback for these, so the play tracker never learned about the new position until the next ~30s heartbeat. Replace the React onAudioSeeked prop with a native HTML5 'seeked' event listener on the audio element, which fires for every seek (programmatic or via slider release). The handler is debounced by 250ms so the burst of seeks emitted while dragging the progress bar coalesces into a single reportPlayback call at the final position. --- ui/src/audioplayer/Player.jsx | 42 ++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index e2070deea..c3d795b26 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -272,18 +272,6 @@ const Player = () => { } }, []) - const onAudioSeeked = useCallback( - (info) => { - if (!info.isRadio && currentTrackId) { - const posMs = Math.floor(info.currentTime * 1000) - lastPositionMsRef.current = posMs - const state = audioInstance?.paused ? 'paused' : 'playing' - subsonic.reportPlayback(currentTrackId, posMs, state) - } - }, - [currentTrackId, audioInstance], - ) - const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), @@ -436,6 +424,35 @@ const Player = () => { } }, [isMobilePlayer, audioInstance]) + // Report every seek (including programmatic ones the library does not surface + // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag + // bursts into one report at the final position. + useEffect(() => { + if (!audioInstance) return + let timer = null + const flush = () => { + timer = null + if ( + !currentTrackIdRef.current || + playerStateRef.current?.current?.isRadio + ) { + return + } + const posMs = Math.floor((audioInstance.currentTime || 0) * 1000) + const state = audioInstance.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackIdRef.current, posMs, state) + } + const handleSeeked = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(flush, 250) + } + audioInstance.addEventListener('seeked', handleSeeked) + return () => { + if (timer) clearTimeout(timer) + audioInstance.removeEventListener('seeked', handleSeeked) + } + }, [audioInstance]) + return ( <ThemeProvider theme={createMuiTheme(theme)}> <ReactJkMusicPlayer @@ -444,7 +461,6 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} - onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} From edffca24b11b5378876cbcfed62acbbed8d95d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 23 May 2026 12:16:15 -0300 Subject: [PATCH 253/596] fix(lastfm): require signed state token on link callback (#5521) * fix(lastfm): require signed state token on link callback The Last.fm OAuth callback at /api/lastfm/link/callback trusted a raw \`uid\` query parameter and wrote the resulting Last.fm session key under that user with no ownership check. Any authenticated user who learned a victim's internal user ID (e.g. from playlist ownerId) could redirect the victim's scrobbles to an attacker-controlled Last.fm account by calling the callback directly with the victim's uid and a Last.fm token obtained for their own account. The callback cannot use the regular auth middleware because it is reached via a browser redirect from Last.fm, which cannot carry a JWT header. Instead, GET /api/lastfm/link (authenticated) now also returns a short- lived (5 min) HMAC-signed link token bound to the requesting user, with a dedicated "lastfm-link" scope claim. The callback verifies the signature, scope and expiry before deriving the user ID from the token; the \`uid\` query value is no longer trusted as a user identifier. The UI fetches this token at link-flow start and passes it in place of the raw user ID. Reuses the existing HS256 secret via auth.EncodeToken/DecodeAndVerifyToken so no new key management is introduced. * fix(ui): keep Last.fm popup tied to user gesture for Safari Opening the Last.fm OAuth tab after an awaited fetch causes the popup to be blocked on Safari and on Firefox with strict popup blocking enabled, because the browser's transient-activation window has already elapsed by the time window.open is reached. Linking became impossible on those browsers in the previous commit. Move the click handler up to the parent component and open a placeholder about:blank tab synchronously from the click; the linkToken fetch then runs in parallel and we redirect the existing tab to Last.fm's auth URL once it resolves. The user gesture stays attached to the window.open call, so popup blockers no longer fire. The polling/progress UI is unchanged; it now receives the openedTab ref from the parent instead of owning it. * fix(lastfm): require exp claim on link tokens jwtauth.VerifyToken treats a JWT without an exp claim as non-expiring, so verifyLinkToken used to delegate expiry handling entirely. A future regression in createLinkToken that dropped the exp field would silently turn link tokens into permanent bearer credentials. Assert presence of an exp claim explicitly and add a regression test covering the missing-exp case. Also tightens the wrong-scope test to use a freshly-minted token with all claims present except the scope, instead of relying on auth.CreatePublicToken which happens to also be missing exp. * style(lastfm): simplify comments in link token code Trim doc comments on createLinkToken/verifyLinkToken/callback/startLink to the load-bearing lines: keep the non-obvious 'jwtauth treats missing exp as non-expiring' note and the popup-blocker hint, drop the rest since the function names already describe behavior. Signed-off-by: Deluan <deluan@navidrome.org> * fix(lastfm): address review feedback on link token PR - Wrap openInNewTab in a try/catch in startLink: openInNewTab calls win.focus() unconditionally, so if the browser blocks the popup (window.open returns null) it throws a TypeError synchronously, before the catch() on the link-token fetch is attached. The throw used to escape the click handler, leaving the UI without a notification. Now the failure is surfaced as lastfmLinkFailure and the toggle stays usable. - Rename the link-token "subject" rejection message to "user ID" since the claim is uid, not the JWT sub field. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/lastfm/auth_router.go | 15 +- adapters/lastfm/auth_router_test.go | 218 +++++++++++++++++++++++ adapters/lastfm/link_token.go | 50 ++++++ ui/src/personal/LastfmScrobbleToggle.jsx | 51 ++++-- 4 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 adapters/lastfm/auth_router_test.go create mode 100644 adapters/lastfm/link_token.go diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 162ae9037..499863e28 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { return } resp["status"] = key != "" + linkToken, err := createLinkToken(u.ID) + if err != nil { + log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + resp["linkToken"] = linkToken _ = rest.RespondWithJSON(w, http.StatusOK, resp) } @@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - uid, err := p.String("uid") + linkToken, err := p.String("uid") if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } + uid, err := verifyLinkToken(linkToken) + if err != nil { + log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token") + return + } // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go new file mode 100644 index 000000000..4cbbd4298 --- /dev/null +++ b/adapters/lastfm/auth_router_test.go @@ -0,0 +1,218 @@ +package lastfm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("auth_router", func() { + var ( + ds *tests.MockDataStore + userProps *tests.MockedUserPropsRepo + httpClient *tests.FakeHttpClient + router *Router + ) + + const ( + victimID = "victim-user-id" + attackerID = "attacker-user-id" + ) + + BeforeEach(func() { + userProps = &tests.MockedUserPropsRepo{} + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedUserProps: userProps, + } + auth.Init(ds) + + httpClient = &tests.FakeHttpClient{} + router = &Router{ + ds: ds, + apiKey: "API_KEY", + secret: "SECRET", + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + router.client = newClient(router.apiKey, router.secret, httpClient) + router.Handler = router.routes() + }) + + storedSessionKey := func(userID string) string { + key, _ := userProps.Get(userID, sessionKeyProperty) + return key + } + + stubGetSessionOK := func(sessionKey string) { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)), + StatusCode: 200, + } + } + + Describe("getLinkStatus", func() { + It("includes a signed linkToken for the authenticated user", func() { + req := httptest.NewRequest(http.MethodGet, "/link", nil) + ctx := request.WithUser(req.Context(), model.User{ID: victimID}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + router.getLinkStatus(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var body map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) + Expect(body["apiKey"]).To(Equal("API_KEY")) + Expect(body["status"]).To(Equal(false)) + token, ok := body["linkToken"].(string) + Expect(ok).To(BeTrue()) + Expect(token).ToNot(BeEmpty()) + + verified, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(verified).To(Equal(victimID)) + }) + }) + + Describe("callback", func() { + It("stores the session key under the user encoded in the signed token", func() { + stubGetSessionOK("LEGIT_SESSION") + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION")) + }) + + It("rejects a raw (unsigned) uid value", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects an expired link token", func() { + expiredToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + "exp": time.Now().Add(-1 * time.Minute).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() { + sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"}) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(attackerID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("writes only under the user encoded in the token, regardless of query manipulation", func() { + // An attacker holds a legitimate link token for their own account. + // They attempt to call the callback hoping to overwrite the victim's + // session key — but the handler must derive the user ID from the + // signed token, not from any other input. + stubGetSessionOK("ATTACKER_SESSION") + attackerToken, err := createLinkToken(attackerID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION")) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + }) + + It("returns 400 when uid is missing", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when token is missing", func() { + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("link token helpers", func() { + It("round-trips a freshly issued token", func() { + token, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + uid, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(uid).To(Equal(victimID)) + }) + + It("rejects garbage", func() { + _, err := verifyLinkToken("not-a-jwt") + Expect(err).To(HaveOccurred()) + }) + + It("rejects a token whose scope claim is wrong", func() { + wrongScopeToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": "some-other-scope", + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(wrongScopeToken) + Expect(err).To(MatchError("invalid link token scope")) + }) + + It("rejects a scoped token that has no expiration", func() { + nonExpiringToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(nonExpiringToken) + Expect(err).To(MatchError("link token missing expiration")) + }) + }) +}) diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go new file mode 100644 index 000000000..fd8ceb3c9 --- /dev/null +++ b/adapters/lastfm/link_token.go @@ -0,0 +1,50 @@ +package lastfm + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/core/auth" +) + +const ( + linkTokenScope = "lastfm-link" + linkTokenTTL = 5 * time.Minute +) + +// createLinkToken issues a signed token binding the Last.fm callback to the +// user who initiated the OAuth flow. It travels back through Last.fm via the +// `cb` URL in place of the previously-trusted raw `uid` query parameter. +func createLinkToken(userID string) (string, error) { + claims := map[string]any{ + "uid": userID, + "scope": linkTokenScope, + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + } + return auth.EncodeToken(claims) +} + +// verifyLinkToken validates a signed link token and returns the encoded user ID. +// It enforces both the signature/expiry (via the underlying JWT verifier) and a +// dedicated scope claim, preventing tokens minted for other purposes (e.g. a +// regular session JWT) from being accepted here. +func verifyLinkToken(tokenStr string) (string, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return "", err + } + // jwtauth treats a token without `exp` as non-expiring; require it + // explicitly so an accidental regression cannot mint permanent tokens. + if exp, ok := token.Expiration(); !ok || exp.IsZero() { + return "", errors.New("link token missing expiration") + } + var scope string + if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope { + return "", errors.New("invalid link token scope") + } + var uid string + if err := token.Get("uid", &uid); err != nil || uid == "" { + return "", errors.New("invalid link token user ID") + } + return uid, nil +} diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index 67018d2bb..c8e07328f 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -13,21 +13,10 @@ import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' const Progress = (props) => { - const { setLinked, setCheckingLink, apiKey } = props + const { setLinked, setCheckingLink, openedTab } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 - const openedTab = useRef() - - useEffect(() => { - const callbackEndpoint = baseUrl( - `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, - ) - const callbackUrl = `${window.location.origin}${callbackEndpoint}` - openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, - ) - }, [apiKey]) const endChecking = (success) => { linkCheckDelay = null @@ -76,6 +65,7 @@ export const LastfmScrobbleToggle = (props) => { const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) const [apiKey, setApiKey] = useState(false) + const openedTab = useRef() useEffect(() => { httpClient('/api/lastfm/link') @@ -88,9 +78,42 @@ export const LastfmScrobbleToggle = (props) => { }) }, [setLinked, setApiKey]) + const startLink = () => { + // Open the tab synchronously so popup blockers attribute it to the click. + let tab + try { + tab = openInNewTab('about:blank') + } catch { + notify('message.lastfmLinkFailure', 'warning') + return + } + openedTab.current = tab + setCheckingLink(true) + httpClient('/api/lastfm/link') + .then((response) => { + const linkToken = response.json.linkToken + if (!linkToken) { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + return + } + const callbackEndpoint = baseUrl( + `/api/lastfm/link/callback?uid=${encodeURIComponent(linkToken)}`, + ) + const callbackUrl = `${window.location.origin}${callbackEndpoint}` + tab.location.href = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}` + }) + .catch(() => { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + }) + } + const toggleScrobble = () => { if (!linked) { - setCheckingLink(true) + startLink() } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) .then(() => { @@ -121,7 +144,7 @@ export const LastfmScrobbleToggle = (props) => { <Progress setLinked={setLinked} setCheckingLink={setCheckingLink} - apiKey={apiKey} + openedTab={openedTab} /> )} {!apiKey && ( From 55a31f30b3725a17b8a25b13c426dbc41d85b258 Mon Sep 17 00:00:00 2001 From: Tom Boucher <trekkie@nomorestars.com> Date: Sat, 23 May 2026 18:20:18 -0400 Subject: [PATCH 254/596] fix(scanner): respect tag split config when multiple frames map to the same tag (#5193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: split tag values from multiple sources individually When a file has multiple tag frames mapping to the same logical tag (e.g. both TXXX:MOOD and TMOO), TagLib merges them into one key with multiple values. SplitTagValue had a len(values) != 1 guard that skipped splitting entirely in this case, leaving comma-separated values unsplit. Change SplitTagValue to split each value individually regardless of input count. Empty values are filtered during splitting. Fixes #5065 * test: cover SplitTagValue with multi-frame regression cases Add tests pinning the behavior fixed by SplitTagValue iterating over each input value. The previous len(values) != 1 short-circuit silently skipped splitting whenever TagLib merged multiple ID3v2 frames into the same property (e.g. TMOO + TXXX:MOOD for mood, or duplicate TIPL entries for composer), as reported in #5065. Three layers of coverage: - model/tag_mappings_test.go: direct unit tests on TagConf.SplitTagValue covering single/multi-value input, case-insensitive separators, missing SplitRx, empty input, and the empty-strings-passed-through contract that the downstream metadata pipeline relies on. - model/metadata/metadata_test.go: end-to-end check that a "mood" tag surfaced as two raw values (the exact shape from the bug report) is split, trimmed, and deduplicated to the expected three moods. - model/metadata/map_participants_test.go: parallel multi-value case for the COMPOSER tag, ensuring the same fix also corrects multi-frame role parsing. All three new specs fail on the pre-fix code and pass on the patched SplitTagValue. --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- model/metadata/map_participants_test.go | 20 ++++++++ model/metadata/metadata_test.go | 15 ++++++ model/tag_mappings.go | 24 +++++----- model/tag_mappings_test.go | 64 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 model/tag_mappings_test.go diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index 71cb9c1f2..5ee802ced 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -684,6 +684,26 @@ var _ = Describe("Participants", func() { Expect(composers[2].Name).To(Equal("The Album Artist")) }) }) + + // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when + // multiple frames map to the same role tag (e.g. TIPL producer entries), + // the configured split separator must still apply to each value. + When("the tag has multiple values", func() { + It("should split each value individually", func() { + mf = toMediaFile(model.RawTags{ + "COMPOSER": {"John Doe/Jane Doe", "Someone Else"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) + composers := participants[model.RoleComposer] + Expect(composers).To(ConsistOf( + HaveField("Name", "John Doe"), + HaveField("Name", "Jane Doe"), + HaveField("Name", "Someone Else"), + )) + }) + }) }) Describe("MBID tags", func() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index c9acdbce9..350731b89 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -129,6 +129,21 @@ var _ = Describe("Metadata", func() { Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"})) }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by + // TagLib's PropertyMap as a single "mood" key with multiple values. The split + // configuration must still apply to each value individually. + It("should split values from multiple frames mapping to the same tag", func() { + props.Tags = model.RawTags{ + // Same shape as the bug report: two frames, comma-separated content. + "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"}, + } + md = metadata.New(filePath, props) + + Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad")) + }) }) DescribeTable("Date", diff --git a/model/tag_mappings.go b/model/tag_mappings.go index bfe098f77..af76de741 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -34,23 +34,25 @@ type TagConf struct { SplitRx *regexp.Regexp `yaml:"-"` } -// SplitTagValue splits a tag value by the split separators, but only if it has a single value. +// SplitTagValue splits tag values by the configured split separators. +// Each value in the input slice is individually split and trimmed. func (c TagConf) SplitTagValue(values []string) []string { - // If there's not exactly one value or no separators, return early. - if len(values) != 1 || c.SplitRx == nil { + if c.SplitRx == nil || len(values) == 0 { return values } - tag := values[0] - // Replace all occurrences of any separator with the zero-width space. - tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) + var result []string + for _, tag := range values { + // Replace all occurrences of any separator with the zero-width space. + tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) - // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for i, part := range parts { - parts[i] = strings.TrimSpace(part) + // Split by the zero-width space and trim each substring. + parts := strings.Split(tag, consts.Zwsp) + for _, part := range parts { + result = append(result, strings.TrimSpace(part)) + } } - return parts + return result } type TagType string diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go new file mode 100644 index 000000000..1665d557b --- /dev/null +++ b/model/tag_mappings_test.go @@ -0,0 +1,64 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TagConf", func() { + Describe("SplitTagValue", func() { + var conf TagConf + + BeforeEach(func() { + conf = TagConf{Split: []string{";", "/", ","}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + }) + + It("splits a single value on configured separators", func() { + Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"})) + }) + + It("trims whitespace around split values", func() { + Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"})) + }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD), + // TagLib's PropertyMap merges them into a slice with several entries. Previously + // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case. + It("splits each value individually when given multiple inputs", func() { + input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"} + Expect(conf.SplitTagValue(input)).To(Equal([]string{ + "Love", "Emotional", "Ballad", + "Love", "Emotional", "Ballad", + })) + }) + + It("matches separators case-insensitively when the split pattern allows", func() { + c := TagConf{Split: []string{" AND "}} + c.SplitRx = compileSplitRegex("test", c.Split) + Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"})) + }) + + It("returns values unchanged when no separators are configured", func() { + c := TagConf{} + Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"})) + Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"})) + }) + + It("returns an empty slice for empty input", func() { + Expect(conf.SplitTagValue([]string{})).To(BeEmpty()) + }) + + It("handles a value with no separator as a single-element result", func() { + Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"})) + }) + + It("produces empty strings when separators are adjacent (dedup happens downstream)", func() { + // SplitTagValue itself does not filter empties; that is the job of + // filterDuplicatedOrEmptyValues in the metadata pipeline. + Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) + }) + }) +}) From 945d0ba1e2ddfc0e8b6f63b47e2913e43310dd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 24 May 2026 00:24:30 -0300 Subject: [PATCH 255/596] fix(transcoding): cap concurrent transcodes to prevent ffmpeg DoS (#5522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transcoding): add MaxConcurrent and MaxConcurrentPerUser config Introduce Transcoding.MaxConcurrent (default NumCPU()*2) and Transcoding.MaxConcurrentPerUser (default 3) to support upcoming concurrency limits on the streaming pipeline. No behavior change yet. Refs #5246 * feat(transcoding): add TranscodeLimiter with global and per-user caps Introduce a non-blocking limiter that gates concurrent transcodes. Returns ErrTooManyTranscodes immediately when the cap is reached so callers can translate it into a 429 response, rather than queuing requests. The per-user reservation is taken first to avoid burning a global slot that would only be rolled back when the per-user cap rejects the caller. Release is idempotent so wrapping the transcoder reader's Close is safe. Refs #5246 * feat(transcoding): cap concurrent transcodes in media streamer Acquire a TranscodeLimiter slot before spawning ffmpeg in the transcoding cache's read function, and release it when the resulting reader is closed. Raw streams and cache hits bypass the limiter so a single saturating client cannot block ordinary playback. When the cap is reached, ErrTooManyTranscodes bubbles up through cache.Get, ready for the HTTP layer to translate into a 429 response. Refs #5246 * feat(transcoding): return HTTP 429 with Retry-After when transcode cap is hit Map stream.ErrTooManyTranscodes to HTTP 429 in both the Subsonic API (/stream, /download) and the public share endpoint, including a 5s Retry-After hint. The Subsonic response still carries a failed-status envelope so clients that ignore HTTP codes also see the failure. Refs #5246 * feat(transcoding): default MaxConcurrent to 0 (disabled) Ship the limiter opt-in so existing installations are not affected by a behavior change on upgrade. Users hitting the DoS reported in #5246 can enable it by setting Transcoding.MaxConcurrent to a positive value (NumCPU()*2 is a reasonable starting point). Refs #5246 * fix(transcoding): make global and per-user caps independent Previously the limiter short-circuited to a no-op whenever MaxConcurrent was zero, silently ignoring a configured MaxConcurrentPerUser. Treat each cap independently so an operator can throttle per-user without enforcing a global ceiling (or vice versa), and only fall back to the no-op limiter when both caps are disabled. * fix(archiver): abort archive download when the transcode limiter rejects The album/artist/playlist zip writers were silently producing zip entries with headers but no data when ms.NewStream returned ErrTooManyTranscodes, because the per-file error was discarded by `_ = a.addFileToZip(...)`. The client received HTTP 200 with a corrupt zip and no indication that the server was rate-limited. Now the zip loop bails out as soon as it sees ErrTooManyTranscodes, and the Download handler swallows the error (the response status and Content-Disposition are already flushed by the time the limit is hit, so no 429 can be sent). The truncated zip surfaces the problem to the client; operators see a clear "transcode cap reached" warning in the server logs. Refs #5246 * fix(transcoding): release limiter slot on client close, not ffmpeg EOF Previously the slot was wrapped around the ffmpeg source reader, so it was only released by the cache's background copyAndClose goroutine when ffmpeg finished producing the file — meaning a client that disconnected after a single byte still held the slot for the full transcode duration. Under MaxConcurrent=N this serialized fresh requests behind abandoned encodes for minutes. Hand the release function back from the cache producer via the streamJob struct and wire it into the consumer-side Stream.Close. The HTTP handler already runs `defer stream.Close()`, so disconnect now frees the slot immediately. Cache hits never enter the producer and still pay no slot, and singleflight waiters on the same key correctly inherit no release (only the original producer's job holds the slot). Refs #5246 * fix(transcoding): skip per-user cap for anonymous requests Public share viewers have no user in context, so userName(ctx) returned the literal string "UNKNOWN" and the limiter mapped every anonymous viewer to the same bucket. With MaxConcurrentPerUser=N, only N unrelated anonymous clients could stream a viral share at any time — the opposite of the fairness the per-user cap is meant to provide. Introduce a limiterKey(ctx) helper that returns "" for anonymous callers (userName(ctx) is unchanged for logs), and teach Acquire to skip the per-user reservation when the key is empty. The global cap is still enforced for anonymous traffic and remains the protection against runaway anonymous load. Refs #5246 * refactor(transcoding): tidy limiter struct and centralize Retry-After Per review feedback: - Drop the redundant maxConcurrent field on transcodeLimiter; the channel capacity already enforces the global cap and the field was only used inside the constructor. - Only allocate the perUser map when MaxConcurrentPerUser > 0. - Move the Retry-After value into core/stream as RetryAfterSeconds so the Subsonic API and public-share handlers cannot drift if the window is later tuned. * fix(transcoding): do not log limiter rejections as cache failures NewStream was emitting an error-level "Error accessing transcoding cache" log whenever cache.Get returned anything non-nil, including the limiter's ErrTooManyTranscodes — even though the producer had already logged the rejection at warn level. The result was double logging and a misleading "cache failure" classification that buries real cache problems. Skip the error log when the cause is ErrTooManyTranscodes; the warn line from the producer is the canonical signal. * fix(archiver): open stream before writing zip entry header Per review: addFileToZip previously called z.CreateHeader before NewStream, so when the limiter rejected a transcode the zip already contained a 0-byte entry for that track. Open the source first and only write the header once the read side is ready; rejections now skip the entry entirely. The truncation comment in handleArchiveErr was also misleading — z.Close finalises the central directory, so the client receives a well-formed zip containing only the tracks written before the rejection, not a "truncated" archive. Reword to match reality. * fix(transcoding): hold slot for ffmpeg lifetime, force cancellable ctx The previous release-on-consumer-close design let a client open many unique transcodes, disconnect immediately, and still spawn the configured cap's worth of ffmpeg processes — the cache writer goroutine continued draining ffmpeg to disk after the client disappeared, defeating the DoS protection the limiter is meant to provide. Move the release back onto the source reader so the slot is freed only when ffmpeg actually exits (either EOF or context cancellation). To keep disconnects from leaking slots for the full transcode duration, force the request context into ffmpeg whenever the limiter is enabled — so client disconnect cancels the process and frees the slot promptly. When the limiter is disabled, the legacy EnableTranscodingCancellation behavior is preserved unchanged. Reported by codex and Copilot reviewers on #5522. --- conf/configuration.go | 8 ++ core/archiver.go | 56 ++++++--- core/archiver_test.go | 26 ++++ core/stream/limiter.go | 135 +++++++++++++++++++++ core/stream/limiter_test.go | 186 +++++++++++++++++++++++++++++ core/stream/media_streamer.go | 63 ++++++++-- core/stream/media_streamer_test.go | 54 +++++++++ server/public/handle_streams.go | 9 +- server/subsonic/api.go | 26 +++- server/subsonic/api_test.go | 20 ++++ server/subsonic/stream.go | 23 +++- 11 files changed, 571 insertions(+), 35 deletions(-) create mode 100644 core/stream/limiter.go create mode 100644 core/stream/limiter_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 6fff1641a..b4c43f135 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -113,6 +113,7 @@ type configOptions struct { PID pidOptions `json:",omitzero"` Inspect inspectOptions `json:",omitzero"` Subsonic subsonicOptions `json:",omitzero"` + Transcoding transcodingOptions `json:",omitzero"` LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` @@ -165,6 +166,11 @@ type scannerOptions struct { PurgeMissing string // Values: "never", "always", "full" } +type transcodingOptions struct { + MaxConcurrent int + MaxConcurrentPerUser int +} + type subsonicOptions struct { AppendSubtitle bool AppendAlbumVersion bool @@ -822,6 +828,8 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") + viper.SetDefault("transcoding.maxconcurrent", 0) + viper.SetDefault("transcoding.maxconcurrentperuser", 0) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) diff --git a/core/archiver.go b/core/archiver.go index 96cc2c31e..5d1c090cd 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -3,6 +3,7 @@ package core import ( "archive/zip" "context" + "errors" "fmt" "io" "os" @@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { file := a.albumFilename(mf, format, isMultiDisc) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Stop iterating: continuing would just rack up more + // rejections from the limiter. Close finalises whatever + // tracks were already written; the rejected one is not + // present in the archive (addFileToZip aborts before + // writing its entry header). + _ = z.Close() + return addErr + } } } err = z.Close() @@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { file := a.playlistFilename(mf, format, idx) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Abort the whole archive: continuing would silently emit + // empty zip entries since the headers are already written. + _ = z.Close() + return addErr + } mf.Path = file zippedMfs[idx] = mf } @@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { path := mf.AbsolutePath() + + // Open the source before writing the zip entry header so a rejection + // (limiter, missing file, etc.) does not leave an empty entry in the + // archive. + var r io.ReadCloser + var err error + if format != "raw" && format != "" { + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + } else { + r, err = os.Open(path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + return err + } + defer func() { + if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) + } + }() + w, err := z.CreateHeader(&zip.FileHeader{ Name: filename, Modified: mf.UpdatedAt, @@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return err } - var r io.ReadCloser - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) - } else { - r, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) - return err - } - - defer func() { - if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { - log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) - } - }() - _, err = io.Copy(w, r) if err != nil { log.Error(ctx, "Error zipping file", "file", path, err) diff --git a/core/archiver_test.go b/core/archiver_test.go index 4f7aed278..f432139d8 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() { }) }) + Context("when the transcode limiter rejects a file", func() { + It("aborts the archive instead of continuing with empty entries", func() { + mfs := model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + } + + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{ + Filters: squirrel.Eq{"album_id": "1"}, + Sort: "album", + }}).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}). + Return(nil, stream.ErrTooManyTranscodes).Once() + + out := new(bytes.Buffer) + err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) + Expect(err).To(MatchError(stream.ErrTooManyTranscodes)) + // NewStream should only have been called once: the loop must bail + // out on the rejection instead of trying every remaining track. + ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1) + }) + }) + Context("ZipShare", func() { It("zips a share correctly", func() { mfs := model.MediaFiles{ diff --git a/core/stream/limiter.go b/core/stream/limiter.go new file mode 100644 index 000000000..622fe21cc --- /dev/null +++ b/core/stream/limiter.go @@ -0,0 +1,135 @@ +package stream + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" +) + +// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the +// configured concurrency cap has been reached. Callers should translate this +// into an HTTP 429 response so well-behaved clients back off and retry. +var ErrTooManyTranscodes = errors.New("too many concurrent transcodes") + +// RetryAfterSeconds is the value returned in the HTTP Retry-After header when +// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well +// within this window, so retrying after this delay typically succeeds. +const RetryAfterSeconds = 5 + +// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces +// both a global cap (to protect the host from process exhaustion) and an optional +// per-user cap (to keep one client from starving the others). Acquire never +// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately. +type TranscodeLimiter interface { + // Acquire reserves a slot for the given user. On success it returns a release + // function that must be called exactly once when the transcode is done. + // Calling release more than once is safe and idempotent. + Acquire(ctx context.Context, user string) (release func(), err error) + + // Enabled reports whether the limiter actually enforces any cap. Callers + // can use it to decide whether to bind ffmpeg's lifetime to the request + // context so disconnects free slots quickly, rather than letting the + // process drain to completion in the background. + Enabled() bool +} + +// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is +// independent: a value of zero or less disables that cap. When both caps are +// disabled the limiter is a no-op. +func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter { + if maxConcurrent <= 0 && maxPerUser <= 0 { + return noopLimiter{} + } + l := &transcodeLimiter{maxPerUser: maxPerUser} + if maxConcurrent > 0 { + l.global = make(chan struct{}, maxConcurrent) + } + if maxPerUser > 0 { + l.perUser = make(map[string]int) + } + return l +} + +// releasingReadCloser wraps an io.ReadCloser so that closing it also releases +// the limiter slot exactly once. release must be the function returned by +// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too. +type releasingReadCloser struct { + io.ReadCloser + release func() +} + +func (r *releasingReadCloser) Close() error { + err := r.ReadCloser.Close() + r.release() + return err +} + +type noopLimiter struct{} + +func (noopLimiter) Acquire(context.Context, string) (func(), error) { + return func() {}, nil +} + +func (noopLimiter) Enabled() bool { return false } + +type transcodeLimiter struct { + maxPerUser int + global chan struct{} + + mu sync.Mutex + perUser map[string]int +} + +func (*transcodeLimiter) Enabled() bool { return true } + +func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) { + // Reserve a per-user slot first so a noisy user can't burn through + // global slots only to be rejected later. An empty user key means + // "anonymous" (e.g. public share viewers); we skip the per-user cap + // entirely so unrelated anonymous clients do not share a bucket. + perUserActive := l.maxPerUser > 0 && user != "" + if perUserActive { + l.mu.Lock() + if l.perUser[user] >= l.maxPerUser { + l.mu.Unlock() + return nil, ErrTooManyTranscodes + } + l.perUser[user]++ + l.mu.Unlock() + } + + if l.global != nil { + select { + case l.global <- struct{}{}: + default: + if perUserActive { + l.releasePerUser(user) + } + return nil, ErrTooManyTranscodes + } + } + + var released atomic.Bool + return func() { + if !released.CompareAndSwap(false, true) { + return + } + if l.global != nil { + <-l.global + } + if perUserActive { + l.releasePerUser(user) + } + }, nil +} + +func (l *transcodeLimiter) releasePerUser(user string) { + l.mu.Lock() + defer l.mu.Unlock() + l.perUser[user]-- + if l.perUser[user] <= 0 { + delete(l.perUser, user) + } +} diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go new file mode 100644 index 000000000..d278d47c8 --- /dev/null +++ b/core/stream/limiter_test.go @@ -0,0 +1,186 @@ +package stream_test + +import ( + "context" + "errors" + "sync" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TranscodeLimiter", func() { + ctx := log.NewContext(context.TODO()) + + Describe("Disabled (both caps <= 0)", func() { + It("never blocks and never returns ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(0, 0) + for range 100 { + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + Expect(rel).ToNot(BeNil()) + } + }) + }) + + Describe("Per-user cap only (no global cap)", func() { + It("still enforces the per-user limit when MaxConcurrent is disabled", func() { + lim := stream.NewTranscodeLimiter(0, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // Other users have their own buckets. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + }) + + Describe("Global cap", func() { + It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(2, 0) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "carol") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + _, err = lim.Acquire(ctx, "carol") + Expect(err).ToNot(HaveOccurred()) + + rel2() + }) + + It("releases a slot only once even if release is called multiple times", func() { + lim := stream.NewTranscodeLimiter(1, 0) + + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel() + rel() + rel() + + // After releases, exactly one slot should be available. + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) + + Describe("Per-user cap", func() { + It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() { + lim := stream.NewTranscodeLimiter(10, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // A different user is unaffected. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + + It("skips the per-user cap for anonymous users (empty key)", func() { + // Anonymous requests (e.g. public share viewers) deliberately + // bypass the per-user cap so unrelated anonymous clients are not + // collapsed into a single shared bucket. The global cap remains + // the only ceiling on anonymous traffic. + lim := stream.NewTranscodeLimiter(10, 1) + + rels := make([]func(), 0, 5) + for range 5 { + rel, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rels = append(rels, rel) + } + for _, rel := range rels { + rel() + } + }) + + It("still applies the global cap to anonymous users", func() { + lim := stream.NewTranscodeLimiter(2, 1) + + rel1, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + rel2() + }) + }) + + Describe("Concurrent safety", func() { + It("survives parallel Acquire/release with consistent counts", func() { + lim := stream.NewTranscodeLimiter(5, 0) + + var wg sync.WaitGroup + var acquired int64 + var rejected int64 + var mu sync.Mutex + + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + rel, err := lim.Acquire(ctx, "alice") + mu.Lock() + if err == nil { + acquired++ + mu.Unlock() + rel() + } else { + rejected++ + mu.Unlock() + } + _ = i + }(i) + } + wg.Wait() + + Expect(acquired + rejected).To(Equal(int64(50))) + // After all releases, all 5 slots should be free again. + for range 5 { + _, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + } + _, err := lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) +}) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index de03b4d2f..f898e066e 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -2,6 +2,7 @@ package stream import ( "context" + "errors" "fmt" "io" "mime" @@ -28,13 +29,19 @@ type MediaStreamer interface { type TranscodingCache cache.FileCache func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer { - return &mediaStreamer{ds: ds, transcoder: t, cache: cache} + return &mediaStreamer{ + ds: ds, + transcoder: t, + cache: cache, + limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser), + } } type mediaStreamer struct { ds model.DataStore transcoder ffmpeg.FFmpeg cache cache.FileCache + limiter TranscodeLimiter } type streamJob struct { @@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } r, err := ms.cache.Get(ctx, job) if err != nil { - log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + // Rate-limit rejections are already logged at warn level by the + // producer; treating them as cache failures here would both + // double-log and mask actual cache problems. + if !errors.Is(err, ErrTooManyTranscodes) { + log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + } return nil, err } cached = r.Cached @@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache { return nil, os.ErrInvalid } - // Choose the appropriate context based on EnableTranscodingCancellation configuration. - // This is where we decide whether transcoding processes should be cancellable or not. + release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx)) + if err != nil { + log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached", + "id", job.mf.ID, "user", userName(ctx), + "maxConcurrent", conf.Server.Transcoding.MaxConcurrent, + "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser) + return nil, err + } + + // Choose the context that drives the ffmpeg process. + // + // When the limiter is enabled, force the request context so a + // client disconnect cancels ffmpeg and frees the slot promptly. + // Otherwise a client could open many transcodes, disconnect + // immediately, and still leave the configured cap's worth of + // ffmpeg processes draining in the background — which is exactly + // the DoS the limiter is meant to prevent. + // + // When the limiter is disabled, preserve the legacy behavior + // governed by EnableTranscodingCancellation so this PR does not + // change observable behavior for operators who have not opted in. var transcodingCtx context.Context - if conf.Server.EnableTranscodingCancellation { - // Use the request context directly, allowing cancellation when client disconnects + if job.ms.limiter.Enabled() || conf.Server.EnableTranscodingCancellation { transcodingCtx = ctx } else { - // Use background context with request values preserved. - // This prevents cancellation but maintains request metadata (user, client, etc.) transcodingCtx = request.AddValues(context.Background(), ctx) } @@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache { Offset: job.offset, }) if err != nil { + release() log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid } - return out, nil + // Tie the slot to the ffmpeg process: copyAndClose calls Close + // on this reader after io.Copy returns, which is exactly when + // ffmpeg has exited (either EOF or context cancellation). + return &releasingReadCloser{ReadCloser: out, release: release}, nil }) } @@ -255,3 +287,16 @@ func userName(ctx context.Context) string { return user.UserName } } + +// limiterKey returns the per-user bucket key used by the transcode limiter. +// For anonymous requests (e.g. public shares) it returns the empty string, +// which signals the limiter to skip the per-user cap entirely — otherwise +// every anonymous viewer of a public share would collide on the same key +// and starve each other within MaxConcurrentPerUser slots. The global cap +// still applies and remains the protection against runaway anonymous load. +func limiterKey(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); ok { + return user.UserName + } + return "" +} diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bbf868fa..676e8d6f8 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -2,6 +2,7 @@ package stream_test import ( "context" + "errors" "io" "os" @@ -10,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +63,58 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) + It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + // Rebuild the streamer with a tight cap. The first request will hold the + // ffmpeg reader open (we don't read/close it), saturating the single slot. + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Different cache key so it doesn't dedupe with the first request. + _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + + It("releases the slot once the stream is closed", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + _, _ = io.ReadAll(s1) + _ = s1.Close() + Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) + + // Slot should now be free for a different transcode. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + + It("does not consume a slot for raw streams", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + // First, saturate the single transcode slot. + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Raw stream must still succeed. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + It("returns a seekable stream if the file is complete in the cache", func() { s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 7d5a836b3..c7b8a4d4f 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,7 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/stream" + streampkg "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" @@ -48,10 +48,15 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{ + stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ Format: info.format, BitRate: info.bitrate, }) if err != nil { + if errors.Is(err, streampkg.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) + http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests) + return + } log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) return diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 7d15125b6..f39dec009 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" @@ -304,6 +305,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorDataNotFound, "data not found") case errors.Is(err, model.ErrNotAuthorized): err = newError(responses.ErrorAuthorizationFail) + case errors.Is(err, stream.ErrTooManyTranscodes): + err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") default: err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) } @@ -313,15 +316,31 @@ func mapToSubsonicError(err error) subError { } func sendError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, stream.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(stream.RetryAfterSeconds)) + sendResponseWithStatus(w, r, errorResponse(err), http.StatusTooManyRequests) + return + } + sendResponse(w, r, errorResponse(err)) +} + +func errorResponse(err error) *responses.Subsonic { subErr := mapToSubsonicError(err) response := newResponse() response.Status = responses.StatusFailed response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()} - - sendResponse(w, r, response) + return response } func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { + sendResponseWithStatus(w, r, payload, 0) +} + +// sendResponseWithStatus writes the response body in the format requested by +// the client. When status is non-zero, WriteHeader is called with that code +// before the body is written; callers that need to set additional headers +// (e.g. Retry-After) must set them before calling. +func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic, status int) { p := req.Params(r) f, _ := p.String("f") var response []byte @@ -356,6 +375,9 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub sendError(w, r, err) return } + if status != 0 { + w.WriteHeader(status) + } if payload.Status == responses.StatusOK { if log.IsGreaterOrEqualTo(log.LevelTrace) { diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index a2db4a0af..a1b66925a 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -3,11 +3,13 @@ package subsonic import ( "encoding/json" "encoding/xml" + "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -152,6 +154,24 @@ var _ = Describe("sendResponse", func() { }) }) + It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() { + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/rest/stream", nil) + + sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes)) + + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) + + var subsonicResponse responses.Subsonic + err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed)) + Expect(subsonicResponse.Error).ToNot(BeNil()) + Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric)) + Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode")) + }) + It("updates status pointer when an error occurs", func() { pointer := int32(0) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index b49af2b24..28b4585f0 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,12 +1,15 @@ package subsonic import ( + "context" + "errors" "fmt" "net/http" "strconv" "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -119,14 +122,28 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err case *model.Album: setHeaders(v.Name) - return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)) case *model.Artist: setHeaders(v.Name) - return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)) case *model.Playlist: setHeaders(v.Name) - return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)) default: return nil, model.ErrNotFound } } + +// handleArchiveErr swallows ErrTooManyTranscodes from archive downloads so the +// outer error handler does not try to write a 429 onto a response whose status +// and Content-Disposition have already been flushed. The archive ends up with +// the tracks that were written before the rejection (the rejected track and +// any following ones are omitted); the server-side log is the unambiguous +// signal operators can act on. +func handleArchiveErr(ctx context.Context, id string, err error) error { + if errors.Is(err, stream.ErrTooManyTranscodes) { + log.Warn(ctx, "Archive download finalized early: transcode cap reached", "id", id, err) + return nil + } + return err +} From 823d851b75f72cea1ca1092aec0e30ced5aeef79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 24 May 2026 00:51:58 -0300 Subject: [PATCH 256/596] refactor(transcoding): rename EnableTranscodingCancellation to Transcoding.EnableCancellation (#5523) Move the option into the nested Transcoding config group alongside the limit knobs it interacts with, so all transcoding-related settings live together. The old top-level name is still honored via the existing mapDeprecatedOption / logDeprecatedOptions plumbing, which forwards the value to the new key and logs a deprecation warning at startup. The old struct field is removed (the new field is the single source of truth); the deprecated default is removed so viper.IsSet correctly distinguishes "user set the legacy option" from "no one set it." --- conf/configuration.go | 6 ++++-- core/stream/media_streamer.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index b4c43f135..08f12fc94 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -47,7 +47,6 @@ type configOptions struct { UIWelcomeMessage string MaxSidebarPlaylists int EnableTranscodingConfig bool - EnableTranscodingCancellation bool EnableDownloads bool EnableExternalServices bool EnableM3UExternalAlbumArt bool @@ -169,6 +168,7 @@ type scannerOptions struct { type transcodingOptions struct { MaxConcurrent int MaxConcurrentPerUser int + EnableCancellation bool } type subsonicOptions struct { @@ -330,6 +330,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -455,6 +456,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation") // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") @@ -743,7 +745,6 @@ func setViperDefaults() { viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("enabletranscodingconfig", false) - viper.SetDefault("enabletranscodingcancellation", false) viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) @@ -830,6 +831,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.minimalclients", "SubMusic") viper.SetDefault("transcoding.maxconcurrent", 0) viper.SetDefault("transcoding.maxconcurrentperuser", 0) + viper.SetDefault("transcoding.enablecancellation", false) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index f898e066e..b09d9bab8 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -248,10 +248,10 @@ func NewTranscodingCache() TranscodingCache { // the DoS the limiter is meant to prevent. // // When the limiter is disabled, preserve the legacy behavior - // governed by EnableTranscodingCancellation so this PR does not - // change observable behavior for operators who have not opted in. + // governed by Transcoding.EnableCancellation so unchanged configs + // keep their previous observable behavior. var transcodingCtx context.Context - if job.ms.limiter.Enabled() || conf.Server.EnableTranscodingCancellation { + if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation { transcodingCtx = ctx } else { transcodingCtx = request.AddValues(context.Background(), ctx) From fc9cdf39c8098e2c0e3d315aee4aff2d7772caa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 27 May 2026 23:18:35 -0300 Subject: [PATCH 257/596] fix(conf): make Dir a plain value type to prevent sync.Once corruption (#5543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dir embedded sync.Once directly and exposed a value-receiver GoString so that pretty.Sprintf("%# v", Server) could render the path. That meant every pretty-print copied the entire Dir along with its Once, and a goroutine concurrently using the original (or any copy) for Path() could hit a "sync: unlock of unlocked mutex" runtime fatal error. The failure was reproduced deterministically on Windows CI when test-suite shuffle ordering raced cache initialization (utils/cache/file_caches.go's NewFileCache.func1 -> conf.CacheFolder.MustPath) against the configuration-dump pretty.Sprintf in Load(). Drop the sync.Once entirely. Dir is now a plain {path, perm} value type, and Path() calls os.MkdirAll on every invocation. MkdirAll is idempotent, so repeated calls on an existing directory cost one stat syscall — negligible for the few config paths read at startup and during cache init. This removes the entire class of bug: - No Mutex, so copies (via reflection, pretty-print, etc.) are safe. - No state pointer, so no nil-state defensive checks scattered across methods, and no risk of two copies seeing different lifecycle state. - go vet is happy with the value receivers — the //nolint:govet suppression on GoString is gone. Adds two regression tests in conf/dir_test.go: - GoString renders Dir as a quoted path under pretty.Sprintf (and does not leak the internal struct fields). - Concurrent copy + Path() stress test, locking in the copy-safety property in case the type ever grows non-trivial state again. --- conf/dir.go | 47 ++++++++++++++++++++++++----------------------- conf/dir_test.go | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/conf/dir.go b/conf/dir.go index abbe72b77..f7a14b933 100644 --- a/conf/dir.go +++ b/conf/dir.go @@ -1,20 +1,20 @@ package conf import ( + "cmp" "fmt" "os" - "sync" ) -// Dir wraps a directory path and lazily creates the directory on first use. -// The directory is created at most once; if creation fails, the error is -// permanently cached (sync.Once semantics). Dir is not safe for mutation -// after Path() has been called. +// Dir wraps a directory path and creates the directory on demand. Dir is a +// plain value type — safe to copy, compare, and print via reflection-based +// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards. +// Directory creation is delegated to os.MkdirAll on every Path() call; +// MkdirAll is idempotent, so repeated calls cost one stat syscall when the +// directory already exists. type Dir struct { path string perm os.FileMode - once sync.Once - err error } // NewDir creates a new Dir with the given path and default permissions (os.ModePerm). @@ -23,31 +23,32 @@ func NewDir(path string) Dir { } // NewDirWithPerm creates a new Dir with the given path and permissions. +// A perm of 0 is treated as "default" and resolves to os.ModePerm at +// directory-creation time; pass an explicit non-zero mode to constrain the +// permissions. func NewDirWithPerm(path string, perm os.FileMode) Dir { return Dir{path: path, perm: perm} } // String returns the raw path without creating the directory. Satisfies fmt.Stringer. -func (d *Dir) String() string { +func (d Dir) String() string { return d.path } -// Path creates the directory on first call (via sync.Once) and returns the path. -func (d *Dir) Path() (string, error) { - d.once.Do(func() { - if d.path == "" { - return - } - d.err = os.MkdirAll(d.path, d.perm) - if d.err != nil { - d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err) - } - }) - return d.path, d.err +// Path ensures the directory exists and returns its path. Safe to call +// repeatedly; an empty path is returned as-is with no error. +func (d Dir) Path() (string, error) { + if d.path == "" { + return "", nil + } + if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil { + return d.path, fmt.Errorf("creating directory %q: %w", d.path, err) + } + return d.path, nil } // MustPath calls Path() and calls logFatal on error. -func (d *Dir) MustPath() string { +func (d Dir) MustPath() string { path, err := d.Path() if err != nil { logFatal("creating directory:", err) @@ -57,12 +58,12 @@ func (d *Dir) MustPath() string { // GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) // prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer +func (d Dir) GoString() string { return fmt.Sprintf("%q", d.path) } // MarshalText returns the raw path bytes. No side effects. -func (d *Dir) MarshalText() ([]byte, error) { +func (d Dir) MarshalText() ([]byte, error) { return []byte(d.path), nil } diff --git a/conf/dir_test.go b/conf/dir_test.go index 2dd4250bc..79db379d2 100644 --- a/conf/dir_test.go +++ b/conf/dir_test.go @@ -2,7 +2,9 @@ package conf_test import ( "os" + "sync" + "github.com/kr/pretty" "github.com/navidrome/navidrome/conf" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,9 +37,9 @@ var _ = Describe("Dir", func() { Expect(target).To(BeADirectory()) }) - It("returns the same result on subsequent calls (sync.Once)", func() { + It("is idempotent on subsequent calls", func() { dir := GinkgoT().TempDir() - target := dir + "/once" + target := dir + "/idempotent" d := conf.NewDir(target) path1, err1 := d.Path() @@ -45,6 +47,7 @@ var _ = Describe("Dir", func() { Expect(err1).ToNot(HaveOccurred()) Expect(err2).ToNot(HaveOccurred()) Expect(path1).To(Equal(path2)) + Expect(target).To(BeADirectory()) }) It("returns an error when directory cannot be created", func() { @@ -124,4 +127,38 @@ var _ = Describe("Dir", func() { Expect(d2.String()).To(Equal(d1.String())) }) }) + + Describe("GoString", func() { + // Regression: pretty.Sprintf("%# v", ...) is used by the + // configuration dump. It must render Dir as a quoted path via + // GoString, not dump the internal struct fields. + It("renders Dir as a quoted path under pretty.Sprintf", func() { + type host struct { + DataFolder conf.Dir + } + h := host{DataFolder: conf.NewDir("./data")} + out := pretty.Sprintf("%# v", h) + Expect(out).To(ContainSubstring(`DataFolder: "./data"`)) + Expect(out).ToNot(ContainSubstring("perm:")) + Expect(out).ToNot(ContainSubstring("path:")) + }) + + It("is safe to copy and use concurrently", func() { + // Regression for the Windows "sync: unlock of unlocked mutex" + // crash that was caused by copying a Dir embedding sync.Once. + // Dir is a plain value type now, but keep the concurrent stress + // test to lock in the property. + dir := GinkgoT().TempDir() + d := conf.NewDir(dir + "/race") + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + copy1 := d + _ = pretty.Sprintf("%# v", copy1) + _, _ = copy1.Path() + }) + } + wg.Wait() + }) + }) }) From 74a5c0c6d116c206fed3f2287af5ab46a3f62a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 27 May 2026 23:29:17 -0300 Subject: [PATCH 258/596] fix(playlists): preserve unchanged fields on partial REST updates (#5542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(playlists): preserve unchanged fields on partial REST updates (#5541) The REST adapter for playlists was discarding the `cols` argument that rest.Put provides (the list of fields actually present in the JSON body). updatePlaylistEntity then compared the deserialized entity's zero-valued Name/Comment against the DB row, decided "content changed", and called updateMetadata with &entity.Name — overwriting the name with the empty string. This surfaced via the Playlists list view's bulk "Make Public" action, which sends N parallel `PUT /api/playlist/{id}` requests with body `{"public": true}`. Affected playlists ended up with their names wiped (UI showed "Loading..." indefinitely). The per-row Public toggle was unaffected because it spreads the full record into the payload. Honor the cols list: gate every field-change check and every pointer passed to updateMetadata by whether the field was actually in the request body. Empty cols falls back to the existing "treat as a full record" behavior so non-REST callers are unaffected. * test(playlists): cover rules-only PUT + case-variant owner-change guard Follow-ups from manual testing and code review of the prior commit: - Manual testing confirmed Feishin-style rules-only PUT works correctly on the fix; add ginkgo regression tests for rules-only update, name+ rules combined, idempotent rules PUT (no-op), and bulk Make-Public preserving rules on smart playlists. - Keep the non-admin owner-change permission check gated on the deserialized entity content (not on `sent("ownerId")`) so a case-variant JSON key like {"OwnerId":"x"} can't downgrade the 403 to a silent 200. Go's json decoder is case-insensitive on struct field matching but rest.Put's field-name extraction is case- sensitive; the entity-based guard catches both spellings. The apply-side gating on ownerChanged still prevents the actual mutation, so this was a behavioral (not security) regression, but worth fixing. Adds a regression test asserting the case-variant key still returns rest.ErrPermissionDenied. - Correct misleading doc on applyContentUpdate: the path does not rewrite the backing M3U file; it goes through updateMetadata which bumps updatedAt and invalidates cached cover-art URLs. * fix(playlists): match REST cols case-insensitively (PR #5542 review) Go's encoding/json populates struct fields from case-variant keys like {"Name":"x"} or {"OwnerId":"y"}, but rest.Put's getFieldNames extracts raw JSON keys verbatim. With case-sensitive matching, sentFields would ignore the field on the update side — a request with {"Name":"Renamed"} would parse into entity.Name but then sent("name") returns false and the rename silently no-ops. Normalize both sides to lowercase. The entity-based owner-permission guard added in the previous commit remains as belt-and-suspenders but is now redundant with this change. Also clarify the applyContentUpdate doc comment: namePtr/commentPtr are nil when the field is absent OR present-but-unchanged, while publicPtr only tracks presence (an idempotent public is still forwarded). * refactor(playlists): drop redundant entity-based owner-permission guard The case-insensitive sentFields predicate already prevents case-variant JSON keys like {"OwnerId":"x"} from bypassing the ownerChanged check, so the duplicated entity-content guard is no longer load-bearing. Strengthen the regression test into a DescribeTable covering canonical, PascalCase, all-upper, and all-lower spellings to lock in the case-insensitive contract. --- core/playlists/rest_adapter.go | 111 ++++++++++++++---- core/playlists/rest_adapter_test.go | 169 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 25 deletions(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index c9b7c4ea6..3f886aadd 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -4,11 +4,13 @@ import ( "context" "errors" "reflect" + "strings" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" ) // --- REST adapter (follows Share/Library pattern) --- @@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) +func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { +// +// cols names the fields the client actually sent in the JSON body (extracted by +// rest.Put). When non-empty, fields outside cols are not considered changed and +// are left untouched — this prevents partial requests like bulk "Make Public" +// (body: {"public": true}) from wiping fields that just happen to be zero in +// the deserialized entity (see issue #5541). An empty cols means "treat the +// entity as a complete record" — preserved for callers that don't use the REST +// wrapper. +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity return err } } + + sent := sentFields(cols) + usr, _ := request.UserFrom(ctx) - if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { + ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID + if !usr.IsAdmin && ownerChanged { return rest.ErrPermissionDenied } - contentChanged := entity.Name != current.Name || - entity.Comment != current.Comment || - (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || - !rulesEqual(current.Rules, entity.Rules) + nameChanged := sent("name") && entity.Name != current.Name + commentChanged := sent("comment") && entity.Comment != current.Comment + rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) - if contentChanged { - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID - } + if nameChanged || commentChanged || ownerChanged || rulesChanged { + return s.applyContentUpdate(ctx, current, entity, sent, + nameChanged, commentChanged, ownerChanged, rulesChanged) + } + return s.applyFlagsOnly(ctx, current, entity, sent) +} + +// applyContentUpdate handles updates that change at least one of name/comment/ +// owner/rules. It goes through updateMetadata, which always bumps updatedAt +// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the +// field is absent from the request OR present-but-unchanged (so updateMetadata +// skips them); publicPtr is nil only when public is absent from the request +// (an idempotent public value is still forwarded). +func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool, +) error { + if ownerChanged { + current.OwnerID = entity.OwnerID + } + if rulesChanged { current.Rules = entity.Rules - if current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - - // Only sync/public changed — skip updatedAt so cover art URLs stay stable - var cols []string - if current.Path != "" && current.Sync != entity.Sync { + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync - cols = append(cols, "sync") } - if current.Public != entity.Public { + var namePtr, commentPtr *string + var publicPtr *bool + if nameChanged { + namePtr = &entity.Name + } + if commentChanged { + commentPtr = &entity.Comment + } + if sent("public") { + publicPtr = &entity.Public + } + return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr) +} + +// applyFlagsOnly handles updates that only toggle sync/public — skips +// updatedAt so cover art URLs stay stable. +func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, +) error { + var updateCols []string + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + updateCols = append(updateCols, "sync") + } + if sent("public") && current.Public != entity.Public { current.Public = entity.Public - cols = append(cols, "public") + updateCols = append(updateCols, "public") } - if len(cols) == 0 { + if len(updateCols) == 0 { return nil } - return s.ds.Playlist(ctx).Put(current, cols...) + return s.ds.Playlist(ctx).Put(current, updateCols...) +} + +// sentFields returns a predicate that reports whether a JSON field was present +// in the request body. Matching is case-insensitive to mirror Go's json +// decoder, which populates struct fields from case-variant keys like +// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity +// as a full record" — every field is considered sent. +func sentFields(cols []string) func(string) bool { + if len(cols) == 0 { + return func(string) bool { return true } + } + set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} }) + return func(field string) bool { + _, ok := set[strings.ToLower(field)] + return ok + } } func rulesEqual(a, b *criteria.Criteria) bool { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 68461b259..79d72d147 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -125,6 +125,25 @@ var _ = Describe("REST Adapter", func() { Expect(err).To(Equal(rest.ErrPermissionDenied)) }) + DescribeTable("denies regular user from changing ownership under any case-variant JSON key", + func(colName string) { + // rest.Put's field-name extraction is case-sensitive, but Go's + // json decoder is case-insensitive on struct fields, so any + // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates + // entity.OwnerID. sentFields normalizes both sides so the + // permission gate fires regardless of casing. + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{OwnerID: "other-user"} + err := repo.Update("pls-1", pls, colName) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }, + Entry("canonical camelCase", "ownerId"), + Entry("PascalCase", "OwnerId"), + Entry("all upper", "OWNERID"), + Entry("all lower", "ownerid"), + ) + It("updates smart playlist rules", func() { mockPlsRepo.Data["smart-1"] = &model.Playlist{ ID: "smart-1", @@ -218,6 +237,156 @@ var _ = Describe("REST Adapter", func() { err := repo.Update("nonexistent", pls) Expect(err).To(Equal(rest.ErrNotFound)) }) + + // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public") + // must only touch the fields the client actually sent. The cols list from + // rest.Put names those fields; fields outside it must be left alone, even + // when the deserialized entity has zero values for them. + Context("with partial updates (cols)", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + mockPlsRepo.Data["partial"] = &model.Playlist{ + ID: "partial", + Name: "Original Name", + Comment: "Original comment", + OwnerID: "user-1", + Public: false, + } + }) + + It("preserves name and comment when only public is sent (bulk Make Public)", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("preserves name when only sync is sent for a file-backed playlist", func() { + mockPlsRepo.Data["file-partial"] = &model.Playlist{ + ID: "file-partial", + Name: "Keep Me", + OwnerID: "user-1", + Path: "/music/p.m3u", + Sync: true, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + + It("renames the playlist when only name is sent", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeFalse()) + }) + + It("clears the comment when an empty comment is sent explicitly", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + }) + + It("updates rules-only on a smart playlist (Feishin-style edit)", func() { + mockPlsRepo.Data["smart-partial"] = &model.Playlist{ + ID: "smart-partial", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Public: true, + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} + err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("updates name and rules together (smart-playlist Edit form)", func() { + mockPlsRepo.Data["smart-edit"] = &model.Playlist{ + ID: "smart-edit", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} + err := repo.Update("smart-edit", + &model.Playlist{Name: "Smart Renamed", Rules: newRules}, + "name", "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + }) + + It("does not bump the saved rules on an idempotent rules-only PUT", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{ + ID: "smart-idempotent", + Name: "Smart Idempotent", + OwnerID: "user-1", + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + // Same rules sent back — rulesEqual should report no change and + // the request should no-op (no Put call). + sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened + }) + + It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-public"] = &model.Playlist{ + ID: "smart-public", + Name: "Smart Public", + OwnerID: "user-1", + Public: false, + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-public", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public")) + }) + + It("does not treat a missing ownerId as an ownership transfer attempt", func() { + // A non-admin user sending only {public:true} should not be blocked + // just because OwnerID is the zero value in the deserialized entity. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + }) + + It("matches cols case-insensitively (mirrors json decoder behavior)", func() { + // Go's json decoder populates struct fields from case-variant keys + // like {"Name":"x"}, but rest.Put's field-name extraction is + // case-sensitive. sentFields normalizes both sides so a request + // with {"Name":"Renamed"} is honored, not silently ignored. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + }) + }) }) Describe("Delete", func() { From 833c50adc7d45dcc9f0f6dfb700e02be9a3706a1 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 28 May 2026 00:07:49 -0300 Subject: [PATCH 259/596] test(stream): fix data race in MediaStreamer transcoding cap tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three It blocks that build a tight-cap streamer each spawned a fresh transcoding cache without waiting for its background initialization. The init goroutine reads conf.Server.CacheFolder, which races against SnapshotConfig's pointer-swap restore (Server = &restored) fired by DeferCleanup at the end of the spec. CI tripped the race under -shuffle=on -race; locally it reproduced about 10% of the time. Wait for tightCache.Available() before constructing the streamer, mirroring the outer BeforeEach. For the slot-saturation spec, swap in a blocking io.Pipe-backed mock ffmpeg so the cache's background copyAndClose can't drain the source and release the slot — the previous behavior happened to work only because the cache wasn't yet available and the no-cache path was exercised. --- core/stream/media_streamer_test.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 676e8d6f8..f5ca16d3f 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -64,11 +64,19 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Duration()).To(Equal(float32(257.0))) }) It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { - // Rebuild the streamer with a tight cap. The first request will hold the - // ffmpeg reader open (we don't read/close it), saturating the single slot. + // Use an ffmpeg whose Read blocks indefinitely so the cache's + // background copy can't drain the source and release the slot — + // keeping the single transcode slot pinned for this test. + pr, pw := io.Pipe() + DeferCleanup(func() { _ = pw.Close() }) + blockingFFmpeg := tests.NewMockFFmpeg("") + blockingFFmpeg.Reader = pr + conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) @@ -83,7 +91,9 @@ var _ = Describe("MediaStreamer", func() { It("releases the slot once the stream is closed", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) @@ -101,7 +111,9 @@ var _ = Describe("MediaStreamer", func() { It("does not consume a slot for raw streams", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") // First, saturate the single transcode slot. From 59b6755014be0ec7e722ec94819723b28875e403 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 28 May 2026 19:25:26 -0300 Subject: [PATCH 260/596] chore(deps): update dependencies to latest versions in go.mod and go.sum Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 937cffbd5..29a415126 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.5.5 - github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -39,8 +39,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.3 - github.com/onsi/gomega v1.40.0 + github.com/onsi/ginkgo/v2 v2.29.0 + github.com/onsi/gomega v1.41.0 github.com/pelletier/go-toml/v2 v2.3.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -59,10 +59,10 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.40.0 - golang.org/x/net v0.54.0 + golang.org/x/image v0.41.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 @@ -81,7 +81,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect @@ -115,7 +115,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -133,7 +133,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect golang.org/x/tools v0.45.0 // indirect diff --git a/go.sum b/go.sum index 0c550e47d..57289abfd 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -73,8 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= @@ -193,10 +193,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= -github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= +github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -224,8 +224,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -316,10 +316,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= -golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -338,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -364,8 +364,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= From 2a43c4683ea41492a6ad8c6b22af9a1f7eacb1e9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 28 May 2026 22:09:54 -0300 Subject: [PATCH 261/596] chore: go fix Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/inspect.go | 6 +++--- core/artwork/benchmark_e2e_test.go | 2 +- core/artwork/benchmark_helpers_test.go | 4 ++-- core/ffmpeg/ffmpeg.go | 4 ++-- core/share.go | 2 +- log/journal.go | 2 +- model/tag_mappings.go | 4 ++-- persistence/sql_search_fts.go | 12 ++++++------ persistence/sql_search_like.go | 2 +- plugins/host_taskqueue.go | 5 ++--- plugins/host_taskqueue_test.go | 14 +++++++------- scheduler/crontab_schedule_test.go | 2 +- server/subsonic/api_test.go | 2 +- server/throttle_backlog.go | 5 ++--- utils/cache/benchmark_test.go | 4 ++-- 15 files changed, 34 insertions(+), 36 deletions(-) diff --git a/cmd/inspect.go b/cmd/inspect.go index 9f9270b1e..5e88793cc 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{ }, } -var marshalers = map[string]func(interface{}) ([]byte, error){ +var marshalers = map[string]func(any) ([]byte, error){ "pretty": prettyMarshal, "toml": toml.Marshal, "yaml": yaml.Marshal, "json": json.Marshal, - "jsonindent": func(v interface{}) ([]byte, error) { + "jsonindent": func(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }, } -func prettyMarshal(v interface{}) ([]byte, error) { +func prettyMarshal(v any) ([]byte, error) { out := v.([]core.InspectOutput) var res strings.Builder for i := range out { diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index 393cbb473..bf3d435a8 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() r, _, err := aw.Get(context.Background(), artID, 300, true) diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 60990bb8b..0076506f3 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte { // generateGradientImage creates an RGBA image with a diagonal gradient pattern. func generateGradientImage(width, height int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { + for y := range height { + for x := range width { r := uint8((x * 255) / width) g := uint8((y * 255) / height) b := uint8(((x + y) * 255) / (width + height)) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index a3f6cd7d2..58e9fd152 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -496,8 +496,8 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) // instead of decoding all frames up to the offset (slow). insertAt := len(args) - for i := len(args) - 1; i >= 0; i-- { - if args[i] == "-i" { + for i, arg := range slices.Backward(args) { + if arg == "-i" { insertAt = i break } diff --git a/core/share.go b/core/share.go index eb9b63ae9..5a611c7f0 100644 --- a/core/share.go +++ b/core/share.go @@ -98,7 +98,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + firstId, _, _ := strings.Cut(s.ResourceIDs, ",") v, err := model.GetEntityByID(r.ctx, r.ds, firstId) if err != nil { return "", err diff --git a/log/journal.go b/log/journal.go index f1c17d2e7..dd7cf5400 100644 --- a/log/journal.go +++ b/log/journal.go @@ -36,6 +36,6 @@ func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) { if !ok { priority = 6 // default to info for unknown levels } - prefix := []byte(fmt.Sprintf("<%d>", priority)) + prefix := fmt.Appendf(nil, "<%d>", priority) return append(prefix, formatted...), nil } diff --git a/model/tag_mappings.go b/model/tag_mappings.go index af76de741..dd19a157b 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -47,8 +47,8 @@ func (c TagConf) SplitTagValue(values []string) []string { tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for _, part := range parts { + parts := strings.SplitSeq(tag, consts.Zwsp) + for part := range parts { result = append(result, strings.TrimSpace(part)) } } diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index e9b961d91..b90dc937b 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -66,7 +66,7 @@ func normalizeForFTS(values ...string) string { result = append(result, variant) } for _, v := range values { - for _, word := range strings.Fields(v) { + for word := range strings.FieldsSeq(v) { transliterated := sanitize.Accents(word) // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) @@ -279,9 +279,9 @@ type ftsSearch struct { } // ToSql returns a single-query fallback for the REST filter path (no two-phase split). -func (s *ftsSearch) ToSql() (string, []interface{}, error) { +func (s *ftsSearch) ToSql() (string, []any, error) { sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" - return sql, []interface{}{s.matchExpr}, nil + return sql, []any{s.matchExpr}, nil } // execute runs a two-phase FTS5 search: @@ -373,8 +373,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Check if all effective FTS tokens are very short (≤2 chars). // Short tokens with prefix matching are too broad when special chars were stripped. // For quoted phrases, extract the content and check the tokens inside. - tokens := strings.Fields(ftsQuery) - for _, t := range tokens { + tokens := strings.FieldsSeq(ftsQuery) + for t := range tokens { t = strings.TrimSuffix(t, "*") // Skip internal phrase placeholders if strings.HasPrefix(t, "\x00") { @@ -390,7 +390,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Extract content between quotes inner := strings.Trim(t, `"`) innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") - for _, it := range strings.Fields(innerAlpha) { + for it := range strings.FieldsSeq(innerAlpha) { if len(it) > 2 { return false } diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go index 769a911d5..972545ac5 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -16,7 +16,7 @@ type likeSearch struct { filter Sqlizer } -func (s *likeSearch) ToSql() (string, []interface{}, error) { +func (s *likeSearch) ToSql() (string, []any, error) { return s.filter.ToSql() } diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index eff73c822..a5db3344f 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path/filepath" "sync" @@ -540,9 +541,7 @@ func (s *taskQueueServiceImpl) cleanupLoop() { func (s *taskQueueServiceImpl) runCleanup() { s.mu.Lock() queues := make(map[string]*queueState, len(s.queues)) - for k, v := range s.queues { - queues[k] = v - } + maps.Copy(queues, s.queues) s.mu.Unlock() now := time.Now().UnixMilli() diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index 8a58f1eb4..faff79c8e 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -367,8 +367,8 @@ var _ = Describe("TaskQueueService", func() { // Enqueue several more tasks — they stay pending since the worker is busy var pendingIDs []string - for i := 0; i < 3; i++ { - taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i))) + for i := range 3 { + taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) pendingIDs = append(pendingIDs, taskID) } @@ -674,8 +674,8 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) // Enqueue 5 tasks - for i := 0; i < 5; i++ { - _, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i))) + for i := range 5 { + _, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) } @@ -1112,7 +1112,7 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // the second will be dequeued but block on the rate limiter (status=running), // the rest will stay pending. var taskIDs []string - for i := 0; i < 5; i++ { + for range 5 { output, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-cancel", @@ -1186,11 +1186,11 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Enqueue several tasks - for i := 0; i < 4; i++ { + for i := range 4 { _, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-clear", - Payload: []byte(fmt.Sprintf("task-%d", i)), + Payload: fmt.Appendf(nil, "task-%d", i), }) Expect(err).ToNot(HaveOccurred()) } diff --git a/scheduler/crontab_schedule_test.go b/scheduler/crontab_schedule_test.go index b1e26f1de..b616f0884 100644 --- a/scheduler/crontab_schedule_test.go +++ b/scheduler/crontab_schedule_test.go @@ -185,7 +185,7 @@ var _ = Describe("ParseCrontab", func() { // findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63). func findSetBit(v uint64) int { v &^= 1 << 63 // clear starBit - for i := 0; i < 63; i++ { + for i := range 63 { if v&(1<<uint(i)) != 0 { return i } diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index a1b66925a..b565109a5 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -1,6 +1,7 @@ package subsonic import ( + "context" "encoding/json" "encoding/xml" "fmt" @@ -13,7 +14,6 @@ import ( "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "golang.org/x/net/context" ) var _ = Describe("sendResponse", func() { diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go index c3672fd1e..0d31d289e 100644 --- a/server/throttle_backlog.go +++ b/server/throttle_backlog.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "maps" "net/http" "sync" "time" @@ -76,9 +77,7 @@ func (t *requestThrottle) handler(next http.Handler) http.Handler { next.ServeHTTP(buf, r) }() - for k, v := range buf.header { - w.Header()[k] = v - } + maps.Copy(w.Header(), buf.header) if buf.code > 0 { w.WriteHeader(buf.code) } diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index e3fc08eda..9ab07cf18 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -116,7 +116,7 @@ func BenchmarkConcurrentCacheRead(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) @@ -152,7 +152,7 @@ func BenchmarkConcurrentCacheMiss(b *testing.B) { wg.Add(n) // All goroutines request the SAME key (not yet cached) item := &benchItem{key: fmt.Sprintf("miss-%d", i)} - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) From dad4203f9a93225a17d5474318372ae0e4a9069f Mon Sep 17 00:00:00 2001 From: Tales Costa <talonacontato@gmail.com> Date: Tue, 2 Jun 2026 09:38:57 -0300 Subject: [PATCH 262/596] fix(ui): Gruvbox Dark colors (#5553) * Add Gruvbox Dark theme Add Gruvbox Dark color theme including: - gruvboxDark.js with full palette and component overrides - gruvboxDark.css.js with custom player styles * Fix: move error state to MuiFormHelperText --- ui/src/themes/gruvboxDark.css.js | 9 ++++- ui/src/themes/gruvboxDark.js | 65 ++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js index dc1f64041..f482451b2 100644 --- a/ui/src/themes/gruvboxDark.css.js +++ b/ui/src/themes/gruvboxDark.css.js @@ -5,7 +5,7 @@ const stylesheet = ` } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #458588 + background-color: #ebdbb2 } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { @@ -50,6 +50,13 @@ const stylesheet = ` .MuiCheckbox-colorSecondary.Mui-checked { color: #458588 !important } +.react-jinke-music-player-main .music-player-panel svg { + color: #ebdbb2; + fill: #ebdbb2; +} +.react-jinke-music-player-main .music-player-panel button { + color: #ebdbb2; +} ` export default stylesheet diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 20f5c732f..0f4cbd7c4 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -14,22 +14,34 @@ export default { background: { default: '#282828', }, + text: { + primary: '#ebdbb2', + secondary: '#a89984', + }, }, overrides: { MuiPaper: { root: { color: '#ebdbb2', backgroundColor: '#3c3836', - MuiSnackbarContent: { - root: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - message: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - }, + }, + }, + MuiSnackbarContent: { + root: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + message: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + }, + MuiTypography: { + root: { + color: '#ebdbb2', + }, + colorTextSecondary: { + color: '#a89984', }, }, MuiButton: { @@ -45,6 +57,19 @@ export default { color: '#ebdbb2', }, }, + MuiListItemIcon: { + root: { + color: '#ebdbb2', + }, + }, + MuiListItemText: { + primary: { + color: '#ebdbb2', + }, + secondary: { + color: '#a89984', + }, + }, MuiChip: { clickable: { background: '#49483e', @@ -57,11 +82,10 @@ export default { }, MuiFormHelperText: { root: { - Mui: { - error: { - color: '#cc241d', - }, - }, + color: '#ebdbb2', + }, + error: { + color: '#cc241d', }, }, MuiTableHead: { @@ -113,6 +137,17 @@ export default { 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', }, }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#ebdbb2', + }, + albumSubtitle: { + color: '#a89984', + }, + }, }, player: { theme: 'dark', From bc107d1ceed30f78abc1c5fb3d1a601a4f099e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 3 Jun 2026 20:03:08 -0400 Subject: [PATCH 263/596] fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set (#5559) * fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set When a client reports playback with ignoreScrobble=true, the reportPlayback handler suppressed both the scrobble submission and the NowPlaying update sent to external agents (Last.fm, ListenBrainz, plugins). These are independent concerns: ignoring the scrobble submission should not stop Navidrome from telling external services what is currently playing. The !params.IgnoreScrobble guard now applies only to the scrobble submission and play-count path; the NowPlaying dispatch is gated solely by the player's ScrobbleEnabled flag. This mirrors the legacy scrobble endpoint, where submission=false has always still set NowPlaying. * test(scrobbler): assert no scrobble dispatch when ignoreScrobble=true Address PR review feedback: explicitly verify that ignoreScrobble=true suppresses the scrobble submission (not just the play count) while NowPlaying is still dispatched, so the flag cannot regress into ignoring nothing. Also expand the NowPlaying gating comment to spell out the IgnoreScrobble vs ScrobbleEnabled rules and identify the external agents involved. --- core/scrobbler/play_tracker.go | 9 ++++++++- core/scrobbler/play_tracker_test.go | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index bdb261ef2..860a80bce 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - if !params.IgnoreScrobble && player.ScrobbleEnabled && + // NowPlaying gating, by design distinct from scrobble submission: + // - IgnoreScrobble=true -> still send NowPlaying (suppresses only the + // scrobble submission/play-count above), mirroring the legacy scrobble + // endpoint's submission=false behavior. + // - player.ScrobbleEnabled=false -> never send NowPlaying. + // External agents here are the active scrobblers (Last.fm, ListenBrainz, and + // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. + if player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 5383244cd..b5a478c2a 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -521,6 +521,7 @@ var _ = Describe("PlayTracker", func() { }) It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + fake.ScrobbleCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) @@ -531,6 +532,7 @@ var _ = Describe("PlayTracker", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) + Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse()) }) It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { @@ -715,14 +717,14 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) - It("does NOT dispatch when ignoreScrobble=true", func() { + It("still dispatches NowPlaying when ignoreScrobble=true", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("does NOT dispatch when ScrobbleEnabled=false", func() { From 37908d3cead9d982a1075b5f3fdd3314d32a87e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 4 Jun 2026 19:43:13 -0400 Subject: [PATCH 264/596] fix: enforce ownership atomically on player and share updates (#5563) * fix(player): enforce ownership atomically on player update The native API PUT /api/player/{id} authorized writes using the userId in the request body via isPermitted, while the actual write targeted the row by the URL id. A non-admin user could set userId to their own id in the body to pass the check, then overwrite and reassign ownership of another user's player row identified by the URL id (cross-tenant takeover). Add updateOwned on the base repository: an atomic, ownership-restricted UPDATE that folds the owner predicate (user_id = caller) into the WHERE clause for non-admins, so a row owned by another user simply does not match and no write happens. It also never writes user_id, so ownership is immutable on update and no caller (admin included) can reassign a player to a different owner. Unlike put, it never falls through to an INSERT, so a non-matching id returns ErrNotFound instead of creating a row. playerRepository.Update now uses updateOwned. Extract filterUpdateValues, shared by put and updateOwned, so the update-column filtering lives in one place. The create path (Save) keeps the body-based isPermitted check, which is correct for new records. Add regression tests covering the spoofed-userId hijack, regular-user and admin ownership reassignment, legitimate owner updates, and the nonexistent-player case. * fix(share): enforce ownership atomically on share update shareRepository.Update authorized writes with a separate checkOwnership SELECT, then wrote the row via put(). The check and the write were two statements (a TOCTOU window), put() could fall through to an INSERT on a missing id, and put() would write user_id if present in the update columns, so ownership was mutable on update. Switch Update to updateOwned, which folds the owner predicate into the UPDATE's WHERE clause, never writes user_id, and never inserts. This makes the write atomic and ownership immutable, and drops the extra ownership SELECT on the happy path. To preserve the previous 403/404 distinction, updateOwned now classifies a non-matching id: it runs a follow-up existence check only on the failure path (count == 0, where no write happened, so no TOCTOU) and returns ErrPermissionDenied when the row exists but is owned by another user, ErrNotFound when the id is missing. The player path inherits this: its tests now expect ErrPermissionDenied for a non-owner targeting an existing row, and ErrNotFound only for a genuinely missing id. Add share regression tests for the nonexistent-id and ownership- reassignment cases. checkOwnership remains in use by Delete. * refactor(persistence): extract canonical ownerFilter predicate The non-admin owner-restriction predicate (user_id = me, exempting admins and headless contexts) was spelled out independently in updateOwned and in playerRepository.addRestriction. The two copies had drifted: addRestriction did not exempt the headless/invalid user, so a headless context restricted to user_id = "-1" (matching nothing) while updateOwned exempted it. Extract sqlRepository.ownerFilter as the single definition and route both call sites through it. addRestriction now exempts the headless user too; that path is only reachable from the authenticated native API, so there is no production behavior change, but the latent divergence is removed. playlistRepository.userFilter is intentionally left alone: it encodes a different policy (public OR owner_id = me, on the owner_id column). * fix(share): preserve all-columns update path in Update shareRepository.Update unconditionally appended "updated_at" to cols. filterUpdateValues treats an empty cols as "update every column", so when a caller passes no columns, appending "updated_at" turned an all-columns update into an updated_at-only one, silently dropping every other field. The REST controller always populates cols from the request-body field names, so this path is not reachable through the native API and the behavior was latent (and pre-existing). Guard the append so the all-columns path is preserved, and add a regression test that updates with no columns and asserts the other fields persist. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/player_repository.go | 20 +++--- persistence/player_repository_test.go | 87 +++++++++++++++++++++++- persistence/share_repository.go | 15 ++--- persistence/share_repository_test.go | 38 +++++++++++ persistence/sql_base_repository.go | 95 ++++++++++++++++++++++----- 5 files changed, 216 insertions(+), 39 deletions(-) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 6c8339378..c9c7d3b4b 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -67,11 +67,10 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { if len(sql) > 0 { s = append(s, sql[0]) } - u := loggedUser(r.ctx) - if u.IsAdmin { - return s + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) } - return append(s, Eq{"user_id": u.ID}) + return s } func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { @@ -125,6 +124,10 @@ func (r *playerRepository) NewInstance() any { return &model.Player{} } +// isPermitted authorizes creating a new record, based on the owner declared in the request body. +// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a +// player they own. Updates must not use this (the body owner is attacker-controlled); they go +// through updateOwned, which authorizes against the persisted user_id in the WHERE clause. func (r *playerRepository) isPermitted(p *model.Player) bool { u := loggedUser(r.ctx) return u.IsAdmin || p.UserId == u.ID @@ -145,14 +148,7 @@ func (r *playerRepository) Save(entity any) (string, error) { func (r *playerRepository) Update(id string, entity any, cols ...string) error { t := entity.(*model.Player) t.ID = id - if !r.isPermitted(t) { - return rest.ErrPermissionDenied - } - _, err := r.put(id, t, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.updateOwned(id, t, cols...) } func (r *playerRepository) Delete(id string) error { diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f6c669493..f640cd9a4 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -215,9 +215,12 @@ var _ = Describe("PlayerRepository", func() { clone.MaxBitRate = 10000 err := repo.Update(clone.ID, &clone, "ip") - if clone.UserId == "" { + if player.UserId == "" { Expect(err).To(HaveOccurred()) } else if !admin && player.Username == adminPlayer1.Username { + // A non-admin cannot target another user's player: the ownership-restricted + // update matches no owned row, so it reports permission-denied rather than + // touching it. Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { @@ -244,4 +247,86 @@ var _ = Describe("PlayerRepository", func() { Entry("admin context", true, players, adminPlayer1, regularPlayer), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) + + Describe("Ownership enforcement (cross-tenant write protection)", func() { + var regularRepo *playerRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + regularRepo = NewPlayerRepository(ctx, database).(*playerRepository) + }) + + It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { + // Attacker (regularUser) targets the victim's (adminUser) player by URL id, + // but sets userId in the body to their own id to try to pass the permission check. + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, // attacker's own id, spoofed in the body + MaxBitRate: 1, + } + + // The ownership-restricted update matches no row owned by the attacker, so the write + // targets nothing and reports permission-denied rather than overwriting the victim's row. + err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The victim's player must remain untouched. + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + }) + + It("does not let a regular user reassign their own player to another user", func() { + // Owner updates their own player but tries to give it away to the admin. The update + // succeeds for the other fields, but user_id is never written, so ownership stays put. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "given-away" + + err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // Ownership must not have changed. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("does not let an admin reassign a player to another user", func() { + // Even an admin cannot change a player's owner via update. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "admin-renamed" + + err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // The name change applies, but ownership must not have moved. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("admin-renamed")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("lets the owner update their own player", func() { + update := regularPlayer + update.Name = "renamed-by-owner" + + err := regularRepo.Update(regularPlayer.ID, &update, "name") + Expect(err).To(BeNil()) + + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("renamed-by-owner")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("returns not found when updating a nonexistent player", func() { + ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} + err := regularRepo.Update("does-not-exist", &ghost, "name") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) }) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 415109640..bcd13ff3e 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -53,6 +53,10 @@ func (r *shareRepository) checkOwnership(id string) error { return nil } +// TODO: this still uses the legacy checkOwnership SELECT-then-delete pattern (a TOCTOU window), +// the same shape removed from Update. Once a base-repo deleteOwned exists (built on ownerFilter, +// mirroring updateOwned), route Delete through it and drop checkOwnership entirely. playerRepository +// .Delete (which restricts via addRestriction) should adopt the same primitive. func (r *shareRepository) Delete(id string) error { if err := r.checkOwnership(id); err != nil { return err @@ -166,17 +170,12 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { func (r *shareRepository) Update(id string, entity any, cols ...string) error { s := entity.(*model.Share) - if err := r.checkOwnership(id); err != nil { - return err - } s.ID = id s.UpdatedAt = time.Now() - cols = append(cols, "updated_at") - _, err := r.put(id, s, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound + if len(cols) > 0 { + cols = append(cols, "updated_at") } - return err + return r.updateOwned(id, s, cols...) } func (r *shareRepository) Save(entity any) (string, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 6988f323f..dcc84d66f 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -216,6 +216,44 @@ var _ = Describe("ShareRepository", func() { err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) + + It("returns not found when updating a nonexistent share", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + + It("updates all columns when no specific columns are given", func() { + insertShare("all-cols-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + // No cols: the update must write every column, not just updated_at. + err := repo.(rest.Persistable).Update("all-cols-share", + &model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) + Expect(err).ToNot(HaveOccurred()) + + got, err := repo.(rest.Repository).Read("all-cols-share") + Expect(err).ToNot(HaveOccurred()) + share := got.(*model.Share) + Expect(share.Description).To(Equal("All Updated")) + Expect(share.MaxBitRate).To(Equal(192)) + Expect(share.ResourceType).To(Equal("album")) + }) + + It("does not let an owner reassign their share to another user", func() { + insertShare("reassign-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("reassign-share", + &model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") + Expect(err).ToNot(HaveOccurred()) + + // Ownership must not have moved, even though user_id was passed in the body and cols. + got, err := repo.(rest.Repository).Read("reassign-share") + Expect(err).ToNot(HaveOccurred()) + Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) + }) }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index fd263d37b..55e83d544 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -57,6 +58,16 @@ func loggedUser(ctx context.Context) *model.User { } } +// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for +// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid +// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +func (r sqlRepository) ownerFilter() Sqlizer { + if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { + return Eq{"user_id": usr.ID} + } + return nil +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -382,6 +393,47 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) { return res.Exist > 0, err } +// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only update rows they own: the +// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply +// does not match and no write happens. Ownership itself is immutable here: user_id is never written, +// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put, +// it never falls through to an INSERT, so a non-matching id never creates a row. +// +// When the update matches no row it classifies the failure: if the row exists but is owned by +// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is +// still atomic; the extra lookup happens only on the failure path (count == 0), where no write +// occurred, so there is no TOCTOU on the update. +func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error { + values, err := toSQLArgs(m) + if err != nil { + return fmt.Errorf("error preparing values to write to DB: %w", err) + } + updateValues := filterUpdateValues(values, id, colsToUpdate...) + delete(updateValues, "user_id") // ownership is immutable on update + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + if owner := r.ownerFilter(); owner != nil { + update = update.Where(owner) + } + count, err := r.executeSQL(update) + if err != nil { + return err + } + if count == 0 { + // The update matched no row: either the id is missing, or it exists but is owned by + // someone else. Disambiguate to return the more accurate error. + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound + } + return nil +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). @@ -408,6 +460,30 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } +// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the +// row identified by id: only the requested colsToUpdate (or all columns when none are specified), +// dropping columns that must never be overwritten on update (created_at, birth_time). +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := map[string]any{} + + // This is a map of the columns that need to be updated, if specified + c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { + return toSnakeCase(s), struct{}{} + }) + for k, v := range values { + if _, found := c2upd[k]; len(c2upd) == 0 || found { + updateValues[k] = v + } + } + + updateValues["id"] = id + delete(updateValues, "created_at") + // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now + // TODO move to mediafile_repository when each repo has its own upsert method + delete(updateValues, "birth_time") + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { @@ -415,24 +491,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri } // If there's an ID, try to update first if id != "" { - updateValues := map[string]any{} - - // This is a map of the columns that need to be updated, if specified - c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { - return toSnakeCase(s), struct{}{} - }) - for k, v := range values { - if _, found := c2upd[k]; len(c2upd) == 0 || found { - updateValues[k] = v - } - } - - updateValues["id"] = id - delete(updateValues, "created_at") - // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now - // TODO move to mediafile_repository when each repo has its own upsert method - delete(updateValues, "birth_time") - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) count, err := r.executeSQL(update) if err != nil { return "", err From 11640f2e4d2e807bce3b473474a35af96dd4e9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 4 Jun 2026 23:07:13 -0400 Subject: [PATCH 265/596] fix: restrict transcoding config reads to admins (#5564) * fix(security): restrict transcoding config reads to admins Authenticated non-admin users could read transcoding configs through the native API (GET /api/transcoding and /api/transcoding/{id}) when EnableTranscodingConfig was enabled. The responses included the full command templates, disclosing admin-configured ffmpeg invocations and local command paths. Write operations were already admin-only. The /transcoding route was registered in the general authenticated group, and only the repository's write methods checked IsAdmin. This applies the boundary at two layers: - Move the route under adminOnlyMiddleware, alongside the other admin-only resources (/library, /config, /inspect). - Add an IsAdmin guard to the repository's rest.Repository read methods (Read, ReadAll, Count) as defense-in-depth. The guard is scoped to the REST methods only. The streaming pipeline resolves profiles via Get/FindByFormat (model.TranscodingRepository), which stay open so transcoding keeps working for non-admin users. Adds regression tests covering non-admin read denial and confirming non-admin streaming lookups (Get/FindByFormat) still succeed. * fix(security): redact transcoding Command for non-admins instead of blocking reads Reworks the previous approach after review (Codex P2): moving /transcoding under adminOnlyMiddleware and denying non-admin reads broke legitimate non-admin UI flows. The web UI reads the transcoding resource as a regular user in several places that need only the profile name and target format: the player edit dropdown (ReferenceInput), the player list (ReferenceField), and the share/download format pickers (useGetList -> {targetFormat, name}). The only sensitive field is Command (the admin-owned ffmpeg template). So: - Revert the route move; /transcoding stays in the authenticated group. - Read/ReadAll now return the profiles to any authenticated user but blank the Command field for non-admins (mirrors user_repository's field-level redaction). Count is no longer denied (the UI needs list pagination). - Writes remain admin-only (Save/Update/Delete/Put). - Streaming is unaffected: it resolves profiles via Get/FindByFormat, which are not redacted, so on-the-fly transcoding keeps working for non-admins. Tests updated: non-admin reads succeed with Command blank, admin reads keep Command, non-admin Get/FindByFormat keep Command, writes still denied. --- persistence/transcoding_repository.go | 19 ++++++- persistence/transcoding_repository_test.go | 60 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 870da61c8..96fd3efdb 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -53,14 +53,29 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro } func (r *transcodingRepository) Read(id string) (any, error) { - return r.Get(id) + res, err := r.Get(id) + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + res.Command = "" + } + return res, nil } func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") res := model.Transcodings{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + for i := range res { + res[i].Command = "" + } + } + return res, nil } func (r *transcodingRepository) EntityName() string { diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index eddc5047a..73250163c 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() { _, err = adminRepo.Get("to-delete") Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("reads the Command field via the REST Read method", func() { + tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := adminRepo.(*transcodingRepository).Read("adminread") + Expect(err).ToNot(HaveOccurred()) + Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret")) + }) }) Describe("Regular User", func() { + It("reads a transcoding but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).Read("readreg") + Expect(err).ToNot(HaveOccurred()) + t := res.(*model.Transcoding) + Expect(t.Name).To(Equal("temp")) + Expect(t.TargetFormat).To(Equal("test_format")) + Expect(t.Command).To(BeEmpty()) + }) + + It("lists transcodings but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).ReadAll() + Expect(err).ToNot(HaveOccurred()) + list := res.(model.Transcodings) + Expect(list).ToNot(BeEmpty()) + for _, t := range list { + Expect(t.Command).To(BeEmpty()) + } + }) + + It("counts transcodings", func() { + count, err := repo.(*transcodingRepository).Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", 0)) + }) + + It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() { + tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.Get("streamreg") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("streamreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + + It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() { + tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.FindByFormat("test_format") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("fmtreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + It("fails to create", func() { err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) Expect(err).To(Equal(rest.ErrPermissionDenied)) From cf1f190bb57d1e8137f805553e9479031c2103f5 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 5 Jun 2026 08:14:00 -0400 Subject: [PATCH 266/596] fix(subsonic): use SQLite RANDOM() sorting in getRandomSongs, for faster results Related to #5558 Signed-off-by: Deluan <deluan@navidrome.org> --- server/subsonic/filter/filters.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 8ba4f0ff9..856870a6c 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -92,7 +92,7 @@ func SongsByAlbum(albumId string) Options { func SongsByRandom(genre string, fromYear, toYear int) Options { options := Options{ - Sort: "random", + Sort: "random()", } ff := And{} if genre != "" { From 174621f2595ac9d2567de5296353acfab5182448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 5 Jun 2026 13:54:55 -0400 Subject: [PATCH 267/596] fix(nativeapi): make /api/song path filter work and use startsWith (#5566) The native API exposes a `path` query param on /api/song, but it was not registered in the media file filter map. Unmapped real columns fall through to a default LIKE predicate that emits an unqualified `path LIKE ?`. Since the song query joins the library table (which also has a `path` column), SQLite returned "ambiguous column name: path" and the request failed with HTTP 500. Register a dedicated path filter qualified to media_file.path, resolving the ambiguity. The value is matched with startsWith semantics (LIKE arg || '%') against the library-relative path stored in media_file.path. To register it inline (without a one-off wrapper), startsWithFilter now takes a bound field and returns a filterFunc, mirroring containsFilter. The two existing callers are updated accordingly, and the now-unused withTableName helper is removed. The user 'name' filter, which previously relied on withTableName, is now qualified directly as user.name; tests are added to guard that filter against the same column-ambiguity class (the user query also joins the library table, which has a name column). Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/mediafile_repository.go | 1 + persistence/mediafile_repository_test.go | 28 ++++++++++++++ persistence/sql_base_repository.go | 9 ----- persistence/sql_restful.go | 8 ++-- persistence/user_repository.go | 2 +- persistence/user_repository_test.go | 47 ++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 264778ea0..559378262 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -104,6 +104,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "missing": booleanFilter, "artists_id": artistFilter, "library_id": libraryIdFilter, + "path": startsWithFilter("media_file.path"), } // Add all album tags as filters for tag := range model.TagMappings() { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 464d88288..2bc9d0267 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -524,6 +524,34 @@ var _ = Describe("MediaRepository", func() { } }) }) + + Describe("path", func() { + It("matches files whose path starts with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "test/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + + var found bool + for _, f := range files { + Expect(f.Path).To(HavePrefix("test/")) + if f.ID == mfWithoutAnnotation.ID { + found = true + } + } + Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included") + }) + + It("excludes files whose path does not start with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "no-such-prefix/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + Expect(files).To(BeEmpty()) + }) + }) }) Describe("Search", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 55e83d544..c2ba4e073 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -197,15 +197,6 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti return sq } -func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { - return func(field string, value any) Sqlizer { - if r.tableName != "" { - field = r.tableName + "." + field - } - return filter(field, value) - } -} - // libraryIdFilter is a filter function to be added to resources that have a library_id column. func libraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_id": value} diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 02162387c..1dcabcec6 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -46,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query continue } // Default to a "starts with" filter - filters = append(filters, startsWithFilter(f, v)) + filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)}) } return filters } @@ -91,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer { return Eq{field: value} } -func startsWithFilter(field string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%s%%", value)} +func startsWithFilter(field string) func(string, any) Sqlizer { + return func(_ string, value any) Sqlizer { + return Like{field: fmt.Sprintf("%s%%", value)} + } } func containsFilter(field string) func(string, any) Sqlizer { diff --git a/persistence/user_repository.go b/persistence/user_repository.go index dc149e8ba..9decff4e5 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -59,7 +59,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r.registerModel(&model.User{}, map[string]filterFunc{ "id": idFilter(r.tableName), "password": invalidFilter(ctx), - "name": r.withTableName(startsWithFilter), + "name": startsWithFilter(r.tableName + ".name"), }) once.Do(func() { _ = r.initPasswordEncryptionKey() diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 8abbf76a9..6f8ab9161 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -207,6 +208,52 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("ReadAll name filter", func() { + var adminRepo model.ResourceRepository + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) + adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository) + + for _, u := range []model.User{ + {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, + {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, + } { + Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed()) + } + }) + + AfterEach(func() { + ur := adminRepo.(model.UserRepository) + _ = ur.Delete("filter-alice") + _ = ur.Delete("filter-bob") + }) + + It("matches users whose name starts with the given prefix", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + var names []string + for _, u := range users { + names = append(names, u.Name) + } + Expect(names).To(ContainElement("Alice Filter")) + Expect(names).ToNot(ContainElement("Bob Filter")) + }) + + It("does not match names by mid-string substring (startsWith, not contains)", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + for _, u := range users { + Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), + "a mid-string substring should not match a startsWith filter") + } + }) + }) + Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User From 1e7996f5d708b0b2768688b62dae9354a22b427a Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 5 Jun 2026 15:50:59 -0400 Subject: [PATCH 268/596] fix(share): enforce per-user ownership on share reads Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count, CountAll) did not apply an owner filter, so non-admin users saw shares belonging to other users. The write paths already enforced per-user ownership; this brings reads in line with them. Add an addRestriction()/ownerFilter() based scope to share reads, keeping admins and the headless public-share resolution path unrestricted. Route share and player Delete through a new base-repo deleteOwned() primitive that applies the ownership predicate in the DELETE's WHERE clause (atomic, no select-then- delete window) and classifies a zero-row result as permission-denied vs not-found, mirroring updateOwned. The addRestriction helper and the write-miss classifier are hoisted onto the base repository so player and share share one implementation. Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error handler so ownership/not-found failures from the rest-backed repositories return the proper Subsonic codes (50 / 70) instead of a generic error. Covered by unit tests (persistence, subsonic error mapping) and an end-to-end cross-user sharing isolation test. --- persistence/player_repository.go | 18 +-- persistence/player_repository_test.go | 46 +++++--- persistence/share_repository.go | 41 +------ persistence/share_repository_test.go | 160 ++++++++++++++++++++++++-- persistence/sql_base_repository.go | 63 +++++++--- server/e2e/subsonic_sharing_test.go | 79 +++++++++++++ server/subsonic/api.go | 5 +- server/subsonic/api_test.go | 24 ++++ 8 files changed, 336 insertions(+), 100 deletions(-) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index c9c7d3b4b..353b0444f 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -62,17 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu return s.Where(r.addRestriction()) } -func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { - s := And{} - if len(sql) > 0 { - s = append(s, sql[0]) - } - if owner := r.ownerFilter(); owner != nil { - s = append(s, owner) - } - return s -} - func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { sel := r.newSelect(options...). Columns( @@ -152,12 +141,7 @@ func (r *playerRepository) Update(id string, entity any, cols ...string) error { } func (r *playerRepository) Delete(id string) error { - filter := r.addRestriction(And{Eq{"player.id": id}}) - err := r.delete(filter) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } var _ model.PlayerRepository = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f640cd9a4..b7085a1fb 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() { }) Describe("Delete", func() { - DescribeTable("item type", func(player model.Player) { - err := repo.Delete(player.ID) + It("deletes a player owned by the current user", func() { + err := repo.Delete(userPlayer.ID) Expect(err).To(BeNil()) - isReal := player.UserId != "" - canDelete := admin || player.UserId == userPlayer.UserId - count, err := repo.Count() Expect(err).To(BeNil()) + Expect(count).To(Equal(baseCount - 1)) - if isReal && canDelete { - Expect(count).To(Equal(baseCount - 1)) - } else { - Expect(count).To(Equal(baseCount)) - } + _, err = repo.Get(userPlayer.ID) + Expect(err).To(Equal(model.ErrNotFound)) + }) - item, err := repo.Get(player.ID) - if !isReal || canDelete { + It("does not delete another user's player when not admin", func() { + err := repo.Delete(otherPlayer.ID) + + if admin { + // Admins may delete any player. + Expect(err).To(BeNil()) + Expect(repo.Count()).To(Equal(baseCount - 1)) + _, err = repo.Get(otherPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) } else { - Expect(*item).To(Equal(player)) + // The ownership-restricted delete matches no owned row, so it reports + // permission-denied and leaves the other user's player untouched. + Expect(err).To(Equal(rest.ErrPermissionDenied)) + Expect(repo.Count()).To(Equal(baseCount)) + item, err := repo.Get(otherPlayer.ID) + Expect(err).To(BeNil()) + Expect(*item).To(Equal(otherPlayer)) } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) + }) + + It("returns not-found for a nonexistent player", func() { + err := repo.Delete("i don't exist") + Expect(err).To(Equal(rest.ErrNotFound)) + Expect(repo.Count()).To(Equal(baseCount)) + }) }) Describe("Read", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index bcd13ff3e..89dc19e19 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -30,51 +30,18 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito return r } -// TODO: Ownership checks should be moved to the service layer (core/share.go) -func (r *shareRepository) checkOwnership(id string) error { - usr := loggedUser(r.ctx) - if usr.IsAdmin || usr.ID == invalidUserId { - return nil - } - sel := r.newSelect().Columns("user_id").Where(Eq{"id": id}) - var share struct { - UserID string `db:"user_id"` - } - err := r.queryOne(sel, &share) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err - } - if share.UserID != usr.ID { - return rest.ErrPermissionDenied - } - return nil -} - -// TODO: this still uses the legacy checkOwnership SELECT-then-delete pattern (a TOCTOU window), -// the same shape removed from Update. Once a base-repo deleteOwned exists (built on ownerFilter, -// mirroring updateOwned), route Delete through it and drop checkOwnership entirely. playerRepository -// .Delete (which restricts via addRestriction) should adopt the same primitive. func (r *shareRepository) Delete(id string) error { - if err := r.checkOwnership(id); err != nil { - return err - } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...).Join("user u on u.id = share.user_id"). - Columns("share.*", "user_name as username") + Columns("share.*", "user_name as username"). + Where(r.addRestriction()) } func (r *shareRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"id": id}) + return r.exists(r.addRestriction(And{Eq{"id": id}})) } func (r *shareRepository) Get(id string) (*model.Share, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index dcc84d66f..0b3ece598 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -20,7 +20,7 @@ var _ = Describe("ShareRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo = NewShareRepository(ctx, GetDBXBuilder()) // Insert the admin user into the database (required for foreign key constraint) @@ -38,7 +38,7 @@ var _ = Describe("ShareRepository", func() { Context("Repository creation and basic operations", func() { It("should create repository successfully with no user context", func() { // Create repository with no user context (headless) - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) Expect(headlessRepo).ToNot(BeNil()) }) @@ -60,7 +60,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should see all shares - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) shares, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) @@ -92,7 +92,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the share - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) share, err := headlessRepo.Get(shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) @@ -155,7 +155,7 @@ var _ = Describe("ShareRepository", func() { Describe("Delete", func() { It("allows a non-admin user to delete their own share", func() { insertShare("own-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("own-share-del") Expect(err).ToNot(HaveOccurred()) @@ -163,15 +163,21 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from deleting another user's share", func() { insertShare("other-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("other-share-del") Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The share was not deleted: the owner can still read it. + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder()) + _, err = ownerRepo.(rest.Repository).Read("other-share-del") + Expect(err).ToNot(HaveOccurred()) }) It("allows an admin to delete any user's share", func() { insertShare("admin-del-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("admin-del-share") Expect(err).ToNot(HaveOccurred()) @@ -179,7 +185,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to delete a share", func() { insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Delete("headless-del-share") Expect(err).ToNot(HaveOccurred()) }) @@ -188,7 +194,7 @@ var _ = Describe("ShareRepository", func() { Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -196,7 +202,7 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from updating another user's share", func() { insertShare("other-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") Expect(err).To(Equal(rest.ErrPermissionDenied)) @@ -204,7 +210,7 @@ var _ = Describe("ShareRepository", func() { It("allows an admin to update any user's share", func() { insertShare("admin-upd-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -212,7 +218,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to update a share", func() { insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) @@ -255,5 +261,135 @@ var _ = Describe("ShareRepository", func() { Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) }) }) + + Describe("Read scoping", func() { + BeforeEach(func() { + // Persist owner/other users so the JOIN in selectShare resolves. + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + insertShare("share-owner-1", ownerUser.ID) + insertShare("share-owner-2", ownerUser.ID) + insertShare("share-other-1", otherUser.ID) + }) + + Context("non-admin user", func() { + var nonAdminRepo model.ShareRepository + var nonAdminRest rest.Repository + + BeforeEach(func() { + nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder()) + nonAdminRest = nonAdminRepo.(rest.Repository) + }) + + It("GetAll returns only own shares", func() { + shares, err := nonAdminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("ReadAll returns only own shares", func() { + res, err := nonAdminRest.ReadAll() + Expect(err).ToNot(HaveOccurred()) + shares := res.(model.Shares) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("Get returns own share", func() { + s, err := nonAdminRepo.Get("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-owner-1")) + }) + + It("Get returns ErrNotFound for another user's share", func() { + _, err := nonAdminRepo.Get("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Read returns ErrNotFound for another user's share", func() { + _, err := nonAdminRest.Read("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Exists returns true for own share", func() { + exists, err := nonAdminRepo.Exists("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("Exists returns false for another user's share", func() { + exists, err := nonAdminRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("CountAll counts only own shares", func() { + count, err := nonAdminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + + It("Count (rest) counts only own shares", func() { + count, err := nonAdminRest.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + }) + + Context("admin user", func() { + It("GetAll returns all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + shares, err := adminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1")) + }) + + It("CountAll counts all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + count, err := adminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 3)) + }) + }) + + Context("headless context (public share route)", func() { + It("GetAll returns all shares", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(shares).To(HaveLen(3)) + }) + + It("Get returns another user's share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + s, err := headlessRepo.Get("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-other-1")) + }) + + It("Exists returns true for any share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + exists, err := headlessRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) + }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index c2ba4e073..321e790db 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -61,6 +61,9 @@ func loggedUser(ctx context.Context) *model.User { // ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for // tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid // user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +// +// The predicate uses an unqualified user_id, so it only works on queries where that column is +// unambiguous (no join introducing a second user_id). func (r sqlRepository) ownerFilter() Sqlizer { if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { return Eq{"user_id": usr.ID} @@ -68,6 +71,20 @@ func (r sqlRepository) ownerFilter() Sqlizer { return nil } +// addRestriction combines an optional caller predicate with the ownership filter, producing the +// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and +// only the caller's predicate (if any) remains. +func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer { + s := And{} + if len(sql) > 0 { + s = append(s, sql[0]) + } + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) + } + return s +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -402,29 +419,47 @@ func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) err } updateValues := filterUpdateValues(values, id, colsToUpdate...) delete(updateValues, "user_id") // ownership is immutable on update - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) - if owner := r.ownerFilter(); owner != nil { - update = update.Where(owner) - } + update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues) count, err := r.executeSQL(update) if err != nil { return err } if count == 0 { - // The update matched no row: either the id is missing, or it exists but is owned by - // someone else. Disambiguate to return the more accurate error. - exists, err := r.exists(Eq{"id": id}) - if err != nil { - return err - } - if exists { - return rest.ErrPermissionDenied - } - return rest.ErrNotFound + return r.classifyOwnedWriteMiss(id) } return nil } +// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only delete rows they own: the +// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply +// does not match and is left untouched. The failure path mirrors updateOwned (see +// classifyOwnedWriteMiss), so there is no TOCTOU on the delete. +func (r sqlRepository) deleteOwned(id string) error { + count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id}))) + if err != nil { + return err + } + if count == 0 { + return r.classifyOwnedWriteMiss(id) + } + return nil +} + +// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched +// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise +// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred. +func (r sqlRepository) classifyOwnedWriteMiss(id string) error { + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). diff --git a/server/e2e/subsonic_sharing_test.go b/server/e2e/subsonic_sharing_test.go index 1a082ba0f..03bf1f80f 100644 --- a/server/e2e/subsonic_sharing_test.go +++ b/server/e2e/subsonic_sharing_test.go @@ -125,3 +125,82 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + +var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { + var userA, userB model.User + var shareID string + var albumID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + userA = createUser("share-user-a", "share-user-a", "Share User A", false) + userB = createUser("share-user-b", "share-user-b", "Share User B", false) + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + shareID = resp.Shares.Share[0].ID + Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName)) + }) + + It("userB's getShares does not leak userA's share", func() { + resp := doReqWithUser(userB, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("userA still sees own share", func() { + resp := doReqWithUser(userA, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].ID).To(Equal(shareID)) + Expect(resp.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("admin sees userA's share", func() { + resp := doReqWithUser(adminUser, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + ids := make([]string, len(resp.Shares.Share)) + for i, s := range resp.Shares.Share { + ids[i] = s.ID + } + Expect(ids).To(ContainElement(shareID)) + }) + + It("userB cannot updateShare on userA's share", func() { + resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm description unchanged for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("userB cannot deleteShare on userA's share", func() { + resp := doReqWithUser(userB, "deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm share still present for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].ID).To(Equal(shareID)) + }) +}) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index f39dec009..82e404228 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,6 +9,7 @@ import ( "regexp" "strconv" + "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" @@ -301,9 +302,9 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorMissingParameter, err.Error()) case errors.Is(err, req.ErrInvalidParam): err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index b565109a5..f8d5b6642 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "encoding/xml" + "errors" "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/deluan/rest" "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -187,3 +190,24 @@ var _ = Describe("sendResponse", func() { Expect(pointer).To(Equal(responses.ErrorDataNotFound)) }) }) + +var _ = Describe("mapToSubsonicError", func() { + DescribeTable("maps repository errors to the correct Subsonic error code", + func(err error, expectedCode int32) { + subErr := mapToSubsonicError(err) + Expect(subErr.code).To(Equal(expectedCode)) + }, + Entry("rest.ErrPermissionDenied -> not authorized (50)", + rest.ErrPermissionDenied, responses.ErrorAuthorizationFail), + Entry("rest.ErrNotFound -> data not found (70)", + rest.ErrNotFound, responses.ErrorDataNotFound), + Entry("model.ErrNotAuthorized -> not authorized (50)", + model.ErrNotAuthorized, responses.ErrorAuthorizationFail), + Entry("model.ErrNotFound -> data not found (70)", + model.ErrNotFound, responses.ErrorDataNotFound), + Entry("wrapped rest.ErrPermissionDenied is still mapped", + fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail), + Entry("unknown error -> generic (0)", + errors.New("boom"), responses.ErrorGeneric), + ) +}) From fb61827ab6c23ccbcb2096a581b07496ee3820c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 5 Jun 2026 18:06:52 -0400 Subject: [PATCH 269/596] test: fix flaky tests in utils/cache (#5567) * test: fix flaky tests in utils/cache Two tests in the utils/cache suite were timing- and ordering-dependent and failed intermittently on CI (notably on the Windows runner). The FileHaunter tests raced the asynchronous cache-cleanup goroutine with a fixed 400ms sleep, then asserted the directory state once. On slow runners the haunter had not finished scrubbing, so the assertion saw the original files and failed. Replace the fixed sleep with Eventually polling so the assertions wait for the haunter to converge. While doing so, the exact set and count of reaped files proved nondeterministic (the empty file is double-counted in the size loop and LRU survivors depend on OS access-time ordering), so the assertions now check the haunter's actual guarantees: the empty file is always scrubbed and the cache stays within the configured maxSize/maxItems bound. This also lets the previously-disabled maxItems context and its commented-out assertions be re-enabled. The HTTPClient 'caches repeated requests' test relied on a shared requestsReceived counter that was never reset in BeforeEach. Under randomized spec order another spec could run first and leave the counter non-zero, breaking the first assertion. Reset the counter and header in BeforeEach to make the spec independent of execution order. Verified with: ginkgo -race -repeat=80 --randomize-all ./utils/cache/ * test: surface errors in dirSize and align Eventually with house style Address code review feedback on the cache flaky-test fix: - dirSize now returns (uint64, error) and the maxSize spec asserts the error is nil. Previously a ReadDir/Info failure silently returned 0, which always satisfies '<= maxSize' and would mask a real filesystem error as a passing test. - dirSize skips non-regular entries (info.Mode().IsRegular()) to match its doc comment and avoid counting directories or symlinks. - The Eventually blocks now use .WithTimeout()/.WithPolling() with time.Duration values instead of string-literal durations, matching the prevailing pattern in the test suite. --- utils/cache/cached_http_client_test.go | 2 + utils/cache/file_haunter_test.go | 61 ++++++++++++++++++++------ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/utils/cache/cached_http_client_test.go b/utils/cache/cached_http_client_test.go index 1ec1a3a27..5f8b0029c 100644 --- a/utils/cache/cached_http_client_test.go +++ b/utils/cache/cached_http_client_test.go @@ -20,6 +20,8 @@ var _ = Describe("HTTPClient", func() { var header string BeforeEach(func() { + requestsReceived = 0 + header = "" ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestsReceived++ header = r.Header.Get("head") diff --git a/utils/cache/file_haunter_test.go b/utils/cache/file_haunter_test.go index 47440cc22..6c5151abb 100644 --- a/utils/cache/file_haunter_test.go +++ b/utils/cache/file_haunter_test.go @@ -29,15 +29,15 @@ var _ = Describe("FileHaunter", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + // Use a short haunter period so cleanup runs promptly; the assertions + // below poll with Eventually instead of racing a fixed sleep. fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy( - cache.NewFileHaunter("", maxItems, maxSize, 300*time.Millisecond), + cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond), )) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCache.Clean) Expect(createTestFiles(fsCache)).To(Succeed()) - - <-time.After(400 * time.Millisecond) }) Context("When maxSize is defined", func() { @@ -46,24 +46,39 @@ var _ = Describe("FileHaunter", func() { }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(4)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") + // stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty. + // With maxSize=20, the haunter scrubs the empty file plus enough of + // the oldest files to bring the total size down to <= 20 bytes. + // Which files survive (and therefore the exact count) depends on + // access-time ordering, so we only assert the haunter's guarantees: + // the empty file is always scrubbed and the total size stays within + // the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + size, err := dirSize(cacheDir) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(size).To(BeNumerically("<=", maxSize)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) - XContext("When maxItems is defined", func() { + Context("When maxItems is defined", func() { BeforeEach(func() { maxItems = 3 }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(maxItems)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") - //Expect(fsCache.Exists("stream-1")).To(BeFalse(), "stream-1 should have been scrubbed") + // With maxItems=3, the haunter scrubs the empty file plus enough of + // the oldest files to bring the count within the limit. As above, the + // exact survivors depend on access-time ordering, so we assert the + // guaranteed invariants: the empty file is gone and the item count + // stays within the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + entries, readErr := os.ReadDir(cacheDir) + g.Expect(readErr).ToNot(HaveOccurred()) + g.Expect(len(entries)).To(BeNumerically("<=", maxItems)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) }) @@ -93,6 +108,26 @@ func createTestFiles(c *fscache.FSCache) error { return nil } +// dirSize returns the total size in bytes of all regular files in dir. +func dirSize(dir string) (uint64, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + var total uint64 + for _, e := range entries { + info, err := e.Info() + if err != nil { + return 0, err + } + if !info.Mode().IsRegular() { + continue + } + total += uint64(info.Size()) + } + return total, nil +} + func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser { r, w, _ := c.Get(name) _, _ = w.Write([]byte(contents)) From 03841ffe965d637fbcd6a003870bd6b40c94ee8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 5 Jun 2026 18:16:21 -0400 Subject: [PATCH 270/596] fix(ui): update German, Finnish, Galician, Dutch, Slovak, Thai, Chinese (traditional) translations from POEditor (#5351) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/de.json | 4 +- resources/i18n/fi.json | 4 +- resources/i18n/gl.json | 4 +- resources/i18n/nl.json | 4 +- resources/i18n/sk.json | 170 ++++++++++++++++++------------------ resources/i18n/th.json | 18 ++-- resources/i18n/zh-Hant.json | 6 +- 7 files changed, 114 insertions(+), 96 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index c540dee05..1a516d393 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -38,7 +38,9 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "Disc %{discNumber}" + "disc": "Disc %{discNumber}", + "albumGain": "Album Gain", + "trackGain": "Titel Gain" }, "actions": { "addToQueue": "Später abspielen", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index bbad47bd6..0e6149f87 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,9 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "Levy %{discNumber}" + "disc": "Levy %{discNumber}", + "albumGain": "Albumin äänenvoimakkuus", + "trackGain": "Kappaleen äänenvoimakkuus" }, "actions": { "addToQueue": "Lisää jonoon", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index d62ca2ab2..444998d03 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,9 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "Disco %{discNumber}" + "disc": "Disco %{discNumber}", + "albumGain": "Gañancia de Album", + "trackGain": "Gañancia de Canción" }, "actions": { "addToQueue": "Ao final da cola", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 3f638c13c..46c3df9de 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -38,7 +38,9 @@ "missing": "Ontbrekend", "libraryName": "Bibliotheek", "composer": "Componist", - "disc": "Schijf %{discNumber}" + "disc": "Schijf %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Nummer gain" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json index af5afade7..f294d1602 100644 --- a/resources/i18n/sk.json +++ b/resources/i18n/sk.json @@ -2,7 +2,7 @@ "languageName": "Slovenčina", "resources": { "song": { - "name": "Skladba |||| Skladieb", + "name": "Skladba |||| Skladby", "fields": { "albumArtist": "Interpret albumu", "duration": "Dĺžka", @@ -10,20 +10,14 @@ "playCount": "Počet prehratí", "title": "Názov", "artist": "Interpret", - "composer": "Skladateľ", "album": "Album", "path": "Cesta k súboru", - "libraryName": "Knižnica", "genre": "Žáner", "compilation": "Kompilácia", "year": "Rok", "size": "Veľkosť súboru", "updatedAt": "Nahrané", "bitRate": "Prenosová rýchlosť", - "bitDepth": "Bitová hĺbka", - "sampleRate": "Vzorkovacia frekvencia", - "channels": "Kanály", - "disc": "Disk %{discNumber}", "discSubtitle": "Podtitul disku", "starred": "Obľúbené", "comment": "Komentár", @@ -31,6 +25,7 @@ "quality": "Kvalita", "bpm": "BPM", "playDate": "Naposledy prehraná skladba", + "channels": "Kanály", "createdAt": "Pridané", "grouping": "Zoskupovanie", "mood": "Nálada", @@ -38,17 +33,24 @@ "tags": "Ďalšie značky", "mappedTags": "Mapované značky", "rawTags": "Nespracované značky", - "missing": "Chýbajúce" + "bitDepth": "Bitová hĺbka", + "sampleRate": "Vzorkovacia frekvencia", + "missing": "Chýbajúce", + "libraryName": "Knižnica", + "composer": "Skladateľ", + "disc": "Disk %{discNumber}", + "albumGain": "Zosilnenie albumu", + "trackGain": "Zosilnenie stopy" }, "actions": { "addToQueue": "Prehrať neskôr", "playNow": "Prehrať teraz", "addToPlaylist": "Pridať do zoznamu skladieb", - "showInPlaylist": "Zobraziť v zozname skladieb", "shuffleAll": "Zamiešať všetko", "download": "Stiahnuť", "playNext": "Prehrať ako ďalšie", "info": "Získať informácie", + "showInPlaylist": "Zobraziť v zozname skladieb", "instantMix": "Okamžitý mix" } }, @@ -60,38 +62,38 @@ "duration": "Dĺžka", "songCount": "Skladby", "playCount": "Počet prehratí", - "size": "Veľkosť", "name": "Názov", - "libraryName": "Knižnica", "genre": "Žáner", "compilation": "Kompilácia", "year": "Rok", - "date": "Dátum záznamu", - "originalDate": "Pôvodné", - "releaseDate": "Vydané", - "releases": "Vydanie |||| Vydania", - "released": "Vydané", "updatedAt": "Aktualizované", "comment": "Komentár", "rating": "Hodnotenie", "createdAt": "Pridané", + "size": "Veľkosť", + "originalDate": "Pôvodné", + "releaseDate": "Vydané", + "releases": "Vydanie |||| Vydania", + "released": "Vydané", "recordLabel": "Štítok", "catalogNum": "Katalógové číslo", "releaseType": "Typ vydania", "grouping": "Zoskupovanie", "media": "Médiá", "mood": "Nálada", - "missing": "Chýbajúce" + "date": "Dátum záznamu", + "missing": "Chýbajúce", + "libraryName": "Knižnica" }, "actions": { "playAll": "Prehrať", "playNext": "Prehrať ako ďalšie", "addToQueue": "Prehrať neskôr", - "share": "Zdieľať", "shuffle": "Zamiešať", "addToPlaylist": "Pridať do zoznamu skladieb", "download": "Stiahnuť", - "info": "Získať informácie" + "info": "Získať informácie", + "share": "Zdieľať" }, "lists": { "all": "Všetko", @@ -109,10 +111,10 @@ "name": "Názov", "albumCount": "Počet albumov", "songCount": "Počet skladieb", - "size": "Veľkosť", "playCount": "Prehrania", "rating": "Hodnotenie", "genre": "Žáner", + "size": "Veľkosť", "role": "Rola", "missing": "Chýbajúci" }, @@ -133,9 +135,9 @@ "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" }, "actions": { - "topSongs": "Najpopulárnejšie skladby", "shuffle": "Zamiešať", - "radio": "Rádio" + "radio": "Rádio", + "topSongs": "Najpopulárnejšie skladby" } }, "user": { @@ -144,7 +146,6 @@ "userName": "Používateľské meno", "isAdmin": "Správca", "lastLoginAt": "Naposledy prihlásený", - "lastAccessAt": "Posledný Prístup", "updatedAt": "Upravený", "name": "Meno", "password": "Heslo", @@ -153,6 +154,7 @@ "currentPassword": "Súčastné heslo", "newPassword": "Nové heslo", "token": "Token", + "lastAccessAt": "Posledný Prístup", "libraries": "Knižnice" }, "helperTexts": { @@ -164,14 +166,14 @@ "updated": "Používateľ upravený", "deleted": "Používateľ odstránený" }, - "validation": { - "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" - }, "message": { "listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.", "clickHereForToken": "Kliknite sem pre získanie svojho tokenu", "selectAllLibraries": "Vybrať všetky knižnice", "adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam" + }, + "validation": { + "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" } }, "player": { @@ -214,9 +216,9 @@ "selectPlaylist": "Vybrať zoznam skladieb:", "addNewPlaylist": "Vytvoriť \"%{name}\"", "export": "Export", - "saveQueue": "Uložiť rad do zoznamu skladieb", "makePublic": "Zverejniť", "makePrivate": "Nastaviť ako súkromné", + "saveQueue": "Uložiť rad do zoznamu skladieb", "searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...", "pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb", "removeFromSelection": "Odstrániť z výberu" @@ -247,7 +249,6 @@ "username": "Zdieľané", "url": "URL", "description": "Popis", - "downloadable": "Povoliť sťahovanie?", "contents": "Obsah", "expiresAt": "Vyprší", "lastVisitedAt": "Naposledy navštívené", @@ -255,19 +256,17 @@ "format": "Formát", "maxBitRate": "Max. Bit Rate", "updatedAt": "Nahrané", - "createdAt": "Vytvorené" - }, - "notifications": {}, - "actions": {} + "createdAt": "Vytvorené", + "downloadable": "Povoliť sťahovanie?" + } }, "missing": { "name": "Chýbajúci súbor |||| Chýbajúce súbory", - "empty": "Žiadne chýbajúce súbory", "fields": { "path": "Cesta", "size": "Veľkosť", - "libraryName": "Knižnica", - "updatedAt": "Zmizol dňa" + "updatedAt": "Zmizol dňa", + "libraryName": "Knižnica" }, "actions": { "remove": "Odstrániť", @@ -275,7 +274,8 @@ }, "notifications": { "removed": "Chýbajúce súbory odstránené" - } + }, + "empty": "Žiadne chýbajúce súbory" }, "library": { "name": "Knižnica |||| Knižnice", @@ -305,20 +305,20 @@ }, "actions": { "scan": "Skenovať knižnicu", - "quickScan": "Rýchly sken", - "fullScan": "Úplný sken", "manageUsers": "Spravovať prístup používateľov", - "viewDetails": "Zobraziť detaily" + "viewDetails": "Zobraziť detaily", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken" }, "notifications": { "created": "Knižnica úspešne vytvorená", "updated": "Knižnica úspešne aktualizovaná", "deleted": "Knižnica úspešne odstránená", "scanStarted": "Skenovanie knižnice spustené", + "scanCompleted": "Skenovanie knižnice dokončené", "quickScanStarted": "Rýchly sken spustený", "fullScanStarted": "Úplný sken spustený", - "scanError": "Chyba pri spustení skenu. Skontrolujte logy", - "scanCompleted": "Skenovanie knižnice dokončené" + "scanError": "Chyba pri spustení skenu. Skontrolujte logy" }, "validation": { "nameRequired": "Názov knižnice je povinný", @@ -391,8 +391,6 @@ }, "messages": { "configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.", - "configValidationError": "Overenie konfigurácie zlyhalo:", - "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", "clickPermissions": "Kliknite na oprávnenie pre detaily", "noConfig": "Žiadna konfigurácia nastavená", "allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.", @@ -402,8 +400,10 @@ "allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.", "noLibraries": "Žiadne knižnice nevybrané", "librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.", - "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.", - "requiredHosts": "Požadovaní hostitelia" + "requiredHosts": "Požadovaní hostitelia", + "configValidationError": "Overenie konfigurácie zlyhalo:", + "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", + "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie." }, "placeholders": { "configKey": "kľúč", @@ -446,7 +446,6 @@ "add": "Pridať", "back": "Ísť späť", "bulk_actions": "1 vybraná |||| %{smart_count} vybraných", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Zrušiť", "clear_input_value": "Vymazať hodnotu", "clone": "Klonovať", @@ -470,6 +469,7 @@ "close_menu": "Zavrieť ponuku", "unselect": "Zrušiť výber", "skip": "Preskočiť", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Zdieľať", "download": "Stiahnuť" }, @@ -557,58 +557,52 @@ } }, "message": { - "uploadCover": "Nahrať obrázok obalu", - "removeCover": "Odstrániť obrázok obalu", - "coverUploaded": "Obrázok obalu albumu aktualizovaný", - "coverRemoved": "Obrázok obalu albumu odstránený", - "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", - "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu", "note": "POZNÁMKA", "transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.", "transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.", "songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb", - "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", - "startingInstantMix": "Načítava sa Instant Mix...", - "noTopSongsFound": "Nenašli sa žiadne top skladby", "noPlaylistsAvailable": "Žiadne nie sú dostupné", "delete_user_title": "Odstrániť používateľa '%{name}'", "delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?", - "remove_missing_title": "Odstráňte chýbajúce súbory", - "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", - "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", - "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", "notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača", "notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https", "lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý", "lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť", "lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý", "lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť", - "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", - "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", - "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", "openIn": { "lastfm": "Otvoriť na Last.fm", "musicbrainz": "Otvoriť na MusicBrainz" }, "lastfmLink": "Čítať ďalej...", + "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", + "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", + "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte", "shareOriginalFormat": "Zdieľať v pôvodnom formáte", "shareDialogTitle": "Zdieľať %{resource} '%{name}'", "shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}", - "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", "shareSuccess": "URL skopírovaná do schránky: %{url}", "shareFailure": "Chyba pri kopírovaní URL %{url} do schránky", "downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte" + "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", + "remove_missing_title": "Odstráňte chýbajúce súbory", + "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", + "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", + "noTopSongsFound": "Nenašli sa žiadne top skladby", + "startingInstantMix": "Načítava sa Instant Mix...", + "uploadCover": "Nahrať obrázok obalu", + "removeCover": "Odstrániť obrázok obalu", + "coverUploaded": "Obrázok obalu albumu aktualizovaný", + "coverRemoved": "Obrázok obalu albumu odstránený", + "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", + "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu" }, "menu": { "library": "Knižnica", - "librarySelector": { - "allLibraries": "Všetky knižnice (%{count})", - "multipleLibraries": "%{selected} z %{total} knižníc", - "selectLibraries": "Vyberte knižnice", - "none": "Žiadne" - }, "settings": "Nastavenia", "version": "Verzia", "theme": "Téma", @@ -619,7 +613,6 @@ "language": "Jazyk", "defaultView": "Predvolená stránka", "desktop_notifications": "Oznámenia na ploche", - "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný", "lastfmScrobbling": "Scrobblovať na Last.fm", "listenBrainzScrobbling": "Scrobblovať na ListenBrainz", "replaygain": "Mód ReplayGain", @@ -628,13 +621,20 @@ "none": "Vypnuté", "album": "Použiť Album Gain", "track": "Použiť Track Gain" - } + }, + "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný" } }, "albumList": "Albumy", + "about": "O Navidrome", "playlists": "Zoznamy skladieb", "sharedPlaylists": "Zdieľané zoznamy skladieb", - "about": "O Navidrome" + "librarySelector": { + "allLibraries": "Všetky knižnice (%{count})", + "multipleLibraries": "%{selected} z %{total} knižníc", + "selectLibraries": "Vyberte knižnice", + "none": "Žiadne" + } }, "player": { "playListsText": "Rad", @@ -682,11 +682,11 @@ "currentValue": "Aktuálna hodnota", "configurationFile": "Konfiguračný súbor", "exportToml": "Exportovať konfiguráciu (TOML)", - "downloadToml": "Stiahnuť konfiguráciu (TOML)", "exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML", "exportFailed": "Nepodarilo sa skopírovať konfiguráciu", "devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)", - "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách" + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách", + "downloadToml": "Stiahnuť konfiguráciu (TOML)" } }, "activity": { @@ -694,17 +694,12 @@ "totalScanned": "Naskenované priečinky", "quickScan": "Rýchly sken", "fullScan": "Úplný sken", - "selectiveScan": "Selektívne", "serverUptime": "Doba od spustenia", "serverDown": "OFFLINE", "scanType": "Posledný Sken", "status": "Chyba skenovania", - "elapsedTime": "Uplynutý čas" - }, - "nowPlaying": { - "title": "Práve hrá", - "empty": "Nič sa neprehráva", - "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" + "elapsedTime": "Uplynutý čas", + "selectiveScan": "Selektívne" }, "help": { "title": "Klávesové skratky Navidrome", @@ -714,10 +709,15 @@ "toggle_play": "Prehrať / Pozastaviť", "prev_song": "Predchádzajúca skladba", "next_song": "Nasledujúca skladba", - "current_song": "Prejsť na aktuálnu skladbu", "vol_up": "Zvýšiť hlasitosť", "vol_down": "Znížiť hlasitosť", - "toggle_love": "Pridať túto skladbu do obľúbených" + "toggle_love": "Pridať túto skladbu do obľúbených", + "current_song": "Prejsť na aktuálnu skladbu" } + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" } } \ No newline at end of file diff --git a/resources/i18n/th.json b/resources/i18n/th.json index b445d7464..fde89494e 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -38,7 +38,9 @@ "missing": "หายไป", "libraryName": "ห้องสมุด", "composer": "ผู้แต่ง", - "disc": "" + "disc": "พื้นที่ %{discNumber}", + "albumGain": "เนื้อหาในอัลบั้ม", + "trackGain": "เนื้อหาในเพลง" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -355,7 +357,7 @@ "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", - "allowWriteAccess": "" + "allowWriteAccess": "อนุญาตให้เขียน" }, "sections": { "status": "สถานะ", @@ -401,7 +403,7 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น" }, "placeholders": { "configKey": "คีย์", @@ -591,7 +593,13 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...", + "uploadCover": "อัพโหลดภาพหน้าปก", + "removeCover": "ลบถาพหน้าปก", + "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", + "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", + "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", + "coverRemoveError": "ลบภาพหน้าปกผิดพลาด" }, "menu": { "library": "ห้องสมุดเพลง", @@ -712,4 +720,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} +} \ No newline at end of file diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 92b4af3d0..d00ae2ac3 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,9 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "光碟 %{discNumber}" + "disc": "光碟 %{discNumber}", + "albumGain": "專輯增益", + "trackGain": "曲目增益" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +720,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file From a6451f75d6bfab01a612ed05e20d7345bddc7544 Mon Sep 17 00:00:00 2001 From: Xabi <888924+xabirequejo@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:32:10 +0200 Subject: [PATCH 271/596] fix(ui): update Basque localisation (#5364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added two strings (gain), imrpoved some, fixed a typo Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/eu.json | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 6bfd09d0e..30db91cde 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -2,7 +2,7 @@ "languageName": "Euskara", "resources": { "song": { - "name": "Abestia |||| Abesti", + "name": "Abestia |||| Abestiak", "fields": { "albumArtist": "Albumaren artista", "duration": "Iraupena", @@ -22,6 +22,8 @@ "bitRate": "Bit-tasa", "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", + "albumGain": "Album-irabazia", + "trackGain": "Pista-irabazia", "channels": "Kanalak", "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", @@ -53,7 +55,7 @@ } }, "album": { - "name": "Albuma |||| Album", + "name": "Albuma |||| Albumak", "fields": { "albumArtist": "Albumaren artista", "artist": "Artista", @@ -104,7 +106,7 @@ } }, "artist": { - "name": "Artista |||| Artista", + "name": "Artista |||| Artistak", "fields": { "name": "Izena", "albumCount": "Album kopurua", @@ -117,7 +119,7 @@ "missing": "Ez da aurkitu" }, "roles": { - "albumartist": "Albumeko egilea |||| Albumeko artistak", + "albumartist": "Albumeko artista |||| Albumeko artistak", "artist": "Artista |||| Artistak", "composer": "Konpositorea |||| Konpositoreak", "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", @@ -335,7 +337,7 @@ } }, "plugin": { - "name": "Plugina |||| Plugin", + "name": "Plugina |||| Pluginak", "fields": { "id": "IDa", "name": "Izena", @@ -492,7 +494,7 @@ "input": { "file": { "upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.", - "upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia." + "upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia." }, "image": { "upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.", @@ -537,9 +539,9 @@ "skip_nav": "Joan edukira" }, "notification": { - "updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira", + "updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira", "created": "Elementua sortu da", - "deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.", + "deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.", "bad_item": "Elementu okerra", "item_doesnt_exist": "Elementua ez dago", "http_error": "Errorea zerbitzariarekin komunikatzerakoan", @@ -588,7 +590,7 @@ "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", "openIn": { - "lastfm": "Ikusi Last.fm-n", + "lastfm": "Ikusi Last.fm-en", "musicbrainz": "Ikusi MusicBrainz-en" }, "lastfmLink": "Irakurri gehiago…", From 1709ce37f694ecab5b21c3d0e17c5a5dc23254cf Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:32:57 -0600 Subject: [PATCH 272/596] fix(ui): update Spanish translations and add missing gain keys (#5433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): update Spanish translations and add missing gain keys - Add missing 'albumGain' and 'trackGain' keys (matches recent additions in pt-br, ru) - Translate 'Playlists' and 'Shared Playlists' to 'Listas de reproducción' / '...compartidas' - Translate 'OFFLINE' (server down indicator) to 'DESCONECTADO' Spanish translation now covers 553/553 keys (was 551/553). Signed-off-by: Daniel Banariba <banaribad@gmail.com> * fix(ui): address review feedback on Spanish translations - Use 'Ganancia del álbum' (with article 'del') for consistency with the existing pattern in line 624 ('album': 'Ganancia del álbum') and 'Artista del álbum'. Thanks @gemini-code-assist for the catch. - Revert 'playlists' and 'sharedPlaylists' to keep the loanword 'Playlist(s)' which is the form actually used by Spanish-speaking music app users (Spotify ES, etc.) and matches existing usage elsewhere in this same file (e.g. line 48 'Agregar a la playlist'). Signed-off-by: Daniel Banariba <banaribad@gmail.com> --------- Signed-off-by: Daniel Banariba <banaribad@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/es.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/i18n/es.json b/resources/i18n/es.json index a018eda3d..555c165d6 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -35,6 +35,8 @@ "rawTags": "Etiquetas sin procesar", "bitDepth": "Profundidad de bits", "sampleRate": "Frecuencia de muestreo", + "albumGain": "Ganancia del álbum", + "trackGain": "Ganancia de pista", "missing": "Faltante", "libraryName": "Biblioteca", "composer": "Compositor", @@ -693,7 +695,7 @@ "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", "serverUptime": "Uptime del servidor", - "serverDown": "OFFLINE", + "serverDown": "DESCONECTADO", "scanType": "Tipo", "status": "Error de escaneo", "elapsedTime": "Tiempo transcurrido", From 318ad164df36373f79f9d444f26382095e841fab Mon Sep 17 00:00:00 2001 From: Buck DeFore <bdefore@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:35:00 -0400 Subject: [PATCH 273/596] fix(ui): suppress capitalization and correction for login on mobile keyboards (#3783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * suppress capitalization and correction for login on mobile keyboards * prettier pass --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/layout/Login.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index 91f56b273..a84e01f4d 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -101,8 +101,13 @@ const renderInput = ({ }) => ( <TextField error={!!(touched && error)} + inputProps={{ + // mobile keyboards: suppress capitalization and correction for login related fields + autocapitalize: 'none', + autocorrect: 'off', + ...inputProps, + }} helperText={touched && error} - {...inputProps} {...props} fullWidth /> From 29c123854cdb3f17adb11c2a7950ef634b93143a Mon Sep 17 00:00:00 2001 From: craiglush <75083395+craiglush@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:35:56 +0100 Subject: [PATCH 274/596] feat(ui): Add Moonbase themes (Alpha light + Bravo dark) (#5243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Moonbase theme A warm dark theme with gold (#d4a039) accents on deep charcoal backgrounds (#0a0a09/#141413). Features muted cream text (#e5ddd3), copper error states (#c45c3c), and subtle earthy secondary tones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix review comments on Moonbase theme - Fix CSS selector: use :not(.player-delete) instead of :not([class=".player-delete"]) - Fix MuiFormHelperText override structure: target error key directly - Remove empty icon: {} and avatar: {} from NDLogin overrides - Use comma-separated rgba syntax and hex for linear-gradient Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add Moonbase Alpha (light) and rename dark to Moonbase Bravo Split the Moonbase theme into a complementary pair: - Moonbase Alpha: warm cream/stone light theme with deep gold accents - Moonbase Bravo: the original deep charcoal dark theme Both share the same gold (#d4a039) brand accent, copper error states, and earthy neutral palette. Alpha uses darkened gold (#9a7420) for better contrast on light backgrounds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/themes/index.js | 4 ++ ui/src/themes/moonbaseAlpha.css.js | 63 +++++++++++++++++++++ ui/src/themes/moonbaseAlpha.js | 90 ++++++++++++++++++++++++++++++ ui/src/themes/moonbaseBravo.css.js | 63 +++++++++++++++++++++ ui/src/themes/moonbaseBravo.js | 90 ++++++++++++++++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 ui/src/themes/moonbaseAlpha.css.js create mode 100644 ui/src/themes/moonbaseAlpha.js create mode 100644 ui/src/themes/moonbaseBravo.css.js create mode 100644 ui/src/themes/moonbaseBravo.js diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index f65948438..e6cd4e0ff 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -15,6 +15,8 @@ import NutballTheme from './nutball' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' +import MoonbaseAlphaTheme from './moonbaseAlpha' +import MoonbaseBravoTheme from './moonbaseBravo' export default { // Classic default themes @@ -31,6 +33,8 @@ export default { GruvboxDarkTheme, LigeraTheme, MonokaiTheme, + MoonbaseAlphaTheme, + MoonbaseBravoTheme, NautilineTheme, NordTheme, NuclearTheme, diff --git a/ui/src/themes/moonbaseAlpha.css.js b/ui/src/themes/moonbaseAlpha.css.js new file mode 100644 index 000000000..757cfc03c --- /dev/null +++ b/ui/src/themes/moonbaseAlpha.css.js @@ -0,0 +1,63 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #9a7420 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #b8862e; + border-color: #9a7420 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #c9b896; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #b8862e +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #9a7420 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #9a7420 !important +} + +.react-jinke-music-player-main .rc-slider-rail { + background-color: #ddd7cc !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #1a1917 !important +} + +.react-jinke-music-player-main .music-player-panel { + color: #1a1917 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #9a7420 !important +} + +.music-player-lyric { + color: #9a7420 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #9a7420 +} +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #9a7420 +} + +.progress-bar-content .audio-title a { + color: #1a1917 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #b8862e !important +} +` +export default stylesheet diff --git a/ui/src/themes/moonbaseAlpha.js b/ui/src/themes/moonbaseAlpha.js new file mode 100644 index 000000000..51d8a2696 --- /dev/null +++ b/ui/src/themes/moonbaseAlpha.js @@ -0,0 +1,90 @@ +import stylesheet from './moonbaseAlpha.css.js' + +export default { + themeName: 'Moonbase - Alpha', + palette: { + primary: { + main: '#9a7420', + }, + secondary: { + main: '#ede8df', + contrastText: '#1a1917', + }, + type: 'light', + background: { + default: '#f5f0e8', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#1a1917', + backgroundColor: '#faf8f4', + }, + }, + MuiButton: { + textPrimary: { + color: '#9a7420', + }, + textSecondary: { + color: '#1a1917', + }, + }, + MuiChip: { + clickable: { + background: '#ede8df', + }, + }, + MuiFormGroup: { + root: { + color: '#1a1917', + }, + }, + MuiFormHelperText: { + error: { + color: '#b04a2e', + }, + }, + MuiTableHead: { + root: { + color: '#6b635a', + background: '#f5f0e8 !important', + }, + }, + MuiTableCell: { + root: { + color: '#1a1917', + background: '#faf8f4 !important', + }, + head: { + color: '#6b635a', + background: '#f5f0e8 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#9a7420', + }, + welcome: { + color: '#1a1917', + }, + card: { + minWidth: 300, + background: '#faf8f4', + }, + button: { + boxShadow: '3px 3px 5px rgba(0, 0, 0, 0.12)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(245, 240, 232, 0.72), #faf8f4)!important', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/moonbaseBravo.css.js b/ui/src/themes/moonbaseBravo.css.js new file mode 100644 index 000000000..580b054cc --- /dev/null +++ b/ui/src/themes/moonbaseBravo.css.js @@ -0,0 +1,63 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #d4a039 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #d4a039; + border-color: #b8862e +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #d4a039; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #d4a039 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #d4a039 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #d4a039 !important +} + +.react-jinke-music-player-main .rc-slider-rail { + background-color: #2a2a27 !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #e5ddd3 !important +} + +.react-jinke-music-player-main .music-player-panel { + color: #e5ddd3 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #d4a039 !important +} + +.music-player-lyric { + color: #d4a039 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #d4a039 +} +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #d4a039 +} + +.progress-bar-content .audio-title a { + color: #e5ddd3 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #d4a039 !important +} +` +export default stylesheet diff --git a/ui/src/themes/moonbaseBravo.js b/ui/src/themes/moonbaseBravo.js new file mode 100644 index 000000000..87585df29 --- /dev/null +++ b/ui/src/themes/moonbaseBravo.js @@ -0,0 +1,90 @@ +import stylesheet from './moonbaseBravo.css.js' + +export default { + themeName: 'Moonbase - Bravo', + palette: { + primary: { + main: '#d4a039', + }, + secondary: { + main: '#1e1e1c', + contrastText: '#e5ddd3', + }, + type: 'dark', + background: { + default: '#0a0a09', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e5ddd3', + backgroundColor: '#141413', + }, + }, + MuiButton: { + textPrimary: { + color: '#d4a039', + }, + textSecondary: { + color: '#e5ddd3', + }, + }, + MuiChip: { + clickable: { + background: '#1e1e1c', + }, + }, + MuiFormGroup: { + root: { + color: '#e5ddd3', + }, + }, + MuiFormHelperText: { + error: { + color: '#c45c3c', + }, + }, + MuiTableHead: { + root: { + color: '#8a8278', + background: '#0a0a09 !important', + }, + }, + MuiTableCell: { + root: { + color: '#e5ddd3', + background: '#141413 !important', + }, + head: { + color: '#8a8278', + background: '#0a0a09 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#d4a039', + }, + welcome: { + color: '#e5ddd3', + }, + card: { + minWidth: 300, + background: '#1e1e1c', + }, + button: { + boxShadow: '3px 3px 5px #0a0a09', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(10, 10, 9, 0.72), #141413)!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} From e15896bf3260da57dc4e5331cc7d8271ba22b5bd Mon Sep 17 00:00:00 2001 From: Love <51426041+lov3b@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:42:27 +0200 Subject: [PATCH 275/596] feat(ui): Add Catppuccin Latte (#5250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Catppuccin Latte (the light version) theme based on the existing Catppuccin Macchiato theme. The palette and player styling are adapted for light mode while staying as close as practical to the existing Macchiato theme behavior. I've opted to use gray for the color for controls. The dark version appears to mix a few control/accent colors, so for Latte I standardized those choices. This might be worth looking into in a separate PR. It uses gray and blue. Signed-off-by: Love Billenius <lovebillenius@disroot.org> Co-authored-by: Deluan Quintão <deluan@navidrome.org> Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/themes/catppuccinLatte.css.js | 203 +++++++++++++++++++++++++++ ui/src/themes/catppuccinLatte.js | 104 ++++++++++++++ ui/src/themes/index.js | 2 + 3 files changed, 309 insertions(+) create mode 100644 ui/src/themes/catppuccinLatte.css.js create mode 100644 ui/src/themes/catppuccinLatte.js diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js new file mode 100644 index 000000000..84c8d2d7f --- /dev/null +++ b/ui/src/themes/catppuccinLatte.css.js @@ -0,0 +1,203 @@ +const stylesheet = ` + .react-jinke-music-player-main.light-theme svg, + .react-jinke-music-player .music-player-controller, + .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { + color: #6c6f85; + stroke: #6c6f85; + } + + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #7c7f93; + } + + .react-jinke-music-player-main.light-theme svg:active, + .react-jinke-music-player-main.light-theme svg:hover { + color: #7c7f93; + } + + .react-jinke-music-player-mobile-play-model-tip, + .react-jinke-music-player-main.light-theme .play-mode-title { + background-color: #6c6f85; + color: #eff1f5; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #6c6f85; + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #6c6f85; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #6c6f85; + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #6c6f85; + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #6c6f85 !important; + } + + .react-jinke-music-player-main .loading svg { + color: #6c6f85 !important; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .rc-slider-rail, + .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #6c6f85 !important; + border: 1px solid #6c6f85; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #e6e9ef; + color: #4c4f69; + box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); + } + + .react-jinke-music-player-main.light-theme .music-player-panel { + color: #4c4f69; + } + + .audio-lists-panel { + background-color: #e6e9ef; + bottom: 6.25rem; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: rgba(76, 79, 105, 0.08); + } + + .audio-lists-panel-header { + border-bottom: 1px solid #ccd0da; + box-shadow: none; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; + } + + .react-jinke-music-player-main.light-theme .audio-lists-panel-header { + background-color: #e6e9ef; + color: #4c4f69; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + color: #4c4f69; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #6c6f85; /* subtext0 */ + -webkit-text-stroke: 0.35px #eff1f5; + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #6c6f85 !important; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #6c6f85; + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #6c6f85; + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #dce0e8; /* surface1 */ + } + + /* Mobile */ + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + background-color: #e6e9ef; + border-color: #e6e9ef; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + color: #6c6f85; + } + + .react-jinke-music-player .music-player-controller.music-player-playing:before { + border: 1px solid rgba(76, 79, 105, 0.18); + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + background: rgba(108, 111, 133, 0.2); + color: #eff1f5; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, + .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #6c6f85; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinLatte.js b/ui/src/themes/catppuccinLatte.js new file mode 100644 index 000000000..3624cd853 --- /dev/null +++ b/ui/src/themes/catppuccinLatte.js @@ -0,0 +1,104 @@ +import stylesheet from './catppuccinLatte.css.js' + +export default { + themeName: 'Catppuccin Latte', + palette: { + primary: { main: '#8839ef' }, // mauve + secondary: { + main: '#ccd0da', // surface0 + contrastText: '#4c4f69', // text + }, + type: 'light', + background: { + default: '#eff1f5', // base + }, + }, + + overrides: { + MuiPaper: { + root: { + color: '#4c4f69', // text + backgroundColor: '#e6e9ef', // mantle + }, + }, + + MuiButton: { + textPrimary: { + color: '#1e66f5', // blue + }, + textSecondary: { + color: '#4c4f69', // text + }, + }, + + MuiChip: { + clickable: { + background: '#ccd0da', // surface0 + }, + }, + + MuiFormGroup: { + root: { + color: '#4c4f69', + }, + }, + + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#d20f39', // red + }, + }, + }, + }, + + MuiTableHead: { + root: { + color: '#4c4f69', + background: '#e6e9ef', + }, + }, + + MuiTableCell: { + root: { + color: '#4c4f69', + background: '#e6e9ef !important', + }, + head: { + color: '#4c4f69', + background: '#e6e9ef !important', + }, + }, + + NDLogin: { + systemNameLink: { + color: '#8839ef', // mauve + }, + icon: {}, + welcome: { + color: '#4c4f69', + }, + card: { + minWidth: 300, + background: '#eff1f5', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #ccd0da', + }, + }, + + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(255 255 255 / 72%), rgb(239 241 245))!important', + }, + }, + }, + + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index e6cd4e0ff..f4886fd31 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -9,6 +9,7 @@ import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import CatppuccinLatteTheme from './catppuccinLatte' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' import NutballTheme from './nutball' @@ -26,6 +27,7 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, CatppuccinMacchiatoTheme, + CatppuccinLatteTheme, DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, From cc18bf7329138372e31aec47f177e28bb73afd2e Mon Sep 17 00:00:00 2001 From: Metalhearf <6446231+Metalhearf@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:04:48 +0200 Subject: [PATCH 276/596] feat(ui): add Tokyo Night theme (#5497) * feat(themes): add Tokyo Night theme Signed-off-by: Metalhearf <6446231+Metalhearf@users.noreply.github.com> * fix(themes): address review feedback on Tokyo Night Signed-off-by: Metalhearf <6446231+Metalhearf@users.noreply.github.com> --------- Signed-off-by: Metalhearf <6446231+Metalhearf@users.noreply.github.com> Co-authored-by: Deluan <deluan@navidrome.org> --- ui/src/themes/index.js | 4 + ui/src/themes/tokyoNight.css.js | 143 ++++++++++ ui/src/themes/tokyoNight.js | 382 +++++++++++++++++++++++++++ ui/src/themes/tokyoNightLight.css.js | 123 +++++++++ ui/src/themes/tokyoNightLight.js | 382 +++++++++++++++++++++++++++ 5 files changed, 1034 insertions(+) create mode 100644 ui/src/themes/tokyoNight.css.js create mode 100644 ui/src/themes/tokyoNight.js create mode 100644 ui/src/themes/tokyoNightLight.css.js create mode 100644 ui/src/themes/tokyoNightLight.js diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index f4886fd31..f79a6a999 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -18,6 +18,8 @@ import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' import MoonbaseAlphaTheme from './moonbaseAlpha' import MoonbaseBravoTheme from './moonbaseBravo' +import TokyoNightLightTheme from './tokyoNightLight' +import TokyoNightTheme from './tokyoNight' export default { // Classic default themes @@ -43,4 +45,6 @@ export default { NutballTheme, SpotifyTheme, SquiddiesGlassTheme, + TokyoNightLightTheme, + TokyoNightTheme, } diff --git a/ui/src/themes/tokyoNight.css.js b/ui/src/themes/tokyoNight.css.js new file mode 100644 index 000000000..882fcd3eb --- /dev/null +++ b/ui/src/themes/tokyoNight.css.js @@ -0,0 +1,143 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #7aa2f7 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #7aa2f7 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #7aa2f7; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #7aa2f7 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #7aa2f7 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #7aa2f7 !important +} + +.react-jinke-music-player-main .loading svg { + color: #7aa2f7 !important +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #24283b; + color: #c0caf5; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +.audio-lists-panel { + background-color: #24283b; + bottom: 6.25rem; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #292e42; +} + +.audio-lists-panel-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: none; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #c0caf5; + -webkit-text-stroke: 0.5px #1a1b26; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #7aa2f7 !important; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #7aa2f7 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #7aa2f7 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #7aa2f7; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122, 162, 247, .3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #7aa2f7; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js new file mode 100644 index 000000000..07d372a6b --- /dev/null +++ b/ui/src/themes/tokyoNight.js @@ -0,0 +1,382 @@ +import stylesheet from './tokyoNight.css.js' + +const background = '#1a1b26' +const surface = '#24283b' +const currentLine = '#292e42' +const foreground = '#c0caf5' +const comment = '#565f89' +const blue = '#7aa2f7' +const cyan = '#7dcfff' +const purple = '#bb9af7' +const red = '#f7768e' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Tokyo Night', + palette: { + primary: { + main: blue, + }, + secondary: { + main: purple, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'dark', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${blue} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: blue, + }, + '&$checked + $track': { + backgroundColor: blue, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: purple, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: blue, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #15161e', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(26 27 38 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/tokyoNightLight.css.js b/ui/src/themes/tokyoNightLight.css.js new file mode 100644 index 000000000..a22c82d03 --- /dev/null +++ b/ui/src/themes/tokyoNightLight.css.js @@ -0,0 +1,123 @@ +const stylesheet = ` +.react-jinke-music-player-main.light-theme .loading svg { + color: #2e7de9; + font-size: 24px +} + +.react-jinke-music-player-mobile-play-model-tip { + background-color: #2e7de9; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #2e7de9 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer { + color: #2e7de9 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #2e7de9 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb { + background-color: #2e7de9; +} + +.react-jinke-music-player-main.light-theme svg { + color: #3760bf +} + +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: rgba(55, 96, 191, .12) !important +} + +.react-jinke-music-player-main.light-theme .music-player-controller { + background-color: #d5d6db; + border-color: #d5d6db +} + +.react-jinke-music-player-main.light-theme .music-player-panel { + background-color: #d5d6db; + box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05); + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .music-player-panel .img-content { + box-shadow: 0 0 10px #c4c8da +} + +.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar { + background-color: rgba(55, 96, 191, .08) !important +} + +.react-jinke-music-player-main.light-theme .rc-switch { + color: #fff +} + +.react-jinke-music-player-main.light-theme .rc-switch:after { + background-color: #fff +} + +.react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #2e7de9 !important; + border: 1px solid #2e7de9 +} + +.react-jinke-music-player-main.light-theme .rc-switch-inner { + color: #fff +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: #e1e2e7 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover { + background-color: #ebebed; + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel { + background-color: #d5d6db; + box-shadow: 0 0 2px #c4c8da; + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item { + background-color: #d5d6db +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) { + background-color: #dadbe0 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing { + background-color: #c4c8da !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { + color: #2e7de9 !important +} +` + +export default stylesheet diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js new file mode 100644 index 000000000..f84cd0be9 --- /dev/null +++ b/ui/src/themes/tokyoNightLight.js @@ -0,0 +1,382 @@ +import stylesheet from './tokyoNightLight.css.js' + +const background = '#e1e2e7' +const surface = '#d5d6db' +const currentLine = '#c4c8da' +const foreground = '#3760bf' +const comment = '#848cb5' +const blue = '#2e7de9' +const cyan = '#007197' +const purple = '#9854f1' +const red = '#f52a65' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Tokyo Night Light', + palette: { + primary: { + main: blue, + }, + secondary: { + main: purple, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'light', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.15) 0px 4px 6px, rgba(15, 17, 21, 0.08) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${blue} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: blue, + }, + '&$checked + $track': { + backgroundColor: blue, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 20%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: purple, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: blue, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #a8aecb', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(225 226 231 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} From 9a2eb483e8e9ed21c75ebadccab59fe35ad40cd2 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 6 Jun 2026 10:58:13 -0400 Subject: [PATCH 277/596] fix(transcode): log warning for invalid or stale transcode tokens Signed-off-by: Deluan <deluan@navidrome.org> --- server/subsonic/transcode.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 4e494b324..578ad44fc 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -370,6 +370,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* if err != nil { switch { case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale): + log.Warn(ctx, "Invalid or stale transcode token", "mediaID", mediaID, err) http.Error(w, "Gone", http.StatusGone) default: log.Error(ctx, "Error validating transcode params", err) From 5c387630ffc8042755cfdcd9edaecdb427592331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 7 Jun 2026 12:29:17 -0400 Subject: [PATCH 278/596] fix(ui): update Estonian translations from POEditor (#5573) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/et.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/i18n/et.json b/resources/i18n/et.json index d511c1246..b0131f4ea 100644 --- a/resources/i18n/et.json +++ b/resources/i18n/et.json @@ -154,12 +154,12 @@ "currentPassword": "Senine salasõna", "newPassword": "Uus salasõna", "token": "Tunnusluba", - "lastAccessAt": "Viimasti avatud", + "lastAccessAt": "Viimati avatud", "libraries": "Kogumikud" }, "helperTexts": { "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", - "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks" + "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikud või jäta vaikimisi väärtuse kasutamiseks tühjaks" }, "notifications": { "created": "Kasutaja on lisatud", @@ -413,10 +413,10 @@ }, "ra": { "auth": { - "welcome1": "Aitäh, et paigaldasite Navidrome'i!", + "welcome1": "Aitäh, et paigaldasid Navidrome'i!", "welcome2": "Alustamiseks lisa peakasutaja", "confirmPassword": "Korda salasõna", - "buttonCreateAdmin": "Loo admin", + "buttonCreateAdmin": "Lisa peakasutaja", "auth_check_error": "Jätkamiseks palun logi sisse", "user_menu": "Profiil", "username": "Kasutajanimi", @@ -427,7 +427,7 @@ "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" }, "validation": { - "invalidChars": "Palun kasutage ainult tähti ja numbreid", + "invalidChars": "Palun kasuta ainult tähti ja numbreid", "passwordDoesNotMatch": "Salasõnad ei kattu", "required": "Nõutav", "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", @@ -558,8 +558,8 @@ }, "message": { "note": "MÄRGE", - "transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.", - "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.", + "transcodingDisabled": "Teisendusseadistuste muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovid muuta või lisada teisendamisega seotud seadistusi, taaskäivita server %{config} valikuga.", + "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese teisendusseadistuste käivitada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult teisendusvalikute muutmiseks.", "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", "noPlaylistsAvailable": "Pole saadaval", "delete_user_title": "Kustuta kasutaja „%{name}“", @@ -603,13 +603,13 @@ }, "menu": { "library": "Kogumik", - "settings": "Seaded", + "settings": "Seadistused", "version": "Versioon", - "theme": "Teema", + "theme": "Kujundus", "personal": { "name": "Isiklik", "options": { - "theme": "Teema", + "theme": "Kujundus", "language": "Keel", "defaultView": "Vaikimisi vaade", "desktop_notifications": "Teavitused töölaual", From 1b46b9771229ee903d094968337d3e2efcfcbf20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 8 Jun 2026 08:22:59 -0400 Subject: [PATCH 279/596] fix(ui): update Indonesian translations from POEditor (#5575) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/id.json | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/resources/i18n/id.json b/resources/i18n/id.json index cdba66663..762ce4ebb 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -37,7 +37,10 @@ "sampleRate": "Sample rate", "missing": "Hilang", "libraryName": "Pustaka", - "composer": "Komposer" + "composer": "Komposer", + "disc": "Disk %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Trek gain" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -353,7 +356,8 @@ "allUsers": "Izinkan semua pengguna", "selectedUsers": "Pengguna yang dipilih", "allLibraries": "Izinkan semua pustaka", - "selectedLibraries": "Pustaka dipilih" + "selectedLibraries": "Pustaka dipilih", + "allowWriteAccess": "Izinkan akses tulis" }, "sections": { "status": "Status", @@ -398,7 +402,8 @@ "librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.", "requiredHosts": "Hosts diperlukan", "configValidationError": "Validasi konfigurasi gagal:", - "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid." + "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid.", + "allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only" }, "placeholders": { "configKey": "key", @@ -588,7 +593,13 @@ "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan", "noTopSongsFound": "Tidak ada lagu teratas ditemukan", - "startingInstantMix": "Memuat Mix Instan..." + "startingInstantMix": "Memuat Mix Instan...", + "uploadCover": "Unggah Sampul", + "removeCover": "Hapus Sampul", + "coverUploaded": "Sampul diperbarui", + "coverRemoved": "Sampul dihapus", + "coverUploadError": "Kesalahan mengunggah sampul", + "coverRemoveError": "Kesalahan menghapus sampul" }, "menu": { "library": "Pustaka", @@ -674,7 +685,8 @@ "exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML", "exportFailed": "Gagal menyalin konfigurasi", "devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)", - "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang" + "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang", + "downloadToml": "Unduh Konfigurasi (TOML)" } }, "activity": { From 9cd2cd0a8b8675a5229fe2ee79ec71b5fdde0b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 9 Jun 2026 19:27:15 -0400 Subject: [PATCH 280/596] fix(ui): load ND_DEFAULTLANGUAGE on app startup (#4000) * fix: load ND_DEFAULTLANGUAGE on app startup Added in to apply on initial mount, ensuring the locale is set even when the login page is skipped by reverse-proxy authentication. Removed the redundant language-init effect from . Fixes #3605. * style(ui): format App.jsx with Prettier Ran Prettier on ui/src/App.jsx to satisfy code style checks after adding default-language useEffect. * fix(ui): move default language initialization to Admin component Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): streamline locale setting in App component Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/App.jsx | 28 ++++++++++++++++++++++++++-- ui/src/layout/Login.jsx | 24 +----------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 35eaee3eb..d10aa5a33 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,7 +1,12 @@ import ReactGA from 'react-ga' import { Provider } from 'react-redux' import { createHashHistory } from 'history' -import { Admin as RAAdmin, Resource } from 'react-admin' +import { + Admin as RAAdmin, + Resource, + useSetLocale, + useRefresh, +} from 'react-admin' import { HotKeys } from 'react-hotkeys' import dataProvider from './dataProvider' import authProvider from './authProvider' @@ -36,7 +41,7 @@ import { transcodingReducer, } from './reducers' import createAdminStore from './store/createAdminStore' -import { i18nProvider } from './i18n' +import { i18nProvider, retrieveTranslation } from './i18n' import config, { shareInfo } from './config' import { keyMap } from './hotkeys' import useChangeThemeColor from './useChangeThemeColor' @@ -44,6 +49,7 @@ import SharePlayer from './share/SharePlayer' import { HTML5Backend } from 'react-dnd-html5-backend' import { DndProvider } from 'react-dnd' import missing from './missing/index.js' +import { useEffect } from 'react' const history = createHashHistory() @@ -84,6 +90,24 @@ const App = () => ( ) const Admin = (props) => { + const setLocale = useSetLocale() + const refresh = useRefresh() + useEffect(() => { + if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { + retrieveTranslation(config.defaultLanguage) + .then(() => setLocale(config.defaultLanguage)) + .then(() => { + localStorage.setItem('locale', config.defaultLanguage) + refresh(true) + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error( + 'Cannot load language "' + config.defaultLanguage + '": ' + e, + ) + }) + } + }, [setLocale, refresh]) useChangeThemeColor() /* eslint-disable react/jsx-key */ return ( diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index a84e01f4d..a7763cff3 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react' +import React, { useState, useCallback } from 'react' import PropTypes from 'prop-types' import { Field, Form } from 'react-final-form' import { useDispatch } from 'react-redux' @@ -13,8 +13,6 @@ import { createMuiTheme, useLogin, useNotify, - useRefresh, - useSetLocale, useTranslate, useVersion, } from 'react-admin' @@ -24,7 +22,6 @@ import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' import config from '../config' import { clearQueue } from '../actions' -import { retrieveTranslation } from '../i18n' import { INSIGHTS_DOC_URL } from '../consts.js' const useStyles = makeStyles( @@ -407,27 +404,8 @@ Login.propTypes = { // the right theme const LoginWithTheme = (props) => { const theme = useCurrentTheme() - const setLocale = useSetLocale() - const refresh = useRefresh() const version = useVersion() - useEffect(() => { - if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { - retrieveTranslation(config.defaultLanguage) - .then(() => { - setLocale(config.defaultLanguage).then(() => { - localStorage.setItem('locale', config.defaultLanguage) - }) - refresh(true) - }) - .catch((e) => { - throw new Error( - 'Cannot load language "' + config.defaultLanguage + '": ' + e, - ) - }) - } - }, [refresh, setLocale]) - return ( <ThemeProvider theme={createMuiTheme(theme)}> <Login key={version} {...props} /> From b6fba33b1406fddd43c536e453a77164bff7c5d5 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 9 Jun 2026 08:50:09 -0400 Subject: [PATCH 281/596] chore(docs): add Danian hosting option to installation instructions Signed-off-by: Deluan <deluan@navidrome.org> --- release/goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/goreleaser.yml b/release/goreleaser.yml index e5035adda..103f2beaf 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -114,7 +114,7 @@ release: ## Where to go next? * Read installation instructions on our [website](https://www.navidrome.org/docs/installation/). - * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) for a simple cloud solution. + * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution. * Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)! # Add the MSI installers to the release From bd3192be0b36cb63d0b01b618c9e79226db0e0a0 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 9 Jun 2026 19:29:51 -0400 Subject: [PATCH 282/596] fix(server): make DB `PRAGMA optimize` error non-fatal Signed-off-by: Deluan <deluan@navidrome.org> --- db/db.go | 1 - 1 file changed, 1 deletion(-) diff --git a/db/db.go b/db/db.go index 168c12122..6e5b2f569 100644 --- a/db/db.go +++ b/db/db.go @@ -51,7 +51,6 @@ func Db() *sql.DB { _, err = db.Exec("PRAGMA optimize=0x10002") if err != nil { log.Error("Error applying PRAGMA optimize", err) - return nil } } return db From 37e5a1d248cd2d4cc9f8d4ea95f4f6aa4a49cdda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mrzyg=C5=82=C3=B3d?= <77896685+devBoi76@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:47:54 +0200 Subject: [PATCH 283/596] fix(ui): Fix Nautiline theme font and width on mobile devices (#5590) Signed-off-by: devBoi76 <mrzyglodjan@protonmail.com> --- ui/public/fonts/Unbounded-Variable.woff2 | Bin 50892 -> 259144 bytes ui/src/themes/nautiline.js | 1 - 2 files changed, 1 deletion(-) diff --git a/ui/public/fonts/Unbounded-Variable.woff2 b/ui/public/fonts/Unbounded-Variable.woff2 index 96d8ff5fa1426b92047112b5c3c7b02279dd523e..a2f74491f07bcf8019a62424d62a78f568882ed8 100644 GIT binary patch literal 259144 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%0AO8nLH!I@o*%ID|JQ4VbsA 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 }DJEOzL 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 Rh|iUC@JA|Y5V=`7;1Hzsj>7z=F22P&~` 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 SF2#ediNWA&|0m!G0002i$VdkO literal 50892 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 aL-Fu8;qOO^%F74Uoh_y?ODsRvTm%5u!_SZa diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js index 65ded5fc5..0c13dc0ec 100644 --- a/ui/src/themes/nautiline.js +++ b/ui/src/themes/nautiline.js @@ -627,7 +627,6 @@ const NautilineTheme = { root: { [`@media (max-width: ${breakpoints.xs}px)`]: { padding: '0.7em', - width: '100%', minWidth: 'unset', }, }, From 08fd2227919c72f36e5366cff2bbca853515f77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 10 Jun 2026 21:12:31 -0400 Subject: [PATCH 284/596] fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585) * fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields ReplayGain values are stored in dedicated nullable columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags JSON blob. The isMissing/isPresent operators previously only supported tag and role fields, causing two failure modes: 1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256: these got registered as JSON tags from mappings.yaml, so isMissing queried json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty -> the playlist matched ALL songs. 2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL generation returned an error. Because refreshSmartPlaylist deletes old tracks before regenerating, the abort left the playlist empty. Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add static alias entries (replaygain_album_gain -> rgalbumgain etc.) with Numeric+Nullable set; because AddTagNames skips names already in the field map, these static entries take precedence over the mappings.yaml tag registration. missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead of the json_tree lookup. Fixes #5584 * chore(smartplaylist): address code review feedback - Simplify the isMissing/isPresent unsupported-field error message, removing the internal "nullable fields" jargon - Standardize comments on mappings.yaml (the actual filename) - Clarify the alias precedence comment in the LookupField test --- model/criteria/fields.go | 31 +++++++++------ model/criteria/fields_test.go | 22 +++++++++++ persistence/criteria_sql.go | 18 ++++++++- persistence/criteria_sql_test.go | 20 ++++++++-- persistence/e2e/e2e_suite_test.go | 22 +++++++---- persistence/e2e/smartplaylist_test.go | 57 +++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 24 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 9eafff7ab..36206712f 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -4,13 +4,14 @@ import "strings" // FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Alias string // If set, this field is a backward-compat alias for another canonical name - IsTag bool - IsRole bool - Numeric bool - Boolean bool + Alias string // If set, this field is a backward-compat alias for another canonical name + IsTag bool + IsRole bool + Numeric bool + Boolean bool + Nullable bool // If set, this column field can be NULL, so isMissing/isPresent are supported on it - tagAlias string // If set, a tag name from mappings.yml that resolves to this field + tagAlias string // If set, a tag name from mappings.yaml that resolves to this field name string // Canonical name, populated by LookupField from the map key } @@ -80,15 +81,23 @@ var fieldMap = map[string]FieldInfo{ "mbz_recording_id": {}, "mbz_release_track_id": {}, "mbz_release_group_id": {}, - "rgalbumgain": {Numeric: true}, - "rgalbumpeak": {Numeric: true}, - "rgtrackgain": {Numeric: true}, - "rgtrackpeak": {Numeric: true}, + "rgalbumgain": {Numeric: true, Nullable: true}, + "rgalbumpeak": {Numeric: true, Nullable: true}, + "rgtrackgain": {Numeric: true, Nullable: true}, + "rgtrackpeak": {Numeric: true, Nullable: true}, "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Backward compatibility: the replaygain_* tag names (as written in metadata and in the + // PR #5256 example) are aliases for the canonical rg* column fields. Without these, the tag + // names would be registered as empty tags from mappings.yaml and isMissing would always match. + "replaygain_album_gain": {Alias: "rgalbumgain", Numeric: true, Nullable: true}, + "replaygain_album_peak": {Alias: "rgalbumpeak", Numeric: true, Nullable: true}, + "replaygain_track_gain": {Alias: "rgtrackgain", Numeric: true, Nullable: true}, + "replaygain_track_peak": {Alias: "rgtrackpeak", Numeric: true, Nullable: true}, + // Pseudo-field for random sorting "random": {}, } @@ -128,7 +137,7 @@ func AddRoles(roles []string) { } } -// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` +// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml` // configuration file. func AddTagNames(tagNames []string) { for _, tagName := range tagNames { diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 5b6f53341..3dc0c7b90 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -53,5 +53,27 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) + It("marks ReplayGain column fields as nullable", func() { + field, ok := LookupField("rgAlbumGain") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) + gomega.Expect(field.Nullable).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeFalse()) + }) + + It("resolves replaygain_* tag names as aliases to nullable column fields", func() { + // AddTagNames skips names already in the field map, so the startup tag registration + // (from mappings.yaml) must not convert the pre-registered alias into a tag field. + AddTagNames([]string{"replaygain_album_gain"}) + + field, ok := LookupField("replaygain_album_gain") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) + gomega.Expect(field.Nullable).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeFalse()) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 37e4ae340..ee0baca18 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -218,8 +218,8 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er } return nil, fmt.Errorf("invalid field in criteria: %s", field) } - if !info.IsTag && !info.IsRole { - return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + if !info.IsTag && !info.IsRole && !info.Nullable { + return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field) } b, ok := value.(bool) @@ -227,6 +227,20 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) } negate := checkAbsence == b + + // Nullable column fields (e.g. ReplayGain) are stored in dedicated columns, not in the tags + // JSON, so "missing" maps to a NULL check on the column rather than a json_tree lookup. + if info.Nullable && !info.IsTag && !info.IsRole { + col, ok := fieldExpr(info.Name()) + if !ok || col == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if negate { + return squirrel.Eq{col: nil}, nil + } + return squirrel.NotEq{col: nil}, nil + } + return jsonExpr(info, nil, negate), nil } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 5c8909e1c..8e801a703 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -14,7 +14,7 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate", "replaygain_album_gain"}) criteria.AddNumericTags([]string{"rate"}) }) @@ -85,6 +85,20 @@ var _ = Describe("Smart playlist criteria SQL", func() { "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), + // isMissing/isPresent — nullable column fields (ReplayGain) + Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true}, + "media_file.rg_album_gain IS NULL"), + Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false}, + "media_file.rg_album_gain IS NOT NULL"), + Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true}, + "media_file.rg_track_peak IS NOT NULL"), + Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false}, + "media_file.rg_track_peak IS NULL"), + // isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584) + Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true}, + "media_file.rg_album_gain IS NULL"), + Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true}, + "media_file.rg_album_gain IS NOT NULL"), ) Describe("playlist permissions", func() { @@ -143,12 +157,12 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("returns an error when isMissing is used with a regular field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isPresent is used with a regular field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isMissing has a non-boolean value", func() { diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index f42292f02..1ff2139e6 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -70,11 +70,13 @@ var ( func buildTestFS() { abbeyRoad := template(_t{ - "albumartist": "The Beatles", - "artist": "The Beatles", - "album": "Abbey Road", - "year": 1969, - "genre": "Rock;Blues", + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + "replaygain_album_gain": "-6.5 dB", + "replaygain_album_peak": "0.98", }) ledZepIV := template(_t{ "albumartist": "Led Zeppelin", @@ -116,12 +118,16 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks", + "replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks", + "replaygain_track_gain": "-6.0 dB", "replaygain_track_peak": "0.92"})), + // Stairway To Heaven has track gain but no album gain, to distinguish the two fields "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", - "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, + "replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})), "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index a844dc982..4966d3b8a 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -371,4 +371,61 @@ var _ = Describe("Smart Playlists", func() { Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) }) }) + + // ReplayGain values are stored in nullable media_file columns (not in the tags JSON), so + // isMissing/isPresent translate to IS [NOT] NULL checks on those columns (issue #5584). + Describe("isMissing/isPresent on ReplayGain fields", func() { + It("isMissing finds tracks without album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with album gain", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgalbumgain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with album peak", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgalbumpeak":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isMissing distinguishes track gain from album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgtrackgain":true}}]}`) + Expect(results).To(ConsistOf("Black Dog", "So What", "Bohemian Rhapsody", + "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent finds tracks with track gain", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgtrackgain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + }) + + It("resolves the replaygain_album_gain alias to the rgalbumgain column", func() { + results := evaluateRule(`{"all":[{"isMissing":{"replaygain_album_gain":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("resolves the replaygain_track_gain alias to the rgtrackgain column", func() { + results := evaluateRule(`{"all":[{"isPresent":{"replaygain_track_gain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + }) + + It("supports numeric comparisons through the replaygain_* alias", func() { + results := evaluateRule(`{"all":[{"gt":{"replaygain_track_gain":-7.5}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("combines isMissing on ReplayGain with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) From 3b958dd6a725b10d227ada78d7840c968d240972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 10 Jun 2026 23:15:58 -0400 Subject: [PATCH 285/596] refactor(stream): remove dead type branches from getIntClaim (#5594) The jwx library always deserializes numeric claims from a parsed token as float64, so the int and int64 branches in getIntClaim could never succeed and were dead code. Keep only the float64 path, which is the one actually exercised by the token round-trip, and update the comment to document the library behavior. --- core/stream/token.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/core/stream/token.go b/core/stream/token.go index 24a154b54..21a26ca93 100644 --- a/core/stream/token.go +++ b/core/stream/token.go @@ -92,17 +92,10 @@ func paramsFromToken(token jwt.Token) (*params, error) { return &p, nil } -// getIntClaim extracts an int claim from a JWT token, handling the case where -// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). +// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a +// parsed token are always deserialized as float64, regardless of the type used +// when encoding. func getIntClaim(token jwt.Token, key string) int { - var v int - if err := token.Get(key, &v); err == nil { - return v - } - var v64 int64 - if err := token.Get(key, &v64); err == nil { - return int(v64) - } var f float64 if err := token.Get(key, &f); err == nil { return int(f) From 5ec6e6a8d48f5534be79bc6ee0064fa5a61e5c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 12 Jun 2026 15:53:37 -0400 Subject: [PATCH 286/596] fix(opensubsonic): make search3 empty-query pagination fast at large offsets (#5601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subsonic): make search3 empty-query pagination fast at large offsets Empty-query search3 (used by clients like Symfonium to sync the whole library) degraded linearly with songOffset: the offset optimization in optimizePagination keeps the original query's LEFT JOINs (annotation, bookmark, library) inside its rowid NOT IN subquery, making it as slow as plain OFFSET (~5s per page at offset 900K on a 920K-track library). Rewrite the empty-query branch of doSearch to use the same two-phase approach as the FTS search: Phase 1 paginates rowids on the bare main table, which SQLite satisfies with a covering index at any offset; Phase 2 hydrates only the page's rows with all JOINs. The Phase 2 hydration logic is extracted into hydrateRowidPage, now shared with ftsSearch.execute. Also replace the media_file_missing index with a composite covering index on (missing, library_id), so Phase 1 stays covering for non-admin users, whose queries include a library_id filter. The composite serves all missing-only lookups via its prefix. With a 920K-track / 85K-album test library, search3 empty-query responses are now flat (~0.1s) at every offset, for both admin and non-admin users (previously 3-5s at offsets above 600K). * refactor(persistence): share search Phase 1 contract and dedup junction fan-out Extract the Phase 1 query assembly that was duplicated between the FTS search and the empty-query search into executeTwoPhase: both paths now supply only their strategy-specific FROM/JOINs and ORDER BY, while the shared contract (missing filter, library access, options.Filters, and Max/Offset semantics) lives in one place. Also fix a pagination integrity bug: the artist library filter joins the library_artist junction table, so an artist present in multiple libraries produced duplicate rowids in Phase 1, corrupting offset-based pagination (short pages and repeated artists during full-library syncs). Phase 1 now applies DISTINCT whenever a junction-based LibraryFilter is set. DISTINCT is used instead of GROUP BY because bm25() cannot be evaluated in a grouped query; plain-filter tables (media_file, album) skip the dedup so their Phase 1 keeps the streaming covering-index plan. This also fixes the same duplication in the pre-existing FTS search path. * fix(persistence): pin artist search Phase 1 join order with CROSS JOIN search3 always filters artists by library (library_artist.library_id IN ...), and with the junction JOIN in the search Phase 1 rowid query SQLite chose to drive from library_artist, sorting every junction row with a temp b-tree on each page — a flat ~200ms penalty per request at 405K artists, even at offset 0 (the previous code avoided this by accident: its GROUP BY artist.id pinned an artist-driven plan). Use CROSS JOIN (SQLite's explicit join-order override) in a search-only variant of the artist library filter, keeping artist as the outer table so Phase 1 streams rowids in artist.id order from the primary key index and LIMIT/OFFSET short-circuits. The DISTINCT dedup stays and costs nothing under the streaming plan. Other artist queries keep the planner's freedom. With 405K artists, empty-query artist search is now 0.07s at offset 0 and 0.25s at offset 399K end-to-end (was 0.31s/0.34s before this fix, and up to 1.2s on master at deep offsets). Artist FTS text search is unaffected. --- ...dd_media_file_missing_library_id_index.sql | 13 ++++ persistence/artist_repository.go | 17 +++++- persistence/artist_repository_test.go | 61 +++++++++++++++++++ persistence/mediafile_repository_test.go | 43 +++++++++++++ persistence/sql_search.go | 58 ++++++++++++++++-- persistence/sql_search_fts.go | 46 ++------------ 6 files changed, 192 insertions(+), 46 deletions(-) create mode 100644 db/migrations/20260612171826_add_media_file_missing_library_id_index.sql diff --git a/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql new file mode 100644 index 000000000..8bd8ad5b8 --- /dev/null +++ b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Covering index for the rowid-only pagination query used by search3 with an empty query +-- (full library sync). It must cover both `missing` and `library_id` so SQLite never touches +-- the (wide) media_file rows while skipping over large offsets. +-- Replaces media_file_missing: the composite serves all `missing = ?` lookups via its prefix. +create index if not exists media_file_missing_library_id + on media_file(missing, library_id); +drop index if exists media_file_missing; + +-- +goose Down +create index if not exists media_file_missing + on media_file(missing); +drop index if exists media_file_missing_library_id; diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index cfdc499e0..aa3bc0776 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -540,13 +540,28 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { return totalRowsAffected, nil } +// applyLibraryFilterToSearchQuery is applyLibraryFilterToArtistQuery with the join order +// pinned via CROSS JOIN (SQLite's explicit join-order override): the search Phase 1 paginates +// rowids by artist.id, and when the planner drives from library_artist it must sort every +// junction row on every page (temp b-tree over the whole table). Keeping artist as the outer +// table streams rows in artist.id order from its primary key index, so LIMIT/OFFSET +// short-circuits. Search-only: other artist queries keep the planner's freedom. +func (r *artistRepository) applyLibraryFilterToSearchQuery(query SelectBuilder) SelectBuilder { + user := loggedUser(r.ctx) + query = query.CrossJoin("library_artist on library_artist.artist_id = artist.id") + if user.ID != invalidUserId && !user.IsAdmin { + query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) + } + return query +} + func (r *artistRepository) searchCfg() searchConfig { return searchConfig{ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist NaturalOrder: "artist.id", OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, MBIDFields: []string{"mbz_artist_id"}, - LibraryFilter: r.applyLibraryFilterToArtistQuery, + LibraryFilter: r.applyLibraryFilterToSearchQuery, } } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 076a9da3b..7003efec3 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "fmt" "os" "path/filepath" @@ -594,6 +595,66 @@ var _ = Describe("ArtistRepository", func() { }) }) + Context("Empty Query (sync pagination)", func() { + It("does not duplicate artists that belong to multiple libraries", func() { + // An artist in two libraries has two library_artist rows; pagination + // must still enumerate it exactly once, at a stable offset. + Expect(lr.AddArtist(lib2.ID, artistBeatles.ID)).To(Succeed()) + + all, err := repo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + seen := map[string]bool{} + var paged model.Artists + for offset := range len(all) { + page, err := repo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + for _, a := range page { + Expect(seen[a.ID]).To(BeFalse(), fmt.Sprintf("artist %s returned twice", a.ID)) + seen[a.ID] = true + } + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + }) + + It("paginates all artists in natural order without overlaps or gaps", func() { + all, err := repo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 1)) + + var paged model.Artists + pageSize := 2 + for offset := 0; offset < len(all); offset += pageSize { + page, err := repo.Search("", model.QueryOptions{Max: pageSize, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID)) + } + }) + + It("respects library filtering for restricted users", func() { + // Create an artist only in library 2 (not accessible to restricted user) + lib2Artist := model.Artist{ID: "empty-query-lib2-artist", Name: "Empty Query Lib2 Artist"} + Expect(repo.Put(&lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + + results, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + for _, a := range results { + Expect(a.ID).ToNot(Equal(lib2Artist.ID), "Empty query search should respect library filtering") + } + + // Clean up + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + } + }) + }) + Context("Headless Processes (No User Context)", func() { It("should see all artists from all libraries when no user is in context", func() { // Add artists to different libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 2bc9d0267..532e9c10f 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -652,6 +652,49 @@ var _ = Describe("MediaRepository", func() { _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) }) }) + + Context("empty query (natural order pagination)", func() { + It("returns all non-missing files in natural order", func() { + results, err := mr.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + for _, result := range results { + Expect(result.Missing).To(BeFalse()) + } + }) + + It(`treats quoted empty query ("") the same as empty`, func() { + all, err := mr.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + quoted, err := mr.Search(`""`, model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(quoted).To(HaveLen(len(all))) + }) + + It("paginates without overlaps or gaps", func() { + all, err := mr.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 3)) + + var paged model.MediaFiles + pageSize := 3 + for offset := 0; offset < len(all); offset += pageSize { + page, err := mr.Search("", model.QueryOptions{Max: pageSize, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID), fmt.Sprintf("row %d differs", i)) + } + }) + + It("returns empty page when offset is beyond the total", func() { + results, err := mr.Search("", model.QueryOptions{Max: 10, Offset: 100000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) }) Describe("FindByPaths", func() { diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 43965ebb7..19cbaf24f 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -1,6 +1,7 @@ package persistence import ( + "fmt" "strings" . "github.com/Masterminds/squirrel" @@ -20,8 +21,10 @@ type searchConfig struct { NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid") OrderBy []string // ORDER BY for text search results (e.g. ["name"]) MBIDFields []string // columns to match when query is a UUID - // LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1. - // Needed when library access requires a junction table (e.g. artist → library_artist). + // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1 of + // two-phase searches (FTS and empty-query). Needed when library access goes through a + // junction table (e.g. artist → library_artist), whose JOIN can fan out rowids for + // entities in multiple libraries — Phase 1 dedups whenever this is set. LibraryFilter func(sq SelectBuilder) SelectBuilder } @@ -57,8 +60,8 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order. if q == "" || q == `""` { - sq = sq.OrderBy(cfg.NaturalOrder) - return r.queryAll(sq, results, options) + rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder) + return r.executeTwoPhase(sq, results, rowidCore, cfg, options) } // MBID search: if query is a valid UUID, search by MBID fields instead @@ -82,6 +85,53 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea return strategy.execute(r, sq, results, cfg, options) } +// executeTwoPhase runs a search in two phases: +// - Phase 1: rowidCore (strategy-specific FROM/JOINs and ORDER BY) plus the shared search +// contract applied here — non-missing rows only, library access, options.Filters, and +// pagination. Keeping Phase 1 free of the full SELECT's JOINs lets SQLite paginate via a +// covering index; with those JOINs, large offsets degrade to O(offset) join probes — +// multi-second responses on 100k+ libraries. +// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid page. +func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error { + rowidQuery := rowidCore. + Where(Eq{r.tableName + ".missing": false}) + if options.Max > 0 { + rowidQuery = rowidQuery.Limit(uint64(options.Max)) + } + if options.Offset > 0 { + rowidQuery = rowidQuery.Offset(uint64(options.Offset)) + } + if cfg.LibraryFilter != nil { + // Junction-table library filters can repeat rowids for entities in multiple + // libraries, which would corrupt offset-based pagination — dedup before paginating. + // (DISTINCT, not GROUP BY: bm25() can't be evaluated in a grouped query.) + rowidQuery = cfg.LibraryFilter(rowidQuery).Distinct() + } else { + rowidQuery = r.applyLibraryFilter(rowidQuery) + } + if options.Filters != nil { + rowidQuery = rowidQuery.Where(options.Filters) + } + return r.hydrateRowidPage(sq, rowidQuery, results) +} + +// hydrateRowidPage joins sq to the ordered rowid set produced by rowidQuery, preserving its +// ordering. rowidQuery must handle pagination itself; sq's LIMIT/OFFSET are stripped. +func (r sqlRepository) hydrateRowidPage(sq SelectBuilder, rowidQuery SelectBuilder, results any) error { + rowidSQL, rowidArgs, err := rowidQuery.ToSql() + if err != nil { + return fmt.Errorf("building rowid query: %w", err) + } + sq = sq.RemoveLimit().RemoveOffset() + rankedSubquery := fmt.Sprintf( + "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked", + rowidSQL, + ) + sq = sq.Join(rankedSubquery+" ON "+r.tableName+".rowid = _ranked._rid", rowidArgs...) + sq = sq.OrderBy("_ranked._rn") + return r.queryAll(sq, results) +} + func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { if uuid.Validate(mbid) != nil || len(mbidFields) == 0 { return nil diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index b90dc937b..bbae47fe8 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -284,11 +284,9 @@ func (s *ftsSearch) ToSql() (string, []any, error) { return sql, []any{s.matchExpr}, nil } -// execute runs a two-phase FTS5 search: -// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination. -// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set. -// -// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1. +// execute runs a two-phase FTS5 search (see executeTwoPhase): Phase 1 here contributes the +// FTS MATCH join and BM25 rank ordering. Complex ORDER BY (function calls, aggregations) are +// dropped from Phase 1. func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { qualifiedOrderBys := []string{s.rankExpr} for _, ob := range cfg.OrderBy { @@ -297,45 +295,11 @@ func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg sea } } - // Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly. - // Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0. - rowidQuery := Select(s.tableName+".rowid"). + rowidCore := Select(s.tableName+".rowid"). From(s.tableName). Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr). - Where(Eq{s.tableName + ".missing": false}). OrderBy(qualifiedOrderBys...) - if options.Max > 0 { - rowidQuery = rowidQuery.Limit(uint64(options.Max)) - } - if options.Offset > 0 { - rowidQuery = rowidQuery.Offset(uint64(options.Offset)) - } - - // Library filter + musicFolderId must be applied here, before pagination. - if cfg.LibraryFilter != nil { - rowidQuery = cfg.LibraryFilter(rowidQuery) - } else { - rowidQuery = r.applyLibraryFilter(rowidQuery) - } - if options.Filters != nil { - rowidQuery = rowidQuery.Where(options.Filters) - } - - rowidSQL, rowidArgs, err := rowidQuery.ToSql() - if err != nil { - return fmt.Errorf("building FTS rowid query: %w", err) - } - - // Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination), - // join on the ranked rowid set to hydrate with full columns. - sq = sq.RemoveLimit().RemoveOffset() - rankedSubquery := fmt.Sprintf( - "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked", - rowidSQL, - ) - sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...) - sq = sq.OrderBy("_ranked._rn") - return r.queryAll(sq, dest) + return r.executeTwoPhase(sq, dest, rowidCore, cfg, options) } // qualifyOrderBy prepends tableName to a simple column name. Returns empty string for From da56df3160aa0d0c452d5ad66b7154919e335c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 13 Jun 2026 13:15:20 -0400 Subject: [PATCH 287/596] feat(smartplaylist): extend isMissing/isPresent to bpm, bitDepth and many text fields (#5603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly. --- core/stream/decider.go | 3 +- core/stream/decider_test.go | 65 ++++++++++--------- core/stream/legacy_client_test.go | 14 ++-- ...60612222838_make_bpm_bitdepth_nullable.sql | 35 ++++++++++ model/criteria/fields.go | 52 ++++++++------- model/criteria/fields_test.go | 12 ++++ model/mediafile.go | 7 +- model/mediafile_test.go | 21 +++++- model/metadata/map_mediafile.go | 10 ++- model/metadata/map_mediafile_test.go | 28 ++++++++ persistence/criteria_sql.go | 51 ++++++++++----- persistence/criteria_sql_test.go | 62 ++++++++++++++++-- persistence/mediafile_repository_test.go | 45 +++++++++++++ server/e2e/e2e_suite_test.go | 2 +- server/e2e/subsonic_playlists_test.go | 38 +++++++++++ server/subsonic/helpers.go | 5 +- server/subsonic/transcode_test.go | 2 +- 17 files changed, 352 insertions(+), 100 deletions(-) create mode 100644 db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql diff --git a/core/stream/decider.go b/core/stream/decider.go index d6e48497c..7940c6862 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" ) const fallbackBitrate = 256 // kbps @@ -142,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta sd.Codec = mf.AudioCodec() sd.Bitrate = mf.BitRate sd.SampleRate = mf.SampleRate - sd.BitDepth = mf.BitDepth + sd.BitDepth = gg.V(mf.BitDepth) sd.Channels = mf.Channels } sd.IsLossless = isLosslessFormat(sd.Codec) diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index f74953258..03c4ea437 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,7 +24,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile { Codec: mf.AudioCodec(), BitRate: mf.BitRate, SampleRate: mf.SampleRate, - BitDepth: mf.BitDepth, + BitDepth: gg.V(mf.BitDepth), Channels: mf.Channels, } data, _ := json.Marshal(probe) @@ -243,7 +244,7 @@ var _ = Describe("Decider", func() { Context("Transcoding", func() { It("selects transcoding when direct play isn't possible", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, // kbps DirectPlayProfiles: []DirectPlayProfile{ @@ -278,7 +279,7 @@ var _ = Describe("Decider", func() { }) It("uses default bitrate when client doesn't specify", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "mp3", Protocol: ProtocolHTTP}, @@ -331,7 +332,7 @@ var _ = Describe("Decider", func() { }) It("selects first valid transcoding profile in order", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, DirectPlayProfiles: []DirectPlayProfile{ @@ -351,7 +352,7 @@ var _ = Describe("Decider", func() { Context("Lossless to lossless transcoding", func() { It("allows lossless to lossless when samplerate needs downsampling", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)}) ci := &ClientInfo{ MaxAudioBitrate: 1000, DirectPlayProfiles: []DirectPlayProfile{ @@ -369,7 +370,7 @@ var _ = Describe("Decider", func() { It("sets IsLossless=true on transcoded stream when target is lossless", func() { // Transcoding to mp3 (lossy) should result in IsLossless=false. - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -526,7 +527,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play due to samplerate limitation", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, @@ -573,7 +574,7 @@ var _ = Describe("Decider", func() { }) It("applies channel limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -596,7 +597,7 @@ var _ = Describe("Decider", func() { }) It("applies samplerate limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -619,7 +620,7 @@ var _ = Describe("Decider", func() { }) It("applies bitdepth limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -642,7 +643,7 @@ var _ = Describe("Decider", func() { }) It("preserves source bit depth when no limitation applies", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -656,7 +657,7 @@ var _ = Describe("Decider", func() { }) It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -680,7 +681,7 @@ var _ = Describe("Decider", func() { Context("DSD sample rate conversion", func() { It("converts DSD sample rate to PCM-equivalent in decision", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -700,7 +701,7 @@ var _ = Describe("Decider", func() { }) It("converts DSD sample rate for FLAC target without codec limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -719,7 +720,7 @@ var _ = Describe("Decider", func() { }) It("applies codec profile limit to DSD-converted FLAC sample rate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -746,7 +747,7 @@ var _ = Describe("Decider", func() { }) It("applies audioBitdepth limitation to DSD-converted bit depth", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -775,7 +776,7 @@ var _ = Describe("Decider", func() { // Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels. // The decider must clamp to the codec's hard limit even when no // transcoding profile MaxAudioChannels is configured. - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -791,7 +792,7 @@ var _ = Describe("Decider", func() { }) It("honors a stricter profile MaxAudioChannels over the codec clamp", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -806,7 +807,7 @@ var _ = Describe("Decider", func() { }) It("applies the codec clamp when the profile limit is looser", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -821,7 +822,7 @@ var _ = Describe("Decider", func() { }) It("passes channels through unchanged for codecs with no hard limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -840,7 +841,7 @@ var _ = Describe("Decider", func() { Context("Probe-based lossless detection", func() { It("uses probe codec name for lossless detection", func() { // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv" - mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)} probe := ffmpeg.AudioProbeResult{ Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2, } @@ -884,7 +885,7 @@ var _ = Describe("Decider", func() { Context("Opus fixed sample rate", func() { It("sets Opus output to 48000Hz regardless of input", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -901,7 +902,7 @@ var _ = Describe("Decider", func() { }) It("sets Opus output to 48000Hz even for 96kHz input", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -917,7 +918,7 @@ var _ = Describe("Decider", func() { Context("Container vs format separation", func() { It("preserves mp4 container when falling back to aac format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -935,7 +936,7 @@ var _ = Describe("Decider", func() { }) It("uses container as format when container matches transcoding config", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -952,7 +953,7 @@ var _ = Describe("Decider", func() { Context("MP3 max sample rate", func() { It("caps sample rate at 48000 for MP3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -966,7 +967,7 @@ var _ = Describe("Decider", func() { }) It("preserves sample rate at 44100 for MP3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -982,7 +983,7 @@ var _ = Describe("Decider", func() { Context("AAC max sample rate", func() { It("caps sample rate at 96000 for AAC", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -1025,7 +1026,7 @@ var _ = Describe("Decider", func() { Context("Source stream details", func() { It("populates source stream correctly with kbps bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, @@ -1058,7 +1059,7 @@ var _ = Describe("Decider", func() { }) It("ignores player MaxBitRate in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ Name: "TestClient", DirectPlayProfiles: []DirectPlayProfile{ @@ -1074,7 +1075,7 @@ var _ = Describe("Decider", func() { Context("Format-aware default bitrate", func() { It("uses opus default bitrate from DB", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, @@ -1087,7 +1088,7 @@ var _ = Describe("Decider", func() { }) It("uses aac default bitrate from DB", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index ce7b38650..2ddd74ce0 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -138,7 +138,7 @@ var _ = Describe("ResolveRequest", func() { }) It("transcodes to requested format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "opus", 0, 0) @@ -147,7 +147,7 @@ var _ = Describe("ResolveRequest", func() { }) It("transcodes to requested format with bitrate limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0) @@ -169,7 +169,7 @@ var _ = Describe("ResolveRequest", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "", 128, 0) @@ -179,7 +179,7 @@ var _ = Describe("ResolveRequest", func() { }) It("passes offset through", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "opus", 128, 30) @@ -259,7 +259,7 @@ var _ = Describe("ResolveRequest", func() { Context("Player MaxBitRate cap", func() { It("applies player MaxBitRate cap when client has no limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) decider := svc.(*deciderService) @@ -270,7 +270,7 @@ var _ = Describe("ResolveRequest", func() { }) It("uses client limit when it is more restrictive than player MaxBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) decider := svc.(*deciderService) @@ -332,7 +332,7 @@ var _ = Describe("ResolveRequest", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0) diff --git a/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql b/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql new file mode 100644 index 000000000..c84323158 --- /dev/null +++ b/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql @@ -0,0 +1,35 @@ +-- +goose Up +drop index if exists media_file_bpm; + +alter table media_file add column bpm_new integer; +alter table media_file add column bit_depth_new integer; + +update media_file set + bpm_new = nullif(bpm, 0), + bit_depth_new = nullif(bit_depth, 0); + +alter table media_file drop column bpm; +alter table media_file drop column bit_depth; + +alter table media_file rename column bpm_new to bpm; +alter table media_file rename column bit_depth_new to bit_depth; + +create index if not exists media_file_bpm on media_file (bpm); + +-- +goose Down +drop index if exists media_file_bpm; + +alter table media_file add column bpm_old integer default 0 not null; +alter table media_file add column bit_depth_old integer default 0 not null; + +update media_file set + bpm_old = coalesce(bpm, 0), + bit_depth_old = coalesce(bit_depth, 0); + +alter table media_file drop column bpm; +alter table media_file drop column bit_depth; + +alter table media_file rename column bpm_old to bpm; +alter table media_file rename column bit_depth_old to bit_depth; + +create index if not exists media_file_bpm on media_file (bpm); diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 36206712f..5c9ec898d 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -4,12 +4,14 @@ import "strings" // FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Alias string // If set, this field is a backward-compat alias for another canonical name - IsTag bool - IsRole bool - Numeric bool - Boolean bool - Nullable bool // If set, this column field can be NULL, so isMissing/isPresent are supported on it + Alias string // If set, this field is a backward-compat alias for another canonical name + IsTag bool + IsRole bool + Numeric bool + Boolean bool + // Nullable: isMissing/isPresent are supported on this column field. For numeric/boolean + // fields, missing means NULL; for string fields it means NULL or empty string. + Nullable bool tagAlias string // If set, a tag name from mappings.yaml that resolves to this field name string // Canonical name, populated by LookupField from the map key @@ -22,7 +24,7 @@ func (f FieldInfo) Name() string { var fieldMap = map[string]FieldInfo{ "title": {}, - "album": {}, + "album": {Nullable: true}, "hascoverart": {Boolean: true}, "tracknumber": {}, "discnumber": {}, @@ -35,26 +37,26 @@ var fieldMap = map[string]FieldInfo{ "size": {}, "compilation": {Boolean: true}, "missing": {Boolean: true}, - "explicitstatus": {}, + "explicitstatus": {Nullable: true}, "dateadded": {}, "datemodified": {}, - "discsubtitle": {}, - "comment": {}, - "lyrics": {}, - "sorttitle": {}, - "sortalbum": {}, - "sortartist": {}, - "sortalbumartist": {}, - "albumcomment": {}, - "catalognumber": {}, + "discsubtitle": {Nullable: true}, + "comment": {Nullable: true}, + "lyrics": {Nullable: true}, + "sorttitle": {Nullable: true}, + "sortalbum": {Nullable: true}, + "sortartist": {Nullable: true}, + "sortalbumartist": {Nullable: true}, + "albumcomment": {Nullable: true}, + "catalognumber": {Nullable: true}, "filepath": {}, "filetype": {}, "codec": {}, "duration": {}, "bitrate": {}, - "bitdepth": {}, + "bitdepth": {Numeric: true, Nullable: true}, "samplerate": {}, - "bpm": {}, + "bpm": {Numeric: true, Nullable: true}, "channels": {}, "loved": {Boolean: true}, "dateloved": {}, @@ -75,12 +77,12 @@ var fieldMap = map[string]FieldInfo{ "artistlastplayed": {}, "artistdateloved": {}, "artistdaterated": {}, - "mbz_album_id": {}, - "mbz_album_artist_id": {}, - "mbz_artist_id": {}, - "mbz_recording_id": {}, - "mbz_release_track_id": {}, - "mbz_release_group_id": {}, + "mbz_album_id": {Nullable: true}, + "mbz_album_artist_id": {Nullable: true}, + "mbz_artist_id": {Nullable: true}, + "mbz_recording_id": {Nullable: true}, + "mbz_release_track_id": {Nullable: true}, + "mbz_release_group_id": {Nullable: true}, "rgalbumgain": {Numeric: true, Nullable: true}, "rgalbumpeak": {Numeric: true, Nullable: true}, "rgtrackgain": {Numeric: true, Nullable: true}, diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 3dc0c7b90..2367101a8 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -75,5 +75,17 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsTag).To(gomega.BeFalse()) }) + It("marks mbz_* and lyrics string fields as nullable (empty means missing)", func() { + for _, name := range []string{"mbz_album_id", "mbz_album_artist_id", "mbz_artist_id", + "mbz_recording_id", "mbz_release_track_id", "mbz_release_group_id", "lyrics", + "album", "comment", "catalognumber", "discsubtitle", "albumcomment", + "sorttitle", "sortalbum", "sortartist", "sortalbumartist", "explicitstatus"} { + field, ok := LookupField(name) + gomega.Expect(ok).To(gomega.BeTrue(), name) + gomega.Expect(field.Nullable).To(gomega.BeTrue(), name) + gomega.Expect(field.Numeric).To(gomega.BeFalse(), name) + } + }) + }) }) diff --git a/model/mediafile.go b/model/mediafile.go index 6be8402ae..718f0443d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" ) @@ -54,7 +55,7 @@ type MediaFile struct { Duration float32 `structs:"duration" json:"duration"` BitRate int `structs:"bit_rate" json:"bitRate"` SampleRate int `structs:"sample_rate" json:"sampleRate"` - BitDepth int `structs:"bit_depth" json:"bitDepth"` + BitDepth *int `structs:"bit_depth" json:"bitDepth,omitempty"` Channels int `structs:"channels" json:"channels"` Codec string `structs:"codec" json:"codec"` ProbeData string `structs:"probe_data" json:"-" hash:"ignore"` @@ -71,7 +72,7 @@ type MediaFile struct { Compilation bool `structs:"compilation" json:"compilation"` Comment string `structs:"comment" json:"comment,omitempty"` Lyrics string `structs:"lyrics" json:"lyrics"` - BPM int `structs:"bpm" json:"bpm,omitempty"` + BPM *int `structs:"bpm" json:"bpm,omitempty"` ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` @@ -225,7 +226,7 @@ func (mf MediaFile) inferCodecFromSuffix() string { return "dsd" case "m4a": // AAC if BitDepth==0, ALAC if BitDepth>0 - if mf.BitDepth > 0 { + if gg.V(mf.BitDepth) > 0 { return "alac" } return "aac" diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 3547ec4ef..65c5a0652 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -564,7 +564,7 @@ var _ = Describe("MediaFile", func() { DescribeTable("infers codec from suffix when Codec field is empty", func(suffix string, bitDepth int, expected string) { - mf := MediaFile{Suffix: suffix, BitDepth: bitDepth} + mf := MediaFile{Suffix: suffix, BitDepth: new(bitDepth)} Expect(mf.AudioCodec()).To(Equal(expected)) }, Entry("mp3", "mp3", 0, "mp3"), @@ -597,13 +597,30 @@ var _ = Describe("MediaFile", func() { ) It("prefers stored codec over suffix inference", func() { - mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0} + mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} Expect(mf.AudioCodec()).To(Equal("alac")) }) }) }) +var _ = Describe("MediaFile.Hash", func() { + // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, + // or every file would be spuriously re-imported on the next scan. + // Golden hashes were captured at 46221d516 when those fields were plain ints. + It("keeps hashes identical to the pre-pointer-conversion values", func() { + // Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning + // them guarantees the pointer conversion cannot trigger a full-library re-import. + Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622")) + Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689")) + }) + It("changes the hash when a pointer field has a value", func() { + base := MediaFile{Title: "Song"} + Expect(base.Equals(MediaFile{Title: "Song", BPM: new(120)})).To(BeFalse()) + Expect(base.Equals(MediaFile{Title: "Song", BitDepth: new(24)})).To(BeFalse()) + }) +}) + func t(v string) time.Time { var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"} for _, f := range timeFormats { diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 824cad7c2..966a545be 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -35,7 +35,11 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.DiscSubtitle = md.String(model.TagDiscSubtitle) mf.CatalogNum = md.String(model.TagCatalogNumber) mf.Comment = md.String(model.TagComment) - mf.BPM = int(math.Round(md.Float(model.TagBPM))) + if f := md.NullableFloat(model.TagBPM); f != nil { + if v := int(math.Round(*f)); v != 0 { + mf.BPM = new(v) + } + } mf.Lyrics = md.mapLyrics() mf.ExplicitStatus = md.mapExplicitStatusTag() @@ -63,7 +67,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.Duration = md.Length() mf.BitRate = md.AudioProperties().BitRate mf.SampleRate = md.AudioProperties().SampleRate - mf.BitDepth = md.AudioProperties().BitDepth + if bd := md.AudioProperties().BitDepth; bd > 0 { + mf.BitDepth = new(bd) + } mf.Channels = md.AudioProperties().Channels mf.Codec = md.AudioProperties().Codec mf.Path = md.FilePath() diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index 16142f526..75a7ed358 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -117,4 +117,32 @@ var _ = Describe("ToMediaFile", func() { Expect(actual).To(Equal(expected)) }) }) + + Describe("BPM", func() { + It("maps the BPM tag rounded to the nearest integer", func() { + mf = toMediaFile(model.RawTags{"BPM": {"120.6"}}) + Expect(mf.BPM).To(Equal(new(121))) + }) + It("leaves BPM nil when the tag is absent", func() { + mf = toMediaFile(model.RawTags{}) + Expect(mf.BPM).To(BeNil()) + }) + It("leaves BPM nil when the tag is zero or unparseable", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil()) + Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil()) + }) + }) + + Describe("BitDepth", func() { + It("maps the bit depth when present", func() { + props.AudioProperties = metadata.AudioProperties{BitDepth: 24} + mf = toMediaFile(model.RawTags{}) + Expect(mf.BitDepth).To(Equal(new(24))) + }) + It("leaves BitDepth nil when zero (lossy codecs have no bit depth)", func() { + props.AudioProperties = metadata.AudioProperties{BitDepth: 0} + mf = toMediaFile(model.RawTags{}) + Expect(mf.BitDepth).To(BeNil()) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index ee0baca18..fa769ef40 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -28,9 +28,10 @@ func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { } type smartPlaylistField struct { - expr string - order string - joinType smartPlaylistJoinType + expr string + order string + joinType smartPlaylistJoinType + emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) } type smartPlaylistCriteria struct { @@ -72,7 +73,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "datemodified": {expr: "media_file.updated_at"}, "discsubtitle": {expr: "media_file.disc_subtitle"}, "comment": {expr: "media_file.comment"}, - "lyrics": {expr: "media_file.lyrics"}, + "lyrics": {expr: "media_file.lyrics", emptyValues: []string{"[]"}}, "sorttitle": {expr: "media_file.sort_title"}, "sortalbum": {expr: "media_file.sort_album_name"}, "sortartist": {expr: "media_file.sort_artist_name"}, @@ -218,30 +219,44 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er } return nil, fmt.Errorf("invalid field in criteria: %s", field) } - if !info.IsTag && !info.IsRole && !info.Nullable { - return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field) - } - b, ok := value.(bool) if !ok { return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) } negate := checkAbsence == b - // Nullable column fields (e.g. ReplayGain) are stored in dedicated columns, not in the tags - // JSON, so "missing" maps to a NULL check on the column rather than a json_tree lookup. - if info.Nullable && !info.IsTag && !info.IsRole { - col, ok := fieldExpr(info.Name()) - if !ok || col == "" { + switch { + case info.IsTag || info.IsRole: + return jsonExpr(info, nil, negate), nil + case info.Nullable: + // Nullable column fields are stored in dedicated columns, not in the tags JSON, so + // "missing" maps to a column check rather than a json_tree lookup. Numeric/boolean + // columns (e.g. ReplayGain, BPM) encode absence as NULL only; string columns (e.g. + // mbz_* IDs, lyrics) additionally treat empty string — and any field-specific empty + // encodings (e.g. '[]' for lyrics) — as missing. The unified flow below handles both: + // numeric/boolean fields simply have no empties, so the loops are no-ops. + f, ok := smartPlaylistFields[info.Name()] + if !ok || f.expr == "" { return nil, fmt.Errorf("invalid field in criteria: %s", field) } - if negate { - return squirrel.Eq{col: nil}, nil + col := f.expr + var empties []string + if !info.Numeric && !info.Boolean { + empties = append([]string{""}, f.emptyValues...) } - return squirrel.NotEq{col: nil}, nil + missing := squirrel.Or{squirrel.Eq{col: nil}} + present := squirrel.And{squirrel.NotEq{col: nil}} + for _, e := range empties { + missing = append(missing, squirrel.Eq{col: e}) + present = append(present, squirrel.NotEq{col: e}) + } + if negate { + return missing, nil + } + return present, nil + default: + return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field) } - - return jsonExpr(info, nil, negate), nil } func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 8e801a703..0257fa0cf 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -87,18 +87,68 @@ var _ = Describe("Smart playlist criteria SQL", func() { "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), // isMissing/isPresent — nullable column fields (ReplayGain) Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true}, - "media_file.rg_album_gain IS NULL"), + "(media_file.rg_album_gain IS NULL)"), Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false}, - "media_file.rg_album_gain IS NOT NULL"), + "(media_file.rg_album_gain IS NOT NULL)"), Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true}, - "media_file.rg_track_peak IS NOT NULL"), + "(media_file.rg_track_peak IS NOT NULL)"), Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false}, - "media_file.rg_track_peak IS NULL"), + "(media_file.rg_track_peak IS NULL)"), // isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584) Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true}, - "media_file.rg_album_gain IS NULL"), + "(media_file.rg_album_gain IS NULL)"), Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true}, - "media_file.rg_album_gain IS NOT NULL"), + "(media_file.rg_album_gain IS NOT NULL)"), + // isMissing/isPresent — string column fields (empty string means missing) + Entry("isMissing mbz_recording_id [true]", criteria.IsMissing{"mbz_recording_id": true}, + "(media_file.mbz_recording_id IS NULL OR media_file.mbz_recording_id = ?)", ""), + Entry("isMissing mbz_recording_id [false]", criteria.IsMissing{"mbz_recording_id": false}, + "(media_file.mbz_recording_id IS NOT NULL AND media_file.mbz_recording_id <> ?)", ""), + Entry("isPresent mbz_album_id [true]", criteria.IsPresent{"mbz_album_id": true}, + "(media_file.mbz_album_id IS NOT NULL AND media_file.mbz_album_id <> ?)", ""), + Entry("isPresent mbz_album_id [false]", criteria.IsPresent{"mbz_album_id": false}, + "(media_file.mbz_album_id IS NULL OR media_file.mbz_album_id = ?)", ""), + // lyrics: absence is encoded as '' or '[]' (empty serialized LyricList) + Entry("isMissing lyrics [true]", criteria.IsMissing{"lyrics": true}, + "(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"), + Entry("isPresent lyrics [true]", criteria.IsPresent{"lyrics": true}, + "(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"), + Entry("isMissing lyrics [false]", criteria.IsMissing{"lyrics": false}, + "(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"), + Entry("isPresent lyrics [false]", criteria.IsPresent{"lyrics": false}, + "(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"), + // isMissing/isPresent — nullable numeric columns (BPM, BitDepth) + Entry("isMissing bpm [true]", criteria.IsMissing{"bpm": true}, + "(media_file.bpm IS NULL)"), + Entry("isPresent bpm [true]", criteria.IsPresent{"bpm": true}, + "(media_file.bpm IS NOT NULL)"), + Entry("isMissing bitdepth [true]", criteria.IsMissing{"bitdepth": true}, + "(media_file.bit_depth IS NULL)"), + Entry("isPresent bitdepth [false]", criteria.IsPresent{"bitdepth": false}, + "(media_file.bit_depth IS NULL)"), + // isMissing/isPresent — more string column fields (empty string means missing) + Entry("isMissing album [true]", criteria.IsMissing{"album": true}, + "(media_file.album IS NULL OR media_file.album = ?)", ""), + Entry("isMissing comment [true]", criteria.IsMissing{"comment": true}, + "(media_file.comment IS NULL OR media_file.comment = ?)", ""), + Entry("isMissing catalognumber [true]", criteria.IsMissing{"catalognumber": true}, + "(media_file.catalog_num IS NULL OR media_file.catalog_num = ?)", ""), + Entry("isMissing discsubtitle [true]", criteria.IsMissing{"discsubtitle": true}, + "(media_file.disc_subtitle IS NULL OR media_file.disc_subtitle = ?)", ""), + Entry("isMissing albumcomment [true]", criteria.IsMissing{"albumcomment": true}, + "(media_file.mbz_album_comment IS NULL OR media_file.mbz_album_comment = ?)", ""), + Entry("isMissing sorttitle [true]", criteria.IsMissing{"sorttitle": true}, + "(media_file.sort_title IS NULL OR media_file.sort_title = ?)", ""), + Entry("isMissing sortalbum [true]", criteria.IsMissing{"sortalbum": true}, + "(media_file.sort_album_name IS NULL OR media_file.sort_album_name = ?)", ""), + Entry("isMissing sortartist [true]", criteria.IsMissing{"sortartist": true}, + "(media_file.sort_artist_name IS NULL OR media_file.sort_artist_name = ?)", ""), + Entry("isMissing sortalbumartist [true]", criteria.IsMissing{"sortalbumartist": true}, + "(media_file.sort_album_artist_name IS NULL OR media_file.sort_album_artist_name = ?)", ""), + Entry("isMissing explicitstatus [true]", criteria.IsMissing{"explicitstatus": true}, + "(media_file.explicit_status IS NULL OR media_file.explicit_status = ?)", ""), + Entry("isPresent comment [true]", criteria.IsPresent{"comment": true}, + "(media_file.comment IS NOT NULL AND media_file.comment <> ?)", ""), ) Describe("playlist permissions", func() { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 532e9c10f..4c2363e43 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -824,4 +824,49 @@ var _ = Describe("MediaRepository", func() { Expect(mediafiles[0].ID).To(Equal("mf1")) }) }) + + Describe("BPM and BitDepth nullable round-trip", func() { + It("stores nil BPM and BitDepth as NULL and retrieves them as nil", func() { + newID := id.NewRandom() + mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-nil.mp3"} + Expect(mr.Put(&mf)).To(Succeed()) + + retrieved, err := mr.Get(newID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.BPM).To(BeNil()) + Expect(retrieved.BitDepth).To(BeNil()) + + // Also verify via raw SQL that the columns are truly NULL (not 0) + db := GetDBXBuilder() + var row struct { + BPM *int `db:"bpm"` + BitDepth *int `db:"bit_depth"` + } + err = db.NewQuery("SELECT bpm, bit_depth FROM media_file WHERE id={:id}"). + Bind(dbx.Params{"id": newID}). + One(&row) + Expect(err).ToNot(HaveOccurred()) + Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database") + Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database") + + _ = mr.Delete(newID) + }) + + It("stores non-nil BPM and BitDepth and retrieves correct values", func() { + newID := id.NewRandom() + bpm := 120 + bitDepth := 24 + mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth} + Expect(mr.Put(&mf)).To(Succeed()) + + retrieved, err := mr.Get(newID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.BPM).ToNot(BeNil()) + Expect(*retrieved.BPM).To(Equal(120)) + Expect(retrieved.BitDepth).ToNot(BeNil()) + Expect(*retrieved.BitDepth).To(Equal(24)) + + _ = mr.Delete(newID) + }) + }) }) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 7ce8de2e6..12a7c95e0 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -133,7 +133,7 @@ func buildTestFS() storagetest.FakeFS { // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec})), + _t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec, "bpm": 120})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", _t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})), // Rock / The Beatles / Help! (no MBIDs) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 466e68cf0..467535df7 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -646,5 +646,43 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { stringResp := doReq("getPlaylist", "id", stringPls.ID) Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) }) + + DescribeTable("isMissing/isPresent partition all songs for nullable column fields", + func(fieldName string) { + allPls := &model.Playlist{ + Name: "All Songs " + fieldName, + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, + } + Expect(ds.Playlist(ctx).Put(allPls)).To(Succeed()) + missingPls := &model.Playlist{ + Name: "Missing " + fieldName, + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}}, + } + Expect(ds.Playlist(ctx).Put(missingPls)).To(Succeed()) + presentPls := &model.Playlist{ + Name: "Present " + fieldName, + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}}, + } + Expect(ds.Playlist(ctx).Put(presentPls)).To(Succeed()) + + allResp := doReq("getPlaylist", "id", allPls.ID) + missingResp := doReq("getPlaylist", "id", missingPls.ID) + presentResp := doReq("getPlaylist", "id", presentPls.ID) + + Expect(allResp.Status).To(Equal(responses.StatusOK)) + Expect(allResp.Playlist.SongCount).To(BeNumerically(">", int32(0))) + Expect(missingResp.Playlist.SongCount + presentResp.Playlist.SongCount). + To(Equal(allResp.Playlist.SongCount)) + }, + Entry("bpm", "bpm"), + Entry("bitdepth", "bitdepth"), + Entry("lyrics", "lyrics"), + Entry("mbz_recording_id", "mbz_recording_id"), + Entry("album", "album"), + Entry("comment", "comment"), + ) }) }) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e4c39e373..e6c6f9114 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -250,7 +251,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.Comment = mf.Comment child.SortName = sortName(mf.SortTitle, mf.OrderTitle) - child.BPM = int32(mf.BPM) + child.BPM = int32(gg.V(mf.BPM)) child.MediaType = responses.MediaTypeSong child.MusicBrainzId = mf.MbzRecordingID child.Isrc = mf.Tags.Values(model.TagISRC) @@ -262,7 +263,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.ChannelCount = int32(mf.Channels) child.SamplingRate = int32(mf.SampleRate) - child.BitDepth = int32(mf.BitDepth) + child.BitDepth = int32(gg.V(mf.BitDepth)) child.Genres = toItemGenres(mf.Genres) child.Moods = mf.Tags.Values(model.TagMood) child.Groupings = mf.Tags.Values(model.TagGrouping) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index adc7b7600..29a883ac1 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -205,7 +205,7 @@ var _ = Describe("Transcode endpoints", func() { It("includes transcode stream when transcoding", func() { mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}, }) mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-2", From af78bdeb3a8b42089d7a35080cffe6ece125c894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 13 Jun 2026 13:29:29 -0400 Subject: [PATCH 288/596] fix(artwork): never serve artist folder images as album art (#5596) * test(artwork): add failing e2e tests for artist image leaking as album art Reproduces a v0.62.0 regression (#5451/#5457): the album cover-art parent-folder fallback can include the artist folder, serving the artist thumbnail (e.g. Artist/folder.jpg) as album art for any album without image files in its own folder(s). Covers three scenarios: a plain Artist/Album layout with no album images, a single-disc album spread across sibling folders under the artist folder, and a spread album whose own front.jpg is shadowed by the artist's cover.jpg via CoverArtPriority order. Also adds an albumByName test helper for multi-album layouts. The tests are expected to fail until the parent-folder inclusion is gated by a structural check (skip the common parent when audio from other albums lives under it). * fix(artwork): never serve artist folder images as album art The album cover-art parent-folder fallback (introduced in #5451/#5457) could include the artist folder as a source of album images, serving the artist thumbnail (e.g. Artist/folder.jpg) as cover art for any album without image files in its own folder(s). This affected both plain Artist/Album layouts and single-disc albums spread across sibling folders under the artist folder. Gate the common-parent inclusion with a structural check: the parent only qualifies as an album root when no audio belonging to other albums lives in it or anywhere beneath it. An artist folder contains other albums' tracks, while an album root above disc subfolders contains only this album's, so the check works for any disc folder naming scheme and never affects the multi-disc fixes from #5376/#5456. A single-album artist with no images anywhere remains structurally indistinguishable from an album root and is a known residual case. * refactor(artwork): move album-root audio check into folder repository Replace the raw subtree SQL (LIKE/ESCAPE expression and wildcard escaping) that lived in core/artwork with an explicit FolderRepository.HasAudioOutsideFolders method, implemented in the persistence layer next to the existing folder-subtree query pattern. This also removes the test mock's brittle dispatch that sniffed the generated SQL to recognize the query; the fake now overrides the new method directly. Extract the whole parent-folder resolution from loadAlbumFoldersPaths into an albumRootParent helper, flattening four levels of nesting back into a linear flow. Behavior is unchanged; the unit test for a parent containing audio moved to the persistence suite, with added coverage for subtree boundaries, missing folders, and LIKE-wildcard escaping in folder paths. * refactor(persistence): use exists helper in HasAudioOutsideFolders Replace the hand-rolled count(*) query with the repository's canonical exists helper, as suggested in PR review. --- core/artwork/e2e/album_test.go | 92 +++++++++++++++++++++++++++ core/artwork/e2e/suite_test.go | 14 ++++ core/artwork/reader_album.go | 70 ++++++++++++++------ core/artwork/reader_album_test.go | 55 ++++++++++++++++ core/artwork/reader_artist_test.go | 8 +++ model/folder.go | 4 ++ persistence/folder_repository.go | 28 ++++++++ persistence/folder_repository_test.go | 61 ++++++++++++++++++ 8 files changed, 311 insertions(+), 21 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index e765e1b1b..5e61684cc 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -357,6 +357,98 @@ var _ = Describe("Album artwork resolution", func() { }) }) + // Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder + // fallback can pick up images from the ARTIST folder, serving the artist + // thumbnail as album art for any album without its own image files. + When("an album has no images and the artist folder has folder.jpg", func() { + // Artist/ + // ├── folder.jpg ← artist thumbnail, must NOT become album art + // ├── Album A/ + // │ └── 01 - Track.mp3 (no images) + // └── Album B/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("does not use the artist image as album art", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), + "Artist/Album B/cover.jpg": imageFile("album-b"), + }) + scan() + + alA := albumByName("Album A") + _, err := readArtworkOrErr(alA.CoverArtID()) + Expect(err).To(HaveOccurred(), + "Album A has no images of its own, so it must fall through to the placeholder "+ + "instead of inheriting the artist folder's folder.jpg") + + alB := albumByName("Album B") + Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b"))) + }) + }) + + When("a single-disc album is spread across sibling folders under the artist folder", func() { + // Artist/ + // ├── folder.jpg ← artist thumbnail, must NOT become album art + // ├── Album A/ + // │ └── 01 - Track.mp3 (album: "Album A") + // ├── Album A bonus/ + // │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder) + // └── Album B/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("does not use the artist image as album art for the spread album", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), + "Artist/Album B/cover.jpg": imageFile("album-b"), + }) + scan() + + alA := albumByName("Album A") + Expect(alA.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two sibling folders as one spread album") + _, err := readArtworkOrErr(alA.CoverArtID()) + Expect(err).To(HaveOccurred(), + "the spread album has no images of its own, so it must fall through to the "+ + "placeholder instead of inheriting the artist folder's folder.jpg") + }) + }) + + When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() { + // Artist/ + // ├── cover.jpg ← artist image; matches cover.* (first pattern), + // │ must NOT shadow the album's own front.jpg + // ├── Album A/ + // │ ├── 01 - Track.mp3 (album: "Album A") + // │ └── front.jpg ← should win + // ├── Album A bonus/ + // │ └── 02 - Track.mp3 (album: "Album A") + // └── Album B/ + // └── 01 - Track.mp3 + It("prefers the album's own art over the artist image", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/cover.jpg": imageFile("artist-image"), + "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album A/front.jpg": imageFile("album-a-front"), + "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), + }) + scan() + + alA := albumByName("Album A") + Expect(alA.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two sibling folders as one spread album") + Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front"))) + }) + }) + When("embedded is first in CoverArtPriority but the track has no embedded art", func() { // Artist/ // └── Album/ diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 733e2e98c..06cc05b6f 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -2,6 +2,7 @@ package artworke2e_test import ( "context" + "fmt" "path/filepath" "testing" @@ -104,3 +105,16 @@ func firstAlbum() model.Album { Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) return albums[0] } + +func albumByName(name string) model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + for _, al := range albums { + if al.Name == name { + return al + } + } + Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) + return model.Album{} +} diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 73ba9b5ee..8ad07773b 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -113,28 +113,12 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return nil, nil, nil, err } - folderIDSet := make(map[string]bool, len(folderIDs)) - for _, id := range folderIDs { - folderIDSet[id] = true + parent, err := albumRootParent(ctx, ds, folders, folderIDs) + if err != nil { + return nil, nil, nil, err } - - // Check if all folders share a common parent that is not already included. - // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" - // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). - // For single-folder albums, the parent is only included when the folder has no - // images of its own (indicating a disc subfolder needing parent artwork). - if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { - if len(folders) >= 2 || !anyFolderHasImages(folders) { - parentFolder, err := ds.Folder(ctx).Get(commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) - } else if err != nil { - return nil, nil, nil, err - } - if parentFolder != nil && parentFolder.ParentID != "" { - folders = append(folders, *parentFolder) - } - } + if parent != nil { + folders = append(folders, *parent) } var paths []string @@ -159,6 +143,50 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +// albumRootParent returns the common parent of the album's folders when it +// qualifies as the album's root folder (e.g. "Artist/Album" above disc +// subfolders), or nil when there is no such parent. This finds cover art in +// the album root folder when tracks live in disc subfolders, like +// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and +// "Artist/Album/CD2/". The parent must look like an album root, not an +// artist-level folder — it qualifies only when it holds no audio belonging to +// other albums — so artist images are never served as album art. +func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) { + folderIDSet := make(map[string]bool, len(folderIDs)) + for _, id := range folderIDs { + folderIDSet[id] = true + } + commonParentID := commonParentFolder(folders, folderIDSet) + if commonParentID == "" { + return nil, nil + } + // Single-folder albums only use the parent when the folder has no images + // of its own (indicating a disc subfolder needing parent artwork). + if len(folders) < 2 && anyFolderHasImages(folders) { + return nil, nil + } + parent, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + return nil, nil + } + if err != nil { + return nil, err + } + if parent.ParentID == "" { + // The library root can never be an album root + return nil, nil + } + hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs) + if err != nil { + return nil, err + } + if hasOtherAudio { + return nil, nil + } + return parent, nil +} + func anyFolderHasImages(folders []model.Folder) bool { for _, f := range folders { if len(f.ImageFiles) > 0 { diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index 1cf039bee..fe4a1a545 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -339,6 +339,61 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("does not include parent images when other albums' audio lives under the parent", func() { + // Simulates: Artist/folder.jpg with Artist/Album (no images) and + // another album's tracks elsewhere under the artist folder + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ParentID: "libraryRoot", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"folder.jpg"}, + } + repo.hasOtherAudio = true + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(BeEmpty()) + }) + + It("propagates errors from the album-root check", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + repo.otherAudioErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + }) + It("propagates non-ErrNotFound errors from parent folder lookup", func() { repo.result = []model.Folder{ { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 50ca3a2ce..6d6d58fc5 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -702,12 +702,20 @@ type fakeFolderRepo struct { getErr error getCallCount int err error + // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root + // check). False means the parent qualifies as an album root. + hasOtherAudio bool + otherAudioErr error } func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, f.err } +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return f.hasOtherAudio, f.otherAudioErr +} + func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { f.getCallCount++ if f.getErr != nil { diff --git a/model/folder.go b/model/folder.go index 7a769735e..39cb6db84 100644 --- a/model/folder.go +++ b/model/folder.go @@ -86,6 +86,10 @@ type FolderRepository interface { GetAll(...QueryOptions) ([]Folder, error) CountAll(...QueryOptions) (int64, error) GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) + // HasAudioOutsideFolders reports whether any folder in parent's subtree + // (including parent itself) contains audio files and is not one of the + // given folder IDs. + HasAudioOutsideFolders(parent Folder, excludeFolderIDs []string) (bool, error) Put(*Folder) error MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index f7bb6a4fe..624ef21b8 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -7,6 +7,7 @@ import ( "iter" "maps" "os" + "path" "path/filepath" "slices" "strings" @@ -188,6 +189,33 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol return m, nil } +// HasAudioOutsideFolders reports whether any folder in parent's subtree +// (including parent itself) contains audio files and is not one of the given +// folder IDs. LIKE wildcards in the parent path are escaped, so it is always +// matched as a literal prefix. +func (r folderRepository) HasAudioOutsideFolders(parent model.Folder, excludeFolderIDs []string) (bool, error) { + if parent.NumAudioFiles > 0 { + return true, nil + } + parentPath := strings.TrimPrefix(path.Join(parent.Path, parent.Name), "/") + return r.exists(And{ + Eq{"library_id": parent.LibraryID, "missing": false}, + Gt{"num_audio_files": 0}, + NotEq{"id": excludeFolderIDs}, + Or{ + // Direct children have path = parentPath; deeper descendants match the prefix + Eq{"path": parentPath}, + Expr(`path LIKE ? ESCAPE '\'`, escapeLikePrefix(parentPath)+"/%"), + }, + }) +} + +// escapeLikePrefix escapes SQL LIKE wildcards so a string can be used as a +// literal prefix in a LIKE pattern (with ESCAPE '\'). +func escapeLikePrefix(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + func (r folderRepository) Put(f *model.Folder) error { dbf := dbFolder{Folder: f} _, err := r.put(dbf.ID, &dbf) diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index ebc08fd04..413a6b38f 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -217,6 +217,67 @@ var _ = Describe("FolderRepository", func() { }) }) + Describe("HasAudioOutsideFolders", func() { + var albumRoot, disc1, disc2 *model.Folder + + // TestHasAudio/Album/ + // ├── CD1/ (audio, belongs to the album) + // └── CD2/ (audio, belongs to the album) + BeforeEach(func() { + albumRoot = model.NewFolder(testLib, "TestHasAudio/Album") + disc1 = model.NewFolder(testLib, "TestHasAudio/Album/CD1") + disc1.NumAudioFiles = 5 + disc2 = model.NewFolder(testLib, "TestHasAudio/Album/CD2") + disc2.NumAudioFiles = 5 + for _, f := range []*model.Folder{albumRoot, disc1, disc2} { + Expect(repo.Put(f)).To(Succeed()) + } + }) + + It("returns false when all audio under the parent belongs to the given folders", func() { + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + }) + + It("returns true when another folder under the parent has audio", func() { + bonus := model.NewFolder(testLib, "TestHasAudio/Album/Bonus") + bonus.NumAudioFiles = 1 + Expect(repo.Put(bonus)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) + }) + + It("returns true when the parent itself contains audio files", func() { + albumRoot.NumAudioFiles = 2 + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) + }) + + It("ignores audio outside the parent's subtree", func() { + other := model.NewFolder(testLib, "TestHasAudio/Other Album") + other.NumAudioFiles = 10 + Expect(repo.Put(other)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + }) + + It("ignores missing folders", func() { + gone := model.NewFolder(testLib, "TestHasAudio/Album/Gone") + gone.NumAudioFiles = 3 + gone.Missing = true + Expect(repo.Put(gone)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + }) + + It("does not treat LIKE wildcards in the parent path as patterns", func() { + // "TestHas_udio" would LIKE-match "TestHasAudio" if "_" were not escaped + wildcardRoot := model.NewFolder(testLib, "TestHas_udio/Album") + Expect(repo.Put(wildcardRoot)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*wildcardRoot, []string{"none"})).To(BeFalse()) + }) + }) + Describe("wrapFolderCursor", func() { It("does not panic when the cursor yields a dbFolder with nil Folder", func() { // Simulate what queryWithStableResults does on the rows.Err() path: From c466f6b612a89b7b80a27d24a48a2f8cd1e2a2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 13 Jun 2026 13:58:26 -0400 Subject: [PATCH 289/596] fix(artwork): prevent WebP segfault on 32-bit and disable WebP-by-default in Docker (#5606) * fix(artwork): avoid WebP segfault on 32-bit ARM On 32-bit ARM, the gen2brain/webp native libwebp path uses ebitengine/purego reverse callbacks, which purego does not support on that architecture. Selecting it crashes the process with a SIGSEGV when encoding or decoding WebP cover art, taking down the whole server on the first web UI artwork request (issue #5597). Force the safe WASM path on armv7/v6 in two layers: build the Docker arm binary with the gen2brain/webp "nodynamic" tag so purego is never linked, and add a runtime GOARCH guard in the init hook so source builds on 32-bit ARM are also protected. arm64 keeps the native libwebp path. * fix(artwork): also disable native WebP on 32-bit x86 purego's callback implementation is built with the constraint !386 && !arm, so 32-bit x86 (386) crashes with the same SIGSEGV as 32-bit ARM when the native libwebp path is used. Navidrome ships linux/386 and windows/386 builds, so guard 386 alongside arm: extend the runtime GOARCH check and the Docker nodynamic build tag to cover both. 64-bit arches keep the native libwebp path. * fix(artwork): rely on nodynamic build tag, drop ineffective runtime guard The previous runtime GOARCH guard did not actually prevent the crash: gen2brain/webp selects the native (purego) vs WASM backend in its own package init() and registers the purego write callback at import time, before any Navidrome hook runs. webp.Dynamic() is only a status getter, and Decode/Encode branch on the library's unexported flag, so the guard merely skipped a log line while the native path stayed active. The effective fix is the nodynamic build tag (applied for 32-bit ARM and x86 in the Dockerfile), which compiles gen2brain/webp WASM-only so purego is never linked. Drop the misleading guard and document that source builds on 32-bit architectures must be built with -tags nodynamic. * fix(artwork): don't enable WebP encoding by default in Docker The Docker image set ND_ENABLEWEBPENCODING=true, which (a) forced cover-art thumbnails through WebP for every install and (b) overrode any EnableWebPEncoding=false set in the user's navidrome.toml, since env vars take precedence over the config file in Viper. On 32-bit platforms the only available WebP backend is the WASM encoder, which is slow on the underpowered hardware those builds typically run on, so enabling it by default is the wrong tradeoff there. Remove the env default and leave EnableWebPEncoding off unless the user opts in. Combined with the nodynamic build tag, 32-bit images neither crash nor pay the WASM cost out of the box. A smarter automatic policy (use WebP only when native libwebp is available) can be revisited separately. --- Dockerfile | 11 +++++++++-- core/artwork/reader_resized.go | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ad1e2a41c..e8a00f470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,8 +69,16 @@ RUN --mount=type=bind,source=. \ set -e xx-go --wrap export CGO_ENABLED=1 + # Native libwebp (gen2brain/webp) uses ebitengine/purego reverse callbacks, + # which purego does not support on 32-bit ARM or x86 and crash with a SIGSEGV + # (issue #5597). Build those arches with the "nodynamic" tag so gen2brain/webp + # is WASM-only and never links the purego path. 64-bit arches keep native libwebp. + BUILD_TAGS=netgo,sqlite_fts5 + if [ "$(xx-info arch)" = "arm" ] || [ "$(xx-info arch)" = "386" ]; then + BUILD_TAGS=${BUILD_TAGS},nodynamic + fi # -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve. - go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \ + go build -tags=${BUILD_TAGS} -ldflags="-w -s \ -linkmode=external -extldflags '-latomic' \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ @@ -159,7 +167,6 @@ ENV ND_MUSICFOLDER=/music ENV ND_DATAFOLDER=/data ENV ND_CONFIGFILE=/data/navidrome.toml ENV ND_PORT=4533 -ENV ND_ENABLEWEBPENCODING=true RUN touch /.nddockerenv EXPOSE ${ND_PORT} diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 85a19a4c3..08f42f130 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -21,6 +21,12 @@ import ( func init() { conf.AddHook(func() { + // gen2brain/webp selects native (purego/libwebp) vs WASM in its own + // package init() and exposes the result only via webp.Dynamic(); there is + // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path + // crashes (issue #5597), so those builds must be compiled with the + // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an + // error here and forces the safe WASM path. if err := webp.Dynamic(); err != nil { log.Debug("Using WASM WebP encoder/decoder", "reason", err) } else { From f3887df334b5c0afb1e53c9106e3134890d0b57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 14 Jun 2026 10:47:11 -0400 Subject: [PATCH 290/596] perf(smartplaylists): merge negated artist/tag rules into one NOT EXISTS * fix(smartplaylists): merge negated artist/tag rules in AND groups Smart playlists with many negated role/tag conditions ANDed together (e.g. 100+ "isNot artist" rules, issue #5511) generated one correlated NOT EXISTS subquery per rule, scanning media_file_artists for every candidate row. On large libraries this took minutes and triggered API timeouts and SQLite lock contention. By De Morgan, "NOT EXISTS(role=X) AND NOT EXISTS(role=Y)" is equivalent to "NOT EXISTS(role=X OR role=Y)", so multiple negated conditions for the same field can be collapsed into a single batched NOT EXISTS. This mirrors the existing OR-group merge that #5515 added for positive conditions. The shared grouping/batching logic is extracted into mergeSameFieldConds, parameterized by polarity, so the OR/positive and AND/negated paths reuse one algorithm instead of duplicating it. roleCondGroup/tagCondGroup gain a 'not' flag to emit the negated subquery. Benchmark (323k tracks, 120 isNot artist rules, reporter's exact shape): merged ~54ms vs unmerged ~8.7s steady-state (~160x faster). * docs: trim redundant comments on merge helpers The De Morgan explanation was repeated across three doc comments. Keep it in one place (mergeNegatedJsonConds, where negation is introduced) and reduce the shared core and group-type comments to concise one-liners. --- persistence/criteria_sql.go | 72 +++++++++++------ persistence/criteria_sql_benchmark_test.go | 55 +++++++++++++ persistence/criteria_sql_test.go | 91 ++++++++++++++++++++++ 3 files changed, 193 insertions(+), 25 deletions(-) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index fa769ef40..b74f498d0 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -140,7 +140,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } and = append(and, cond) } - return and, nil + return mergeNegatedJsonConds(and), nil case criteria.Any: or := squirrel.Or{} for _, child := range e { @@ -454,6 +454,25 @@ const jsonCondBatchSize = 350 // This turns N separate correlated subqueries into ceil(N/batchSize), dramatically // improving performance for smart playlists with many patterns. func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + if merged, ok := mergeSameFieldConds(or, false); ok { + return squirrel.Or(merged) + } + return or +} + +// mergeNegatedJsonConds is the AND-group counterpart to mergeJsonConds, merging negated +// conditions. By De Morgan, "NOT EXISTS(X) AND NOT EXISTS(Y)" == "NOT EXISTS(X OR Y)". +func mergeNegatedJsonConds(and squirrel.And) squirrel.Sqlizer { + if merged, ok := mergeSameFieldConds(and, true); ok { + return squirrel.And(merged) + } + return and +} + +// mergeSameFieldConds groups roleCond/tagCond entries that share a field and the requested +// polarity, replacing each group of 2+ with batched roleCondGroup/tagCondGroup subqueries. +// Returns the rewritten conditions and whether any merge happened. +func mergeSameFieldConds(conds []squirrel.Sqlizer, negated bool) ([]squirrel.Sqlizer, bool) { type condEntry struct { index int cond squirrel.Sqlizer @@ -465,10 +484,10 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { tag string } groups := make(map[string]*group) - for i, s := range or { + for i, s := range conds { switch c := s.(type) { case roleCond: - if c.not || c.cond == nil { + if c.not != negated || c.cond == nil { continue } g, exists := groups["role:"+c.role] @@ -478,7 +497,7 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { } g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) case tagCond: - if c.not || c.cond == nil { + if c.not != negated || c.cond == nil { continue } g, exists := groups["tag:"+c.tag] @@ -490,7 +509,6 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { } } - merged := false remove := make(map[int]bool) var additions []squirrel.Sqlizer for _, key := range slices.Sorted(maps.Keys(groups)) { @@ -498,45 +516,42 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { if len(g.entries) < 2 { continue } - merged = true - for _, e := range g.entries { - remove[e.index] = true - } - conds := make([]squirrel.Sqlizer, len(g.entries)) + batchConds := make([]squirrel.Sqlizer, len(g.entries)) for i, e := range g.entries { - conds[i] = e.cond + remove[e.index] = true + batchConds[i] = e.cond } if g.isRole { role := key[len("role:"):] - for batch := range slices.Chunk(conds, jsonCondBatchSize) { - additions = append(additions, roleCondGroup{role: role, conds: batch}) + for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch, not: negated}) } } else { - for batch := range slices.Chunk(conds, jsonCondBatchSize) { - additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch}) + for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch, not: negated}) } } } - if !merged { - return or + if len(remove) == 0 { + return conds, false } - result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions)) - for i, s := range or { + result := make([]squirrel.Sqlizer, 0, len(conds)-len(remove)+len(additions)) + for i, s := range conds { if !remove[i] { result = append(result, s) } } - result = append(result, additions...) - return result + return append(result, additions...), true } -// roleCondGroup represents multiple role conditions for the same role, merged into -// a single EXISTS subquery for performance. +// roleCondGroup represents multiple role conditions for the same role, merged into a single +// (optionally negated) EXISTS subquery for performance. type roleCondGroup struct { role string conds []squirrel.Sqlizer + not bool } func (g roleCondGroup) ToSql() (string, []any, error) { @@ -551,15 +566,19 @@ func (g roleCondGroup) ToSql() (string, []any, error) { allArgs = append(allArgs, args...) } cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + if g.not { + cond = "not " + cond + } return cond, allArgs, nil } -// tagCondGroup represents multiple tag conditions for the same tag, merged into -// a single EXISTS subquery for performance. +// tagCondGroup represents multiple tag conditions for the same tag, merged into a single +// (optionally negated) EXISTS subquery for performance. type tagCondGroup struct { tag string numeric bool conds []squirrel.Sqlizer + not bool } func (g tagCondGroup) ToSql() (string, []any, error) { @@ -578,6 +597,9 @@ func (g tagCondGroup) ToSql() (string, []any, error) { } cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", g.tag, strings.Join(innerParts, " OR ")) + if g.not { + cond = "not " + cond + } return cond, allArgs, nil } diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go index d901e9eda..1dcf97871 100644 --- a/persistence/criteria_sql_benchmark_test.go +++ b/persistence/criteria_sql_benchmark_test.go @@ -60,6 +60,61 @@ func BenchmarkSmartPlaylistRole(b *testing.B) { }) } +// BenchmarkSmartPlaylistNegatedRole compares performance for smart playlists with many +// negated role conditions ANDed together (e.g. 500 "isNot artist" rules, issue #5511) +// between the current implementation (merged NOT EXISTS via criteria pipeline) and the +// old baseline (one separate NOT EXISTS subquery per pattern). +func BenchmarkSmartPlaylistNegatedRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl-neg.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "isNot artist" patterns in an AND group + allExprs := make(criteria.All, benchNumPatterns) + for i := range benchNumPatterns { + allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: allExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedNotExists", func(b *testing.B) { + benchmarkUnmergedNegatedJSONTree(b, ctx) + }) +} + +// benchmarkUnmergedNegatedJSONTree builds the old-style query with N separate negated +// json_tree EXISTS subqueries ANDed together (the pre-optimization baseline). +func benchmarkUnmergedNegatedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" AND ") + } + sb.WriteString("not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)") + args = append(args, fmt.Sprintf("Artist %04d", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + // benchmarkCriteriaPipeline runs the criteria through the actual production code path: // newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 0257fa0cf..9ff7f1f07 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -408,6 +408,97 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name }) + + It("merges negated role conditions in an AND group into a single NOT EXISTS", func() { + expr := criteria.All{ + criteria.IsNot{"artist": "Beatles"}, + criteria.IsNot{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // A single NOT EXISTS with both names ORed inside (De Morgan) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("artist.name = ? OR artist.name = ?")) + Expect(args).To(HaveExactElements("artist", "Beatles", "Kraftwerk")) + }) + + It("merges negated notContains role conditions in an AND group", func() { + expr := criteria.All{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges negated tag conditions in an AND group into a single NOT EXISTS", func() { + expr := criteria.All{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%")) + }) + + It("does not merge a single negated condition with a positive one of the same role in AND", func() { + // AND of mixed polarity must not be collapsed: NOT EXISTS(a) AND EXISTS(b) + // is not equivalent to any single merged subquery. + expr := criteria.All{ + criteria.Contains{"artist": "Beatles"}, + criteria.IsNot{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // One positive EXISTS and one negated NOT EXISTS, kept separate + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(strings.Count(sql, "exists")).To(Equal(2)) // "not exists" contains "exists" + }) + + It("does not merge negated conditions of different roles in AND", func() { + expr := criteria.All{ + criteria.IsNot{"artist": "Beatles"}, + criteria.IsNot{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large negated AND groups to avoid SQLite expression tree depth limit", func() { + allExprs := make(criteria.All, jsonCondBatchSize+1) + for i := range allExprs { + allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two NOT EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) }) Describe("joins", func() { From 2c90685bc29ae5c1623f25ab5d15cd039725569a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 14 Jun 2026 13:39:16 -0400 Subject: [PATCH 291/596] fix(scanner): import playlists skipped when no admin existed yet (#5609) * fix(scanner): import playlists skipped when no admin existed yet (#5499) On a fresh install the first scan runs before any admin user exists, so the scanner's playlist phase skips all playlists (playlists are owned by the first admin). Nothing re-imported them afterwards because folder selection is gated on updated_at > last_scan_at, which nothing bumps. The playlist phase now: - resolves the admin at phase time (FindFirstAdmin) instead of trusting the context snapshot taken at scan start, so a long admin-less scan still imports playlists in its own phase if an admin was created meanwhile; - records a persisted PlaylistsImportPending flag when no admin exists yet; - when that flag is set, imports ALL playlist folders via a new GetAllWithPlaylists (bypassing the timestamp gate) and clears the flag. Playlists are recovered by the next scan that runs with an admin, with no dependency on scan duration and no changes to the auth/server layers. * fix(scanner): surface datastore errors in playlist import deferral (#5499) Address review feedback: - distinguish model.ErrNotFound (no admin yet -> defer) from real datastore errors when resolving the admin, so DB failures are propagated, not swallowed; - propagate the error if the pending-import flag can't be persisted, so a scan doesn't complete as successful without recording the recovery; - surface read errors when checking the pending flag. Also name the no-admin condition for readability. * fix(scanner): simplify admin existence check in playlist import Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): streamline folder access in playlist import logic Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- consts/consts.go | 3 + model/folder.go | 3 + persistence/folder_repository.go | 12 +++ persistence/folder_repository_test.go | 32 ++++++++ scanner/phase_4_playlists.go | 78 ++++++++++++++---- scanner/phase_4_playlists_test.go | 110 ++++++++++++++++++++++++-- tests/mock_user_repo.go | 12 +++ 7 files changed, 226 insertions(+), 24 deletions(-) diff --git a/consts/consts.go b/consts/consts.go index edd8f2b54..4baf4610d 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -14,6 +14,9 @@ const ( DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" InitialSetupFlagKey = "InitialSetup" FullScanAfterMigrationFlagKey = "FullScanAfterMigration" + // PlaylistsImportPendingFlagKey marks that playlist import was deferred because + // no admin user existed yet; the next scan with an admin imports them. + PlaylistsImportPendingFlagKey = "PlaylistsImportPending" LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" diff --git a/model/folder.go b/model/folder.go index 39cb6db84..81800c072 100644 --- a/model/folder.go +++ b/model/folder.go @@ -93,4 +93,7 @@ type FolderRepository interface { Put(*Folder) error MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) + // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring + // the scan-timestamp gate used by GetTouchedWithPlaylists. + GetAllWithPlaylists() (FolderCursor, error) } diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 624ef21b8..8fb7f0296 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -250,6 +250,18 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) return wrapFolderCursor(cursor), nil } +func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { + query := r.selectFolder().Where(And{ + Eq{"missing": false}, + Gt{"num_playlists": 0}, + }) + cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) + if err != nil { + return nil, err + } + return wrapFolderCursor(cursor), nil +} + func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return func(yield func(model.Folder, error) bool) { for f, err := range cursor { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 413a6b38f..a8945dfee 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -317,4 +317,36 @@ var _ = Describe("FolderRepository", func() { Expect(folders[0].ID).To(Equal("f1")) }) }) + + Describe("GetAllWithPlaylists", func() { + It("returns all non-missing folders with playlists, ignoring the scan-timestamp gate", func() { + withPls := model.NewFolder(testLib, "TestAllPls/WithPls") + withPls.NumPlaylists = 2 + noPls := model.NewFolder(testLib, "TestAllPls/NoPls") + noPls.NumPlaylists = 0 + missingWithPls := model.NewFolder(testLib, "TestAllPls/Missing") + missingWithPls.NumPlaylists = 1 + missingWithPls.Missing = true + + Expect(repo.Put(withPls)).To(Succeed()) + Expect(repo.Put(noPls)).To(Succeed()) + Expect(repo.Put(missingWithPls)).To(Succeed()) + + // Force the folder's updated_at to the past so GetTouchedWithPlaylists + // (which gates on updated_at > last_scan_at) would NOT return it. + _, err := conn.NewQuery("UPDATE folder SET updated_at = {:t} WHERE id = {:id}"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00", "id": withPls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + var ids []string + cursor, err := repo.GetAllWithPlaylists() + Expect(err).ToNot(HaveOccurred()) + for f, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + ids = append(ids, f.ID) + } + + Expect(ids).To(ConsistOf(withPls.ID)) // only the non-missing folder with playlists + }) + }) }) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index d52743966..8ba014235 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "errors" "fmt" "os" "strings" @@ -10,6 +11,7 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -18,12 +20,13 @@ import ( ) type phasePlaylists struct { - ctx context.Context - scanState *scanState - ds model.DataStore - pls playlists.Playlists - cw artwork.CacheWarmer - refreshed atomic.Uint32 + ctx context.Context + scanState *scanState + ds model.DataStore + pls playlists.Playlists + cw artwork.CacheWarmer + refreshed atomic.Uint32 + pendingImport bool } func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { @@ -49,22 +52,41 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { log.Info(p.ctx, "Playlists will not be imported, AutoImportPlaylists is set to false") return nil } - u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin || u.ID == "" { - log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ - "Please create an admin user first, and then update the playlists for them to be imported") - return nil + + // Resolve the admin at phase time (the producer runs late in the scan), so an + // admin created while the scan was in progress is picked up. Assigned once, + // before any put() below, so the channel send synchronizes it with the stages. + admin, err := p.ds.User(p.ctx).FindFirstAdmin() + if err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("finding admin user: %w", err) + } + noAdmin := admin == nil || admin.ID == "" + if noAdmin { + return p.deferImport() + } + p.ctx = request.WithUser(p.ctx, *admin) + + // When recovering a deferred import, scan all playlist folders, not just touched ones. + pending, err := p.importPending() + if err != nil { + return fmt.Errorf("checking pending playlist import: %w", err) + } + p.pendingImport = pending + var cursor model.FolderCursor + if p.pendingImport { + cursor, err = p.ds.Folder(p.ctx).GetAllWithPlaylists() + } else { + cursor, err = p.ds.Folder(p.ctx).GetTouchedWithPlaylists() + } + if err != nil { + return fmt.Errorf("loading folders with playlists: %w", err) } count := 0 - cursor, err := p.ds.Folder(p.ctx).GetTouchedWithPlaylists() - if err != nil { - return fmt.Errorf("loading touched folders: %w", err) - } - log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh") + log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh", "pendingImport", p.pendingImport) for folder, err := range cursor { if err != nil { - return fmt.Errorf("loading touched folder: %w", err) + return fmt.Errorf("loading folder with playlists: %w", err) } count++ put(&folder) @@ -78,6 +100,23 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } +// deferImport records the pending-import flag so a later scan with an admin can +// import the playlists, and returns an error if the flag can't be persisted (so +// the scan does not complete as successful without recording the recovery). +func (p *phasePlaylists) deferImport() error { + if err := p.ds.Property(p.ctx).Put(consts.PlaylistsImportPendingFlagKey, "1"); err != nil { + return fmt.Errorf("recording pending playlist import: %w", err) + } + log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+ + "They will be imported automatically once an admin user is created.") + return nil +} + +func (p *phasePlaylists) importPending() (bool, error) { + v, err := p.ds.Property(p.ctx).DefaultGet(consts.PlaylistsImportPendingFlagKey, "0") + return v == "1", err +} + func (p *phasePlaylists) stages() []ppl.Stage[*model.Folder] { return []ppl.Stage[*model.Folder]{ ppl.NewStage(p.processPlaylistsInFolder, ppl.Name("process playlists in folder"), ppl.Concurrency(3)), @@ -123,6 +162,11 @@ func (p *phasePlaylists) finalize(err error) error { } else { p.scanState.changesDetected.Store(true) } + if p.pendingImport && err == nil { + if derr := p.ds.Property(p.ctx).Delete(consts.PlaylistsImportPendingFlagKey); derr != nil { + log.Warn(p.ctx, "Scanner: Could not clear pending playlist-import flag", derr) + } + } logF(p.ctx, "Scanner: Finished refreshing playlists", "refreshed", refreshed, err) return err } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 0e01a7549..49ffc7fb7 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -9,10 +9,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -30,14 +30,22 @@ var _ = Describe("phasePlaylists", func() { cw artwork.CacheWarmer ) + var userRepo *tests.MockedUserRepo + var propRepo *tests.MockedPropertyRepo + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AutoImportPlaylists = true ctx = context.Background() - ctx = request.WithUser(ctx, model.User{ID: "123", IsAdmin: true}) folderRepo = &mockFolderRepository{} + userRepo = tests.CreateMockUserRepo() + // An admin user exists by default, so playlist import proceeds. + Expect(userRepo.Put(&model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) + propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{ - MockedFolder: folderRepo, + MockedFolder: folderRepo, + MockedUser: userRepo, + MockedProperty: propRepo, } pls = &mockPlaylists{} cw = artwork.NoopCacheWarmer() @@ -84,6 +92,81 @@ var _ = Describe("phasePlaylists", func() { Expect(called).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("error loading folders"))) }) + + It("sets the pending flag and imports nothing when no admin user exists", func() { + // Remove the admin user; produce resolves the admin at phase time. + userRepo.Data = map[string]*model.User{} + folderRepo.SetData(map[*model.Folder]error{ + {Path: "/path/to/folder1"}: nil, + }) + + called := false + err := phase.produce(func(folder *model.Folder) { called = true }) + + Expect(err).ToNot(HaveOccurred()) + Expect(called).To(BeFalse()) + v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(v).To(Equal("1")) + }) + + It("returns an error (not a silent defer) on a datastore failure resolving the admin", func() { + userRepo.Error = errors.New("db is locked") + + err := phase.produce(func(folder *model.Folder) {}) + + Expect(err).To(MatchError(ContainSubstring("finding admin user"))) + // Must NOT have set the pending flag on a real error. + _, getErr := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(getErr).To(HaveOccurred()) + }) + + It("returns an error when the pending flag cannot be persisted", func() { + userRepo.Data = map[string]*model.User{} // no admin -> defer path + propRepo.Error = errors.New("property table unavailable") + + err := phase.produce(func(folder *model.Folder) {}) + + Expect(err).To(MatchError(ContainSubstring("recording pending playlist import"))) + }) + + It("imports all playlist folders when the pending flag is set", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + folderRepo.SetAllData(map[*model.Folder]error{ + {Path: "/path/to/folder1"}: nil, + {Path: "/path/to/folder2"}: nil, + }) + // Touched set is empty: proves selection used GetAllWithPlaylists. + folderRepo.SetData(map[*model.Folder]error{}) + + var produced []*model.Folder + err := phase.produce(func(folder *model.Folder) { produced = append(produced, folder) }) + + Expect(err).ToNot(HaveOccurred()) + Expect(produced).To(HaveLen(2)) + Expect(phase.pendingImport).To(BeTrue()) + }) + }) + + Describe("finalize", func() { + It("clears the pending flag after a successful pending import", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + phase.pendingImport = true + + Expect(phase.finalize(nil)).To(Succeed()) + + _, err := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(err).To(HaveOccurred()) // deleted + }) + + It("keeps the pending flag when the import failed", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + phase.pendingImport = true + + Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred()) + + v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(v).To(Equal("1")) + }) }) Describe("processPlaylistsInFolder", func() { @@ -141,12 +224,13 @@ func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Fold type mockFolderRepository struct { model.FolderRepository - data map[*model.Folder]error + data map[*model.Folder]error + allData map[*model.Folder]error } -func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { +func cursorFromData(data map[*model.Folder]error) model.FolderCursor { return func(yield func(model.Folder, error) bool) { - for folder, err := range f.data { + for folder, err := range data { if err != nil { if !yield(model.Folder{}, err) { return @@ -157,9 +241,21 @@ func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, er return } } - }, nil + } +} + +func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { + return cursorFromData(f.data), nil +} + +func (f *mockFolderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { + return cursorFromData(f.allData), nil } func (f *mockFolderRepository) SetData(m map[*model.Folder]error) { f.data = m } + +func (f *mockFolderRepository) SetAllData(m map[*model.Folder]error) { + f.allData = m +} diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 7c7dadbc4..2d6ff3c02 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -57,6 +57,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use return u.FindByUsername(username) } +func (u *MockedUserRepo) FindFirstAdmin() (*model.User, error) { + if u.Error != nil { + return nil, u.Error + } + for _, usr := range u.Data { + if usr.IsAdmin { + return usr, nil + } + } + return nil, model.ErrNotFound +} + func (u *MockedUserRepo) Get(id string) (*model.User, error) { if u.Error != nil { return nil, u.Error From c4c70519b5f3a2a6eaf01f5786754d899cda9bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 14 Jun 2026 16:52:01 -0400 Subject: [PATCH 292/596] fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611) * fix(transcoding): enforce player MaxBitRate on getTranscodeDecision The Web UI streams via getTranscodeDecision, which (since #5473) ignored the server-side player config. Apply the player's MaxBitRate as a bitrate ceiling on the client's declared limits before MakeDecision, restoring per-player bitrate enforcement without reintroducing the forced-format override. Fixes #5583. * test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision Invert the assertions added in #5473 that expected the player cap to be ignored; getTranscodeDecision now enforces it (issue #5583). * feat(ui): clarify web player ignores forced transcoding format Add helper text to the Transcoding field on the player edit form when the player is the NavidromeUI web client, since it enforces only the Max. Bit Rate, not the forced format. Part of issue #5583. * refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate method in core/stream, used by both getTranscodeDecision and the legacy ResolveRequest path. Removes handler-layer duplication and corrects a misleading comment that wrongly implied the legacy single-field cap was buggy. * fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set A bare /stream or /download request from a player configured with a server-side MaxBitRate (but no forced format) was served raw, ignoring the cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the player MaxBitRate alone is below the source bitrate, matching the already-correct forced-format and request-bitrate paths. Part of #5583. * fix(ui): add Brazilian Portuguese translation for player transcoding helper text Translates the new resources.player.helperTexts.transcodingId key added for the web player transcoding-format clarification. Part of #5583. * fix(ui): restore Transcoding field styling and render helper text The TranscodingInput wrapper swallowed the variant SimpleForm injects into its direct children (field lost its outlined box) and put helperText on the ReferenceInput, which does not forward it to the input. Spread the form props onto ReferenceInput and move helperText to the SelectInput child so both the outlined styling and the helper text render. Part of #5583. * fix(i18n): update Brazilian Portuguese translation for album artist field Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): clean up comments in PlayerEdit component Signed-off-by: Deluan <deluan@navidrome.org> * test(ui): mock useTranslate in PlayerEdit test for determinism Avoid depending on ra-core's out-of-provider translation behavior, which can vary by version. Part of #5583. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/stream/legacy_client.go | 20 +++++-- core/stream/legacy_client_test.go | 86 ++++++++++++++++++++++++--- core/stream/types.go | 19 ++++++ core/stream/types_test.go | 59 ++++++++++++++++++ resources/i18n/pt-br.json | 7 ++- server/e2e/subsonic_transcode_test.go | 46 +++++++------- server/subsonic/transcode.go | 10 ++++ server/subsonic/transcode_test.go | 71 ++++++++++++++++++++++ ui/src/i18n/en.json | 3 + ui/src/player/PlayerEdit.jsx | 33 +++++++--- ui/src/player/PlayerEdit.test.jsx | 54 +++++++++++++++++ 11 files changed, 364 insertions(+), 44 deletions(-) create mode 100644 core/stream/types_test.go create mode 100644 ui/src/player/PlayerEdit.test.jsx diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 9dd6179a0..652e42eba 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -12,7 +12,7 @@ import ( // buildLegacyClientInfo translates legacy Subsonic stream/download parameters // into a ClientInfo for use with MakeDecision. -func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} // Determine target format for transcoding @@ -22,6 +22,10 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int targetFormat = reqFormat case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": targetFormat = conf.Server.DefaultDownsamplingFormat + case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + // Server-side player MaxBitRate alone forces downsampling, even when the + // client sent no format/bitrate params (issue #5583, legacy /stream path). + targetFormat = conf.Server.DefaultDownsamplingFormat } if targetFormat != "" { @@ -63,15 +67,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile return req } - clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + playerMaxBitRate := 0 + if player, ok := request.PlayerFrom(ctx); ok { + playerMaxBitRate = player.MaxBitRate + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) // Apply server-side player transcoding override before making the decision if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { - modified := *clientInfo - modified.MaxAudioBitrate = player.MaxBitRate + } else if player, ok := request.PlayerFrom(ctx); ok { + modified := *clientInfo + if modified.CapBitrate(player.MaxBitRate) { clientInfo = &modified log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) } diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index 2ddd74ce0..bc8405976 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -21,7 +21,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("sets transcoding profile for explicit format without bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 0) + ci := buildLegacyClientInfo(mf, "mp3", 0, 0) Expect(ci.Name).To(Equal("legacy")) Expect(ci.TranscodingProfiles).To(HaveLen(1)) @@ -34,7 +34,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("does not add direct play profile when explicit format differs from source (no bitrate)", func() { - ci := buildLegacyClientInfo(mf, "opus", 0) + ci := buildLegacyClientInfo(mf, "opus", 0, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) @@ -42,7 +42,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("adds direct play profile when explicit format matches source format", func() { - ci := buildLegacyClientInfo(mf, "flac", 0) + ci := buildLegacyClientInfo(mf, "flac", 0, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac")) @@ -52,7 +52,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("sets transcoding profile and bitrate for explicit format with bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 192) + ci := buildLegacyClientInfo(mf, "mp3", 192, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) @@ -63,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("returns direct play profile when no format and no bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 0) + ci := buildLegacyClientInfo(mf, "", 0, 0) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -77,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - ci := buildLegacyClientInfo(mf, "", 128) + ci := buildLegacyClientInfo(mf, "", 128, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) @@ -91,7 +91,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("returns direct play when bitrate >= source bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 960) + ci := buildLegacyClientInfo(mf, "", 960, 0) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -100,6 +100,51 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.TranscodingProfiles).To(BeEmpty()) Expect(ci.MaxAudioBitrate).To(BeZero()) }) + + It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 0, 256) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + }) + + It("does not downsample when player MaxBitRate is >= source bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 0, 960) + + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + }) + + It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + ci := buildLegacyClientInfo(mf, "", 0, 256) + + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + }) + + It("prefers explicit request format over player cap", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "mp3", 0, 256) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + }) }) var _ = Describe("ResolveRequest", func() { @@ -289,6 +334,33 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Format).To(Equal("raw")) }) + + It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(256)) + }) + + It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) }) Context("fallback for unknown format", func() { diff --git a/core/stream/types.go b/core/stream/types.go index bd8ce292c..11642c11c 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -40,6 +40,25 @@ type ClientInfo struct { CodecProfiles []CodecProfile } +// CapBitrate lowers the client's declared audio bitrate limits to maxKbps, +// never raising them. A zero limit means "unlimited" and is set to maxKbps. +// Returns true if anything changed. No-op when maxKbps <= 0. +func (ci *ClientInfo) CapBitrate(maxKbps int) bool { + if maxKbps <= 0 { + return false + } + changed := false + if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate { + ci.MaxAudioBitrate = maxKbps + changed = true + } + if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate { + ci.MaxTranscodingAudioBitrate = maxKbps + changed = true + } + return changed +} + // DirectPlayProfile describes a format the client can play directly type DirectPlayProfile struct { Containers []string diff --git a/core/stream/types_test.go b/core/stream/types_test.go new file mode 100644 index 000000000..2d2a83d06 --- /dev/null +++ b/core/stream/types_test.go @@ -0,0 +1,59 @@ +package stream + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ClientInfo", func() { + Describe("CapBitrate", func() { + It("is a no-op when maxKbps is zero", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} + Expect(ci.CapBitrate(0)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(320)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("is a no-op when maxKbps is negative", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} + Expect(ci.CapBitrate(-1)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(320)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("sets both limits when both are zero (unlimited)", func() { + ci := &ClientInfo{} + Expect(ci.CapBitrate(256)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(256)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(256)) + }) + + It("lowers limits higher than maxKbps", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 500} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("does not raise limits lower than maxKbps", func() { + ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 96} + Expect(ci.CapBitrate(320)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(96)) + }) + + It("reports changed when only one limit is lowered", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 128} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + + It("caps only the zero (unlimited) limit", func() { + ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 0} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + }) +}) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index d9f29f5d4..bc4d149a1 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -4,7 +4,7 @@ "song": { "name": "Música |||| Músicas", "fields": { - "albumArtist": "Artista", + "albumArtist": "Artista do Álbum", "duration": "Duração", "trackNumber": "#", "playCount": "Execuções", @@ -57,7 +57,7 @@ "album": { "name": "Álbum |||| Álbuns", "fields": { - "albumArtist": "Artista", + "albumArtist": "Artista do Álbum", "artist": "Artista", "duration": "Duração", "songCount": "Músicas", @@ -187,6 +187,9 @@ "lastSeen": "Últ. acesso", "reportRealPath": "Use paths reais", "scrobbleEnabled": "Enviar scrobbles para serviços externos" + }, + "helperTexts": { + "transcodingId": "O player web ignora o formato de conversão e aplica apenas o limite de Bitrate máx." } }, "transcoding": { diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index ae3d6208c..c769fd2d4 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -396,30 +396,34 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap is ignored", func() { - It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is enforced", func() { + It("forces transcode when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps, player cap is 320, but getTranscodeDecision - // ignores server-side overrides — client profiles are used as-is + // FLAC is 900kbps. Player cap (320) < source → direct play is + // rejected and the file is transcoded down. resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // Target bitrate is capped at the player MaxBitRate (320kbps). + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) - It("uses only client limit, not player MaxBitRate", func() { + It("uses the player cap when it is more restrictive than the client limit", func() { setPlayerMaxBitRate(192) // 192 kbps player cap - // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 - // but getTranscodeDecision ignores player cap → client limit (320kbps) applies + // Client caps at 320kbps (bitrateCapClient); player is more + // restrictive at 192 → player cap wins. resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Only client limit (320kbps) applies → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Player cap (192kbps) applies → 192000 bps. + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) @@ -475,35 +479,33 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { - It("does not inject maxAudioBitrate from player cap", func() { + Describe("player MaxBitRate injected by getTranscodeDecision", func() { + It("injects the player cap as the transcode target when the client declares none", func() { setPlayerMaxBitRate(320) - // opusTranscodeClient has no client bitrate limits - // Player cap is 320, but getTranscodeDecision ignores it - // FLAC (900kbps) → can't direct play → transcode to opus using format default + // opusTranscodeClient has no client bitrate limits. The player + // cap (320) is injected, so FLAC (900kbps) → opus is capped at 320. resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // Bitrate should be opus format default (128kbps), not player cap (320kbps) - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) + // Bitrate is the player cap (320kbps), not the opus format default. + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) - It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { + It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) - // Player cap is 320, but getTranscodeDecision ignores it - // Only client maxTranscodingAudioBitrate=192 applies + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps). + // Player cap (320) is higher → the lower client limit wins. resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate=192 → 192000 bps + // Client limit (192kbps) wins → 192000 bps. Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 578ad44fc..c95c25cb0 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" ) @@ -278,6 +279,15 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + // Apply the player's MaxBitRate as a ceiling on the client's declared + // limits (issue #5583). Both fields are capped because the client sends + // them independently here; capping only MaxAudioBitrate would let an + // independent MaxTranscodingAudioBitrate slip through computeBitrate. + if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) { + log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision", + "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + // Get media file mf, err := api.ds.MediaFile(ctx).Get(mediaID) if err != nil { diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 29a883ac1..4a1017752 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -234,6 +235,76 @@ var _ = Describe("Transcode endpoints", func() { Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) }) + + Describe("player MaxBitRate cap", func() { + withPlayer := func(r *http.Request, maxBitRate int) *http.Request { + ctx := request.WithPlayer(r.Context(), model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true} + mockTD.token = "token" + }) + + It("caps client MaxAudioBitrate at the player MaxBitRate when client declares none", func() { + body := `{"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient).ToNot(BeNil()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("does not raise a lower client-declared limit", func() { + // Client declares 192 kbps (192000 bps); player cap is 320 — client wins. + body := `{"maxAudioBitrate":192000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + }) + + It("lowers a higher client-declared limit to the player cap", func() { + // Client declares 320 kbps (320000 bps); player cap is 192 — player wins. + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 192) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("does nothing when no player is in context", func() { + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + + It("does nothing when player MaxBitRate is 0", func() { + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + }) }) Describe("GetTranscodeStream", func() { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..595b20a0d 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -187,6 +187,9 @@ "lastSeen": "Last Seen At", "reportRealPath": "Report Real Path", "scrobbleEnabled": "Send Scrobbles to external services" + }, + "helperTexts": { + "transcodingId": "The web player ignores the transcoding format and only enforces the Max. Bit Rate limit." } }, "transcoding": { diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index 1826500bd..d09ed855f 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -8,6 +8,7 @@ import { SelectInput, ReferenceInput, useTranslate, + useRecordContext, } from 'react-admin' import { Title } from '../common' import config from '../config' @@ -19,17 +20,35 @@ const PlayerTitle = ({ record }) => { return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> } +export const TranscodingInput = (props) => { + const translate = useTranslate() + const record = useRecordContext(props) + const isWebPlayer = record?.client === 'NavidromeUI' + return ( + <ReferenceInput + {...props} + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput + source="name" + resettable + helperText={ + isWebPlayer + ? translate('resources.player.helperTexts.transcodingId') + : undefined + } + /> + </ReferenceInput> + ) +} + const PlayerEdit = (props) => ( <Edit title={<PlayerTitle />} {...props}> <SimpleForm variant={'outlined'}> <TextInput source="name" validate={[required()]} /> - <ReferenceInput - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput source="name" resettable /> - </ReferenceInput> + <TranscodingInput /> <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> <BooleanInput source="reportRealPath" fullWidth /> {(config.lastFMEnabled || config.listenBrainzEnabled) && ( diff --git a/ui/src/player/PlayerEdit.test.jsx b/ui/src/player/PlayerEdit.test.jsx new file mode 100644 index 000000000..2b8c862e2 --- /dev/null +++ b/ui/src/player/PlayerEdit.test.jsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { render, screen, cleanup } from '@testing-library/react' +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' +import { useRecordContext } from 'react-admin' +import { TranscodingInput } from './PlayerEdit' + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useRecordContext: vi.fn(), + // Mock useTranslate to return the key verbatim so assertions don't depend + // on ra-core's out-of-provider translation behavior. + useTranslate: () => (key) => key, + // Render the inputs as simple stand-ins so we can read their props. + ReferenceInput: ({ children, variant }) => ( + <div data-testid="reference-input" data-variant={variant || ''}> + {children} + </div> + ), + SelectInput: ({ helperText }) => ( + <div data-testid="select-input" data-helpertext={helperText || ''} /> + ), + } +}) + +describe('<TranscodingInput />', () => { + beforeEach(() => { + useRecordContext.mockReset() + }) + afterEach(cleanup) + + it('shows helper text for the NavidromeUI player', () => { + useRecordContext.mockReturnValue({ client: 'NavidromeUI' }) + render(<TranscodingInput />) + expect(screen.getByTestId('select-input').dataset.helpertext).toBe( + 'resources.player.helperTexts.transcodingId', + ) + }) + + it('shows no helper text for other clients', () => { + useRecordContext.mockReturnValue({ client: 'DSub' }) + render(<TranscodingInput />) + expect(screen.getByTestId('select-input').dataset.helpertext).toBe('') + }) + + it('forwards the form variant injected by SimpleForm to the input', () => { + useRecordContext.mockReturnValue({ client: 'DSub' }) + render(<TranscodingInput variant="outlined" />) + expect(screen.getByTestId('reference-input').dataset.variant).toBe( + 'outlined', + ) + }) +}) From 08a027dbcc1ab9d3b6f22982c1954c4353a4521d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 14 Jun 2026 20:52:19 -0400 Subject: [PATCH 293/596] fix(transcoding): honor player forced format on the WebUI transcode flow (#5613) * feat(stream): add ClientInfo.ForceFormat for browser-aware forced format Restricts the client to a forced transcoding format and suppresses direct play, but only when the client declares it supports that format. Part of #5583. * fix(transcoding): honor player forced format on getTranscodeDecision When the WebUI player has a forced transcoding format configured and the browser declares it can play that format, transcode to it (suppressing direct play). Fall back to normal negotiation with a warning when the format is unsupported. The MaxBitRate cap still applies on top. Fixes #5583. * test(e2e): cover player forced format on getTranscodeDecision Forced format honored when the client supports it, falls back to negotiation otherwise, and the MaxBitRate cap still applies on top. Part of #5583. * feat(ui): remove obsolete 'format ignored' helper text on player form The web player now honors the forced transcoding format, so the caveat added in #5611 no longer applies. Reverts the Transcoding field to a plain selector. Part of #5583. --- core/stream/types.go | 25 +++++++++ core/stream/types_test.go | 74 +++++++++++++++++++++++++++ resources/i18n/pt-br.json | 3 -- server/e2e/subsonic_transcode_test.go | 48 +++++++++++++++++ server/subsonic/transcode.go | 13 +++++ server/subsonic/transcode_test.go | 67 ++++++++++++++++++++++++ ui/src/i18n/en.json | 3 -- ui/src/player/PlayerEdit.jsx | 33 +++--------- ui/src/player/PlayerEdit.test.jsx | 54 ------------------- 9 files changed, 234 insertions(+), 86 deletions(-) delete mode 100644 ui/src/player/PlayerEdit.test.jsx diff --git a/core/stream/types.go b/core/stream/types.go index 11642c11c..19474dd91 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -59,6 +59,31 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool { return changed } +// ForceFormat narrows the client to transcoding to targetFormat and suppresses +// direct play, but only if the client already declares a profile for that +// format. All matching profiles are kept so negotiation can still pick among +// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or +// unsupported. +func (ci *ClientInfo) ForceFormat(targetFormat string) bool { + if targetFormat == "" { + return false + } + var matched []Profile + for i := range ci.TranscodingProfiles { + // matchesContainer is alias-aware, so a forced "oga" (legacy Opus + // target_format) still matches a resolved "opus" profile. + if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) { + matched = append(matched, ci.TranscodingProfiles[i]) + } + } + if len(matched) == 0 { + return false + } + ci.TranscodingProfiles = matched + ci.DirectPlayProfiles = nil + return true +} + // DirectPlayProfile describes a format the client can play directly type DirectPlayProfile struct { Containers []string diff --git a/core/stream/types_test.go b/core/stream/types_test.go index 2d2a83d06..eff408362 100644 --- a/core/stream/types_test.go +++ b/core/stream/types_test.go @@ -56,4 +56,78 @@ var _ = Describe("ClientInfo", func() { Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) }) }) + + Describe("ForceFormat", func() { + It("restricts to the forced format and clears direct play when supported", func() { + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("opus") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("matches a container-only forced format (mp3)", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("mp3") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + }) + + It("matches the forced format against codec aliases (oga/opus)", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + // Legacy DBs may store the Opus transcoding as target_format "oga". + ok := ci.ForceFormat("oga") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + }) + + It("is a no-op when the forced format is not supported by the client", func() { + original := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: original, + } + ok := ci.ForceFormat("opus") + Expect(ok).To(BeFalse()) + Expect(ci.TranscodingProfiles).To(Equal(original)) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + }) + + It("is a no-op for an empty target format", func() { + ci := &ClientInfo{TranscodingProfiles: []Profile{{Container: "mp3", AudioCodec: "mp3"}}} + Expect(ci.ForceFormat("")).To(BeFalse()) + }) + + It("keeps all matching profiles when multiple resolve to the forced format", func() { + first := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP} + second := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP, MaxAudioChannels: 2} + other := Profile{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP} + ci := &ClientInfo{TranscodingProfiles: []Profile{first, other, second}} + + ok := ci.ForceFormat("opus") + + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(ConsistOf(first, second)) + }) + }) }) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index bc4d149a1..b3b3bab2f 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -187,9 +187,6 @@ "lastSeen": "Últ. acesso", "reportRealPath": "Use paths reais", "scrobbleEnabled": "Enviar scrobbles para serviços externos" - }, - "helperTexts": { - "transcodingId": "O player web ignora o formato de conversão e aplica apenas o limite de Bitrate máx." } }, "transcoding": { diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index c769fd2d4..afe7d52ca 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -159,11 +159,22 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(ds.Player(ctx).Put(player)).To(Succeed()) } + setPlayerForcedFormat := func(format string) { + doReq("ping") + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + Expect(err).ToNot(HaveOccurred()) + trc, err := ds.Transcoding(ctx).FindByFormat(format) + Expect(err).ToNot(HaveOccurred()) + player.TranscodingId = trc.ID + Expect(ds.Player(ctx).Put(player)).To(Succeed()) + } + AfterEach(func() { // Reset player MaxBitRate to 0 after each test player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") if err == nil { player.MaxBitRate = 0 + player.TranscodingId = "" _ = ds.Player(ctx).Put(player) } }) @@ -509,6 +520,43 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) + + Describe("player forced format", func() { + It("transcodes a FLAC to the forced opus format when the client supports it", func() { + setPlayerForcedFormat("opus") + + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + }) + + It("falls back to negotiation when the client does not support the forced format", func() { + setPlayerForcedFormat("opus") + + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + }) + + It("applies maxBitRate on top of the forced format", func() { + setPlayerForcedFormat("opus") + setPlayerMaxBitRate(96) + + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(96000))) + }) + }) }) Describe("getTranscodeStream", func() { diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index c95c25cb0..511db2b85 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -279,6 +279,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + // Honor the player's forced transcoding format, falling back to normal + // negotiation when the client can't play it (issue #5583). + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + if !clientInfo.ForceFormat(trc.TargetFormat) { + clientName := clientInfo.Name + if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" { + clientName = player.Client + } + log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", + "forcedFormat", trc.TargetFormat, "client", clientName) + } + } + // Apply the player's MaxBitRate as a ceiling on the client's declared // limits (issue #5583). Both fields are capped because the client sends // them independently here; capping only MaxAudioBitrate would let an diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 4a1017752..7e36ab243 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -305,6 +305,73 @@ var _ = Describe("Transcode endpoints", func() { Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) }) }) + + Describe("player forced format", func() { + withForcedFormat := func(r *http.Request, format string, maxBitRate int) *http.Request { + ctx := r.Context() + ctx = request.WithTranscoding(ctx, model.Transcoding{TargetFormat: format}) + if maxBitRate > 0 { + ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) + } + return r.WithContext(ctx) + } + + BeforeEach(func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanTranscode: true} + mockTD.token = "token" + }) + + It("forces a supported format and clears direct play", func() { + body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], + "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty()) + }) + + It("falls back to negotiation when the forced format is unsupported", func() { + // Forced format is opus, but the client only declares mp3 and flac. + // Should fall back to negotiating among the client's own profiles. + body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], + "transcodingProfiles":[ + {"container":"flac","audioCodec":"flac","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + // Profiles left intact for normal negotiation (forced format not applied). + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(2)) + Expect(mockTD.capturedClient.DirectPlayProfiles).ToNot(BeEmpty()) + }) + + It("applies the maxBitRate cap on top of the forced format", func() { + // Client supports opus + mp3; forced format opus must be selected, + // and the maxBitRate cap applied on top. + body := `{"transcodingProfiles":[ + {"container":"ogg","audioCodec":"opus","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 128) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + }) }) Describe("GetTranscodeStream", func() { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 595b20a0d..74fb23ab9 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -187,9 +187,6 @@ "lastSeen": "Last Seen At", "reportRealPath": "Report Real Path", "scrobbleEnabled": "Send Scrobbles to external services" - }, - "helperTexts": { - "transcodingId": "The web player ignores the transcoding format and only enforces the Max. Bit Rate limit." } }, "transcoding": { diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index d09ed855f..1826500bd 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -8,7 +8,6 @@ import { SelectInput, ReferenceInput, useTranslate, - useRecordContext, } from 'react-admin' import { Title } from '../common' import config from '../config' @@ -20,35 +19,17 @@ const PlayerTitle = ({ record }) => { return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> } -export const TranscodingInput = (props) => { - const translate = useTranslate() - const record = useRecordContext(props) - const isWebPlayer = record?.client === 'NavidromeUI' - return ( - <ReferenceInput - {...props} - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput - source="name" - resettable - helperText={ - isWebPlayer - ? translate('resources.player.helperTexts.transcodingId') - : undefined - } - /> - </ReferenceInput> - ) -} - const PlayerEdit = (props) => ( <Edit title={<PlayerTitle />} {...props}> <SimpleForm variant={'outlined'}> <TextInput source="name" validate={[required()]} /> - <TranscodingInput /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> <BooleanInput source="reportRealPath" fullWidth /> {(config.lastFMEnabled || config.listenBrainzEnabled) && ( diff --git a/ui/src/player/PlayerEdit.test.jsx b/ui/src/player/PlayerEdit.test.jsx deleted file mode 100644 index 2b8c862e2..000000000 --- a/ui/src/player/PlayerEdit.test.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from 'react' -import { render, screen, cleanup } from '@testing-library/react' -import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' -import { useRecordContext } from 'react-admin' -import { TranscodingInput } from './PlayerEdit' - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useRecordContext: vi.fn(), - // Mock useTranslate to return the key verbatim so assertions don't depend - // on ra-core's out-of-provider translation behavior. - useTranslate: () => (key) => key, - // Render the inputs as simple stand-ins so we can read their props. - ReferenceInput: ({ children, variant }) => ( - <div data-testid="reference-input" data-variant={variant || ''}> - {children} - </div> - ), - SelectInput: ({ helperText }) => ( - <div data-testid="select-input" data-helpertext={helperText || ''} /> - ), - } -}) - -describe('<TranscodingInput />', () => { - beforeEach(() => { - useRecordContext.mockReset() - }) - afterEach(cleanup) - - it('shows helper text for the NavidromeUI player', () => { - useRecordContext.mockReturnValue({ client: 'NavidromeUI' }) - render(<TranscodingInput />) - expect(screen.getByTestId('select-input').dataset.helpertext).toBe( - 'resources.player.helperTexts.transcodingId', - ) - }) - - it('shows no helper text for other clients', () => { - useRecordContext.mockReturnValue({ client: 'DSub' }) - render(<TranscodingInput />) - expect(screen.getByTestId('select-input').dataset.helpertext).toBe('') - }) - - it('forwards the form variant injected by SimpleForm to the input', () => { - useRecordContext.mockReturnValue({ client: 'DSub' }) - render(<TranscodingInput variant="outlined" />) - expect(screen.getByTestId('reference-input').dataset.variant).toBe( - 'outlined', - ) - }) -}) From f0625ff709f790fab4230444ac32eeb49055c728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 15 Jun 2026 16:23:39 -0400 Subject: [PATCH 294/596] perf(subsonic): speed up getRandomSongs with two-phase random-rowid selection (#5618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getRandomSongs used a single ORDER BY random() over the media_file table. At large library sizes that forces SQLite to scan the wide table and sort every matching row before applying the limit — roughly 4 seconds for a 1M-track library, regardless of how many songs are requested. Add MediaFileRepository.GetRandom, which does this in two passes: first select N random rowids over a narrow index (filters + library scope only, no wide columns or joins), so the random sort runs over compact, index-friendly data; then hydrate just those rows with the full select. The wide media_file row is never part of the sort. End-to-end this drops getRandomSongs from ~4s to ~0.3s on a 1M-track library, and the cost no longer grows with the requested size. The handler now calls GetRandom directly. The filter helper that builds the genre/year filters is renamed SongsByRandom -> SongsByGenreAndYearRange to reflect what it does, since the random ordering is now owned by GetRandom rather than a Sort option. Filters (genre, year, library) compose into the first pass unchanged. The album random list path is left as-is (far fewer rows, already fast). --- model/mediafile.go | 3 + persistence/mediafile_repository.go | 34 +++++++++ persistence/mediafile_repository_test.go | 97 ++++++++++++++++++++++++ server/subsonic/album_lists.go | 5 +- server/subsonic/filter/filters.go | 6 +- tests/mock_mediafile_repo.go | 11 +++ 6 files changed, 150 insertions(+), 6 deletions(-) diff --git a/model/mediafile.go b/model/mediafile.go index 718f0443d..6a489bcd5 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -439,6 +439,9 @@ type MediaFileRepository interface { Get(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) + // GetRandom returns up to options.Max media files in random order, applying the same + // filters as GetAll. Sort/Order are ignored. + GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 559378262..dd8145eae 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -207,6 +207,40 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media return res.toModels(), nil } +// GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the +// wide media_file row: pick random rowids first, then hydrate only those. +func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { + var opt model.QueryOptions + if len(options) > 0 { + opt = options[0] + } + + rowidQuery := Select("media_file.rowid").From(r.tableName) + rowidQuery = r.applyFilters(rowidQuery, model.QueryOptions{Filters: opt.Filters}) + rowidQuery = r.applyLibraryFilter(rowidQuery) + rowidQuery = rowidQuery.OrderBy("random()") + if opt.Max > 0 { + rowidQuery = rowidQuery.Limit(uint64(opt.Max)) + } + + var rowids []int64 + if err := r.queryAllSlice(rowidQuery, &rowids); err != nil { + return nil, err + } + if len(rowids) == 0 { + return model.MediaFiles{}, nil + } + + // Re-shuffle in Phase 2: `WHERE rowid IN (...)` returns rows in ascending rowid order, not + // the random order from Phase 1. Sorting only the (<=Max) hydrated rows is negligible. + sq := r.selectMediaFile().Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") + var res dbMediaFiles + if err := r.queryAll(sq, &res); err != nil { + return nil, err + } + return res.toModels(), nil +} + func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { placeholders := make([]string, len(values)) args := make([]any, len(values)) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 4c2363e43..7989dc25a 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "time" "github.com/Masterminds/squirrel" @@ -106,6 +107,102 @@ var _ = Describe("MediaRepository", func() { } }) + Describe("GetRandom", func() { + It("returns the requested number of distinct, fully-hydrated media files", func() { + results, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(5)) + + // Each returned row must match its GetAll counterpart exactly — proves Phase 2 + // hydrates full rows (not bare rowids) — and ids must be distinct. + byID := map[string]model.MediaFile{} + all, err := mr.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range all { + byID[mf.ID] = mf + } + seen := map[string]bool{} + for _, mf := range results { + expected, ok := byID[mf.ID] + Expect(ok).To(BeTrue(), "returned id must be a real media file") + Expect(mf.Title).To(Equal(expected.Title), "row must be fully hydrated") + Expect(seen[mf.ID]).To(BeFalse(), "no duplicate rows") + seen[mf.ID] = true + } + }) + + It("returns all matching files when Max exceeds the total", func() { + results, err := mr.GetRandom(model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(13)) + }) + + It("honors filters", func() { + results, err := mr.GetRandom(model.QueryOptions{ + Max: 10, + Filters: squirrel.Eq{"media_file.title": "Antenna"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + for _, mf := range results { + Expect(mf.Title).To(Equal("Antenna")) + } + }) + + It("returns varying results across calls", func() { + // Retry a few times: two random draws of 5 from 13 rows differ with near-certainty. + first, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + firstIDs := func() []string { + ids := make([]string, len(first)) + for i, mf := range first { + ids[i] = mf.ID + } + return ids + }() + differed := false + for range 10 { + next, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + nextIDs := make([]string, len(next)) + for i, mf := range next { + nextIDs[i] = mf.ID + } + if !reflect.DeepEqual(firstIDs, nextIDs) { + differed = true + break + } + } + Expect(differed).To(BeTrue(), "GetRandom should not return an identical set every call") + }) + + It("randomizes order even when Max exceeds the total", func() { + // Same set of rows every time (all 13), but the order must still be shuffled — + // guards against Phase 2's `rowid IN (...)` returning rows in rowid order. + first, err := mr.GetRandom(model.QueryOptions{Max: 100}) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(HaveLen(13)) + firstIDs := make([]string, len(first)) + for i, mf := range first { + firstIDs[i] = mf.ID + } + differed := false + for range 10 { + next, err := mr.GetRandom(model.QueryOptions{Max: 100}) + Expect(err).ToNot(HaveOccurred()) + nextIDs := make([]string, len(next)) + for i, mf := range next { + nextIDs[i] = mf.ID + } + if !reflect.DeepEqual(firstIDs, nextIDs) { + differed = true + break + } + } + Expect(differed).To(BeTrue(), "order must vary even when returning all rows") + }) + }) + 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) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 0d82c8be9..24bbca960 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -240,10 +240,11 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.SongsByRandom(genre, fromYear, toYear) + opts := filter.SongsByGenreAndYearRange(genre, fromYear, toYear) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + opts.Max = size - songs, err := api.getSongs(r.Context(), 0, size, opts) + songs, err := api.ds.MediaFile(r.Context()).GetRandom(opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 856870a6c..c3710394f 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -90,10 +90,8 @@ func SongsByAlbum(albumId string) Options { }) } -func SongsByRandom(genre string, fromYear, toYear int) Options { - options := Options{ - Sort: "random()", - } +func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { + options := Options{} ff := And{} if genre != "" { ff = append(ff, filterByGenre(genre)) diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 01eacae30..f15ba1bc6 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -98,6 +98,17 @@ func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, return result, nil } +func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFiles, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + if len(qo) > 0 && qo[0].Max > 0 && len(res) > qo[0].Max { + res = res[:qo[0].Max] + } + return res, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") From 838ceee26d6f95dc998b6810d2008688b899c761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 16 Jun 2026 21:47:15 -0400 Subject: [PATCH 295/596] perf(subsonic): speed up artist search3 deep-offset pagination (#5620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(subsonic): speed up artist search3 deep-offset pagination Empty-query and FTS artist search (search3/search2) paginated via a CROSS JOIN library_artist + DISTINCT in Phase 1 purely for library access control. The DISTINCT forced a temp b-tree over the whole junction table on every page, making deep offsets O(offset): ~200ms at offset 299k on 300k artists. Replace it with a join-free EXISTS predicate keyed on artist.id, backed by a new covering index on library_artist(artist_id, library_id). EXISTS keeps artist as the ordered driver and never fans out rowids, so Phase 1 stays a plain ordered scan that LIMIT/OFFSET can short-circuit. Admin, headless, and all-libraries users skip the filter entirely (the dominant case) for a flat ordered walk over the primary key. Measured on a 300k-artist / 1M-song library: admin/all-libs pagination is ~4.5-5.4x faster at depth (~180ms to ~33ms at offset 400k); restricted subset users keep correct, gap-free pages while also getting faster. The narrowing artist filter is applied at the subsonic layer only when the request targets a strict subset of the user's libraries, so the common case (and the admin fast-path) is never burdened with a redundant predicate. * fix(subsonic): narrow artist search by library set, not count narrowsArtistLibraries decided whether to add the subsonic-layer artist narrowing filter by comparing len(requested) < len(accessible). musicFolderId is not deduplicated, so duplicate IDs inflated the requested count: a user requesting ?musicFolderId=1&musicFolderId=1&musicFolderId=2 against three accessible libraries produced len([1,1,2])==3, which is not < 3, so the filter was skipped and the user saw artists from the third library too. Compare as set membership instead: the request narrows iff some accessible library is absent from it (requested is always a subset of accessible, validated upstream by selectedMusicFolderIds). This is immune to duplicate IDs. Add a regression test that fails against the old length-based check. Also consolidate the repeated EXISTS/no-DISTINCT/O(page) rationale that the prior commit spread across five sites down to a single authoritative comment on ArtistLibraryFilter, with the call sites referencing it. * perf(subsonic): drop redundant library_artist covering index The migration added an index on library_artist(artist_id, library_id) on the theory that the restricted-subset artist-search EXISTS needed it to seek by artist_id. Benchmarking on a 405k-artist / 5-library dataset showed no benefit: the EXISTS subquery constrains both columns (artist_id = and library_id IN), so SQLite already resolves it as a covering-index seek on the existing (library_id, artist_id) UNIQUE autoindex. With the new index present the planner still picks the autoindex and ignores it. Drop the migration and correct the comment. Removing ~11MB of dead index plus its write-amplification on every library_artist insert/delete, for zero query gain. * fix(scanner): mark artists missing when they lose their last library Artist search Phase 1 filters on artist.missing and Phase 2 inner-joins library_artist, so a non-missing artist with no library_artist row (an orphan) takes a pagination slot in Phase 1 and then vanishes in Phase 2, shortening the page and shifting deep offsets. The admin/headless search fast-path walks artist unfiltered, so it is fully exposed to this. Two paths created such orphans without updating artist.missing: - RefreshStats deletes library_artist rows whose stats are '{}' (artist lost all content in a library) after every scan. This is the common source. - Library deletion cascades away the library's library_artist rows. Mark newly-orphaned artists missing at both sources, so the shared 'missing = false' search filter excludes them immediately instead of waiting for a later scan. In RefreshStats the update only runs when the cleanup actually removed rows (the only way a new orphan can appear), so steady-state scans pay nothing; measured ~160ms on 300k artists only when orphans can exist. * refactor(subsonic): address review feedback on artist search filter Code-review follow-ups to the artist search pagination change: - ArtistLibraryFilter: short-circuit to a constant-false predicate when no library IDs are given, avoiding a degenerate empty IN () subquery. - ArtistLibraryFilter: add an inner LIMIT 1 to the correlated EXISTS so SQLite cannot flatten it into a fan-out join (an artist in multiple of the user's libraries would otherwise yield duplicate rowids and corrupt pagination). - narrowsArtistLibraries: compare accessible-vs-requested as a set lookup instead of slices.Contains in a loop. - searchConfig.LibraryFilter: document that a join-free filter is now a correctness requirement (DISTINCT was removed), not just a performance one. * docs: trim verbose comments in artist search/orphan code Condense the over-explained comments added in this PR to the essential 'why', removing repeated cross-references and restatements of the adjacent code. * fix(scanner): heal pre-existing orphan artists on full refresh The orphan-marking added to RefreshStats only ran when its empty-stats cleanup deleted rows, so it reconciled newly-created orphans but not ones already left in the database by older versions (whose library_artist row was deleted before this fix existed). Such legacy orphans would surface in the admin/headless search fast-path as short/gappy pages. Also run the orphan-marking on a full refresh (allArtists), so a full scan — which upgrades commonly trigger and users can run manually — reconciles the backlog. No migration needed; the runtime fixes prevent recurrence. * perf(subsonic): extend artist search fast-path to all-library users applyLibraryFilterToSearchQuery only skipped the library filter for admin and headless processes. A regular (non-admin) user who can access every library has the same result set as an admin, but was still given the EXISTS filter — an O(offset) cost for a predicate that matches every non-missing artist anyway. Skip the filter for them too, using a cheap library CountAll() (a count over the tiny library table) compared against the user's library count. On any error it falls back to the filtered path, which is correct, just slower. * fix(scanner): log error as trailing arg, not explicit error key Signed-off-by: Deluan <deluan@navidrome.org> * test(scanner): e2e guard for orphan artists under PurgeMissing Adds an end-to-end scanner test for the orphan-artist invariant fixed in RefreshStats: with Scanner.PurgeMissing enabled, removing all of an artist's files hard-deletes them, cascades away their media_file_artists rows, and RefreshStats then drops the artist's emptied library_artist row. The test asserts no non-missing artist is left without a library_artist row. Verified it fails without the RefreshStats orphan-marking and passes with it. * test(scanner): assert the orphaned artist is marked missing The orphan e2e test only checked the aggregate no-orphan invariant (orphanCount == 0), which a fully-deleted artist or an un-cleaned row would also satisfy — so it could pass without exercising the fix. Assert Pink Floyd's row specifically: missing=false before, missing=true after, and absent from the non-missing results. Verified it fails without the RefreshStats orphan-marking. * test(scanner): drop misleading non-missing-list assertion for orphan GetAll has no default missing filter, but selectArtist inner-joins library_artist, so an orphaned artist (no junction row) is excluded from the results whether or not it is marked missing. The Not(ContainElement) check therefore passed for the wrong reason. The direct floydMissing() == 1 query is the assertion that actually validates the missing flag; keep that plus the orphan-count invariant and an over-marking guard on The Beatles. * test(scanner): document why orphan check reads the artist row directly Clarify that GetAll cannot observe the orphan: selectArtist inner-joins library_artist, so an artist with no junction row is excluded from results whether or not it is marked missing. Asserting on GetAll would pass even without the fix, so the test reads the artist row directly to check the missing flag. * test(scanner): return descriptive artist state for clearer failures floydState returns PRESENT/MISSING/NOT_FOUND instead of 0/1/-1, so a failure reads '<string>: PRESENT to equal MISSING' rather than '0 to equal 1'. * refactor(subsonic): keep artist library scoping in the repository The search endpoint built a persistence-layer EXISTS predicate (persistence.ArtistLibraryFilter) and injected it into artistOpts.Filters — the only place the subsonic package reached into persistence, leaking a storage detail up two layers. Pass the same Eq{"library_id": ids} filter used for albums and songs, and let the artist repository translate it to the join-free library_artist predicate (scopeSearchToLibraries), where the junction knowledge belongs. The subset-vs- fast-path decision moves there too, so narrowsArtistLibraries and the persistence import are gone from the subsonic layer. Behavior is unchanged; coverage for the translation moves to artist_repository_test. * refactor(persistence): extract canonical markOrphansMissing helper The 'mark non-missing artists with no library_artist row as missing' invariant was hand-written as SQL in two places (RefreshStats and libraryRepository.Delete), in two slightly different dialects (not exists vs id not in). Extract a single artistRepository.markOrphansMissing method next to markMissing and call it from both sites, so the invariant has one definition. * fix(persistence): apply scoped library filter in both search phases Two bugs from moving the artist library-scoping into the repository: - Search() scoped opts.Filters for Phase 1 but still passed the original (unscoped) options to selectArtist, so Phase 2 re-applied the raw Eq{library_id} against the wrong columns and a restricted user's search returned nothing. Pass the scoped opts to both phases. - scopeSearchToLibraries dropped the filter unconditionally for admins, so an admin explicitly narrowing via musicFolderId (e.g. search3?musicFolderId=2) leaked content from other libraries. Compare the request against the user's visible library set (all libraries for admin/headless), narrowing whenever it is a strict subset. Both regressions were caught by the server/e2e multi-library suite. * fix(core): delete library and reconcile orphans in one transaction libraryRepository.Delete runs the FK-cascade delete and the orphaned-artist reconciliation (markOrphansMissing) as two writes on r.db. Called directly they autocommit separately, so an interruption between them could leave non-missing artists with no library_artist row — the orphan state the artist search fast-path forbids. Wrap the deletion in ds.WithTx at the core wrapper so both writes commit atomically; the watcher/scanner/broker side-effects stay post-commit. * refactor(persistence): unify artist search library scoping into one filter Phase 1 previously applied two overlapping library predicates: cfg.LibraryFilter (scoped to the user's libraries) AND options.Filters (the requested subset), producing two correlated EXISTS subqueries per rowid even though the request is always a subset of the user's libraries. And the 'does this user see everything' decision was implemented twice (userHasAllLibraries via CountAll vs scopeSearchToLibraries via set-membership), with applyLibraryFilterToSearchQuery as a third scoping path. Resolve the effective library scope once in Search() via searchScope (intersect the requested set with the user's visible libraries; nil = fast-path), clear opts.Filters, and realize that single scope as the only Phase-1 LibraryFilter. The visibility logic is now one pipeline: requestedLibraryIDs + visibleLibraryIDs + userSeesAllLibraries. Behavior unchanged; one EXISTS instead of two on the hot path, one source of truth for library visibility. * fix(persistence): harden artist search against malformed library_id filter Search consumed only an Eq{"library_id": []int} filter; an Eq whose library_id value wasn't []int slipped through unconsumed and would reach Phase 1's bare artist table (no library_id column) → SQL error. Recognize any Eq carrying a library_id key (isLibraryIDFilter) and always consume it, falling back to the user's visible scope for a malformed value. Non-library filters are still left in place for doSearch. * refactor(persistence): trim redundant comments and unexport artist library filter The artist-search-pagination work left dense explanatory comments, with the join-free / LIMIT-1 anti-flatten rationale and the orphan-artist mechanics each restated in several places. Consolidate each rationale into one canonical home (artistLibraryFilter for the EXISTS/LIMIT-1 trick, markOrphansMissing for the orphan lifecycle) and have the other sites reference it instead of repeating it. Also unexport ArtistLibraryFilter to artistLibraryFilter: its only caller is searchCfg in the same package and no test references it, so it never needed to be part of the package's exported surface. Comments only plus the rename; no behavior change. * refactor: add slice.ToSet and use it for the artist search subset check searchScope's subset test compared the requested libraries against the visible set with a nested slices.Contains, which is O(visible * requested). On an instance with many libraries (e.g. 100 libraries, a user granted 99) and an explicit musicFolderId request, that is ~9.8k comparisons; with a set it is ~200. Add a small reusable slice.ToSet helper (a slice -> map[T]struct{} set, collapsing duplicates) and use it to make the membership lookups O(1), restoring O(n+m) without the throwaway struct{}{} literal that an inline ToMap would need. No behavior change. * refactor(artist): move artistLibraryFilter to artist_repository Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/library.go | 6 +- persistence/artist_repository.go | 162 ++++++++++++++++--- persistence/artist_repository_test.go | 205 +++++++++++++++++++++++++ persistence/library_repository.go | 5 + persistence/library_repository_test.go | 46 ++++++ persistence/sql_search.go | 12 +- scanner/scanner_test.go | 64 ++++++++ server/subsonic/searching.go | 2 +- server/subsonic/searching_test.go | 44 +++--- utils/slice/slice.go | 10 ++ utils/slice/slice_test.go | 14 ++ 11 files changed, 515 insertions(+), 55 deletions(-) diff --git a/core/library.go b/core/library.go index 0bf3be9fa..365dcbd4c 100644 --- a/core/library.go +++ b/core/library.go @@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { return r.mapError(err) } - err = r.LibraryRepository.Delete(libID) + // Run the deletion in a transaction so the cascade delete and the orphaned-artist + // reconciliation it triggers (see libraryRepository.Delete) commit atomically. + err = r.ds.WithTx(func(tx model.DataStore) error { + return tx.Library(r.ctx).Delete(libID) + }, "delete library") if err != nil { return r.mapError(err) } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index aa3bc0776..56843b911 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -353,6 +353,19 @@ func (r *artistRepository) purgeEmpty() error { return nil } +// markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the +// search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can +// be dropped: RefreshStats cleanup and library deletion cascade. +func (r *artistRepository) markOrphansMissing() error { + _, err := r.executeSQL(Expr( + "update artist set missing = true where missing = false " + + "and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)")) + if err != nil { + return fmt.Errorf("marking orphaned artists missing: %w", err) + } + return nil +} + // markMissing marks artists as missing if all their albums are missing. func (r *artistRepository) markMissing() error { q := Expr(` @@ -527,57 +540,156 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { totalRowsAffected += rowsAffected } - // // Remove library_artist entries for artists that no longer have any content in any library + // Remove library_artist entries for artists that no longer have any content in a library. cleanupSQL := Delete("library_artist").Where("stats = '{}'") cleanupRows, err := r.executeSQL(cleanupSQL) if err != nil { - log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err) - } else if cleanupRows > 0 { - log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", err) + } else { + if cleanupRows > 0 { + log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + } + // Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan + // also heals any left by older versions. + if cleanupRows > 0 || allArtists { + if err := r.markOrphansMissing(); err != nil { + log.Warn(r.ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) + } + } } log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } -// applyLibraryFilterToSearchQuery is applyLibraryFilterToArtistQuery with the join order -// pinned via CROSS JOIN (SQLite's explicit join-order override): the search Phase 1 paginates -// rowids by artist.id, and when the planner drives from library_artist it must sort every -// junction row on every page (temp b-tree over the whole table). Keeping artist as the outer -// table streams rows in artist.id order from its primary key index, so LIMIT/OFFSET -// short-circuits. Search-only: other artist queries keep the planner's freedom. -func (r *artistRepository) applyLibraryFilterToSearchQuery(query SelectBuilder) SelectBuilder { - user := loggedUser(r.ctx) - query = query.CrossJoin("library_artist on library_artist.artist_id = artist.id") - if user.ID != invalidUserId && !user.IsAdmin { - query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) - } - return query -} - -func (r *artistRepository) searchCfg() searchConfig { +// searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must +// restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope]. +func (r *artistRepository) searchCfg(scope []int) searchConfig { return searchConfig{ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist - NaturalOrder: "artist.id", - OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, - MBIDFields: []string{"mbz_artist_id"}, - LibraryFilter: r.applyLibraryFilterToSearchQuery, + NaturalOrder: "artist.id", + OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, + MBIDFields: []string{"mbz_artist_id"}, + // scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing). + // Otherwise the join-free [artistLibraryFilter]. + LibraryFilter: func(query SelectBuilder) SelectBuilder { + if scope == nil { + return query + } + return query.Where(artistLibraryFilter(scope)) + }, } } +// artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the +// library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN +// would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops +// SQLite from flattening the EXISTS back into a fan-out join, while still using the +// (library_id, artist_id) UNIQUE autoindex. +func artistLibraryFilter(libraryIDs []int) Sqlizer { + if len(libraryIDs) == 0 { + return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery + } + sub, args, _ := Select("1").From("library_artist"). + Where(And{ + Expr("library_artist.artist_id = artist.id"), + Eq{"library_artist.library_id": libraryIDs}, + }).Limit(1).ToSql() + return Expr("EXISTS ("+sub+")", args...) +} + func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } + // Artists have no library_id column, so the library_id filter callers pass (same as albums/songs) + // can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg). + scope := r.searchScope(opts.Filters) + if isLibraryIDFilter(opts.Filters) { + opts.Filters = nil + } var res dbArtists - err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts) + err := r.doSearch(r.selectArtist(opts), q, &res, r.searchCfg(scope), opts) if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } return res.toModels(), nil } +// searchScope returns the library IDs the search must be restricted to, or nil to skip the filter +// entirely (the fast-path: the user sees everything the search could return, so a filter would be +// pure O(offset) overhead). It intersects the requested libraries with what the user can see. +func (r *artistRepository) searchScope(filter Sqlizer) []int { + visible, err := r.visibleLibraryIDs() + if err != nil { + return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen + } + requested := r.requestedLibraryIDs(filter) + if requested == nil { + // No explicit request: scope to the visible set, unless the user sees everything. + if r.userSeesAllLibraries(visible) { + return nil + } + return visible + } + // Narrow unless the request already covers everything the user can see. Compare by membership, + // not length: the requested IDs may contain duplicates. + requestedSet := slice.ToSet(requested) + if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) { + return requested + } + return nil +} + +// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is +// not that shape. +func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int { + eq, ok := filter.(Eq) + if !ok { + return nil + } + ids, _ := eq["library_id"].([]int) + return ids +} + +// isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can +// consume it before it reaches the bare artist table (which has no library_id column). +func isLibraryIDFilter(filter Sqlizer) bool { + eq, ok := filter.(Eq) + if !ok { + return false + } + _, ok = eq["library_id"] + return ok +} + +// userSeesAllLibraries reports whether the visible set already covers every library, so a search +// needs no library filter at all. +func (r *artistRepository) userSeesAllLibraries(visible []int) bool { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + return true // visible is the whole library table + } + total, err := NewLibraryRepository(r.ctx, r.db).CountAll() + if err != nil || total == 0 { + return false + } + return int64(len(visible)) >= total +} + +// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and +// headless processes, otherwise the user's granted libraries. +func (r *artistRepository) visibleLibraryIDs() ([]int, error) { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + var ids []int + err := r.queryAllSlice(Select("id").From("library"), &ids) + return ids, err + } + return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil +} + func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 7003efec3..603c5dd5e 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -111,6 +111,80 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("searchScope", func() { + // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), + // the way Search() does, for a repo whose context carries the given user. + scope := func(user model.User, filter squirrel.Sqlizer) []int { + ctx := request.WithUser(GinkgoT().Context(), user) + r := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + return r.searchScope(filter) + } + subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}} + + It("scopes to a strict subset of the user's libraries", func() { + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2}})).To(Equal([]int{1, 2})) + }) + + It("treats duplicate IDs as a set so a real subset still narrows", func() { + // {1,1,2} has 3 entries but is a strict subset of the user's 3 libraries. + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 1, 2}})).To(Equal([]int{1, 1, 2})) + }) + + It("returns nil (fast-path) when the request covers all the user's libraries", func() { + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2, 3}})).To(BeNil()) + }) + + It("scopes to the user's libraries when no library filter is given", func() { + // A restricted user (strictly fewer libs than exist) with no musicFolderId is still + // confined to their granted libs. Build the user with total-1 libraries derived from + // the real DB total, so the "sees all" fast-path can't kick in regardless of count. + total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">", 0)) + libs := make(model.Libraries, 0, total-1) + for i := int64(1); i < total; i++ { // total-1 distinct libraries → a strict subset + libs = append(libs, model.Library{ID: int(i)}) + } + restricted := model.User{ID: "r", Libraries: libs} + got := scope(restricted, nil) + Expect(got).To(HaveLen(int(total) - 1)) + }) + + It("returns nil (fast-path) for an admin requesting all existing libraries", func() { + // Admins see every library, so the visible set is the whole library table — derive + // it from the DB rather than assuming a count. + var allLibs []int + Expect(NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).(*libraryRepository). + queryAllSlice(squirrel.Select("id").From("library"), &allLibs)).To(Succeed()) + admin := model.User{ID: "a", IsAdmin: true} + Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil()) + Expect(scope(admin, nil)).To(BeNil()) + }) + + It("narrows for an admin explicitly requesting a subset via musicFolderId", func() { + // An admin scoping to a single, non-existent-as-the-whole-set library must still be + // narrowed (regression: search3?musicFolderId=lib2 was leaking lib1 content). + admin := model.User{ID: "a", IsAdmin: true} + Expect(scope(admin, squirrel.Eq{"library_id": []int{-1}})).To(Equal([]int{-1})) + }) + + It("returns nil for a non-library_id filter (no library scoping requested)", func() { + // Such a filter carries no library intent; for this fully-granted-style user the + // search needs no extra library restriction. + allUser := model.User{ID: "u2", IsAdmin: true} + Expect(scope(allUser, squirrel.Eq{"name": "x"})).To(BeNil()) + }) + + It("falls back to the visible scope for a malformed library_id value (no crash)", func() { + // A library_id filter whose value isn't []int is still recognized as a library + // filter (so Search consumes it and it never reaches the bare artist table), and + // searchScope falls back to exactly the no-filter behavior rather than crashing. + malformed := squirrel.Eq{"library_id": "not-a-slice"} + Expect(isLibraryIDFilter(malformed)).To(BeTrue()) + Expect(scope(subsetUser, malformed)).To(Equal(scope(subsetUser, nil))) + }) + }) + Describe("dbArtist mapping", func() { var ( artist *model.Artist @@ -653,6 +727,38 @@ var _ = Describe("ArtistRepository", func() { _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) } }) + + It("paginates a restricted user's visible artists without gaps", func() { + // ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands + // inside the restricted user's visible range — exercising the no-gap guarantee. + lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"} + Expect(repo.Put(&lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + DeferCleanup(func() { + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + } + }) + + all, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 1)) + for _, a := range all { + Expect(a.ID).ToNot(Equal(lib2Artist.ID)) + } + + var paged model.Artists + for offset := range len(all) { + page, err := restrictedRepo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset)) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID)) + } + }) }) Context("Headless Processes (No User Context)", func() { @@ -891,6 +997,45 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) + + It("takes the unfiltered fast-path when the user can access every library", func() { + // The fixture DB has a single library and the user was granted it, so it has access + // to all libraries: search results must match what an admin sees. + adminRepo := NewArtistRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + adminAll, err := adminRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + userAll, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + ids := func(artists model.Artists) []string { + out := make([]string, len(artists)) + for i, a := range artists { + out[i] = a.ID + } + return out + } + Expect(ids(userAll)).To(Equal(ids(adminAll))) + Expect(userAll).ToNot(BeEmpty()) + }) + + It("detects all-library access regardless of result equivalence", func() { + // userSeesAllLibraries drives the search fast-path for a non-admin: true when the + // visible-library count reaches the DB total. Derive the total from the DB so the + // assertion doesn't depend on how many libraries other specs left behind. + raw := restrictedRepo.(*artistRepository) // context carries a non-admin user + total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">", 0)) + + allLibs := make([]int, total) + for i := range allLibs { + allLibs[i] = i + 1 + } + Expect(raw.userSeesAllLibraries(allLibs)).To(BeTrue()) + Expect(raw.userSeesAllLibraries(allLibs[:total-1])).To(BeFalse()) + Expect(raw.userSeesAllLibraries([]int{})).To(BeFalse()) + }) }) }) @@ -976,6 +1121,66 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Describe("RefreshStats", func() { + var repo *artistRepository + + missing := func(id string) bool { + var vals []bool + Expect(repo.queryAllSlice(squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed()) + Expect(vals).To(HaveLen(1)) + return vals[0] + } + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + }) + + It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() { + // A library_artist row with stats '{}' (no content) gets deleted by the cleanup, + // which would orphan this non-missing artist. + emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"} + Expect(repo.Put(&emptyArtist)).To(Succeed()) + _, err := repo.executeSQL(squirrel.Insert("library_artist"). + SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"})) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _, _ = repo.executeSQL(squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) + _ = repo.delete(squirrel.Eq{"id": emptyArtist.ID}) + }) + + Expect(missing(emptyArtist.ID)).To(BeFalse()) + + _, err = repo.RefreshStats(true) + Expect(err).ToNot(HaveOccurred()) + + Expect(missing(emptyArtist.ID)).To(BeTrue()) + var orphanIDs []string + Expect(repo.queryAllSlice(squirrel.Select("id").From("artist"). + Where("missing = false"). + Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed()) + Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID)) + }) + + It("heals a pre-existing orphan (no library_artist row) on a full refresh", func() { + // A legacy orphan left by an older version: non-missing, with no library_artist row at + // all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still + // reconcile it. + legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"} + Expect(repo.Put(&legacyOrphan)).To(Succeed()) + DeferCleanup(func() { + _ = repo.delete(squirrel.Eq{"id": legacyOrphan.ID}) + }) + + Expect(missing(legacyOrphan.ID)).To(BeFalse()) + + _, err := repo.RefreshStats(true) + Expect(err).ToNot(HaveOccurred()) + + Expect(missing(legacyOrphan.ID)).To(BeTrue()) + }) + }) }) // Helper function to create an artist with proper library association. diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 1d8e6f35e..3789a71c9 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -261,6 +261,11 @@ func (r *libraryRepository) Delete(id int) error { return err } + // The cascade above can drop an artist's last library_artist row; reconcile any such orphans. + if err := NewArtistRepository(r.ctx, r.db).(*artistRepository).markOrphansMissing(); err != nil { + return fmt.Errorf("marking orphaned artists missing after deleting library %d: %w", id, err) + } + // Clear cache entry for this library only if DB operation was successful libLock.Lock() defer libLock.Unlock() diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index de7161643..1743df209 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -206,4 +207,49 @@ var _ = Describe("LibraryRepository", func() { }) }) }) + + Describe("Delete", func() { + var adminRepo model.LibraryRepository + var artistRepo model.ArtistRepository + + artistMissing := func(id string) bool { + var missing bool + err := conn.NewQuery("SELECT missing FROM artist WHERE id = {:id}"). + Bind(dbx.Params{"id": id}).Row(&missing) + Expect(err).ToNot(HaveOccurred()) + return missing + } + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + adminRepo = NewLibraryRepository(adminCtx, conn) + artistRepo = NewArtistRepository(adminCtx, conn) + }) + + It("marks artists orphaned by the delete as missing", func() { + lib := model.Library{Name: "Doomed Library", Path: "/doomed"} + Expect(adminRepo.Put(&lib)).To(Succeed()) + + orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"} + sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"} + Expect(artistRepo.Put(&orphanArtist)).To(Succeed()) + Expect(artistRepo.Put(&sharedArtist)).To(Succeed()) + Expect(adminRepo.AddArtist(lib.ID, orphanArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(lib.ID, sharedArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(1, sharedArtist.ID)).To(Succeed()) + DeferCleanup(func() { + if raw, ok := artistRepo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete("artist"). + Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}})) + } + }) + + Expect(artistMissing(orphanArtist.ID)).To(BeFalse()) + + Expect(adminRepo.Delete(lib.ID)).To(Succeed()) + + Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing") + Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible") + }) + }) }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 19cbaf24f..3049baae7 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -21,10 +21,9 @@ type searchConfig struct { NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid") OrderBy []string // ORDER BY for text search results (e.g. ["name"]) MBIDFields []string // columns to match when query is a UUID - // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1 of - // two-phase searches (FTS and empty-query). Needed when library access goes through a - // junction table (e.g. artist → library_artist), whose JOIN can fan out rowids for - // entities in multiple libraries — Phase 1 dedups whenever this is set. + // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1, for entities whose + // library access goes through a junction table (e.g. artist → library_artist). It MUST be join-free + // (Phase 1 has no DISTINCT, so a fan-out JOIN would corrupt offset pagination). See [artistLibraryFilter]. LibraryFilter func(sq SelectBuilder) SelectBuilder } @@ -102,10 +101,7 @@ func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore rowidQuery = rowidQuery.Offset(uint64(options.Offset)) } if cfg.LibraryFilter != nil { - // Junction-table library filters can repeat rowids for entities in multiple - // libraries, which would corrupt offset-based pagination — dedup before paginating. - // (DISTINCT, not GROUP BY: bm25() can't be evaluated in a grouped query.) - rowidQuery = cfg.LibraryFilter(rowidQuery).Distinct() + rowidQuery = cfg.LibraryFilter(rowidQuery) } else { rowidQuery = r.applyLibraryFilter(rowidQuery) } diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 7bf91d64f..cc3732bc3 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -2,6 +2,7 @@ package scanner_test import ( "context" + "database/sql" "errors" "path/filepath" "testing/fstest" @@ -531,6 +532,69 @@ var _ = Describe("Scanner", Ordered, func() { })).To(Equal(int64(2))) }) + It("leaves no non-missing orphan artist after purging an artist's only content", func() { + // Guards the orphan case: with PurgeMissing on, removing an artist's last file hard-deletes + // its media_file_artists rows, RefreshStats recomputes its stats to '{}', and the cleanup + // drops its last library_artist row — leaving the artist row alive but orphaned. RefreshStats + // must then mark it missing (see markOrphansMissing). + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways + + By("Starting from a library where Pink Floyd has its own single album") + floyd := template(_t{"artist": "Pink Floyd", "album": "The Wall", "year": 1979}) + fsys = createFS(fstest.MapFS{ + "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), + "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), + "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), + "Pink Floyd/The Wall/01 - Another Brick.mp3": floyd(track(1, "Another Brick in the Wall")), + }) + Expect(runScanner(ctx, true)).To(Succeed()) + + nonMissingArtists := func() []string { + aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"missing": false}}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(aa, func(a model.Artist) string { return a.Name }) + } + orphanCount := func() int64 { + var n int64 + Expect(db.Db().QueryRowContext(ctx, + "SELECT count(*) FROM artist WHERE missing = false "+ + "AND id NOT IN (SELECT artist_id FROM library_artist)").Scan(&n)).To(Succeed()) + return n + } + // Read the artist row directly: selectArtist inner-joins library_artist, so an orphan never + // surfaces through the repository. Returns a descriptive string for clear test failures. + floydState := func() string { + var m bool + err := db.Db().QueryRowContext(ctx, + "SELECT missing FROM artist WHERE name = 'Pink Floyd'").Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return "NOT_FOUND" + } + Expect(err).ToNot(HaveOccurred()) + if m { + return "MISSING" + } + return "PRESENT" + } + + By("Confirming Pink Floyd is visible after the import, with no orphan") + Expect(nonMissingArtists()).To(ContainElement("Pink Floyd")) + Expect(floydState()).To(Equal("PRESENT")) + Expect(orphanCount()).To(BeZero()) + + By("Removing all of Pink Floyd's files and rescanning") + fsys.Remove("Pink Floyd/The Wall/01 - Another Brick.mp3") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Checking Pink Floyd's row survives but is marked missing, leaving no orphan") + Expect(floydState()).To(Equal("MISSING")) + Expect(orphanCount()).To(BeZero()) + // The Beatles keep their content, so the fix must not over-mark them. + Expect(nonMissingArtists()).To(ContainElement("The Beatles")) + }) + It("does not override artist fields when importing an undertagged file", func() { By("Making sure artist in the DB contains MBID and sort name") aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{ diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index fd7e29587..5d4989ae5 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -74,7 +74,7 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI if len(musicFolderIds) > 0 { songOpts.Filters = Eq{"library_id": musicFolderIds} albumOpts.Filters = Eq{"library_id": musicFolderIds} - artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds} + artistOpts.Filters = Eq{"library_id": musicFolderIds} } // Run searches in parallel diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 9a9c6af6f..4e72bd2e6 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -39,12 +39,17 @@ var _ = Describe("Search", func() { } Describe("Search2", func() { - It("should accept musicFolderId parameter", func() { + It("scopes all entity types to the requested libraries", func() { + // The subsonic layer passes the same library_id filter to all three repos; the + // artist repository translates it to the join-free library_artist predicate itself. r := newGetRequest("query=test", "musicFolderId=1") ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, }) r = r.WithContext(ctx) @@ -54,14 +59,13 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult2).ToNot(BeNil()) - // Verify that library filter was applied to all repositories assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) }) - It("should return results from all accessible libraries when musicFolderId is not provided", func() { - r := newGetRequest("query=test") + It("applies no library filter when musicFolderId is not provided", func() { + r := newGetRequest("query=test") // no musicFolderId → all accessible libraries ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -79,10 +83,9 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult2).ToNot(BeNil()) - // Verify that library filter was applied to all repositories with all accessible libraries assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) }) It("should return empty results when user has no accessible libraries", func() { @@ -122,12 +125,15 @@ var _ = Describe("Search", func() { }) Describe("Search3", func() { - It("should accept musicFolderId parameter", func() { + It("scopes all entity types to the requested libraries", func() { r := newGetRequest("query=test", "musicFolderId=1") ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, }) r = r.WithContext(ctx) @@ -137,14 +143,13 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify that library filter was applied to all repositories assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) }) - It("should return results from all accessible libraries when musicFolderId is not provided", func() { - r := newGetRequest("query=test") + It("applies no library filter when musicFolderId is not provided", func() { + r := newGetRequest("query=test") // no musicFolderId → all accessible libraries ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -162,10 +167,9 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify that library filter was applied to all repositories with all accessible libraries assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) }) It("should return empty results when user has no accessible libraries", func() { diff --git a/utils/slice/slice.go b/utils/slice/slice.go index e87ac5388..73537c8f8 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -42,6 +42,16 @@ func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[ return m } +// ToSet builds a set (a map keyed by the slice's elements) for O(1) membership tests. Duplicate +// elements collapse to a single key. +func ToSet[T comparable](s []T) map[T]struct{} { + m := make(map[T]struct{}, len(s)) + for _, item := range s { + m[item] = struct{}{} + } + return m +} + func CompactByFrequency[T comparable](list []T) []T { counters := make(map[T]int) for _, item := range list { diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 64cb89d53..27548d693 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -81,6 +81,20 @@ var _ = Describe("Slice Utils", func() { }) }) + Describe("ToSet", func() { + It("returns empty set for an empty input", func() { + Expect(slice.ToSet([]int{})).To(BeEmpty()) + }) + + It("builds a set with one key per distinct element", func() { + result := slice.ToSet([]int{1, 2, 2, 3, 3, 3}) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKey(1)) + Expect(result).To(HaveKey(2)) + Expect(result).To(HaveKey(3)) + }) + }) + Describe("CompactByFrequency", func() { It("returns empty slice for an empty input", func() { Expect(slice.CompactByFrequency([]int{})).To(BeEmpty()) From 6abc2ed517329ed3f744170623904dbb6719336a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 18 Jun 2026 08:57:45 -0400 Subject: [PATCH 296/596] fix(transcoding): preserve source metadata when transcoding downloads (#5628) * fix(transcoding): preserve source metadata when transcoding downloads Default transcoding commands used `-map 0:a:0` with no metadata mapping, so transcoded files lost all source tags (title, artist, album, etc.). Downloads in the original format were unaffected because the file is copied byte-for-byte. Add `-map_metadata 0 -map_metadata 0:s:0` to the default commands. Both flags are required: `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and `-map_metadata 0:s:0` copies stream-level tags (OPUS/OGG sources), which store tags at different levels. The flags are added in three coordinated places, since for users on the default command the args are built programmatically (buildDynamicArgs) rather than from the stored command string: - consts.go default commands, for new installations - buildDynamicArgs, the active path for default-command users - a migration updating only rows that still hold the exact old default, so customized commands are left untouched AAC is included for consistency but remains a no-op: its `-f adts` container cannot hold metadata, and the MP4 alternative breaks pipe streaming. Fixes #5623 * fix(transcoding): target audio stream for metadata and propagate ctx Address review feedback on the metadata-preservation change: - Use `-map_metadata 0:s:a:0` instead of `0:s:0` to copy tags from the first audio stream specifically. When a source has embedded cover art exposed as a video stream at index 0 (common in music files), `0:s:0` pulls the image stream's metadata and the audio tags are lost. Verified empirically with ffmpeg 7.1.3: a source with video at stream 0 and a tagged audio stream loses its title under `0:s:0` but keeps it under `0:s:a:0`; audio-only OPUS/MP3/FLAC sources are unaffected by the change. - Propagate the migration context via `tx.ExecContext(ctx, ...)` instead of discarding it, so the migration honors cancellation/timeouts. Claude-Session: https://claude.ai/code/session_015iFHDzX53wCKt11qFHMeZk --- consts/consts.go | 8 +-- core/ffmpeg/ffmpeg.go | 8 +++ core/ffmpeg/ffmpeg_test.go | 14 ++-- ...09_add_metadata_to_default_transcodings.go | 64 +++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 db/migrations/20260618120509_add_metadata_to_default_transcodings.go diff --git a/consts/consts.go b/consts/consts.go index 4baf4610d..3795b590a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -156,25 +156,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 58e9fd152..3d4cd0e72 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -403,6 +403,14 @@ func buildDynamicArgs(opts TranscodeOptions) []string { args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") + // Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC); + // -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG). + // Both are needed because the two source families store tags at different + // levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids + // pulling metadata from an embedded cover-art/video stream at index 0. Note: + // adts (AAC) output cannot hold tags, so these are a no-op there. + args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0") + if codec, ok := formatCodecMap[opts.Format]; ok { args = append(args, "-c:a", codec) } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 2e2895738..9c20e6c05 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() { Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -113,6 +113,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "256k", "-ar", "48000", @@ -132,6 +133,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-ar", "48000", "-v", "0", @@ -149,6 +151,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libopus", "-b:a", "128k", "-v", "0", @@ -169,6 +172,7 @@ var _ = Describe("ffmpeg", func() { "-ss", "30", "-i", "/music/file.mp3", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "192k", "-v", "0", @@ -186,6 +190,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "aac", "-b:a", "256k", "-v", "0", @@ -203,6 +208,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-sample_fmt", "s32", "-v", "0", diff --git a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go new file mode 100644 index 000000000..2186cda91 --- /dev/null +++ b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go @@ -0,0 +1,64 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMetadataToDefaultTranscodings, downAddMetadataToDefaultTranscodings) +} + +// metadataPairs maps the current default commands (no metadata mapping) to the +// new defaults that preserve source tags. Index 0 = old, index 1 = new. +// +// The new commands add `-map_metadata 0 -map_metadata 0:s:a:0` after `-map 0:a:0`: +// `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and +// `-map_metadata 0:s:a:0` copies tags from the first audio stream (OPUS/OGG +// sources); both are needed because the two source families store tags at +// different levels. Targeting the audio stream explicitly avoids pulling +// metadata from an embedded cover-art/video stream at index 0. +// +// AAC is included for consistency, but its `-f adts` container cannot hold tags, +// so the flags are a no-op there. +// +// Only rows still holding the exact unmodified default are updated, so any +// user-customized command is left untouched. +var metadataPairs = [][2]string{ + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} From 32ac53dc9f7270828535c719b716b08cc481a3eb Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 18 Jun 2026 09:50:26 -0400 Subject: [PATCH 297/596] refactor(migrations): propagate context.Context through all DB calls Thread the context.Context that goose.UpContext already passes into every migration through to all DB calls: tx.Exec/Query/QueryRow become tx.ExecContext/QueryContext/QueryRowContext with ctx. The shared helpers in migration.go (notice, forceFullRescan, isDBInitialized) gain a ctx parameter and all call sites are updated. No-op migration functions use blank params (_ context.Context, _ *sql.Tx). This is a behavior-preserving change: the SQL, arguments, and ordering of every migration are unchanged; only cancellation/deadline propagation is added. Add a forbidigo lint rule scoped to db/migrations/ that forbids the non-context tx.Exec/Query/QueryRow forms, preventing regression. Signed-off-by: Deluan <deluan@navidrome.org> --- .golangci.yml | 12 ++++++++ db/migrations/20200130083147_create_schema.go | 6 ++-- .../20200131183653_standardize_item_type.go | 8 +++--- ...00208222418_add_defaults_to_annotations.go | 6 ++-- ...20200220143731_change_duration_to_float.go | 8 +++--- ...0310171621_enable_search_by_albumartist.go | 8 +++--- ...81627_add_transcoding_and_player_tables.go | 8 +++--- ...319211049_merge_search_into_main_tables.go | 10 +++---- .../20200325185135_add_album_artist_id.go | 10 +++---- ...00326090707_fix_album_artists_importing.go | 8 +++--- .../20200327193744_add_year_range_to_album.go | 10 +++---- db/migrations/20200404214704_add_indexes.go | 6 ++-- ...9002249_enable_search_by_tracks_artists.go | 8 +++--- ...created_and_updated_fields_to_playlists.go | 6 ++-- ...200418110522_reindex_to_fix_album_years.go | 8 +++--- ...2708_reindex_to_change_full_text_search.go | 8 +++--- .../20200423204116_add_sort_fields.go | 10 +++---- .../20200508093059_add_artist_song_count.go | 10 +++---- .../20200512104202_add_disc_subtitle.go | 10 +++---- ...0200516140647_add_playlist_tracks_table.go | 10 +++---- .../20200608153717_referential_integrity.go | 28 +++++++++---------- ...20200706231659_add_default_transcodings.go | 6 ++-- .../20200710211442_add_playlist_path.go | 6 ++-- ...20200731095603_create_play_queues_table.go | 6 ++-- .../20200801101355_create_bookmark_table.go | 6 ++-- ...0819111809_drop_email_unique_constraint.go | 6 ++-- .../20201003111749_add_starred_at_index.go | 6 ++-- .../20201010162350_add_album_size.go | 6 ++-- ...20201012210022_add_artist_playlist_size.go | 6 ++-- db/migrations/20201021085410_add_mbids.go | 10 +++---- .../20201021093209_add_media_file_indexes.go | 6 ++-- ...01021135455_add_media_file_artist_index.go | 6 ++-- .../20201030162009_add_artist_info_table.go | 6 ++-- .../20201110205344_add_comments_and_lyrics.go | 10 +++---- .../20201128100726_add_real-path_option.go | 6 ++-- ...01213124814_add_all_artist_ids_to_album.go | 12 ++++---- .../20210322132848_add_timestamp_indexes.go | 6 ++-- .../20210418232815_fix_album_comments.go | 8 +++--- .../20210430212322_add_bpm_metadata.go | 10 +++---- .../20210530121921_create_shares_table.go | 6 ++-- .../20210601231734_update_share_fieldnames.go | 6 ++-- .../20210616150710_encrypt_all_passwords.go | 4 +-- ...1716_drop_player_name_unique_constraint.go | 6 ++-- ...add_user_prefs_player_scrobbler_enabled.go | 16 +++++------ ...add_referential_integrity_to_user_props.go | 6 ++-- .../20210626213026_add_scrobble_buffer.go | 6 ++-- .../20210715151153_add_genre_tables.go | 10 +++---- .../20210821212604_add_mediafile_channels.go | 10 +++---- .../20211008205505_add_smart_playlist.go | 6 ++-- ...023184825_add_order_title_to_media_file.go | 12 ++++---- ...1026191915_unescape_lyrics_and_comments.go | 6 ++-- .../20211029213200_add_userid_to_playlist.go | 6 ++-- ...215414_add_alphabetical_by_artist_index.go | 6 ++-- ...0211105162746_remove_invalid_artist_ids.go | 6 ++-- ...231849_add_musicbrainz_release_track_id.go | 10 +++---- .../20221219112733_add_album_image_paths.go | 10 +++---- .../20221219140528_remove_cover_art_id.go | 10 +++---- .../20230112111457_add_album_paths.go | 10 +++---- .../20230114121537_touch_playlists.go | 6 ++-- .../20230115103212_create_internet_radio.go | 6 ++-- .../20230117155559_add_replaygain_metadata.go | 10 +++---- .../20230117180400_add_album_info.go | 6 ++-- .../20230119152657_recreate_share_table.go | 6 ++-- ...230202143713_change_path_list_separator.go | 8 +++--- ...81414_change_image_files_list_separator.go | 6 ++-- .../20230310222612_add_download_to_share.go | 6 ++-- .../20230515184510_add_release_date.go | 10 +++---- ...6214944_rename_musicbrainz_recording_id.go | 8 +++--- .../20231209211223_alter_lyric_column.go | 4 +-- ...0_add_default_values_to_null_columns.go.go | 2 +- .../20240511210036_add_sample_rate.go | 2 +- .../20240629152843_remove_annotation_id.go | 2 +- .../20241026183640_support_new_scanner.go | 6 ++-- ...250611010101_playqueue_current_to_index.go | 2 +- .../20250701010101_add_folder_hash.go | 2 +- .../20250701010103_add_library_stats.go | 2 +- ...1010104_make_replaygain_fields_nullable.go | 2 +- .../20260220173400_add_fts5_search.go | 2 +- ...75815_add_codec_and_update_transcodings.go | 22 +++++++-------- .../20260309120007_fix_probe_data_null.go | 8 +++--- ...60309203355_ensure_default_transcodings.go | 8 +++--- ...0260310113858_fix_aac_transcode_command.go | 6 ++-- .../20260513173954_move_ss_before_input.go | 8 +++--- db/migrations/migration.go | 14 +++++----- 84 files changed, 327 insertions(+), 315 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 28eb375a5..76eb882ca 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,6 +13,7 @@ linters: - dogsled - durationcheck - errorlint + - forbidigo - gocritic - gocyclo - goprintffuncname @@ -36,6 +37,14 @@ linters: - G401 - G505 - G115 + forbidigo: + forbid: + - pattern: 'tx\.Exec$' + msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context" + - pattern: 'tx\.Query$' + msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context" + - pattern: 'tx\.QueryRow$' + msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context" govet: enable: - nilness @@ -45,6 +54,9 @@ linters: - gosec path: _test\.go text: "G703" + - path-except: 'db/migrations/' + linters: + - forbidigo generated: lax presets: - comments diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go index 2fae4f57d..250fb00a5 100644 --- a/db/migrations/20200130083147_create_schema.go +++ b/db/migrations/20200130083147_create_schema.go @@ -12,9 +12,9 @@ func init() { goose.AddMigrationContext(Up20200130083147, Down20200130083147) } -func Up20200130083147(_ context.Context, tx *sql.Tx) error { +func Up20200130083147(ctx context.Context, tx *sql.Tx) error { log.Info("Creating DB Schema") - _, err := tx.Exec(` + _, err := tx.ExecContext(ctx, ` create table if not exists album ( id varchar(255) not null @@ -179,6 +179,6 @@ create table if not exists user return err } -func Down20200130083147(_ context.Context, tx *sql.Tx) error { +func Down20200130083147(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go index 471dc8002..bf7d9d5f7 100644 --- a/db/migrations/20200131183653_standardize_item_type.go +++ b/db/migrations/20200131183653_standardize_item_type.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200131183653, Down20200131183653) } -func Up20200131183653(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200131183653(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table search_dg_tmp ( id varchar(255) not null @@ -37,8 +37,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile'; return err } -func Down20200131183653(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Down20200131183653(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table search_dg_tmp ( id varchar(255) not null diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migrations/20200208222418_add_defaults_to_annotations.go index d058b02c3..6807c8ad2 100644 --- a/db/migrations/20200208222418_add_defaults_to_annotations.go +++ b/db/migrations/20200208222418_add_defaults_to_annotations.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200208222418, Down20200208222418) } -func Up20200208222418(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200208222418(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` update annotation set play_count = 0 where play_count is null; update annotation set rating = 0 where rating is null; create table annotation_dg_tmp @@ -51,6 +51,6 @@ create index annotation_starred return err } -func Down20200208222418(_ context.Context, tx *sql.Tx) error { +func Down20200208222418(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migrations/20200220143731_change_duration_to_float.go index 72b785ef8..ea5465ade 100644 --- a/db/migrations/20200220143731_change_duration_to_float.go +++ b/db/migrations/20200220143731_change_duration_to_float.go @@ -11,9 +11,9 @@ func init() { goose.AddMigrationContext(Up20200220143731, Down20200220143731) } -func Up20200220143731(_ context.Context, tx *sql.Tx) error { - notice(tx, "This migration will force the next scan to be a full rescan!") - _, err := tx.Exec(` +func Up20200220143731(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "This migration will force the next scan to be a full rescan!") + _, err := tx.ExecContext(ctx, ` create table media_file_dg_tmp ( id varchar(255) not null @@ -125,6 +125,6 @@ update media_file set updated_at = '0001-01-01'; return err } -func Down20200220143731(_ context.Context, tx *sql.Tx) error { +func Down20200220143731(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go index 373e0a475..73436c890 100644 --- a/db/migrations/20200310171621_enable_search_by_albumartist.go +++ b/db/migrations/20200310171621_enable_search_by_albumartist.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200310171621, Down20200310171621) } -func Up20200310171621(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to enable search by Album Artist!") - return forceFullRescan(tx) +func Up20200310171621(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!") + return forceFullRescan(ctx, tx) } -func Down20200310171621(_ context.Context, tx *sql.Tx) error { +func Down20200310171621(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migrations/20200310181627_add_transcoding_and_player_tables.go index 3be91ac35..ef872c4ae 100644 --- a/db/migrations/20200310181627_add_transcoding_and_player_tables.go +++ b/db/migrations/20200310181627_add_transcoding_and_player_tables.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200310181627, Down20200310181627) } -func Up20200310181627(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200310181627(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table transcoding ( id varchar(255) not null primary key, @@ -45,8 +45,8 @@ create table player return err } -func Down20200310181627(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Down20200310181627(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` drop table transcoding; drop table player; `) diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migrations/20200319211049_merge_search_into_main_tables.go index f888cdd4c..a7a6ff0f9 100644 --- a/db/migrations/20200319211049_merge_search_into_main_tables.go +++ b/db/migrations/20200319211049_merge_search_into_main_tables.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200319211049, Down20200319211049) } -func Up20200319211049(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200319211049(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add full_text varchar(255) default ''; create index if not exists media_file_full_text @@ -33,10 +33,10 @@ drop table if exists search; if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200319211049(_ context.Context, tx *sql.Tx) error { +func Down20200319211049(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migrations/20200325185135_add_album_artist_id.go index f01f2c558..01537f886 100644 --- a/db/migrations/20200325185135_add_album_artist_id.go +++ b/db/migrations/20200325185135_add_album_artist_id.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200325185135, Down20200325185135) } -func Up20200325185135(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200325185135(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add album_artist_id varchar(255) default ''; create index album_artist_album_id @@ -26,10 +26,10 @@ create index media_file_artist_album_id if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200325185135(_ context.Context, tx *sql.Tx) error { +func Down20200325185135(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200326090707_fix_album_artists_importing.go b/db/migrations/20200326090707_fix_album_artists_importing.go index c42e8c327..17afe37fe 100644 --- a/db/migrations/20200326090707_fix_album_artists_importing.go +++ b/db/migrations/20200326090707_fix_album_artists_importing.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200326090707, Down20200326090707) } -func Up20200326090707(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) +func Up20200326090707(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200326090707(_ context.Context, tx *sql.Tx) error { +func Down20200326090707(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migrations/20200327193744_add_year_range_to_album.go index 66f2b23e8..d9b048e22 100644 --- a/db/migrations/20200327193744_add_year_range_to_album.go +++ b/db/migrations/20200327193744_add_year_range_to_album.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200327193744, Down20200327193744) } -func Up20200327193744(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200327193744(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table album_dg_tmp ( id varchar(255) not null @@ -72,10 +72,10 @@ create index album_max_year if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200327193744(_ context.Context, tx *sql.Tx) error { +func Down20200327193744(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go index 6207b0a3d..8b8d8607e 100644 --- a/db/migrations/20200404214704_add_indexes.go +++ b/db/migrations/20200404214704_add_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200404214704, Down20200404214704) } -func Up20200404214704(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200404214704(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_year on media_file (year); @@ -25,6 +25,6 @@ create index if not exists media_file_track_number return err } -func Down20200404214704(_ context.Context, tx *sql.Tx) error { +func Down20200404214704(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200409002249_enable_search_by_tracks_artists.go b/db/migrations/20200409002249_enable_search_by_tracks_artists.go index 22006c8af..482341a89 100644 --- a/db/migrations/20200409002249_enable_search_by_tracks_artists.go +++ b/db/migrations/20200409002249_enable_search_by_tracks_artists.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200409002249, Down20200409002249) } -func Up20200409002249(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!") - return forceFullRescan(tx) +func Up20200409002249(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!") + return forceFullRescan(ctx, tx) } -func Down20200409002249(_ context.Context, tx *sql.Tx) error { +func Down20200409002249(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go index 266dc087d..4aa502b4b 100644 --- a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go +++ b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200411164603, Down20200411164603) } -func Up20200411164603(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200411164603(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add created_at datetime; alter table playlist @@ -23,6 +23,6 @@ update playlist return err } -func Down20200411164603(_ context.Context, tx *sql.Tx) error { +func Down20200411164603(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200418110522_reindex_to_fix_album_years.go b/db/migrations/20200418110522_reindex_to_fix_album_years.go index 22b024cea..54e03f4c6 100644 --- a/db/migrations/20200418110522_reindex_to_fix_album_years.go +++ b/db/migrations/20200418110522_reindex_to_fix_album_years.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200418110522, Down20200418110522) } -func Up20200418110522(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to fix search Albums by year") - return forceFullRescan(tx) +func Up20200418110522(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to fix search Albums by year") + return forceFullRescan(ctx, tx) } -func Down20200418110522(_ context.Context, tx *sql.Tx) error { +func Down20200418110522(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200419222708_reindex_to_change_full_text_search.go b/db/migrations/20200419222708_reindex_to_change_full_text_search.go index efeb1bb84..89e3ccee5 100644 --- a/db/migrations/20200419222708_reindex_to_change_full_text_search.go +++ b/db/migrations/20200419222708_reindex_to_change_full_text_search.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200419222708, Down20200419222708) } -func Up20200419222708(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(tx) +func Up20200419222708(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(ctx, tx) } -func Down20200419222708(_ context.Context, tx *sql.Tx) error { +func Down20200419222708(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go index 4097a9d60..a51bb2270 100644 --- a/db/migrations/20200423204116_add_sort_fields.go +++ b/db/migrations/20200423204116_add_sort_fields.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200423204116, Down20200423204116) } -func Up20200423204116(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200423204116(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add order_artist_name varchar(255) collate nocase; alter table artist @@ -57,10 +57,10 @@ create index if not exists media_file_order_artist_name if err != nil { return err } - notice(tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(ctx, tx) } -func Down20200423204116(_ context.Context, tx *sql.Tx) error { +func Down20200423204116(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200508093059_add_artist_song_count.go b/db/migrations/20200508093059_add_artist_song_count.go index aac78e698..72a47bc94 100644 --- a/db/migrations/20200508093059_add_artist_song_count.go +++ b/db/migrations/20200508093059_add_artist_song_count.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(Up20200508093059, Down20200508093059) } -func Up20200508093059(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200508093059(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add song_count integer default 0 not null; `) if err != nil { return err } - notice(tx, "A full rescan will be performed to calculate artists' song counts") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts") + return forceFullRescan(ctx, tx) } -func Down20200508093059(_ context.Context, tx *sql.Tx) error { +func Down20200508093059(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go index b3e907d8d..29734e0c0 100644 --- a/db/migrations/20200512104202_add_disc_subtitle.go +++ b/db/migrations/20200512104202_add_disc_subtitle.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(Up20200512104202, Down20200512104202) } -func Up20200512104202(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200512104202(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add disc_subtitle varchar(255); `) if err != nil { return err } - notice(tx, "A full rescan will be performed to import disc subtitles") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to import disc subtitles") + return forceFullRescan(ctx, tx) } -func Down20200512104202(_ context.Context, tx *sql.Tx) error { +func Down20200512104202(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migrations/20200516140647_add_playlist_tracks_table.go index fcaae9d8e..59265e410 100644 --- a/db/migrations/20200516140647_add_playlist_tracks_table.go +++ b/db/migrations/20200516140647_add_playlist_tracks_table.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(Up20200516140647, Down20200516140647) } -func Up20200516140647(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200516140647(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists playlist_tracks ( id integer default 0 not null, @@ -28,7 +28,7 @@ create unique index if not exists playlist_tracks_pos if err != nil { return err } - rows, err := tx.Query("select id, tracks from playlist") + rows, err := tx.QueryContext(ctx, "select id, tracks from playlist") if err != nil { return err } @@ -49,7 +49,7 @@ create unique index if not exists playlist_tracks_pos return err } - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -96,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) return nil } -func Down20200516140647(_ context.Context, tx *sql.Tx) error { +func Down20200516140647(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go index 2959237fa..c9c766f7e 100644 --- a/db/migrations/20200608153717_referential_integrity.go +++ b/db/migrations/20200608153717_referential_integrity.go @@ -11,46 +11,46 @@ func init() { goose.AddMigrationContext(Up20200608153717, Down20200608153717) } -func Up20200608153717(_ context.Context, tx *sql.Tx) error { +func Up20200608153717(ctx context.Context, tx *sql.Tx) error { // First delete dangling players - _, err := tx.Exec(` + _, err := tx.ExecContext(ctx, ` delete from player where user_name not in (select user_name from user)`) if err != nil { return err } // Also delete dangling players - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` delete from playlist where owner not in (select user_name from user)`) if err != nil { return err } // Also delete dangling playlist tracks - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` delete from playlist_tracks where playlist_id not in (select id from playlist)`) if err != nil { return err } // Add foreign key to player table - err = updatePlayer_20200608153717(tx) + err = updatePlayer_20200608153717(ctx, tx) if err != nil { return err } // Add foreign key to playlist table - err = updatePlaylist_20200608153717(tx) + err = updatePlaylist_20200608153717(ctx, tx) if err != nil { return err } // Add foreign keys to playlist_tracks table - return updatePlaylistTracks_20200608153717(tx) + return updatePlaylistTracks_20200608153717(ctx, tx) } -func updatePlayer_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table player_dg_tmp ( id varchar(255) not null @@ -77,8 +77,8 @@ alter table player_dg_tmp rename to player; return err } -func updatePlaylist_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -108,8 +108,8 @@ create index playlist_name return err } -func updatePlaylistTracks_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_tracks_dg_tmp ( id integer default 0 not null, @@ -133,6 +133,6 @@ create unique index playlist_tracks_pos return err } -func Down20200608153717(_ context.Context, tx *sql.Tx) error { +func Down20200608153717(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go index a498d32b0..e87481ae1 100644 --- a/db/migrations/20200706231659_add_default_transcodings.go +++ b/db/migrations/20200706231659_add_default_transcodings.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings) } -func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { - row := tx.QueryRow("SELECT COUNT(*) FROM transcoding") +func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding") var count int err := row.Scan(&count) if err != nil { @@ -38,6 +38,6 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go index 8abfed6cf..32cc8d034 100644 --- a/db/migrations/20200710211442_add_playlist_path.go +++ b/db/migrations/20200710211442_add_playlist_path.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath) } -func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add path string default '' not null; @@ -23,6 +23,6 @@ alter table playlist return err } -func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error { +func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migrations/20200731095603_create_play_queues_table.go index d63a1ecb9..7a27137bc 100644 --- a/db/migrations/20200731095603_create_play_queues_table.go +++ b/db/migrations/20200731095603_create_play_queues_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable) } -func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playqueue ( id varchar(255) not null primary key, @@ -32,6 +32,6 @@ create table playqueue return err } -func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { +func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go index fe68fafd7..df814d7b8 100644 --- a/db/migrations/20200801101355_create_bookmark_table.go +++ b/db/migrations/20200801101355_create_bookmark_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable) } -func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table bookmark ( user_id varchar(255) not null @@ -49,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue; return err } -func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { +func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migrations/20200819111809_drop_email_unique_constraint.go index b2dd4285c..8259ad3fe 100644 --- a/db/migrations/20200819111809_drop_email_unique_constraint.go +++ b/db/migrations/20200819111809_drop_email_unique_constraint.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint) } -func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_dg_tmp ( id varchar(255) not null @@ -38,6 +38,6 @@ alter table user_dg_tmp rename to user; return err } -func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { +func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201003111749_add_starred_at_index.go b/db/migrations/20201003111749_add_starred_at_index.go index 7ee7a283f..b46430743 100644 --- a/db/migrations/20201003111749_add_starred_at_index.go +++ b/db/migrations/20201003111749_add_starred_at_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201003111749, Down20201003111749) } -func Up20201003111749(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201003111749(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists annotation_starred_at on annotation (starred_at); `) return err } -func Down20201003111749(_ context.Context, tx *sql.Tx) error { +func Down20201003111749(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go index f1182ab6c..df1fa8ca2 100644 --- a/db/migrations/20201010162350_add_album_size.go +++ b/db/migrations/20201010162350_add_album_size.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201010162350, Down20201010162350) } -func Up20201010162350(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201010162350(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add size integer default 0 not null; create index if not exists album_size @@ -28,7 +28,7 @@ where id not null;`) return err } -func Down20201010162350(_ context.Context, tx *sql.Tx) error { +func Down20201010162350(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201012210022_add_artist_playlist_size.go b/db/migrations/20201012210022_add_artist_playlist_size.go index 4eb67f14e..1c738dd1e 100644 --- a/db/migrations/20201012210022_add_artist_playlist_size.go +++ b/db/migrations/20201012210022_add_artist_playlist_size.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201012210022, Down20201012210022) } -func Up20201012210022(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201012210022(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add size integer default 0 not null; create index if not exists artist_size @@ -40,6 +40,6 @@ update playlist set size = ifnull(( return err } -func Down20201012210022(_ context.Context, tx *sql.Tx) error { +func Down20201012210022(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go index 624bb1a67..53001fc73 100644 --- a/db/migrations/20201021085410_add_mbids.go +++ b/db/migrations/20201021085410_add_mbids.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201021085410, Down20201021085410) } -func Up20201021085410(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021085410(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add mbz_track_id varchar(255); alter table media_file @@ -49,11 +49,11 @@ alter table artist if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func Down20201021085410(_ context.Context, tx *sql.Tx) error { +func Down20201021085410(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201021093209_add_media_file_indexes.go b/db/migrations/20201021093209_add_media_file_indexes.go index f3a800949..7d6ad4965 100644 --- a/db/migrations/20201021093209_add_media_file_indexes.go +++ b/db/migrations/20201021093209_add_media_file_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201021093209, Down20201021093209) } -func Up20201021093209(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021093209(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_artist on media_file (artist); create index if not exists media_file_album_artist @@ -23,6 +23,6 @@ create index if not exists media_file_mbz_track_id return err } -func Down20201021093209(_ context.Context, tx *sql.Tx) error { +func Down20201021093209(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201021135455_add_media_file_artist_index.go b/db/migrations/20201021135455_add_media_file_artist_index.go index ca04d8a20..e8f22c3a7 100644 --- a/db/migrations/20201021135455_add_media_file_artist_index.go +++ b/db/migrations/20201021135455_add_media_file_artist_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201021135455, Down20201021135455) } -func Up20201021135455(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021135455(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_artist_id on media_file (artist_id); `) return err } -func Down20201021135455(_ context.Context, tx *sql.Tx) error { +func Down20201021135455(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migrations/20201030162009_add_artist_info_table.go index f2917ae49..e33e15c23 100644 --- a/db/migrations/20201030162009_add_artist_info_table.go +++ b/db/migrations/20201030162009_add_artist_info_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl) } -func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add biography varchar(255) default '' not null; alter table artist @@ -31,6 +31,6 @@ alter table artist return err } -func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { +func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201110205344_add_comments_and_lyrics.go b/db/migrations/20201110205344_add_comments_and_lyrics.go index 5bb17b8d0..c60917bdd 100644 --- a/db/migrations/20201110205344_add_comments_and_lyrics.go +++ b/db/migrations/20201110205344_add_comments_and_lyrics.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201110205344, Down20201110205344) } -func Up20201110205344(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201110205344(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add comment varchar; alter table media_file @@ -24,10 +24,10 @@ alter table album if err != nil { return err } - notice(tx, "A full rescan will be performed to import comments and lyrics") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to import comments and lyrics") + return forceFullRescan(ctx, tx) } -func Down20201110205344(_ context.Context, tx *sql.Tx) error { +func Down20201110205344(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201128100726_add_real-path_option.go b/db/migrations/20201128100726_add_real-path_option.go index db102dfa9..4b3f62128 100644 --- a/db/migrations/20201128100726_add_real-path_option.go +++ b/db/migrations/20201128100726_add_real-path_option.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201128100726, Down20201128100726) } -func Up20201128100726(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201128100726(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table player add report_real_path bool default FALSE not null; `) return err } -func Down20201128100726(_ context.Context, tx *sql.Tx) error { +func Down20201128100726(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migrations/20201213124814_add_all_artist_ids_to_album.go index 170497f5c..81c30d611 100644 --- a/db/migrations/20201213124814_add_all_artist_ids_to_album.go +++ b/db/migrations/20201213124814_add_all_artist_ids_to_album.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(Up20201213124814, Down20201213124814) } -func Up20201213124814(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201213124814(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add all_artist_ids varchar; @@ -25,11 +25,11 @@ create index if not exists album_all_artist_ids return err } - return updateAlbums20201213124814(tx) + return updateAlbums20201213124814(ctx, tx) } -func updateAlbums20201213124814(tx *sql.Tx) error { - rows, err := tx.Query(` +func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, ` select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ') from album a left join media_file mf on a.id = mf.album_id group by a.id `) @@ -59,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, return rows.Err() } -func Down20201213124814(_ context.Context, tx *sql.Tx) error { +func Down20201213124814(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go index 3341dd3d2..5ed250fea 100644 --- a/db/migrations/20210322132848_add_timestamp_indexes.go +++ b/db/migrations/20210322132848_add_timestamp_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo) } -func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists album_updated_at on album (updated_at); create index if not exists album_created_at @@ -29,6 +29,6 @@ create index if not exists media_file_updated_at return err } -func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { +func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go index 59067640a..3c7ed86c1 100644 --- a/db/migrations/20210418232815_fix_album_comments.go +++ b/db/migrations/20210418232815_fix_album_comments.go @@ -14,10 +14,10 @@ func init() { goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments) } -func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { +func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.Query(` - SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; + rows, err := tx.QueryContext(ctx, ` + SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; `) if err != nil { return err @@ -49,7 +49,7 @@ func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { return rows.Err() } -func downFixAlbumComments(_ context.Context, tx *sql.Tx) error { +func downFixAlbumComments(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go index 721c9e179..00a0f1447 100644 --- a/db/migrations/20210430212322_add_bpm_metadata.go +++ b/db/migrations/20210430212322_add_bpm_metadata.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata) } -func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add bpm integer; @@ -22,10 +22,10 @@ create index if not exists media_file_bpm if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error { +func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go index e9208bd69..d9e902a43 100644 --- a/db/migrations/20210530121921_create_shares_table.go +++ b/db/migrations/20210530121921_create_shares_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable) } -func upCreateSharesTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table share ( id varchar(255) not null primary key, @@ -30,6 +30,6 @@ create table share return err } -func downCreateSharesTable(_ context.Context, tx *sql.Tx) error { +func downCreateSharesTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go index 965c0186e..5a459a34c 100644 --- a/db/migrations/20210601231734_update_share_fieldnames.go +++ b/db/migrations/20210601231734_update_share_fieldnames.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames) } -func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share rename column expires to expires_at; alter table share rename column created to created_at; alter table share rename column last_visited to last_visited_at; @@ -21,6 +21,6 @@ alter table share rename column last_visited to last_visited_at; return err } -func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { +func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go index f67e3fb0a..dc8a9abd4 100644 --- a/db/migrations/20210616150710_encrypt_all_passwords.go +++ b/db/migrations/20210616150710_encrypt_all_passwords.go @@ -16,7 +16,7 @@ func init() { } func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`SELECT id, user_name, password from user;`) + rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`) if err != nil { return err } @@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error { +func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migrations/20210619231716_drop_player_name_unique_constraint.go index 200332156..734ffc340 100644 --- a/db/migrations/20210619231716_drop_player_name_unique_constraint.go +++ b/db/migrations/20210619231716_drop_player_name_unique_constraint.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint) } -func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table player_dg_tmp ( id varchar(255) not null @@ -43,6 +43,6 @@ create index if not exists player_name return err } -func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { +func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go index 5257dfab3..aa5e7a8f0 100644 --- a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go +++ b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled) } -func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { - err := upAddUserPrefs(tx) +func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { + err := upAddUserPrefs(ctx, tx) if err != nil { return err } - return upPlayerScrobblerEnabled(tx) + return upPlayerScrobblerEnabled(ctx, tx) } -func upAddUserPrefs(tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_props ( user_id varchar not null, @@ -33,13 +33,13 @@ create table user_props return err } -func upPlayerScrobblerEnabled(tx *sql.Tx) error { - _, err := tx.Exec(` +func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table player add scrobble_enabled bool default true; `) return err } -func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { +func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go index 033392d93..b2f93b4e3 100644 --- a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go +++ b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps) } -func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_props_dg_tmp ( user_id varchar not null @@ -34,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props; return err } -func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { +func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go index 1c4d0de2a..75d9d681c 100644 --- a/db/migrations/20210626213026_add_scrobble_buffer.go +++ b/db/migrations/20210626213026_add_scrobble_buffer.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer) } -func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists scrobble_buffer ( user_id varchar not null @@ -34,6 +34,6 @@ create table if not exists scrobble_buffer return err } -func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { +func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go index ab2c54239..143f9c72b 100644 --- a/db/migrations/20210715151153_add_genre_tables.go +++ b/db/migrations/20210715151153_add_genre_tables.go @@ -11,9 +11,9 @@ func init() { goose.AddMigrationContext(upAddGenreTables, downAddGenreTables) } -func upAddGenreTables(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to import multiple genres!") - _, err := tx.Exec(` +func upAddGenreTables(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to import multiple genres!") + _, err := tx.ExecContext(ctx, ` create table if not exists genre ( id varchar not null primary key, @@ -61,9 +61,9 @@ create table if not exists artist_genres if err != nil { return err } - return forceFullRescan(tx) + return forceFullRescan(ctx, tx) } -func downAddGenreTables(_ context.Context, tx *sql.Tx) error { +func downAddGenreTables(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go index 9a0988b17..ee18be01b 100644 --- a/db/migrations/20210821212604_add_mediafile_channels.go +++ b/db/migrations/20210821212604_add_mediafile_channels.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels) } -func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add channels integer; @@ -22,10 +22,10 @@ create index if not exists media_file_channels if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error { +func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go index c8ed67c47..0d2d1ad4e 100644 --- a/db/migrations/20211008205505_add_smart_playlist.go +++ b/db/migrations/20211008205505_add_smart_playlist.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist) } -func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add column rules varchar null; alter table playlist @@ -33,6 +33,6 @@ create unique index playlist_fields_idx return err } -func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { +func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migrations/20211023184825_add_order_title_to_media_file.go index ee6fc67d1..4a2ae4047 100644 --- a/db/migrations/20211023184825_add_order_title_to_media_file.go +++ b/db/migrations/20211023184825_add_order_title_to_media_file.go @@ -14,8 +14,8 @@ func init() { goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile) } -func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table main.media_file add order_title varchar null collate NOCASE; create index if not exists media_file_order_title @@ -25,12 +25,12 @@ create index if not exists media_file_order_title return err } - return upAddOrderTitleToMediaFile_populateOrderTitle(tx) + return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx) } //goland:noinspection GoSnakeCaseUsage -func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { - rows, err := tx.Query(`select id, title from media_file`) +func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, title from media_file`) if err != nil { return err } @@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { return rows.Err() } -func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { +func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migrations/20211026191915_unescape_lyrics_and_comments.go index d4ba5e194..a7969ffed 100644 --- a/db/migrations/20211026191915_unescape_lyrics_and_comments.go +++ b/db/migrations/20211026191915_unescape_lyrics_and_comments.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments) } -func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`select id, comment, lyrics, title from media_file`) +func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`) if err != nil { return err } @@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { return rows.Err() } -func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { +func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migrations/20211029213200_add_userid_to_playlist.go index e262fc205..909ea1c54 100644 --- a/db/migrations/20211029213200_add_userid_to_playlist.go +++ b/db/migrations/20211029213200_add_userid_to_playlist.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist) } -func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -56,6 +56,6 @@ create index playlist_updated_at return err } -func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { +func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go index 4ab4305d0..f786b69e7 100644 --- a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go +++ b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex) } -func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index album_alphabetical_by_artist ON album(compilation, order_album_artist_name, order_album_name) `) return err } -func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { +func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211105162746_remove_invalid_artist_ids.go b/db/migrations/20211105162746_remove_invalid_artist_ids.go index 5e078c820..8c9887dd1 100644 --- a/db/migrations/20211105162746_remove_invalid_artist_ids.go +++ b/db/migrations/20211105162746_remove_invalid_artist_ids.go @@ -11,13 +11,13 @@ func init() { goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds) } -func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id) `) return err } -func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { +func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go index 481762117..42e13a1e5 100644 --- a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go +++ b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go @@ -11,19 +11,19 @@ func init() { goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId) } -func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add mbz_release_track_id varchar(255); `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { +func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20221219112733_add_album_image_paths.go b/db/migrations/20221219112733_add_album_image_paths.go index ee9c77c8a..f8ebd40e9 100644 --- a/db/migrations/20221219112733_add_album_image_paths.go +++ b/db/migrations/20221219112733_add_album_image_paths.go @@ -11,17 +11,17 @@ func init() { goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths) } -func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table main.album add image_files varchar; `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(ctx, tx) } -func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { +func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20221219140528_remove_cover_art_id.go b/db/migrations/20221219140528_remove_cover_art_id.go index a1eaa89f9..30f86297a 100644 --- a/db/migrations/20221219140528_remove_cover_art_id.go +++ b/db/migrations/20221219140528_remove_cover_art_id.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId) } -func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album drop column cover_art_id; alter table album rename column cover_art_path to embed_art_path `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(ctx, tx) } -func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { +func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go index 2dfb9a747..2819522a1 100644 --- a/db/migrations/20230112111457_add_album_paths.go +++ b/db/migrations/20230112111457_add_album_paths.go @@ -16,15 +16,15 @@ func init() { goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths) } -func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`alter table album add paths varchar;`) +func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `alter table album add paths varchar;`) if err != nil { return err } //nolint:gosec - rows, err := tx.Query(` - select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + rows, err := tx.QueryContext(ctx, ` + select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id `) if err != nil { return err @@ -63,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string { return strings.Join(dirs, string(filepath.ListSeparator)) } -func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error { +func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go index 0f10e275c..71959b0a8 100644 --- a/db/migrations/20230114121537_touch_playlists.go +++ b/db/migrations/20230114121537_touch_playlists.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists) } -func upTouchPlaylists(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`update playlist set updated_at = datetime('now');`) +func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`) return err } -func downTouchPlaylists(_ context.Context, tx *sql.Tx) error { +func downTouchPlaylists(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go index 5c014dac2..3e0da348f 100644 --- a/db/migrations/20230115103212_create_internet_radio.go +++ b/db/migrations/20230115103212_create_internet_radio.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio) } -func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists radio ( id varchar(255) not null primary key, @@ -26,6 +26,6 @@ create table if not exists radio return err } -func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error { +func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go index d6be3b313..3aad70925 100644 --- a/db/migrations/20230117155559_add_replaygain_metadata.go +++ b/db/migrations/20230117155559_add_replaygain_metadata.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata) } -func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add rg_album_gain real; alter table media_file add @@ -26,10 +26,10 @@ alter table media_file add return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { +func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go index 5d6dd8230..750d3838f 100644 --- a/db/migrations/20230117180400_add_album_info.go +++ b/db/migrations/20230117180400_add_album_info.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo) } -func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add description varchar(255) default '' not null; alter table album @@ -29,6 +29,6 @@ alter table album return err } -func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error { +func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go index e1ae816c0..10eff31ca 100644 --- a/db/migrations/20230119152657_recreate_share_table.go +++ b/db/migrations/20230119152657_recreate_share_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo) } -func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` drop table if exists share; create table share ( @@ -37,6 +37,6 @@ create table share return err } -func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { +func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migrations/20230202143713_change_path_list_separator.go index 78b030ae4..fb5f2be1a 100644 --- a/db/migrations/20230202143713_change_path_list_separator.go +++ b/db/migrations/20230202143713_change_path_list_separator.go @@ -16,10 +16,10 @@ func init() { goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator) } -func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error { +func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.Query(` - select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + rows, err := tx.QueryContext(ctx, ` + select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id `) if err != nil { return err @@ -58,6 +58,6 @@ func upChangePathListSeparatorDirs(filePaths string) string { return strings.Join(dirs, consts.Zwsp) } -func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error { +func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migrations/20230209181414_change_image_files_list_separator.go index 7f4d4cb0e..e5dc4ab43 100644 --- a/db/migrations/20230209181414_change_image_files_list_separator.go +++ b/db/migrations/20230209181414_change_image_files_list_separator.go @@ -16,8 +16,8 @@ func init() { goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator) } -func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`select id, image_files from album`) +func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, image_files from album`) if err != nil { return err } @@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string { return strings.Join(allPaths, consts.Zwsp) } -func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { +func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go index ed2879ec3..3ee24cc77 100644 --- a/db/migrations/20230310222612_add_download_to_share.go +++ b/db/migrations/20230310222612_add_download_to_share.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare) } -func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share add downloadable bool not null default false; `) return err } -func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error { +func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go index 1141a1e74..f22bdfae8 100644 --- a/db/migrations/20230515184510_add_release_date.go +++ b/db/migrations/20230515184510_add_release_date.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear) } -func upAddRelRecYear(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add date varchar(255) default '' not null; alter table media_file @@ -41,10 +41,10 @@ alter table album return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddRelRecYear(_ context.Context, tx *sql.Tx) error { +func downAddRelRecYear(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go index 170fc264c..562a59bb5 100644 --- a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go +++ b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId) } -func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_track_id to mbz_recording_id; `) return err } -func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_recording_id to mbz_track_id; `) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index ac73fc98f..891cb9f5b 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) + rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) if err != nil { return err } @@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") + notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") return nil } diff --git a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go index a65b0aefd..518d125e4 100644 --- a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go +++ b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go @@ -558,6 +558,6 @@ create index media_file_mbz_track_id return err } -func Down20240122223340(context.Context, *sql.Tx) error { +func Down20240122223340(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20240511210036_add_sample_rate.go b/db/migrations/20240511210036_add_sample_rate.go index 619cdcffd..76b809c36 100644 --- a/db/migrations/20240511210036_add_sample_rate.go +++ b/db/migrations/20240511210036_add_sample_rate.go @@ -19,7 +19,7 @@ alter table media_file create index if not exists media_file_sample_rate on media_file (sample_rate); `) - notice(tx, "A full rescan should be performed to pick up additional tags") + notice(ctx, tx, "A full rescan should be performed to pick up additional tags") return err } diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go index b450b26d4..972932e10 100644 --- a/db/migrations/20240629152843_remove_annotation_id.go +++ b/db/migrations/20240629152843_remove_annotation_id.go @@ -61,6 +61,6 @@ create index annotation_starred_at return err } -func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error { +func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index fcbef7e4e..f5899f08f 100644 --- a/db/migrations/20241026183640_support_new_scanner.go +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -97,8 +97,8 @@ insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing; `), func() error { - notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.") - return forceFullRescan(tx) + notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.") + return forceFullRescan(ctx, tx) }, ) } @@ -314,6 +314,6 @@ alter table artist } } -func downSupportNewScanner(context.Context, *sql.Tx) error { +func downSupportNewScanner(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go index d9250eba2..1b83c0b35 100644 --- a/db/migrations/20250611010101_playqueue_current_to_index.go +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -75,6 +75,6 @@ create table playqueue_dg_tmp( return err } -func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { +func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go index e82a0749f..c350d31f5 100644 --- a/db/migrations/20250701010101_add_folder_hash.go +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -16,6 +16,6 @@ func upAddFolderHash(ctx context.Context, tx *sql.Tx) error { return err } -func downAddFolderHash(ctx context.Context, tx *sql.Tx) error { +func downAddFolderHash(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go index 8025229cc..a84758a04 100644 --- a/db/migrations/20250701010103_add_library_stats.go +++ b/db/migrations/20250701010103_add_library_stats.go @@ -43,6 +43,6 @@ update library set return err } -func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error { +func downAddLibraryStats(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go index 163608d32..c6beb2a51 100644 --- a/db/migrations/20250701010104_make_replaygain_fields_nullable.go +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -39,7 +39,7 @@ ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; return err } - notice(tx, "Fetching replaygain fields properly will require a full scan") + notice(ctx, tx, "Fetching replaygain fields properly will require a full scan") return nil } diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go index dc4cd647b..6f2bde429 100644 --- a/db/migrations/20260220173400_add_fts5_search.go +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -22,7 +22,7 @@ func stripPunct(col string) string { } func upAddFts5Search(ctx context.Context, tx *sql.Tx) error { - notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") + notice(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go index 4e8b1b7f5..f52f48440 100644 --- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -12,20 +12,20 @@ func init() { goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) } -func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { +func upAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { // Add codec column to media_file. - _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) if err != nil { return err } - _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) if err != nil { return err } // Update old AAC default (adts) to new default (ipod with fragmented MP4). // Only affects users who still have the unmodified old default command. - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", @@ -36,12 +36,12 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { // Add FLAC transcoding for existing installations that were seeded before FLAC was added. var count int - err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), "flac audio", "flac", 0, "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", @@ -52,22 +52,22 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { } // Add probe_data column for caching ffprobe results. - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) if err != nil { return err } return nil } -func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) +func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) + _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`) return err } diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go index a7e7366ed..c76d6ed1a 100644 --- a/db/migrations/20260309120007_fix_probe_data_null.go +++ b/db/migrations/20260309120007_fix_probe_data_null.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull) } -func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error { // Recreate probe_data column as NOT NULL with empty string default. // The previous migration created it with DEFAULT NULL, which causes // scan errors when reading into Go string fields. - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) return err } -func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index ab6d24952..4df66d26d 100644 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -13,7 +13,7 @@ func init() { goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings) } -func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { // Older installations may be missing default transcodings that were added // after the initial seeding (e.g., aac was added later than mp3/opus). // Insert any missing defaults without touching user-customized entries. @@ -22,12 +22,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { // but the same name. for _, t := range consts.DefaultTranscodings { var count int - err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) + err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command, ) @@ -39,6 +39,6 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go index 588137383..a4fa8fcc1 100644 --- a/db/migrations/20260310113858_fix_aac_transcode_command.go +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -11,20 +11,20 @@ func init() { goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand) } -func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error { // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+). // Switch to `-f adts` (raw AAC framing) which works reliably via pipe. // Only update rows that still have the old default command. const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -" const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -" - _, err := tx.Exec( + _, err := tx.ExecContext(ctx, "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", newCommand, oldCommand, ) return err } -func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go index c16583aa0..472ae7b43 100644 --- a/db/migrations/20260513173954_move_ss_before_input.go +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -36,18 +36,18 @@ var ssSeekPairs = [][2]string{ }, } -func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { +func upMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { for _, p := range ssSeekPairs { - if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { return err } } return nil } -func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { +func downMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { for _, p := range ssSeekPairs { - if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { return err } } diff --git a/db/migrations/migration.go b/db/migrations/migration.go index fde6f5817..9b1098af1 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -12,23 +12,23 @@ import ( ) // Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...) -func notice(tx *sql.Tx, msg string) { - if isDBInitialized(tx) { +func notice(ctx context.Context, tx *sql.Tx, msg string) { + if isDBInitialized(ctx, tx) { line := strings.Repeat("*", len(msg)+8) fmt.Printf("\n%s\nNOTICE: %s\n%s\n\n", line, msg, line) } } // Call this in migrations that requires a full rescan -func forceFullRescan(tx *sql.Tx) error { +func forceFullRescan(ctx context.Context, tx *sql.Tx) error { // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. if conf.Server.DevOptimizeDB { - _, err := tx.Exec(`ANALYZE;`) + _, err := tx.ExecContext(ctx, `ANALYZE;`) if err != nil { return err } } - _, err := tx.Exec(fmt.Sprintf(` + _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) return err @@ -44,9 +44,9 @@ var ( initialized bool ) -func isDBInitialized(tx *sql.Tx) bool { +func isDBInitialized(ctx context.Context, tx *sql.Tx) bool { once.Do(func() { - rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey) + rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey) checkErr(err) initialized = checkCount(rows) > 0 }) From ecba19a08ef5727f2b2a4033b5138ff15d6b867d Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 18 Jun 2026 15:48:29 -0400 Subject: [PATCH 298/596] fix(scanner): resolve symlinks to their target when classifying files The scanner classified a file by the name of the directory entry, so a symlink was treated as audio/image/playlist based on the link name rather than what it actually points to. Now symlinks are fully resolved (following the whole chain) and classified by the resolved target's extension, so a symlink to a non-audio file is no longer imported as a track. This also makes Scanner.FollowSymlinks apply to file symlinks, not just directory symlinks as before. The default stays true, so following symlinks to real audio files (second drives, shared folders, etc.) keeps working. Adds trace logging for symlink resolution decisions and real-fs regression tests covering multi-level symlink chains. --- scanner/walk_dir_tree.go | 49 +++++- scanner/walk_dir_tree_test.go | 211 +++++++++++++++++++++++- tests/fixtures/symlink_chain/evil1.mp3 | 1 + tests/fixtures/symlink_chain/evil2.mp3 | 1 + tests/fixtures/symlink_chain/evil3.mp3 | 1 + tests/fixtures/symlink_chain/level1.mp3 | 1 + tests/fixtures/symlink_chain/level2.mp3 | 1 + tests/fixtures/symlink_chain/level3.mp3 | 1 + 8 files changed, 261 insertions(+), 5 deletions(-) create mode 120000 tests/fixtures/symlink_chain/evil1.mp3 create mode 120000 tests/fixtures/symlink_chain/evil2.mp3 create mode 120000 tests/fixtures/symlink_chain/evil3.mp3 create mode 120000 tests/fixtures/symlink_chain/level1.mp3 create mode 120000 tests/fixtures/symlink_chain/level2.mp3 create mode 120000 tests/fixtures/symlink_chain/level3.mp3 diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index e6a694f2b..78796ac5f 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -147,12 +147,16 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC if fileInfo.ModTime().After(folder.modTime) { folder.modTime = fileInfo.ModTime() } + name, ok := resolveEntryName(ctx, job.fs, dirPath, entry) + if !ok { + continue + } switch { - case model.IsAudioFile(entry.Name()): + case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry - case model.IsValidPlaylist(entry.Name()): + case model.IsValidPlaylist(name): folder.numPlaylists++ - case model.IsImageFile(entry.Name()): + case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) } @@ -213,6 +217,45 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, return fileInfo.IsDir(), nil } +const maxSymlinkHops = 40 + +// resolveEntryName returns the name to classify the entry by, and whether to +// consider it at all. Symlinks are resolved to their final target so the caller +// classifies by the target's extension, not the link's name. Returns ok=false +// when symlinks are disabled or the target can't be resolved. +func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) { + if entry.Type()&fs.ModeSymlink == 0 { + return entry.Name(), true + } + linkPath := path.Join(dirPath, entry.Name()) + if !conf.Server.Scanner.FollowSymlinks { + log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) + return "", false + } + cur := linkPath + for hop := 0; hop < maxSymlinkHops; hop++ { + target, err := fs.ReadLink(fsys, cur) + if err != nil { + if hop == 0 { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := path.Base(cur) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved) + return resolved, true + } + if path.IsAbs(target) { + // Absolute targets are not valid fs.FS paths, so the next ReadLink fails and + // resolution stops here, leaving cur as the target to classify by name. + cur = target + } else { + cur = path.Join(path.Dir(cur), target) + } + } + log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath) + return "", false +} + // isDirReadable returns true if the directory represented by dirEnt is readable func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool { dir, err := fsys.Open(dirPath) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 42b7af7ba..95cbba88f 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -45,6 +45,10 @@ var _ = Describe("walk_dir_tree", func() { "root/d/f3.mp3": {}, "root/e/original/f1.mp3": {}, "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, + "root/f/realsong.mp3": {Data: []byte("AUDIO")}, + "root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")}, + "root/f/secret": {Data: []byte("TOPSECRET")}, + "root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")}, }, } job = &scanJob{ @@ -96,12 +100,18 @@ var _ = Describe("walk_dir_tree", func() { // Symlink specific checks if followSymlinks { Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) + Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } else { Expect(folders).ToNot(HaveKey("root/e/symlink")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } }, - Entry("with symlinks enabled", true, 7), - Entry("with symlinks disabled", false, 6), + Entry("with symlinks enabled", true, 8), + Entry("with symlinks disabled", false, 7), ) }) @@ -264,6 +274,176 @@ var _ = Describe("walk_dir_tree", func() { }) }) + Describe("resolveEntryName", func() { + var fsys fs.FS + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + fsys = fstest.MapFS{ + "dir/real.mp3": {Data: []byte("AUDIO")}, + "dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")}, + "dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")}, + "dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")}, + "dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")}, + "dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")}, + } + }) + + resolve := func(name string) (string, bool) { + entries, err := fs.ReadDir(fsys, "dir") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, "dir", e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("with symlinks enabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a direct symlink to its audio target name", func() { + name, ok := resolve("audio.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink CHAIN to the final target name", func() { + name, ok := resolve("chain.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink to a non-audio target name (so caller can reject it)", func() { + name, ok := resolve("evil.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("passwd")) + }) + It("rejects a symlink loop", func() { + _, ok := resolve("loop1.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("with symlinks disabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("skips any file symlink", func() { + _, ok := resolve("audio.mp3") + Expect(ok).To(BeFalse()) + }) + }) + }) + + Describe("symlink chain (real fs)", func() { + BeforeEach(func() { + tests.SkipOnWindows("symlink semantics") + DeferCleanup(configtest.SetupConfig()) + }) + + classify := func(fsys fs.FS, dirPath, name string) (string, bool) { + entries, err := fs.ReadDir(fsys, dirPath) + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("committed 3-level fixtures", func() { + // tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures". + var fsys fs.FS + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + wd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + fsys = os.DirFS(wd) + }) + + It("keeps a 3-level chain that resolves to real audio", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("test.mp3")) + Expect(model.IsAudioFile(name)).To(BeTrue()) + }) + + It("rejects a 3-level chain that resolves to a non-audio file", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("index.html")) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips the chain entirely when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + _, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeFalse()) + _, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("out-of-tree escape (temp dir)", func() { + var root string + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + root = GinkgoT().TempDir() + outside := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + // evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink. + Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed()) + }) + + It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() { + fsys := os.DirFS(root) + + name, ok := classify(fsys, ".", "song.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "legit.flac") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "evil.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + fsys := os.DirFS(root) + entries, err := fs.ReadDir(fsys, ".") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + _, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e) + if e.Type()&fs.ModeSymlink != 0 { + Expect(ok).To(BeFalse(), e.Name()) + } else { + Expect(ok).To(BeTrue(), e.Name()) + } + } + }) + }) + }) + Describe("isDirIgnored", func() { DescribeTable("returns expected result", func(dirName string, expected bool) { @@ -414,3 +594,30 @@ func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) { } return nil, fmt.Errorf("not a directory") } + +// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS). +func (m *mockMusicFS) ReadLink(name string) (string, error) { + mapFS := m.FS.(fstest.MapFS) + entry, ok := mapFS[name] + if !ok { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist} + } + if entry.Mode&fs.ModeSymlink == 0 { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")} + } + return string(entry.Data), nil +} + +// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS). +func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) { + mapFS := m.FS.(fstest.MapFS) + if _, ok := mapFS[name]; !ok { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} + } + f, err := m.FS.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Stat() +} diff --git a/tests/fixtures/symlink_chain/evil1.mp3 b/tests/fixtures/symlink_chain/evil1.mp3 new file mode 120000 index 000000000..79c5d6f02 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil1.mp3 @@ -0,0 +1 @@ +../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 new file mode 120000 index 000000000..56d18ad24 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil2.mp3 @@ -0,0 +1 @@ +evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 new file mode 120000 index 000000000..e1cac02e9 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil3.mp3 @@ -0,0 +1 @@ +evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 new file mode 120000 index 000000000..887033521 --- /dev/null +++ b/tests/fixtures/symlink_chain/level1.mp3 @@ -0,0 +1 @@ +../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 new file mode 120000 index 000000000..eca2115ee --- /dev/null +++ b/tests/fixtures/symlink_chain/level2.mp3 @@ -0,0 +1 @@ +level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 new file mode 120000 index 000000000..dd72f3cca --- /dev/null +++ b/tests/fixtures/symlink_chain/level3.mp3 @@ -0,0 +1 @@ +level2.mp3 \ No newline at end of file From 3a14faa033a8e9d925f353dab476472c54bf04f5 Mon Sep 17 00:00:00 2001 From: Yuuta <61791392+ranokay@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:00:58 +0300 Subject: [PATCH 299/596] feat(subsonic): add structured sidecar lyrics support with OpenSubsonic v2 karaoke cues and agent layers (#5076) Expand backend lyrics support with richer sidecar formats and upgrade the OpenSubsonic songLyrics implementation to the version 2 structured karaoke contract, while preserving version 1 behavior by default. Sidecar formats and parsing: - Add a TTML parser (core/lyrics/ttml.go): clock time, offset time, bare decimal seconds, nested timing contexts, and token-level <span> timing for word/syllable karaoke. Parses Apple Music-style metadata tracks (translation and pronunciation/transliteration) and agent metadata into per-track agents[] plus per-cue-line agentId. Hydrates missing line timing from cue timing. - Add an SRT parser (core/lyrics/srt.go). - Add a LRCLIB Lyricsfile (.yaml/.yml) parser (model/lyricsfile.go): maps per-word lines[].words[] to cues with inclusive UTF-8 byte offsets and attributes overlapping lines to synthetic voice agents so parallel vocals split correctly in the enhanced response. - Extend LRC parsing for Enhanced LRC inline <mm:ss.xx> word-timing markers. - Add UTF-8 BOM and UTF-16 LE support for TTML/LRC sidecars. - Parse the above formats from embedded tags as well as sidecar files. Source resolution: - Default lyricspriority is now ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded" so the new formats are discoverable without manual configuration. - Preserve configured source priority across duplicate media-file candidates instead of only checking the first DB match, so higher-priority sidecar lyrics on older duplicates can still win. - Raise the embedded-lyrics tag maxLength to 1 MB to fit word-timed TTML/Enhanced-LRC karaoke for a full song. OpenSubsonic songLyrics v2: - Advertise songLyrics versions [1, 2]. - With enhanced=true, getLyricsBySongId may return structuredLyrics.kind (main/translation/pronunciation), cueLine[] line-level karaoke groupings, cueLine.cue[] timed words/syllables with required UTF-8 byteStart/byteEnd, reusable structuredLyrics.agents[], and cueLine.agentId references. - Without enhanced=true, the response stays v1-compatible: no kind, no cueLine, no agents, no non-main tracks; the existing line[] payload is always populated so legacy clients keep working. Contract details: - cueLine is emitted only for synced lyrics with cue data. - Within a cueLine, cue.end is normalized all-or-none and overlaps are removed; overlaps across separate cueLines remain valid for parallel vocal layers. - Missing cue end-times are filled from the next cue or the parent line. - When cueLines share an index, the one whose agent has role "main" is first. - LyricCue.Value is serialized as XML chardata; cues with nil start are skipped rather than serialized as 0. Refactoring: - Move pure format parsers into model/ (lyrics.go, lyrics_ttml.go, lyrics_srt.go, lyrics_embedded.go, lyricsfile.go) and extract Subsonic response building into server/subsonic/lyrics.go. - Centralize lyric-kind constants and add Lyrics.EffectiveKind/IsMainKind. - Add gg.Clone helper. Spec references: https://github.com/opensubsonic/open-subsonic-api/discussions/213 https://github.com/opensubsonic/open-subsonic-api/pull/218 (songLyrics v2) https://github.com/opensubsonic/open-subsonic-api/pull/228 (cue byte offsets) --- README.md | 1 + cmd/wire_gen.go | 2 +- conf/configuration.go | 2 +- core/lyrics/lyrics.go | 104 +- core/lyrics/lyrics_test.go | 235 +++- core/lyrics/sources.go | 11 +- core/lyrics/sources_test.go | 93 +- model/lyrics.go | 376 +++++- model/lyrics_embedded.go | 55 + model/lyrics_embedded_test.go | 160 +++ model/lyrics_srt.go | 167 +++ model/lyrics_test.go | 199 +++ model/lyrics_ttml.go | 1256 ++++++++++++++++++ model/lyrics_ttml_test.go | 429 ++++++ model/lyricsfile.go | 276 ++++ model/lyricsfile_test.go | 283 ++++ model/metadata/map_mediafile.go | 8 +- model/metadata/metadata_test.go | 7 +- plugins/manager.go | 2 +- resources/mappings.yaml | 4 +- server/e2e/e2e_suite_test.go | 2 +- server/e2e/subsonic_sonic_similarity_test.go | 2 +- server/subsonic/filter/filters.go | 15 - server/subsonic/helpers.go | 42 - server/subsonic/lyrics.go | 181 +++ server/subsonic/lyrics_test.go | 618 +++++++++ server/subsonic/media_retrieval.go | 23 +- server/subsonic/media_retrieval_test.go | 189 +-- server/subsonic/opensubsonic.go | 2 +- server/subsonic/opensubsonic_test.go | 4 +- server/subsonic/responses/responses.go | 38 +- tests/fixtures/bom-test.ttml | 2 + tests/fixtures/bom-utf16-test.ttml | Bin 0 -> 414 bytes tests/fixtures/test-enhanced.lrc | 6 + tests/fixtures/test-instrumental.yaml | 6 + tests/fixtures/test-metadata.ttml | 25 + tests/fixtures/test-overlapping.yaml | 24 + tests/fixtures/test-words.yaml | 17 + tests/fixtures/test.elrc | 5 + tests/fixtures/test.srt | 7 + tests/fixtures/test.ttml | 12 + tests/fixtures/test.yaml | 12 + ui/embed.go | 2 +- utils/gg/gg.go | 10 + utils/gg/gg_test.go | 21 + 45 files changed, 4582 insertions(+), 353 deletions(-) create mode 100644 model/lyrics_embedded.go create mode 100644 model/lyrics_embedded_test.go create mode 100644 model/lyrics_srt.go create mode 100644 model/lyrics_ttml.go create mode 100644 model/lyrics_ttml_test.go create mode 100644 model/lyricsfile.go create mode 100644 model/lyricsfile_test.go create mode 100644 server/subsonic/lyrics.go create mode 100644 server/subsonic/lyrics_test.go create mode 100644 tests/fixtures/bom-test.ttml create mode 100644 tests/fixtures/bom-utf16-test.ttml create mode 100644 tests/fixtures/test-enhanced.lrc create mode 100644 tests/fixtures/test-instrumental.yaml create mode 100644 tests/fixtures/test-metadata.ttml create mode 100644 tests/fixtures/test-overlapping.yaml create mode 100644 tests/fixtures/test-words.yaml create mode 100644 tests/fixtures/test.elrc create mode 100644 tests/fixtures/test.srt create mode 100644 tests/fixtures/test.ttml create mode 100644 tests/fixtures/test.yaml diff --git a/README.md b/README.md index 0ae5bdfaf..4bc85e6a6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided - Ready to use binaries for all major platforms, including **Raspberry Pi** - Automatically **monitors your library** for changes, importing new files and reloading new metadata + - Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`) - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com) - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps) - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported** diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0939eef4d..d6ffc44d4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - lyricsLyrics := lyrics.NewLyrics(manager) + lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) diff --git a/conf/configuration.go b/conf/configuration.go index 08f12fc94..2ae6e84ca 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -776,7 +776,7 @@ func setViperDefaults() { viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") + viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 758053042..b9fb8cb74 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -4,56 +4,122 @@ import ( "context" "strings" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" ) -// Lyrics can fetch lyrics for a media file. -type Lyrics interface { +// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy +// artist/title lookup, so source-priority resolution can still reach older +// matches without turning it into an unbounded table scan. +const maxLegacyLyricsCandidates = 10 + +// Provider fetches lyrics for a single media file. It is the contract +// implemented by individual lyrics sources, such as plugins. +type Provider interface { GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) } +// Lyrics resolves lyrics for media files, honoring the configured source +// priority. +type Lyrics interface { + Provider + GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) +} + // PluginLoader discovers and loads lyrics provider plugins. type PluginLoader interface { - LoadLyricsProvider(name string) (Lyrics, bool) + LoadLyricsProvider(name string) (Provider, bool) } type lyricsService struct { + ds model.DataStore pluginLoader PluginLoader } // NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin // system is available. -func NewLyrics(pluginLoader PluginLoader) Lyrics { - return &lyricsService{pluginLoader: pluginLoader} +func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics { + return &lyricsService{ds: ds, pluginLoader: pluginLoader} } // GetLyrics returns lyrics for the given media file, trying sources in the // order specified by conf.Server.LyricsPriority. func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - var lyricsList model.LyricList - var err error + return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) +} +// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup, +// scanning a bounded window of duplicate matches so source priority still wins +// across them. +func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { + opts := songsByArtistTitleWithLyricsFirst(artist, title) + opts.Max = maxLegacyLyricsCandidates + mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts) + if err != nil { + return nil, err + } + if len(mediaFiles) == 0 { + return nil, nil + } + candidates := make([]*model.MediaFile, 0, len(mediaFiles)) + for i := range mediaFiles { + candidates = append(candidates, &mediaFiles[i]) + } + return l.getLyricsForCandidates(ctx, candidates) +} + +func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions { + return model.QueryOptions{ + Sort: "lyrics, updated_at", + Order: "desc", + Filters: And{ + Eq{"missing": false}, + Eq{"title": title}, + Or{ + persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), + }, + }, + } +} + +func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) - switch { - case strings.EqualFold(pattern, "embedded"): - lyricsList, err = fromEmbedded(ctx, mf) - case strings.HasPrefix(pattern, "."): - lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) - default: - lyricsList, err = l.fromPlugin(ctx, mf, pattern) + if pattern == "" { + continue } - if err != nil { - log.Error(ctx, "error getting lyrics", "source", pattern, err) - } + for _, mf := range mediaFiles { + if mf == nil { + continue + } - if len(lyricsList) > 0 { - return lyricsList, nil + lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) + if err != nil { + log.Error(ctx, "error getting lyrics", "source", pattern, err) + continue + } + + if len(lyricsList) > 0 { + return lyricsList, nil + } } } return nil, nil } + +func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { + switch { + case strings.EqualFold(pattern, "embedded"): + return fromEmbedded(ctx, mf) + case strings.HasPrefix(pattern, "."): + return fromExternalFile(ctx, mf, pattern) + default: + return l.fromPlugin(ctx, mf, pattern) + } +} diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 9ab732ad1..a16d04712 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -16,7 +17,7 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("sources", func() { +var _ = Describe("Lyrics", func() { var mf model.MediaFile var ctx context.Context @@ -44,6 +45,71 @@ var _ = Describe("sources", func() { }, } + elrcLyrics := model.LyricList{ + model.Lyrics{ + DisplayArtist: "ELRC Artist", + DisplayTitle: "ELRC Song", + Lang: "eng", + Line: []model.Line{ + { + Start: new(int64(1000)), + End: new(int64(3000)), + Value: "Lead words", + Cue: []model.Cue{ + { + Start: new(int64(1000)), + End: new(int64(1500)), + Value: "Lead ", + ByteStart: 0, + ByteEnd: 4, + }, + { + Start: new(int64(1500)), + End: new(int64(3000)), + Value: "words", + ByteStart: 5, + ByteEnd: 9, + }, + }, + }, + { + Start: new(int64(3000)), + Value: "Fallback line", + }, + }, + Synced: true, + }, + } + + ttmlLyrics := model.LyricList{ + model.Lyrics{ + Kind: "main", + Lang: "eng", + Line: []model.Line{ + { + Start: new(int64(18800)), + Value: "We're no strangers to love", + }, + { + Start: new(int64(22800)), + Value: "You know the rules and so do I", + }, + }, + Synced: true, + }, + model.Lyrics{ + Kind: "main", + Lang: "por", + Line: []model.Line{ + { + Start: new(int64(18800)), + Value: "Nao somos estranhos ao amor", + }, + }, + Synced: true, + }, + } + unsyncedLyrics := model.LyricList{ model.Lyrics{ Lang: "xxx", @@ -59,6 +125,25 @@ var _ = Describe("sources", func() { }, } + srtLyrics := model.LyricList{ + model.Lyrics{ + Lang: "xxx", + Line: []model.Line{ + { + Start: new(int64(18800)), + End: new(int64(22800)), + Value: "We're from subtitles", + }, + { + Start: new(int64(22801)), + End: new(int64(26000)), + Value: "Another subtitle line", + }, + }, + Synced: true, + }, + } + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -68,19 +153,100 @@ var _ = Describe("sources", func() { Lyrics: string(lyricsJson), Path: "tests/fixtures/test.mp3", } - ctx = context.Background() + ctx = GinkgoT().Context() }) DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { conf.Server.LyricsPriority = priority - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(expected)) }, Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics), Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics), - Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics)) + Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics), + Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics), + Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), + Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) + + It("resolves source priority across duplicate media files", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + embeddedJSON, err := json.Marshal(embeddedLyrics) + Expect(err).To(BeNil()) + + repo := &tests.MockMediaFileRepo{} + repo.SetData(model.MediaFiles{ + { + Lyrics: string(embeddedJSON), + Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", + }, + { + Lyrics: "[]", + Path: "tests/fixtures/test.mp3", + }, + }) + svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).To(BeNil()) + Expect(list).To(Equal(ttmlLyrics)) + }) + + It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() { + dir, err := os.MkdirTemp("", "lyrics-case-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + probe := filepath.Join(dir, "CASECHECK") + Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed()) + _, err = os.Stat(filepath.Join(dir, "casecheck")) + if err == nil { + Skip("filesystem is case-insensitive") + } + Expect(os.IsNotExist(err)).To(BeTrue()) + + conf.Server.LyricsPriority = ".LRC" + Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed()) + + svc := lyrics.NewLyrics(nil, nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]model.Line{ + {Start: new(int64(1000)), Value: "Upper suffix"}, + })) + }) + + It("falls through generic YAML sidecars that are not Lyricsfile documents", func() { + dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed()) + + conf.Server.LyricsPriority = ".yaml,.lrc" + svc := lyrics.NewLyrics(nil, nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]model.Line{ + {Start: new(int64(1000)), Value: "Fallback line"}, + })) + }) Context("Errors", func() { var RegularUserContext = XContext @@ -110,7 +276,7 @@ var _ = Describe("sources", func() { It("should fallback to embedded if an error happens when parsing file", func() { conf.Server.LyricsPriority = ".mp3,embedded" - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) @@ -119,7 +285,7 @@ var _ = Describe("sources", func() { It("should return nothing if error happens when trying to parse file", func() { conf.Server.LyricsPriority = ".mp3" - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(BeEmpty()) @@ -137,7 +303,7 @@ var _ = Describe("sources", func() { It("should return lyrics from a plugin", func() { conf.Server.LyricsPriority = "test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -147,7 +313,7 @@ var _ = Describe("sources", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mf.Lyrics = "" // No embedded lyrics mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -156,7 +322,7 @@ var _ = Describe("sources", func() { It("should skip plugin if embedded has lyrics", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // embedded wins @@ -165,7 +331,7 @@ var _ = Describe("sources", func() { It("should skip unknown plugin names gracefully", func() { conf.Server.LyricsPriority = "nonexistent-plugin,embedded" mockLoader.notFound = true - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded @@ -175,7 +341,7 @@ var _ = Describe("sources", func() { conf.Server.LyricsPriority = "MyLyricsPlugin" mockLoader.pluginName = "MyLyricsPlugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -184,12 +350,55 @@ var _ = Describe("sources", func() { It("should handle plugin error gracefully", func() { conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" mockLoader.err = fmt.Errorf("plugin error") - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded }) }) + + var _ = Describe("GetLyricsByArtistTitle", func() { + var svc lyrics.Lyrics + var repo *tests.MockMediaFileRepo + var ds *tests.MockDataStore + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded" + repo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: repo} + svc = lyrics.NewLyrics(ds, nil) + }) + + It("bounds the query to a duplicate window", func() { + repo.SetData(model.MediaFiles{}) + _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Options.Max).To(Equal(10)) + }) + + It("returns nil when no media file matches", func() { + repo.SetData(model.MediaFiles{}) + list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) + + It("resolves lyrics from the matched media files", func() { + embedded, err := model.ToLyrics("eng", "Embedded lyrics line") + Expect(err).ToNot(HaveOccurred()) + embeddedJSON, err := json.Marshal(model.LyricList{*embedded}) + Expect(err).ToNot(HaveOccurred()) + repo.SetData(model.MediaFiles{ + {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, + }) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) + }) + }) }) type mockPluginLoader struct { @@ -206,7 +415,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string { return []string{"test-lyrics-plugin"} } -func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) { if m.notFound { return nil, false } diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 82a10ca41..2962c6e5c 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -36,18 +36,19 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( return nil, err } - lyrics, err := model.ToLyrics("xxx", string(contents)) + list, err := model.ParseLyricsFile(suffix, contents) if err != nil { - log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err) + log.Error(ctx, "error parsing external lyric file", "path", externalLyric, err) return nil, err - } else if lyrics == nil { + } + + if len(list) == 0 { log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) return nil, nil } log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) - - return model.LyricList{*lyrics}, nil + return list, nil } // fromPlugin attempts to load lyrics from a plugin with the given name. diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index d1aefcb5d..002931c0c 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -61,52 +61,26 @@ var _ = Describe("sources", func() { Expect(lyrics).To(HaveLen(0)) }) - It("should return synchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + // fromExternalFile delegates format parsing to model.ParseLyricsFile; the + // per-format parser output is covered exhaustively in the model package. + // Here we only verify each suffix is read from disk and routed to a parser. + DescribeTable("should read the sidecar file and route its suffix to a parser", + func(path, suffix string, expectSynced bool) { + mf := model.MediaFile{Path: path} + lyrics, err := fromExternalFile(ctx, &mf, suffix) - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22801)), - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - Synced: true, - }, - })) - }) - - It("should return unsynchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".txt") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - Synced: false, - }, - })) - }) + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeEmpty()) + Expect(lyrics[0].Line).ToNot(BeEmpty()) + Expect(lyrics[0].Synced).To(Equal(expectSynced)) + }, + Entry(".lrc synced", "tests/fixtures/test.mp3", ".lrc", true), + Entry(".elrc enhanced", "tests/fixtures/test.mp3", ".elrc", true), + Entry(".txt plain", "tests/fixtures/test.mp3", ".txt", false), + Entry(".srt subtitles", "tests/fixtures/test.mp3", ".srt", true), + Entry(".ttml multilingual", "tests/fixtures/test.mp3", ".ttml", true), + Entry(".yaml lyricsfile", "tests/fixtures/test.mp3", ".yaml", true), + ) It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { // The function looks for <basePath-without-ext><suffix>, so we need to pass @@ -141,5 +115,34 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) + + It("should handle TTML files with UTF-8 BOM marker", func() { + mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".ttml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(1)) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) + Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line")) + }) + + It("should handle UTF-16 BE encoded TTML files", func() { + mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".ttml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(2)) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) + Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one")) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) + Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) + }) + }) }) diff --git a/model/lyrics.go b/model/lyrics.go index f75f3b11b..bf3936f46 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -2,43 +2,93 @@ package model import ( "cmp" + "fmt" "regexp" "slices" "strconv" "strings" + "unicode" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/str" ) +type Cue struct { + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` + Value string `structs:"value" json:"value"` + ByteStart int `structs:"byteStart" json:"byteStart"` + ByteEnd int `structs:"byteEnd" json:"byteEnd"` + AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"` +} + +type Agent struct { + ID string `structs:"id" json:"id"` + Role string `structs:"role" json:"role"` + Name string `structs:"name,omitempty" json:"name,omitempty"` +} + type Line struct { Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` Value string `structs:"value" json:"value"` + Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` } type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Lang string `structs:"lang" json:"lang"` - Line []Line `structs:"line" json:"line"` - Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` - Synced bool `structs:"synced" json:"synced"` + DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` + Kind string `structs:"kind,omitempty" json:"kind,omitempty"` + Lang string `structs:"lang" json:"lang"` + Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` + Line []Line `structs:"line" json:"line"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Synced bool `structs:"synced" json:"synced"` } +// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are +// the canonical wire values; keep them in sync with the spec. +const ( + LyricKindMain = "main" + LyricKindTranslation = "translation" + LyricKindPronunciation = "pronunciation" +) + // support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]` +const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` var ( // Should either be at the beginning of file, or beginning of line syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) timeRegex = regexp.MustCompile(timeRegexString) lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) ) func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } +// IsMainKind reports whether the lyric is the main track. A blank kind is an +// untyped (single-track) lyric, which the contract treats as main. +func (l Lyrics) IsMainKind() bool { + return l.EffectiveKind() == LyricKindMain +} + +// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank. +// A blank kind means an untyped (single-track) lyric, which the contract treats +// as main. +func (l Lyrics) EffectiveKind() string { + if strings.TrimSpace(l.Kind) == "" { + return LyricKindMain + } + return l.Kind +} + func ToLyrics(language, text string) (*Lyrics, error) { text = str.SanitizeText(text) @@ -105,10 +155,13 @@ func ToLyrics(language, text string) (*Lyrics, error) { } if validLine { + value, baseCues := parseEnhancedLine(priorLine) for idx := range timestamps { + startCopy := timestamps[idx] structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), }) } timestamps = nil @@ -153,10 +206,13 @@ func ToLyrics(language, text string) (*Lyrics, error) { } if validLine { + value, baseCues := parseEnhancedLine(priorLine) for idx := range timestamps { + startCopy := timestamps[idx] structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), }) } } @@ -173,13 +229,170 @@ func ToLyrics(language, text string) (*Lyrics, error) { DisplayArtist: artist, DisplayTitle: title, Lang: language, - Line: structuredLines, + Line: NormalizeCueLines(structuredLines), Offset: offset, Synced: synced, } return &lyrics, nil } +// ParseLyricsFile parses a sidecar lyrics file, dispatching on its extension to +// the matching format parser. Unknown extensions fall back to the generic +// LRC/plain-text parser. It is the single owner of the suffix→parser mapping, +// mirroring [ParseEmbedded] for tag-embedded lyrics. +func ParseLyricsFile(suffix string, contents []byte) (LyricList, error) { + var list LyricList + var err error + switch { + case strings.EqualFold(suffix, ".ttml"): + list, err = ParseTTML(contents) + case strings.EqualFold(suffix, ".srt"): + list, err = ParseSRT(contents) + case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"): + list, err = ParseLyricsfile(string(contents)) + default: + var lyric *Lyrics + lyric, err = ToLyrics("xxx", string(contents)) + if lyric != nil { + list = LyricList{*lyric} + } + } + if err != nil { + return nil, fmt.Errorf("parsing %s lyrics: %w", strings.TrimPrefix(suffix, "."), err) + } + return list, nil +} + +// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers +// and computes UTF-8 byte offsets against the final stripped line value. +func parseEnhancedLine(text string) (string, []Cue) { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return strings.TrimSpace(text), nil + } + + type segment struct { + start int64 + rawStart int + rawEnd int + } + + segments := make([]segment, 0, len(matches)) + var rawValue strings.Builder + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + continue + } + + rawStart := rawValue.Len() + rawValue.WriteString(word) + segments = append(segments, segment{ + start: timeMs, + rawStart: rawStart, + rawEnd: rawValue.Len(), + }) + } + + if len(segments) == 0 { + return strings.TrimSpace(stripEnhancedMarkers(text)), nil + } + + finalRaw := rawValue.String() + leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) + rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) + trimmedEnd := len(finalRaw) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + cues := make([]Cue, 0, len(segments)) + for _, seg := range segments { + start := seg.start + byteStart := max(seg.rawStart, leftTrimBytes) + byteEnd := min(seg.rawEnd, trimmedEnd) + if byteStart >= byteEnd { + continue + } + + cues = append(cues, Cue{ + Start: &start, + Value: finalRaw[byteStart:byteEnd], + ByteStart: byteStart - leftTrimBytes, + ByteEnd: byteEnd - leftTrimBytes - 1, + }) + } + + return strings.TrimSpace(finalRaw), cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + func parseTime(line string, match []int) (int64, error) { var hours, millis int64 var err error @@ -227,3 +440,142 @@ func parseTime(line string, match []int) (int64, error) { } type LyricList []Lyrics + +// Main returns the main-kind lyric, falling back to the first entry so untyped +// lyrics still resolve. The bool is false only when the list is empty. It is +// used to surface a single lyric through the plain-text legacy getLyrics +// endpoint, which has no notion of translation/pronunciation tracks. +func (ll LyricList) Main() (Lyrics, bool) { + if len(ll) == 0 { + return Lyrics{}, false + } + for _, l := range ll { + if l.IsMainKind() { + return l, true + } + } + return ll[0], true +} + +func NormalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line = NormalizeCueLines(lyrics.Line) + if len(lyrics.Agents) == 0 { + lyrics.Agents = nil + } + return lyrics +} + +func NormalizeCueLines(lines []Line) []Line { + if len(lines) == 0 { + return lines + } + + normalized := make([]Line, len(lines)) + copy(normalized, lines) + + for i := range normalized { + if len(normalized[i].Cue) > 0 { + normalized[i].Cue = slices.Clone(normalized[i].Cue) + } + + var fallbackEnd *int64 + if normalized[i].End != nil { + v := *normalized[i].End + fallbackEnd = &v + } else if i+1 < len(normalized) && normalized[i+1].Start != nil { + v := *normalized[i+1].Start + fallbackEnd = &v + } + + normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) + } + + return normalized +} + +func NormalizeLineTiming(line Line) Line { + if len(line.Cue) == 0 { + return line + } + + var earliestStart *int64 + var latestEnd *int64 + for i := range line.Cue { + token := line.Cue[i] + if token.Start != nil { + if earliestStart == nil || *token.Start < *earliestStart { + v := *token.Start + earliestStart = &v + } + } + + candidateEnd := token.End + if candidateEnd == nil { + candidateEnd = token.Start + } + if candidateEnd != nil { + if latestEnd == nil || *candidateEnd > *latestEnd { + v := *candidateEnd + latestEnd = &v + } + } + } + + if line.Start == nil && earliestStart != nil { + v := *earliestStart + line.Start = &v + } + if line.End == nil && latestEnd != nil { + v := *latestEnd + line.End = &v + } + return line +} + +func normalizeCueLine(line Line, fallbackEnd *int64) Line { + if len(line.Cue) == 0 { + return line + } + line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) + return NormalizeLineTiming(line) +} + +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues + } + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = gg.Clone(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} diff --git a/model/lyrics_embedded.go b/model/lyrics_embedded.go new file mode 100644 index 000000000..7b412556e --- /dev/null +++ b/model/lyrics_embedded.go @@ -0,0 +1,55 @@ +package model + +import ( + "encoding/xml" + "strings" + + "github.com/navidrome/navidrome/log" +) + +// ParseEmbedded parses lyrics read from media-file metadata tags. It detects rich +// payloads before falling back to the generic LRC/plain-text parser, because +// text sanitization would otherwise strip TTML XML markup. +func ParseEmbedded(language, text string) (LyricList, error) { + text = strings.TrimPrefix(text, "\ufeff") + + if isTTMLDocument(text) { + list, err := parseTTMLWithDefaultLang([]byte(text), language) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil { + log.Warn("Error parsing embedded TTML lyrics, falling back to plain lyrics", "error", err) + } + } + + list, err := parseSRTWithLanguage([]byte(text), language) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil && strings.Contains(text, "-->") { + log.Warn("Error parsing embedded SRT lyrics, falling back to plain lyrics", "error", err) + } + + lyric, err := ToLyrics(language, text) + if err != nil { + return nil, err + } + if lyric == nil || lyric.IsEmpty() { + return nil, nil + } + return LyricList{*lyric}, nil +} + +func isTTMLDocument(text string) bool { + decoder := xml.NewDecoder(strings.NewReader(strings.TrimSpace(text))) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok { + return strings.EqualFold(start.Name.Local, "tt") + } + } +} diff --git a/model/lyrics_embedded_test.go b/model/lyrics_embedded_test.go new file mode 100644 index 000000000..77f17973a --- /dev/null +++ b/model/lyrics_embedded_test.go @@ -0,0 +1,160 @@ +package model + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseEmbedded", func() { + It("should parse embedded TTML with the tag language as the default", func() { + content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <head> + <metadata> + <ttm:agent xml:id="lead" ttm:type="person"> + <ttm:name>Lead Vocal</ttm:name> + </ttm:agent> + </metadata> + </head> + <body> + <div> + <p begin="00:00:01.000" end="00:00:03.000"> + <span begin="00:00:01.000" end="00:00:02.000" ttm:agent="lead">Hello </span><span begin="00:00:02.000" end="00:00:03.000" ttm:agent="lead">world</span> + </p> + </div> + </body> +</tt>` + + list, err := ParseEmbedded("ENG", content) + + // ParseEmbedded's job is to detect TTML and apply the tag language as the + // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Hello world")) + }) + + It("should preserve embedded TTML translation and pronunciation tracks", func() { + content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal"> + <head> + <metadata> + <iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"> + <translations> + <translation xml:lang="es"> + <text for="L1">Hola</text> + </translation> + </translations> + <transliterations> + <transliteration xml:lang="ja-Latn"> + <text for="L1"><span begin="00:00:01.000" end="00:00:01.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:00:01.300" end="00:00:01.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text> + </transliteration> + </transliterations> + </iTunesMetadata> + </metadata> + </head> + <body xml:lang="ja"> + <div> + <p begin="00:00:01.000" end="00:00:02.000" itunes:key="L1">こんにちは</p> + </div> + </body> +</tt>` + + list, err := ParseEmbedded("eng", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("ja")) + Expect(list[0].Line[0].Value).To(Equal("こんにちは")) + Expect(list[1].Kind).To(Equal("translation")) + Expect(list[1].Lang).To(Equal("es")) + Expect(list[1].Line[0].Value).To(Equal("Hola")) + Expect(list[2].Kind).To(Equal("pronunciation")) + Expect(list[2].Lang).To(Equal("ja-latn")) + Expect(list[2].Line[0].Value).To(Equal("konni")) + Expect(list[2].Line[0].Cue).To(HaveLen(2)) + }) + + It("should parse embedded SRT with the tag language", func() { + content := `1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line` + + list, err := ParseEmbedded("POR", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(Equal(LyricList{ + { + Lang: "por", + Line: []Line{ + { + Start: new(int64(18800)), + End: new(int64(22800)), + Value: "We're from subtitles", + }, + { + Start: new(int64(22801)), + End: new(int64(26000)), + Value: "Another subtitle line", + }, + }, + Synced: true, + }, + })) + }) + + It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { + content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" + + list, err := ParseEmbedded("eng", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("should keep embedded enhanced LRC cues", func() { + content := "[00:01.00]<00:01.00>Lead <00:01.50>words" + + list, err := ParseEmbedded("eng", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Lead words")) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + }) + + It("should fall back to plain lyrics when embedded TTML is invalid", func() { + content := `<tt xmlns="http://www.w3.org/ns/ttml"> + <body> + <p begin="not-a-time">Broken</p> + </body> +</tt>` + + list, err := ParseEmbedded("eng", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).ToNot(BeEmpty()) + values := make([]string, 0, len(list[0].Line)) + for _, line := range list[0].Line { + values = append(values, line.Value) + } + Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) + }) +}) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go new file mode 100644 index 000000000..928fc45d9 --- /dev/null +++ b/model/lyrics_srt.go @@ -0,0 +1,167 @@ +package model + +import ( + "bytes" + "regexp" + "strconv" + "strings" + + "github.com/navidrome/navidrome/utils/str" +) + +var ( + srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`) + srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) +) + +func ParseSRT(contents []byte) (LyricList, error) { + return parseSRTWithLanguage(contents, "xxx") +} + +func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) { + raw := strings.ReplaceAll(string(contents), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + blocks := splitSRTBlocks(raw) + lines := make([]Line, 0, len(blocks)) + + for _, block := range blocks { + line, ok, err := parseSRTBlock(block) + if err != nil { + return nil, err + } + if ok { + lines = append(lines, line) + } + } + + if len(lines) == 0 { + return nil, nil + } + + lyrics := NormalizeLyrics(Lyrics{ + Lang: normalizeLyricLang(language), + Line: lines, + Synced: true, + }) + return LyricList{lyrics}, nil +} + +func splitSRTBlocks(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + parts := srtBlockSeparatorRegex.Split(raw, -1) + blocks := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + blocks = append(blocks, part) + } + } + return blocks +} + +func parseSRTBlock(block string) (Line, bool, error) { + scanner := bytes.Split([]byte(block), []byte("\n")) + if len(scanner) == 0 { + return Line{}, false, nil + } + + lines := make([]string, 0, len(scanner)) + for _, line := range scanner { + lines = append(lines, strings.TrimSpace(string(line))) + } + + if len(lines) == 0 { + return Line{}, false, nil + } + + startIdx := 0 + if digitsOnly(lines[0]) { + startIdx = 1 + } + if startIdx >= len(lines) { + return Line{}, false, nil + } + + timing := strings.Split(lines[startIdx], "-->") + if len(timing) != 2 { + return Line{}, false, nil + } + + startMs, err := parseSRTTime(timing[0]) + if err != nil { + return Line{}, false, err + } + endMs, err := parseSRTTime(timing[1]) + if err != nil { + return Line{}, false, err + } + + textLines := make([]string, 0, len(lines)-startIdx-1) + for _, line := range lines[startIdx+1:] { + if line == "" { + continue + } + textLines = append(textLines, line) + } + + value := str.SanitizeText(strings.Join(textLines, "\n")) + if value == "" { + return Line{}, false, nil + } + + return Line{ + Start: &startMs, + End: &endMs, + Value: value, + }, true, nil +} + +func parseSRTTime(value string) (int64, error) { + match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return 0, strconv.ErrSyntax + } + + hours, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return 0, err + } + minutes, err := strconv.ParseInt(match[2], 10, 64) + if err != nil { + return 0, err + } + seconds, err := strconv.ParseInt(match[3], 10, 64) + if err != nil { + return 0, err + } + millis, err := strconv.ParseInt(match[4], 10, 64) + if err != nil { + return 0, err + } + + switch len(match[4]) { + case 1: + millis *= 100 + case 2: + millis *= 10 + } + + return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 644b85ad2..b772e2f5e 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -108,4 +108,203 @@ var _ = Describe("ToLyrics", func() { {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, })) }) + + It("should parse Enhanced LRC with word-level timing", func() { + lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, + {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + + Expect(line1.Cue[1].End).To(BeNil()) + }) + + It("should not parse malformed Enhanced LRC timing markers", func() { + lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(3)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) + + It("should preserve byte offsets for Enhanced LRC cues", func() { + lyrics, err := ToLyrics("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(1)) + + t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Oh love me tonight")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, + {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, + {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, + {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, + })) + }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + lyrics, err := ToLyrics("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) +}) + +var _ = Describe("NormalizeCueLines", func() { + It("should not mutate caller cue slices when filling missing cue end times", func() { + start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) + lines := []Line{ + { + Start: &start0, + Value: "Some lyrics", + Cue: []Cue{ + {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + }, + }, + { + Start: &nextLineStart, + Value: "Next line", + }, + } + + normalized := NormalizeCueLines(lines) + + Expect(normalized[0].Cue[0].End).To(Equal(&start1)) + Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) + Expect(lines[0].Cue[0].End).To(BeNil()) + Expect(lines[0].Cue[1].End).To(BeNil()) + }) +}) + +var _ = Describe("Lyrics.EffectiveKind", func() { + It("defaults a blank kind to main", func() { + Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) + Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain)) + }) + + It("returns the kind as-is when set", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation)) + }) +}) + +var _ = Describe("Lyrics.IsMainKind", func() { + It("is true for a blank (untyped) kind", func() { + Expect(Lyrics{}.IsMainKind()).To(BeTrue()) + }) + + It("is true for the main kind", func() { + Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue()) + }) + + It("is false for translation and pronunciation kinds", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse()) + Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse()) + }) +}) + +var _ = Describe("LyricList.Main", func() { + It("returns false when the list is empty", func() { + _, ok := LyricList{}.Main() + Expect(ok).To(BeFalse()) + }) + + It("returns the main-kind entry when present", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindMain, Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Kind).To(Equal(LyricKindMain)) + }) + + It("falls back to the first entry when no main kind exists", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindPronunciation, Lang: "ja"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("en")) + }) + + It("treats a blank kind as main", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("xxx")) + }) }) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go new file mode 100644 index 000000000..fe3a547d5 --- /dev/null +++ b/model/lyrics_ttml.go @@ -0,0 +1,1256 @@ +package model + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + defaultTTMLFrameRate = 30.0 + defaultTTMLSubFrameRate = 1.0 + defaultTTMLTickRate = 1.0 + + ttmlBackgroundAgentPrefix = "__nd_bg__|" +) + +var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`) +var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`) + +type ttmlTimeKind int + +const ( + ttmlTimeAbsolute ttmlTimeKind = iota + ttmlTimeOffset + ttmlTimeAmbiguous +) + +type ttmlTimingParams struct { + frameRate float64 + subFrameRate float64 + tickRate float64 +} + +type ttmlTimingContext struct { + lang string + role string + agentID string + begin int64 + hasBegin bool + end int64 + hasEnd bool + invalid bool +} + +type ttmlLineRef struct { + order int + line Line +} + +type ttmlMetadataEntry struct { + key string + line Line + seq int +} + +type ttmlResolvedMetadataLine struct { + order int + seq int + line Line +} + +type ttmlDefinedAgent struct { + ID string + Type string + Name string +} + +type ttmlPiece struct { + raw string + cue *Cue +} + +type ttmlParser struct { + decoder *xml.Decoder + params ttmlTimingParams + + mainLangOrder []string + mainLinesByLang map[string][]Line + + mainLineRefsByKey map[string]ttmlLineRef + mainLineOrder int + + translationLangOrder []string + translationEntriesByLg map[string][]ttmlMetadataEntry + + pronunciationLangOrder []string + pronunciationEntriesByLg map[string][]ttmlMetadataEntry + + definedAgents map[string]ttmlDefinedAgent + + metadataSeq int +} + +func ParseTTML(contents []byte) (LyricList, error) { + return parseTTMLWithDefaultLang(contents, "xxx") +} + +func parseTTMLWithDefaultLang(contents []byte, defaultLang string) (LyricList, error) { + contents = xmlEncodingRegex.ReplaceAll(contents, []byte(`<?xml$1encoding="UTF-8"$2?>`)) + + p := ttmlParser{ + decoder: xml.NewDecoder(bytes.NewReader(contents)), + params: ttmlTimingParams{ + frameRate: defaultTTMLFrameRate, + subFrameRate: defaultTTMLSubFrameRate, + tickRate: defaultTTMLTickRate, + }, + mainLinesByLang: make(map[string][]Line), + mainLineRefsByKey: make(map[string]ttmlLineRef), + translationEntriesByLg: make(map[string][]ttmlMetadataEntry), + pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry), + definedAgents: make(map[string]ttmlDefinedAgent), + } + + root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)} + + for { + token, err := p.decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + start, ok := token.(xml.StartElement) + if !ok { + continue + } + + if err := p.parseElement(start, root); err != nil { + return nil, err + } + } + + return p.toLyricList(), nil +} + +func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error { + local := strings.ToLower(start.Name.Local) + if local == "tt" { + p.updateTimingParams(start.Attr) + } + + switch local { + case "translation": + return p.parseMetadataTrack(start, parent, LyricKindTranslation) + case "transliteration": + return p.parseMetadataTrack(start, parent, LyricKindPronunciation) + case "agent": + return p.parseAgentDefinition(start) + } + + ctx := p.childContext(start.Attr, parent) + if local == "p" { + lineText, tokens, err := p.parseParagraph(ctx) + if err != nil { + return err + } + if ctx.invalid || lineText == "" { + return nil + } + + parsedLine := Line{Value: lineText} + if ctx.hasBegin { + startMs := ctx.begin + parsedLine.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedLine.End = &endMs + } + if len(tokens) > 0 { + parsedLine.Cue = tokens + } + parsedLine = NormalizeLineTiming(parsedLine) + + lineKey, _ := attrValue(start.Attr, "key") + p.addMainLine(ctx.lang, lineKey, parsedLine) + return nil + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + nextParent := ctx + if ctx.invalid { + // Best effort: ignore invalid timing in container elements, and + // continue traversing descendants with parent context. + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error { + ctx := p.childContext(start.Attr, parent) + lang := normalizeLyricLang(ctx.lang) + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "text") { + entry, ok, err := p.parseMetadataText(t, ctx) + if err != nil { + return err + } + if ok { + p.addMetadataEntry(kind, lang, entry) + } + continue + } + + nextParent := ctx + if ctx.invalid { + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error { + id, ok := attrValue(start.Attr, "id") + id = strings.TrimSpace(id) + if !ok || id == "" { + return p.skipElement(start) + } + + agent := ttmlDefinedAgent{ + ID: id, + Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))), + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "name") { + name, err := p.collectElementText(t) + if err != nil { + return err + } + name = sanitizeTTMLText(name) + if name != "" && agent.Name == "" { + agent.Name = name + } + continue + } + if err := p.skipElement(t); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + p.definedAgents[agent.ID] = agent + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) { + forKey, hasFor := attrValue(start.Attr, "for") + forKey = strings.TrimSpace(forKey) + + pieces, err := p.parseInlineElement(start, parent) + if err != nil { + return ttmlMetadataEntry{}, false, err + } + if !hasFor || forKey == "" { + return ttmlMetadataEntry{}, false, nil + } + + ctx := p.childContext(start.Attr, parent) + if ctx.invalid { + return ttmlMetadataEntry{}, false, nil + } + + value, tokens := buildTTMLLineFromPieces(pieces) + line := Line{Value: value} + if ctx.hasBegin { + startMs := ctx.begin + line.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + line.End = &endMs + } + if len(tokens) > 0 { + line.Cue = tokens + } + line = NormalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + return ttmlMetadataEntry{}, false, nil + } + + return ttmlMetadataEntry{key: forKey, line: line}, true, nil +} + +func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) { + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return "", nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, parent) + if err != nil { + return "", nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, "p") { + value, tokens := buildTTMLLineFromPieces(pieces) + return value, tokens, nil + } + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) { + local := strings.ToLower(start.Name.Local) + if local == "br" { + return []ttmlPiece{{raw: "\n"}}, nil + } + + ctx := p.childContext(start.Attr, parent) + _, hasBegin := attrValue(start.Attr, "begin") + _, hasEnd := attrValue(start.Attr, "end") + _, hasDur := attrValue(start.Attr, "dur") + hasOwnTiming := hasBegin || hasEnd || hasDur + + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, ctx) + if err != nil { + return nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if !strings.EqualFold(t.Name.Local, start.Name.Local) { + continue + } + + if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) { + rawValue := concatTTMLPieceRaw(pieces) + tokenText := sanitizeTTMLText(rawValue) + if tokenText != "" { + parsedToken := Cue{ + AgentID: p.resolveCueAgentID(ctx), + } + if ctx.hasBegin { + startMs := ctx.begin + parsedToken.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedToken.End = &endMs + } + + return []ttmlPiece{{ + raw: rawValue, + cue: &parsedToken, + }}, nil + } + } + + return pieces, nil + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { + finalized := finalizeTTMLLines(splitTTMLPiecesByNewline(pieces)) + for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { + finalized = finalized[1:] + } + for len(finalized) > 0 { + last := finalized[len(finalized)-1] + if last.text != "" || len(last.cues) > 0 { + break + } + finalized = finalized[:len(finalized)-1] + } + + var value strings.Builder + cues := make([]Cue, 0, 8) + byteOffset := 0 + for i, line := range finalized { + if i > 0 { + value.WriteByte('\n') + byteOffset++ + } + value.WriteString(line.text) + for _, cue := range line.cues { + cue.ByteStart += byteOffset + cue.ByteEnd += byteOffset + cues = append(cues, cue) + } + byteOffset += len(line.text) + } + + return value.String(), cues +} + +type ttmlFinalLine struct { + text string + cues []Cue +} + +func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine { + finalized := make([]ttmlFinalLine, 0, len(lines)) + for _, line := range lines { + text, cues := finalizeTTMLLogicalLine(line) + finalized = append(finalized, ttmlFinalLine{text: text, cues: cues}) + } + return finalized +} + +func splitTTMLPiecesByNewline(pieces []ttmlPiece) [][]ttmlPiece { + lines := [][]ttmlPiece{{}} + for _, piece := range pieces { + raw := normalizeTTMLPieceRaw(piece.raw) + if raw == "" { + continue + } + + start := 0 + for i := 0; i < len(raw); i++ { + if raw[i] != '\n' { + continue + } + if start < i { + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw[start:i], + cue: gg.Clone(piece.cue), + }) + } + lines = append(lines, []ttmlPiece{}) + start = i + 1 + } + if start < len(raw) { + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw[start:], + cue: gg.Clone(piece.cue), + }) + } + } + return lines +} + +func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) { + rawLine := concatTTMLPieceRaw(line) + if rawLine == "" { + return "", nil + } + + leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace)) + rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace)) + trimmedEnd := len(rawLine) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + trimmed := strings.TrimSpace(rawLine) + cues := make([]Cue, 0, len(line)) + cursor := 0 + for _, piece := range line { + pieceEnd := cursor + len(piece.raw) + if piece.cue != nil { + byteStart := max(cursor, leftTrimBytes) + byteEnd := min(pieceEnd, trimmedEnd) + if byteStart < byteEnd { + cue := *piece.cue + cue.Value = rawLine[byteStart:byteEnd] + cue.ByteStart = byteStart - leftTrimBytes + cue.ByteEnd = byteEnd - leftTrimBytes - 1 + cues = append(cues, cue) + } + } + cursor = pieceEnd + } + + return trimmed, cues +} + +func normalizeTTMLPieceRaw(raw string) string { + raw = str.SanitizeText(raw) + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + return raw +} + +func concatTTMLPieceRaw(pieces []ttmlPiece) string { + var raw strings.Builder + for _, piece := range pieces { + raw.WriteString(normalizeTTMLPieceRaw(piece.raw)) + } + return raw.String() +} + +func ttmlPiecesContainCue(pieces []ttmlPiece) bool { + for _, piece := range pieces { + if piece.cue != nil { + return true + } + } + return false +} + +func (p *ttmlParser) toLyricList() LyricList { + res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder)) + for _, lang := range p.mainLangOrder { + lines := p.mainLinesByLang[lang] + if len(lines) == 0 { + continue + } + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: LyricKindMain, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...) + res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...) + return res +} + +func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList { + res := make(LyricList, 0, len(langOrder)) + + for _, lang := range langOrder { + entries := entriesByLang[lang] + if len(entries) == 0 { + continue + } + + seenKeys := make(map[string]struct{}, len(entries)) + resolved := make([]ttmlResolvedMetadataLine, 0, len(entries)) + for _, entry := range entries { + if _, exists := seenKeys[entry.key]; exists { + continue + } + seenKeys[entry.key] = struct{}{} + + ref, ok := p.mainLineRefsByKey[entry.key] + if !ok { + log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key) + continue + } + + line := entry.line + if line.Start == nil && ref.line.Start != nil { + startMs := *ref.line.Start + line.Start = &startMs + } + if line.End == nil && ref.line.End != nil { + endMs := *ref.line.End + line.End = &endMs + } + line = NormalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + continue + } + + resolved = append(resolved, ttmlResolvedMetadataLine{ + order: ref.order, + seq: entry.seq, + line: line, + }) + } + + if len(resolved) == 0 { + continue + } + + sort.SliceStable(resolved, func(i, j int) bool { + if resolved[i].order != resolved[j].order { + return resolved[i].order < resolved[j].order + } + return resolved[i].seq < resolved[j].seq + }) + + lines := make([]Line, len(resolved)) + for i := range resolved { + lines[i] = resolved[i].line + } + + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: kind, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + return res +} + +func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) + return NormalizeLyrics(lyrics) +} + +func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { + if len(lines) == 0 { + return lines, nil + } + + usedOrder := make([]string, 0, 4) + usedSet := make(map[string]struct{}, 4) + sawEmptyCue := false + + for i := range lines { + for j := range lines[i].Cue { + agentID := strings.TrimSpace(lines[i].Cue[j].AgentID) + if agentID == "" { + sawEmptyCue = true + continue + } + if _, exists := usedSet[agentID]; !exists { + usedSet[agentID] = struct{}{} + usedOrder = append(usedOrder, agentID) + } + } + } + + if len(usedOrder) == 0 { + return lines, nil + } + + mainID := "" + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if role != "bg" && role != "group" { + mainID = agentID + break + } + } + if mainID == "" && sawEmptyCue { + mainID = "main" + } + if mainID == "" { + for _, agentID := range usedOrder { + if p.baseRoleForAgent(agentID) != "bg" { + mainID = agentID + break + } + } + } + if mainID == "" { + mainID = usedOrder[0] + } + + if _, exists := usedSet[mainID]; !exists { + usedSet[mainID] = struct{}{} + usedOrder = append([]string{mainID}, usedOrder...) + } + + for i := range lines { + for j := range lines[i].Cue { + if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" { + lines[i].Cue[j].AgentID = mainID + } + } + } + + agents := make([]Agent, 0, len(usedOrder)) + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if agentID == mainID { + role = "main" + } + agent := Agent{ + ID: agentID, + Role: role, + Name: p.agentNameForID(agentID), + } + agents = append(agents, agent) + } + + return lines, agents +} + +func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string { + agentID := strings.TrimSpace(ctx.agentID) + if contextHasRole(ctx.role, "x-bg") { + if agentID == "" { + agentID = "main" + } + return backgroundAgentID(agentID) + } + return agentID +} + +func (p *ttmlParser) baseRoleForAgent(agentID string) string { + if isBackgroundAgentID(agentID) { + return "bg" + } + + if agent, ok := p.definedAgents[agentID]; ok { + switch agent.Type { + case "group": + return "group" + default: + return "voice" + } + } + + return "voice" +} + +func (p *ttmlParser) agentNameForID(agentID string) string { + if isBackgroundAgentID(agentID) { + baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix) + if baseID == "main" { + return "" + } + if agent, ok := p.definedAgents[baseID]; ok { + return agent.Name + } + return "" + } + + if agent, ok := p.definedAgents[agentID]; ok { + return agent.Name + } + + return "" +} + +func backgroundAgentID(agentID string) string { + return ttmlBackgroundAgentPrefix + agentID +} + +func isBackgroundAgentID(agentID string) bool { + return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix) +} + +func contextHasRole(roles string, role string) bool { + lowerRole := strings.ToLower(role) + for _, candidate := range strings.Fields(strings.ToLower(roles)) { + if candidate == lowerRole { + return true + } + } + return false +} + +func appendTTMLRoles(existing string, roles string) string { + for _, role := range strings.Fields(roles) { + if contextHasRole(existing, role) { + continue + } + if existing == "" { + existing = role + } else { + existing += " " + role + } + } + return existing +} + +func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) { + lang = normalizeLyricLang(lang) + if _, ok := p.mainLinesByLang[lang]; !ok { + p.mainLangOrder = append(p.mainLangOrder, lang) + } + p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line) + + lineKey = strings.TrimSpace(lineKey) + if lineKey != "" { + if _, exists := p.mainLineRefsByKey[lineKey]; !exists { + p.mainLineRefsByKey[lineKey] = ttmlLineRef{ + order: p.mainLineOrder, + line: line, + } + } + } + p.mainLineOrder++ +} + +func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) { + lang = normalizeLyricLang(lang) + entry.seq = p.metadataSeq + p.metadataSeq++ + + switch kind { + case LyricKindTranslation: + if _, ok := p.translationEntriesByLg[lang]; !ok { + p.translationLangOrder = append(p.translationLangOrder, lang) + } + p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry) + case LyricKindPronunciation: + if _, ok := p.pronunciationEntriesByLg[lang]; !ok { + p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang) + } + p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry) + } +} + +func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext { + ctx := parent + + if lang, ok := attrValue(attrs, "lang"); ok { + ctx.lang = normalizeLyricLang(lang) + } + if agentID, ok := attrValue(attrs, "agent"); ok { + ctx.agentID = strings.TrimSpace(agentID) + } + if role, ok := attrValue(attrs, "role"); ok { + role = strings.TrimSpace(role) + if role != "" { + ctx.role = appendTTMLRoles(ctx.role, role) + } + } + + beginExpr, hasBegin := attrValue(attrs, "begin") + endExpr, hasEnd := attrValue(attrs, "end") + durExpr, hasDur := attrValue(attrs, "dur") + + if hasBegin { + begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := int64(0) + if parent.hasBegin { + base = parent.begin + } + ctx.begin = resolveTTMLTime(begin, kind, base, parent) + ctx.hasBegin = true + } else { + ctx.begin = parent.begin + ctx.hasBegin = parent.hasBegin + } + + var calculatedEnd int64 + calculatedHasEnd := false + + if hasEnd { + end, kind, ok := parseTTMLTimeExpression(endExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := ctx.begin + if !ctx.hasBegin { + base = parent.begin + } + calculatedEnd = resolveTTMLTime(end, kind, base, parent) + calculatedHasEnd = true + } + + if hasDur { + dur, ok := parseTTMLDurationExpression(durExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + if ctx.hasBegin { + durEnd := ctx.begin + dur + if !calculatedHasEnd || durEnd < calculatedEnd { + calculatedEnd = durEnd + calculatedHasEnd = true + } + } + } + + if !calculatedHasEnd && parent.hasEnd { + calculatedEnd = parent.end + calculatedHasEnd = true + } + + ctx.end = calculatedEnd + ctx.hasEnd = calculatedHasEnd + return ctx +} + +func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) { + frameRate := p.params.frameRate + if value, ok := attrValue(attrs, "frameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + frameRate = parsed + } + } + + if value, ok := attrValue(attrs, "frameRateMultiplier"); ok { + parts := strings.Fields(value) + if len(parts) == 2 { + numerator, errA := strconv.ParseFloat(parts[0], 64) + denominator, errB := strconv.ParseFloat(parts[1], 64) + if errA == nil && errB == nil && denominator > 0 { + frameRate = frameRate * (numerator / denominator) + } + } + } + + subFrameRate := p.params.subFrameRate + if value, ok := attrValue(attrs, "subFrameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + subFrameRate = parsed + } + } + + tickRate := p.params.tickRate + if value, ok := attrValue(attrs, "tickRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + tickRate = parsed + } + } + + p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate) + p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate) + p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate) +} + +func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) { + value, _, ok := parseTTMLTimeExpression(expr, params) + return value, ok +} + +func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 { + switch kind { + case ttmlTimeAbsolute: + return value + case ttmlTimeOffset: + return base + value + case ttmlTimeAmbiguous: + absolute := value + offset := base + value + + // No parent timing context → no reference frame for offsets. + // Prefer absolute when offset differs (i.e., base > 0). + if !parent.hasBegin && !parent.hasEnd && base != 0 { + return absolute + } + + if parent.hasBegin && parent.hasEnd { + absoluteInParent := absolute >= parent.begin && absolute <= parent.end + offsetInParent := offset >= parent.begin && offset <= parent.end + if absoluteInParent && !offsetInParent { + return absolute + } + if offsetInParent && !absoluteInParent { + return offset + } + } + + if parent.hasBegin { + if absolute < parent.begin && offset >= parent.begin { + return offset + } + if absolute >= parent.begin && offset > absolute { + return absolute + } + } + return offset + default: + return base + value + } +} + +func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) { + expr = strings.TrimSpace(expr) + if expr == "" { + return 0, ttmlTimeOffset, false + } + + lower := strings.ToLower(expr) + if strings.Contains(lower, "wallclock(") || + strings.Contains(lower, ".begin") || + strings.Contains(lower, ".end") { + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } + + // Best-effort support for non-standard TTML seen in the wild where a + // bare decimal value is used (implicitly seconds), e.g. "0.170". + if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 { + return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true + } + + if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 { + value, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return 0, ttmlTimeOffset, false + } + + unit := matches[2] + seconds := 0.0 + switch unit { + case "h": + seconds = value * 60 * 60 + case "m": + seconds = value * 60 + case "s": + seconds = value + case "ms": + seconds = value / 1000 + case "f": + seconds = value / params.frameRate + case "t": + seconds = value / params.tickRate + default: + return 0, ttmlTimeOffset, false + } + + return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true + } + + colonCount := strings.Count(expr, ":") + switch colonCount { + case 1, 2: + clockMs, ok := parseTTMLClockTime(expr) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return clockMs, ttmlTimeAbsolute, true + case 3: + framesMs, ok := parseTTMLFrameTime(expr, params) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return framesMs, ttmlTimeAbsolute, true + default: + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } +} + +func parseTTMLClockTime(value string) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 2 && len(parts) != 3 { + return 0, false + } + + hours := int64(0) + minutesIdx := 0 + if len(parts) == 3 { + h, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + hours = h + minutesIdx = 1 + } + + minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64) + if err != nil { + return 0, false + } + + totalSeconds := float64(hours*60*60+minutes*60) + seconds + return int64(math.Round(totalSeconds * 1000)), true +} + +func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 4 { + return 0, false + } + + hours, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + + minutes, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return 0, false + } + + frameParts := strings.SplitN(parts[3], ".", 2) + frames, err := strconv.ParseFloat(frameParts[0], 64) + if err != nil { + return 0, false + } + + subFrames := 0.0 + if len(frameParts) == 2 { + subFrames, err = strconv.ParseFloat(frameParts[1], 64) + if err != nil { + return 0, false + } + } + + totalSeconds := float64(hours*60*60 + minutes*60 + seconds) + totalSeconds += frames / params.frameRate + totalSeconds += subFrames / (params.subFrameRate * params.frameRate) + + return int64(math.Round(totalSeconds * 1000)), true +} + +func attrValue(attrs []xml.Attr, key string) (string, bool) { + for _, attr := range attrs { + if strings.EqualFold(attr.Name.Local, key) { + return strings.TrimSpace(attr.Value), true + } + } + return "", false +} + +func attrOrEmpty(attrs []xml.Attr, key string) string { + value, _ := attrValue(attrs, key) + return value +} + +func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) { + var text strings.Builder + + for { + token, err := p.decoder.Token() + if err != nil { + return "", err + } + + switch t := token.(type) { + case xml.StartElement: + value, err := p.collectElementText(t) + if err != nil { + return "", err + } + text.WriteString(value) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return text.String(), nil + } + case xml.CharData: + text.WriteString(string(t)) + } + } +} + +func (p *ttmlParser) skipElement(_ xml.StartElement) error { + depth := 1 + for depth > 0 { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch token.(type) { + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + } + } + return nil +} + +func normalizeLyricLang(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + if lang == "" { + return "xxx" + } + return lang +} + +func sanitizeTTMLText(raw string) string { + raw = str.SanitizeText(raw) + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + lines := strings.Split(raw, "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func linesAreSynced(lines []Line) bool { + for i := range lines { + if lines[i].Start != nil { + return true + } + for j := range lines[i].Cue { + if lines[i].Cue[j].Start != nil { + return true + } + } + } + return false +} diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go new file mode 100644 index 000000000..ef882fdcd --- /dev/null +++ b/model/lyrics_ttml_test.go @@ -0,0 +1,429 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseTTML", func() { + Describe("Multi-language and timing", func() { + It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10"> + <body> + <div xml:lang="eng" begin="1s"> + <p begin="2s">Line one</p> + <p begin="00:00:04:15.1"><span>Line two</span><br/>with break</p> + </div> + <div xml:lang="por"> + <p begin="45t">Linha</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(2)) + + By("parsing the English track") + eng := list[0] + Expect(eng.Lang).To(Equal("eng")) + Expect(eng.Synced).To(BeTrue()) + Expect(eng.Line[0].Start).To(Equal(new(int64(3000)))) + Expect(eng.Line[0].Value).To(Equal("Line one")) + Expect(eng.Line[1].Start).To(Equal(new(int64(4517)))) + Expect(eng.Line[1].Value).To(Equal("Line two\nwith break")) + + By("parsing the Portuguese track") + por := list[1] + Expect(por.Lang).To(Equal("por")) + Expect(por.Line[0].Start).To(Equal(new(int64(4500)))) + Expect(por.Line[0].Value).To(Equal("Linha")) + }) + }) + + Describe("Unsupported cue handling", func() { + It("should skip wallclock cues and keep valid ones", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"> + <body xml:lang="eng"> + <div> + <p begin="wallclock(2026-01-01T00:00:00Z)">Skip me</p> + <p begin="1s">Keep me</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(1000)))) + Expect(list[0].Line[0].Value).To(Equal("Keep me")) + }) + }) + + Describe("Begin/End/Dur with inheritance", func() { + It("should correctly accumulate nested timing from body, div, and p elements", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"> + <body xml:lang="eng" begin="10s"> + <div begin="5s" dur="8s"> + <p begin="1s" dur="2s">First line</p> + <p begin="3s" end="5s">Second line</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(16000)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(18000)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Non-standard bare second offsets", func() { + It("should parse bare decimal numbers as seconds", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"> + <body xml:lang="eng" begin="10"> + <div> + <p begin="0.170">First line</p> + <p begin="3.710">Second line</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(10170)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(13710)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Word timing tokens", func() { + It("should extract timed tokens from spans including background role", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <body xml:lang="eng"> + <div> + <p begin="00:01.000" end="00:03.000"> + <span begin="00:01.000" end="00:01.400">He</span><span begin="00:01.400" end="00:01.800">llo</span> + <span ttm:role="x-bg"><span begin="00:02.000" end="00:02.500">echo</span></span> + </p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(1000)))) + Expect(line.Value).To(Equal("Hello\necho")) + Expect(line.End).To(Equal(new(int64(3000)))) + Expect(line.Cue).To(HaveLen(3)) + + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"})) + }) + + It("should append role tokens exactly instead of using substring matches", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <body xml:lang="eng"> + <div> + <p begin="00:01.000" end="00:03.000" ttm:role="not-x-bg"><span begin="00:01.000" end="00:01.400">Lead</span><span ttm:role="x-bg"><span begin="00:02.000" end="00:02.500">Echo</span></span></p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main")) + }) + + It("should parse named TTML agents into main, voice, and group roles", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <head> + <metadata> + <ttm:agent xml:id="v1" type="person"><ttm:name>Chris Martin</ttm:name></ttm:agent> + <ttm:agent xml:id="v2" type="person"><ttm:name>Jin</ttm:name></ttm:agent> + <ttm:agent xml:id="v1000" type="group"><ttm:name>All</ttm:name></ttm:agent> + </metadata> + </head> + <body xml:lang="eng"> + <div> + <p begin="1s" end="2s" ttm:agent="v1"><span begin="1s" end="1.5s">You</span></p> + <p begin="2s" end="3s" ttm:agent="v2"><span begin="2s" end="2.5s">and</span></p> + <p begin="3s" end="4s" ttm:agent="v1000"><span begin="3s" end="3.5s">All</span></p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "v1", Role: "main", Name: "Chris Martin"}, + {ID: "v2", Role: "voice", Name: "Jin"}, + {ID: "v1000", Role: "group", Name: "All"}, + })) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1")) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2")) + Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000")) + }) + + It("should avoid collisions between derived background agents and explicit TTML agent ids", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <head> + <metadata> + <ttm:agent xml:id="lead" type="person"><ttm:name>Lead</ttm:name></ttm:agent> + <ttm:agent xml:id="lead__bg" type="person"><ttm:name>Existing Background Id</ttm:name></ttm:agent> + </metadata> + </head> + <body xml:lang="eng"> + <div> + <p begin="1s" end="2s" ttm:agent="lead"> + <span begin="1s" end="1.4s">Lead</span> + <span ttm:role="x-bg"><span begin="1.5s" end="1.8s">Echo</span></span> + </p> + <p begin="2s" end="3s" ttm:agent="lead__bg"> + <span begin="2s" end="2.5s">Named</span> + </p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "lead", Role: "main", Name: "Lead"}, + {ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"}, + {ID: "lead__bg", Role: "voice", Name: "Existing Background Id"}, + })) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead")) + Expect(list[0].Line[1].Cue).To(HaveLen(1)) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg")) + }) + + It("should fill missing cue agent ids with the resolved main agent", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <head> + <metadata> + <ttm:agent xml:id="guest" type="person"><ttm:name>Guest Vocal</ttm:name></ttm:agent> + </metadata> + </head> + <body xml:lang="eng"> + <div> + <p begin="1s" end="3s"> + <span begin="1s" end="1.4s">Lead</span> + <span begin="2s" end="2.4s" ttm:agent="guest">Guest</span> + </p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "guest", Role: "main", Name: "Guest Vocal"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest")) + }) + }) + + Describe("Ambiguous decimal timing", func() { + It("should prefer absolute timing when values fall inside parent window", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"> + <body xml:lang="eng"> + <div begin="37.870" end="45.570"> + <p begin="43.444" end="45.570"> + <span begin="43.444" end="43.716">go</span> + <span begin="43.716" end="43.887">go</span> + </p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(43444)))) + Expect(line.Value).To(Equal("go\ngo")) + Expect(line.End).To(Equal(new(int64(45570)))) + Expect(line.Cue).To(HaveLen(2)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4})) + }) + }) + + Describe("Unsynced fallback", func() { + It("should return unsynced lyrics when no timing is present", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"> + <body> + <div> + <p>No timing here</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(BeNil()) + Expect(list[0].Line[0].Value).To(Equal("No timing here")) + }) + }) + + Describe("Metadata tracks", func() { + It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal"> + <head> + <metadata> + <iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"> + <translations> + <translation xml:lang="es"> + <text for="L1">Hola</text> + <text for="MISSING">Skip me</text> + </translation> + </translations> + <transliterations> + <transliteration xml:lang="ja-Latn"> + <text for="L2"><span begin="00:02.000" end="00:02.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:02.300" end="00:02.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text> + </transliteration> + </transliterations> + </iTunesMetadata> + </metadata> + </head> + <body xml:lang="ja"> + <div> + <p begin="00:01.000" end="00:01.500" itunes:key="L1">こんにちは</p> + <p begin="00:02.000" end="00:02.700" itunes:key="L2">こんばんは</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + + By("checking the main track") + main := list[0] + Expect(main.Kind).To(Equal("main")) + Expect(main.Lang).To(Equal("ja")) + Expect(main.Line).To(HaveLen(2)) + + By("checking the translation track") + translation := list[1] + Expect(translation.Kind).To(Equal("translation")) + Expect(translation.Lang).To(Equal("es")) + Expect(translation.Line).To(HaveLen(1)) + Expect(translation.Line[0].Start).To(Equal(new(int64(1000)))) + Expect(translation.Line[0].Value).To(Equal("Hola")) + Expect(translation.Line[0].End).To(Equal(new(int64(1500)))) + + By("checking the pronunciation track") + pronunciation := list[2] + Expect(pronunciation.Kind).To(Equal("pronunciation")) + Expect(pronunciation.Lang).To(Equal("ja-latn")) + Expect(pronunciation.Line).To(HaveLen(1)) + Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000)))) + Expect(pronunciation.Line[0].Value).To(Equal("konni")) + Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600)))) + Expect(pronunciation.Line[0].Cue).To(HaveLen(2)) + Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1})) + Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4})) + }) + }) + + Describe("Pronunciation with bare decimal end times", func() { + It("should correctly parse bare decimal times in transliteration spans", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal"> + <head> + <metadata> + <iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"> + <transliterations> + <transliteration xml:lang="ja-Latn"> + <text for="L1"><span begin="2.747" end="3.018" xmlns="http://www.w3.org/ns/ttml">I</span> <span begin="3.018" end="3.179" xmlns="http://www.w3.org/ns/ttml">woke</span> <span begin="3.179" end="3.582" xmlns="http://www.w3.org/ns/ttml">up</span></text> + </transliteration> + </transliterations> + </iTunesMetadata> + </metadata> + </head> + <body xml:lang="ja"> + <div> + <p begin="00:02.747" end="00:04.000" itunes:key="L1">起きた</p> + </div> + </body> +</tt>`) + + list, err := ParseTTML(content) + Expect(err).ToNot(HaveOccurred()) + + var pronunciation *Lyrics + for i := range list { + if list[i].Kind == "pronunciation" { + pronunciation = &list[i] + break + } + } + Expect(pronunciation).ToNot(BeNil()) + Expect(pronunciation.Line).To(HaveLen(1)) + + line := pronunciation.Line[0] + Expect(line.Start).To(Equal(new(int64(2747)))) + Expect(line.Value).To(Equal("I woke up")) + Expect(line.Cue).To(HaveLen(3)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8})) + }) + }) +}) diff --git a/model/lyricsfile.go b/model/lyricsfile.go new file mode 100644 index 000000000..b2b123256 --- /dev/null +++ b/model/lyricsfile.go @@ -0,0 +1,276 @@ +package model + +import ( + "fmt" + "strings" + + "github.com/navidrome/navidrome/utils/str" + "gopkg.in/yaml.v3" +) + +// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document +// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) +// into a model.LyricList containing a single main Lyrics entry. Returns +// (nil, nil) when the input parses as YAML but does not declare Lyricsfile +// version 1.0. +// +// When the source contains per-word timing via lines[].words[], each word +// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and +// overlapping lines are attributed to synthetic voice agents via lowest-free +// voice ID assignment so the OpenSubsonic v2 enhanced response can split +// parallel vocals. +func ParseLyricsfile(text string) (LyricList, error) { + var doc lyricsfileDocument + dec := yaml.NewDecoder(strings.NewReader(text)) + dec.KnownFields(false) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) + } + + if strings.TrimSpace(doc.Version) != lyricsfileVersion { + return nil, nil + } + + lyrics := Lyrics{ + DisplayArtist: str.SanitizeText(doc.Metadata.Artist), + DisplayTitle: str.SanitizeText(doc.Metadata.Title), + Lang: normalizeLyricLang(doc.Metadata.Language), + Kind: LyricKindMain, + } + if doc.Metadata.OffsetMs != 0 { + off := doc.Metadata.OffsetMs + lyrics.Offset = &off + } + + if doc.Metadata.Instrumental { + return LyricList{NormalizeLyrics(lyrics)}, nil + } + + if len(doc.Lines) == 0 { + lines := buildPlainLyricsfileLines(doc.Plain) + if len(lines) == 0 { + return nil, nil + } + lyrics.Line = lines + return LyricList{NormalizeLyrics(lyrics)}, nil + } + + lines, agents := buildLyricsfileLines(doc.Lines) + lyrics.Line = lines + lyrics.Agents = agents + lyrics.Synced = true + return LyricList{NormalizeLyrics(lyrics)}, nil +} + +const lyricsfileVersion = "1.0" + +type lyricsfileDocument struct { + Version string `yaml:"version"` + Metadata lyricsfileMetadata `yaml:"metadata"` + Lines []lyricsfileLineEntry `yaml:"lines"` + Plain string `yaml:"plain"` +} + +type lyricsfileMetadata struct { + Title string `yaml:"title"` + Artist string `yaml:"artist"` + Album string `yaml:"album"` + DurationMs int64 `yaml:"duration_ms"` + OffsetMs int64 `yaml:"offset_ms"` + Language string `yaml:"language"` + Instrumental bool `yaml:"instrumental"` +} + +type lyricsfileLineEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` + Words []lyricsfileWordEntry `yaml:"words"` +} + +type lyricsfileWordEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` +} + +// buildLyricsfileLines converts YAML line entries to model.Line entries with +// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free +// voice ID). The Agents slice is emitted only when at least one cue carries +// attribution AND more than one voice is used; otherwise AgentIDs are +// stripped so the wire shape stays simple per the OpenSubsonic spec rule +// "agents should not be emitted without cueLine data". +func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { + if len(entries) == 0 { + return nil, nil + } + + // Resolved end timestamps per entry: explicit end_ms, final word end_ms, + // then the next entry's start. The last entry's end stays nil when no + // explicit or word-level end is available. + ends := make([]*int64, len(entries)) + for i := range entries { + var nextStart *int64 + if i+1 < len(entries) { + v := entries[i+1].StartMs + nextStart = &v + } + ends[i] = lyricsfileLineEnd(entries[i], nextStart) + } + + active := map[int]int64{} + maxVoice := -1 + anyCues := false + lines := make([]Line, 0, len(entries)) + + for i, entry := range entries { + for vID, vEnd := range active { + if vEnd <= entry.StartMs { + delete(active, vID) + } + } + + voiceID := 0 + for { + if _, busy := active[voiceID]; !busy { + break + } + voiceID++ + } + if voiceID > maxVoice { + maxVoice = voiceID + } + + agentID := fmt.Sprintf("voice-%d", voiceID) + cues, value := wordsToLineCues(entry, agentID) + if len(cues) > 0 { + anyCues = true + } + + startMs := entry.StartMs + line := Line{ + Start: &startMs, + End: ends[i], + Value: value, + Cue: cues, + } + lines = append(lines, line) + + var endMs int64 + if ends[i] != nil { + endMs = *ends[i] + } else { + endMs = entry.StartMs + } + active[voiceID] = endMs + } + + // Monophonic source, or attribution that has nowhere to land: emit no + // agents and strip per-cue AgentIDs to keep the wire shape simple. + if maxVoice <= 0 || !anyCues { + for i := range lines { + for j := range lines[i].Cue { + lines[i].Cue[j].AgentID = "" + } + } + return lines, nil + } + + agents := make([]Agent, 0, maxVoice+1) + for v := 0; v <= maxVoice; v++ { + role := "voice" + if v == 0 { + role = "main" + } + agents = append(agents, Agent{ + ID: fmt.Sprintf("voice-%d", v), + Role: role, + }) + } + return lines, agents +} + +func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { + if entry.EndMs != nil { + v := *entry.EndMs + return &v + } + if len(entry.Words) > 0 { + lastWord := entry.Words[len(entry.Words)-1] + if lastWord.EndMs != nil { + v := *lastWord.EndMs + return &v + } + } + if nextStart != nil { + v := *nextStart + return &v + } + return nil +} + +func buildPlainLyricsfileLines(plain string) []Line { + plain = str.SanitizeText(plain) + rawLines := strings.Split(plain, "\n") + lines := make([]Line, 0, len(rawLines)) + for _, raw := range rawLines { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + lines = append(lines, Line{Value: value}) + } + return lines +} + +// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue +// entries with inclusive UTF-8 byte offsets into the reconstructed line +// value. The line value is built from cue text concatenation rather than +// trusting entry.Text, because the Lyricsfile spec only requires word.text +// to "approximate" line.text - byte offsets must always land inside +// Line.Value. +func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { + if len(entry.Words) == 0 { + return nil, str.SanitizeText(entry.Text) + } + + var sb strings.Builder + for _, w := range entry.Words { + sb.WriteString(w.Text) + } + lineValue := sb.String() + + cues := make([]Cue, len(entry.Words)) + cursor := 0 + for i, w := range entry.Words { + valueBytes := len(w.Text) + bs := cursor + be := bs + if valueBytes > 0 { + be = bs + valueBytes - 1 + cursor = be + 1 + } + + s := w.StartMs + cue := Cue{ + Start: &s, + Value: w.Text, + ByteStart: bs, + ByteEnd: be, + AgentID: agentID, + } + if w.EndMs != nil { + e := *w.EndMs + cue.End = &e + } + cues[i] = cue + } + + for i := 0; i < len(cues)-1; i++ { + if cues[i].End == nil && cues[i+1].Start != nil { + v := *cues[i+1].Start + cues[i].End = &v + } + } + return cues, lineValue +} diff --git a/model/lyricsfile_test.go b/model/lyricsfile_test.go new file mode 100644 index 000000000..a3588a2ea --- /dev/null +++ b/model/lyricsfile_test.go @@ -0,0 +1,283 @@ +package model_test + +import ( + . "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseLyricsfile", func() { + DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", + func(input string) { + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(BeNil()) + }, + Entry("arbitrary YAML", "hello: world\n"), + Entry("Lyricsfile-shaped but unversioned", `metadata: + title: 'Looks close' +lines: + - text: "But should not be claimed" + start_ms: 1000 +`), + ) + + It("returns an error for invalid YAML", func() { + _, err := ParseLyricsfile("not: valid: yaml: [") + Expect(err).To(HaveOccurred()) + }) + + It("parses line-level metadata without cues", func() { + input := `version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Test Artist")) + Expect(l.DisplayTitle).To(Equal("Sample Track")) + Expect(l.Synced).To(BeTrue()) + Expect(l.Offset).ToNot(BeNil()) + Expect(*l.Offset).To(Equal(int64(-100))) + Expect(l.Agents).To(BeNil()) + + Expect(l.Line).To(HaveLen(2)) + Expect(*l.Line[0].Start).To(Equal(int64(18800))) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(22801))) + Expect(l.Line[0].Value).To(Equal("We're no strangers to love")) + Expect(l.Line[0].Cue).To(BeNil()) + + Expect(*l.Line[1].Start).To(Equal(int64(22801))) + Expect(l.Line[1].End).To(BeNil()) + Expect(l.Line[1].Value).To(Equal("You know the rules and so do I")) + Expect(l.Line[1].Cue).To(BeNil()) + }) + + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { + input := `version: '1.0' +metadata: + title: 'Plain Track' + artist: 'Plain Artist' + language: 'en' +lines: [] +plain: | + [Verse 1] + First line + + Second line +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("en")) + Expect(l.DisplayArtist).To(Equal("Plain Artist")) + Expect(l.DisplayTitle).To(Equal("Plain Track")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(Equal([]Line{ + {Value: "[Verse 1]"}, + {Value: "First line"}, + {Value: "Second line"}, + })) + }) + + It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { + input := `version: '1.0' +metadata: + title: 'Karaoke' + artist: 'Singer' + language: 'eng' +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Synced).To(BeTrue()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(HaveLen(1)) + + line := l.Line[0] + Expect(*line.Start).To(Equal(int64(1000))) + Expect(*line.End).To(Equal(int64(3000))) + Expect(line.Value).To(Equal("Hello world")) + Expect(line.Cue).To(HaveLen(2)) + + Expect(*line.Cue[0].Start).To(Equal(int64(1000))) + Expect(*line.Cue[0].End).To(Equal(int64(1500))) + Expect(line.Cue[0].Value).To(Equal("Hello ")) + Expect(line.Cue[0].ByteStart).To(Equal(0)) + Expect(line.Cue[0].ByteEnd).To(Equal(5)) + Expect(line.Cue[0].AgentID).To(Equal("")) + + Expect(*line.Cue[1].Start).To(Equal(int64(1500))) + Expect(*line.Cue[1].End).To(Equal(int64(3000))) + Expect(line.Cue[1].Value).To(Equal("world")) + Expect(line.Cue[1].ByteStart).To(Equal(6)) + Expect(line.Cue[1].ByteEnd).To(Equal(10)) + Expect(line.Cue[1].AgentID).To(Equal("")) + }) + + It("prefers final word end_ms over next line start when inferring line end", func() { + input := `version: '1.0' +metadata: + title: 'Overlap From Words' +lines: + - text: "Long vocal" + start_ms: 1000 + words: + - text: "Long " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 3000 + end_ms: 3500 + words: + - text: "echo" + start_ms: 3000 + end_ms: 3500 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + }) + + It("synthesises voice agents for overlapping lines and attributes per-cue", func() { + input := `version: '1.0' +metadata: + title: 'Duet' +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + + Expect(l.Line[0].Value).To(Equal("Lead vocal")) + Expect(*l.Line[0].Start).To(Equal(int64(1000))) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue).To(HaveLen(2)) + Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4)) + Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5)) + Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9)) + + Expect(l.Line[1].Value).To(Equal("echo")) + Expect(*l.Line[1].Start).To(Equal(int64(2000))) + Expect(*l.Line[1].End).To(Equal(int64(3000))) + Expect(l.Line[1].Cue).To(HaveLen(1)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3)) + }) + + It("emits empty lines with Synced=false for instrumental tracks", func() { + input := `version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Composer")) + Expect(l.DisplayTitle).To(Equal("Solo Piano")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Line).To(BeEmpty()) + Expect(l.Agents).To(BeNil()) + }) + + It("strips agent attribution when overlapping lines carry no cues", func() { + input := `version: '1.0' +lines: + - text: "Lead" + start_ms: 1000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Line).To(HaveLen(2)) + Expect(l.Agents).To(BeNil()) + Expect(l.Line[0].Cue).To(BeNil()) + Expect(l.Line[1].Cue).To(BeNil()) + }) +}) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 966a545be..b46174c59 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -143,13 +143,15 @@ func (md Metadata) mapLyrics() string { lang := raw.Key() text := raw.Value() - lyrics, err := model.ToLyrics(lang, text) + lyrics, err := model.ParseEmbedded(lang, text) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) continue } - if !lyrics.IsEmpty() { - lyricList = append(lyricList, *lyrics) + for _, lyric := range lyrics { + if !lyric.IsEmpty() { + lyricList = append(lyricList, lyric) + } } } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 350731b89..7ebe9fa4a 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -105,7 +105,7 @@ var _ = Describe("Metadata", func() { props.Tags = model.RawTags{ "Title": {strings.Repeat("a", 2048)}, "Comment": {strings.Repeat("a", 8192)}, - "lyrics:xxx": {strings.Repeat("a", 60000)}, + "lyrics:xxx": {strings.Repeat("a", 2_000_000)}, } md = metadata.New(filePath, props) @@ -116,9 +116,10 @@ var _ = Describe("Metadata", func() { Expect(pair).To(HaveLen(1)) Expect(pair[0].Key()).To(Equal("xxx")) + // Lyrics keep a much larger cap so word-timed karaoke survives. // Note: a total of 6 characters are lost from maxLength from - // the key portion and separator - Expect(pair[0].Value()).To(HaveLen(32762)) + // the key portion and separator. + Expect(pair[0].Value()).To(HaveLen(1048570)) }) It("should split multiple values", func() { diff --git a/plugins/manager.go b/plugins/manager.go index 67e0ee987..a7649d47e 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -241,7 +241,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) { return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) } diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 16dddd504..294654b6a 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -110,7 +110,9 @@ main: lyrics: # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` aliases: [ uslt:description, lyrics, unsyncedlyrics ] - maxLength: 32768 + # Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song, + # while still bounding against pathological tags. + maxLength: 1048576 type: pair # ex: lyrics:eng, lyrics:xxx comment: aliases: [ comm:description, comment, ©cmt, description, icmt ] diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 12a7c95e0..0403306a6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -501,7 +501,7 @@ func setupTestDB() { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, nil, ) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 40161470b..1b8d34eb1 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -47,7 +47,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, sonicSvc, ) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index c3710394f..d19e163dd 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -106,21 +106,6 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { return addDefaultFilters(options) } -func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { - return addDefaultFilters(Options{ - Sort: "lyrics, updated_at", - Order: "desc", - Max: 1, - Filters: And{ - Eq{"title": title}, - Or{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), - }, - }, - }) -} - func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options { if len(musicFolderIds) == 0 { return opts diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e6c6f9114..4027ba8b6 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -495,48 +495,6 @@ func mapExplicitStatus(explicitStatus string) string { return "" } -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { - lines := make([]responses.Line, len(lyrics.Line)) - - for i, line := range lyrics.Line { - lines[i] = responses.Line{ - Start: line.Start, - Value: line.Value, - } - } - - structured := responses.StructuredLyric{ - DisplayArtist: lyrics.DisplayArtist, - DisplayTitle: lyrics.DisplayTitle, - Lang: lyrics.Lang, - Line: lines, - Offset: lyrics.Offset, - Synced: lyrics.Synced, - } - - if structured.DisplayArtist == "" { - structured.DisplayArtist = mf.Artist - } - if structured.DisplayTitle == "" { - structured.DisplayTitle = mf.Title - } - - return structured -} - -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { - lyricList := make(responses.StructuredLyrics, len(lyricsList)) - - for i, lyrics := range lyricsList { - lyricList[i] = buildStructuredLyric(mf, lyrics) - } - - res := &responses.LyricsList{ - StructuredLyrics: lyricList, - } - return res -} - // getUserAccessibleLibraries returns the list of libraries the current user has access to. func getUserAccessibleLibraries(ctx context.Context) []model.Library { user := getUser(ctx) diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go new file mode 100644 index 000000000..ce3c3fae4 --- /dev/null +++ b/server/subsonic/lyrics.go @@ -0,0 +1,181 @@ +package subsonic + +import ( + "slices" + "sort" + "strings" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" +) + +// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal +// layer; its cue line is emitted before other agents sharing the same index. +const agentRoleMain = "main" + +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { + filtered := lyricsList + if !enhanced { + // Without enhanced, only return main-kind entries (a blank kind is main). + filtered = nil + for _, l := range lyricsList { + if l.IsMainKind() { + filtered = append(filtered, l) + } + } + } + + lyricList := make(responses.StructuredLyrics, len(filtered)) + for i, lyrics := range filtered { + lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) + } + return &responses.LyricsList{StructuredLyrics: lyricList} +} + +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { + agents := newLyricAgents(lyrics.Agents) + + lines := make([]responses.Line, len(lyrics.Line)) + var cueLines []responses.CueLine + for i, line := range lyrics.Line { + lines[i] = responses.Line{Start: line.Start, Value: line.Value} + if enhanced && len(line.Cue) > 0 { + cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...) + } + } + + structured := responses.StructuredLyric{ + DisplayArtist: lyrics.DisplayArtist, + DisplayTitle: lyrics.DisplayTitle, + Lang: lyrics.Lang, + Line: lines, + CueLine: cueLines, + Offset: lyrics.Offset, + Synced: lyrics.Synced, + } + + if enhanced { + structured.Kind = lyrics.EffectiveKind() + if len(cueLines) > 0 && len(agents.response) > 0 { + structured.Agents = agents.response + } + } + + if structured.DisplayArtist == "" { + structured.DisplayArtist = mf.Artist + } + if structured.DisplayTitle == "" { + structured.DisplayTitle = mf.Title + } + return structured +} + +// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and +// the response agent list reused without rescanning the slice per line. +type lyricAgents struct { + orderByID map[string]int + roleByID map[string]string + response []responses.Agent +} + +func newLyricAgents(agents []model.Agent) lyricAgents { + a := lyricAgents{ + orderByID: make(map[string]int, len(agents)), + roleByID: make(map[string]string, len(agents)), + response: make([]responses.Agent, 0, len(agents)), + } + for i, agent := range agents { + a.orderByID[agent.ID] = i + a.roleByID[agent.ID] = agent.Role + a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name}) + } + return a +} + +// buildCueLines splits a line's cues by agent and emits one CueLine per agent, +// ordered main-role first then by the agent's declared order. +func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine { + agentOrder := make([]string, 0, 2) + cuesByAgent := make(map[string][]model.Cue) + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + agentID := strings.TrimSpace(cue.AgentID) + if _, exists := cuesByAgent[agentID]; !exists { + agentOrder = append(agentOrder, agentID) + } + cuesByAgent[agentID] = append(cuesByAgent[agentID], cue) + } + + sort.SliceStable(agentOrder, func(i, j int) bool { + return agents.less(agentOrder[i], agentOrder[j], i, j) + }) + + cueLines := make([]responses.CueLine, 0, len(agentOrder)) + for _, agentID := range agentOrder { + cueLine := responses.CueLine{ + Index: index, + Start: line.Start, + End: line.End, + Value: line.Value, + Cue: buildLyricCues(cuesByAgent[agentID], line.End), + } + if agentID != "" { + cueLine.AgentID = agentID + } + cueLines = append(cueLines, cueLine) + } + return cueLines +} + +// less orders two agent IDs: the main role wins, then the declared agent order, +// then known-before-unknown, then the original encounter order (origI/origJ). +func (a lyricAgents) less(left, right string, origI, origJ int) bool { + leftMain := a.roleByID[left] == agentRoleMain + rightMain := a.roleByID[right] == agentRoleMain + if leftMain != rightMain { + return leftMain + } + + leftOrder, leftOK := a.orderByID[left] + rightOrder, rightOK := a.orderByID[right] + if leftOK && rightOK && leftOrder != rightOrder { + return leftOrder < rightOrder + } + if leftOK != rightOK { + return leftOK + } + return origI < origJ +} + +func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { + if len(cues) == 0 { + return nil + } + + // Only resolve end times when at least one cue carries one; otherwise the + // group is start-only and must stay that way. + hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil }) + if hasAnyEnd { + cues = model.NormalizeCueEnds(cues, lineEnd) + } + + out := make([]responses.LyricCue, 0, len(cues)) + for i := range cues { + if cues[i].Start == nil { + continue + } + cue := responses.LyricCue{ + Start: *cues[i].Start, + Value: cues[i].Value, + ByteStart: cues[i].ByteStart, + ByteEnd: cues[i].ByteEnd, + } + if hasAnyEnd { + cue.End = cues[i].End + } + out = append(out, cue) + } + return out +} diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go new file mode 100644 index 000000000..e0f291b70 --- /dev/null +++ b/server/subsonic/lyrics_test.go @@ -0,0 +1,618 @@ +package subsonic + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetLyricsBySongId", func() { + var router *Router + var ds model.DataStore + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded,.lrc" + }) + + const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" + const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" + const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" + var times = []int64{18800, 22801} + + compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { + Expect(actual).ToNot(BeNil()) + Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) + for i, realLyric := range actual.StructuredLyrics { + expectedLyric := expected.StructuredLyrics[i] + + Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) + Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) + Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) + Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) + + if expectedLyric.Offset == nil { + Expect(realLyric.Offset).To(BeNil()) + } else { + Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) + } + + Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) + for j, realLine := range realLyric.Line { + expectedLine := expectedLyric.Line[j] + Expect(realLine.Value).To(Equal(expectedLine.Value)) + + if expectedLine.Start == nil { + Expect(realLine.Start).To(BeNil()) + } else { + Expect(*realLine.Start).To(Equal(*expectedLine.Start)) + } + } + + Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) + for j, realCueLine := range realLyric.CueLine { + expectedCueLine := expectedLyric.CueLine[j] + Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) + Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) + Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) + if expectedCueLine.Start == nil { + Expect(realCueLine.Start).To(BeNil()) + } else { + Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) + } + if expectedCueLine.End == nil { + Expect(realCueLine.End).To(BeNil()) + } else { + Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) + } + + Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) + for k, realCue := range realCueLine.Cue { + expectedCue := expectedCueLine.Cue[k] + Expect(realCue.Value).To(Equal(expectedCue.Value)) + Expect(realCue.Start).To(Equal(expectedCue.Start)) + Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) + Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) + if expectedCue.End == nil { + Expect(realCue.End).To(BeNil()) + } else { + Expect(*realCue.End).To(Equal(*expectedCue.End)) + } + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + synced, _ := model.ToLyrics("eng", syncedLyrics) + unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + lyricsJson, err := json.Marshal(model.LyricList{ + *synced, *unsynced, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + Lang: "eng", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + }, + { + Lang: "xxx", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: false, + Line: []responses.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + }, + }, + }) + }) + + It("should parse lrc metadata", func() { + r := newGetRequest("id=1") + synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) + lyricsJson, err := json.Marshal(model.LyricList{ + *synced, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + Offset: new(int64(-100)), + }, + }, + }) + }) + + It("should return multilingual TTML sidecar lyrics", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1") + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Path: "tests/fixtures/test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + porTime := int64(18800) + ttmlTime := int64(22800) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: &ttmlTime, + Value: "You know the rules and so do I", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "por", + Synced: true, + Line: []responses.Line{ + { + Start: &porTime, + Value: "Nao somos estranhos ao amor", + }, + }, + }, + }, + }) + }) + + It("should return metadata-linked translation and pronunciation tracks from TTML", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1&enhanced=true") + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Path: "tests/fixtures/test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + mainStartA := int64(1000) + mainStartB := int64(2000) + tokenStartA := int64(2000) + tokenEndA := int64(2300) + tokenStartB := int64(2300) + tokenEndB := int64(2600) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "ja", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "こんにちは", + }, + { + Start: &mainStartB, + Value: "こんばんは", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "translation", + Lang: "es", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "Hola", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "pronunciation", + Lang: "ja-latn", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartB, + Value: "konni", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &mainStartB, + End: &tokenEndB, + Value: "konni", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 1, + Value: "ko", + }, + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 2, + ByteEnd: 4, + Value: "nni", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + tokenStartA := int64(1000) + tokenEndA := int64(1400) + tokenStartB := int64(2000) + tokenEndB := int64(2500) + lyricsJson, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + Cue: []model.Cue{ + { + Start: &tokenStartA, + End: &tokenEndA, + Value: "Hello", + ByteStart: 0, + ByteEnd: 4, + AgentID: "lead", + }, + { + Start: &tokenStartB, + End: &tokenEndB, + Value: "echo", + ByteStart: 6, + ByteEnd: 9, + AgentID: "__nd_bg__|lead", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Hello echo", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 4, + Value: "Hello", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 6, + ByteEnd: 9, + Value: "echo", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should keep enhanced line-level lyrics when no cue data is available", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Kind: "main", + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Line without word timing", + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Line without word timing", + }, + }, + }, + }, + }) + }) + + It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + asciiLineStart := int64(0) + asciiLineEnd := int64(2400) + asciiCueStartA := int64(0) + asciiCueEndA := int64(300) + asciiCueStartB := int64(900) + asciiCueEndB := int64(1300) + asciiCueStartC := int64(1300) + asciiCueEndC := int64(1600) + asciiCueStartD := int64(1600) + + utfLineStart := int64(2747) + utfLineEnd := int64(6214) + utfCueStartA := int64(2747) + utfCueEndA := int64(3018) + utfCueStartB := int64(3018) + utfCueEndB := int64(3179) + utfCueStartC := int64(3582) + utfCueEndC := int64(4100) + utfCueStartD := int64(4500) + utfCueEndD := int64(6214) + + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []model.Cue{ + {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []model.Cue{ + {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + {Start: &asciiLineStart, Value: "Oh love love me tonight"}, + {Start: &utfLineStart, Value: "눈을 뜬 순간"}, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []responses.LyricCue{ + {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Index: 1, + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []responses.LyricCue{ + {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }, + }) + }) +}) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 9ab3a20b0..089a1fdda 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -98,22 +97,13 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { response := newResponse() lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse - mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) - + structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title) if err != nil { return nil, err } - if len(mediaFiles) == 0 { - return response, nil - } - - structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0]) - if err != nil { - return nil, err - } - - if len(structuredLyrics) == 0 { + mainLyric, ok := structuredLyrics.Main() + if !ok { return response, nil } @@ -121,10 +111,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Title = title var lyricsText strings.Builder - for _, line := range structuredLyrics[0].Line { + for _, line := range mainLyric.Line { lyricsText.WriteString(line.Value + "\n") } - lyricsResponse.Value = lyricsText.String() return response, nil @@ -146,8 +135,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } + enhanced, _ := req.Params(r).Bool("enhanced") + response := newResponse() - response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 12c0dff56..60deda208 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -16,7 +16,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" @@ -119,28 +118,12 @@ var _ = Describe("MediaRetrievalController", func() { }) Expect(err).ToNot(HaveOccurred()) - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) mockRepo.SetData(model.MediaFiles{ { - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer - }, - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older - }, - { - ID: "3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), }, }) response, err := router.GetLyrics(r) @@ -149,6 +132,26 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) + It("should surface the main-kind track when translation tracks are present", func() { + r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + start := int64(0) + lyricsJSON, err := json.Marshal(model.LyricList{ + {Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}}, + {Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}}, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + response, err := router.GetLyrics(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n")) + }) It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() { r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") mockRepo.SetData(model.MediaFiles{}) @@ -167,12 +170,6 @@ var _ = Describe("MediaRetrievalController", func() { Artist: "Rick Astley", Title: "Never Gonna Give You Up", }, - { - Path: "tests/fixtures/test.mp3", - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - }, }) response, err := router.GetLyrics(r) Expect(err).ToNot(HaveOccurred()) @@ -181,142 +178,6 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) }) - - Describe("GetLyricsBySongId", func() { - const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" - const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" - var times = []int64{18800, 22801} - - compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { - Expect(actual).ToNot(BeNil()) - Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) - for i, realLyric := range actual.StructuredLyrics { - expectedLyric := expected.StructuredLyrics[i] - - Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) - Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) - Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) - - if expectedLyric.Offset == nil { - Expect(realLyric.Offset).To(BeNil()) - } else { - Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) - } - - Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) - for j, realLine := range realLyric.Line { - expectedLine := expectedLyric.Line[j] - Expect(realLine.Value).To(Equal(expectedLine.Value)) - - if expectedLine.Start == nil { - Expect(realLine.Start).To(BeNil()) - } else { - Expect(*realLine.Start).To(Equal(*expectedLine.Start)) - } - } - } - } - - It("should return mixed lyrics", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: false, - Line: []responses.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - }, - }) - }) - - It("should parse lrc metadata", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - }, - }, - }) - }) - }) }) type fakeArtwork struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 85edb1012..97b3cafcc 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -11,7 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, - {Name: "songLyrics", Versions: []int32{1}}, + {Name: "songLyrics", Versions: []int32{1, 2}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 3ccbf232e..e4217303f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -58,7 +58,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(6), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), @@ -88,7 +88,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index dcb458932..7e41a1daa 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -547,13 +547,39 @@ type Line struct { Value string `xml:",chardata" json:"value"` } +type LyricCue struct { + Start int64 `xml:"start,attr" json:"start"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + ByteStart int `xml:"byteStart,attr" json:"byteStart"` + ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"` + Value string `xml:",chardata" json:"value"` +} + +type Agent struct { + ID string `xml:"id,attr" json:"id"` + Role string `xml:"role,attr" json:"role"` + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` +} + +type CueLine struct { + Index int32 `xml:"index,attr" json:"index"` + Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + Value string `xml:"value,attr" json:"value"` + AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` + Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"` +} + type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` - Synced bool `xml:"synced,attr" json:"synced"` + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` } type StructuredLyrics []StructuredLyric diff --git a/tests/fixtures/bom-test.ttml b/tests/fixtures/bom-test.ttml new file mode 100644 index 000000000..319ab1f07 --- /dev/null +++ b/tests/fixtures/bom-test.ttml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"><body><div xml:lang="eng"><p begin="00:00:00.00">BOM test line</p></div></body></tt> diff --git a/tests/fixtures/bom-utf16-test.ttml b/tests/fixtures/bom-utf16-test.ttml new file mode 100644 index 0000000000000000000000000000000000000000..a5621ef5d54ddd1a6a748046809f0ac7cf81ead1 GIT binary patch literal 414 zcmaKo;R=F45QOJ<PjUD^PYpt(^j}X<50E8SP^Xkwy?iyhiWEdFoX6h!&CEVuSfIci zXPjWrp~3}M98tq#i2yM|MEn}Qc<k8U^VP%Y>jrDAFy+*oGX-)?$ZJ_<V0zMobI@*s z43>4%3VF`Ruc_(Sm07EE;wB(%fl?J8dKcwxBxju2j!wj#8~$lHQ_`<fr=lLQvf+%8 zQZv<5Ir;?R-;gKCD&8c0MRkitmH!hHBm*&42fvvu)7BqMtDEeUZ@+T(JK!$gMtwl} literal 0 HcmV?d00001 diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc new file mode 100644 index 000000000..8f7b60f8c --- /dev/null +++ b/tests/fixtures/test-enhanced.lrc @@ -0,0 +1,6 @@ +[ar:Test Artist] +[ti:Enhanced Test] +[lang:eng] +[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here +[00:03.00]<00:03.00>More <00:03.50>words +[00:05.00]Plain line without inline markers diff --git a/tests/fixtures/test-instrumental.yaml b/tests/fixtures/test-instrumental.yaml new file mode 100644 index 000000000..84190a3b0 --- /dev/null +++ b/tests/fixtures/test-instrumental.yaml @@ -0,0 +1,6 @@ +version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true diff --git a/tests/fixtures/test-metadata.ttml b/tests/fixtures/test-metadata.ttml new file mode 100644 index 000000000..c0243c18f --- /dev/null +++ b/tests/fixtures/test-metadata.ttml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal"> + <head> + <metadata> + <iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"> + <translations> + <translation xml:lang="es"> + <text for="L1">Hola</text> + </translation> + </translations> + <transliterations> + <transliteration xml:lang="ja-Latn"> + <text for="L2"><span begin="00:02.000" end="00:02.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:02.300" end="00:02.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text> + </transliteration> + </transliterations> + </iTunesMetadata> + </metadata> + </head> + <body xml:lang="ja"> + <div> + <p begin="00:01.000" end="00:01.500" itunes:key="L1">こんにちは</p> + <p begin="00:02.000" end="00:02.700" itunes:key="L2">こんばんは</p> + </div> + </body> +</tt> diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml new file mode 100644 index 000000000..c1f95a87b --- /dev/null +++ b/tests/fixtures/test-overlapping.yaml @@ -0,0 +1,24 @@ +version: '1.0' +metadata: + title: 'Duet' + artist: 'Lead and Echo' + language: 'eng' + +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 diff --git a/tests/fixtures/test-words.yaml b/tests/fixtures/test-words.yaml new file mode 100644 index 000000000..625098d6a --- /dev/null +++ b/tests/fixtures/test-words.yaml @@ -0,0 +1,17 @@ +version: '1.0' +metadata: + title: 'Karaoke Test' + artist: 'Test Artist' + language: 'eng' + +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 diff --git a/tests/fixtures/test.elrc b/tests/fixtures/test.elrc new file mode 100644 index 000000000..01c3d2cdd --- /dev/null +++ b/tests/fixtures/test.elrc @@ -0,0 +1,5 @@ +[ar:ELRC Artist] +[ti:ELRC Song] +[lang:eng] +[00:01.00]<00:01.00>Lead <00:01.50>words +[00:03.00]Fallback line diff --git a/tests/fixtures/test.srt b/tests/fixtures/test.srt new file mode 100644 index 000000000..3c9c09a39 --- /dev/null +++ b/tests/fixtures/test.srt @@ -0,0 +1,7 @@ +1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line diff --git a/tests/fixtures/test.ttml b/tests/fixtures/test.ttml new file mode 100644 index 000000000..a85673a1b --- /dev/null +++ b/tests/fixtures/test.ttml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10"> + <body> + <div xml:lang="eng"> + <p begin="00:00:18.80">We're no strangers to love</p> + <p begin="00:00:22:24">You know the rules and so do I</p> + </div> + <div xml:lang="por"> + <p begin="188t">Nao somos estranhos ao amor</p> + </div> + </body> +</tt> diff --git a/tests/fixtures/test.yaml b/tests/fixtures/test.yaml new file mode 100644 index 000000000..bc5022b75 --- /dev/null +++ b/tests/fixtures/test.yaml @@ -0,0 +1,12 @@ +version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 + +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 diff --git a/ui/embed.go b/ui/embed.go index 3e2c413b3..2d5fcd979 100644 --- a/ui/embed.go +++ b/ui/embed.go @@ -5,7 +5,7 @@ import ( "io/fs" ) -//go:embed build/* +//go:embed all:build var filesystem embed.FS func BuildAssets() fs.FS { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 674cacf20..837f56339 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -16,3 +16,13 @@ func If[T any](cond bool, v1, v2 T) T { } return v2 } + +// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to +// avoid aliasing the pointed-to value when a separate *T is needed. +func Clone[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index a2dd8154f..bb6fae867 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -46,4 +46,25 @@ var _ = Describe("GG", func() { Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2)) }) }) + + Describe("Clone", func() { + It("returns a pointer to a copy of the value", func() { + original := 123 + cloned := gg.Clone(&original) + Expect(cloned).To(HaveValue(Equal(123))) + Expect(cloned).NotTo(BeIdenticalTo(&original)) + }) + + It("does not alias the original value", func() { + original := 123 + cloned := gg.Clone(&original) + original = 456 + Expect(*cloned).To(Equal(123)) + }) + + It("returns nil when the input is nil", func() { + var v *int + Expect(gg.Clone(v)).To(BeNil()) + }) + }) }) From aa5aa731dc6709bfc002ed0711c31b793c48595a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 19 Jun 2026 18:25:35 -0400 Subject: [PATCH 300/596] refactor(lyrics): single ParseLyrics entry point + all-format plugin lyrics (#5632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(lyrics): read sidecar files via library storage FS Routes fromExternalFile reads through storage.For(mf.LibraryPath) instead of os.Open on AbsolutePath, fixing sidecar reads for non-local backends. UTF-16 LE/BE and BOM handling preserved via ioutils.UTF8Reader. * refactor(lyrics): address review feedback on sidecar FS read - Move blank local-storage import from sources.go into lyrics_suite_test.go (the test suite already imports the local package for RegisterExtractor, so local's init() runs; production binaries get the scheme via normal wiring) - Fix misleading comment: model.ParseLyrics → model.ParseLyricsFile - Replace what-comment with why-comment in BeforeSuite explaining the log.Fatal guard that requires the no-op extractor registration * test(lyrics): add subsonic e2e baseline for getLyrics endpoints Establishes a behavioral baseline for getLyricsBySongId (v2 structured) and getLyrics (legacy) before the lyrics parser refactor. Covers embedded formats (LRC synced, plain text, TTML) and sidecar formats (LRC, SRT, YAML), all isolated under a Lyrics/ fixture folder so the new fixtures do not perturb existing test behavior beyond fixture counts. Sidecar files are injected as raw &fstest.MapFile{Data: []byte(...)} entries; the scanner skips non-audio extensions (.lrc, .srt, .yaml) so they are invisible to scanning but reachable via the fake FS at request time through fromExternalFile/storage.For. Update album/artist/song counts in the album-list, multi-library, and search3 empty-query tests to reflect the six new tracks (1 new artist, 1 new album, 6 new songs). * test(lyrics): strengthen e2e lyrics baseline (lang assertions, rename helper) Rename the local helper `main` to `firstLyric` to avoid collision with the reserved-feeling built-in name. Add `Lang` assertions to both embedded and sidecar DescribeTable entries, locking the current observed values: "xxx" (ISO 639-2 "no language specified") for all embedded and LRC/SRT sidecars, and "eng" for the YAML sidecar (which explicitly sets `language: eng`). * feat(lyrics): detect Lyricsfile YAML in content-sniffing * feat(plugins): content-sniff plugin lyrics for all formats Replace model.ToLyrics (LRC/plain only) with model.ParseEmbedded so plugin responses are content-sniffed for TTML, SRT, YAML, LRC, and plain text. ParseEmbedded returns a LyricList, so the loop now flattens multiple tracks per response entry. The test-lyrics WASM plugin gains a "ttml" format mode (configured via pdk.GetConfig) that returns a minimal TTML document; rebuilt with the standard Go wasip1 toolchain (GOOS=wasip1 GOARCH=wasm). A new Ginkgo test asserts Synced==true and the exact cue value, which the old plain-text path could not produce. GetLyrics doc comment updated to reflect content-sniffing; a later task will retarget it to ParseLyrics once that function is introduced. * test(plugins): validate plugin lyrics auto-detect across all formats The test-lyrics WASM plugin now supports per-format modes via the "format" config key: ttml, srt, yaml, lrc, and plain, in addition to the existing default plain-text response. The plugin is rebuilt with the standard Go wasip1 compiler. lyrics_adapter_test.go gains a DescribeTable covering all five formats, asserting both Synced (the discriminator that proves correct format detection) and the exact line value. This validates the full auto-detect chain (TTML → SRT → YAML/Lyricsfile → LRC → plain) end-to-end through the real plugin → adapter → parser flow. * refactor(lyrics): consolidate parsers into model.ParseLyrics * refactor(lyrics): retarget legacy callers to model.ParseLyrics Pin suffix to ".lrc" to preserve byte-identical output for stored plain/LRC text that was previously handled by the now-removed ToLyrics. * test(lyrics): fix lyrics tests after parser consolidation - Rewrite the YAML-fallback test to assert the correct design: a non-Lyricsfile .yaml sidecar returns as plain text and shadows lower-priority sources (rather than falling through to .lrc). - Add LibraryPath + relative Path split to the three subsonic tests that read sidecar files via storage.For(), so they resolve against the correct fixtures directory. - Register a no-op extractor in api_suite_test.go BeforeSuite so newLocalStorage does not fatal when storage.For is called during sidecar-lyrics tests. * test(lyrics): add per-format ParseLyrics benchmarks Baseline measurements (count=2 runs) on M2: BenchmarkParseLyrics_LRC-8 5725 178796 ns/op 49.78 MB/s 427877 B/op 523 allocs/op BenchmarkParseLyrics_Plain-8 5425 230854 ns/op 32.44 MB/s 102508 B/op 16 allocs/op BenchmarkParseLyrics_EnhancedLRC-8 1942 605893 ns/op 17.66 MB/s 860678 B/op 4256 allocs/op BenchmarkParseLyrics_SRT-8 3249 373991 ns/op 25.91 MB/s 1113575 B/op 4407 allocs/op BenchmarkParseLyrics_TTML-8 1483 813027 ns/op 13.86 MB/s 2198052 B/op 8665 allocs/op BenchmarkParseLyrics_YAML-8 1700 678250 ns/op 13.01 MB/s 1235096 B/op 8288 allocs/op BenchmarkParseLyrics_SniffTTML-8 1525 776482 ns/op 14.51 MB/s 2225448 B/op 8681 allocs/op BenchmarkParseLyrics_SniffSRT-8 2528 451210 ns/op 21.48 MB/s 1157000 B/op 4422 allocs/op BenchmarkParseLyrics_SniffYAML-8 1333 827152 ns/op 10.67 MB/s 1337195 B/op 8718 allocs/op BenchmarkParseLyrics_SniffLRC-8 2820 413038 ns/op 21.55 MB/s 588934 B/op 1812 allocs/op BenchmarkParseLyrics_SniffPlain-8 2968 409091 ns/op 18.31 MB/s 254470 B/op 1491 allocs/op Content-sniff path overhead: 1.5–15% depending on format. * test(lyrics): use real public-domain fixtures for parser benchmarks Replace synthetic benchmark payloads with 'Auld Lang Syne' (Robert Burns, 1788, public domain) rendered into every supported format (LRC, plain, enhanced LRC, SRT, TTML, Lyricsfile YAML) so the numbers reflect realistic content. Same song across formats makes per-format cost comparable. Baseline (Apple M-series, -benchmem, real fixtures): LRC ~28 us/op 42 KB 147 allocs Plain ~23 us/op 18 KB 22 allocs EnhancedLRC ~37 us/op 51 KB 374 allocs SRT ~52 us/op 139 KB 581 allocs TTML ~119 us/op 276 KB 1227 allocs YAML ~142 us/op 193 KB 1732 allocs Sniff(LRC) ~47 us/op 57 KB 237 allocs Sniff(TTML) ~122 us/op 282 KB 1250 allocs Sniff(YAML) ~186 us/op 218 KB 1847 allocs Fixtures in tests/fixtures/lyrics/. * fix(lyrics): preserve [] (not null) for empty lyrics in backfill migration ParseLyrics returns nil for zero-line input (whitespace-only stored lyrics). json.Marshal(nil LyricList) produces null, violating the DB invariant that media_file.lyrics uses [] for empty lyrics, never null. Initialize to model.LyricList{} when ParseLyrics returns nil so the marshalled result is always []. * refactor(lyrics): unify parser dispatch and centralize empty-list invariant Apply thermo-nuclear review findings (behavior-preserving): - Replace the suffix switch + three single-use closure adapters (parseTTMLKnown/parseSRTKnown + inline YAML closure) with a bySuffix map of a single lyricParser(lang, contents) signature. Normalize parseTTMLWithDefaultLang/parseSRTWithLanguage to that (lang, contents) order so no adapter glue is needed. - Collapse the parallel sniffLyrics engine into one parseFirstMatch primitive shared by both the suffix and content-sniff paths (sniffOrder candidate list). TTML stays gated via parseTTMLIfDocument in sniff mode to avoid running the XML decoder on plain/LRC text. - Add LyricList.MarshalJSON so empty/nil always serializes to [] (the lyrics column invariant), in one canonical place. Delete the migration's nil-guard, which the marshaler now subsumes. Behavior verified unchanged: full suite + race + e2e green. * refactor(lyrics): single registry drives both suffix dispatch and sniff order Collapse the bySuffix map and sniffOrder slice into one ordered registry: slice order is the content-sniff probe order, each row's suffixes drive sidecar dispatch, and per-row bySuffix/byContent parsers preserve the gated-TTML-when-sniffing distinction. One source of truth, no duplicated parser references. * refactor(lyrics): self-skipping parsers collapse the format table to one column Move the TTML <tt>-document gate into parseTTMLWithDefaultLang itself (after the encoding fixup, so UTF-16-declared docs are still recognized): non-TTML content returns (nil, nil) to skip; a malformed <tt> document still errors. SRT and Lyricsfile YAML already self-skip. With every structured parser self-skipping, the format table drops to one {suffixes, parse} column named lyricFormats — no bySuffix/byContent split, no separate sniff-only TTML gate. Both the suffix and content-sniff paths share the same parser per format. * refactor(lyrics): strip BOM once at ParseLyrics entry for all paths Previously only the content-sniff path stripped the BOM; the suffix path relied on its callers (fromExternalFile via UTF8Reader) having already stripped it. That implicit contract was fragile — a caller passing raw BOM-prefixed bytes with a suffix would reach the parsers with the BOM intact (SanitizeText does not strip it). Strip once at entry so every path and parser sees clean bytes regardless of caller. No-op for already-stripped input. * refactor(lyrics): trim verbose comments to essential why * refactor(lyrics): move LRC parser to its own lyrics_lrc.go Extract parseLRC, the enhanced-LRC helpers (parseEnhancedLine, adjustGroup, stripEnhancedMarkers, shiftELRCCues), parseTime, and the LRC regexes from lyrics.go into lyrics_lrc.go, with the parseLRC tests in lyrics_lrc_test.go. This makes the layout symmetric — one file per format (lrc/srt/ttml/yaml) — and leaves lyrics.go holding only shared types and cue normalization. All moved symbols were already LRC-private; no behavior change. * refactor(lyrics): collapse ParseLyrics suffix/sniff branches into one loop Both modes differ only in which formats to try, so select candidates in a single loop (all formats when sniffing, the suffix's own otherwise) and run them through parseFirstMatch once. Drops the projected-slice make+index and the ContainsFunc closure; unmatched suffixes yield no candidates and fall to the plain-text floor, as before. * refactor(lyrics): apply simplify-review cleanups - stripBOM: bytes.TrimPrefix instead of []byte<->string round-trip (no alloc) - ParseLyrics: pre-size the candidates slice - move isTTMLDocument to lyrics_ttml.go beside its only caller (the dispatch layer should hold no per-format knowledge) * refactor(lyrics): simplify test descriptions for structured lyrics Signed-off-by: Deluan <deluan@navidrome.org> * refactor(lyrics): fold parseLyricsfile into lyricParser signature and rename file - parseLyricsfile now matches the lyricParser signature directly (reads via bytes.NewReader), removing the parseLyricsfileBytes adapter and the string(contents) copy; the lyricFormats table references it directly. - StructuredLyrics drops the vestigial LyricList{} init (json.Unmarshal overwrites; MarshalJSON owns the empty->[] invariant). - Rename lyricsfile.go -> lyrics_lyricsfile.go (and its test) to match the lyrics_<format>.go convention used by lrc/srt/ttml. * refactor(lyrics): move test-only parseTTML/parseSRT wrappers to test files These zero-arg wrappers (defaulting lang to "xxx") had no production callers after the consolidation — only the format tests used them. Move each beside its tests so the production files carry no test-only code. * build: exclude generated *_gen.go files from linting The plugin host *_gen.go files (ndpgen output) were tripping the whitespace linter despite carrying a generated marker. Exclude them by path so make lint and the pre-push hook pass on untouched generated code. * perf(lyrics): drop []byte/string round-trips in parsers Apply code-review feedback to remove avoidable allocations in the lyrics parsers. isTTMLDocument now takes []byte directly, so parseTTMLWithDefaultLang no longer copies its buffer into a string before the TTML probe. parseSRTBlock splits its block with strings.Split instead of converting to []byte and back per line. ParseLyrics hoists strings.ToLower(suffix) out of the format loop. No behavior change; the dropped len(scanner)==0 SRT guard was dead (strings.Split never returns an empty slice, and the existing len(lines)==0 check still covers empty input). Signed-off-by: Deluan <deluan@navidrome.org> * refactor(lyrics): colocate and unexport cue-normalization helpers Move the cue-normalization machinery out of lyrics.go into a dedicated lyrics_normalize.go (with lyrics_normalize_test.go), leaving lyrics.go to hold just the shared lyric types and their methods. lyrics.go was mixing the domain type/contract definitions with format-agnostic post-processing. Unexport normalizeLyrics, normalizeCueLines, and normalizeLineTiming: they have no callers outside the model package, so they should not be part of its public API. NormalizeCueEnds stays exported because the Subsonic enhanced-lyrics serializer (server/subsonic/lyrics.go) resolves cue ends per agent group while building the response; that is the only legitimate cross-package caller. Also includes a small no-op robustness tweak in parseLRC: len(times) == 0 instead of times == nil (equivalent here, more idiomatic). No behavior change. * test(lyrics): add direct coverage for NormalizeCueEnds NormalizeCueEnds is exported and carries the most intricate logic in the normalization cluster (fill-from-next, fill-from-fallback, both clamps, and the all-or-none clear), but was only exercised transitively. Add a focused spec covering each branch plus the empty-input and no-mutation guarantees, bringing the function to 100% coverage. * test(lyrics): cover legacy getLyrics across formats and sources Expand the legacy getLyrics e2e coverage from a single embedded-plain case to a table over all six fixtures: embedded LRC/plain/TTML and sidecar LRC/SRT/YAML. Each case asserts the v1 plain-text fallback contract — the structured lyric is flattened to LRC-style plain text with no timing markup leaking through (no LRC brackets, SRT arrows, or XML tags), regardless of the source format or whether it is embedded or a sidecar file. This pins the behavior that synced TTML/SRT/ YAML formats degrade gracefully to plain text on the legacy endpoint. * test(lyrics): cover songLyrics v1 vs v2 with word-level fixtures Correct and expand the e2e lyrics coverage to match the OpenSubsonic songLyrics extension contract: - v1 (getLyricsBySongId, no enhanced): line-level lyrics with no cueLine, kind, or agents — even for word-level formats (ELRC, Lyricsfile YAML). - v2 (getLyricsBySongId?enhanced=true): word-level cueLine surfaces for ELRC and YAML sources; kind="main" is set; a line-level source (SRT) still yields no cueLine even when enhanced. - legacy getLyrics (artist/title): the original Subsonic endpoint, flattening any format to plain text. A prior commit mislabeled this as the "v1 contract"; getLyrics predates OpenSubsonic and is unrelated to the extension versions. Drive these with the public-domain tests/fixtures/lyrics files (the same set the parser benchmarks use) so the e2e content stays in sync and actually carries the word-level timing needed to distinguish v1 from v2. The embedded "synced LRC" fixture is upgraded to ELRC (word-level); track counts are unchanged, so the rest of the suite is unaffected. * test(lyrics): parameterize v2 enhanced coverage across all formats Convert the v2 (enhanced) e2e block from three ad-hoc cases into a DescribeTable covering all six formats, matching the v1 and legacy tables. Each entry declares whether the source carries word-level timing: ELRC, TTML, and Lyricsfile YAML surface a cueLine; LRC, SRT, and plain text do not. All six get kind="main". Add word-level <span> timing to the first line of the auld-lang-syne.ttml fixture so TTML exercises the word-level cueLine path (the parser already supports <span begin/end>, but the fixture was line-level only). The first line now yields the same five word cues as the ELRC and YAML fixtures, keeping the table assertions uniform across formats. * fix(lyrics): honor caller language when Lyricsfile YAML omits it parseLyricsfile discarded the caller's language argument, so a Lyricsfile YAML parsed from an embedded tag or plugin response with no metadata.language was labeled "xxx" even when ParseLyrics was given a language. The SRT and TTML parsers already use the caller language as their default; fall back to it here too, preferring the document's own metadata.language when present. Also reword a misleading TTML comment: isTTMLDocument still runs an XML decode (it stops at the first element), so the skip avoids the full TTML parse, not the XML decoder entirely. * refactor(lyrics): consolidate lyrics parsing functions names Signed-off-by: Deluan <deluan@navidrome.org> * test(lyrics): drop test-only parse wrappers after parser rename Commit 48c0173e8 renamed the production parsers to parseTTML/parseSRT, which collided with the same-named test-only wrappers and broke the model test build (parseTTML/parseSRT redeclared). Remove the wrappers and call the production parsers directly with the placeholder language at each test site. * test(lyrics): complete the truncated enhanced-LRC fixture The auld-lang-syne.elrc fixture stopped after the first two stanzas (8 lyric lines) while every other format fixture carries the full 24-line song. Extend it to all 24 lines with per-word timing so it is a faithful enhanced-LRC sample and the EnhancedLRC parser benchmark runs on a workload comparable to the others. The first line's word timings are unchanged, so the e2e cueLine assertions still hold. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- .golangci.yml | 1 + core/lyrics/lyrics_suite_test.go | 18 + core/lyrics/lyrics_test.go | 18 +- core/lyrics/sources.go | 38 +- core/lyrics/sources_test.go | 67 +-- .../20231209211223_alter_lyric_column.go | 4 +- model/lyrics.go | 506 +----------------- model/lyrics_benchmark_test.go | 45 ++ model/lyrics_embedded.go | 55 -- model/lyrics_lrc.go | 350 ++++++++++++ model/lyrics_lrc_test.go | 219 ++++++++ model/{lyricsfile.go => lyrics_lyricsfile.go} | 21 +- ...file_test.go => lyrics_lyricsfile_test.go} | 41 +- model/lyrics_normalize.go | 134 +++++ model/lyrics_normalize_test.go | 120 +++++ model/lyrics_parse.go | 73 +++ ..._embedded_test.go => lyrics_parse_test.go} | 69 ++- model/lyrics_srt.go | 21 +- model/lyrics_srt_test.go | 30 ++ model/lyrics_test.go | 243 +-------- model/lyrics_ttml.go | 30 +- model/lyrics_ttml_test.go | 28 +- model/mediafile.go | 2 +- model/metadata/map_mediafile.go | 2 +- plugins/lyrics_adapter.go | 12 +- plugins/lyrics_adapter_test.go | 26 + plugins/testdata/test-lyrics/main.go | 37 +- scanner/metadata_old/metadata.go | 14 +- server/e2e/e2e_suite_test.go | 43 ++ server/e2e/subsonic_album_lists_test.go | 30 +- server/e2e/subsonic_lyrics_test.go | 124 +++++ server/e2e/subsonic_multilibrary_test.go | 2 +- server/e2e/subsonic_searching_test.go | 6 +- server/subsonic/api_suite_test.go | 17 + server/subsonic/lyrics_test.go | 40 +- server/subsonic/media_retrieval_test.go | 17 +- tests/fixtures/lyrics/auld-lang-syne.elrc | 27 + tests/fixtures/lyrics/auld-lang-syne.lrc | 28 + tests/fixtures/lyrics/auld-lang-syne.srt | 95 ++++ tests/fixtures/lyrics/auld-lang-syne.ttml | 31 ++ tests/fixtures/lyrics/auld-lang-syne.txt | 24 + tests/fixtures/lyrics/auld-lang-syne.yaml | 95 ++++ 42 files changed, 1841 insertions(+), 962 deletions(-) create mode 100644 model/lyrics_benchmark_test.go delete mode 100644 model/lyrics_embedded.go create mode 100644 model/lyrics_lrc.go create mode 100644 model/lyrics_lrc_test.go rename model/{lyricsfile.go => lyrics_lyricsfile.go} (91%) rename model/{lyricsfile_test.go => lyrics_lyricsfile_test.go} (85%) create mode 100644 model/lyrics_normalize.go create mode 100644 model/lyrics_normalize_test.go create mode 100644 model/lyrics_parse.go rename model/{lyrics_embedded_test.go => lyrics_parse_test.go} (60%) create mode 100644 model/lyrics_srt_test.go create mode 100644 server/e2e/subsonic_lyrics_test.go create mode 100644 tests/fixtures/lyrics/auld-lang-syne.elrc create mode 100644 tests/fixtures/lyrics/auld-lang-syne.lrc create mode 100644 tests/fixtures/lyrics/auld-lang-syne.srt create mode 100644 tests/fixtures/lyrics/auld-lang-syne.ttml create mode 100644 tests/fixtures/lyrics/auld-lang-syne.txt create mode 100644 tests/fixtures/lyrics/auld-lang-syne.yaml diff --git a/.golangci.yml b/.golangci.yml index 76eb882ca..200fe122f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -68,6 +68,7 @@ linters: - builtin$ - examples$ - node_modules + - _gen\.go$ formatters: exclusions: generated: lax diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go index f87381905..c9fdcbae8 100644 --- a/core/lyrics/lyrics_suite_test.go +++ b/core/lyrics/lyrics_suite_test.go @@ -1,9 +1,13 @@ package lyrics_test import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,17 @@ func TestLyrics(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Lyrics Suite") } + +// core/storage/local calls log.Fatal if the default scanner extractor is unregistered +// when constructing any localStorage. Register a no-op so storage.For("file://...") works +// in tests without importing the real extractor. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &noopExtractor{} + }) +}) + +type noopExtractor struct{} + +func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *noopExtractor) Version() string { return "noop" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index a16d04712..6baacbe71 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -22,8 +22,9 @@ var _ = Describe("Lyrics", func() { var ctx context.Context const badLyrics = "This is a set of lyrics\nThat is not good" - unsynced, _ := model.ToLyrics("xxx", badLyrics) - embeddedLyrics := model.LyricList{*unsynced} + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics)) + unsynced, _ := unsyncedList.Main() + embeddedLyrics := model.LyricList{unsynced} syncedLyrics := model.LyricList{ model.Lyrics{ @@ -224,7 +225,7 @@ var _ = Describe("Lyrics", func() { })) }) - It("falls through generic YAML sidecars that are not Lyricsfile documents", func() { + It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() { dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { @@ -241,10 +242,14 @@ var _ = Describe("Lyrics", func() { Path: "song.mp3", }) + // ParseLyrics falls back to plain text for any suffix when the content + // doesn't match the structured format, so the .yaml hit is non-empty and + // shadows the lower-priority .lrc entirely. Expect(err).To(BeNil()) Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) Expect(list[0].Line).To(Equal([]model.Line{ - {Start: new(int64(1000)), Value: "Fallback line"}, + {Value: "title: not lyricsfile"}, })) }) @@ -385,9 +390,10 @@ var _ = Describe("Lyrics", func() { }) It("resolves lyrics from the matched media files", func() { - embedded, err := model.ToLyrics("eng", "Embedded lyrics line") + embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line")) Expect(err).ToNot(HaveOccurred()) - embeddedJSON, err := json.Marshal(model.LyricList{*embedded}) + embedded, _ := embeddedList.Main() + embeddedJSON, err := json.Marshal(model.LyricList{embedded}) Expect(err).ToNot(HaveOccurred()) repo.SetData(model.MediaFiles{ {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 2962c6e5c..9de2f6a18 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -3,9 +3,12 @@ package lyrics import ( "context" "errors" - "os" + "fmt" + "io" + "io/fs" "path" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/ioutils" @@ -23,31 +26,44 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er } func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { - basePath := mf.AbsolutePath() - ext := path.Ext(basePath) + ext := path.Ext(mf.Path) + sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix - externalLyric := basePath[0:len(basePath)-len(ext)] + suffix + store, err := storage.For(mf.LibraryPath) + if err != nil { + return nil, fmt.Errorf("getting storage for library: %w", err) + } + fsys, err := store.FS() + if err != nil { + return nil, fmt.Errorf("opening library filesystem: %w", err) + } - contents, err := ioutils.UTF8ReadFile(externalLyric) - if errors.Is(err, os.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path", "path", externalLyric) + f, err := fsys.Open(sidecarRelPath) + if errors.Is(err, fs.ErrNotExist) { + log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath) return nil, nil } else if err != nil { return nil, err } + defer f.Close() - list, err := model.ParseLyricsFile(suffix, contents) + contents, err := io.ReadAll(ioutils.UTF8Reader(f)) if err != nil { - log.Error(ctx, "error parsing external lyric file", "path", externalLyric, err) + return nil, err + } + + list, err := model.ParseLyrics(suffix, "xxx", contents) + if err != nil { + log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err) return nil, err } if len(list) == 0 { - log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) + log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath) return nil, nil } - log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) + log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath) return list, nil } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 002931c0c..68f45424e 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -3,6 +3,7 @@ package lyrics import ( "context" "encoding/json" + "path/filepath" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -25,10 +26,12 @@ var _ = Describe("sources", func() { const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() - expectedList := model.LyricList{*synced, *unsynced} + expectedList := model.LyricList{synced, unsynced} lyricsJson, err := json.Marshal(expectedList) Expect(err).ToNot(HaveOccurred()) @@ -53,46 +56,51 @@ var _ = Describe("sources", func() { }) Describe("fromExternalFile", func() { + var fixturesDir string + + BeforeEach(func() { + // tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly. + abs, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + fixturesDir = abs + }) + + mf := func(name string) *model.MediaFile { + return &model.MediaFile{LibraryPath: fixturesDir, Path: name} + } + It("should return nil for lyrics that don't exist", func() { - mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(0)) }) - // fromExternalFile delegates format parsing to model.ParseLyricsFile; the + // fromExternalFile delegates format parsing to model.ParseLyrics; the // per-format parser output is covered exhaustively in the model package. - // Here we only verify each suffix is read from disk and routed to a parser. + // Here we only verify each suffix is read from the library FS and routed. DescribeTable("should read the sidecar file and route its suffix to a parser", - func(path, suffix string, expectSynced bool) { - mf := model.MediaFile{Path: path} - lyrics, err := fromExternalFile(ctx, &mf, suffix) + func(name, suffix string, expectSynced bool) { + lyrics, err := fromExternalFile(ctx, mf(name), suffix) Expect(err).To(BeNil()) Expect(lyrics).ToNot(BeEmpty()) Expect(lyrics[0].Line).ToNot(BeEmpty()) Expect(lyrics[0].Synced).To(Equal(expectSynced)) }, - Entry(".lrc synced", "tests/fixtures/test.mp3", ".lrc", true), - Entry(".elrc enhanced", "tests/fixtures/test.mp3", ".elrc", true), - Entry(".txt plain", "tests/fixtures/test.mp3", ".txt", false), - Entry(".srt subtitles", "tests/fixtures/test.mp3", ".srt", true), - Entry(".ttml multilingual", "tests/fixtures/test.mp3", ".ttml", true), - Entry(".yaml lyricsfile", "tests/fixtures/test.mp3", ".yaml", true), + Entry(".lrc synced", "test.mp3", ".lrc", true), + Entry(".elrc enhanced", "test.mp3", ".elrc", true), + Entry(".txt plain", "test.mp3", ".txt", false), + Entry(".srt subtitles", "test.mp3", ".srt", true), + Entry(".ttml multilingual", "test.mp3", ".ttml", true), + Entry(".yaml lyricsfile", "test.mp3", ".yaml", true), ) It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { - // The function looks for <basePath-without-ext><suffix>, so we need to pass - // a MediaFile with .mp3 path and look for .lrc suffix - mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) @@ -100,14 +108,10 @@ var _ = Describe("sources", func() { }) It("should handle UTF-16 LE encoded LRC files", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) @@ -117,8 +121,7 @@ var _ = Describe("sources", func() { }) It("should handle TTML files with UTF-8 BOM marker", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".ttml") + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(1)) @@ -130,8 +133,7 @@ var _ = Describe("sources", func() { }) It("should handle UTF-16 BE encoded TTML files", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".ttml") + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(1)) @@ -143,6 +145,5 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) }) - }) }) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index 891cb9f5b..259a37745 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -46,12 +46,12 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { continue } - lyrics, err := model.ToLyrics("xxx", lyrics.String) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String)) if err != nil { return err } - text, err := json.Marshal(model.LyricList{*lyrics}) + text, err := json.Marshal(parsed) if err != nil { return err } diff --git a/model/lyrics.go b/model/lyrics.go index bf3936f46..111b1c2a9 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -1,17 +1,8 @@ package model import ( - "cmp" - "fmt" - "regexp" - "slices" - "strconv" + "encoding/json" "strings" - "unicode" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/str" ) type Cue struct { @@ -55,20 +46,6 @@ const ( LyricKindPronunciation = "pronunciation" ) -// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` - -var ( - // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) - - // Enhanced LRC: inline word-level timing markers like <00:12.34> - enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` - enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) -) - func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } @@ -89,358 +66,16 @@ func (l Lyrics) EffectiveKind() string { return l.Kind } -func ToLyrics(language, text string) (*Lyrics, error) { - text = str.SanitizeText(text) - - lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) - - artist := "" - title := "" - var offset *int64 = nil - - synced := syncRegex.MatchString(text) - priorLine := "" - validLine := false - repeated := false - var timestamps []int64 - - for _, line := range lines { - line := strings.TrimSpace(line) - if line == "" { - if validLine { - priorLine += "\n" - } - continue - } - var text string - var time *int64 = nil - - if synced { - idTag := lrcIdRegex.FindStringSubmatch(line) - if idTag != nil { - switch idTag[1] { - case "ar": - artist = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "lang": - language = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "offset": - { - off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) - if err != nil { - log.Warn("Error parsing offset", "offset", idTag[2], "error", err) - } else { - offset = &off - } - } - case "ti": - title = str.SanitizeText(strings.TrimSpace(idTag[2])) - } - - continue - } - - times := timeRegex.FindAllStringSubmatchIndex(line, -1) - if len(times) > 1 { - repeated = true - } - - // The second condition is for when there is a timestamp in the middle of - // a line (after any text) - if times == nil || times[0][0] != 0 { - if validLine { - priorLine += "\n" + line - } - continue - } - - if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } - timestamps = nil - } - - end := 0 - - // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] - for _, match := range times { - // for multiple matches, we need to check that later matches are not - // in the middle of the string - if end != 0 { - middle := strings.TrimSpace(line[end:match[0]]) - if middle != "" { - break - } - } - - end = match[1] - timeInMillis, err := parseTime(line, match) - if err != nil { - return nil, err - } - - timestamps = append(timestamps, timeInMillis) - } - - if end >= len(line) { - priorLine = "" - } else { - priorLine = strings.TrimSpace(line[end:]) - } - - validLine = true - } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, - }) - } - } - - if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } - } - - // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time - if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { - return cmp.Compare(*a.Start, *b.Start) - }) - } - - lyrics := Lyrics{ - DisplayArtist: artist, - DisplayTitle: title, - Lang: language, - Line: NormalizeCueLines(structuredLines), - Offset: offset, - Synced: synced, - } - return &lyrics, nil -} - -// ParseLyricsFile parses a sidecar lyrics file, dispatching on its extension to -// the matching format parser. Unknown extensions fall back to the generic -// LRC/plain-text parser. It is the single owner of the suffix→parser mapping, -// mirroring [ParseEmbedded] for tag-embedded lyrics. -func ParseLyricsFile(suffix string, contents []byte) (LyricList, error) { - var list LyricList - var err error - switch { - case strings.EqualFold(suffix, ".ttml"): - list, err = ParseTTML(contents) - case strings.EqualFold(suffix, ".srt"): - list, err = ParseSRT(contents) - case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"): - list, err = ParseLyricsfile(string(contents)) - default: - var lyric *Lyrics - lyric, err = ToLyrics("xxx", string(contents)) - if lyric != nil { - list = LyricList{*lyric} - } - } - if err != nil { - return nil, fmt.Errorf("parsing %s lyrics: %w", strings.TrimPrefix(suffix, "."), err) - } - return list, nil -} - -// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers -// and computes UTF-8 byte offsets against the final stripped line value. -func parseEnhancedLine(text string) (string, []Cue) { - matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) - if len(matches) == 0 { - return strings.TrimSpace(text), nil - } - - type segment struct { - start int64 - rawStart int - rawEnd int - } - - segments := make([]segment, 0, len(matches)) - var rawValue strings.Builder - for i, match := range matches { - timeMs, err := parseTime( - // Rewrite <...> as [...] so parseTime can handle it with the same logic - "["+text[match[0]+1:match[1]-1]+"]", - // Adjust match indices to point into our rewritten string (need start/end pairs for each group) - []int{ - 0, match[1] - match[0], - adjustGroup(match, 2), adjustGroup(match, 3), - adjustGroup(match, 4), adjustGroup(match, 5), - adjustGroup(match, 6), adjustGroup(match, 7), - adjustGroup(match, 8), adjustGroup(match, 9), - }, - ) - if err != nil { - continue - } - - // Text runs from after this marker to the start of the next marker (or end of string) - textStart := match[1] - var textEnd int - if i+1 < len(matches) { - textEnd = matches[i+1][0] - } else { - textEnd = len(text) - } - - word := text[textStart:textEnd] - if word == "" { - continue - } - - rawStart := rawValue.Len() - rawValue.WriteString(word) - segments = append(segments, segment{ - start: timeMs, - rawStart: rawStart, - rawEnd: rawValue.Len(), - }) - } - - if len(segments) == 0 { - return strings.TrimSpace(stripEnhancedMarkers(text)), nil - } - - finalRaw := rawValue.String() - leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) - rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) - trimmedEnd := len(finalRaw) - rightTrimBytes - if trimmedEnd < leftTrimBytes { - trimmedEnd = leftTrimBytes - } - - cues := make([]Cue, 0, len(segments)) - for _, seg := range segments { - start := seg.start - byteStart := max(seg.rawStart, leftTrimBytes) - byteEnd := min(seg.rawEnd, trimmedEnd) - if byteStart >= byteEnd { - continue - } - - cues = append(cues, Cue{ - Start: &start, - Value: finalRaw[byteStart:byteEnd], - ByteStart: byteStart - leftTrimBytes, - ByteEnd: byteEnd - leftTrimBytes - 1, - }) - } - - return strings.TrimSpace(finalRaw), cues -} - -// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. -// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. -func adjustGroup(match []int, groupIdx int) int { - orig := match[groupIdx] - if orig == -1 { - return -1 - } - // Offset is: original position minus the position of '<' in the original, plus 1 for '[' - return orig - match[0] -} - -// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text, -// returning the plain lyric text. -func stripEnhancedMarkers(text string) string { - return enhancedLRCRegex.ReplaceAllString(text, "") -} - -// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End -// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute -// timestamps anchored at the line's first occurrence, so repeated-line LRC -// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the -// second occurrence to point at the correct moment. Returned *int64 pointers -// are freshly allocated so the input slice is never aliased into the result. -func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { - if len(baseCues) == 0 { - return nil - } - out := make([]Cue, len(baseCues)) - for i, c := range baseCues { - out[i] = c - if c.Start != nil { - s := *c.Start + offsetMs - out[i].Start = &s - } - if c.End != nil { - e := *c.End + offsetMs - out[i].End = &e - } - } - return out -} - -func parseTime(line string, match []int) (int64, error) { - var hours, millis int64 - var err error - - hourStart := match[2] - if hourStart != -1 { - // subtract 1 because group has : at the end - hourEnd := match[3] - 1 - hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) - if err != nil { - return 0, err - } - } - - minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) - if err != nil { - return 0, err - } - - sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) - if err != nil { - return 0, err - } - - msStart := match[8] - if msStart != -1 { - msEnd := match[9] - // +1 offset since this capture group contains . - millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) - if err != nil { - return 0, err - } - - length := msEnd - msStart - - if length == 3 { - millis *= 10 - } else if length == 2 { - millis *= 100 - } - } - - timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis - return timeInMillis, nil -} - type LyricList []Lyrics +// MarshalJSON keeps the lyrics column invariant: empty/nil serializes to [], never null. +func (ll LyricList) MarshalJSON() ([]byte, error) { + if len(ll) == 0 { + return []byte("[]"), nil + } + return json.Marshal([]Lyrics(ll)) +} + // Main returns the main-kind lyric, falling back to the first entry so untyped // lyrics still resolve. The bool is false only when the list is empty. It is // used to surface a single lyric through the plain-text legacy getLyrics @@ -456,126 +91,3 @@ func (ll LyricList) Main() (Lyrics, bool) { } return ll[0], true } - -func NormalizeLyrics(lyrics Lyrics) Lyrics { - lyrics.Line = NormalizeCueLines(lyrics.Line) - if len(lyrics.Agents) == 0 { - lyrics.Agents = nil - } - return lyrics -} - -func NormalizeCueLines(lines []Line) []Line { - if len(lines) == 0 { - return lines - } - - normalized := make([]Line, len(lines)) - copy(normalized, lines) - - for i := range normalized { - if len(normalized[i].Cue) > 0 { - normalized[i].Cue = slices.Clone(normalized[i].Cue) - } - - var fallbackEnd *int64 - if normalized[i].End != nil { - v := *normalized[i].End - fallbackEnd = &v - } else if i+1 < len(normalized) && normalized[i+1].Start != nil { - v := *normalized[i+1].Start - fallbackEnd = &v - } - - normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) - } - - return normalized -} - -func NormalizeLineTiming(line Line) Line { - if len(line.Cue) == 0 { - return line - } - - var earliestStart *int64 - var latestEnd *int64 - for i := range line.Cue { - token := line.Cue[i] - if token.Start != nil { - if earliestStart == nil || *token.Start < *earliestStart { - v := *token.Start - earliestStart = &v - } - } - - candidateEnd := token.End - if candidateEnd == nil { - candidateEnd = token.Start - } - if candidateEnd != nil { - if latestEnd == nil || *candidateEnd > *latestEnd { - v := *candidateEnd - latestEnd = &v - } - } - } - - if line.Start == nil && earliestStart != nil { - v := *earliestStart - line.Start = &v - } - if line.End == nil && latestEnd != nil { - v := *latestEnd - line.End = &v - } - return line -} - -func normalizeCueLine(line Line, fallbackEnd *int64) Line { - if len(line.Cue) == 0 { - return line - } - line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) - return NormalizeLineTiming(line) -} - -// NormalizeCueEnds resolves missing cue end times within a single ordered cue -// group: each end is filled from the next cue's start, then from fallbackEnd, -// and is clamped so it never precedes the cue's own start nor overruns the next -// cue. End times are all-or-none — if any cue still lacks an end afterwards, all -// ends in the group are cleared. The input slice is never mutated. -func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { - if len(cues) == 0 { - return cues - } - - out := slices.Clone(cues) - for i := range out { - end := out[i].End - if end == nil { - if i+1 < len(out) && out[i+1].Start != nil { - end = out[i+1].Start - } else { - end = fallbackEnd - } - } - if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { - end = out[i+1].Start - } - if end != nil && out[i].Start != nil && *end < *out[i].Start { - end = out[i].Start - } - out[i].End = gg.Clone(end) - } - - for i := range out { - if out[i].End == nil { - for j := range out { - out[j].End = nil - } - break - } - } - return out -} diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go new file mode 100644 index 000000000..0de2549e7 --- /dev/null +++ b/model/lyrics_benchmark_test.go @@ -0,0 +1,45 @@ +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// Benchmark payloads are real public-domain lyrics ("Auld Lang Syne", Robert +// Burns, 1788) rendered into every supported format, so the numbers reflect +// realistic content and sizing. The same song across formats makes per-format +// cost directly comparable. Fixtures live in tests/fixtures/lyrics/. +func loadLyricFixture(b *testing.B, name string) []byte { + b.Helper() + contents, err := os.ReadFile(filepath.Join("..", "tests", "fixtures", "lyrics", name)) + if err != nil { + b.Fatal(err) + } + return contents +} + +func benchmarkParse(b *testing.B, suffix, fixture string) { + contents := loadLyricFixture(b, fixture) + b.ReportAllocs() + b.SetBytes(int64(len(contents))) + for b.Loop() { + if _, err := ParseLyrics(suffix, "eng", contents); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkParseLyrics_LRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_Plain(b *testing.B) { benchmarkParse(b, ".txt", "auld-lang-syne.txt") } +func BenchmarkParseLyrics_EnhancedLRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.elrc") } +func BenchmarkParseLyrics_SRT(b *testing.B) { benchmarkParse(b, ".srt", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_TTML(b *testing.B) { benchmarkParse(b, ".ttml", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_YAML(b *testing.B) { benchmarkParse(b, ".yaml", "auld-lang-syne.yaml") } + +// Content-sniff path (empty suffix) — what embedded tags and plugins hit. +func BenchmarkParseLyrics_SniffTTML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_SniffSRT(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_SniffYAML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.yaml") } +func BenchmarkParseLyrics_SniffLRC(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_SniffPlain(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.txt") } diff --git a/model/lyrics_embedded.go b/model/lyrics_embedded.go deleted file mode 100644 index 7b412556e..000000000 --- a/model/lyrics_embedded.go +++ /dev/null @@ -1,55 +0,0 @@ -package model - -import ( - "encoding/xml" - "strings" - - "github.com/navidrome/navidrome/log" -) - -// ParseEmbedded parses lyrics read from media-file metadata tags. It detects rich -// payloads before falling back to the generic LRC/plain-text parser, because -// text sanitization would otherwise strip TTML XML markup. -func ParseEmbedded(language, text string) (LyricList, error) { - text = strings.TrimPrefix(text, "\ufeff") - - if isTTMLDocument(text) { - list, err := parseTTMLWithDefaultLang([]byte(text), language) - if err == nil && len(list) > 0 { - return list, nil - } - if err != nil { - log.Warn("Error parsing embedded TTML lyrics, falling back to plain lyrics", "error", err) - } - } - - list, err := parseSRTWithLanguage([]byte(text), language) - if err == nil && len(list) > 0 { - return list, nil - } - if err != nil && strings.Contains(text, "-->") { - log.Warn("Error parsing embedded SRT lyrics, falling back to plain lyrics", "error", err) - } - - lyric, err := ToLyrics(language, text) - if err != nil { - return nil, err - } - if lyric == nil || lyric.IsEmpty() { - return nil, nil - } - return LyricList{*lyric}, nil -} - -func isTTMLDocument(text string) bool { - decoder := xml.NewDecoder(strings.NewReader(strings.TrimSpace(text))) - for { - token, err := decoder.Token() - if err != nil { - return false - } - if start, ok := token.(xml.StartElement); ok { - return strings.EqualFold(start.Name.Local, "tt") - } - } -} diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go new file mode 100644 index 000000000..6fde0c8f5 --- /dev/null +++ b/model/lyrics_lrc.go @@ -0,0 +1,350 @@ +package model + +import ( + "cmp" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" +) + +// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] +const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` + +var ( + // Should either be at the beginning of file, or beginning of line + syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) + timeRegex = regexp.MustCompile(timeRegexString) + lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) +) + +func parseLRC(language, text string) (*Lyrics, error) { + text = str.SanitizeText(text) + + lines := strings.Split(text, "\n") + structuredLines := make([]Line, 0, len(lines)*2) + + artist := "" + title := "" + var offset *int64 = nil + + synced := syncRegex.MatchString(text) + priorLine := "" + validLine := false + repeated := false + var timestamps []int64 + + for _, line := range lines { + line := strings.TrimSpace(line) + if line == "" { + if validLine { + priorLine += "\n" + } + continue + } + var text string + var time *int64 = nil + + if synced { + idTag := lrcIdRegex.FindStringSubmatch(line) + if idTag != nil { + switch idTag[1] { + case "ar": + artist = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "lang": + language = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "offset": + { + off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) + if err != nil { + log.Warn("Error parsing offset", "offset", idTag[2], "error", err) + } else { + offset = &off + } + } + case "ti": + title = str.SanitizeText(strings.TrimSpace(idTag[2])) + } + + continue + } + + times := timeRegex.FindAllStringSubmatchIndex(line, -1) + if len(times) > 1 { + repeated = true + } + + // The second condition is for when there is a timestamp in the middle of + // a line (after any text) + if len(times) == 0 || times[0][0] != 0 { + if validLine { + priorLine += "\n" + line + } + continue + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + } + + end := 0 + + // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] + for _, match := range times { + // for multiple matches, we need to check that later matches are not + // in the middle of the string + if end != 0 { + middle := strings.TrimSpace(line[end:match[0]]) + if middle != "" { + break + } + } + + end = match[1] + timeInMillis, err := parseTime(line, match) + if err != nil { + return nil, err + } + + timestamps = append(timestamps, timeInMillis) + } + + if end >= len(line) { + priorLine = "" + } else { + priorLine = strings.TrimSpace(line[end:]) + } + + validLine = true + } else { + text = line + structuredLines = append(structuredLines, Line{ + Start: time, + Value: text, + }) + } + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + } + + // If there are repeated values, there is no guarantee that they are in order + // In this, case, sort the lyrics by start time + if repeated { + slices.SortFunc(structuredLines, func(a, b Line) int { + return cmp.Compare(*a.Start, *b.Start) + }) + } + + lyrics := Lyrics{ + DisplayArtist: artist, + DisplayTitle: title, + Lang: language, + Line: normalizeCueLines(structuredLines), + Offset: offset, + Synced: synced, + } + return &lyrics, nil +} + +// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers +// and computes UTF-8 byte offsets against the final stripped line value. +func parseEnhancedLine(text string) (string, []Cue) { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return strings.TrimSpace(text), nil + } + + type segment struct { + start int64 + rawStart int + rawEnd int + } + + segments := make([]segment, 0, len(matches)) + var rawValue strings.Builder + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + continue + } + + rawStart := rawValue.Len() + rawValue.WriteString(word) + segments = append(segments, segment{ + start: timeMs, + rawStart: rawStart, + rawEnd: rawValue.Len(), + }) + } + + if len(segments) == 0 { + return strings.TrimSpace(stripEnhancedMarkers(text)), nil + } + + finalRaw := rawValue.String() + leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) + rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) + trimmedEnd := len(finalRaw) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + cues := make([]Cue, 0, len(segments)) + for _, seg := range segments { + start := seg.start + byteStart := max(seg.rawStart, leftTrimBytes) + byteEnd := min(seg.rawEnd, trimmedEnd) + if byteStart >= byteEnd { + continue + } + + cues = append(cues, Cue{ + Start: &start, + Value: finalRaw[byteStart:byteEnd], + ByteStart: byteStart - leftTrimBytes, + ByteEnd: byteEnd - leftTrimBytes - 1, + }) + } + + return strings.TrimSpace(finalRaw), cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + +func parseTime(line string, match []int) (int64, error) { + var hours, millis int64 + var err error + + hourStart := match[2] + if hourStart != -1 { + // subtract 1 because group has : at the end + hourEnd := match[3] - 1 + hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) + if err != nil { + return 0, err + } + } + + minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) + if err != nil { + return 0, err + } + + sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) + if err != nil { + return 0, err + } + + msStart := match[8] + if msStart != -1 { + msEnd := match[9] + // +1 offset since this capture group contains . + millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) + if err != nil { + return 0, err + } + + length := msEnd - msStart + + if length == 3 { + millis *= 10 + } else if length == 2 { + millis *= 100 + } + } + + timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis + return timeInMillis, nil +} diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go new file mode 100644 index 000000000..38f03587a --- /dev/null +++ b/model/lyrics_lrc_test.go @@ -0,0 +1,219 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLRC", func() { + It("should parse tags with spaces", func() { + lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Lang).To(Equal("eng")) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.DisplayArtist).To(Equal("An artist")) + Expect(lyrics.DisplayTitle).To(Equal("A title")) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) + }) + + It("Should ignore bad offset", func() { + lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Offset).To(BeNil()) + }) + + It("should accept lines with no text and weird times", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, + })) + }) + + It("Should support multiple timestamps per line", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, + })) + }) + + It("Should support parsing multiline string", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, + })) + }) + + It("Does not match timestamp in middle of line", func() { + lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeFalse()) + Expect(lyrics.Line).To(Equal([]Line{ + {Value: "This could [00:00:00] be a synced file"}, + })) + }) + + It("Allows timestamp in middle of line if also at beginning", func() { + lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, + })) + }) + + It("Ignores lines in synchronized lyric prior to first timestamp", func() { + lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Text"}, + })) + }) + + It("Handles all possible ms cases", func() { + lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, + })) + }) + + It("Properly sorts repeated lyrics out of order", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, + })) + }) + + It("should parse Enhanced LRC with word-level timing", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, + {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + + Expect(line1.Cue[1].End).To(BeNil()) + }) + + It("should not parse malformed Enhanced LRC timing markers", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(3)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) + + It("should preserve byte offsets for Enhanced LRC cues", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(1)) + + t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Oh love me tonight")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, + {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, + {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, + {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, + })) + }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) +}) diff --git a/model/lyricsfile.go b/model/lyrics_lyricsfile.go similarity index 91% rename from model/lyricsfile.go rename to model/lyrics_lyricsfile.go index b2b123256..49d416f3f 100644 --- a/model/lyricsfile.go +++ b/model/lyrics_lyricsfile.go @@ -1,6 +1,7 @@ package model import ( + "bytes" "fmt" "strings" @@ -8,7 +9,7 @@ import ( "gopkg.in/yaml.v3" ) -// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document +// parseLyricsfile parses a LRCLIB Lyricsfile YAML document // (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) // into a model.LyricList containing a single main Lyrics entry. Returns // (nil, nil) when the input parses as YAML but does not declare Lyricsfile @@ -19,9 +20,9 @@ import ( // overlapping lines are attributed to synthetic voice agents via lowest-free // voice ID assignment so the OpenSubsonic v2 enhanced response can split // parallel vocals. -func ParseLyricsfile(text string) (LyricList, error) { +func parseLyricsfile(lang string, contents []byte) (LyricList, error) { var doc lyricsfileDocument - dec := yaml.NewDecoder(strings.NewReader(text)) + dec := yaml.NewDecoder(bytes.NewReader(contents)) dec.KnownFields(false) if err := dec.Decode(&doc); err != nil { return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) @@ -31,10 +32,16 @@ func ParseLyricsfile(text string) (LyricList, error) { return nil, nil } + // Fall back to the caller's language when the document omits its own, matching + // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. + docLang := doc.Metadata.Language + if strings.TrimSpace(docLang) == "" { + docLang = lang + } lyrics := Lyrics{ DisplayArtist: str.SanitizeText(doc.Metadata.Artist), DisplayTitle: str.SanitizeText(doc.Metadata.Title), - Lang: normalizeLyricLang(doc.Metadata.Language), + Lang: normalizeLyricLang(docLang), Kind: LyricKindMain, } if doc.Metadata.OffsetMs != 0 { @@ -43,7 +50,7 @@ func ParseLyricsfile(text string) (LyricList, error) { } if doc.Metadata.Instrumental { - return LyricList{NormalizeLyrics(lyrics)}, nil + return LyricList{normalizeLyrics(lyrics)}, nil } if len(doc.Lines) == 0 { @@ -52,14 +59,14 @@ func ParseLyricsfile(text string) (LyricList, error) { return nil, nil } lyrics.Line = lines - return LyricList{NormalizeLyrics(lyrics)}, nil + return LyricList{normalizeLyrics(lyrics)}, nil } lines, agents := buildLyricsfileLines(doc.Lines) lyrics.Line = lines lyrics.Agents = agents lyrics.Synced = true - return LyricList{NormalizeLyrics(lyrics)}, nil + return LyricList{normalizeLyrics(lyrics)}, nil } const lyricsfileVersion = "1.0" diff --git a/model/lyricsfile_test.go b/model/lyrics_lyricsfile_test.go similarity index 85% rename from model/lyricsfile_test.go rename to model/lyrics_lyricsfile_test.go index a3588a2ea..45899cda7 100644 --- a/model/lyricsfile_test.go +++ b/model/lyrics_lyricsfile_test.go @@ -1,15 +1,14 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ParseLyricsfile", func() { +var _ = Describe("parseLyricsfile", func() { DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", func(input string) { - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(BeNil()) }, @@ -23,7 +22,7 @@ lines: ) It("returns an error for invalid YAML", func() { - _, err := ParseLyricsfile("not: valid: yaml: [") + _, err := parseLyricsfile("", []byte("not: valid: yaml: [")) Expect(err).To(HaveOccurred()) }) @@ -40,7 +39,7 @@ lines: - text: "You know the rules and so do I" start_ms: 22801 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -67,6 +66,24 @@ lines: Expect(l.Line[1].Cue).To(BeNil()) }) + DescribeTable("resolves the lyric language", + func(metaLanguage, callerLang, want string) { + input := "version: '1.0'\nmetadata:\n title: 'T'\n" + if metaLanguage != "" { + input += " language: '" + metaLanguage + "'\n" + } + input += "lines:\n - text: \"line\"\n start_ms: 0\n" + + lyrics, err := parseLyricsfile(callerLang, []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Lang).To(Equal(want)) + }, + Entry("prefers the document's own language", "eng", "deu", "eng"), + Entry("falls back to the caller language when metadata omits it", "", "deu", "deu"), + Entry("uses xxx when neither is provided", "", "", "xxx"), + ) + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { input := `version: '1.0' metadata: @@ -80,7 +97,7 @@ plain: | Second line ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -116,7 +133,7 @@ lines: start_ms: 1500 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -168,7 +185,7 @@ lines: start_ms: 3000 end_ms: 3500 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -207,7 +224,7 @@ lines: start_ms: 2000 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -246,7 +263,7 @@ metadata: language: 'eng' instrumental: true ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -270,7 +287,7 @@ lines: start_ms: 2000 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go new file mode 100644 index 000000000..276aa4da3 --- /dev/null +++ b/model/lyrics_normalize.go @@ -0,0 +1,134 @@ +package model + +import ( + "slices" + + "github.com/navidrome/navidrome/utils/gg" +) + +func normalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line = normalizeCueLines(lyrics.Line) + if len(lyrics.Agents) == 0 { + lyrics.Agents = nil + } + return lyrics +} + +func normalizeCueLines(lines []Line) []Line { + if len(lines) == 0 { + return lines + } + + normalized := make([]Line, len(lines)) + copy(normalized, lines) + + for i := range normalized { + if len(normalized[i].Cue) > 0 { + normalized[i].Cue = slices.Clone(normalized[i].Cue) + } + + var fallbackEnd *int64 + if normalized[i].End != nil { + v := *normalized[i].End + fallbackEnd = &v + } else if i+1 < len(normalized) && normalized[i+1].Start != nil { + v := *normalized[i+1].Start + fallbackEnd = &v + } + + normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) + } + + return normalized +} + +func normalizeLineTiming(line Line) Line { + if len(line.Cue) == 0 { + return line + } + + var earliestStart *int64 + var latestEnd *int64 + for i := range line.Cue { + token := line.Cue[i] + if token.Start != nil { + if earliestStart == nil || *token.Start < *earliestStart { + v := *token.Start + earliestStart = &v + } + } + + candidateEnd := token.End + if candidateEnd == nil { + candidateEnd = token.Start + } + if candidateEnd != nil { + if latestEnd == nil || *candidateEnd > *latestEnd { + v := *candidateEnd + latestEnd = &v + } + } + } + + if line.Start == nil && earliestStart != nil { + v := *earliestStart + line.Start = &v + } + if line.End == nil && latestEnd != nil { + v := *latestEnd + line.End = &v + } + return line +} + +func normalizeCueLine(line Line, fallbackEnd *int64) Line { + if len(line.Cue) == 0 { + return line + } + line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) + return normalizeLineTiming(line) +} + +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +// +// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends +// per agent group while building the response; all other normalization is +// package-internal. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues + } + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = gg.Clone(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} diff --git a/model/lyrics_normalize_test.go b/model/lyrics_normalize_test.go new file mode 100644 index 000000000..24faffd54 --- /dev/null +++ b/model/lyrics_normalize_test.go @@ -0,0 +1,120 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("normalizeCueLines", func() { + It("should not mutate caller cue slices when filling missing cue end times", func() { + start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) + lines := []Line{ + { + Start: &start0, + Value: "Some lyrics", + Cue: []Cue{ + {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + }, + }, + { + Start: &nextLineStart, + Value: "Next line", + }, + } + + normalized := normalizeCueLines(lines) + + Expect(normalized[0].Cue[0].End).To(Equal(&start1)) + Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) + Expect(lines[0].Cue[0].End).To(BeNil()) + Expect(lines[0].Cue[1].End).To(BeNil()) + }) +}) + +var _ = Describe("NormalizeCueEnds", func() { + // p returns a fresh pointer so cases don't share *int64 state. + p := func(v int64) *int64 { return &v } + + // endsOf extracts the resolved end times (nil-safe) for compact assertions. + endsOf := func(cues []Cue) []*int64 { + out := make([]*int64, len(cues)) + for i := range cues { + out[i] = cues[i].End + } + return out + } + + It("returns the input as-is when empty", func() { + Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil()) + Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty()) + }) + + It("fills a missing end from the next cue's start", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) + }) + + It("fills the last cue's missing end from fallbackEnd", func() { + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)})) + }) + + It("clamps an end that overruns the next cue's start", func() { + cues := []Cue{ + {Start: p(1000), End: p(9999)}, + {Start: p(1500), End: p(2000)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)})) + }) + + It("clamps an end that precedes the cue's own start", func() { + cues := []Cue{ + {Start: p(1000), End: p(500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1000)})) + }) + + It("clears all ends when any cue still lacks one (all-or-none)", func() { + // The last cue has no end and there is no fallback, so it stays nil and + // every end in the group is cleared. + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, nil) + + Expect(endsOf(out)).To(Equal([]*int64{nil, nil})) + }) + + It("does not mutate the input slice", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + _ = NormalizeCueEnds(cues, p(3000)) + + Expect(cues[0].End).To(BeNil()) + Expect(cues[1].End).To(BeNil()) + }) +}) diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go new file mode 100644 index 000000000..4bfaa29e8 --- /dev/null +++ b/model/lyrics_parse.go @@ -0,0 +1,73 @@ +package model + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/log" +) + +// lyricParser returns an empty list (not an error) when the input is not its +// format, so parsers can be tried in order. lang is the default for formats that +// do not carry their own. +type lyricParser func(lang string, contents []byte) (LyricList, error) + +// lyricFormats is the structured formats in content-sniff probe order; each +// row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. +var lyricFormats = []struct { + suffixes []string + parse lyricParser +}{ + {[]string{".ttml"}, parseTTML}, + {[]string{".srt"}, parseSRT}, + {[]string{".yaml", ".yml"}, parseLyricsfile}, +} + +// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes +// to that format's parser; an empty or "auto" suffix content-sniffs. Either way, +// a structured parser that does not match falls back to the LRC/plain-text floor. +func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { + contents = stripBOM(contents) + suffix = strings.ToLower(suffix) + sniff := suffix == "" || suffix == "auto" + + // Sniffing tries every format in order; a known suffix selects just its own. + // Unmatched suffixes leave no candidates, so parseFirstMatch falls to plain. + candidates := make([]lyricParser, 0, len(lyricFormats)) + for _, f := range lyricFormats { + if sniff || slices.Contains(f.suffixes, suffix) { + candidates = append(candidates, f.parse) + } + } + return parseFirstMatch(lang, contents, candidates...) +} + +func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { + for _, parse := range candidates { + list, err := parse(lang, contents) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil { + log.Warn("Error parsing lyrics, falling back to plain text", "error", err) + } + } + return plainLRC(lang, contents) +} + +func plainLRC(lang string, contents []byte) (LyricList, error) { + lyric, err := parseLRC(lang, string(contents)) + if err != nil { + return nil, fmt.Errorf("parsing lyrics: %w", err) + } + if lyric == nil || lyric.IsEmpty() { + return nil, nil + } + return LyricList{*lyric}, nil +} + +func stripBOM(contents []byte) []byte { + return bytes.TrimPrefix(contents, []byte("\ufeff")) +} diff --git a/model/lyrics_embedded_test.go b/model/lyrics_parse_test.go similarity index 60% rename from model/lyrics_embedded_test.go rename to model/lyrics_parse_test.go index 77f17973a..eb58a29ef 100644 --- a/model/lyrics_embedded_test.go +++ b/model/lyrics_parse_test.go @@ -7,7 +7,48 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("ParseEmbedded", func() { +var _ = Describe("ParseLyrics", func() { + DescribeTable("known suffix routes to the matching parser", + func(suffix, contents string, wantSynced bool, wantFirst string) { + list, err := ParseLyrics(suffix, "eng", []byte(contents)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(Equal(wantSynced)) + Expect(list[0].Line[0].Value).To(Equal(wantFirst)) + }, + Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"), + Entry(".txt plain", ".txt", "plain line", false, "plain line"), + Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"), + Entry(".ttml", ".ttml", `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">ttml line</p></div></body></tt>`, true, "ttml line"), + Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"), + ) + + It("empty suffix content-sniffs (TTML)", func() { + ttml := `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">auto ttml</p></div></body></tt>` + list, err := ParseLyrics("", "eng", []byte(ttml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto ttml")) + }) + + It("empty suffix content-sniffs (YAML)", func() { + yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n" + list, err := ParseLyrics("auto", "eng", []byte(yaml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto yaml")) + }) + + It("falls back to plain text when a known suffix fails to parse structurally", func() { + list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file")) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line[0].Value).To(Equal("not actually an srt file")) + }) +}) + +var _ = Describe("ParseLyrics content-sniffing", func() { It("should parse embedded TTML with the tag language as the default", func() { content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> <head> @@ -26,9 +67,9 @@ var _ = Describe("ParseEmbedded", func() { </body> </tt>` - list, err := ParseEmbedded("ENG", content) + list, err := ParseLyrics("", "ENG", []byte(content)) - // ParseEmbedded's job is to detect TTML and apply the tag language as the + // ParseLyrics's job is to detect TTML and apply the tag language as the // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -63,7 +104,7 @@ var _ = Describe("ParseEmbedded", func() { </body> </tt>` - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(3)) @@ -88,7 +129,7 @@ We're from subtitles 00:00:22,801 --> 00:00:26,000 Another subtitle line` - list, err := ParseEmbedded("POR", content) + list, err := ParseLyrics("", "POR", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(Equal(LyricList{ @@ -114,7 +155,7 @@ Another subtitle line` It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -127,7 +168,7 @@ Another subtitle line` It("should keep embedded enhanced LRC cues", func() { content := "[00:01.00]<00:01.00>Lead <00:01.50>words" - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -144,7 +185,7 @@ Another subtitle line` </body> </tt>` - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -157,4 +198,16 @@ Another subtitle line` } Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) }) + + It("detects a Lyricsfile YAML payload via content-sniffing", func() { + yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" + + list, err := ParseLyrics("", "eng", []byte(yaml)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("sniffed yaml line")) + }) }) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go index 928fc45d9..319a59961 100644 --- a/model/lyrics_srt.go +++ b/model/lyrics_srt.go @@ -1,7 +1,6 @@ package model import ( - "bytes" "regexp" "strconv" "strings" @@ -14,11 +13,7 @@ var ( srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) ) -func ParseSRT(contents []byte) (LyricList, error) { - return parseSRTWithLanguage(contents, "xxx") -} - -func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) { +func parseSRT(language string, contents []byte) (LyricList, error) { raw := strings.ReplaceAll(string(contents), "\r\n", "\n") raw = strings.ReplaceAll(raw, "\r", "\n") @@ -39,7 +34,7 @@ func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) { return nil, nil } - lyrics := NormalizeLyrics(Lyrics{ + lyrics := normalizeLyrics(Lyrics{ Lang: normalizeLyricLang(language), Line: lines, Synced: true, @@ -65,14 +60,10 @@ func splitSRTBlocks(raw string) []string { } func parseSRTBlock(block string) (Line, bool, error) { - scanner := bytes.Split([]byte(block), []byte("\n")) - if len(scanner) == 0 { - return Line{}, false, nil - } - - lines := make([]string, 0, len(scanner)) - for _, line := range scanner { - lines = append(lines, strings.TrimSpace(string(line))) + rawLines := strings.Split(block, "\n") + lines := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + lines = append(lines, strings.TrimSpace(line)) } if len(lines) == 0 { diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go new file mode 100644 index 000000000..2c0ab2242 --- /dev/null +++ b/model/lyrics_srt_test.go @@ -0,0 +1,30 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseSRT", func() { + It("parses SRT blocks with the default language", func() { + content := []byte("1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle") + + list, err := parseSRT("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("returns nil for input with no valid blocks", func() { + list, err := parseSRT("xxx", []byte("not actually an srt file")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) +}) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index b772e2f5e..fd954ad26 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,251 +1,10 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ToLyrics", func() { - It("should parse tags with spaces", func() { - lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Lang).To(Equal("eng")) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.DisplayArtist).To(Equal("An artist")) - Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(new(int64(1551)))) - }) - - It("Should ignore bad offset", func() { - lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Offset).To(BeNil()) - }) - - It("should accept lines with no text and weird times", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Hi there"}, - {Start: new(int64(10040)), Value: ""}, - {Start: new(int64(40000)), Value: "Test"}, - {Start: new(int64(1000 * 60 * 60)), Value: "late"}, - })) - }) - - It("Should support multiple timestamps per line", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: ""}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, - })) - }) - - It("Should support parsing multiline string", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, - {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, - })) - }) - - It("Does not match timestamp in middle of line", func() { - lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeFalse()) - Expect(lyrics.Line).To(Equal([]Line{ - {Value: "This could [00:00:00] be a synced file"}, - })) - }) - - It("Allows timestamp in middle of line if also at beginning", func() { - lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, - {Start: new(int64(1000)), Value: "Line 2"}, - })) - }) - - It("Ignores lines in synchronized lyric prior to first timestamp", func() { - lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Text"}, - })) - }) - - It("Handles all possible ms cases", func() { - lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1)), Value: "a"}, - {Start: new(int64(10)), Value: "b"}, - {Start: new(int64(100)), Value: "c"}, - })) - }) - - It("Properly sorts repeated lyrics out of order", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Test"}, - {Start: new(int64(40000)), Value: "Not repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, - })) - }) - - It("should parse Enhanced LRC with word-level timing", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(HaveLen(2)) - - t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) - - line0 := lyrics.Line[0] - Expect(line0.Start).To(Equal(&t1000)) - Expect(line0.End).To(Equal(&t3000)) - Expect(line0.Value).To(Equal("Some lyrics here")) - Expect(line0.Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, - {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, - })) - - line1 := lyrics.Line[1] - Expect(line1.Start).To(Equal(&t3000)) - Expect(line1.End).To(Equal(&t3500)) - Expect(line1.Value).To(Equal("More words")) - Expect(line1.Cue).To(Equal([]Cue{ - {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, - {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, - })) - - Expect(line1.Cue[1].End).To(BeNil()) - }) - - It("should not parse malformed Enhanced LRC timing markers", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, - })) - }) - - It("should handle mixed Enhanced and plain LRC lines", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(3)) - - t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) - t3000 := int64(3000) - - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - })) - Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) - Expect(lyrics.Line[0].End).To(Equal(&t3000)) - - Expect(lyrics.Line[1].Cue).To(BeNil()) - Expect(lyrics.Line[1].Value).To(Equal("Plain line")) - - Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ - {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, - {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, - })) - Expect(lyrics.Line[2].Value).To(Equal("More words")) - }) - - It("should preserve byte offsets for Enhanced LRC cues", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(1)) - - t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) - line := lyrics.Line[0] - Expect(line.Value).To(Equal("Oh love me tonight")) - Expect(line.Cue).To(Equal([]Cue{ - {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, - {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, - {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, - {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, - })) - }) - - It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { - lyrics, err := ToLyrics("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(2)) - - t10000 := int64(10000) - t10100 := int64(10100) - t10500 := int64(10500) - t30000 := int64(30000) - t30100 := int64(30100) - t30500 := int64(30500) - - Expect(lyrics.Line[0].Start).To(Equal(&t10000)) - Expect(lyrics.Line[0].End).To(Equal(&t30000)) - Expect(lyrics.Line[0].Value).To(Equal("Hello world")) - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - - Expect(lyrics.Line[1].Start).To(Equal(&t30000)) - Expect(lyrics.Line[1].End).To(Equal(&t30500)) - Expect(lyrics.Line[1].Value).To(Equal("Hello world")) - Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ - {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - }) -}) - -var _ = Describe("NormalizeCueLines", func() { - It("should not mutate caller cue slices when filling missing cue end times", func() { - start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) - lines := []Line{ - { - Start: &start0, - Value: "Some lyrics", - Cue: []Cue{ - {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - }, - }, - { - Start: &nextLineStart, - Value: "Next line", - }, - } - - normalized := NormalizeCueLines(lines) - - Expect(normalized[0].Cue[0].End).To(Equal(&start1)) - Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) - Expect(lines[0].Cue[0].End).To(BeNil()) - Expect(lines[0].Cue[1].End).To(BeNil()) - }) -}) - var _ = Describe("Lyrics.EffectiveKind", func() { It("defaults a blank kind to main", func() { Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go index fe3a547d5..95aab3485 100644 --- a/model/lyrics_ttml.go +++ b/model/lyrics_ttml.go @@ -102,13 +102,29 @@ type ttmlParser struct { metadataSeq int } -func ParseTTML(contents []byte) (LyricList, error) { - return parseTTMLWithDefaultLang(contents, "xxx") +func isTTMLDocument(contents []byte) bool { + decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents))) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok { + return strings.EqualFold(start.Name.Local, "tt") + } + } } -func parseTTMLWithDefaultLang(contents []byte, defaultLang string) (LyricList, error) { +func parseTTML(defaultLang string, contents []byte) (LyricList, error) { contents = xmlEncodingRegex.ReplaceAll(contents, []byte(`<?xml$1encoding="UTF-8"$2?>`)) + // Skip non-TTML content so sniffing doesn't run the full TTML parse on plain + // text — isTTMLDocument does a cheap decode that stops at the first element. + // Checked after the encoding fixup so UTF-16-declared documents are recognized. + if !isTTMLDocument(contents) { + return nil, nil + } + p := ttmlParser{ decoder: xml.NewDecoder(bytes.NewReader(contents)), params: ttmlTimingParams{ @@ -184,7 +200,7 @@ func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingConte if len(tokens) > 0 { parsedLine.Cue = tokens } - parsedLine = NormalizeLineTiming(parsedLine) + parsedLine = normalizeLineTiming(parsedLine) lineKey, _ := attrValue(start.Attr, "key") p.addMainLine(ctx.lang, lineKey, parsedLine) @@ -327,7 +343,7 @@ func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTiming if len(tokens) > 0 { line.Cue = tokens } - line = NormalizeLineTiming(line) + line = normalizeLineTiming(line) if line.Value == "" && len(line.Cue) == 0 { return ttmlMetadataEntry{}, false, nil @@ -615,7 +631,7 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie endMs := *ref.line.End line.End = &endMs } - line = NormalizeLineTiming(line) + line = normalizeLineTiming(line) if line.Value == "" && len(line.Cue) == 0 { continue @@ -657,7 +673,7 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) - return NormalizeLyrics(lyrics) + return normalizeLyrics(lyrics) } func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go index ef882fdcd..a175eef0e 100644 --- a/model/lyrics_ttml_test.go +++ b/model/lyrics_ttml_test.go @@ -5,7 +5,7 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("ParseTTML", func() { +var _ = Describe("parseTTML", func() { Describe("Multi-language and timing", func() { It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { content := []byte(`<?xml version="1.0" encoding="UTF-8"?> @@ -21,7 +21,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(2)) @@ -54,7 +54,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(1)) @@ -75,7 +75,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Lang).To(Equal("eng")) @@ -99,7 +99,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(2)) @@ -124,7 +124,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -154,7 +154,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -187,7 +187,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -222,7 +222,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -256,7 +256,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -283,7 +283,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(1)) @@ -309,7 +309,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Lang).To(Equal("xxx")) @@ -349,7 +349,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(3)) @@ -404,7 +404,7 @@ var _ = Describe("ParseTTML", func() { </body> </tt>`) - list, err := ParseTTML(content) + list, err := parseTTML("xxx", content) Expect(err).ToNot(HaveOccurred()) var pronunciation *Lyrics diff --git a/model/mediafile.go b/model/mediafile.go index 6a489bcd5..d93060dba 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -138,7 +138,7 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID { } func (mf MediaFile) StructuredLyrics() (LyricList, error) { - lyrics := LyricList{} + var lyrics LyricList err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) if err != nil { return nil, err diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index b46174c59..de2ba813e 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -143,7 +143,7 @@ func (md Metadata) mapLyrics() string { lang := raw.Key() text := raw.Value() - lyrics, err := model.ParseEmbedded(lang, text) + lyrics, err := model.ParseLyrics("", lang, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) continue diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index c66b027d7..12d84f60d 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -31,8 +31,8 @@ type LyricsPlugin struct { plugin *plugin } -// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses -// using model.ToLyrics. +// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response +// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), @@ -50,13 +50,15 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode if lang == "" { lang = "xxx" } - parsed, err := model.ToLyrics(lang, lt.Text) + parsed, err := model.ParseLyrics("", lang, []byte(lt.Text)) if err != nil { log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) continue } - if parsed != nil && !parsed.IsEmpty() { - result = append(result, *parsed) + for _, lyric := range parsed { + if !lyric.IsEmpty() { + result = append(result, lyric) + } } } return result, nil diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index a1a6c1809..6e82dbfab 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -83,6 +83,32 @@ var _ = Describe("LyricsPlugin", Ordered, func() { _, err := p.GetLyrics(GinkgoT().Context(), track) Expect(err).To(HaveOccurred()) }) + + // Each DescribeTable entry proves that the adapter's content-sniffing routes + // the plugin's rich payload to the right parser rather than mangling it as plain text. + DescribeTable("content-sniffs plugin responses across all supported formats", + func(format string, wantSynced bool, wantLine string) { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"format": format}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format) + Expect(result[0].Line).To(HaveLen(1)) + Expect(result[0].Line[0].Value).To(Equal(wantLine)) + }, + Entry("ttml", "ttml", true, "plugin ttml line"), + Entry("srt", "srt", true, "plugin srt line"), + Entry("yaml", "yaml", true, "plugin yaml line"), + Entry("lrc", "lrc", true, "plugin lrc line"), + Entry("plain", "plain", false, "plugin plain line"), + ) }) Describe("PluginNames", func() { diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 0e485ceba..2ee2dabbf 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -15,12 +15,47 @@ func init() { type testLyrics struct{} func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { - // Check for configured error errMsg, hasErr := pdk.GetConfig("error") if hasErr && errMsg != "" { return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. + format, hasFormat := pdk.GetConfig("format") + if hasFormat { + var text string + var lang string + switch format { + case "ttml": + lang = "eng" + text = `<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml"> + <body xml:lang="eng"> + <div> + <p begin="00:00.000" end="00:01.000">plugin ttml line</p> + </div> + </body> +</tt>` + case "srt": + lang = "eng" + text = "1\n00:00:01,000 --> 00:00:02,000\nplugin srt line\n" + case "yaml": + lang = "eng" + text = "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: \"plugin yaml line\"\n start_ms: 1000\n" + case "lrc": + lang = "eng" + text = "[00:01.00]plugin lrc line" + case "plain": + lang = "eng" + text = "plugin plain line" + } + if text != "" { + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, + }, nil + } + } + // Check if we should omit language (to test default language handling) noLang, hasNoLang := pdk.GetConfig("no_lang") lang := "eng" diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 3ccbd8961..8cde586b9 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -205,13 +205,14 @@ func (t Tags) Lyrics() string { basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") for _, value := range basicLyrics { - lyrics, err := model.ToLyrics("xxx", value) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(value)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } for tag, value := range t.Tags { @@ -223,13 +224,14 @@ func (t Tags) Lyrics() string { } for _, text := range value { - lyrics, err := model.ToLyrics(language, text) + parsed, err := model.ParseLyrics(".lrc", language, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } } } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 0403306a6..ac4aaa5f2 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -70,6 +70,20 @@ const ( mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id ) +// lyricFixture reads a public-domain lyric fixture (the same files the parser +// benchmarks use) so the e2e fixtures stay in sync with real-world content, +// including the word-level timing carried by the .elrc and .yaml variants. +func lyricFixture(name string) string { + // tests.Init chdirs to the project root, so reference fixtures from there. + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) + Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) + return string(data) +} + +// firstFixtureLine is the opening lyric line shared by every auld-lang-syne +// fixture; tests assert against it regardless of source format. +const firstFixtureLine = "Should auld acquaintance be forgot," + // Shared test state var ( ctx context.Context @@ -128,6 +142,9 @@ func buildTestFS() storagetest.FakeFS { // Template for diverse-format transcode test tracks tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} + // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable + lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), @@ -177,6 +194,31 @@ func buildTestFS() storagetest.FakeFS { "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), }), + // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). + // Content comes from tests/fixtures/lyrics (the same public-domain files the + // parser benchmarks use); the .elrc and .yaml variants carry word-level + // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. + // + // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. + // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic + // since ELRC is still valid LRC. + "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", + _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), + "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", + _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), + "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", + _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), + + // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. + // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile + // entries is safe: they are visible to the fake FS but invisible to the scanner. + "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), + "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, + "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), + "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, + "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), + "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, + // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -409,6 +451,7 @@ var _ = BeforeSuite(func() { // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" + conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false db.Init(ctx) diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index d41d17dbc..6d32a3c88 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,15 +27,16 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats + Expect(albums).To(HaveLen(8)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Lyrics, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) - Expect(albums[5].Title).To(Equal("Pop")) - Expect(albums[6].Title).To(Equal("Transcode Formats")) + Expect(albums[5].Title).To(Equal("Lyrics")) + Expect(albums[6].Title).To(Equal("Pop")) + Expect(albums[7].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -43,23 +44,24 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ + // Non-compilations: Beatles (x2), Led Zeppelin, Lyric Tester, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) - Expect(albums[3].Artist).To(Equal("Miles Davis")) - Expect(albums[4].Artist).To(Equal("Test Artist")) - Expect(albums[5].Artist).To(Equal("Various")) - Expect(albums[6].Artist).To(Equal("シートベルツ")) + Expect(albums[3].Artist).To(Equal("Lyric Tester")) + Expect(albums[4].Artist).To(Equal("Miles Davis")) + Expect(albums[5].Artist).To(Equal("Test Artist")) + Expect(albums[6].Artist).To(Equal("Various")) + Expect(albums[7].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=byGenre filters by genre parameter", func() { @@ -190,7 +192,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -201,7 +203,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(7)) + Expect(resp.AlbumList2.Album).To(HaveLen(8)) }) }) diff --git a/server/e2e/subsonic_lyrics_test.go b/server/e2e/subsonic_lyrics_test.go new file mode 100644 index 000000000..da8d513a3 --- /dev/null +++ b/server/e2e/subsonic_lyrics_test.go @@ -0,0 +1,124 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + // songID resolves a track title to its Subsonic ID via search3. + songID := func(title string) string { + resp := doReq("search3", "query", title, "songCount", "1", "artistCount", "0", "albumCount", "0") + Expect(resp.Status).To(Equal("ok")) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).ToNot(BeEmpty(), "expected to find song %q", title) + return resp.SearchResult3.Song[0].Id + } + + // firstLyric extracts the first StructuredLyric from a LyricsList response. + firstLyric := func(list *responses.LyricsList) responses.StructuredLyric { + Expect(list).ToNot(BeNil()) + Expect(list.StructuredLyrics).ToNot(BeEmpty()) + return list.StructuredLyrics[0] + } + + // songLyrics extension v1: getLyricsBySongId without the enhanced parameter + // returns line-level structured lyrics (line[], lang, synced) and must NOT + // emit any v2/enhanced fields — no cueLine, kind, or agents — even for + // formats that carry word-level timing (ELRC, Lyricsfile YAML). + Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { + DescribeTable("returns line-level lyrics without enhanced fields", + func(title string, wantSynced bool, wantLang string) { + resp := doReq("getLyricsBySongId", "id", songID(title)) + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Synced).To(Equal(wantSynced)) + Expect(got.Lang).To(Equal(wantLang)) + Expect(got.Line).ToNot(BeEmpty()) + Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) + + // v1 must not expose any enhanced (v2) data. + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Kind).To(BeEmpty()) + Expect(got.Agents).To(BeEmpty()) + }, + // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc + // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets + // language: eng, while .srt carries no language and the embedded plain + // text has none — so each format exercises a different language path. + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), + Entry("embedded plain text", "Embedded Plain", false, "xxx"), + Entry("embedded TTML", "Embedded TTML", true, "eng"), + Entry("LRC sidecar", "Sidecar LRC", true, "eng"), + Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), + ) + }) + + // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to + // word/syllable-level timing (cueLine) and the kind classification. Every + // format gains kind="main" for a single untyped lyric layer; only formats + // that carry word-level timing (ELRC, TTML word spans, Lyricsfile YAML) + // surface a cueLine. Line-level formats (LRC, SRT, plain) still yield none. + Describe("getLyricsBySongId v2 (enhanced)", func() { + DescribeTable("returns enhanced lyrics, with cueLine only for word-level sources", + func(title string, wantCueLine bool) { + resp := doReq("getLyricsBySongId", "id", songID(title), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Kind).To(Equal("main")) + if wantCueLine { + Expect(got.CueLine).ToNot(BeEmpty()) + // The first line has one cue per word: "Should auld acquaintance be forgot,". + Expect(got.CueLine[0].Cue).To(HaveLen(5)) + Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) + } else { + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Line).ToNot(BeEmpty()) + } + }, + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true), + Entry("embedded TTML (word-level spans)", "Embedded TTML", true), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true), + Entry("embedded plain text (no timing)", "Embedded Plain", false), + Entry("LRC sidecar (line-level)", "Sidecar LRC", false), + Entry("SRT sidecar (line-level)", "Sidecar SRT", false), + ) + }) + + // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up + // by artist/title and returns the main lyric flattened to plain text — every + // line's Value joined by newlines, with all timing/markup dropped. Synced and + // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. + Describe("getLyrics (legacy artist/title)", func() { + DescribeTable("returns the main lyric as plain text across formats and sources", + func(title string) { + resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) + Expect(resp.Status).To(Equal("ok")) + Expect(resp.Lyrics).ToNot(BeNil()) + Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) + Expect(resp.Lyrics.Title).To(Equal(title)) + Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) + + // No timing markup leaks into the plain-text value, regardless of the + // source format: no LRC brackets/word markers, SRT arrows, or XML tags. + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) + }, + Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), + Entry("embedded plain text", "Embedded Plain"), + Entry("embedded TTML", "Embedded TTML"), + Entry("LRC sidecar", "Sidecar LRC"), + Entry("SRT sidecar", "Sidecar SRT"), + Entry("YAML sidecar", "Sidecar YAML"), + ) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index a837da124..e652cf492 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -142,7 +142,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index e348bc6b9..00b60ad6f 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(6)) - Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(14)) + Expect(resp.SearchResult3.Artist).To(HaveLen(7)) + Expect(resp.SearchResult3.Album).To(HaveLen(8)) + Expect(resp.SearchResult3.Song).To(HaveLen(20)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index a83f2f0eb..485daca58 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,9 +1,13 @@ package subsonic import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,16 @@ func TestSubsonicApi(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Subsonic API Suite") } + +// newLocalStorage fatals if the default extractor is not registered. +// Register a no-op so storage.For works in sidecar-lyrics tests. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &subsonicNoopExtractor{} + }) +}) + +type subsonicNoopExtractor struct{} + +func (e *subsonicNoopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *subsonicNoopExtractor) Version() string { return "noop" } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index e0f291b70..f4ba6208a 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -2,6 +2,7 @@ package subsonic import ( "encoding/json" + "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -99,10 +100,12 @@ var _ = Describe("GetLyricsBySongId", func() { It("should return mixed lyrics", func() { r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, + synced, unsynced, }) Expect(err).ToNot(HaveOccurred()) @@ -155,9 +158,10 @@ var _ = Describe("GetLyricsBySongId", func() { It("should parse lrc metadata", func() { r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) + synced, _ := syncedList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *synced, + synced, }) Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ @@ -199,13 +203,16 @@ var _ = Describe("GetLyricsBySongId", func() { conf.Server.LyricsPriority = ".ttml,embedded" r := newGetRequest("id=1") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - ID: "1", - Path: "tests/fixtures/test.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", + ID: "1", + LibraryPath: fixturesDir, + Path: "test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", }, }) @@ -252,13 +259,16 @@ var _ = Describe("GetLyricsBySongId", func() { conf.Server.LyricsPriority = ".ttml,embedded" r := newGetRequest("id=1&enhanced=true") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - ID: "1", - Path: "tests/fixtures/test-metadata.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", + ID: "1", + LibraryPath: fixturesDir, + Path: "test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", }, }) diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 60deda208..228427d5a 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net/http/httptest" + "path/filepath" "slices" "time" @@ -112,9 +113,10 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I") + lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) + lyrics, _ := lyricsList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *lyrics, + lyrics, }) Expect(err).ToNot(HaveOccurred()) @@ -163,12 +165,15 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - Path: "tests/fixtures/test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", + LibraryPath: fixturesDir, + Path: "test.mp3", + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", }, }) response, err := router.GetLyrics(r) diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc new file mode 100644 index 000000000..41342d910 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.elrc @@ -0,0 +1,27 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[lang:eng] +[00:00.00]<00:00.00>Should <00:00.90>auld <00:01.80>acquaintance <00:02.70>be <00:03.60>forgot, +[00:04.50]<00:04.50>And <00:05.40>never <00:06.30>brought <00:07.20>to <00:08.10>mind? +[00:09.00]<00:09.00>Should <00:09.90>auld <00:10.80>acquaintance <00:11.70>be <00:12.60>forgot, +[00:13.50]<00:13.50>And <00:14.62>auld <00:15.75>lang <00:16.88>syne? +[00:18.00]<00:18.00>For <00:18.75>auld <00:19.50>lang <00:20.25>syne, <00:21.00>my <00:21.75>dear, +[00:22.50]<00:22.50>For <00:23.62>auld <00:24.75>lang <00:25.88>syne, +[00:27.00]<00:27.00>We'll <00:27.64>tak <00:28.29>a <00:28.93>cup <00:29.57>o' <00:30.21>kindness <00:30.86>yet, +[00:31.50]<00:31.50>For <00:32.62>auld <00:33.75>lang <00:34.88>syne. +[00:36.00]<00:36.00>And <00:36.75>surely <00:37.50>ye'll <00:38.25>be <00:39.00>your <00:39.75>pint-stowp, +[00:40.50]<00:40.50>And <00:41.40>surely <00:42.30>I'll <00:43.20>be <00:44.10>mine, +[00:45.00]<00:45.00>And <00:45.56>we'll <00:46.12>tak <00:46.69>a <00:47.25>cup <00:47.81>o' <00:48.38>kindness <00:48.94>yet, +[00:49.50]<00:49.50>For <00:50.62>auld <00:51.75>lang <00:52.88>syne. +[00:54.00]<00:54.00>We <00:54.64>twa <00:55.29>hae <00:55.93>run <00:56.57>about <00:57.21>the <00:57.86>braes, +[00:58.50]<00:58.50>And <00:59.40>pou'd <01:00.30>the <01:01.20>gowans <01:02.10>fine, +[01:03.00]<01:03.00>But <01:03.64>we've <01:04.29>wander'd <01:04.93>mony <01:05.57>a <01:06.21>weary <01:06.86>fit, +[01:07.50]<01:07.50>Sin <01:08.62>auld <01:09.75>lang <01:10.88>syne. +[01:12.00]<01:12.00>We <01:12.64>twa <01:13.29>hae <01:13.93>paidl'd <01:14.57>in <01:15.21>the <01:15.86>burn, +[01:16.50]<01:16.50>Frae <01:17.40>morning <01:18.30>sun <01:19.20>till <01:20.10>dine, +[01:21.00]<01:21.00>But <01:21.64>seas <01:22.29>between <01:22.93>us <01:23.57>braid <01:24.21>hae <01:24.86>roar'd +[01:25.50]<01:25.50>Sin <01:26.62>auld <01:27.75>lang <01:28.88>syne. +[01:30.00]<01:30.00>And <01:30.64>there's <01:31.29>a <01:31.93>hand, <01:32.57>my <01:33.21>trusty <01:33.86>fiere, +[01:34.50]<01:34.50>And <01:35.25>gie's <01:36.00>a <01:36.75>hand <01:37.50>o' <01:38.25>thine, +[01:39.00]<01:39.00>And <01:39.64>we'll <01:40.29>tak <01:40.93>a <01:41.57>right <01:42.21>gude-willie <01:42.86>waught, +[01:43.50]<01:43.50>For <01:44.62>auld <01:45.75>lang <01:46.88>syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.lrc b/tests/fixtures/lyrics/auld-lang-syne.lrc new file mode 100644 index 000000000..56021870a --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.lrc @@ -0,0 +1,28 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[al:Traditional] +[lang:eng] +[00:00.00]Should auld acquaintance be forgot, +[00:04.50]And never brought to mind? +[00:09.00]Should auld acquaintance be forgot, +[00:13.50]And auld lang syne? +[00:18.00]For auld lang syne, my dear, +[00:22.50]For auld lang syne, +[00:27.00]We'll tak a cup o' kindness yet, +[00:31.50]For auld lang syne. +[00:36.00]And surely ye'll be your pint-stowp, +[00:40.50]And surely I'll be mine, +[00:45.00]And we'll tak a cup o' kindness yet, +[00:49.50]For auld lang syne. +[00:54.00]We twa hae run about the braes, +[00:58.50]And pou'd the gowans fine, +[01:03.00]But we've wander'd mony a weary fit, +[01:07.50]Sin auld lang syne. +[01:12.00]We twa hae paidl'd in the burn, +[01:16.50]Frae morning sun till dine, +[01:21.00]But seas between us braid hae roar'd +[01:25.50]Sin auld lang syne. +[01:30.00]And there's a hand, my trusty fiere, +[01:34.50]And gie's a hand o' thine, +[01:39.00]And we'll tak a right gude-willie waught, +[01:43.50]For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.srt b/tests/fixtures/lyrics/auld-lang-syne.srt new file mode 100644 index 000000000..116bec0bf --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.srt @@ -0,0 +1,95 @@ +1 +00:00:00,000 --> 00:00:04,500 +Should auld acquaintance be forgot, + +2 +00:00:04,500 --> 00:00:09,000 +And never brought to mind? + +3 +00:00:09,000 --> 00:00:13,500 +Should auld acquaintance be forgot, + +4 +00:00:13,500 --> 00:00:18,000 +And auld lang syne? + +5 +00:00:18,000 --> 00:00:22,500 +For auld lang syne, my dear, + +6 +00:00:22,500 --> 00:00:27,000 +For auld lang syne, + +7 +00:00:27,000 --> 00:00:31,500 +We'll tak a cup o' kindness yet, + +8 +00:00:31,500 --> 00:00:36,000 +For auld lang syne. + +9 +00:00:36,000 --> 00:00:40,500 +And surely ye'll be your pint-stowp, + +10 +00:00:40,500 --> 00:00:45,000 +And surely I'll be mine, + +11 +00:00:45,000 --> 00:00:49,500 +And we'll tak a cup o' kindness yet, + +12 +00:00:49,500 --> 00:00:54,000 +For auld lang syne. + +13 +00:00:54,000 --> 00:00:58,500 +We twa hae run about the braes, + +14 +00:00:58,500 --> 00:01:03,000 +And pou'd the gowans fine, + +15 +00:01:03,000 --> 00:01:07,500 +But we've wander'd mony a weary fit, + +16 +00:01:07,500 --> 00:01:12,000 +Sin auld lang syne. + +17 +00:01:12,000 --> 00:01:16,500 +We twa hae paidl'd in the burn, + +18 +00:01:16,500 --> 00:01:21,000 +Frae morning sun till dine, + +19 +00:01:21,000 --> 00:01:25,500 +But seas between us braid hae roar'd + +20 +00:01:25,500 --> 00:01:30,000 +Sin auld lang syne. + +21 +00:01:30,000 --> 00:01:34,500 +And there's a hand, my trusty fiere, + +22 +00:01:34,500 --> 00:01:39,000 +And gie's a hand o' thine, + +23 +00:01:39,000 --> 00:01:43,500 +And we'll tak a right gude-willie waught, + +24 +00:01:43,500 --> 00:01:48,000 +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.ttml b/tests/fixtures/lyrics/auld-lang-syne.ttml new file mode 100644 index 000000000..a08be29e2 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.ttml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="eng"> + <body> + <div> + <p begin="00:00:00.000" end="00:00:04.500"><span begin="00:00:00.000" end="00:00:00.900">Should </span><span begin="00:00:00.900" end="00:00:01.800">auld </span><span begin="00:00:01.800" end="00:00:02.700">acquaintance </span><span begin="00:00:02.700" end="00:00:03.600">be </span><span begin="00:00:03.600" end="00:00:04.500">forgot,</span></p> + <p begin="00:00:04.500" end="00:00:09.000">And never brought to mind?</p> + <p begin="00:00:09.000" end="00:00:13.500">Should auld acquaintance be forgot,</p> + <p begin="00:00:13.500" end="00:00:18.000">And auld lang syne?</p> + <p begin="00:00:18.000" end="00:00:22.500">For auld lang syne, my dear,</p> + <p begin="00:00:22.500" end="00:00:27.000">For auld lang syne,</p> + <p begin="00:00:27.000" end="00:00:31.500">We'll tak a cup o' kindness yet,</p> + <p begin="00:00:31.500" end="00:00:36.000">For auld lang syne.</p> + <p begin="00:00:36.000" end="00:00:40.500">And surely ye'll be your pint-stowp,</p> + <p begin="00:00:40.500" end="00:00:45.000">And surely I'll be mine,</p> + <p begin="00:00:45.000" end="00:00:49.500">And we'll tak a cup o' kindness yet,</p> + <p begin="00:00:49.500" end="00:00:54.000">For auld lang syne.</p> + <p begin="00:00:54.000" end="00:00:58.500">We twa hae run about the braes,</p> + <p begin="00:00:58.500" end="00:01:03.000">And pou'd the gowans fine,</p> + <p begin="00:01:03.000" end="00:01:07.500">But we've wander'd mony a weary fit,</p> + <p begin="00:01:07.500" end="00:01:12.000">Sin auld lang syne.</p> + <p begin="00:01:12.000" end="00:01:16.500">We twa hae paidl'd in the burn,</p> + <p begin="00:01:16.500" end="00:01:21.000">Frae morning sun till dine,</p> + <p begin="00:01:21.000" end="00:01:25.500">But seas between us braid hae roar'd</p> + <p begin="00:01:25.500" end="00:01:30.000">Sin auld lang syne.</p> + <p begin="00:01:30.000" end="00:01:34.500">And there's a hand, my trusty fiere,</p> + <p begin="00:01:34.500" end="00:01:39.000">And gie's a hand o' thine,</p> + <p begin="00:01:39.000" end="00:01:43.500">And we'll tak a right gude-willie waught,</p> + <p begin="00:01:43.500" end="00:01:48.000">For auld lang syne.</p> + </div> + </body> +</tt> diff --git a/tests/fixtures/lyrics/auld-lang-syne.txt b/tests/fixtures/lyrics/auld-lang-syne.txt new file mode 100644 index 000000000..42ab8330e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.txt @@ -0,0 +1,24 @@ +Should auld acquaintance be forgot, +And never brought to mind? +Should auld acquaintance be forgot, +And auld lang syne? +For auld lang syne, my dear, +For auld lang syne, +We'll tak a cup o' kindness yet, +For auld lang syne. +And surely ye'll be your pint-stowp, +And surely I'll be mine, +And we'll tak a cup o' kindness yet, +For auld lang syne. +We twa hae run about the braes, +And pou'd the gowans fine, +But we've wander'd mony a weary fit, +Sin auld lang syne. +We twa hae paidl'd in the burn, +Frae morning sun till dine, +But seas between us braid hae roar'd +Sin auld lang syne. +And there's a hand, my trusty fiere, +And gie's a hand o' thine, +And we'll tak a right gude-willie waught, +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.yaml b/tests/fixtures/lyrics/auld-lang-syne.yaml new file mode 100644 index 000000000..ca2a3d32e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.yaml @@ -0,0 +1,95 @@ +version: '1.0' +metadata: + title: 'Auld Lang Syne' + artist: 'Robert Burns' + album: 'Traditional' + language: 'eng' +lines: + - text: "Should auld acquaintance be forgot," + start_ms: 0 + end_ms: 4500 + words: + - text: "Should " + start_ms: 0 + end_ms: 900 + - text: "auld " + start_ms: 900 + end_ms: 1800 + - text: "acquaintance " + start_ms: 1800 + end_ms: 2700 + - text: "be " + start_ms: 2700 + end_ms: 3600 + - text: "forgot," + start_ms: 3600 + end_ms: 4500 + - text: "And never brought to mind?" + start_ms: 4500 + end_ms: 9000 + - text: "Should auld acquaintance be forgot," + start_ms: 9000 + end_ms: 13500 + - text: "And auld lang syne?" + start_ms: 13500 + end_ms: 18000 + - text: "For auld lang syne, my dear," + start_ms: 18000 + end_ms: 22500 + - text: "For auld lang syne," + start_ms: 22500 + end_ms: 27000 + - text: "We'll tak a cup o' kindness yet," + start_ms: 27000 + end_ms: 31500 + - text: "For auld lang syne." + start_ms: 31500 + end_ms: 36000 + - text: "And surely ye'll be your pint-stowp," + start_ms: 36000 + end_ms: 40500 + - text: "And surely I'll be mine," + start_ms: 40500 + end_ms: 45000 + - text: "And we'll tak a cup o' kindness yet," + start_ms: 45000 + end_ms: 49500 + - text: "For auld lang syne." + start_ms: 49500 + end_ms: 54000 + - text: "We twa hae run about the braes," + start_ms: 54000 + end_ms: 58500 + - text: "And pou'd the gowans fine," + start_ms: 58500 + end_ms: 63000 + - text: "But we've wander'd mony a weary fit," + start_ms: 63000 + end_ms: 67500 + - text: "Sin auld lang syne." + start_ms: 67500 + end_ms: 72000 + - text: "We twa hae paidl'd in the burn," + start_ms: 72000 + end_ms: 76500 + - text: "Frae morning sun till dine," + start_ms: 76500 + end_ms: 81000 + - text: "But seas between us braid hae roar'd" + start_ms: 81000 + end_ms: 85500 + - text: "Sin auld lang syne." + start_ms: 85500 + end_ms: 90000 + - text: "And there's a hand, my trusty fiere," + start_ms: 90000 + end_ms: 94500 + - text: "And gie's a hand o' thine," + start_ms: 94500 + end_ms: 99000 + - text: "And we'll tak a right gude-willie waught," + start_ms: 99000 + end_ms: 103500 + - text: "For auld lang syne." + start_ms: 103500 + end_ms: 108000 From 6f7af6650c259f3a209629eaa7a2a2fce16f5798 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 19 Jun 2026 18:27:44 -0400 Subject: [PATCH 301/596] chore(deps): update Go dependencies Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 40 ++++++++-------- go.sum | 141 ++++++++++++++++----------------------------------------- 2 files changed, 60 insertions(+), 121 deletions(-) diff --git a/go.mod b/go.mod index 29a415126..abb3e89f0 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.3 + github.com/andybalholm/cascadia v1.3.4 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -36,12 +36,12 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.46 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.29.0 - github.com/onsi/gomega v1.41.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/onsi/ginkgo/v2 v2.31.0 + github.com/onsi/gomega v1.42.0 + github.com/pelletier/go-toml/v2 v2.4.0 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 @@ -54,17 +54,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 + github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.41.0 - golang.org/x/net v0.55.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 - golang.org/x/term v0.43.0 - golang.org/x/text v0.37.0 + golang.org/x/image v0.43.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + golang.org/x/text v0.38.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect + github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -104,7 +104,7 @@ require ( github.com/lestrrat-go/dsig v1.3.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -133,12 +133,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 // indirect + golang.org/x/tools v0.46.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.2 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 57289abfd..fd254bebb 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -32,8 +32,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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= -github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= +github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= +github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= 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= @@ -101,13 +101,12 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -165,8 +164,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= -github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= @@ -175,8 +174,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU= +github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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= @@ -193,12 +192,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= -github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/ginkgo/v2 v2.31.0 h1:GtuJos5DFUV9EerYJo8RhYxosYNGvOdDE5haKq6Grfs= +github.com/onsi/ginkgo/v2 v2.31.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= +github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row= +github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -279,8 +278,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= -github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -295,7 +294,6 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -311,106 +309,47 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= -golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ= +golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= -gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 05105e91d90d0937813dd183e96c09befe9bb383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 19 Jun 2026 19:13:16 -0400 Subject: [PATCH 302/596] feat(scanner): add Scanner.IgnoreDotFolders to allow indexing dot-prefixed folders (#5568) * feat(scanner): add Scanner.IgnoreDotFolders to allow scanning dot folders Adds a new Scanner.IgnoreDotFolders option (default true, preserving current behavior) that, when disabled, lets the scanner traverse folders whose names start with a dot, such as albums like ".Hack Sign Original Soundtrack". Previously every dot-prefixed entry was skipped unconditionally before the directory check, so such album folders were never indexed. The walk loop now determines whether an entry is a directory first, then skips dot-prefixed files always and dot-prefixed folders only when IgnoreDotFolders is enabled. Special system directories are still ignored in all cases via the ignoredDirs blocklist, which now also lists .git explicitly (it was previously caught only by the generic dot-prefix rule). isDirIgnored is reduced to a pure blocklist check and the name-only predicate is renamed from isEntryIgnored to isDotEntry. * refactor(scanner): centralize entry ignore policy in isIgnoredEntry Consolidates the directory-entry ignore decision into a single isIgnoredEntry helper so the walk loop reads as pure dispatch (recurse into directories, classify files) instead of interleaving ignore policy with traversal. The dot-prefix rule and the ignoredDirs blocklist were previously checked in two separate places inside loadDir's loop. They are now combined behind one helper that takes the entry name and whether it is a directory. isDirIgnored remains a standalone blocklist predicate because the file watcher (isIgnoredPath) calls it directly. Adds focused unit tests for isIgnoredEntry covering both states of Scanner.IgnoreDotFolders. No behavior change. * fix(scanner): stop watcher from scanning ignored dot folders A filesystem change inside a dot-prefixed folder (e.g. ".Hidden Album/track.mp3") previously triggered a targeted scan of that folder, because isIgnoredPath let all media files through and only checked the changed path's parent against the ignore list (which never matched for nested paths due to the trailing separator). With Scanner.IgnoreDotFolders enabled this caused the folder to be indexed even though a full scan would skip it. The watcher now ignores any change located inside an ignored directory via a new isUnderIgnoredDir helper that reuses the same isIgnoredEntry policy as the scan walk, and checks the entry itself with isIgnoredEntry instead of the parent dir. This keeps the watcher and the scanner in agreement for both dot-folders (gated by the flag) and the ignoredDirs blocklist. Adds direct table tests for isIgnoredPath covering both states of the option. * fix(scanner): exclude '.' from isDotEntry and ignore dot media files in watcher Addresses code review feedback: - isDotEntry now excludes the literal "." reference, matching its documentation. Previously isDotEntry(".") returned true, which could mark a path component as a dot-entry in the watcher. - isIgnoredPath now ignores dot-prefixed media files (e.g. ".hidden.mp3") so the watcher matches the scanner, which always skips dot files. Non-media leaves still fall through to the directory-assumption check, so dot-folders continue to follow Scanner.IgnoreDotFolders. Adds unit tests for isDotEntry and watcher coverage for dot-prefixed media files. * docs(scanner): clarify isDotEntry multi-dot exclusion and add test Expand the isDotEntry comment to explain why names with two or more leading dots (".."/"..foo"/"...Album") are not treated as hidden, which surprised a reviewer testing dot-folder scanning. Add a "..foo" test case to make the two-leading-dots behavior explicit. Claude-Session: https://claude.ai/code/session_012STiDTyhZAdH8JNtdNe8L1 --- conf/configuration.go | 2 + scanner/walk_dir_tree.go | 41 ++++++++++------ scanner/walk_dir_tree_test.go | 89 +++++++++++++++++++++++++++++++++-- scanner/watcher.go | 38 +++++++++++---- scanner/watcher_test.go | 44 +++++++++++++++++ 5 files changed, 187 insertions(+), 27 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 2ae6e84ca..665a7992f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -162,6 +162,7 @@ type scannerOptions struct { GenreSeparators string // Deprecated: Use Tags.genre.Split instead GroupAlbumReleases bool // Deprecated: Use PID.Album instead FollowSymlinks bool // Whether to follow symlinks when scanning directories + IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning PurgeMissing string // Values: "never", "always", "full" } @@ -821,6 +822,7 @@ func setViperDefaults() { viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) + viper.SetDefault("scanner.ignoredotfolders", true) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendalbumversion", true) diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 78796ac5f..55bbab684 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -123,9 +123,6 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath) continue } - if isEntryIgnored(entry.Name()) { - continue - } if ctx.Err() != nil { return folder, children, ctx.Err() } @@ -135,7 +132,10 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) continue } - if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) { + if isIgnoredEntry(entry.Name(), isDir) { + continue + } + if isDir && isDirReadable(ctx, job.fs, entryPath) { children = append(children, entryPath) folder.numSubFolders++ } else { @@ -276,22 +276,35 @@ var ignoredDirs = []string{ "#snapshot", "@Recycle", "@Recently-Snapshot", + ".git", ".streams", "lost+found", } -// isDirIgnored returns true if the directory represented by dirEnt should be ignored -func isDirIgnored(name string) bool { - // allows Album folders for albums which eg start with ellipses - if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") { +// isIgnoredEntry returns true if a directory entry with the given name should be +// skipped during scanning. It centralizes all name- and type-based ignore policy: +// - special system directories in ignoredDirs are always ignored; +// - dot-prefixed files are always ignored; +// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled, +// allowing albums like ".Hack Sign" to be scanned when the option is off. +func isIgnoredEntry(name string, isDir bool) bool { + if isDir && isDirIgnored(name) { return true } - if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) { - return true - } - return false + return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders) } -func isEntryIgnored(name string) bool { - return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") +// isDirIgnored returns true if the directory name is in the explicit ignoredDirs +// blocklist. Used both while walking the tree and by the file watcher. +func isDirIgnored(name string) bool { + return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) +} + +// isDotEntry returns true only for names with exactly one leading dot (the +// convention for hidden entries), e.g. ".hidden". Names with two or more leading +// dots are not considered hidden: "." and ".." are the special self/parent +// references, and anything like "..foo" or "...Album" is a regular name (album +// folders sometimes start with ellipses), so all of these return false. +func isDotEntry(name string) bool { + return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 95cbba88f..f3b13a4ef 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -49,6 +49,10 @@ var _ = Describe("walk_dir_tree", func() { "root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")}, "root/f/secret": {Data: []byte("TOPSECRET")}, "root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")}, + "root/g/.Hack Sign Original Soundtrack/track.mp3": {}, + "root/h/.hidden.mp3": {}, + "root/i/.git/config": {}, + "root/i/.streams/stream.mp3": {}, }, } job = &scanJob{ @@ -97,6 +101,14 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders).ToNot(HaveKey("root/d")) + // By default (Scanner.IgnoreDotFolders == true), dot-prefixed + // folders are skipped, dot-prefixed files are not indexed, and + // the special ignoredDirs (.git, .streams) are never traversed. + Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack")) + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + // Symlink specific checks if followSymlinks { Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) @@ -110,8 +122,31 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } }, - Entry("with symlinks enabled", true, 8), - Entry("with symlinks disabled", false, 7), + Entry("with symlinks enabled", true, 11), + Entry("with symlinks disabled", false, 10), + ) + + DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled", + func(followSymlinks bool) { + conf.Server.Scanner.FollowSymlinks = followSymlinks + conf.Server.Scanner.IgnoreDotFolders = false + folders := getFolders() + + // Dot-prefixed album folders are now traversed and indexed + Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll( + HaveLen(1), + HaveKey("track.mp3"), + )) + + // Dot-prefixed files are still ignored, even with the flag off + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + + // Special ignoredDirs remain blocked regardless of the flag + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + }, + Entry("with symlinks enabled", true), + Entry("with symlinks disabled", false), ) }) @@ -450,13 +485,61 @@ var _ = Describe("walk_dir_tree", func() { Expect(isDirIgnored(dirName)).To(Equal(expected)) }, Entry("normal dir", "empty_folder", false), - Entry("hidden dir", ".hidden_folder", true), + Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false), + Entry("git dir", ".git", true), + Entry("streams dir", ".streams", true), Entry("dir starting with ellipsis", "...unhidden_folder", false), Entry("recycle bin", "$Recycle.Bin", true), Entry("snapshot dir", "#snapshot", true), ) }) + Describe("isIgnoredEntry", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("with IgnoreDotFolders enabled (default)", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = true + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder", ".Hack Sign Original Soundtrack", true, true), + Entry("dot file", ".hidden.mp3", false, true), + Entry("blocklisted dir", ".git", true, true), + Entry("ellipsis dir", "...unhidden", true, false), + ) + + DescribeTable("with IgnoreDotFolders disabled", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = false + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false), + Entry("dot file is still ignored", ".hidden.mp3", false, true), + Entry("blocklisted dir still ignored", ".git", true, true), + ) + }) + + Describe("isDotEntry", func() { + DescribeTable("returns expected result", + func(name string, expected bool) { + Expect(isDotEntry(name)).To(Equal(expected)) + }, + Entry("dot folder", ".Hidden", true), + Entry("dot file", ".hidden.mp3", true), + Entry("current dir", ".", false), + Entry("parent dir", "..", false), + Entry("two leading dots", "..foo", false), + Entry("ellipsis", "...unhidden", false), + Entry("normal name", "Album", false), + ) + }) + Describe("fullReadDir", func() { var ( fsys fakeFS diff --git a/scanner/watcher.go b/scanner/watcher.go index 376db910c..baf94b79b 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "path/filepath" + "strings" "sync" "time" @@ -320,18 +321,35 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music } func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { - baseDir, name := filepath.Split(path) + _, name := filepath.Split(path) + // A change anywhere inside an ignored directory (a dot-folder when + // Scanner.IgnoreDotFolders is enabled, or a special system folder) must not + // trigger a scan, even for media files: the scan would skip it anyway. + if isUnderIgnoredDir(path) { + return true + } switch { - case model.IsAudioFile(path): - return false - case model.IsValidPlaylist(path): - return false - case model.IsImageFile(path): - return false + case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path): + // A media file is normally not ignored, but a dot-prefixed one (e.g. + // ".hidden.mp3") is always skipped by the scanner, so don't scan for it. + return isDotEntry(name) case name == ".DS_Store": return true } - // As it can be a deletion and not a change, we cannot reliably know if the path is a file or directory. - // But at this point, we can assume it's a directory. If it's a file, it would be ignored anyway - return isDirIgnored(baseDir) + // As it can be a deletion and not a change, we cannot reliably know if the + // path is a file or directory. But at this point, we can assume it's a + // directory. If it's a file, it would be ignored anyway. + return isIgnoredEntry(name, true) +} + +// isUnderIgnoredDir returns true if any parent directory component of the given +// path is an ignored directory, reusing the same policy as the scanner walk. +func isUnderIgnoredDir(path string) bool { + dir, _ := filepath.Split(path) + for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") { + if part != "" && isIgnoredEntry(part, true) { + return true + } + } + return false } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 9795129b0..ffe9f8b15 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -428,6 +428,50 @@ var _ = Describe("Watcher", func() { Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder") }) }) + + }) +}) + +var _ = Describe("isIgnoredPath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Context("with IgnoreDotFolders enabled (default)", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = true + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file in normal folder", "rock/Album/track.mp3", false), + Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true), + Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true), + Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true), + Entry("media file inside .git", "rock/.git/track.mp3", true), + Entry("dot-folder itself", "rock/.Hidden Album", true), + Entry("normal folder itself", "rock/Album", false), + Entry(".DS_Store file", "rock/Album/.DS_Store", true), + ) + }) + + Context("with IgnoreDotFolders disabled", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = false + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false), + Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true), + Entry("dot-folder itself is allowed", "rock/.Hidden Album", false), + Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true), + Entry(".git still ignored", "rock/.git/config", true), + ) }) }) From 6486a27634b112c1d386a5e768a12eefc9253ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 21 Jun 2026 03:45:56 -0400 Subject: [PATCH 303/596] refactor(matcher): index-space resolution + batched title lookups (#5635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(matcher): resolve matches in song-index space * test(matcher): pin per-index duration matching for duplicate title+artist songs * refactor(matcher): drop unreachable specificity sentinel * refactor(matcher): hoist PreferStarred read out of scoring loop * docs(matcher): correct config field references in MatchSongs doc * fix(matcher): log swallowed per-artist DB error in title matching * fix(matcher): fail title matching when all artist lookups error * refactor(matcher): simplify loaders and test helpers * docs(matcher): move algorithm docs to package-level doc.go * docs(matcher): focus examples on fuzzy matching behavior * refactor(matcher): store index in dedup map and harden test helper * fix(matcher): keep exact-phase matches when all title lookups fail * perf(matcher): batch title-phase artist lookups into one query matchByTitle issued one GetAll per distinct artist, run serially. On a large library a batch of similar-songs spans dozens of artists, and profiling against a 95k-track library showed the matcher was ~90% bound in that serial query loop (a 100-song batch fired ~89 separate multi-join queries, taking ~6s). Replace the loop with a single 'order_artist_name IN (...)' query, then group the returned tracks by artist in memory and score each song against its bucket. This cuts a 100-song batch from ~6s to ~0.4s (roughly 14x) with less memory. Grouping keys on order_artist_name (the field the query filters on, matching how the per-artist queries are keyed), falling back to the sanitized Artist when it is unset. Because there is now a single query, matchByTitle is all-or-nothing like the ID/MBID/ISRC loaders: the per-artist best-effort skip is gone, while resolveMatches still preserves exact-phase matches when the title query fails. * refactor(matcher): group batched title matches by order_artist_name After batching the title-phase lookups into one query, the returned tracks must be grouped back to their artist. Key on MediaFile.OrderArtistName — the exact field the query filters on — so collaboration/"feat." tracks (whose display Artist differs from the sort artist) bucket correctly, with a sanitized-Artist fallback when it is unset. OrderArtistName is deprecated in favor of Participants, but the bulk GetAll path does not hydrate participant detail (the rich artist fields come only from the per-record GetWithParticipants JOIN), so the participant order name is empty here and the column is the only populated source. Also adds a TODO in computeSpecificityLevel: its artist-MBID levels read the deprecated, unpopulated MediaFile.MbzArtistID column, so they never fire today. --- core/external/provider_topsongs_test.go | 6 +- core/matcher/doc.go | 108 ++++++ core/matcher/matcher.go | 449 ++++++++++-------------- core/matcher/matcher_test.go | 165 +++++++-- 4 files changed, 431 insertions(+), 297 deletions(-) create mode 100644 core/matcher/doc.go diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 0d9b5800d..d9aff37e8 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -205,8 +205,8 @@ var _ = Describe("Provider - TopSongs", func() { // Since there are no MBIDs, loadTracksByMBID should not make any database call // loadTracksByTitle should make a database call for title matching - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -237,7 +237,7 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // Mock the title fallback query (finds song2 by title) - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) diff --git a/core/matcher/doc.go b/core/matcher/doc.go new file mode 100644 index 000000000..cff866799 --- /dev/null +++ b/core/matcher/doc.go @@ -0,0 +1,108 @@ +// Package matcher matches song results from external agents (Last.fm, Deezer, +// etc.) to tracks in the local music library, prioritizing accuracy over recall. +// +// It exposes a single [Matcher] type with two entry points that share the same +// matching algorithm: +// +// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library +// tracks, capped at a requested count. Use it when presenting "similar +// songs" results to a client. +// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched +// track, with no deduplication. Use it when the caller needs to correlate +// each result back to its input position (e.g. to attach a per-song +// similarity score). +// +// # Algorithm Overview +// +// Each input song is resolved to its best-matching library track using four +// strategies, applied in priority order. A song matched by a higher-priority +// strategy is never reconsidered by a lower-priority one: +// +// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID. +// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to +// tracks with the same mbz_recording_id. +// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag. +// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string +// comparison with metadata-specificity scoring (see below). +// +// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable +// identifiers always take precedence over fuzzy text matching. Missing tracks +// (those no longer present on disk) are never matched. +// +// # Fuzzy Matching Details +// +// Title+artist matching uses Jaro-Winkler similarity, with a threshold +// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library +// track must clear the title threshold to be considered. Candidates that clear +// it are ranked by, in order: +// +// 1. Title similarity (Jaro-Winkler score, 0.0–1.0) +// 2. Duration proximity (closer duration scores higher; 1.0 when the agent +// reports no duration) +// 3. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; +// prioritizes tracks that are starred or rated >= 4) +// 4. Specificity level (0–5, based on metadata precision; higher is better) +// 5. Album similarity (Jaro-Winkler, as the final tiebreaker) +// +// The specificity levels, from most to least specific, are: +// +// Level 5: Title + Artist MBID + Album MBID +// Level 4: Title + Artist MBID + Album name (fuzzy) +// Level 3: Title + Artist name + Album name (fuzzy) +// Level 2: Title + Artist MBID +// Level 1: Title + Artist name +// Level 0: Title only +// +// The title phase always requires an agent artist to scope the library query, so +// Level 0 does not mean "no artist": it applies when a candidate matches on title +// but its own artist differs from the query's (e.g. a cover or a featured-artist +// credit), leaving the title as the only shared field. +// +// Each input song is scored independently, so two songs with the same title and +// artist but different durations can resolve to different library tracks (each +// matches the track closest to its own duration). +// +// # Examples +// +// All examples below exercise the title+artist phase, where the interesting +// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that +// always win over fuzzy matching; they need no illustration.) +// +// Title threshold — a near-miss title still matches; an exact-only threshold +// rejects it: +// +// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} +// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} +// With threshold 85%: match succeeds (similarity ~0.87) +// With threshold 100%: no match (not an exact title) +// +// Specificity ranking — among candidates that clear the title threshold, a +// better album match wins: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1 +// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// Result: t2 (Level 3 beats Level 1 on the album match) +// +// Duration tiebreak — with title and artist equal, the closest duration wins, +// so two near-identical input songs can resolve to different tracks: +// +// Agent returns: +// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05 +// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take) +// Library has: +// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08 +// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02 +// Result: studio for the first song, live for the second +// +// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a +// starred (or rating >= 4) track is preferred even over a more specific match, +// because the preferred flag outranks specificity: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred +// Result: starred (the preferred flag outranks the better album match) +package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 54c2a368e..8b52cbad2 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" "github.com/xrash/smetrics" @@ -23,176 +24,73 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongs matches agent song results to local library tracks using a multi-phase -// matching algorithm that prioritizes accuracy over recall. +// MatchSongs matches agent songs to library tracks and returns up to count +// tracks in the input's order. See the package documentation for the matching +// algorithm. // -// # Algorithm Overview -// -// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the -// local music library using four matching strategies in priority order: -// -// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID -// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with -// matching mbz_recording_id -// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag -// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison -// with metadata specificity scoring -// -// # Matching Priority -// -// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist. -// This ensures that more reliable identifiers take precedence over fuzzy text matching. -// -// # Fuzzy Matching Details -// -// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable -// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: -// -// 1. Title similarity (Jaro-Winkler score, 0.0-1.0) -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is -// starred or has rating >= 4) -// 4. Specificity level (0-5, based on metadata precision): -// - Level 5: Title + Artist MBID + Album MBID (most specific) -// - Level 4: Title + Artist MBID + Album name (fuzzy) -// - Level 3: Title + Artist name + Album name (fuzzy) -// - Level 2: Title + Artist MBID -// - Level 1: Title + Artist name -// - Level 0: Title only -// 5. Album similarity (Jaro-Winkler, as final tiebreaker) -// -// # Examples -// -// Example 1 - MBID Priority: -// -// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (MBID match takes priority over title+artist) -// -// Example 2 - ISRC Priority: -// -// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (ISRC match takes priority over title+artist) -// -// Example 3 - Specificity Ranking: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: [ -// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1 -// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3 -// ] -// Result: t2 (Level 3 beats Level 1 due to album match) -// -// Example 4 - Fuzzy Title Matching: -// -// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} -// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} -// With threshold=85%: Match succeeds (similarity ~0.87) -// With threshold=100%: No match (not exact) -// -// # Parameters -// -// - ctx: Context for database operations -// - songs: Slice of agent.Song results from external providers -// - count: Maximum number of matches to return -// -// # Returns -// -// Returns up to 'count' MediaFiles from the library that best match the input songs, -// preserving the original order from the agent. Songs that cannot be matched are skipped. +// Each library track appears at most once, unless the same input song is +// repeated: identical input songs intentionally yield repeated output tracks, +// while distinct songs that resolve to the same track are deduplicated. Songs +// that cannot be matched are skipped. func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { if len(songs) == 0 { return nil, nil } - - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + matches, err := m.resolveMatches(ctx, songs) if err != nil { return nil, err } - return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil + return orderAndDedup(songs, matches, count), nil } -// MatchSongsIndexed matches agent song results to local library tracks and returns a map -// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. -// This preserves original indices, allowing callers to correlate results back to the input slice. +// MatchSongsIndexed matches agent songs to library tracks and returns a map from +// input-song index to matched track, letting callers correlate results back to +// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs, +// results are not deduplicated. See the package documentation for the matching +// algorithm. func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { if len(songs) == 0 { return nil, nil } + return m.resolveMatches(ctx, songs) +} - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) - if err != nil { - return nil, err - } - +// resolveMatches resolves each input song to its best-matching library track, +// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC > +// Title); each only fills indices not already matched by a higher-priority loader. +func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { result := make(map[int]model.MediaFile, len(songs)) - for i, t := range songs { - if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { - result[i] = mf + if err := m.matchByID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ID: %w", err) + } + if err := m.matchByMBID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by MBID: %w", err) + } + if err := m.matchByISRC(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err) + } + // The title phase is best-effort: a DB failure there must not discard the exact + // matches already found by the higher-priority phases. Only surface it as fatal + // when nothing matched at all. + if err := m.matchByTitle(ctx, songs, result); err != nil { + if len(result) == 0 { + return nil, fmt.Errorf("failed to match tracks by title: %w", err) } + log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err) } return result, nil } -func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { - byID, err = m.loadTracksByID(ctx, songs) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - byMBID, err = m.loadTracksByMBID(ctx, songs, byID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) - } - return byID, byMBID, byISRC, byTitle, nil -} - -// songMatchedIn checks if a song has already been matched in any of the provided match maps. -func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { - _, found := lookupByIdentifiers(s, priorMatches...) - return found -} - -// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { - keys := []string{s.ID, s.MBID, s.ISRC} - for _, m := range maps { - for _, key := range keys { - if key != "" { - if mf, ok := m[key]; ok && mf.ID != "" { - return mf, true - } - } - } - } - return model.MediaFile{}, false -} - -// loadTracksByID fetches MediaFiles from the library using direct ID matching. -func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +// matchByID fills result with direct ID matches. +func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var ids []string for _, s := range songs { if s.ID != "" { ids = append(ids, s.ID) } } - matches := map[string]model.MediaFile{} if len(ids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -201,27 +99,37 @@ func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[ }, }) if err != nil { - return matches, err + return err } + byID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { - if _, ok := matches[mf.ID]; !ok { - matches[mf.ID] = mf + byID[mf.ID] = mf // media_file.id is unique, so no dedup needed + } + for i, s := range songs { + if s.ID == "" { + continue + } + if mf, ok := byID[s.ID]; ok { + result[i] = mf } } - return matches, nil + return nil } -// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByMBID fills result with MusicBrainz Recording ID matches, skipping +// songs already matched by a higher-priority loader. +func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var mbids []string - for _, s := range songs { - if s.MBID != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID != "" { mbids = append(mbids, s.MBID) } } - matches := map[string]model.MediaFile{} if len(mbids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -230,45 +138,72 @@ func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, pri }, }) if err != nil { - return matches, err + return err } + byMBID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { if id := mf.MbzRecordingID; id != "" { - if _, ok := matches[id]; !ok { - matches[id] = mf + if _, ok := byMBID[id]; !ok { + byMBID[id] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID == "" { + continue + } + if mf, ok := byMBID[s.MBID]; ok { + result[i] = mf + } + } + return nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. -func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByISRC fills result with ISRC tag matches, skipping songs already +// matched by a higher-priority loader. +func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var isrcs []string - for _, s := range songs { - if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC != "" { isrcs = append(isrcs, s.ISRC) } } - matches := map[string]model.MediaFile{} if len(isrcs) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { - return matches, err + return err } + byISRC := make(map[string]model.MediaFile, len(res)) for _, mf := range res { for _, isrc := range mf.Tags.Values(model.TagISRC) { - if _, ok := matches[isrc]; !ok { - matches[isrc] = mf + if _, ok := byISRC[isrc]; !ok { + byISRC[isrc] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC == "" { + continue + } + if mf, ok := byISRC[s.ISRC]; ok { + result[i] = mf + } + } + return nil } // songQuery represents a normalized query for matching a song to library tracks. @@ -308,9 +243,8 @@ func (s matchScore) betterThan(other matchScore) bool { } // sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization -// when the same track is scored against multiple queries in the inner loop. The `mf` field -// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist -// sanitized slice. +// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid +// copying the large MediaFile struct into each entry of the sanitized slice. type sanitizedTrack struct { mf *model.MediaFile title string @@ -329,6 +263,12 @@ func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { // computeSpecificityLevel determines how well query metadata matches a track (0-5). // The track's title, artist, and album fields must be pre-sanitized. +// +// TODO: the artist-MBID levels (5, 4, 2) read the deprecated MediaFile.MbzArtistID +// column, which is not populated — the artist MBID lives in the artist table and is +// only hydrated by GetWithParticipants, not the bulk GetAll path used here. As a +// result those levels never fire. To make them work, hydrate the artist participant +// (or denormalize mbz_artist_id onto media_file) so t.mf carries the artist MBID. func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { @@ -348,56 +288,86 @@ func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float if q.artist != "" && t.artist == q.artist { return 1 } - if t.title == q.title { - return 0 - } - return -1 + return 0 } -// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := m.buildTitleQueries(songs, priorMatches...) - if len(queries) == 0 { - return map[string]model.MediaFile{}, nil +// indexedQuery pairs a normalized songQuery with the index of the input song +// it came from, so title matches can be written back to result by index. +type indexedQuery struct { + index int + query songQuery +} + +// matchByTitle fills result with fuzzy title+artist matches, skipping songs +// already matched by a higher-priority loader. +func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { + byArtist := map[string][]indexedQuery{} + for i, s := range songs { + if _, done := result[i]; done { + continue + } + artist := str.SanitizeFieldForSortingNoArticle(s.Artist) + if artist == "" { + continue // title matching needs an artist to scope the library query + } + q := songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artist: artist, + artistMBID: s.ArtistMBID, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + } + byArtist[artist] = append(byArtist[artist], indexedQuery{index: i, query: q}) + } + if len(byArtist) == 0 { + return nil + } + + // One batched query (order_artist_name IN ...) instead of one per artist: on a + // large library the per-query overhead dominates, so this is the main cost saver. + artists := make([]string, 0, len(byArtist)) + for artist := range byArtist { + artists = append(artists, artist) + } + tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"order_artist_name": artists}, + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) + if err != nil { + return err + } + + // Key on order_artist_name — the exact field the query filtered on, which matches + // how byArtist is keyed. A track's display Artist can differ (collaborations, + // "feat." credits), so re-deriving from Artist would misbucket. This reads the + // deprecated MediaFile.OrderArtistName column because the bulk GetAll path does + // not hydrate participant detail (only {id, name}), so the participant's order + // name is empty here; the column is the only populated source. + tracksByArtist := make(map[string][]sanitizedTrack, len(byArtist)) + for i := range tracks { + key := tracks[i].OrderArtistName + if key == "" { + key = str.SanitizeFieldForSortingNoArticle(tracks[i].Artist) + } + tracksByArtist[key] = append(tracksByArtist[key], newSanitizedTrack(&tracks[i])) } threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - - byArtist := map[string][]songQuery{} - for _, q := range queries { - if q.artist != "" { - byArtist[q.artist] = append(byArtist[q.artist], q) - } - } - - matches := map[string]model.MediaFile{} - for artist, artistQueries := range byArtist { - tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"order_artist_name": artist}, - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) - if err != nil { - continue - } - - sanitized := make([]sanitizedTrack, len(tracks)) - for i := range tracks { - sanitized[i] = newSanitizedTrack(&tracks[i]) - } - - for _, q := range artistQueries { - if mf, found := m.findBestMatch(q, sanitized, threshold); found { - key := q.title + "|" + q.artist - if _, exists := matches[key]; !exists { - matches[key] = mf - } + for artist, queries := range byArtist { + sanitized := tracksByArtist[artist] + // Each song is matched independently by index, so two songs with the same + // (title, artist) but different durations can resolve to different tracks. + for _, iq := range queries { + if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { + result[iq.index] = mf } } } - return matches, nil + return nil } // durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration @@ -417,6 +387,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t bestScore := matchScore{titleSimilarity: -1} found := false + preferStarred := conf.Server.Matcher.PreferStarred for _, t := range sanitizedTracks { titleSim := similarityRatio(q.title, t.title) @@ -432,7 +403,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t score := matchScore{ titleSimilarity: titleSim, durationProximity: durationProximity(q.durationMs, t.mf.Duration), - preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), + preferredMatch: preferStarred && isPreferredTrack(t.mf), albumSimilarity: albumSim, specificityLevel: computeSpecificityLevel(q, t, threshold), } @@ -450,66 +421,34 @@ func isPreferredTrack(mf *model.MediaFile) bool { return mf.Starred || mf.Rating >= 4 } -// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { - var queries []songQuery - for _, s := range songs { - if songMatchedIn(s, priorMatches...) { - continue - } - queries = append(queries, songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artist: str.SanitizeFieldForSortingNoArticle(s.Artist), - artistMBID: s.ArtistMBID, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - }) - } - return queries -} - -// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks using priority order: ID > MBID > ISRC > title+artist. -func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// orderAndDedup builds the final ordered result from the per-index matches, +// applying the count limit and deduplication. A library track is added at most +// once unless the same input song appears more than once (callers rely on that +// 1:1 positional behavior for identical duplicate inputs). +func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - addedBy := make(map[string]agents.Song, len(songs)) + addedBy := make(map[string]int, len(songs)) - for _, t := range songs { + for i, s := range songs { if len(mfs) == count { break } - - mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist) + mf, found := matches[i] if !found { continue } - - if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if t != prevSong { + if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { + if s != songs[prevIdx] { continue } } else { - addedBy[mf.ID] = t + addedBy[mf.ID] = i } - mfs = append(mfs, mf) } return mfs } -// findMatchingTrack looks up a song in the match maps using priority order. -func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { - return mf, true - } - key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) - if mf, ok := byTitleArtist[key]; ok { - return mf, true - } - return model.MediaFile{}, false -} - // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. func similarityRatio(a, b string) float64 { if a == b { diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 42e3ec88d..0d08897d1 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -53,24 +53,30 @@ var _ = Describe("Matcher", func() { Return(matches, nil).Once() } - // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return - // early without hitting the DB) don't cause test failures for unexpected calls. Call - // this after expect*Phase for the phases the test actually wants to verify. - allowOtherPhases := func() { + // allowIdentifierPhases installs .Maybe() catch-alls for the ID/MBID/ISRC phases so + // tests that only care about the title phase don't fail on those unexpected calls. + allowIdentifierPhases := func() { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). Return(model.MediaFiles{}, nil).Maybe() + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + allowIdentifierPhases() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). Return(model.MediaFiles{}, nil).Maybe() } - // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise - // the title+artist phase. The title phase uses .Maybe() because it may short-circuit - // when no songs have an artist. - setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { + // allowTitlePhase is a convenience for fuzzy-match tests that only exercise the + // title+artist phase. It uses .Maybe() because the phase may short-circuit when no + // songs have an artist. + allowTitlePhase := func(artistTracks model.MediaFiles) { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). Return(artistTracks, nil).Maybe() } @@ -141,7 +147,7 @@ var _ = Describe("Matcher", func() { titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", } - setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + allowTitlePhase(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -156,7 +162,7 @@ var _ = Describe("Matcher", func() { fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -171,7 +177,7 @@ var _ = Describe("Matcher", func() { differentTracks := model.MediaFiles{ {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, } - setupTitleOnlyExpectations(differentTracks) + allowTitlePhase(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -188,7 +194,7 @@ var _ = Describe("Matcher", func() { libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -204,7 +210,7 @@ var _ = Describe("Matcher", func() { libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -247,7 +253,7 @@ var _ = Describe("Matcher", func() { {ID: "b", Title: "Song B", Artist: "Artist"}, {ID: "c", Title: "Song C", Artist: "Artist"}, } - setupTitleOnlyExpectations(tracks) + allowTitlePhase(tracks) result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -261,6 +267,65 @@ var _ = Describe("Matcher", func() { Expect(result).To(BeEmpty()) }) }) + + Context("artist grouping", func() { + It("groups title-phase tracks by order_artist_name, not display Artist", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Daft Punk"}, + } + // Display Artist differs from the query artist; only OrderArtistName + // matches, so grouping must key on it (a "feat." credit, collaboration, etc.). + track := model.MediaFile{ + ID: "oan-track", Title: "Song A", + Artist: "Daft Punk feat. Pharrell", OrderArtistName: "daft punk", + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("oan-track")) + }) + }) + + // These tests register their own order_artist_name expectation per-test (to inject + // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would + // add a .Maybe() title-phase catch-all that masks the injected error. + Context("title phase DB errors", func() { + It("returns an error when the title query fails and nothing else matched", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One"}, + {Name: "Song B", Artist: "Artist Two"}, + } + allowIdentifierPhases() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(nil, errors.New("db down")) + + _, err := m.MatchSongs(ctx, songs, 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("db down")) + }) + + It("keeps exact-phase matches when the title query fails", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Exact Song", Artist: "Exact Artist"}, + {Name: "Fuzzy Song", Artist: "Fuzzy Artist"}, + } + idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} + expectIDPhase(model.MediaFiles{idMatch}) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(nil, errors.New("db down")) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) }) Describe("MatchSongsIndexed", func() { @@ -328,7 +393,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -348,7 +413,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -368,7 +433,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song", Artist: "Depeche Mode"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -382,7 +447,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song"}, } - setupTitleOnlyExpectations(model.MediaFiles{}) + allowTitlePhase(model.MediaFiles{}) result, err := m.MatchSongs(ctx, songs, 5) @@ -401,7 +466,7 @@ var _ = Describe("Matcher", func() { {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, } - setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) result, err := m.MatchSongs(ctx, songs, 5) @@ -429,7 +494,7 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist Two"}, } - setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -452,7 +517,7 @@ var _ = Describe("Matcher", func() { {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -471,7 +536,7 @@ var _ = Describe("Matcher", func() { {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -492,7 +557,7 @@ var _ = Describe("Matcher", func() { {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -512,7 +577,7 @@ var _ = Describe("Matcher", func() { {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -540,7 +605,7 @@ var _ = Describe("Matcher", func() { ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -560,7 +625,7 @@ var _ = Describe("Matcher", func() { ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -580,7 +645,7 @@ var _ = Describe("Matcher", func() { ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -601,7 +666,7 @@ var _ = Describe("Matcher", func() { ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, } - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) + allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -622,7 +687,7 @@ var _ = Describe("Matcher", func() { ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, } - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) + allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -648,7 +713,7 @@ var _ = Describe("Matcher", func() { ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, } - setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -665,7 +730,7 @@ var _ = Describe("Matcher", func() { ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, } - setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + allowTitlePhase(model.MediaFiles{closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -685,7 +750,7 @@ var _ = Describe("Matcher", func() { ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, } - setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -702,7 +767,7 @@ var _ = Describe("Matcher", func() { ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, } - setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + allowTitlePhase(model.MediaFiles{differentDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -722,7 +787,7 @@ var _ = Describe("Matcher", func() { ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, } - setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) result, err := m.MatchSongs(ctx, songs, 5) @@ -739,7 +804,7 @@ var _ = Describe("Matcher", func() { ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, } - setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + allowTitlePhase(model.MediaFiles{anyTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -756,7 +821,7 @@ var _ = Describe("Matcher", func() { ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, } - setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + allowTitlePhase(model.MediaFiles{shortTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -764,6 +829,28 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("short")) }) + + It("matches same title+artist songs to their own closest-duration track", func() { + songs := []agents.Song{ + {Name: "Same Song", Artist: "Same Artist", Duration: 180000}, + {Name: "Same Song", Artist: "Same Artist", Duration: 240000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, + } + longTrack := model.MediaFile{ + ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, + } + + allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("short")) + Expect(result[1].ID).To(Equal("long")) + }) }) Describe("deduplication edge cases", func() { @@ -782,7 +869,7 @@ var _ = Describe("Matcher", func() { ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -802,7 +889,7 @@ var _ = Describe("Matcher", func() { trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) result, err := m.MatchSongs(ctx, songs, 5) @@ -823,7 +910,7 @@ var _ = Describe("Matcher", func() { trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + allowTitlePhase(model.MediaFiles{trackA, trackB}) result, err := m.MatchSongs(ctx, songs, 2) From 803b385920ce50b9d5ec42c152551dfc7de5c48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 21 Jun 2026 11:05:58 -0400 Subject: [PATCH 304/596] fix(matcher): match by artist credit so artist-MBID specificity works and collaborators match (#5637) * refactor(matcher): carry resolved artist MBID on sanitizedTrack * feat(matcher): two-phase artist resolution for title matching * fix(matcher): wire phase-1 artist mock in consumer tests; guard back-map dupes - External tests for title-fallback paths now register a second `.Maybe()` artistRepo.GetAll expectation for the matcher's phase-1 artist-resolution call, and supply RoleArtist Participants on phase-2 track mocks so the back-map routes tracks to their query buckets correctly. - Back-map loop extracted into buildTracksByQuery helper; guard ensures each track is appended at most once per query bucket even when it credits multiple resolved artists in that bucket, preventing duplicate scoring. - matchTitlePhase2 helper: removed redundant squirrel.Sqlizer type assertion (ranging over squirrel.And already yields Sqlizer). * perf(matcher): use non-correlated IN subquery for phase-2 lookup * refactor(matcher): decompose two-phase title matching into named steps matchByTitle had grown to ~98 lines holding four jobs and eleven locals. Split it into a thin orchestrator over named phases: - groupQueriesByArtist: build the per-artist query buckets. - resolveArtists / resolvedArtists: phase 1, with the three parallel maps (byQuery / mbid / allIDs) folded into one type that owns artist-ID routing and track bucketing. Artist ownership is now two direct map lookups (by order name, by MBID) instead of the prior O(artists x queries) nested scan. - fetchTracksCreditedTo: phase 2, using squirrel.Placeholders instead of a hand-rolled placeholder string. - bucketTracks / scoring loop: unchanged behavior. Pure restructuring; the suite, race, consumers, and the real-DB benchmark are all unchanged. Also dedupes the phase-1 MBID filter as a side effect. * refactor(matcher): apply simplify cleanups to two-phase resolution Behavior-preserving cleanups from a /simplify pass: - struct{} sets instead of bool-valued sets (codebase convention) - move newSanitizedTrack after the dedup guard in bucketTracks, so a track credited to multiple resolved artists is sanitized only when actually bucketed - pre-size the phase-1/phase-2 maps; drop the dead nil-guard in own() - slice.Map for the []string->[]any arg conversion - document why the raw media_file_artists SQL stays in this layer, and that bucketTracks relies on the bulk participants JSON carrying artist IDs - extract an artistParticipants() test helper, collapsing 57 four-level Participants literals (test file -211 lines net) * docs(matcher): rename inner title-matching steps to avoid 'phase' clash The matcher's top-level strategies (ID/MBID/ISRC/Title) are already called phases. Reusing 'phase 1/2' for the two steps inside title matching (resolve artists, fetch their tracks) was confusing. Drop the ordinals and let the function names (resolveArtists, fetchTracksCreditedTo) and prose describe the steps. Renamed the matchTitlePhase2 test helper to matchTracksByArtistQuery. Comment-only; no behavior change. * fix(matcher): own MBID-resolved artists for every aliased query Address bot review on PR #5637. When several agent queries share one ArtistMBID under different sanitized names (agent aliases), the resolver kept only the last query name per MBID, so the others never owned the resolved artist and their songs fell through. Track all query names per MBID instead; add a RED-proven test for the two-alias case. Also invert byQuery into a reverse artist-ID -> query-name index in bucketTracks, dropping the per-participant scan over all queries, and guard fetchTracksCreditedTo against an empty artist-ID slice. Fix the artist mock to forward variadic options with Called(options...) so QueryOptions-shaped matchers receive the same argument shape as real calls. * docs(matcher): trim comments that restated the code Cut three doc comments down to their why: groupQueriesByArtist, own, and bucketTracks no longer restate what the signature and body already show. Condense fetchTracksCreditedTo's rationale from two paragraphs to one, keeping the role='artist', non-correlated-IN, and layer-boundary notes. --- core/external/provider_similarsongs_test.go | 34 +- core/external/provider_topsongs_test.go | 30 +- core/matcher/matcher.go | 238 ++++++++++---- core/matcher/matcher_internal_test.go | 14 + core/matcher/matcher_test.go | 337 ++++++++++++++++++-- 5 files changed, 536 insertions(+), 117 deletions(-) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index c9a1a64ef..f109bf8b1 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -3,6 +3,7 @@ package external_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" @@ -56,7 +57,14 @@ var _ = Describe("Provider - SimilarSongs", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} - matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"} + + // Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping. + dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"} + dmParticipant := model.Participant{Artist: dmArtist} + matchedSong := model.MediaFile{ + ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}}, + } // GetEntityByID tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() @@ -68,13 +76,16 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"}, }, nil).Once() - // Mock loadTracksByID - no ID matches + // Matcher artist resolution: resolve Depeche Mode in the artist table. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe() + + // ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok - })).Return(model.MediaFiles{}, nil).Once() + })).Return(model.MediaFiles{}, nil).Maybe() - // Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called) + // MBID phase: won't fire (empty MBID). mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok || len(and) < 1 { @@ -88,18 +99,19 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasMBID })).Return(model.MediaFiles{}, nil).Maybe() - // Mock loadTracksByTitleAndArtist - queries by artist name + // Matcher track-fetch: subquery returns the matched song with participants. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { + if !ok { return false } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } } - _, hasArtist := eq["order_artist_name"] - return hasArtist + return false })).Return(model.MediaFiles{matchedSong}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "track-1", 5) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index d9aff37e8..600524819 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -148,6 +148,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for the title-match path (song2 falls through). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response agentSongs := []agents.Song{ @@ -159,7 +161,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding matching tracks (only find song 1 on bulk query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2 + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -195,6 +197,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for both title-fallback songs. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with songs that have NO MBID (empty string) agentSongs := []agents.Song{ @@ -203,10 +207,16 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Since there are no MBIDs, loadTracksByMBID should not make any database call - // loadTracksByTitle should make a database call for title matching - song1 := model.MediaFile{ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + // Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them. + participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} + song1 := model.MediaFile{ + ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } + song2 := model.MediaFile{ + ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -224,6 +234,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for song2's title-fallback path. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with mixed MBID availability agentSongs := []agents.Song{ @@ -236,8 +248,12 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - // Mock the title fallback query (finds song2 by title) - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + // Title track-fetch: song2 must carry RoleArtist participants for back-mapping. + participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} + song2 := model.MediaFile{ + ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 8b52cbad2..5322b649f 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -3,13 +3,16 @@ package matcher import ( "context" "fmt" + "maps" "math" + "slices" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/xrash/smetrics" ) @@ -246,43 +249,40 @@ func (s matchScore) betterThan(other matchScore) bool { // when the same track is scored against multiple queries. The `mf` field is a pointer to avoid // copying the large MediaFile struct into each entry of the sanitized slice. type sanitizedTrack struct { - mf *model.MediaFile - title string - artist string - album string + mf *model.MediaFile + title string + artist string + album string + artistMBID string // resolved from the artist table; mf.MbzArtistID is not populated on the bulk path } -func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { +func newSanitizedTrack(mf *model.MediaFile, artistMBID string) sanitizedTrack { return sanitizedTrack{ - mf: mf, - title: str.SanitizeFieldForSorting(mf.Title), - artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), - album: str.SanitizeFieldForSorting(mf.Album), + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + artistMBID: artistMBID, } } // computeSpecificityLevel determines how well query metadata matches a track (0-5). -// The track's title, artist, and album fields must be pre-sanitized. -// -// TODO: the artist-MBID levels (5, 4, 2) read the deprecated MediaFile.MbzArtistID -// column, which is not populated — the artist MBID lives in the artist table and is -// only hydrated by GetWithParticipants, not the bulk GetAll path used here. As a -// result those levels never fire. To make them work, hydrate the artist participant -// (or denormalize mbz_artist_id onto media_file) so t.mf carries the artist MBID. +// The track's title, artist, and album fields must be pre-sanitized, and artistMBID +// must hold the resolved artist MBID. func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && - t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { + t.artistMBID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { return 5 } if q.artistMBID != "" && q.album != "" && - t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { + t.artistMBID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { return 4 } if q.artist != "" && q.album != "" && t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { return 3 } - if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { + if q.artistMBID != "" && t.artistMBID == q.artistMBID { return 2 } if q.artist != "" && t.artist == q.artist { @@ -301,64 +301,25 @@ type indexedQuery struct { // matchByTitle fills result with fuzzy title+artist matches, skipping songs // already matched by a higher-priority loader. func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - byArtist := map[string][]indexedQuery{} - for i, s := range songs { - if _, done := result[i]; done { - continue - } - artist := str.SanitizeFieldForSortingNoArticle(s.Artist) - if artist == "" { - continue // title matching needs an artist to scope the library query - } - q := songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artist: artist, - artistMBID: s.ArtistMBID, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - } - byArtist[artist] = append(byArtist[artist], indexedQuery{index: i, query: q}) - } + byArtist := groupQueriesByArtist(songs, result) if len(byArtist) == 0 { return nil } - // One batched query (order_artist_name IN ...) instead of one per artist: on a - // large library the per-query overhead dominates, so this is the main cost saver. - artists := make([]string, 0, len(byArtist)) - for artist := range byArtist { - artists = append(artists, artist) + resolved, err := m.resolveArtists(ctx, byArtist) + if err != nil || len(resolved.allIDs) == 0 { + return err } - tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"order_artist_name": artists}, - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) + + tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs) if err != nil { return err } - // Key on order_artist_name — the exact field the query filtered on, which matches - // how byArtist is keyed. A track's display Artist can differ (collaborations, - // "feat." credits), so re-deriving from Artist would misbucket. This reads the - // deprecated MediaFile.OrderArtistName column because the bulk GetAll path does - // not hydrate participant detail (only {id, name}), so the participant's order - // name is empty here; the column is the only populated source. - tracksByArtist := make(map[string][]sanitizedTrack, len(byArtist)) - for i := range tracks { - key := tracks[i].OrderArtistName - if key == "" { - key = str.SanitizeFieldForSortingNoArticle(tracks[i].Artist) - } - tracksByArtist[key] = append(tracksByArtist[key], newSanitizedTrack(&tracks[i])) - } - + tracksByQuery := resolved.bucketTracks(tracks) threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 for artist, queries := range byArtist { - sanitized := tracksByArtist[artist] + sanitized := tracksByQuery[artist] // Each song is matched independently by index, so two songs with the same // (title, artist) but different durations can resolve to different tracks. for _, iq := range queries { @@ -370,6 +331,151 @@ func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result return nil } +// groupQueriesByArtist buckets the still-unmatched title queries by sanitized artist name. +// Songs without an artist are skipped: title matching needs one to scope the library query. +func groupQueriesByArtist(songs []agents.Song, result map[int]model.MediaFile) map[string][]indexedQuery { + byArtist := map[string][]indexedQuery{} + for i, s := range songs { + if _, done := result[i]; done { + continue + } + artist := str.SanitizeFieldForSortingNoArticle(s.Artist) + if artist == "" { + continue + } + byArtist[artist] = append(byArtist[artist], indexedQuery{index: i, query: songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artist: artist, + artistMBID: s.ArtistMBID, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + }}) + } + return byArtist +} + +// resolvedArtists holds the agent artists resolved to artist-table rows. Everything routes by +// stable artist ID, never by name, so MBID-resolved artists whose order name differs from the +// query name are not misrouted. +type resolvedArtists struct { + byQuery map[string]map[string]struct{} // sanitized query name -> set of resolved artist IDs + mbid map[string]string // artist ID -> its MBID (the real one, from the artist table) + allIDs []string // every resolved artist ID, for the track lookup +} + +// resolveArtists resolves the queries' artists against the artist table (by sort name or +// agent-provided MBID) and records, for each query, which artist IDs it owns. +func (m *Matcher) resolveArtists(ctx context.Context, byArtist map[string][]indexedQuery) (resolvedArtists, error) { + names := make([]string, 0, len(byArtist)) + mbidToQueries := make(map[string][]string, len(byArtist)) // agent ArtistMBID -> query names that supplied it + for name, queries := range byArtist { + names = append(names, name) + for _, iq := range queries { + if iq.query.artistMBID != "" { + mbidToQueries[iq.query.artistMBID] = append(mbidToQueries[iq.query.artistMBID], name) + } + } + } + + filter := squirrel.Or{squirrel.Eq{"order_artist_name": names}} + if len(mbidToQueries) > 0 { + filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) + } + artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) + if err != nil { + return resolvedArtists{}, err + } + + res := resolvedArtists{ + byQuery: make(map[string]map[string]struct{}, len(byArtist)), + mbid: make(map[string]string, len(artists)), + allIDs: make([]string, 0, len(artists)), + } + for _, a := range artists { + res.mbid[a.ID] = a.MbzArtistID + res.allIDs = append(res.allIDs, a.ID) + // An artist belongs to a query if its order name matches the query name, or if its MBID + // matches one a query supplied. The same MBID can come from several queries (agent aliases), + // so every one of them owns the artist. + res.own(a.OrderArtistName, a.ID) + if a.MbzArtistID != "" { + for _, name := range mbidToQueries[a.MbzArtistID] { + res.own(name, a.ID) + } + } + } + return res, nil +} + +// own records that the named query owns the given artist ID. A name that is not a query simply +// gets its own (unused) entry. +func (r resolvedArtists) own(name, artistID string) { + if r.byQuery[name] == nil { + r.byQuery[name] = map[string]struct{}{} + } + r.byQuery[name][artistID] = struct{}{} +} + +// bucketTracks groups tracks by query name, at most once per query even when a track credits +// several of that query's artists, so the same track is not scored twice. The participants JSON +// on each track carries artist IDs but not their MBID, so the MBID comes from r.mbid instead. +func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[string][]sanitizedTrack { + // Invert byQuery once so each participant maps straight to the queries that own it, instead of + // scanning every query per participant. + queriesByArtist := make(map[string][]string) + for name, ids := range r.byQuery { + for id := range ids { + queriesByArtist[id] = append(queriesByArtist[id], name) + } + } + + byQuery := make(map[string][]sanitizedTrack, len(r.byQuery)) + added := make(map[string]map[string]struct{}, len(r.byQuery)) // query name -> set of track IDs already bucketed + for i := range tracks { + for _, p := range tracks[i].Participants[model.RoleArtist] { + mbid, isResolved := r.mbid[p.ID] + if !isResolved { + continue + } + for _, name := range queriesByArtist[p.ID] { + if added[name] == nil { + added[name] = map[string]struct{}{} + } + if _, dup := added[name][tracks[i].ID]; dup { + continue + } + added[name][tracks[i].ID] = struct{}{} + byQuery[name] = append(byQuery[name], newSanitizedTrack(&tracks[i], mbid)) + } + } + } + return byQuery +} + +// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as +// the main artist (role='artist', not albumartist — that avoids tribute/compilation false +// positives). The non-correlated id IN (subquery) materializes the matching ids once from the +// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs +// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr +// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the +// cleaner home if this is reused. +func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) { + if len(artistIDs) == 0 { + return nil, nil + } + args := slice.Map(artistIDs, func(id string) any { return id }) + return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists "+ + "WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...), + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) +} + // durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration // is to the target. Returns 1.0 if durationMs is 0 (unknown). func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 { diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index f111364c1..62bccd844 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,6 +1,7 @@ package matcher import ( + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -51,3 +52,16 @@ var _ = Describe("similarityRatio", func() { Expect(ratio1).To(Equal(ratio2)) }) }) + +var _ = Describe("matcher internals", func() { + It("computeSpecificityLevel uses sanitizedTrack.artistMBID for artist-MBID levels", func() { + q := songQuery{ + title: "song", + artistMBID: "artist-mbid-1", + albumMBID: "album-mbid-1", + } + mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty + t := newSanitizedTrack(&mf, "artist-mbid-1") // resolved MBID supplied here + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + }) +}) diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 0d08897d1..1fad0ebcc 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -3,6 +3,7 @@ package matcher_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" @@ -19,6 +20,7 @@ import ( var _ = Describe("Matcher", func() { var ds model.DataStore var mediaFileRepo *mockMediaFileRepo + var artistRepo *mockArtistRepo var ctx context.Context var m *matcher.Matcher @@ -26,11 +28,13 @@ var _ = Describe("Matcher", func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) mediaFileRepo = newMockMediaFileRepo() + artistRepo = newMockArtistRepo() DeferCleanup(func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) ds = &tests.MockDataStore{ MockedMediaFile: mediaFileRepo, + MockedArtist: artistRepo, } m = matcher.New(ds) }) @@ -69,16 +73,33 @@ var _ = Describe("Matcher", func() { // this after expect*Phase for the phases the test actually wants to verify. allowOtherPhases := func() { allowIdentifierPhases() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). Return(model.MediaFiles{}, nil).Maybe() } - // allowTitlePhase is a convenience for fuzzy-match tests that only exercise the - // title+artist phase. It uses .Maybe() because the phase may short-circuit when no - // songs have an artist. - allowTitlePhase := func(artistTracks model.MediaFiles) { - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). - Return(artistTracks, nil).Maybe() + // allowTitlePhase wires title matching from a list of library tracks. Each track must carry + // Participants[RoleArtist] with the artist IDs that credit it; the helper derives the artist + // rows the artist resolution returns from those participants, then returns the tracks from + // the track-fetch query. + allowTitlePhase := func(tracks model.MediaFiles) { + // Artist resolution: build artist rows from the tracks' participants. + seen := map[string]model.Artist{} + for _, t := range tracks { + for _, p := range t.Participants[model.RoleArtist] { + if _, ok := seen[p.ID]; !ok { + seen[p.ID] = p.Artist + } + } + } + artists := make(model.Artists, 0, len(seen)) + for _, a := range seen { + artists = append(artists, a) + } + artistRepo.On("GetAll", mock.Anything).Return(artists, nil).Maybe() + // Track fetch (media_file_artists subquery). + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(tracks, nil).Maybe() } Describe("MatchSongs", func() { @@ -146,6 +167,7 @@ var _ = Describe("Matcher", func() { } titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } allowTitlePhase(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -161,6 +183,7 @@ var _ = Describe("Matcher", func() { } fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } allowTitlePhase(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -175,7 +198,9 @@ var _ = Describe("Matcher", func() { {Name: "Yesterday", Artist: "The Beatles"}, } differentTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + }, } allowTitlePhase(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -193,6 +218,7 @@ var _ = Describe("Matcher", func() { } libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -209,6 +235,7 @@ var _ = Describe("Matcher", func() { } libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -249,9 +276,15 @@ var _ = Describe("Matcher", func() { {Name: "Song C", Artist: "Artist"}, } tracks := model.MediaFiles{ - {ID: "a", Title: "Song A", Artist: "Artist"}, - {ID: "b", Title: "Song B", Artist: "Artist"}, - {ID: "c", Title: "Song C", Artist: "Artist"}, + {ID: "a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } allowTitlePhase(tracks) result, err := m.MatchSongs(ctx, songs, 2) @@ -269,15 +302,19 @@ var _ = Describe("Matcher", func() { }) Context("artist grouping", func() { - It("groups title-phase tracks by order_artist_name, not display Artist", func() { + It("groups title-phase tracks by participant artist ID, not display Artist", func() { songs := []agents.Song{ {Name: "Song A", Artist: "Daft Punk"}, } - // Display Artist differs from the query artist; only OrderArtistName - // matches, so grouping must key on it (a "feat." credit, collaboration, etc.). + // Display Artist differs from the query artist; only the participant + // with order_artist_name "daft punk" routes to this query bucket. track := model.MediaFile{ ID: "oan-track", Title: "Song A", - Artist: "Daft Punk feat. Pharrell", OrderArtistName: "daft punk", + Artist: "Daft Punk feat. Pharrell", + Participants: artistParticipants( + model.Artist{ID: "dp", Name: "Daft Punk", OrderArtistName: "daft punk"}, + model.Artist{ID: "ph", Name: "Pharrell", OrderArtistName: "pharrell"}, + ), } allowTitlePhase(model.MediaFiles{track}) @@ -286,9 +323,105 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("oan-track")) }) + + It("matches a track that credits the searched artist as a collaborator", func() { + songs := []agents.Song{ + {Name: "Crazy", Artist: "INXS"}, + } + // "Par-T-One vs. INXS" — display Artist is the collaboration, but INXS is a + // credited artist participant. Searching INXS must match it. + track := model.MediaFile{ + ID: "collab", Title: "Crazy", Artist: "Par-T-One vs. INXS", + Participants: artistParticipants( + model.Artist{ID: "a-partone", Name: "Par-T-One", OrderArtistName: "par-t-one"}, + model.Artist{ID: "a-inxs", Name: "INXS", OrderArtistName: "inxs"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("collab")) + }) + + It("does not match a track where the searched artist is only the album artist", func() { + songs := []agents.Song{ + {Name: "Qmart", Artist: "808 State"}, + } + // Track performed by Björk on an "808 State" compilation: 808 State is the + // albumartist, Björk is the performer. Searching 808 State must NOT match it. + track := model.MediaFile{ + ID: "comp", Title: "Qmart", Artist: "Björk", + Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-bjork", Name: "Björk", OrderArtistName: "bjork"}}, + }, + model.RoleAlbumArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, + }, + }, + } + // Artist resolution returns "808 state" only if some artist row matches; here the + // album-artist participant exists but is NOT role='artist', so the track-fetch query's + // EXISTS (role='artist') would not return the track in production. The mock + // returns it anyway; back-mapping must drop it because no role='artist' + // participant is a resolved artist for the query "808 state". + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("resolves the artist by ArtistMBID when the name differs", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Typo Artist", ArtistMBID: "mbid-9"}, + } + track := model.MediaFile{ + ID: "by-mbid", Title: "Song A", Artist: "Correct Artist", + Participants: artistParticipants(model.Artist{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}), + } + // Artist resolution returns the artist matched by mbz_artist_id; its order name + // ("correct artist") differs from the query name ("typo artist"), so + // resolution must come from the MBID branch. + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-mbid")) + }) + + It("resolves both queries when two share one ArtistMBID under different names", func() { + // Two agent results for the same MusicBrainz artist but spelled differently + // (an alias). Both must match the artist's track via the shared MBID. + songs := []agents.Song{ + {Name: "Song A", Artist: "Alias One", ArtistMBID: "mbid-shared"}, + {Name: "Song B", Artist: "Alias Two", ArtistMBID: "mbid-shared"}, + } + artist := model.Artist{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"} + trackA := model.MediaFile{ID: "ta", Title: "Song A", Artist: "Canonical", Participants: artistParticipants(artist)} + trackB := model.MediaFile{ID: "tb", Title: "Song B", Artist: "Canonical", Participants: artistParticipants(artist)} + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{trackA, trackB}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect([]string{result[0].ID, result[1].ID}).To(ConsistOf("ta", "tb")) + }) }) - // These tests register their own order_artist_name expectation per-test (to inject + // These tests register their own track-fetch expectations per-test (to inject // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would // add a .Maybe() title-phase catch-all that masks the injected error. Context("title phase DB errors", func() { @@ -298,7 +431,11 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist Two"}, } allowIdentifierPhases() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "a1", Name: "Artist One", OrderArtistName: "artist one"}, + {ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). Return(nil, errors.New("db down")) _, err := m.MatchSongs(ctx, songs, 5) @@ -317,7 +454,10 @@ var _ = Describe("Matcher", func() { Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). Return(model.MediaFiles{}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "fa", Name: "Fuzzy Artist", OrderArtistName: "fuzzy artist"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). Return(nil, errors.New("db down")) result, err := m.MatchSongs(ctx, songs, 5) @@ -384,10 +524,12 @@ var _ = Describe("Matcher", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } songs := []agents.Song{ {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, @@ -405,9 +547,11 @@ var _ = Describe("Matcher", func() { It("matches by title + artist name + album name when MBIDs unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, @@ -425,9 +569,11 @@ var _ = Describe("Matcher", func() { It("matches by title + artist only when album info unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ {Name: "Similar Song", Artist: "Depeche Mode"}, @@ -456,9 +602,15 @@ var _ = Describe("Matcher", func() { }) It("returns distinct matches for each artist's version (covers scenario)", func() { - cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} - cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} - cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + } + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "ray-charles", Name: "Ray Charles", OrderArtistName: "ray charles"}), + } + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", + Participants: artistParticipants(model.Artist{ID: "sinatra", Name: "Frank Sinatra", OrderArtistName: "frank sinatra"}), + } songs := []agents.Song{ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, @@ -480,13 +632,16 @@ var _ = Describe("Matcher", func() { preciseMatch := model.MediaFile{ ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } lessAccurateMatch := model.MediaFile{ ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", + MbzArtistID: "mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } artistTwoMatch := model.MediaFile{ ID: "artist-two", Title: "Song B", Artist: "Artist Two", + Participants: artistParticipants(model.Artist{ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}), } songs := []agents.Song{ @@ -503,6 +658,31 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("precise")) Expect(result[1].ID).To(Equal("artist-two")) }) + + It("uses the resolved artist MBID for specificity (level 5)", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + } + // Two tracks with the same title and album; only the one whose resolved artist + // carries mbid-1 (and whose album MBID matches) wins via Level 5. Without the + // resolved MBID, both tracks tie at Level 3 (name+album) and the first wins by + // chance — verifiable by RED-proof: see task-2-report.md. + precise := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), + } + other := model.MediaFile{ + ID: "other", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "wrong-album-mbid", + Participants: artistParticipants(model.Artist{ID: "a1b", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: ""}), + } + // Artist resolution returns both a1 (by name+mbid) and a1b (by name). + allowTitlePhase(model.MediaFiles{other, precise}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("precise")) + }) }) Describe("fuzzy matching thresholds", func() { @@ -514,7 +694,9 @@ var _ = Describe("Matcher", func() { {Name: "Paranoid Android", Artist: "Radiohead"}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } allowTitlePhase(artistTracks) @@ -533,7 +715,9 @@ var _ = Describe("Matcher", func() { {Name: "Bohemian Rhapsody", Artist: "Queen"}, } artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), + }, } allowTitlePhase(artistTracks) @@ -554,7 +738,9 @@ var _ = Describe("Matcher", func() { {Name: "Paranoid Android", Artist: "Radiohead"}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } allowTitlePhase(artistTracks) @@ -574,7 +760,9 @@ var _ = Describe("Matcher", func() { {Name: "Song", Artist: "Artist"}, } artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } allowTitlePhase(artistTracks) @@ -600,9 +788,11 @@ var _ = Describe("Matcher", func() { } correctMatch := model.MediaFile{ ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) @@ -620,9 +810,11 @@ var _ = Describe("Matcher", func() { } correctMatch := model.MediaFile{ ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) @@ -640,9 +832,11 @@ var _ = Describe("Matcher", func() { } exactMatch := model.MediaFile{ ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } fuzzyMatch := model.MediaFile{ ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) @@ -661,9 +855,12 @@ var _ = Describe("Matcher", func() { } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } starredTrack := model.MediaFile{ - ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) @@ -682,9 +879,12 @@ var _ = Describe("Matcher", func() { } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } ratedTrack := model.MediaFile{ - ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Rating: 4}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) @@ -708,9 +908,11 @@ var _ = Describe("Matcher", func() { } correctMatch := model.MediaFile{ ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } wrongDuration := model.MediaFile{ ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) @@ -728,6 +930,7 @@ var _ = Describe("Matcher", func() { } closeDuration := model.MediaFile{ ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{closeDuration}) @@ -745,9 +948,11 @@ var _ = Describe("Matcher", func() { } closeDuration := model.MediaFile{ ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } farDuration := model.MediaFile{ ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) @@ -765,6 +970,7 @@ var _ = Describe("Matcher", func() { } differentDuration := model.MediaFile{ ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{differentDuration}) @@ -782,9 +988,11 @@ var _ = Describe("Matcher", func() { } differentTitle := model.MediaFile{ ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } correctTitle := model.MediaFile{ ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) @@ -802,6 +1010,7 @@ var _ = Describe("Matcher", func() { } anyTrack := model.MediaFile{ ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{anyTrack}) @@ -819,6 +1028,7 @@ var _ = Describe("Matcher", func() { } shortTrack := model.MediaFile{ ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } allowTitlePhase(model.MediaFiles{shortTrack}) @@ -837,9 +1047,11 @@ var _ = Describe("Matcher", func() { } shortTrack := model.MediaFile{ ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), } longTrack := model.MediaFile{ ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), } allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) @@ -867,6 +1079,7 @@ var _ = Describe("Matcher", func() { } libraryTrack := model.MediaFile{ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), } allowTitlePhase(model.MediaFiles{libraryTrack}) @@ -885,9 +1098,15 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist"}, {Name: "Song C", Artist: "Artist"}, } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) @@ -907,8 +1126,12 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist"}, {Name: "Song B (Remix)", Artist: "Artist"}, } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } allowTitlePhase(model.MediaFiles{trackA, trackB}) @@ -953,6 +1176,27 @@ func (m *mockMediaFileRepo) SetError(hasError bool) { } } +type mockArtistRepo struct { + mock.Mock + model.ArtistRepository +} + +func newMockArtistRepo() *mockArtistRepo { + return &mockArtistRepo{} +} + +func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.Artists), args.Error(1) +} + // matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a // squirrel.And whose first element is a squirrel.Eq containing the given field name. func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { @@ -982,3 +1226,30 @@ func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { return hasField } } + +// artistParticipants builds a Participants map crediting the given artists under RoleArtist. +func artistParticipants(artists ...model.Artist) model.Participants { + list := make(model.ParticipantList, len(artists)) + for i, a := range artists { + list[i] = model.Participant{Artist: a} + } + return model.Participants{model.RoleArtist: list} +} + +// matchTracksByArtistQuery matches the title phase's track-fetch query, identified by its +// squirrel.And containing a squirrel.Expr whose SQL references media_file_artists. +func matchTracksByArtistQuery() func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok { + return false + } + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } + } + return false + } +} From 21a016742eea9f31842107da893e901015e5ca1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:19:13 -0400 Subject: [PATCH 305/596] chore(deps): bump actions/checkout from 6 to 7 in /.github/workflows (#5648) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yml | 22 +++++++++++----------- .github/workflows/push-translations.yml | 2 +- .github/workflows/update-translations.yml | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f858a5a7..8d3cd3ad0 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -24,7 +24,7 @@ jobs: git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true @@ -62,7 +62,7 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -101,7 +101,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -127,7 +127,7 @@ jobs: FFMPEG_VERSION: "7.1" FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -199,7 +199,7 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: 24 @@ -230,7 +230,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - run: | set -e for file in resources/i18n/*.json; do @@ -276,7 +276,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -350,7 +350,7 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download digests uses: actions/download-artifact@v8 @@ -384,7 +384,7 @@ jobs: if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' continue-on-error: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download digests uses: actions/download-artifact@v8 @@ -437,7 +437,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: @@ -471,7 +471,7 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml index f7cf00621..546c64114 100644 --- a/.github/workflows/push-translations.yml +++ b/.github/workflows/push-translations.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 2 diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 8fe0b5379..5db32f3fa 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Get updated translations id: poeditor env: From b38054b29cede010080db904683554515b221720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 22 Jun 2026 16:33:01 -0400 Subject: [PATCH 306/596] perf(artwork): faster image resize + update gen2brain/webp to v0.6.0 (#5652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(artwork): convert decoded images to a fast-path type before resizing x/image/draw's CatmullRom scaler only has optimized paths for *image.RGBA, *image.NRGBA, *image.Gray and *image.YCbCr. Other concrete types — notably *image.NYCbCrA (from WebP) and *image.Paletted (indexed PNGs) — fall back to a generic per-pixel At()/RGBA() loop that is several times slower. Convert such images to *image.RGBA once before scaling; fast-path types are returned unchanged. This makes resize performance independent of which decoder wins the image.Decode("webp") registration, and also speeds up indexed PNGs. Signed-off-by: Deluan <deluan@navidrome.org> * chore(deps): update gen2brain/webp to v0.6.0 v0.6.0 replaces the wazero WASM runtime with a self-contained wasm2go-transpiled WebP decoder/encoder. This drops the webp -> wazero dependency edge (wazero is still used by the plugin system) and makes the WASM-only build path (32-bit / nodynamic) faster and far lighter on allocations. Signed-off-by: Deluan <deluan@navidrome.org> * perf(artwork): defer fast-path conversion until a resize is needed Move toFastScaleType to just before the CatmullRom.Scale call, after the no-upscale early return. Previously the conversion ran right after decode, so a request for a size >= the source dimensions would allocate and walk a full RGBA copy only to discard it when resizeStaticImage returns nil. The resize path is unchanged; the no-op path drops ~30-40% time and up to ~79% memory for large indexed/WebP artwork. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/artwork/reader_resized.go | 17 +++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 08f42f130..cd16cbada 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -132,6 +132,22 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return resizeStaticImage(data, a.size, a.square) } +// toFastScaleType converts images whose concrete type has no optimized scaler +// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed +// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale +// falls back to a generic per-pixel At()/RGBA() loop that is several times +// slower. Fast-path types are returned unchanged. +func toFastScaleType(img image.Image) image.Image { + switch img.(type) { + case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: + return img + default: + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba + } +} + func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { original, format, err := image.Decode(bytes.NewReader(data)) if err != nil { @@ -169,6 +185,7 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dstRect = dst.Bounds() } + original = toFastScaleType(original) xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) buf := bufPool.Get().(*bytes.Buffer) diff --git a/go.mod b/go.mod index abb3e89f0..9bf7358ed 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.5.5 + github.com/gen2brain/webp v0.6.0 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 diff --git a/go.sum b/go.sum index fd254bebb..682a9cfd0 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= -github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= +github.com/gen2brain/webp v0.6.0 h1:VN/cmeDv78sAKbJayyB1YVm9SHlz5qXO06bo4v6V3hY= +github.com/gen2brain/webp v0.6.0/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= From 9bd3400d0ecd6d68501eb60d027f575e1ab347cf Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 22 Jun 2026 16:35:09 -0400 Subject: [PATCH 307/596] chore(deps): update ttlcache and sqlite3 dependencies to latest versions --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 9bf7358ed..91da9e52f 100644 --- a/go.mod +++ b/go.mod @@ -32,16 +32,16 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 - github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/jellydator/ttlcache/v3 v3.4.1 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.46 + github.com/mattn/go-sqlite3 v1.14.47 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.31.0 + github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.0 - github.com/pelletier/go-toml/v2 v2.4.0 + github.com/pelletier/go-toml/v2 v2.4.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 diff --git a/go.sum b/go.sum index 682a9cfd0..1c0dcc9fe 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QS github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -174,8 +174,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU= -github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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= @@ -192,12 +192,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.31.0 h1:GtuJos5DFUV9EerYJo8RhYxosYNGvOdDE5haKq6Grfs= -github.com/onsi/ginkgo/v2 v2.31.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row= -github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.1 h1:j5OMOImsH+j2k7GJ5YO+RxfWwohNiH6t5zB/+h3bagc= +github.com/pelletier/go-toml/v2 v2.4.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= From 06993a8e049056bc57f53e8cb81df14cbc7964c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 22 Jun 2026 21:33:14 -0400 Subject: [PATCH 308/596] test(storage): re-enable local storage tests on Windows (#5654) * refactor(storage): extract LocalPathToURL from storage.For * test(storage): re-enable local storage tests on Windows (#5381) * test(storage): fix Windows drive-letter path expectation The 'should handle Windows drive letters correctly' test was gated behind the now-removed SkipOnWindows BeforeEach, so its expectation had never run. On Windows, newLocalStorage re-joins u.Host+u.Path via filepath.Join, which yields a backslash path (C:\music), not C:/music. Assert against filepath.Join so the expectation matches the OS-native result. * test(storage): probe Windows drive-letter path in LocalPathToURL Three review bots flagged that LocalPathToURL escapes the drive-letter colon (C: -> C%3A), which url.Parse rejects. There are no Windows bug reports, so add a Windows-gated test that exercises the real conversion on a drive-letter path and let CI decide whether the bug is real before changing production code. --- core/storage/local/local_test.go | 59 ++++++++++++++------------------ core/storage/storage.go | 47 +++++++++++++++++-------- core/storage/storage_test.go | 38 ++++++++++++++++++++ 3 files changed, 97 insertions(+), 47 deletions(-) diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index aef89cdd5..d65d8214a 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -45,16 +44,13 @@ var _ = Describe("LocalStorage", func() { }) Describe("newLocalStorage", func() { - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") - }) Context("with valid path", func() { It("should create a localStorage instance with correct path", func() { - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage := storage.(*localStorage) Expect(localStorage.u.Scheme).To(Equal("file")) @@ -94,10 +90,10 @@ var _ = Describe("LocalStorage", func() { err = os.Symlink(realDir, linkDir) Expect(err).ToNot(HaveOccurred()) - u, err := url.Parse("file://" + linkDir) + u, err := storage.LocalPathToURL(linkDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -110,10 +106,10 @@ var _ = Describe("LocalStorage", func() { // Use a non-existent path to trigger symlink resolution failure nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := url.Parse("file://" + nonExistentPath) + u, err := storage.LocalPathToURL(nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -137,7 +133,9 @@ var _ = Describe("LocalStorage", func() { localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) - Expect(localStorage.u.Path).To(Equal("C:/music")) + // newLocalStorage re-joins the drive letter (u.Host) with u.Path via + // filepath.Join, which yields an OS-native (backslash) path on Windows. + Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music"))) }) }) @@ -159,10 +157,10 @@ var _ = Describe("LocalStorage", func() { It("falls back to the default extractor instead of crashing", func() { conf.Server.Scanner.Extractor = "nonexistent-extractor" - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) ls, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor)) @@ -171,16 +169,13 @@ var _ = Describe("LocalStorage", func() { }) Describe("localStorage.FS", func() { - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") - }) Context("with existing directory", func() { It("should return a localFS instance", func() { - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) Expect(musicFS).ToNot(BeNil()) @@ -193,10 +188,10 @@ var _ = Describe("LocalStorage", func() { Context("with non-existent directory", func() { It("should return an error", func() { nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := url.Parse("file://" + nonExistentPath) + u, err := storage.LocalPathToURL(nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) _, err = storage.FS() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(nonExistentPath)) @@ -208,7 +203,6 @@ var _ = Describe("LocalStorage", func() { var testFile string BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // Create a test file testFile = filepath.Join(tempDir, "test.mp3") err := os.WriteFile(testFile, []byte("test data"), 0600) @@ -235,9 +229,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = expectedInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -259,9 +253,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = incompleteInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -288,9 +282,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["non-existent.mp3"] = incompleteInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -303,9 +297,9 @@ var _ = Describe("LocalStorage", func() { It("should return the extractor error", func() { testExtractor.err = &extractorError{message: "extractor failed"} - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -334,9 +328,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = info1 testExtractor.results["test2.mp3"] = info2 - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -390,9 +384,8 @@ var _ = Describe("LocalStorage", func() { Describe("Storage registration", func() { It("should register localStorage for file scheme", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // This tests the init() function indirectly - storage, err := storage.For("file://" + tempDir) + storage, err := storage.For(tempDir) Expect(err).ToNot(HaveOccurred()) Expect(storage).To(BeAssignableToTypeOf(&localStorage{})) }) diff --git a/core/storage/storage.go b/core/storage/storage.go index b9fceb1fd..242965c99 100644 --- a/core/storage/storage.go +++ b/core/storage/storage.go @@ -25,6 +25,27 @@ func Register(schema string, c constructor) { registry[schema] = c } +// LocalPathToURL converts a bare OS filesystem path into an absolute file:// URL, +// applying the same slash-normalisation and per-component escaping the scanner +// relies on. It is the single source of truth for how a local path becomes a +// storage URL, shared by For and by storage tests. +func LocalPathToURL(osPath string) (url.URL, error) { + abs, _ := filepath.Abs(osPath) + abs = filepath.ToSlash(abs) + + // Properly escape each path component using URL standards + pathParts := strings.Split(abs, "/") + escapedParts := slice.Map(pathParts, func(s string) string { + return url.PathEscape(s) + }) + + u, err := url.Parse(LocalSchemaID + "://" + strings.Join(escapedParts, "/")) + if err != nil { + return url.URL{}, err + } + return *u, nil +} + // For returns a Storage implementation for the given URI. // It uses the schema part of the URI to find the correct registered // Storage constructor. @@ -34,24 +55,22 @@ func For(uri string) (Storage, error) { defer lock.RUnlock() parts := strings.Split(uri, "://") + var u *url.URL // Paths without schema are treated as file:// and use the default LocalStorage implementation if len(parts) < 2 { - uri, _ = filepath.Abs(uri) - uri = filepath.ToSlash(uri) - - // Properly escape each path component using URL standards - pathParts := strings.Split(uri, "/") - escapedParts := slice.Map(pathParts, func(s string) string { - return url.PathEscape(s) - }) - - uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/") + parsed, err := LocalPathToURL(uri) + if err != nil { + return nil, err + } + u = &parsed + } else { + parsed, err := url.Parse(uri) + if err != nil { + return nil, err + } + u = parsed } - u, err := url.Parse(uri) - if err != nil { - return nil, err - } c, ok := registry[u.Scheme] if !ok { return nil, errors.New("schema '" + u.Scheme + "' not registered") diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 32fbac413..336b5a7a9 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -4,6 +4,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "testing" "github.com/navidrome/navidrome/tests" @@ -83,6 +84,43 @@ var _ = Describe("Storage", func() { Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"), ) }) + + Describe("LocalPathToURL", func() { + It("builds a file:// URL from an absolute path", func() { + u, err := LocalPathToURL("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + Expect(u.Scheme).To(Equal("file")) + Expect(u.Path).To(Equal("/tmp/music")) + }) + + It("escapes special characters and decodes them back in Path", func() { + u, err := LocalPathToURL("/tmp/Song #1 & More?.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(u.Path).To(Equal("/tmp/Song #1 & More?.mp3")) + }) + + It("produces the same url.URL that For uses for a bare path", func() { + registry = map[string]constructor{} + Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} }) + s, err := For("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + direct, err := LocalPathToURL("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + Expect(s.(*fakeLocalStorage).u).To(Equal(direct)) + }) + + // On Windows, library paths are drive-lettered (e.g. C:\Music). This + // exercises the real conversion to confirm a drive-letter path yields a + // usable file:// URL on the actual platform (no-op on Unix). + It("handles a Windows drive-letter path", func() { + if runtime.GOOS != "windows" { + Skip("Windows-specific path handling") + } + u, err := LocalPathToURL(`C:\Music`) + Expect(err).ToNot(HaveOccurred()) + Expect(u.Scheme).To(Equal("file")) + }) + }) }) type fakeLocalStorage struct { From fa138afea576e14c13e7c8150d195fddce15a954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 23 Jun 2026 18:53:51 -0400 Subject: [PATCH 309/596] fix(playlist/share): apply user library access to import and sharing paths (#5640) * fix(playlist): respect the user's library access when resolving M3U paths FindByPaths looked up paths across all libraries, so importing an M3U could add tracks from libraries the importing user has no access to. Apply the user's library filter to the lookup, matching every other media_file read. Admins and the (admin-context) scanner are unaffected. * fix(share): scope shared playlist tracks to the owner's libraries loadMedia loaded playlist tracks with a fake-admin context, so a shared playlist could include tracks from libraries the owner has no access to. Load as the share owner instead, so the library filter applies. Admin-owned shares are unchanged. * fix(share): only serve shared tracks the owner can access A shared stream fetched the media file by id without checking the share owner's library access, so it could serve tracks from libraries the owner has no access to. Gate share-scoped streams on the owner's library access. Non-share streams are unaffected. * test(share): tidy library-access test setup Consolidate the repeated share-owner test fixture in handleStream into a helper, assert on track fields with HaveField instead of building an id slice, and delete the scratch media file through the public repository method. * style: trim verbose comments in library-access checks * fix(share): guard against nil owner and clean up test users Add a nil check after loading the share owner so a missing user yields a clear error instead of a possible nil dereference, and delete the users created by the new tests in their AfterEach blocks. * fix(share): avoid panic when a shared playlist is no longer visible to its owner Tracks() returns nil when the playlist can't be loaded under the owner's context (e.g. a public playlist shared by a non-owner that was later made private). Capture the result and return early instead of chaining GetAll on a nil repository, leaving the share with no tracks. --- persistence/mediafile_repository.go | 2 +- persistence/mediafile_repository_test.go | 52 +++++++++++++ persistence/share_repository.go | 24 ++++-- persistence/share_repository_test.go | 96 ++++++++++++++++++++++++ server/public/handle_streams.go | 13 ++++ server/public/handle_streams_test.go | 42 ++++++++++- 6 files changed, 220 insertions(+), 9 deletions(-) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index dd8145eae..740ca4956 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -306,7 +306,7 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err return model.MediaFiles{}, nil } - sel := r.newSelect().Columns("*").Where(query) + sel := r.applyLibraryFilter(r.newSelect().Columns("*").Where(query)) var res dbMediaFiles if err := r.queryAll(sel, &res); err != nil { return nil, err diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 7989dc25a..d99bb65a7 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -880,6 +880,58 @@ var _ = Describe("MediaRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) + + Context("when the user has restricted library access", func() { + var otherLib model.Library + var restrictedUser model.User + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), adminUser) + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + + // A second library the restricted user has no access to + otherLib = model.Library{ID: 0, Name: "Other Library", Path: "/other/lib"} + Expect(lr.Put(&otherLib)).To(Succeed()) + + // A track that lives only in the other library (created as admin) + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.Put(&model.MediaFile{ + ID: "otherlib-track", LibraryID: otherLib.ID, + Path: "hidden/test.mp3", Title: "Hidden", + })).To(Succeed()) + + // Non-admin user with access to library 1 ONLY + restrictedUser = createUserWithLibraries("restricted-finder", []int{1}) + ur := NewUserRepository(adminCtx, GetDBXBuilder()) + Expect(ur.Put(&restrictedUser)).To(Succeed()) + Expect(ur.SetUserLibraries(restrictedUser.ID, []int{1})).To(Succeed()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), adminUser) + _ = NewMediaFileRepository(adminCtx, GetDBXBuilder()).Delete("otherlib-track") + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, GetDBXBuilder()).Delete(restrictedUser.ID) + }) + + It("does not resolve paths in libraries the user cannot access", func() { + userMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), restrictedUser), GetDBXBuilder()) + qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) + results, err := userMr.FindByPaths([]string{qualified}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty(), "a track outside the user's libraries must not be resolvable") + }) + + It("still resolves the path for an admin", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) + results, err := adminMr.FindByPaths([]string{qualified}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("otherlib-track")) + }) + }) }) Describe("wrapMediaFileCursor", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 89dc19e19..0013e782b 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -100,16 +100,28 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - // Create a context with a fake admin user, to be able to access all playlists - ctx := request.WithUser(r.ctx, model.User{IsAdmin: true}) + // Load tracks as the share owner so their library access is applied. + owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + if err != nil { + return fmt.Errorf("loading share owner %q: %w", share.UserID, err) + } + if owner == nil { + return fmt.Errorf("share owner %q not found", share.UserID) + } + ctx := request.WithUser(r.ctx, *owner) plsRepo := NewPlaylistRepository(ctx, r.db) - tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + // Tracks returns nil when the playlist is no longer visible to the owner + // (e.g. it was made private after the share was created); leave the share + // with no tracks rather than exposing it. + trackRepo := plsRepo.Tracks(ids[0], true) + if trackRepo == nil { + return nil + } + tracks, err := trackRepo.GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) if err != nil { return err } - if len(tracks) >= 0 { - share.Tracks = tracks.MediaFiles() - } + share.Tracks = tracks.MediaFiles() return nil case "media_file": mfRepo := NewMediaFileRepository(r.ctx, r.db) diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 0b3ece598..3ae456031 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -132,6 +133,101 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Playlist share library scoping", func() { + var otherLib model.Library + var owner model.User + var plsID string + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + + // A second library the owner has no access to, plus a track in it + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"} + Expect(lr.Put(&otherLib)).To(Succeed()) + mr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(mr.Put(&model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed()) + + // Non-admin owner with access to library 1 only + owner = createUserWithLibraries("share-owner", []int{1}) + ur := NewUserRepository(adminCtx, GetDBXBuilder()) + Expect(ur.Put(&owner)).To(Succeed()) + Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + + // Owner-owned playlist containing tracks from both libraries + plsID = "share-scope-pls" + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), owner) + pr := NewPlaylistRepository(ownerCtx, GetDBXBuilder()) + pls := &model.Playlist{ID: plsID, Name: "Scope Test", OwnerID: owner.ID} + pls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}, {ID: "share-other"}}) + Expect(pr.Put(pls)).To(Succeed()) + + // Share row owned by the non-admin owner + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "share-scope", "user": owner.ID, "desc": "Scope test share", + "type": "playlist", "ids": plsID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'share-scope'`).Execute() + pr := NewPlaylistRepository(adminCtx, b) + _ = pr.Delete(plsID) + mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) + _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) + lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + }) + + It("excludes tracks the owner cannot access from the shared playlist", func() { + // Read the share as admin (mimics the public-share render path, which uses + // the share repository's own context). loadMedia must scope to the owner. + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + share, err := adminRepo.Get("share-scope") + Expect(err).ToNot(HaveOccurred()) + + Expect(share.Tracks).To(ContainElement(HaveField("ID", "share-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "share-other")), + "a track outside the owner's libraries must not appear in the share") + }) + + It("returns no tracks when the playlist is not visible to the owner", func() { + // A private playlist owned by someone else: the share owner can no longer + // see it, so Tracks() returns nil. The share must render with no tracks + // instead of panicking. + privatePlsID := "private-pls" + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + pr := NewPlaylistRepository(adminCtx, GetDBXBuilder()) + privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false} + privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}}) + Expect(pr.Put(privatePls)).To(Succeed()) + DeferCleanup(func() { _ = pr.Delete(privatePlsID) }) + + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "share-private", "user": owner.ID, "desc": "Private share", + "type": "playlist", "ids": privatePlsID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery(`DELETE FROM share WHERE id = 'share-private'`).Execute() }) + + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + share, err := adminRepo.Get("share-private") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Tracks).To(BeEmpty()) + }) + }) + Describe("Ownership Checks", func() { var ownerUser = model.User{ID: "2222", UserName: "regular-user"} var otherUser = model.User{ID: "3333", UserName: "third-user"} diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index c7b8a4d4f..8fc407e9e 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -25,6 +25,7 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + var shareOwner *model.User if info.shareID != "" { share, err := pub.ds.Share(ctx).Get(info.shareID) if err != nil { @@ -35,6 +36,12 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { checkShareError(ctx, w, model.ErrExpired, info.shareID) return } + shareOwner, err = pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -48,6 +55,12 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + // 404 rather than 403 so the response doesn't reveal whether the id exists. + if shareOwner != nil && !shareOwner.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "not found", http.StatusNotFound) + return + } + stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ Format: info.format, BitRate: info.bitrate, }) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index f43d75a26..6fa083045 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -131,11 +131,22 @@ var _ = Describe("handleStream", func() { return w } - It("passes all validation and reaches the streamer for a valid token", func() { + shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + mfRepo.SetData(model.MediaFiles{mf}) ds.MockedMediaFile = mfRepo + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, + model.MediaFile{ID: "mf-123", Title: "Test Song"}, + ) claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) @@ -146,6 +157,33 @@ var _ = Describe("handleStream", func() { Expect(streamer.req.BitRate).To(Equal(192)) }) + It("returns 404 when the track is outside the share owner's libraries", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, + model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2}, + ) + + claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + + It("streams a track inside the share owner's libraries", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, + model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1}, + ) + + claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + }) + It("returns 400 for an expired token", func() { claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) From e6560ccb4061bdbbddf65885165d930ec34b9ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 23 Jun 2026 22:23:54 -0400 Subject: [PATCH 310/596] fix(cache): don't serve partially-written transcodes after a crash (#5657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cache): add completion marker helpers to spreadFS * feat(cache): write completion marker after successful cache write * fix(cache): adopt only complete files on reload, grandfather existing caches * test(cache): regression tests for partial-transcode crash leftover (#5636) * test(cache): guard concurrent in-progress streaming with completion marker * test(cache): make concurrent-streaming guard actually attach a second reader mid-write The previous test obtained s2 only after pw.Close(), so no reader ever attached to the in-progress entry. Now pw.Write("hello ") is called synchronously before the second Get — io.Pipe's blocking write gives a deterministic happens-before — then both s1 and s2 are drained in parallel goroutines while the producer writes the rest and closes the pipe. * style(cache): clarify best-effort intent of cleanup os.Remove calls * refactor(cache): lift one-time grandfather pass out of Reload's steady-state loop * refactor(cache): have MarkComplete take the key, owning path mapping in spreadFS * test(cache): assert no completion marker is written when the write fails * refactor(cache): rename migration sentinel to generic .nd-migrated * refactor(cache): rename grandfather migration to migrateExistingFiles * refactor(cache): single-pass Reload with safer marker-error handling Address PR review feedback: - Merge the one-time migration into Reload's single directory walk, avoiding a second full walk on first boot. - Only delete a data file when its marker is definitively absent (os.IsNotExist); skip on other stat errors to avoid destroying valid entries under transient I/O failures. - Write the migration sentinel only after a clean walk, so a partial walk can't strand valid-but-unmarked files for later deletion. - Return early from walkDataFiles on a WalkDir error. - Assert fs.Create error in the marker-removal test. --- utils/cache/file_caches.go | 26 ++++-- utils/cache/file_caches_test.go | 160 ++++++++++++++++++++++++++++++++ utils/cache/spread_fs.go | 90 ++++++++++++++++-- utils/cache/spread_fs_test.go | 96 +++++++++++++++---- 4 files changed, 342 insertions(+), 30 deletions(-) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 9788926d5..ed2374696 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -85,10 +85,11 @@ func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader R go func() { start := time.Now() - cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) + cache, sfs, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) fc.mutex.Lock() defer fc.mutex.Unlock() fc.cache = cache + fc.fs = sfs fc.disabled = cache == nil || err != nil log.Info("Finished initializing cache", "cache", fc.name, "maxSize", fc.cacheSize, "elapsedTime", time.Since(start)) fc.ready.Store(true) @@ -109,6 +110,7 @@ type fileCache struct { cacheFolder string maxItems int cache fscache.Cache + fs *spreadFS getReader ReadFunc disabled bool ready atomic.Bool @@ -177,6 +179,7 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { _ = fc.invalidate(ctx, key) } else { log.Trace(ctx, "File successfully stored in cache", "cache", fc.name, "key", key) + fc.markComplete(ctx, key) } }() } @@ -248,7 +251,18 @@ func copyAndClose(w io.WriteCloser, r io.Reader) error { return err } -func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) { +// markComplete records on disk that the entry for key was written in full, +// so it is eligible for adoption after a restart (see spreadFS.Reload). +func (fc *fileCache) markComplete(ctx context.Context, key string) { + if fc.fs == nil { + return + } + if err := fc.fs.MarkComplete(key); err != nil { + log.Warn(ctx, "Error writing cache completion marker", "cache", fc.name, "key", key, err) + } +} + +func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, *spreadFS, error) { size, err := humanize.ParseBytes(cacheSize) if err != nil { log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize, @@ -257,7 +271,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach } if size == 0 { log.Warn(fmt.Sprintf("%s cache disabled", name)) - return nil, nil + return nil, nil, nil } lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) @@ -269,15 +283,15 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach fs, err = NewSpreadFS(cacheFolder, 0755) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache FS", name), err) - return nil, err + return nil, nil, err } ck, err := fscache.NewCacheWithHaunter(fs, h) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache", name), err) - return nil, err + return nil, nil, err } ck.SetKeyMapper(fs.KeyMapper) - return ck, nil + return ck, fs, nil } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 9a9a9444f..edcfbc6b9 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -104,6 +104,75 @@ var _ = Describe("File Caches", func() { Expect(called).To(BeTrue()) }) + It("writes a completion marker after a successful cache write", func() { + fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return strings.NewReader("complete-data"), nil + }) + s, err := fc.Get(context.Background(), &testArg{"markme"}) + Expect(err).To(BeNil()) + _, _ = io.ReadAll(s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) + Eventually(func() bool { + _, statErr := os.Stat(dataPath + ".complete") + return statErr == nil + }).Should(BeTrue()) + }) + + It("serves a concurrent reader from an in-progress write and marks complete once", func() { + pr, pw := io.Pipe() + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return pr, nil // slow, still-being-produced stream + }) + + // First Get → MISS; the cache starts copying pr into the entry in a goroutine. + s1, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + + // Write the first chunk so the entry exists with in-flight bytes, + // but leave the pipe open so the second reader can attach mid-stream. + // io.Pipe writes block until the cache goroutine reads them, giving us + // a deterministic happens-before: the entry is live before we call Get again. + _, err = pw.Write([]byte("hello ")) + Expect(err).To(BeNil()) + + // Second Get while the pipe is still open → attaches to the in-progress entry. + s2, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + + // Drain both readers concurrently; they race against the producer below. + ch1 := make(chan []byte, 1) + ch2 := make(chan []byte, 1) + go func() { b, _ := io.ReadAll(s1); ch1 <- b }() + go func() { b, _ := io.ReadAll(s2); ch2 <- b }() + + // Deliver the rest of the stream and close; both draining goroutines must see it. + _, err = pw.Write([]byte("world")) + Expect(err).To(BeNil()) + Expect(pw.Close()).To(Succeed()) + + Expect(string(<-ch1)).To(Equal("hello world")) + Expect(string(<-ch2)).To(Equal("hello world")) + _ = s1.Close() + _ = s2.Close() + + // Exactly one completion marker must appear. + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"live"}).Key()) + Eventually(func() bool { + _, e := os.Stat(dataPath + ".complete") + return e == nil + }).Should(BeTrue()) + + // Steady-state HIT: full data, Cached flag set. + s3, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + got3, _ := io.ReadAll(s3) + _ = s3.Close() + Expect(s3.Cached).To(BeTrue()) + Expect(string(got3)).To(Equal("hello world")) + }) + Context("reader errors", func() { When("creating a reader fails", func() { It("does not cache", func() { @@ -138,6 +207,77 @@ var _ = Describe("File Caches", func() { }) }) }) + + Context("crash leftover (issue #5636)", func() { + It("does not serve a partial file left on disk as a complete HIT", func() { + // First init: empties + writes the migration sentinel. + fc1 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return strings.NewReader("UNUSED"), nil + }) + _ = fc1 + + // Plant a partial file (no marker), simulating a killed process. + sfs, err := NewSpreadFS(filepath.Join(conf.Server.CacheFolder.String(), "test"), 0755) + Expect(err).To(BeNil()) + partialPath := sfs.KeyMapper((&testArg{"track"}).Key()) + Expect(os.MkdirAll(filepath.Dir(partialPath), 0755)).To(Succeed()) + Expect(os.WriteFile(partialPath, []byte("PARTIAL"), 0600)).To(Succeed()) + + // "Restart": a fresh cache over the same folder (sentinel present → strict). + getReaderCalled := false + fc2 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + getReaderCalled = true + return strings.NewReader("FULL-TRANSCODE"), nil + }) + + s, err := fc2.Get(context.Background(), &testArg{"track"}) + Expect(err).To(BeNil()) + data, _ := io.ReadAll(s) + _ = s.Close() + + Expect(getReaderCalled).To(BeTrue()) // re-transcoded, not served stale + Expect(string(data)).To(Equal("FULL-TRANSCODE")) + }) + }) + + Context("live error path still invalidates", func() { + It("leaves no data file and no marker after a mid-stream reader error", func() { + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return errFakeReader{errors.New("boom")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"err"}) + Expect(err).To(BeNil()) + _, _ = io.Copy(io.Discard, s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"err"}).Key()) + Eventually(func() bool { + _, e1 := os.Stat(dataPath) + _, e2 := os.Stat(dataPath + ".complete") + return os.IsNotExist(e1) && os.IsNotExist(e2) + }).Should(BeTrue()) + }) + + It("does not write a completion marker when the write fails after partial bytes", func() { + // Mimics a transcode that produces real output and then dies: + // the bytes land on disk, but the entry must NOT be marked complete. + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return &partialThenErrReader{data: []byte("PARTIAL-OUTPUT"), err: errors.New("transcoder died")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"partial"}) + Expect(err).To(BeNil()) + _, _ = io.Copy(io.Discard, s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"partial"}).Key()) + // The marker must never appear for a failed write. Give the async + // writer time to finish, then assert the marker stays absent. + Consistently(func() bool { + _, e := os.Stat(dataPath + ".complete") + return os.IsNotExist(e) + }).Should(BeTrue()) + }) + }) }) }) @@ -148,3 +288,23 @@ func (t *testArg) Key() string { return t.s } type errFakeReader struct{ err error } func (e errFakeReader) Read([]byte) (int, error) { return 0, e.err } + +// partialThenErrReader emits data once, then fails — mimicking a transcoder +// that produces some output and then dies mid-stream. +type partialThenErrReader struct { + data []byte + err error + done bool +} + +func (r *partialThenErrReader) Read(p []byte) (int, error) { + if r.done { + return 0, r.err + } + r.done = true + return copy(p, r.data), nil +} + +func fcSpreadFS(fc *fileCache) *spreadFS { + return fc.fs +} diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 281e2bfab..647439790 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -14,6 +14,9 @@ import ( "github.com/navidrome/navidrome/log" ) +const completeMarkerSuffix = ".complete" +const sentinelName = ".nd-migrated" + type spreadFS struct { root string mode os.FileMode @@ -40,30 +43,83 @@ func NewSpreadFS(dir string, mode os.FileMode) (*spreadFS, error) { } func (sfs *spreadFS) Reload(f func(key string, name string)) error { + // On the first run after upgrade (no sentinel yet), pre-existing files have + // no completion marker. Migrate them instead of discarding them as partials, + // so a user's whole cache isn't wiped. After the sentinel exists, an unmarked + // file is a crash partial and is discarded. + sentinel := filepath.Join(sfs.root, sentinelName) + _, sErr := os.Stat(sentinel) + migrating := os.IsNotExist(sErr) + count := 0 - err := filepath.WalkDir(sfs.root, func(absoluteFilePath string, de fs.DirEntry, err error) error { + err := sfs.walkDataFiles(func(absoluteFilePath string) { + if _, statErr := os.Stat(sfs.markerPath(absoluteFilePath)); statErr != nil { + switch { + case migrating: + if mErr := sfs.MarkComplete(absoluteFilePath); mErr != nil { + log.Warn("Error migrating cache file", "file", absoluteFilePath, mErr) + } + case os.IsNotExist(statErr): + // No completion marker: this is a partial left by a crash. Discard it. + log.Debug("Removing incomplete cache file", "file", absoluteFilePath) + _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload + return + default: + // Marker may exist but is unreadable (transient I/O, permissions): + // skip adoption without destroying a possibly-valid entry. + log.Warn("Error reading cache completion marker", "file", absoluteFilePath, statErr) + return + } + } + f(absoluteFilePath, absoluteFilePath) + count++ + }) + if err != nil { + return err + } + + log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) + // Only record the migration as done after a clean walk, so a partial walk + // doesn't leave valid-but-unmarked files to be discarded on the next run. + if migrating { + if wErr := os.WriteFile(sentinel, nil, 0600); wErr != nil { + log.Warn("Error writing cache migration sentinel", "file", sentinel, wErr) + } + } + return nil +} + +// walkDataFiles visits every cache data file (named XX/XX/<40-hex>), skipping +// completion markers and opportunistically cleaning up orphaned ones. +func (sfs *spreadFS) walkDataFiles(visit func(absoluteFilePath string)) error { + return filepath.WalkDir(sfs.root, func(absoluteFilePath string, _ fs.DirEntry, err error) error { if err != nil { log.Error("Error loading cache", "dir", sfs.root, err) + return nil } path, err := filepath.Rel(sfs.root, absoluteFilePath) if err != nil { return nil //nolint:nilerr } + // Skip marker files; also clean orphan markers (data file gone). + if strings.HasSuffix(path, completeMarkerSuffix) { + dataPath := strings.TrimSuffix(absoluteFilePath, completeMarkerSuffix) + if _, statErr := os.Stat(dataPath); os.IsNotExist(statErr) { + _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload + } + return nil + } + // Skip if name is not in the format XX/XX/XXXXXXXXXXXX parts := strings.Split(path, string(os.PathSeparator)) if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 || len(parts[2]) != 40 { return nil } - f(absoluteFilePath, absoluteFilePath) - count++ + visit(absoluteFilePath) return nil }) - if err == nil { - log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) - } - return err } func (sfs *spreadFS) Create(name string) (stream.File, error) { @@ -79,7 +135,27 @@ func (sfs *spreadFS) Open(name string) (stream.File, error) { return os.Open(name) } +func (sfs *spreadFS) markerPath(dataPath string) string { + return dataPath + completeMarkerSuffix +} + +// MarkComplete records that the cache entry for key was written in full. +// Only files with a marker are adopted on the next Reload; this is what +// distinguishes a complete cache entry from a partial one left by a crash. +// key may be an original cache key or an already-mapped data path; KeyMapper +// is idempotent for the latter (see KeyMapper). +func (sfs *spreadFS) MarkComplete(key string) error { + f, err := os.OpenFile(sfs.markerPath(sfs.KeyMapper(key)), os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + return f.Close() +} + func (sfs *spreadFS) Remove(name string) error { + if err := os.Remove(sfs.markerPath(name)); err != nil && !os.IsNotExist(err) { + log.Warn("Error removing cache completion marker", "file", name, err) + } return os.Remove(name) } diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index 2768ea2d5..0f88d3a58 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -39,31 +39,93 @@ var _ = Describe("Spread FS", func() { }) }) - Describe("Reload", func() { - var files []string + Describe("MarkComplete / Remove markers", func() { + It("creates a .complete marker for a data file", func() { + data := fs.KeyMapper("song1") + f, err := fs.Create(data) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte("ok")) + _ = f.Close() - BeforeEach(func() { - files = []string{"aaaaa", "bbbbb", "ccccc"} - for _, content := range files { - file := fs.KeyMapper(content) - f, err := fs.Create(file) - Expect(err).To(BeNil()) - _, _ = f.Write([]byte(content)) - _ = f.Close() - } + Expect(fs.MarkComplete(data)).To(Succeed()) + _, statErr := os.Stat(data + ".complete") + Expect(statErr).To(BeNil()) }) - It("loads all files from fs", func() { + It("removes the sibling marker when the data file is removed", func() { + data := fs.KeyMapper("song2") + f, err := fs.Create(data) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte("ok")) + _ = f.Close() + Expect(fs.MarkComplete(data)).To(Succeed()) + + Expect(fs.Remove(data)).To(Succeed()) + _, dataErr := os.Stat(data) + Expect(os.IsNotExist(dataErr)).To(BeTrue()) + _, markErr := os.Stat(data + ".complete") + Expect(os.IsNotExist(markErr)).To(BeTrue()) + }) + }) + + Describe("Reload", func() { + makeData := func(content string) string { + file := fs.KeyMapper(content) + f, err := fs.Create(file) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte(content)) + _ = f.Close() + return file + } + + It("migrates all existing files on first run and writes the sentinel", func() { + for _, c := range []string{"aaaaa", "bbbbb", "ccccc"} { + makeData(c) // no markers, simulating a pre-upgrade cache + } + var actual []string - err := fs.Reload(func(key string, name string) { + err := fs.Reload(func(key, name string) { Expect(key).To(Equal(name)) - data, err := os.ReadFile(name) - Expect(err).To(BeNil()) + data, _ := os.ReadFile(name) actual = append(actual, string(data)) }) Expect(err).To(BeNil()) - Expect(actual).To(HaveLen(len(files))) - Expect(actual).To(ContainElements(files[0], files[1], files[2])) + Expect(actual).To(ContainElements("aaaaa", "bbbbb", "ccccc")) + Expect(actual).To(HaveLen(3)) + + _, sentinelErr := os.Stat(filepath.Join(rootDir, ".nd-migrated")) + Expect(sentinelErr).To(BeNil()) + }) + + It("after migration, adopts only marked files and deletes unmarked partials", func() { + // Pretend migration already happened. + Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) + + good := makeData("good") + Expect(fs.MarkComplete(good)).To(Succeed()) + bad := makeData("bad") // partial: no marker + + var actual []string + err := fs.Reload(func(key, name string) { actual = append(actual, name) }) + Expect(err).To(BeNil()) + Expect(actual).To(ConsistOf(good)) + + _, badErr := os.Stat(bad) + Expect(os.IsNotExist(badErr)).To(BeTrue()) // partial deleted + }) + + It("ignores and cleans orphan markers", func() { + Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) + orphan := fs.KeyMapper("orphan") + ".complete" + Expect(os.MkdirAll(filepath.Dir(orphan), 0755)).To(Succeed()) + Expect(os.WriteFile(orphan, nil, 0600)).To(Succeed()) + + var actual []string + err := fs.Reload(func(key, name string) { actual = append(actual, name) }) + Expect(err).To(BeNil()) + Expect(actual).To(BeEmpty()) + _, orphanErr := os.Stat(orphan) + Expect(os.IsNotExist(orphanErr)).To(BeTrue()) }) }) }) From 56f0518830bac54c7d29cd895de915268f7adf29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 24 Jun 2026 09:10:00 -0400 Subject: [PATCH 311/596] feat(subsonic): add OpenSubsonic work and movement attributes (#5659) * feat(subsonic): add Work/Movement response types and tag constants * feat(subsonic): surface works and movements in Child response * test(subsonic): verify works/movements JSON serialization Fix G109 lint: use strconv.ParseInt with bitSize=32 to avoid potential integer overflow; add JSON serialization test confirming omitempty on optional sub-fields. * refactor(subsonic): use number.ParseInt idiom in buildMovements * refactor(subsonic): move work/movement builders to MediaFile methods Introduces model.Work and model.Movement types with Works()/Movements() methods on MediaFile. The Subsonic layer maps them to response types inline via slice.Map, replacing the deleted buildWorks/buildMovements helpers. * test(subsonic): cover populated works/movements in response snapshots * test(subsonic): clarify empty-case name and assert JSON structurally --- model/mediafile.go | 50 +++++++++++++++ model/mediafile_test.go | 63 +++++++++++++++++++ model/tag.go | 5 ++ server/subsonic/helpers.go | 6 ++ server/subsonic/helpers_test.go | 47 ++++++++++++++ ... AlbumList with OS data should match .JSON | 4 +- ...mWithSongsID3 with data should match .JSON | 8 ++- ...sponses Child with data should match .JSON | 20 +++++- ...esponses Child with data should match .XML | 3 + ...thout data should match OpenSubsonic .JSON | 4 +- server/subsonic/responses/responses.go | 13 ++++ server/subsonic/responses/responses_test.go | 7 +++ 12 files changed, 225 insertions(+), 5 deletions(-) diff --git a/model/mediafile.go b/model/mediafile.go index d93060dba..0fd172cee 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" ) @@ -151,6 +152,55 @@ func (mf MediaFile) String() string { return mf.Path } +type Work struct { + Name string + MbzWorkID string +} + +type Movement struct { + Name string + Number int32 + Count int32 +} + +func (mf MediaFile) Works() []Work { + names := mf.Tags.Values(TagWork) + if len(names) == 0 { + return nil + } + ids := mf.Tags.Values(TagMusicBrainzWorkID) + works := make([]Work, 0, len(names)) + for i, name := range names { + w := Work{Name: name} + if i < len(ids) { + w.MbzWorkID = ids[i] + } + works = append(works, w) + } + return works +} + +func (mf MediaFile) Movements() []Movement { + names := mf.Tags.Values(TagMovementName) + if len(names) == 0 { + return nil + } + numbers := mf.Tags.Values(TagMovementNumber) + counts := mf.Tags.Values(TagMovementTotal) + movements := make([]Movement, 0, len(names)) + for i, name := range names { + m := Movement{Name: name} + if i < len(numbers) { + m.Number = number.ParseInt[int32](numbers[i]) + } + if i < len(counts) { + m.Count = number.ParseInt[int32](counts[i]) + } + movements = append(movements, m) + } + return movements +} + // Hash returns a hash of the MediaFile based on its tags and audio properties func (mf MediaFile) Hash() string { opts := &hashstructure.HashOptions{ diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 65c5a0652..f070f4649 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -604,6 +604,69 @@ var _ = Describe("MediaFile", func() { }) +var _ = Describe("MediaFile.Works", func() { + It("returns nil when there are no work tags", func() { + mf := MediaFile{} + Expect(mf.Works()).To(BeNil()) + }) + + It("pairs a work name with its MbzWorkID", func() { + mf := MediaFile{Tags: Tags{ + TagWork: {"Symphony No. 5"}, + TagMusicBrainzWorkID: {"abc-123"}, + }} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Symphony No. 5", MbzWorkID: "abc-123"}, + })) + }) + + It("leaves MbzWorkID empty when no id is present", func() { + mf := MediaFile{Tags: Tags{TagWork: {"Symphony No. 5"}}} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Symphony No. 5"}, + })) + }) + + It("pairs by index and ignores extra ids", func() { + mf := MediaFile{Tags: Tags{ + TagWork: {"Work A", "Work B"}, + TagMusicBrainzWorkID: {"id-a"}, + }} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Work A", MbzWorkID: "id-a"}, + {Name: "Work B"}, + })) + }) +}) + +var _ = Describe("MediaFile.Movements", func() { + It("returns nil when there are no movement tags", func() { + mf := MediaFile{} + Expect(mf.Movements()).To(BeNil()) + }) + + It("builds a movement with name, number and count", func() { + mf := MediaFile{Tags: Tags{ + TagMovementName: {"I. Allegro"}, + TagMovementNumber: {"1"}, + TagMovementTotal: {"4"}, + }} + Expect(mf.Movements()).To(Equal([]Movement{ + {Name: "I. Allegro", Number: 1, Count: 4}, + })) + }) + + It("non-numeric number/count yields 0", func() { + mf := MediaFile{Tags: Tags{ + TagMovementName: {"I. Allegro"}, + TagMovementNumber: {"not-a-number"}, + }} + Expect(mf.Movements()).To(Equal([]Movement{ + {Name: "I. Allegro"}, + })) + }) +}) + var _ = Describe("MediaFile.Hash", func() { // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, // or every file would be spuriously re-imported on the next scan. diff --git a/model/tag.go b/model/tag.go index 1f6b24d21..02ccac05d 100644 --- a/model/tag.go +++ b/model/tag.go @@ -192,6 +192,10 @@ const ( TagISRC TagName = "isrc" TagBPM TagName = "bpm" TagExplicitStatus TagName = "explicitstatus" + TagWork TagName = "work" + TagMovementName TagName = "movementname" + TagMovementNumber TagName = "movement" + TagMovementTotal TagName = "movementtotal" // Dates and years @@ -240,6 +244,7 @@ const ( TagMusicBrainzAlbumArtistID TagName = "musicbrainz_albumartistid" TagMusicBrainzAlbumID TagName = "musicbrainz_albumid" TagMusicBrainzReleaseGroupID TagName = "musicbrainz_releasegroupid" + TagMusicBrainzWorkID TagName = "musicbrainz_workid" TagMusicBrainzComposerID TagName = "musicbrainz_composerid" TagMusicBrainzLyricistID TagName = "musicbrainz_lyricistid" diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 4027ba8b6..a76165cc1 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -290,6 +290,12 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.Contributors = contributors child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus) + child.Works = slice.Map(mf.Works(), func(w model.Work) responses.Work { + return responses.Work{Name: w.Name, MusicBrainzId: w.MbzWorkID} + }) + child.Movements = slice.Map(mf.Movements(), func(m model.Movement) responses.Movement { + return responses.Movement{Name: m.Name, Number: m.Number, Count: m.Count} + }) return &child } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 2ae6eb28e..ed8f257d1 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "net/http/httptest" "time" @@ -358,6 +359,52 @@ var _ = Describe("helpers", func() { Expect(osChild).ToNot(BeNil()) Expect(osChild.Comment).To(Equal("Test Comment")) }) + + It("populates works and movements from tags", func() { + mf.Tags = model.Tags{ + model.TagWork: {"Symphony No. 5"}, + model.TagMusicBrainzWorkID: {"abc-123"}, + model.TagMovementName: {"I. Allegro"}, + model.TagMovementNumber: {"1"}, + model.TagMovementTotal: {"4"}, + } + osChild := osChildFromMediaFile(ctx, mf) + Expect(osChild).ToNot(BeNil()) + Expect(osChild.Works).To(Equal(responses.Array[responses.Work]{ + {Name: "Symphony No. 5", MusicBrainzId: "abc-123"}, + })) + Expect(osChild.Movements).To(Equal(responses.Array[responses.Movement]{ + {Name: "I. Allegro", Number: 1, Count: 4}, + })) + }) + + It("returns empty works and movements when no classical tags are present", func() { + osChild := osChildFromMediaFile(ctx, mf) + Expect(osChild).ToNot(BeNil()) + Expect(osChild.Works).To(BeEmpty()) + Expect(osChild.Movements).To(BeEmpty()) + }) + + It("serializes works and movements to spec-compliant JSON", func() { + mf.Tags = model.Tags{ + model.TagWork: {"Symphony No. 5"}, + model.TagMovementName: {"I. Allegro"}, + model.TagMovementNumber: {"1"}, + } + osChild := osChildFromMediaFile(ctx, mf) + data, err := json.Marshal(osChild) + Expect(err).ToNot(HaveOccurred()) + + var got map[string]any + Expect(json.Unmarshal(data, &got)).To(Succeed()) + // Required name present; optional musicBrainzId/count omitted (omitempty); number present. + Expect(got).To(HaveKeyWithValue("works", []any{ + map[string]any{"name": "Symphony No. 5"}, + })) + Expect(got).To(HaveKeyWithValue("movements", []any{ + map[string]any{"name": "I. Allegro", "number": float64(1)}, + })) + }) }) Context("when legacy clients list is empty", func() { diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index 9d9ae2195..d6b195f58 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -59,7 +59,9 @@ "explicitStatus": "explicit", "groupings": [ "Soundtrack" - ] + ], + "works": [], + "movements": [] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index bff0bd20c..f776c7535 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -171,7 +171,9 @@ "groupings": [ "Soundtrack", "Live" - ] + ], + "works": [], + "movements": [] }, { "id": "2", @@ -217,7 +219,9 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [] + "groupings": [], + "works": [], + "movements": [] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index 4c0ea6c68..fef60c9b1 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -114,6 +114,22 @@ "groupings": [ "Soundtrack", "Live" + ], + "works": [ + { + "name": "Symphony No. 5", + "musicBrainzId": "mbz-work-1" + }, + { + "name": "Encore" + } + ], + "movements": [ + { + "name": "I. Allegro", + "number": 1, + "count": 4 + } ] }, { @@ -146,7 +162,9 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [] + "groupings": [], + "works": [], + "movements": [] } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index ddceb67d4..b626fd6ea 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -26,6 +26,9 @@ </contributors> <groupings>Soundtrack</groupings> <groupings>Live</groupings> + <works name="Symphony No. 5" musicBrainzId="mbz-work-1"></works> + <works name="Encore"></works> + <movements name="I. Allegro" number="1" count="4"></movements> </child> <child id="" isDir="false" title=""> <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index 9a9ab1ff6..ea23dc5d6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -29,7 +29,9 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [] + "groupings": [], + "works": [], + "movements": [] } ], "id": "", diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 7e41a1daa..252eee4c6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -190,6 +190,8 @@ type OpenSubsonicChild struct { DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` + Works Array[Work] `xml:"works,omitempty" json:"works"` + Movements Array[Movement] `xml:"movements,omitempty" json:"movements"` } type Songs struct { @@ -598,6 +600,17 @@ type ItemGenre struct { Name string `xml:"name,attr" json:"name"` } +type Work struct { + Name string `xml:"name,attr" json:"name"` + MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId,omitempty"` +} + +type Movement struct { + Name string `xml:"name,attr" json:"name"` + Number int32 `xml:"number,attr,omitempty" json:"number,omitempty"` + Count int32 `xml:"count,attr,omitempty" json:"count,omitempty"` +} + type ReplayGain struct { TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 3166df875..586e46b63 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -239,6 +239,13 @@ var _ = Describe("Responses", func() { {Role: "composer", Artist: ArtistID3Ref{Id: "4", Name: "composer2"}}, }, ExplicitStatus: "clean", + Works: []Work{ + {Name: "Symphony No. 5", MusicBrainzId: "mbz-work-1"}, + {Name: "Encore"}, + }, + Movements: []Movement{ + {Name: "I. Allegro", Number: 1, Count: 4}, + }, } child[1].OpenSubsonicChild = &OpenSubsonicChild{ ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, From 63a5954e4f7900862e47a8e614003ea3a233608f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 24 Jun 2026 22:26:41 -0400 Subject: [PATCH 312/596] perf(smartplaylist): use annotation index for playcount/rating/loved filters (#5662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(smartplaylist): use annotation index for playcount/rating/loved filters Annotation-field criteria wrapped the column in COALESCE(col, default) so missing annotation rows behave as 0/false. COALESCE prevents SQLite from using the column index, forcing a full media_file scan during smart playlist materialization - multi-second loads on large libraries, independent of rule complexity. Store the raw column plus its default and drop COALESCE when the compared value cannot match the default; fall back to 'col <op> ? OR col IS NULL' when the default would match, so never-annotated tracks are still preserved. Sorting keeps COALESCE to retain deterministic NULL ordering. Result set is unchanged; the materialize query now seeks the annotation index. Signed-off-by: Deluan <deluan@deluan.com> * fix(smartplaylist): keep COALESCE for list-valued annotation comparisons Hardening from final review: a list value (IN (...)) can't drive the index and a default-inclusive list has per-element NULL semantics, so route slice values through COALESCE(col, default) to stay exactly equivalent to the prior form. Also make the bool-default branch explicit (loved only supports equality operators) and share the COALESCE rendering via coalesceExpr. Signed-off-by: Deluan <deluan@deluan.com> * refactor(smartplaylist): make coalesced() a field method Thermo-nuclear review follow-up: promote the free coalesceExpr(f) to a smartPlaylistField.coalesced() method that returns the bare expression when there is no default. This lets sortExpr call field.coalesced() unconditionally and drop its 'if coalesceDefault != nil' branch, removing the 'only annotation fields get coalesced' special case from the sort path. Behavior unchanged. Signed-off-by: Deluan <deluan@deluan.com> * fix(smartplaylist): keep COALESCE for LIKE, bool-ordering, and tag ranges Code review (xhigh) found the index-friendly rewrite did not cover every operator, breaking result-set equivalence on a few reachable raw-JSON paths: - LIKE family (contains/startsWith/endsWith/notContains) on annotation fields used the bare column, so a NULL column never matched and missing-annotation rows were dropped. - Ordering comparators (gt/lt/...) on bool fields (loved) were decided as equality, wrongly including never-annotated rows. - InTheRange on a numeric tag split into two independent json_tree EXISTS, letting different tag values satisfy each bound. Centralize the decision in annotationCond via bareNullInclusion: emit the index-friendly bare form only for scalar values under an exactly-orderable comparator, otherwise fall back to the COALESCE form (always equivalent to the original). Route LIKE through coalesced(); reject tag/role ranges. Replace the local toFloat/toBool with spf13/cast (fixes unhandled numeric types and string bool forms), and drop the redundant LookupField + double reflect.TypeOf. A 17-case brute-force check confirms row-set equivalence to the prior COALESCE form across all operators including the fixed LIKE/bool cases. Signed-off-by: Deluan <deluan@deluan.com> * fix(smartplaylist): keep COALESCE for list values on bool annotation fields Second review found the bareNullInclusion bool branch missed the non-scalar guard the numeric branch has: a list value on loved/albumloved/artistloved (e.g. {"is":{"loved":[true]}}) coerced through toBool (which swallowed the cast error) to false, emitting the bare/OR-IS-NULL form and wrongly including never-annotated rows. Make toBool return (value, ok) like toFloat and bail to COALESCE when the value isn't a scalar bool. Also add the missing test for the tag/role range rejection. A 21-case brute-force confirms row-set equivalence to the original COALESCE form across every operator, including the bool/numeric list paths. Signed-off-by: Deluan <deluan@deluan.com> * refactor(smartplaylist): drop spf13/cast for stdlib value coercion The value coercion only sees the handful of types criteria produces (int, float64, string from JSON; bool already normalized at unmarshal), so cast's broad conversion isn't needed. Use small explicit type switches over strconv instead, keeping the string fallback (ParseFloat/ParseBool) that closes the '1'/'t' gap. No dependency change — cast returns to indirect. Signed-off-by: Deluan <deluan@deluan.com> * refactor(smartplaylist): share bool coercion via criteria.ToBool normalizeBoolValue (unmarshal-time) and the persistence bool guard both parsed bool-ish values independently. Extract the shared logic into an exported criteria.ToBool(any) (bool, ok): normalizeBoolValue delegates to it (behavior unchanged), and the persistence layer reuses it via its existing model/criteria import instead of a local helper. No behavior change. Signed-off-by: Deluan <deluan@deluan.com> * refactor(smartplaylist): trim sqlLiteral and dedup rationale comments /simplify cleanup: fmt %v already renders bool defaults as false/true, so drop sqlLiteral's redundant bool branch. Consolidate the COALESCE-vs-index rationale to the smartPlaylistField comment instead of repeating it across annotationCond and the struct. No behavior change. Signed-off-by: Deluan <deluan@deluan.com> * fix(smartplaylist): address review feedback on multi-field maps and *any From the PR bot reviews: - sqlFields now uses the field's coalesced() form, so annotation fields in a multi-field operator map (Is/Gt/Contains with >1 key) keep COALESCE and don't silently drop never-annotated rows. Covers both the comparison and LIKE fallback paths. (Gemini high, Copilot) - Replace coalesceDefault *any with a plain any (0/false are non-nil interfaces, so nil still means 'no default'); drop the coalesce() boxing helper and the pointer indirection. (Gemini) - Give rangeExpr clear, range-specific errors for the multi-field and malformed -pair cases instead of an empty-field / 'in operator' message. (Copilot) Adds tests for the multi-field COALESCE behavior and the new range errors. Signed-off-by: Deluan <deluan@deluan.com> * Revert multi-field COALESCE handling (YAGNI) The multi-field operator map case the bots flagged is unreachable: marshalExpression rejects any operator map with more than one field, so a multi-field map can never be persisted or loaded. Revert the sqlFields change and its tests rather than harden a code path no supported input can reach. Keep the two reachable improvements from the review: coalesceDefault any (not *any), and the clearer malformed-range error. Signed-off-by: Deluan <deluan@deluan.com> * refactor(persistence): model comparator as a behavior-carrying struct The smart-playlist comparator was a bare string alias, forcing two parallel switches over the same six operators: squirrelCmp mapped each to its squirrel constructor, and bareNullInclusion restated each as a float predicate. Adding or changing an operator meant editing both in sync. Make comparator a struct that bundles those facts per operator (the squirrel builder, the operator as a float predicate, and whether it's an ordering op). Both switches collapse: squirrelCmp is deleted in favor of cmp.build, and bareNullInclusion's numeric switch becomes a single cmp.satisfy call. Generated SQL is unchanged, as the existing table-driven tests confirm. * docs(smartplaylist): trim comments that restate the code Remove or tighten comments that describe what the code already says (likeCond and comparisonExpr doc lines, redundant clauses in annotationField/coalesced/ToBool/ normalizeBoolValue). Keep the comments that explain non-obvious rationale: the COALESCE-vs-index tradeoff, the bareNullInclusion/annotationCond contracts, and the why-we-fall-back notes. * docs(smartplaylist): collapse coalesceDefault comment to one line The field's six-line block duplicated the COALESCE-vs-index rationale that already lives on annotationCond. Reduce it to a one-line description plus a pointer there. --------- Signed-off-by: Deluan <deluan@deluan.com> --- model/criteria/json.go | 30 ++-- model/criteria/operators_test.go | 21 +++ persistence/criteria_sql.go | 250 +++++++++++++++++++++++-------- persistence/criteria_sql_test.go | 65 +++++++- 4 files changed, 292 insertions(+), 74 deletions(-) diff --git a/model/criteria/json.go b/model/criteria/json.go index ca47ceb95..beded9d1f 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -96,20 +96,32 @@ func normalizeBoolFields(m map[string]any) { } } -func normalizeBoolValue(v any) any { +// ToBool coerces a criteria value to a bool, accepting the forms criteria values take: a real bool, +// a strconv.ParseBool-parseable string, or a JSON number that is exactly 0 or 1. Any other value +// (other numbers, slices, nil, unparseable strings) returns ok=false so callers can handle it. +func ToBool(v any) (bool, bool) { switch val := v.(type) { + case bool: + return val, true case string: - if b, err := strconv.ParseBool(val); err == nil { - return b - } + b, err := strconv.ParseBool(val) + return b, err == nil case float64: - if val == 1 { - return true - } - if val == 0 { - return false + switch val { + case 1: + return true, true + case 0: + return false, true } } + return false, false +} + +// normalizeBoolValue leaves non-boolean values unchanged so they flow through to their own validation. +func normalizeBoolValue(v any) any { + if b, ok := ToBool(v); ok { + return b + } return v } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 17c4272ba..e5c8e1763 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -126,4 +126,25 @@ var _ = Describe("Operators", func() { gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) }) }) + + DescribeTable("ToBool", + func(in any, wantVal, wantOk bool) { + got, ok := ToBool(in) + gomega.Expect(ok).To(gomega.Equal(wantOk)) + gomega.Expect(got).To(gomega.Equal(wantVal)) + }, + Entry("real bool true", true, true, true), + Entry("real bool false", false, false, true), + Entry("string true", "true", true, true), + Entry("string false", "false", false, true), + Entry("string 1", "1", true, true), + Entry("string t", "t", true, true), + Entry("string 0", "0", false, true), + Entry("string unparseable", "yes", false, false), + Entry("float64 1", float64(1), true, true), + Entry("float64 0", float64(0), false, true), + Entry("float64 other", float64(2), false, false), + Entry("slice", []any{true}, false, false), + Entry("nil", nil, false, false), + ) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index b74f498d0..43cab4fdd 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -28,10 +28,11 @@ func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { } type smartPlaylistField struct { - expr string - order string - joinType smartPlaylistJoinType - emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) + expr string + order string + joinType smartPlaylistJoinType + emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) + coalesceDefault any // missing-row default for a nullable annotation column; nil = none. See annotationCond. } type smartPlaylistCriteria struct { @@ -89,22 +90,22 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "samplerate": {expr: "media_file.sample_rate"}, "bpm": {expr: "media_file.bpm"}, "channels": {expr: "media_file.channels"}, - "loved": {expr: "COALESCE(annotation.starred, false)"}, + "loved": {expr: "annotation.starred", coalesceDefault: false}, "dateloved": {expr: "annotation.starred_at"}, "lastplayed": {expr: "annotation.play_date"}, "daterated": {expr: "annotation.rated_at"}, - "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, - "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "playcount": {expr: "annotation.play_count", coalesceDefault: 0}, + "rating": {expr: "annotation.rating", coalesceDefault: 0}, "averagerating": {expr: "media_file.average_rating"}, - "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumrating": {expr: "album_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "album_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "album_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, - "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, - "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, - "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, @@ -152,27 +153,17 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } return mergeJsonConds(or), nil case criteria.Is: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Eq(fields) - }, false) + return comparisonExpr(e, cmpEq) case criteria.IsNot: return isNotExpr(e) case criteria.Gt: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Gt(fields) - }, false) + return comparisonExpr(e, cmpGt) case criteria.Lt: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Lt(fields) - }, false) + return comparisonExpr(e, cmpLt) case criteria.Before: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Lt(fields) - }, false) + return comparisonExpr(e, cmpLt) case criteria.After: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Gt(fields) - }, false) + return comparisonExpr(e, cmpGt) case criteria.Contains: return likeExpr(e, "%%%v%%", false) case criteria.NotContains: @@ -204,11 +195,7 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Eq{"value": value}, true), nil } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return squirrel.NotEq(fields), nil + return comparisonExpr(values, cmpNe) } func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { @@ -259,20 +246,17 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er } } -func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { - return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil - } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return makeCond(fields), nil -} - func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { - return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + if _, value, info, ok := singleField(values); ok { + if info.IsTag || info.IsRole { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + // LIKE can't use the column index, so annotation fields keep the COALESCE form: a NULL + // column never matches LIKE, which would silently drop missing-annotation rows the original + // COALESCE form included. + if f, isAnnotation := annotationField(info); isAnnotation { + return likeCond(f.coalesced(), fmt.Sprintf(pattern, value), negate), nil + } } fields, err := sqlFields(values) if err != nil { @@ -292,23 +276,36 @@ func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqli return lk, nil } +func likeCond(col, pattern string, negate bool) squirrel.Sqlizer { + if negate { + return squirrel.NotLike{col: pattern} + } + return squirrel.Like{col: pattern} +} + func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { - fields, err := sqlFields(values) + field, value, info, ok := singleField(values) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + // Tags/roles are multi-valued JSON, so splitting a range into two independent EXISTS + // subqueries would let different values satisfy each bound. Ranges are unsupported there. + return nil, fmt.Errorf("range operator not supported for tag/role field: %s", field) + } + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("range criteria for %q must be a [min, max] pair, got: %v", field, value) + } + low, err := comparisonExpr(map[string]any{field: s.Index(0).Interface()}, cmpGe) if err != nil { return nil, err } - and := squirrel.And{} - for field, value := range fields { - s := reflect.ValueOf(value) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) - } - and = append(and, - squirrel.GtOrEq{field: s.Index(0).Interface()}, - squirrel.LtOrEq{field: s.Index(1).Interface()}, - ) + high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe) + if err != nil { + return nil, err } - return and, nil + return squirrel.And{low, high}, nil } func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { @@ -638,6 +635,139 @@ func fieldExpr(name string) (string, bool) { return field.expr, ok } +// comparator is a scalar SQL comparison operator used by smart playlist criteria. +type comparator struct { + build func(map[string]any) squirrel.Sqlizer + satisfy func(a, b float64) bool // the operator as a predicate, to reason about a column's COALESCE default + // ordering is false only for = and <>, the only operators with a clean bare form over bool columns. + ordering bool +} + +var ( + cmpEq = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Eq(f) }, satisfy: func(a, b float64) bool { return a == b }} + cmpNe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.NotEq(f) }, satisfy: func(a, b float64) bool { return a != b }} + cmpGt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Gt(f) }, satisfy: func(a, b float64) bool { return a > b }, ordering: true} + cmpGe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.GtOrEq(f) }, satisfy: func(a, b float64) bool { return a >= b }, ordering: true} + cmpLt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Lt(f) }, satisfy: func(a, b float64) bool { return a < b }, ordering: true} + cmpLe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.LtOrEq(f) }, satisfy: func(a, b float64) bool { return a <= b }, ordering: true} +) + +// annotationField returns the field definition only for nullable annotation columns that have a +// COALESCE default (playcount, rating, loved). Date annotation columns (lastplayed, dateloved, ...) +// have no default and return ok=false. +func annotationField(info criteria.FieldInfo) (smartPlaylistField, bool) { + f, ok := smartPlaylistFields[info.Name()] + if !ok || f.coalesceDefault == nil { + return smartPlaylistField{}, false + } + return f, true +} + +// coalesced wraps the field in COALESCE(col, default) (bare expression if it has no default). Used +// where index-friendliness does not apply (ORDER BY, list comparisons) and the missing-row-as-default +// semantics must be kept. +func (f smartPlaylistField) coalesced() string { + if f.coalesceDefault == nil { + return f.expr + } + return fmt.Sprintf("COALESCE(%s, %s)", f.expr, sqlLiteral(f.coalesceDefault)) +} + +func comparisonExpr(values map[string]any, cmp comparator) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok { + if info.IsTag || info.IsRole { + return jsonExpr(info, cmp.build(map[string]any{"value": value}), false), nil + } + if f, isAnnotation := annotationField(info); isAnnotation { + return annotationCond(f, cmp, value), nil + } + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return cmp.build(fields), nil +} + +// annotationCond builds a comparison against a nullable annotation column (see smartPlaylistField +// for why). When bareNullInclusion can reason about the comparison exactly it emits the +// index-friendly bare `col <cmp> ?`, adding `OR col IS NULL` only when the default would match; +// otherwise it falls back to the COALESCE form, which can't use the index but is always equivalent. +func annotationCond(f smartPlaylistField, cmp comparator, value any) squirrel.Sqlizer { + wrapNull, ok := bareNullInclusion(f.coalesceDefault, cmp, value) + if !ok { + return cmp.build(map[string]any{f.coalesced(): value}) + } + base := cmp.build(map[string]any{f.expr: value}) + if !wrapNull { + return base + } + // The default satisfies the predicate, so missing rows (NULL) must be included. All comparators + // (including <>) evaluate to false against NULL, so an explicit IS NULL restores them. + return squirrel.Or{base, squirrel.Eq{f.expr: nil}} +} + +// bareNullInclusion decides whether the index-friendly bare form is safe for this comparison and, +// if so, whether missing (NULL) rows must be re-included via `OR col IS NULL`. It returns ok=false +// when the bare form can't be proven equivalent to COALESCE(col, default) <cmp> ? — i.e. the value +// isn't a scalar the comparator can order exactly (lists, bool ordering, unparseable values) — in +// which case the caller keeps the COALESCE form. When ok=true, wrapNull is true iff the default +// value itself satisfies the predicate (so missing rows would match and must be preserved). +func bareNullInclusion(defaultVal any, cmp comparator, value any) (wrapNull, ok bool) { + if b, isBool := defaultVal.(bool); isBool { + // Bool columns have an exact bare form only for equality; ordering operators fall back to + // COALESCE. Mapping both bools to 0/1 lets cmp.satisfy reuse the numeric eq/ne predicate. + if cmp.ordering { + return false, false + } + v, okV := criteria.ToBool(value) + if !okV { + return false, false + } + return cmp.satisfy(boolToFloat(b), boolToFloat(v)), true + } + d, okD := toFloat(defaultVal) + v, okV := toFloat(value) + if !okD || !okV { + // Non-scalar or unparseable value: no exact bare form, keep COALESCE. + return false, false + } + return cmp.satisfy(d, v), true +} + +func boolToFloat(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// toFloat coerces a scalar criteria value to float64. Criteria values come from JSON (float64, +// string) or are built in Go (int, float64), so only those types are handled; anything else — +// including a slice or an unparseable string — reports ok=false so the caller keeps the COALESCE +// form instead of an index-friendly bare comparison. +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + case string: + f, err := strconv.ParseFloat(n, 64) + return f, err == nil + default: + return 0, false + } +} + +// sqlLiteral renders an annotation field's COALESCE default (0 or false) as a SQL literal for ORDER +// BY. %v renders both bool and numeric defaults correctly (false/true, 0). +func sqlLiteral(v any) string { + return fmt.Sprintf("%v", v) +} + func fieldJoinType(name string) smartPlaylistJoinType { info, ok := criteria.LookupField(name) if !ok { @@ -705,7 +835,9 @@ func sortExpr(sortField string) (string, bool) { if !ok || field.expr == "" { return "", false } - mapped = field.expr + // Sorting keeps the COALESCE default so missing-annotation rows sort as that default + // (filtering drops COALESCE for index use, but ORDER BY has no index to preserve here). + mapped = field.coalesced() } if info.Numeric { mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 9ff7f1f07..59bdc4452 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -30,16 +30,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { }, Entry("all group", criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, - "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + "(media_file.title LIKE ? AND annotation.rating > ?)", "%love%", 3), Entry("any group", criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is bool", criteria.Is{"loved": true}, "annotation.starred = ?", true), Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("gt", criteria.Gt{"playCount": 10}, "annotation.play_count > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 10), Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), @@ -49,8 +49,51 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), + // Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks + // with no annotation row behave as that default. To keep the annotation index usable, the + // COALESCE is dropped when the compared value cannot match the default (the missing-row + // case is then naturally excluded); otherwise an explicit `OR col IS NULL` preserves it. + Entry("is safe (value != default)", criteria.Is{"playCount": 3}, "annotation.play_count = ?", 3), + Entry("is unsafe (value == default)", criteria.Is{"playCount": 0}, + "(annotation.play_count = ? OR annotation.play_count IS NULL)", 0), + Entry("is bool false (value == default)", criteria.Is{"loved": false}, + "(annotation.starred = ? OR annotation.starred IS NULL)", false), + Entry("gt safe (value >= default)", criteria.Gt{"playCount": 0}, "annotation.play_count > ?", 0), + Entry("gt unsafe (value < default)", criteria.Gt{"playCount": -1}, + "(annotation.play_count > ? OR annotation.play_count IS NULL)", -1), + Entry("lt safe (value <= default)", criteria.Lt{"playCount": 0}, "annotation.play_count < ?", 0), + Entry("lt unsafe (value > default)", criteria.Lt{"playCount": 5}, + "(annotation.play_count < ? OR annotation.play_count IS NULL)", 5), + Entry("isNot annotation keeps null match", criteria.IsNot{"playCount": 3}, + "(annotation.play_count <> ? OR annotation.play_count IS NULL)", 3), + Entry("isNot annotation value == default", criteria.IsNot{"playCount": 0}, + "annotation.play_count <> ?", 0), + Entry("in range spanning default", criteria.InTheRange{"playCount": []int{-1, 5}}, + "((annotation.play_count >= ? OR annotation.play_count IS NULL) AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", -1, 5), + Entry("in range above default", criteria.InTheRange{"playCount": []int{1, 5}}, + "(annotation.play_count >= ? AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", 1, 5), + // A list value can't drive the index and a default-inclusive list has per-element NULL + // semantics, so the COALESCE form is kept to stay equivalent to the original. + Entry("is list keeps coalesce", criteria.Is{"playCount": []int{0, 3}}, + "COALESCE(annotation.play_count, 0) IN (?,?)", 0, 3), + // LIKE operators can't use the column index, so annotation fields keep the COALESCE form to + // match missing-annotation rows exactly as before (a NULL column never matches LIKE). + Entry("contains annotation keeps coalesce", criteria.Contains{"playCount": 0}, + "COALESCE(annotation.play_count, 0) LIKE ?", "%0%"), + Entry("starts with annotation keeps coalesce", criteria.StartsWith{"rating": 5}, + "COALESCE(annotation.rating, 0) LIKE ?", "5%"), + Entry("not contains annotation keeps coalesce", criteria.NotContains{"playCount": 0}, + "COALESCE(annotation.play_count, 0) NOT LIKE ?", "%0%"), + // Bool annotation fields only have a clean index-friendly form for equality; ordering + // comparators keep the COALESCE form so the missing-row default is honored exactly. + Entry("gt bool keeps coalesce", criteria.Gt{"loved": false}, + "COALESCE(annotation.starred, false) > ?", false), + // A list value on a bool field is non-scalar, so it keeps the COALESCE form too (same as the + // numeric list case) — otherwise a NULL column would diverge from the original. + Entry("is bool list keeps coalesce", criteria.Is{"loved": []any{true}}, + "COALESCE(annotation.starred, false) IN (?)", true), Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), @@ -220,6 +263,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) + It("returns an error for a range over a tag/role field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where() + Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) + }) + + It("returns a clear error for a malformed range value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where() + Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) From 7b7721f00283604e73eb42071272014c86a3123d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 26 Jun 2026 14:46:09 -0400 Subject: [PATCH 313/596] feat(matcher): match similar/top songs by multiple artists (#5668) * feat(matcher): add Song.Artists (agents.Artist) and field-wise song dedup * refactor(matcher): make song equality an agents.Song.Equals method via hashstructure Move the sameSong free function from core/matcher into an Equals method on agents.Song, following the model.MediaFile/Album.Equals convention. Uses strict hashstructure hashing (nil opts, no IgnoreZeroValue) to preserve the original whole-value equality contract. Tests moved to core/agents. * feat(matcher): match by multiple artists with overlap ranking and artist-ID fast-path * refactor(matcher): rank artist overlap and specificity above the preferred-track flag Identity signals (specificityLevel, artistOverlap) now outrank the taste signal (preferredMatch) in betterThan. A starred/4-star track that is a worse identity match no longer beats a more specific or higher-overlap track. PreferStarred still breaks ties when specificity and overlap are equal. * fix(matcher): score artist-MBID specificity against all credited artists, not just the last sanitizedTrack.artistMBID (string) replaced with artistMBIDs (map[string]struct{}) so bucketTracks collects all credited owned MBIDs per query instead of last-write-wins. computeSpecificityLevel tests set membership, letting each of a collaboration's MBID-bearing artists reach the proper specificity level (4/5) independently. * feat(plugins): carry multiple artists (with IDs) through SongRef conversions * feat(plugins): regenerate schemas and PDK wrappers for multi-artist SongRef * refactor(matcher): tidy bucketTracks accumulator and artist resolution Replace bucketTracks' two parallel per-track maps (overlapByQuery/mbidsByQuery) with a named queryAccum struct (F2). Collapse resolveArtists' four hand-mutated parallel maps into a pendingArtist slice with derived nameToQueries/mbidToQueries maps (F1). Replace the own() method on resolvedArtists with a package-level addToSet helper that drops the method/receiver indirection (F3). * docs(matcher): trim comments that restate the code * fix(plugins): use Vec::is_empty for slice fields in generated Rust PDK * fix(matcher): treat a resolved artist ID as an identity match for specificity * docs(matcher): reflect artist-ID identity in the specificity ladder --- core/agents/interfaces.go | 21 ++ core/agents/interfaces_test.go | 47 +++ core/matcher/doc.go | 36 +- core/matcher/matcher.go | 347 +++++++++++------- core/matcher/matcher_internal_test.go | 198 +++++++++- core/matcher/matcher_test.go | 133 ++++++- plugins/capabilities/metadata_agent.go | 2 + plugins/capabilities/metadata_agent.yaml | 5 + plugins/capabilities/sonic_similarity.yaml | 19 + plugins/cmd/ndpgen/internal/generator.go | 9 +- plugins/cmd/ndpgen/internal/generator_test.go | 12 +- plugins/metadata_agent.go | 8 + plugins/metadata_agent_test.go | 22 ++ plugins/pdk/go/metadata/metadata.go | 2 + plugins/pdk/go/metadata/metadata_stub.go | 2 + .../pdk/go/sonicsimilarity/sonicsimilarity.go | 12 + .../sonicsimilarity/sonicsimilarity_stub.go | 12 + .../rust/nd-pdk-capabilities/src/metadata.rs | 3 + .../src/sonicsimilarity.rs | 17 + plugins/sonic_similarity_adapter.go | 3 + plugins/sonic_similarity_adapter_test.go | 22 ++ 21 files changed, 766 insertions(+), 166 deletions(-) create mode 100644 core/agents/interfaces_test.go diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 19df91d02..1fe8c2a23 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" ) @@ -39,11 +40,31 @@ type Song struct { ISRC string Artist string ArtistMBID string + Artists []Artist // optional full artist list; ArtistList normalizes against Artist/ArtistMBID Album string AlbumMBID string Duration uint32 // Duration in milliseconds, 0 means unknown } +// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes +// rather than comparing with ==, which the Artists slice makes illegal. +func (s Song) Equals(other Song) bool { + h1, _ := hashstructure.Hash(s, nil) + h2, _ := hashstructure.Hash(other, nil) + return h1 == h2 +} + +// ArtistList normalizes the single/multi-artist representations so callers never branch on len(Artists). +func (s Song) ArtistList() []Artist { + if len(s.Artists) > 0 { + return s.Artists + } + if s.Artist != "" { + return []Artist{{Name: s.Artist, MBID: s.ArtistMBID}} + } + return nil +} + var ( ErrNotFound = errors.New("not found") ) diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go new file mode 100644 index 000000000..46bd2f93b --- /dev/null +++ b/core/agents/interfaces_test.go @@ -0,0 +1,47 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artist: "A", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S", Artist: "A"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) + +var _ = Describe("Song.ArtistList", func() { + It("returns the Artists slice when present", func() { + s := Song{Artist: "Primary", ArtistMBID: "mbid-primary", Artists: []Artist{ + {ID: "id-drake", Name: "Drake", MBID: "mbid-drake"}, + {Name: "Future", MBID: "mbid-future"}, + }} + Expect(s.ArtistList()).To(Equal([]Artist{ + {ID: "id-drake", Name: "Drake", MBID: "mbid-drake"}, + {Name: "Future", MBID: "mbid-future"}, + })) + }) + It("falls back to the single Artist field with empty ID", func() { + s := Song{Artist: "Drake", ArtistMBID: "mbid-drake"} + Expect(s.ArtistList()).To(Equal([]Artist{{Name: "Drake", MBID: "mbid-drake"}})) + }) + It("returns empty when no artist is set", func() { + Expect(Song{}.ArtistList()).To(BeEmpty()) + }) +}) diff --git a/core/matcher/doc.go b/core/matcher/doc.go index cff866799..1024bc274 100644 --- a/core/matcher/doc.go +++ b/core/matcher/doc.go @@ -39,25 +39,40 @@ // 1. Title similarity (Jaro-Winkler score, 0.0–1.0) // 2. Duration proximity (closer duration scores higher; 1.0 when the agent // reports no duration) -// 3. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; -// prioritizes tracks that are starred or rated >= 4) -// 4. Specificity level (0–5, based on metadata precision; higher is better) -// 5. Album similarity (Jaro-Winkler, as the final tiebreaker) +// 3. Specificity level (0–5, based on metadata precision; higher is better) +// 4. Artist overlap (how many of the song's artists the track credits; more +// shared artists is better) +// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; +// prioritizes tracks that are starred or rated >= 4, but only among +// candidates of equal specificity and overlap) +// 6. Album similarity (Jaro-Winkler, as the final tiebreaker) // // The specificity levels, from most to least specific, are: // -// Level 5: Title + Artist MBID + Album MBID -// Level 4: Title + Artist MBID + Album name (fuzzy) +// Level 5: Title + Artist identity + Album MBID +// Level 4: Title + Artist identity + Album name (fuzzy) // Level 3: Title + Artist name + Album name (fuzzy) -// Level 2: Title + Artist MBID +// Level 2: Title + Artist identity // Level 1: Title + Artist name // Level 0: Title only // +// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal, +// when a source supplies one) or its MBID. A plain name match is the weaker fallback +// used for an artist with no identity match (e.g. a cover credited to a different +// artist of the same name). +// // The title phase always requires an agent artist to scope the library query, so // Level 0 does not mean "no artist": it applies when a candidate matches on title // but its own artist differs from the query's (e.g. a cover or a featured-artist // credit), leaving the title as the only shared field. // +// A song may carry several artists, and the title phase scopes candidate tracks by +// ANY of them: a track credited to at least one shared artist is considered. When a +// source supplies a Navidrome artist ID, that artist is matched directly, skipping +// name/MBID resolution. Among equally specific candidates, the one sharing more of +// the song's artists wins, so a track crediting every collaborator outranks one +// crediting only a single artist. +// // Each input song is scored independently, so two songs with the same title and // artist but different durations can resolve to different library tracks (each // matches the track closest to its own duration). @@ -97,12 +112,13 @@ // Result: studio for the first song, live for the second // // Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a -// starred (or rating >= 4) track is preferred even over a more specific match, -// because the preferred flag outranks specificity: +// starred (or rating >= 4) track is preferred, but only when specificity and +// artist overlap are equal. A more specific match always wins regardless of the +// preferred flag: // // Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Library has: // {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 // {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred -// Result: starred (the preferred flag outranks the better album match) +// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity) package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 5322b649f..d472f4a95 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -209,11 +209,18 @@ func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result m return nil } +// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID +// resolution; name is pre-sanitized (article-stripped). +type queryArtist struct { + id string + name string + mbid string +} + // songQuery represents a normalized query for matching a song to library tracks. type songQuery struct { title string - artist string - artistMBID string + artists []queryArtist album string albumMBID string durationMs uint32 @@ -224,11 +231,13 @@ type matchScore struct { titleSimilarity float64 durationProximity float64 preferredMatch bool - albumSimilarity float64 specificityLevel int + artistOverlap int + albumSimilarity float64 } // betterThan returns true if this score beats another. +// Identity signals (specificity, overlap) outrank the taste signal (preferred). func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -236,12 +245,15 @@ func (s matchScore) betterThan(other matchScore) bool { if s.durationProximity != other.durationProximity { return s.durationProximity > other.durationProximity } - if s.preferredMatch != other.preferredMatch { - return s.preferredMatch - } if s.specificityLevel != other.specificityLevel { return s.specificityLevel > other.specificityLevel } + if s.artistOverlap != other.artistOverlap { + return s.artistOverlap > other.artistOverlap + } + if s.preferredMatch != other.preferredMatch { + return s.preferredMatch + } return s.albumSimilarity > other.albumSimilarity } @@ -249,46 +261,57 @@ func (s matchScore) betterThan(other matchScore) bool { // when the same track is scored against multiple queries. The `mf` field is a pointer to avoid // copying the large MediaFile struct into each entry of the sanitized slice. type sanitizedTrack struct { - mf *model.MediaFile - title string - artist string - album string - artistMBID string // resolved from the artist table; mf.MbzArtistID is not populated on the bulk path + mf *model.MediaFile + title string + artist string + album string + artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal + artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path) } -func newSanitizedTrack(mf *model.MediaFile, artistMBID string) sanitizedTrack { +func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack { return sanitizedTrack{ - mf: mf, - title: str.SanitizeFieldForSorting(mf.Title), - artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), - album: str.SanitizeFieldForSorting(mf.Album), - artistMBID: artistMBID, + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + artistIDs: artistIDs, + artistMBIDs: artistMBIDs, } } -// computeSpecificityLevel determines how well query metadata matches a track (0-5). -// The track's title, artist, and album fields must be pre-sanitized, and artistMBID -// must hold the resolved artist MBID. +// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best +// level achievable across any of the query's artists. Fields must be pre-sanitized. +// +// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the +// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers. +// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited +// to a different artist by the same name). func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { - if q.artistMBID != "" && q.albumMBID != "" && - t.artistMBID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { - return 5 + best := 0 + albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold + for _, a := range q.artists { + _, idMember := t.artistIDs[a.id] + _, mbidMember := t.artistMBIDs[a.mbid] + identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember) + level := 0 + switch { + case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID: + level = 5 + case identity && q.album != "" && albumOK: + level = 4 + case a.name != "" && q.album != "" && t.artist == a.name && albumOK: + level = 3 + case identity: + level = 2 + case a.name != "" && t.artist == a.name: + level = 1 + } + if level > best { + best = level + } } - if q.artistMBID != "" && q.album != "" && - t.artistMBID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { - return 4 - } - if q.artist != "" && q.album != "" && - t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { - return 3 - } - if q.artistMBID != "" && t.artistMBID == q.artistMBID { - return 2 - } - if q.artist != "" && t.artist == q.artist { - return 1 - } - return 0 + return best } // indexedQuery pairs a normalized songQuery with the index of the input song @@ -301,12 +324,12 @@ type indexedQuery struct { // matchByTitle fills result with fuzzy title+artist matches, skipping songs // already matched by a higher-priority loader. func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - byArtist := groupQueriesByArtist(songs, result) - if len(byArtist) == 0 { + queries := groupQueries(songs, result) + if len(queries) == 0 { return nil } - resolved, err := m.resolveArtists(ctx, byArtist) + resolved, err := m.resolveArtists(ctx, queries) if err != nil || len(resolved.allIDs) == 0 { return err } @@ -318,136 +341,191 @@ func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result tracksByQuery := resolved.bucketTracks(tracks) threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - for artist, queries := range byArtist { - sanitized := tracksByQuery[artist] - // Each song is matched independently by index, so two songs with the same - // (title, artist) but different durations can resolve to different tracks. - for _, iq := range queries { - if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { - result[iq.index] = mf - } + for _, iq := range queries { + sanitized := tracksByQuery[iq.index] + if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { + result[iq.index] = mf } } return nil } -// groupQueriesByArtist buckets the still-unmatched title queries by sanitized artist name. -// Songs without an artist are skipped: title matching needs one to scope the library query. -func groupQueriesByArtist(songs []agents.Song, result map[int]model.MediaFile) map[string][]indexedQuery { - byArtist := map[string][]indexedQuery{} +// groupQueries builds one normalized title query per still-unmatched song, carrying its full +// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name; +// songs with no usable artist are skipped (the title phase needs at least one to scope the query). +func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery { + var queries []indexedQuery for i, s := range songs { if _, done := result[i]; done { continue } - artist := str.SanitizeFieldForSortingNoArticle(s.Artist) - if artist == "" { + var artists []queryArtist + for _, a := range s.ArtistList() { + name := str.SanitizeFieldForSortingNoArticle(a.Name) + if a.ID == "" && name == "" { + continue + } + artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID}) + } + if len(artists) == 0 { continue } - byArtist[artist] = append(byArtist[artist], indexedQuery{index: i, query: songQuery{ + queries = append(queries, indexedQuery{index: i, query: songQuery{ title: str.SanitizeFieldForSorting(s.Name), - artist: artist, - artistMBID: s.ArtistMBID, + artists: artists, album: str.SanitizeFieldForSorting(s.Album), albumMBID: s.AlbumMBID, durationMs: s.Duration, }}) } - return byArtist + return queries } -// resolvedArtists holds the agent artists resolved to artist-table rows. Everything routes by -// stable artist ID, never by name, so MBID-resolved artists whose order name differs from the -// query name are not misrouted. +// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index +// that owns them. Routing is always by stable artist ID, never by name. type resolvedArtists struct { - byQuery map[string]map[string]struct{} // sanitized query name -> set of resolved artist IDs - mbid map[string]string // artist ID -> its MBID (the real one, from the artist table) - allIDs []string // every resolved artist ID, for the track lookup + byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs + mbid map[string]string // artist ID -> its MBID (from the artist table) + allIDs []string // every resolved artist ID, for the track lookup } -// resolveArtists resolves the queries' artists against the artist table (by sort name or -// agent-provided MBID) and records, for each query, which artist IDs it owns. -func (m *Matcher) resolveArtists(ctx context.Context, byArtist map[string][]indexedQuery) (resolvedArtists, error) { - names := make([]string, 0, len(byArtist)) - mbidToQueries := make(map[string][]string, len(byArtist)) // agent ArtistMBID -> query names that supplied it - for name, queries := range byArtist { - names = append(names, name) - for _, iq := range queries { - if iq.query.artistMBID != "" { - mbidToQueries[iq.query.artistMBID] = append(mbidToQueries[iq.query.artistMBID], name) +// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a +// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in +// one batched query. Ownership is recorded per query index. +func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) { + res := resolvedArtists{ + byQuery: make(map[int]map[string]struct{}, len(queries)), + mbid: make(map[string]string), + } + allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved + + // One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists + // take the fast-path and are owned directly. + type pendingArtist struct { + name, mbid string + query int + } + var pending []pendingArtist + for _, iq := range queries { + for _, a := range iq.query.artists { + if a.id != "" { + addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly + allIDs[a.id] = struct{}{} + continue } + pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index}) + } + } + // query indices that supplied each order name / each MBID (skip the empty key — an artist may + // have only one of name/mbid). + nameToQueries := map[string][]int{} + mbidToQueries := map[string][]int{} + for _, p := range pending { + if p.name != "" { + nameToQueries[p.name] = append(nameToQueries[p.name], p.query) + } + if p.mbid != "" { + mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query) } } - filter := squirrel.Or{squirrel.Eq{"order_artist_name": names}} + // Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are + // available for specificity scoring). + var filter squirrel.Or + if len(nameToQueries) > 0 { + filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))}) + } if len(mbidToQueries) > 0 { filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) } - artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) - if err != nil { - return resolvedArtists{}, err + if len(allIDs) > 0 { + filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))}) } - - res := resolvedArtists{ - byQuery: make(map[string]map[string]struct{}, len(byArtist)), - mbid: make(map[string]string, len(artists)), - allIDs: make([]string, 0, len(artists)), - } - for _, a := range artists { - res.mbid[a.ID] = a.MbzArtistID - res.allIDs = append(res.allIDs, a.ID) - // An artist belongs to a query if its order name matches the query name, or if its MBID - // matches one a query supplied. The same MBID can come from several queries (agent aliases), - // so every one of them owns the artist. - res.own(a.OrderArtistName, a.ID) - if a.MbzArtistID != "" { - for _, name := range mbidToQueries[a.MbzArtistID] { - res.own(name, a.ID) + if len(filter) > 0 { + artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) + if err != nil { + return resolvedArtists{}, err + } + for _, a := range artists { + res.mbid[a.ID] = a.MbzArtistID + allIDs[a.ID] = struct{}{} + for _, idx := range nameToQueries[a.OrderArtistName] { + addToSet(res.byQuery, idx, a.ID) + } + if a.MbzArtistID != "" { + for _, idx := range mbidToQueries[a.MbzArtistID] { + addToSet(res.byQuery, idx, a.ID) + } } } } + + res.allIDs = slices.Collect(maps.Keys(allIDs)) return res, nil } -// own records that the named query owns the given artist ID. A name that is not a query simply -// gets its own (unused) entry. -func (r resolvedArtists) own(name, artistID string) { - if r.byQuery[name] == nil { - r.byQuery[name] = map[string]struct{}{} +func addToSet(m map[int]map[string]struct{}, k int, v string) { + if m[k] == nil { + m[k] = map[string]struct{}{} } - r.byQuery[name][artistID] = struct{}{} + m[k][v] = struct{}{} } -// bucketTracks groups tracks by query name, at most once per query even when a track credits -// several of that query's artists, so the same track is not scored twice. The participants JSON -// on each track carries artist IDs but not their MBID, so the MBID comes from r.mbid instead. -func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[string][]sanitizedTrack { - // Invert byQuery once so each participant maps straight to the queries that own it, instead of - // scanning every query per participant. - queriesByArtist := make(map[string][]string) - for name, ids := range r.byQuery { +// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct +// artist IDs the track credits. +type scoredTrack struct { + sanitizedTrack + overlap int +} + +// queryAccum tallies, for one track against one query, the overlap count, the credited artists' +// owned IDs, and their MBIDs. +type queryAccum struct { + overlap int + ids map[string]struct{} + mbids map[string]struct{} +} + +func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack { + queriesByArtist := make(map[string][]int) + for idx, ids := range r.byQuery { for id := range ids { - queriesByArtist[id] = append(queriesByArtist[id], name) + queriesByArtist[id] = append(queriesByArtist[id], idx) } } - byQuery := make(map[string][]sanitizedTrack, len(r.byQuery)) - added := make(map[string]map[string]struct{}, len(r.byQuery)) // query name -> set of track IDs already bucketed + byQuery := make(map[int][]scoredTrack, len(r.byQuery)) for i := range tracks { + acc := map[int]*queryAccum{} + credited := map[string]struct{}{} for _, p := range tracks[i].Participants[model.RoleArtist] { - mbid, isResolved := r.mbid[p.ID] - if !isResolved { + if _, dup := credited[p.ID]; dup { continue } - for _, name := range queriesByArtist[p.ID] { - if added[name] == nil { - added[name] = map[string]struct{}{} - } - if _, dup := added[name][tracks[i].ID]; dup { - continue - } - added[name][tracks[i].ID] = struct{}{} - byQuery[name] = append(byQuery[name], newSanitizedTrack(&tracks[i], mbid)) + owners, owned := queriesByArtist[p.ID] + if !owned { + continue } + credited[p.ID] = struct{}{} + mbid := r.mbid[p.ID] // "" if not in the artist-table result + for _, idx := range owners { + a := acc[idx] + if a == nil { + a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}} + acc[idx] = a + } + a.overlap++ + a.ids[p.ID] = struct{}{} + if mbid != "" { + a.mbids[mbid] = struct{}{} + } + } + } + for idx, a := range acc { + byQuery[idx] = append(byQuery[idx], scoredTrack{ + sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids), + overlap: a.overlap, + }) } } return byQuery @@ -488,35 +566,32 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false preferStarred := conf.Server.Matcher.PreferStarred - for _, t := range sanitizedTracks { - titleSim := similarityRatio(q.title, t.title) - + for _, c := range candidates { + titleSim := similarityRatio(q.title, c.title) if titleSim < threshold { continue } - var albumSim float64 if q.album != "" { - albumSim = similarityRatio(q.album, t.album) + albumSim = similarityRatio(q.album, c.album) } - score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, t.mf.Duration), - preferredMatch: preferStarred && isPreferredTrack(t.mf), + durationProximity: durationProximity(q.durationMs, c.mf.Duration), + preferredMatch: preferStarred && isPreferredTrack(c.mf), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, t, threshold), + specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold), + artistOverlap: c.overlap, } - if score.betterThan(bestScore) { bestScore = score - bestMatch = *t.mf + bestMatch = *c.mf found = true } } @@ -544,7 +619,7 @@ func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count i continue } if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if s != songs[prevIdx] { + if !s.Equals(songs[prevIdx]) { continue } } else { diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index 62bccd844..b81fa9012 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,7 +1,9 @@ package matcher import ( + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -54,14 +56,200 @@ var _ = Describe("similarityRatio", func() { }) var _ = Describe("matcher internals", func() { - It("computeSpecificityLevel uses sanitizedTrack.artistMBID for artist-MBID levels", func() { + It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() { q := songQuery{ - title: "song", - artistMBID: "artist-mbid-1", - albumMBID: "album-mbid-1", + title: "song", + artists: []queryArtist{{mbid: "artist-mbid-1"}}, + albumMBID: "album-mbid-1", } mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty - t := newSanitizedTrack(&mf, "artist-mbid-1") // resolved MBID supplied here + t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}}) Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) }) + + It("computeSpecificityLevel maximizes the level over all query artists", func() { + // First artist does not match; second matches by name with a matching album → level 3. + q := songQuery{ + title: "song", + album: "violator", + artists: []queryArtist{ + {name: "no match"}, + {name: "depeche mode"}, + }, + } + mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"} + t := newSanitizedTrack(&mf, nil, nil) + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3)) + }) + + It("scores MBID specificity for any credited artist, not just the last", func() { + q := songQuery{ + title: "song", + artists: []queryArtist{ + {name: "drake", mbid: "mbz-drake"}, + {name: "future", mbid: "mbz-future"}, + }, + album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+ + } + // Track credits BOTH MBIDs; with the old last-wins string this would only match one. + t := sanitizedTrack{ + mf: &model.MediaFile{}, + title: "song", + artist: "drake", + album: "some other album", + artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}}, + } + // Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is + // reached via mbz-future too, which the old code would have dropped. + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2)) + }) + + It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() { + // A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the + // strongest identity signal, so the track's album still elevates specificity above 0. + q := songQuery{ + title: "song", + artists: []queryArtist{{id: "artist-1"}}, + album: "violator", + albumMBID: "album-mbid-1", + } + // Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin). + mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"} + t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil) + // Album MBID matches → level 5 via the ID identity, where the old code scored 0. + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + + // Same artist, album name matches but no album MBID → level 4 via the ID identity. + q.albumMBID = "" + mf2 := model.MediaFile{Title: "Song", Album: "Violator"} + t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil) + Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4)) + }) }) + +var _ = Describe("groupQueries", func() { + It("builds one query per unmatched song carrying all artists", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].index).To(Equal(0)) + Expect(queries[0].query.title).To(Equal("song a")) + Expect(queries[0].query.artists).To(HaveLen(2)) + Expect(queries[0].query.artists[0].name).To(Equal("drake")) + Expect(queries[0].query.artists[1].name).To(Equal("future")) + }) + + It("strips leading articles from artist names", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "The Drake"}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("drake")) + }) + + It("keeps an artist that carries only an ID (empty name)", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].id).To(Equal("ar-x")) + Expect(queries[0].query.artists[0].name).To(Equal("")) + }) + + It("drops an artist with empty id and empty name but keeps usable ones", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: ""}, {Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + }) + + It("falls back to the single Artist field via ArtistList", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Future", ArtistMBID: "mbid-1"}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + Expect(queries[0].query.artists[0].mbid).To(Equal("mbid-1")) + }) + + It("skips already-matched songs and songs with no usable artist", func() { + songs := []agents.Song{ + {Name: "Already Matched", Artist: "Drake"}, + {Name: "No Artist"}, + {Name: "Song C", Artist: "Future"}, + } + queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].index).To(Equal(2)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + }) +}) + +var _ = Describe("bucketTracks", func() { + It("scores tracks by how many of the query's artists they credit (overlap)", func() { + r := resolvedArtists{ + byQuery: map[int]map[string]struct{}{ + 0: {"ar-1": {}, "ar-2": {}}, + }, + mbid: map[string]string{}, + } + trackA := model.MediaFile{ID: "a", Title: "A", + Participants: artistParticipants( + model.Artist{ID: "ar-1", OrderArtistName: "one"}, + model.Artist{ID: "ar-2", OrderArtistName: "two"}, + ), + } + trackB := model.MediaFile{ID: "b", Title: "B", + Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}), + } + byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB}) + Expect(byQuery[0]).To(HaveLen(2)) + overlaps := map[string]int{} + for _, st := range byQuery[0] { + overlaps[st.mf.ID] = st.overlap + } + Expect(overlaps["a"]).To(Equal(2)) + Expect(overlaps["b"]).To(Equal(1)) + }) +}) + +var _ = Describe("resolveArtists ID fast-path", func() { + It("owns an artist supplied by ID without a name match", func() { + ctx := GinkgoT().Context() + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{ + {ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"}, + }) + ds := &tests.MockDataStore{MockedArtist: artistRepo} + m := New(ds) + + queries := []indexedQuery{ + {index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}}, + } + res, err := m.resolveArtists(ctx, queries) + Expect(err).ToNot(HaveOccurred()) + Expect(res.byQuery[0]).To(HaveKey("ar-x")) + Expect(res.allIDs).To(ContainElement("ar-x")) + Expect(res.mbid["ar-x"]).To(Equal("mbz-x")) + }) +}) + +// artistParticipants builds a Participants map crediting the given artists under RoleArtist. +func artistParticipants(artists ...model.Artist) model.Participants { + list := make(model.ParticipantList, len(artists)) + for i, a := range artists { + list[i] = model.Participant{Artist: a} + } + return model.Participants{model.RoleArtist: list} +} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 1fad0ebcc..1b3af3aa3 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -466,6 +466,103 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("track-1")) }) }) + + Context("multiple artists", func() { + It("prefers the track that shares more of the song's artists", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + // Both candidates have display Artist "Drake", so they tie at specificity level 1 + // (name match). The deciding factor is artistOverlap: "both" credits Drake AND + // Future (overlap 2), "one" credits only Drake (overlap 1). + bothArtists := model.MediaFile{ + ID: "both", Title: "Life Is Good", Artist: "Drake", + Participants: artistParticipants( + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, + ), + } + oneArtist := model.MediaFile{ + ID: "one", Title: "Life Is Good", Artist: "Drake", + Participants: artistParticipants( + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + ), + } + allowTitlePhase(model.MediaFiles{oneArtist, bothArtists}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("both")) + }) + + It("matches a single-artist song against a track crediting several artists", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Life Is Good", Artist: "Future"}, + } + track := model.MediaFile{ + ID: "multi", Title: "Life Is Good", Artist: "Future feat. Drake", + Participants: artistParticipants( + model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("multi")) + }) + + It("matches by a directly-supplied Navidrome artist ID (fast-path)", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, + } + track := model.MediaFile{ + ID: "by-id", Title: "Song A", Artist: "Some Artist", + Participants: artistParticipants( + model.Artist{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-id")) + }) + + It("prefers a higher artist-overlap track over a starred lower-overlap track", func() { + conf.Server.Matcher.PreferStarred = true + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Collab Hit", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + // Shares only Drake (overlap 1) but starred. + starredOne := model.MediaFile{ + ID: "starred-one", Title: "Collab Hit", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}), + } + // Shares both (overlap 2), not starred. + shareTwo := model.MediaFile{ + ID: "share-two", Title: "Collab Hit", + Participants: artistParticipants( + model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}, + model.Artist{ID: "id-future", Name: "Future", OrderArtistName: "future"}, + ), + } + allowTitlePhase(model.MediaFiles{starredOne, shareTwo}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("share-two")) // overlap outranks the starred flag + }) + }) }) Describe("MatchSongsIndexed", func() { @@ -848,7 +945,7 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("exact")) }) - It("prefers starred songs over better album match when enabled", func() { + It("prefers a more specific match over a starred track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, @@ -862,17 +959,14 @@ var _ = Describe("Matcher", func() { Annotations: model.Annotations{Starred: true}, Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("starred")) + Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the starred flag }) - It("prefers 4-star songs over better album match when enabled", func() { + It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, @@ -886,14 +980,33 @@ var _ = Describe("Matcher", func() { Annotations: model.Annotations{Rating: 4}, Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("rated")) + Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the 4-star rating + }) + + It("prefers a starred track when specificity and overlap are equal", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + // Both credit the same single artist and the same album → equal specificity AND equal overlap. + plain := model.MediaFile{ + ID: "plain", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + } + starred := model.MediaFile{ + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + } + allowTitlePhase(model.MediaFiles{plain, starred}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("starred")) // preferred still wins the tie }) }) diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 407f21ec5..935594abd 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -148,6 +148,8 @@ type SongRef struct { Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` // Album is the album name. Album string `json:"album,omitempty"` // AlbumMBID is the MusicBrainz release ID. diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index 4940a5056..c90ac1ce0 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -352,6 +352,11 @@ components: artistMbid: type: string description: ArtistMBID is the MusicBrainz artist ID. + artists: + type: array + description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + items: + $ref: '#/components/schemas/ArtistRef' album: type: string description: Album is the album name. diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index cba97d9b0..1d35e1e92 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -16,6 +16,20 @@ exports: contentType: application/json components: schemas: + ArtistRef: + description: ArtistRef is a reference to an artist with name and optional MBID. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name FindSonicPathRequest: properties: startSong: @@ -60,6 +74,11 @@ components: artistMbid: type: string description: ArtistMBID is the MusicBrainz artist ID. + artists: + type: array + description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + items: + $ref: '#/components/schemas/ArtistRef' album: type: string description: Album is the album name. diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 705cd4d36..514da578b 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -568,9 +568,16 @@ func rustConstName(name string) string { } // skipSerializingFunc returns the appropriate skip_serializing_if function name. +// The check must match the rendered Rust type: pointers become Option<T>, slices Vec<T>, +// and maps HashMap<K,V>, each with a different emptiness predicate. func skipSerializingFunc(goType string) string { - if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") { + switch { + case strings.HasPrefix(goType, "*"): return "Option::is_none" + case strings.HasPrefix(goType, "[]"): + return "Vec::is_empty" + case strings.HasPrefix(goType, "map["): + return "HashMap::is_empty" } switch goType { case "string": diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 34c2c2886..052aa146c 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -1289,12 +1289,16 @@ type OnInitOutput struct { var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { - It("should return Option::is_none for pointer, slice, and map types", func() { + It("should return Option::is_none for pointer types", func() { Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none")) + }) + + It("should return the matching emptiness predicate for slice and map types", func() { + // The predicate must match the rendered Rust type: []T -> Vec<T>, map[K]V -> HashMap<K,V>. + Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty")) + Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty")) + Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty")) }) It("should return String::is_empty for string type", func() { diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 52542300c..f3c26411c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -229,6 +229,13 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m // songRefToAgentSong converts a single SongRef to agents.Song func songRefToAgentSong(s capabilities.SongRef) agents.Song { + var artists []agents.Artist + if len(s.Artists) > 0 { + artists = make([]agents.Artist, len(s.Artists)) + for i, a := range s.Artists { + artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID} + } + } return agents.Song{ ID: s.ID, Name: s.Name, @@ -236,6 +243,7 @@ func songRefToAgentSong(s capabilities.SongRef) agents.Song { ISRC: s.ISRC, Artist: s.Artist, ArtistMBID: s.ArtistMBID, + Artists: artists, Album: s.Album, AlbumMBID: s.AlbumMBID, Duration: uint32(s.Duration * 1000), diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 067ae80ca..be9d309ae 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -4,6 +4,7 @@ package plugins import ( "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -329,3 +330,24 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { Expect(err).To(MatchError(errNotImplemented)) }) }) + +var _ = Describe("songRefToAgentSong multi-artist", func() { + It("maps ArtistRef to agents.Artist", func() { + ref := capabilities.SongRef{Name: "Collab", Artist: "Drake", Artists: []capabilities.ArtistRef{ + {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, + {Name: "Future", MBID: "m-future"}, + }} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{ + {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, + {Name: "Future", MBID: "m-future"}, + })) + }) + It("leaves Artists nil and keeps the single Artist when no Artists provided", func() { + ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(BeNil()) + Expect(got.Artist).To(Equal("Drake")) + Expect(got.ArtistMBID).To(Equal("m-drake")) + }) +}) diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index 7cd63865b..cec6ce339 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -177,6 +177,8 @@ type SongRef struct { Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` // Album is the album name. Album string `json:"album,omitempty"` // AlbumMBID is the MusicBrainz release ID. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index bdcd06fcb..b71a4902f 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -174,6 +174,8 @@ type SongRef struct { Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` // Album is the album name. Album string `json:"album,omitempty"` // AlbumMBID is the MusicBrainz release ID. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go index 7a2681f93..864d13311 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -11,6 +11,16 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +// ArtistRef is a reference to an artist with name and optional MBID. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { StartSong SongRef `json:"startSong"` @@ -38,6 +48,8 @@ type SongRef struct { Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` // Album is the album name. Album string `json:"album,omitempty"` // AlbumMBID is the MusicBrainz release ID. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go index 28d86301c..cf4cfc7ef 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -8,6 +8,16 @@ package sonicsimilarity +// ArtistRef is a reference to an artist with name and optional MBID. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { StartSong SongRef `json:"startSong"` @@ -35,6 +45,8 @@ type SongRef struct { Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` // Album is the album name. Album string `json:"album,omitempty"` // AlbumMBID is the MusicBrainz release ID. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 463e52c37..1a51ad9fd 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -251,6 +251,9 @@ pub struct SongRef { /// ArtistMBID is the MusicBrainz artist ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub artist_mbid: String, + /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec<ArtistRef>, /// Album is the album name. #[serde(default, skip_serializing_if = "String::is_empty")] pub album: String, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs index eb2868929..81b163e5f 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -18,6 +18,20 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// ArtistRef is a reference to an artist with name and optional MBID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// ID is the internal Navidrome artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} /// FindSonicPathRequest represents the FindSonicPathRequest data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -60,6 +74,9 @@ pub struct SongRef { /// ArtistMBID is the MusicBrainz artist ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub artist_mbid: String, + /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec<ArtistRef>, /// Album is the album name. #[serde(default, skip_serializing_if = "String::is_empty")] pub album: String, diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index ff399680e..e29684512 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -72,6 +72,9 @@ func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { AlbumMBID: mf.MbzAlbumID, Duration: mf.Duration, } + for _, p := range mf.Participants[model.RoleArtist] { + ref.Artists = append(ref.Artists, capabilities.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID}) + } if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { ref.ISRC = isrcs[0] } diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index daea04761..1b8d4efa4 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -5,6 +5,7 @@ package plugins import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -108,3 +109,24 @@ var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { Expect(err.Error()).To(ContainSubstring("simulated plugin error")) }) }) + +var _ = Describe("mediaFileToSongRef multi-artist", func() { + It("fills Artists (with IDs) from role=artist participants", func() { + mf := &model.MediaFile{ID: "x", Title: "Collab", Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-drake", Name: "Drake", MbzArtistID: "m-drake"}}, + {Artist: model.Artist{ID: "ar-future", Name: "Future", MbzArtistID: "m-future"}}, + }, + }} + ref := mediaFileToSongRef(mf) + Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{ + {ID: "ar-drake", Name: "Drake", MBID: "m-drake"}, + {ID: "ar-future", Name: "Future", MBID: "m-future"}, + })) + }) + It("leaves Artists nil when the track has no role=artist participants", func() { + mf := &model.MediaFile{ID: "x", Title: "Solo", Artist: "Drake"} + ref := mediaFileToSongRef(mf) + Expect(ref.Artists).To(BeNil()) + }) +}) From bd9fa1c602ee2c20fce23abadd6236cdc35e71b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 26 Jun 2026 17:06:14 -0400 Subject: [PATCH 314/596] feat(listenbrainz): match collaboration top-songs via all credited artist MBIDs (#5670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agents): drop single Artist fields from Song, keep only Artists Song now represents credited artists solely via the Artists slice; the single Artist/ArtistMBID fields and the ArtistList passthrough are removed. Equals continues to hash the whole value. * refactor(lastfm,listenbrainz): build Song.Artists in built-in agents Last.fm and ListenBrainz now populate the Artists slice directly. For ListenBrainz top songs, all credited artist MBIDs are mapped (the combined display name on the first credit plus MBID-only collaborators) instead of keeping only the first MBID, feeding the matcher's per-MBID specificity. * refactor(external): set Song.Artists in top-songs enrichment getMatchingTopSongs now seeds an Artists entry from the known artist when a song carries none, replacing the single Artist/ArtistMBID writes. * refactor(plugins): fold single-artist SongRef into Song.Artists SongRef keeps its single Artist/ArtistMBID fields as part of the plugin wire contract; songRefToAgentSong now folds them into a one-element Artists list when a plugin sends no artists array. * refactor(matcher): read Song.Artists directly groupQueries consumes s.Artists now that ArtistList is gone; test inputs build the Artists slice. * fix(matcher): keep MBID-only artists as identity signals Review follow-up: an artist credited only by MBID (empty ID and name) was dropped before resolution in four places, defeating the multi-MBID matching path this PR adds. - matcher.groupQueries: treat a non-empty MBID as a usable artist signal - external.getMatchingTopSongs: backfill the primary credit's name/MBID when the agent left them empty - plugins.songRefToAgentSong: fold a single-artist SongRef when only ArtistMBID is set (not just when Artist name is set) - listenbrainz.topSongArtists: return nil instead of an empty-name placeholder when neither name nor MBIDs are present * fix(external): only backfill top-song artist onto an unnamed credit Review follow-up (codex P2): the previous backfill stamped the queried artist's MBID onto Artists[0] whenever it was empty, even when that credit already named a different (e.g. featured) artist — producing a mismatched name+MBID pair that could mis-rank matches. Now only an unnamed first credit is filled (it is, by construction, the queried artist); an already-named credit is left untouched. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/lastfm/agent.go | 7 +- adapters/lastfm/agent_test.go | 10 +- adapters/listenbrainz/agent.go | 39 ++++-- adapters/listenbrainz/agent_test.go | 125 ++++++++++-------- core/agents/interfaces.go | 29 ++-- core/agents/interfaces_test.go | 24 +--- core/external/provider.go | 18 ++- core/external/provider_similarsongs_test.go | 6 +- core/external/provider_topsongs_test.go | 57 ++++++++ core/matcher/matcher.go | 4 +- core/matcher/matcher_internal_test.go | 21 +-- core/matcher/matcher_test.go | 138 ++++++++++---------- core/sonic/sonic_test.go | 2 +- plugins/metadata_agent.go | 27 ++-- plugins/metadata_agent_test.go | 16 ++- plugins/sonic_similarity_adapter_test.go | 6 +- 16 files changed, 305 insertions(+), 224 deletions(-) diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 02c198120..eb8f3d36e 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -231,10 +231,9 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti res := make([]agents.Song, 0, len(resp)) for _, t := range resp { res = append(res, agents.Song{ - Name: t.Name, - MBID: t.MBID, - Artist: t.Artist.Name, - ArtistMBID: t.Artist.MBID, + Name: t.Name, + MBID: t.MBID, + Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}}, }) } return res, nil diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94788b8bd..7e4e29294 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -309,11 +309,11 @@ var _ = Describe("lastfmAgent", func() { f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{ - {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, - {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, - {Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}, - {Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"}, - {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}, + {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, + {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, + {Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}}, + {Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}}, + {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}}, })) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough")) diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 826a9672e..76beed921 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -141,24 +141,37 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam res := make([]agents.Song, len(resp)) for i, t := range resp { - mbid := "" - if len(t.ArtistMBIDs) > 0 { - mbid = t.ArtistMBIDs[0] - } - res[i] = agents.Song{ - Album: t.ReleaseName, - AlbumMBID: t.ReleaseMBID, - Artist: t.ArtistName, - ArtistMBID: mbid, - Duration: t.DurationMs, - Name: t.RecordingName, - MBID: t.RecordingMbid, + Album: t.ReleaseName, + AlbumMBID: t.ReleaseMBID, + Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs), + Duration: t.DurationMs, + Name: t.RecordingName, + MBID: t.RecordingMbid, } } return res, nil } +// topSongArtists maps the top-recordings response, which carries a single combined display name +// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not +// positionally pairable, so the display name attaches to the first credit and any further MBIDs +// become MBID-only collaborators — still valid identity signals for the matcher. +func topSongArtists(name string, mbids []string) []agents.Artist { + if len(mbids) == 0 { + if name == "" { + return nil + } + return []agents.Artist{{Name: name}} + } + artists := make([]agents.Artist, len(mbids)) + artists[0] = agents.Artist{Name: name, MBID: mbids[0]} + for i, m := range mbids[1:] { + artists[i+1] = agents.Artist{MBID: m} + } + return artists +} + func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { if mbid == "" { return nil, agents.ErrNotFound @@ -203,7 +216,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin songs[i] = agents.Song{ Album: song.ReleaseName, AlbumMBID: song.ReleaseMBID, - Artist: song.Artist, + Artists: []agents.Artist{{Name: song.Artist}}, MBID: song.MBID, Name: song.Name, } diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index df70ec9c4..2c4668296 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -249,24 +249,22 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, { - ID: "", - Name: "String Theocracy", - MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "String Theocracy", - AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", - Duration: 174000, + ID: "", + Name: "String Theocracy", + MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "String Theocracy", + AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + Duration: 174000, }, })) }) @@ -278,17 +276,45 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, })) }) + + It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() { + body := `[{ + "recording_name": "Collab", + "recording_mbid": "rec-1", + "artist_name": "Drake feat. Future", + "artist_mbids": ["mbid-drake", "mbid-future"], + "release_name": "Album", + "release_mbid": "rel-1", + "length": 200000 + }]` + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(HaveLen(1)) + Expect(data[0].Artists).To(Equal([]agents.Artist{ + {Name: "Drake feat. Future", MBID: "mbid-drake"}, + {MBID: "mbid-future"}, + })) + }) + + It("leaves Artists nil when the top song carries no name or MBIDs", func() { + body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]` + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(HaveLen(1)) + Expect(data[0].Artists).To(BeNil()) + }) }) Describe("GetSimilarArtists", func() { @@ -393,26 +419,24 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artist: "a‐ha", - ArtistMBID: "", - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artists: []agents.Artist{{Name: "a‐ha"}}, + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, { - ID: "", - Name: "Wake Me Up Before You Go‐Go", - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - ISRC: "", - Artist: "Wham!", - ArtistMBID: "", - Album: "Make It Big", - AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", - Duration: 0, + ID: "", + Name: "Wake Me Up Before You Go‐Go", + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + ISRC: "", + Artists: []agents.Artist{{Name: "Wham!"}}, + Album: "Make It Big", + AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", + Duration: 0, }, })) }) @@ -427,15 +451,14 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artist: "a‐ha", - ArtistMBID: "", - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artists: []agents.Artist{{Name: "a‐ha"}}, + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, })) }) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 1fe8c2a23..d5f4a6580 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -34,16 +34,14 @@ type ExternalImage struct { } type Song struct { - ID string - Name string - MBID string - ISRC string - Artist string - ArtistMBID string - Artists []Artist // optional full artist list; ArtistList normalizes against Artist/ArtistMBID - Album string - AlbumMBID string - Duration uint32 // Duration in milliseconds, 0 means unknown + ID string + Name string + MBID string + ISRC string + Artists []Artist + Album string + AlbumMBID string + Duration uint32 // Duration in milliseconds, 0 means unknown } // Equals reports strict whole-value equality, used to dedup identical input songs. It hashes @@ -54,17 +52,6 @@ func (s Song) Equals(other Song) bool { return h1 == h2 } -// ArtistList normalizes the single/multi-artist representations so callers never branch on len(Artists). -func (s Song) ArtistList() []Artist { - if len(s.Artists) > 0 { - return s.Artists - } - if s.Artist != "" { - return []Artist{{Name: s.Artist, MBID: s.ArtistMBID}} - } - return nil -} - var ( ErrNotFound = errors.New("not found") ) diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go index 46bd2f93b..c13710a38 100644 --- a/core/agents/interfaces_test.go +++ b/core/agents/interfaces_test.go @@ -6,7 +6,7 @@ import ( ) var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artist: "A", Artists: []Artist{{ID: "x", Name: "A"}}} + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} It("true for identical songs incl Artists", func() { Expect(base.Equals(base)).To(BeTrue()) }) @@ -21,27 +21,7 @@ var _ = Describe("Song.Equals", func() { Expect(base.Equals(other)).To(BeFalse()) }) It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S", Artist: "A"} + a := Song{ID: "1", Name: "S"} Expect(a.Equals(a)).To(BeTrue()) }) }) - -var _ = Describe("Song.ArtistList", func() { - It("returns the Artists slice when present", func() { - s := Song{Artist: "Primary", ArtistMBID: "mbid-primary", Artists: []Artist{ - {ID: "id-drake", Name: "Drake", MBID: "mbid-drake"}, - {Name: "Future", MBID: "mbid-future"}, - }} - Expect(s.ArtistList()).To(Equal([]Artist{ - {ID: "id-drake", Name: "Drake", MBID: "mbid-drake"}, - {Name: "Future", MBID: "mbid-future"}, - })) - }) - It("falls back to the single Artist field with empty ID", func() { - s := Song{Artist: "Drake", ArtistMBID: "mbid-drake"} - Expect(s.ArtistList()).To(Equal([]Artist{{Name: "Drake", MBID: "mbid-drake"}})) - }) - It("returns empty when no artist is set", func() { - Expect(Song{}.ArtistList()).To(BeEmpty()) - }) -}) diff --git a/core/external/provider.go b/core/external/provider.go index 74dab4972..459e8a205 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -471,13 +471,19 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) } - // Enrich songs with artist info if not already present (for top songs, we know the artist) + // Enrich top songs with the queried artist. A song with no artists, or whose first credit the + // agent left unnamed, is attributed to the queried artist. A first credit that already names an + // artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried + // MBID onto it would create a false name+MBID pairing. for i := range songs { - if songs[i].Artist == "" { - songs[i].Artist = artistName - } - if songs[i].ArtistMBID == "" { - songs[i].ArtistMBID = artist.MbzArtistID + switch { + case len(songs[i].Artists) == 0: + songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}} + case songs[i].Artists[0].Name == "": + songs[i].Artists[0].Name = artistName + if songs[i].Artists[0].MBID == "" { + songs[i].Artists[0].MBID = artist.MbzArtistID + } } } diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index f109bf8b1..563003f83 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -73,7 +73,7 @@ var _ = Describe("Provider - SimilarSongs", func() { agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). Return([]agents.Song{ - {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"}, + {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, }, nil).Once() // Matcher artist resolution: resolve Depeche Mode in the artist table. @@ -177,7 +177,7 @@ var _ = Describe("Provider - SimilarSongs", func() { agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5). Return([]agents.Song{ - {Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"}, + {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches @@ -254,7 +254,7 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). Return([]agents.Song{ - {Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"}, + {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 600524819..86f9110e2 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -76,6 +76,63 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) + It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) + + // Agent leaves the first credit unnamed (e.g. an MBID-less collaborator slot). That blank + // credit IS the queried artist, so enrichment fills both name and MBID; the song then matches + // the queried artist's track via the backfilled identity. + agentSongs := []agents.Song{ + {Name: "Song One", Artists: []agents.Artist{{}}}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + track := model.MediaFile{ + ID: "song-1", Title: "Song One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, + }}, + } + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) + + songs, err := p.TopSongs(ctx, "Artist One", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + + It("does not stamp the queried MBID onto an already-named different first credit", func() { + // The queried artist (One) appears only as a featured collaborator; the displayed first credit + // is a DIFFERENT artist (Two) returned without an MBID. Enrichment must NOT assign One's MBID + // to Two — only Two's name match (which fails here) or One's own credit may resolve the track. + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) + + agentSongs := []agents.Song{ + {Name: "Collab Song", Artists: []agents.Artist{{Name: "Artist Two"}}}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + // Library track is credited to Artist One (the queried artist) under a same title. If the + // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID + // could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track. + track := model.MediaFile{ + ID: "one-track", Title: "Collab Song", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, + }}, + } + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) + + songs, err := p.TopSongs(ctx, "Artist One", 1) + + Expect(err).ToNot(HaveOccurred()) + // "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track. + Expect(songs).To(BeEmpty()) + }) + It("returns nil for an unknown artist", func() { // Mock artist not found artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once() diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index d472f4a95..25b8fda5f 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -360,9 +360,9 @@ func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexed continue } var artists []queryArtist - for _, a := range s.ArtistList() { + for _, a := range s.Artists { name := str.SanitizeFieldForSortingNoArticle(a.Name) - if a.ID == "" && name == "" { + if a.ID == "" && name == "" && a.MBID == "" { continue } artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID}) diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index b81fa9012..5b987d937 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -143,7 +143,7 @@ var _ = Describe("groupQueries", func() { It("strips leading articles from artist names", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "The Drake"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}}, } queries := groupQueries(songs, map[int]model.MediaFile{}) Expect(queries).To(HaveLen(1)) @@ -162,32 +162,35 @@ var _ = Describe("groupQueries", func() { Expect(queries[0].query.artists[0].name).To(Equal("")) }) - It("drops an artist with empty id and empty name but keeps usable ones", func() { + It("keeps an artist that carries only an MBID (empty id and name)", func() { + // ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display + // name; the MBID is a usable identity signal and must not be dropped. songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: ""}, {Name: "Future"}}}, + {Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}}, } queries := groupQueries(songs, map[int]model.MediaFile{}) Expect(queries).To(HaveLen(1)) Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].name).To(Equal("future")) + Expect(queries[0].query.artists[0].id).To(Equal("")) + Expect(queries[0].query.artists[0].name).To(Equal("")) + Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future")) }) - It("falls back to the single Artist field via ArtistList", func() { + It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Future", ArtistMBID: "mbid-1"}, + {Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}}, } queries := groupQueries(songs, map[int]model.MediaFile{}) Expect(queries).To(HaveLen(1)) Expect(queries[0].query.artists).To(HaveLen(1)) Expect(queries[0].query.artists[0].name).To(Equal("future")) - Expect(queries[0].query.artists[0].mbid).To(Equal("mbid-1")) }) It("skips already-matched songs and songs with no usable artist", func() { songs := []agents.Song{ - {Name: "Already Matched", Artist: "Drake"}, + {Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}}, {Name: "No Artist"}, - {Name: "Song C", Artist: "Future"}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}}, } queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}}) Expect(queries).To(HaveLen(1)) diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 1b3af3aa3..a46db8a09 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -107,7 +107,7 @@ var _ = Describe("Matcher", func() { It("matches songs with an ID field to MediaFiles by ID", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, + {ID: "track-1", Name: "Some Song", Artists: []agents.Artist{{Name: "Some Artist"}}}, } idMatch := model.MediaFile{ ID: "track-1", Title: "Some Song", Artist: "Some Artist", @@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() { It("matches songs with MBID to tracks with matching mbz_recording_id", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, + {Name: "Paranoid Android", MBID: "abc-123", Artists: []agents.Artist{{Name: "Radiohead"}}}, } mbidMatch := model.MediaFile{ ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", @@ -144,7 +144,7 @@ var _ = Describe("Matcher", func() { It("matches songs with ISRC to tracks with matching ISRC tag", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artists: []agents.Artist{{Name: "Radiohead"}}}, } isrcMatch := model.MediaFile{ ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", @@ -163,7 +163,7 @@ var _ = Describe("Matcher", func() { It("matches songs by title and artist name", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, } titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", @@ -179,7 +179,7 @@ var _ = Describe("Matcher", func() { It("matches songs with fuzzy title similarity", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, } fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", @@ -195,7 +195,7 @@ var _ = Describe("Matcher", func() { It("does not match completely different titles", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}}, } differentTracks := model.MediaFiles{ {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", @@ -213,8 +213,8 @@ var _ = Describe("Matcher", func() { It("removes duplicates when different input songs match the same library track", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Live)", Artists: []agents.Artist{{Name: "Queen"}}}, + {Name: "Bohemian Rhapsody (Original Mix)", Artists: []agents.Artist{{Name: "Queen"}}}, } libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", @@ -230,8 +230,8 @@ var _ = Describe("Matcher", func() { It("preserves duplicates when identical input songs match the same library track", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, } libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", @@ -253,7 +253,7 @@ var _ = Describe("Matcher", func() { // and short-circuit the MBID phase entirely, so no MBID fetch should // occur even though an mbz_recording_id exists in the input. songs := []agents.Song{ - {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artists: []agents.Artist{{Name: "Artist"}}}, } idMatch := model.MediaFile{ ID: "track-id", Title: "Song", Artist: "Artist", @@ -271,9 +271,9 @@ var _ = Describe("Matcher", func() { It("returns at most 'count' results", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, } tracks := model.MediaFiles{ {ID: "a", Title: "Song A", Artist: "Artist", @@ -304,7 +304,7 @@ var _ = Describe("Matcher", func() { Context("artist grouping", func() { It("groups title-phase tracks by participant artist ID, not display Artist", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Daft Punk"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Daft Punk"}}}, } // Display Artist differs from the query artist; only the participant // with order_artist_name "daft punk" routes to this query bucket. @@ -326,7 +326,7 @@ var _ = Describe("Matcher", func() { It("matches a track that credits the searched artist as a collaborator", func() { songs := []agents.Song{ - {Name: "Crazy", Artist: "INXS"}, + {Name: "Crazy", Artists: []agents.Artist{{Name: "INXS"}}}, } // "Par-T-One vs. INXS" — display Artist is the collaboration, but INXS is a // credited artist participant. Searching INXS must match it. @@ -347,7 +347,7 @@ var _ = Describe("Matcher", func() { It("does not match a track where the searched artist is only the album artist", func() { songs := []agents.Song{ - {Name: "Qmart", Artist: "808 State"}, + {Name: "Qmart", Artists: []agents.Artist{{Name: "808 State"}}}, } // Track performed by Björk on an "808 State" compilation: 808 State is the // albumartist, Björk is the performer. Searching 808 State must NOT match it. @@ -379,7 +379,7 @@ var _ = Describe("Matcher", func() { It("resolves the artist by ArtistMBID when the name differs", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Typo Artist", ArtistMBID: "mbid-9"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Typo Artist", MBID: "mbid-9"}}}, } track := model.MediaFile{ ID: "by-mbid", Title: "Song A", Artist: "Correct Artist", @@ -403,8 +403,8 @@ var _ = Describe("Matcher", func() { // Two agent results for the same MusicBrainz artist but spelled differently // (an alias). Both must match the artist's track via the shared MBID. songs := []agents.Song{ - {Name: "Song A", Artist: "Alias One", ArtistMBID: "mbid-shared"}, - {Name: "Song B", Artist: "Alias Two", ArtistMBID: "mbid-shared"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Alias One", MBID: "mbid-shared"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Alias Two", MBID: "mbid-shared"}}}, } artist := model.Artist{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"} trackA := model.MediaFile{ID: "ta", Title: "Song A", Artist: "Canonical", Participants: artistParticipants(artist)} @@ -427,8 +427,8 @@ var _ = Describe("Matcher", func() { Context("title phase DB errors", func() { It("returns an error when the title query fails and nothing else matched", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist One"}, - {Name: "Song B", Artist: "Artist Two"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, } allowIdentifierPhases() artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ @@ -445,8 +445,8 @@ var _ = Describe("Matcher", func() { It("keeps exact-phase matches when the title query fails", func() { songs := []agents.Song{ - {ID: "track-1", Name: "Exact Song", Artist: "Exact Artist"}, - {Name: "Fuzzy Song", Artist: "Fuzzy Artist"}, + {ID: "track-1", Name: "Exact Song", Artists: []agents.Artist{{Name: "Exact Artist"}}}, + {Name: "Fuzzy Song", Artists: []agents.Artist{{Name: "Fuzzy Artist"}}}, } idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} expectIDPhase(model.MediaFiles{idMatch}) @@ -500,7 +500,7 @@ var _ = Describe("Matcher", func() { It("matches a single-artist song against a track crediting several artists", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Life Is Good", Artist: "Future"}, + {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Future"}}}, } track := model.MediaFile{ ID: "multi", Title: "Life Is Good", Artist: "Future feat. Drake", @@ -568,9 +568,9 @@ var _ = Describe("Matcher", func() { Describe("MatchSongsIndexed", func() { It("returns index-keyed map of matched songs", func() { songs := []agents.Song{ - {ID: "track-1", Name: "Song One", Artist: "Artist A"}, - {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, - {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + {ID: "track-1", Name: "Song One", Artists: []agents.Artist{{Name: "Artist A"}}}, + {ID: "track-2", Name: "Song Two", Artists: []agents.Artist{{Name: "Artist B"}}}, + {ID: "track-3", Name: "Song Three", Artists: []agents.Artist{{Name: "Artist C"}}}, } mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} @@ -589,8 +589,8 @@ var _ = Describe("Matcher", func() { It("preserves original indices when some songs don't match", func() { songs := []agents.Song{ - {Name: "Unknown Song", Artist: "Unknown Artist"}, - {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + {Name: "Unknown Song", Artists: []agents.Artist{{Name: "Unknown Artist"}}}, + {ID: "track-1", Name: "Known Song", Artists: []agents.Artist{{Name: "Known Artist"}}}, } mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} @@ -629,7 +629,7 @@ var _ = Describe("Matcher", func() { Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid-123"}}, Album: "Violator", AlbumMBID: "album-mbid-456"}, } allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) @@ -651,7 +651,7 @@ var _ = Describe("Matcher", func() { Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) @@ -673,7 +673,7 @@ var _ = Describe("Matcher", func() { Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, } allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) @@ -710,9 +710,9 @@ var _ = Describe("Matcher", func() { } songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, - {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "Ray Charles"}}, Album: "Greatest Hits"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "Frank Sinatra"}}, Album: "My Way"}, } allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) @@ -742,8 +742,8 @@ var _ = Describe("Matcher", func() { } songs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artist: "Artist Two"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, } allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) @@ -758,7 +758,7 @@ var _ = Describe("Matcher", func() { It("uses the resolved artist MBID for specificity (level 5)", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, } // Two tracks with the same title and album; only the one whose resolved artist // carries mbid-1 (and whose album MBID matches) wins via Level 5. Without the @@ -788,7 +788,7 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, + {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, } artistTracks := model.MediaFiles{ {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", @@ -809,7 +809,7 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, } artistTracks := model.MediaFiles{ {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", @@ -832,7 +832,7 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, + {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, } artistTracks := model.MediaFiles{ {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", @@ -854,7 +854,7 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 75 songs := []agents.Song{ - {Name: "Song", Artist: "Artist"}, + {Name: "Song", Artists: []agents.Artist{{Name: "Artist"}}}, } artistTracks := model.MediaFiles{ {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", @@ -881,7 +881,7 @@ var _ = Describe("Matcher", func() { It("matches album with (Remaster) suffix", func() { songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", @@ -903,7 +903,7 @@ var _ = Describe("Matcher", func() { It("matches album with (Deluxe Edition) suffix", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", @@ -925,7 +925,7 @@ var _ = Describe("Matcher", func() { It("prefers exact album match over fuzzy album match", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } exactMatch := model.MediaFile{ ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", @@ -948,7 +948,7 @@ var _ = Describe("Matcher", func() { It("prefers a more specific match over a starred track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", @@ -969,7 +969,7 @@ var _ = Describe("Matcher", func() { It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", @@ -990,7 +990,7 @@ var _ = Describe("Matcher", func() { It("prefers a starred track when specificity and overlap are equal", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } // Both credit the same single artist and the same album → equal specificity AND equal overlap. plain := model.MediaFile{ @@ -1017,7 +1017,7 @@ var _ = Describe("Matcher", func() { It("prefers tracks with matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, @@ -1039,7 +1039,7 @@ var _ = Describe("Matcher", func() { It("matches tracks with close duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } closeDuration := model.MediaFile{ ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, @@ -1057,7 +1057,7 @@ var _ = Describe("Matcher", func() { It("prefers closer duration over farther duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } closeDuration := model.MediaFile{ ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, @@ -1079,7 +1079,7 @@ var _ = Describe("Matcher", func() { It("still matches when no tracks have matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } differentDuration := model.MediaFile{ ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, @@ -1097,7 +1097,7 @@ var _ = Describe("Matcher", func() { It("prefers title match over duration match when titles differ", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } differentTitle := model.MediaFile{ ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, @@ -1119,7 +1119,7 @@ var _ = Describe("Matcher", func() { It("matches without duration filtering when agent duration is 0", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 0}, } anyTrack := model.MediaFile{ ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, @@ -1137,7 +1137,7 @@ var _ = Describe("Matcher", func() { It("handles very short songs with close duration", func() { songs := []agents.Song{ - {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + {Name: "Short Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 30000}, } shortTrack := model.MediaFile{ ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, @@ -1155,8 +1155,8 @@ var _ = Describe("Matcher", func() { It("matches same title+artist songs to their own closest-duration track", func() { songs := []agents.Song{ - {Name: "Same Song", Artist: "Same Artist", Duration: 180000}, - {Name: "Same Song", Artist: "Same Artist", Duration: 240000}, + {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 180000}, + {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 240000}, } shortTrack := model.MediaFile{ ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, @@ -1185,10 +1185,10 @@ var _ = Describe("Matcher", func() { It("handles mixed scenario with both identical and different input songs", func() { songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "1"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday (Anthology)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Anthology"}, } libraryTrack := model.MediaFile{ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", @@ -1207,9 +1207,9 @@ var _ = Describe("Matcher", func() { It("does not deduplicate songs that match different library tracks", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, } trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), @@ -1234,10 +1234,10 @@ var _ = Describe("Matcher", func() { It("respects count limit after deduplication", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song A (Live)", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song B (Remix)", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song A (Live)", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B (Remix)", Artists: []agents.Artist{{Name: "Artist"}}}, } trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go index 81739b726..813fceea9 100644 --- a/core/sonic/sonic_test.go +++ b/core/sonic/sonic_test.go @@ -101,7 +101,7 @@ var _ = Describe("Sonic", func() { provider := &mockProvider{ similarResults: []sonic.SimilarResult{ - {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85}, }, } loader.names = []string{"test-plugin"} diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index f3c26411c..b565ef6f2 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -227,26 +227,29 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } -// songRefToAgentSong converts a single SongRef to agents.Song +// songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single +// Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead +// of the artists array, they are folded into a one-element Artists list here. func songRefToAgentSong(s capabilities.SongRef) agents.Song { var artists []agents.Artist - if len(s.Artists) > 0 { + switch { + case len(s.Artists) > 0: artists = make([]agents.Artist, len(s.Artists)) for i, a := range s.Artists { artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID} } + case s.Artist != "" || s.ArtistMBID != "": + artists = []agents.Artist{{Name: s.Artist, MBID: s.ArtistMBID}} } return agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Artists: artists, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artists: artists, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: uint32(s.Duration * 1000), } } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index be9d309ae..a14db5d34 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -119,7 +119,8 @@ var _ = Describe("MetadataAgent", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) Expect(songs[0].Name).To(Equal("Similar to Yesterday #1")) - Expect(songs[0].Artist).To(Equal("The Beatles")) + Expect(songs[0].Artists).To(HaveLen(1)) + Expect(songs[0].Artists[0].Name).To(Equal("The Beatles")) }) }) @@ -343,11 +344,18 @@ var _ = Describe("songRefToAgentSong multi-artist", func() { {Name: "Future", MBID: "m-future"}, })) }) - It("leaves Artists nil and keeps the single Artist when no Artists provided", func() { + It("folds the single Artist/ArtistMBID into a one-element Artists when no Artists provided", func() { ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"} got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{{Name: "Drake", MBID: "m-drake"}})) + }) + It("folds an MBID-only single artist (empty name) so the MBID is not dropped", func() { + ref := capabilities.SongRef{Name: "Solo", ArtistMBID: "m-drake"} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{{MBID: "m-drake"}})) + }) + It("leaves Artists nil when neither Artists nor the single Artist/ArtistMBID are provided", func() { + got := songRefToAgentSong(capabilities.SongRef{Name: "Anon"}) Expect(got.Artists).To(BeNil()) - Expect(got.Artist).To(Equal("Drake")) - Expect(got.ArtistMBID).To(Equal("m-drake")) }) }) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index 1b8d4efa4..52079732b 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -43,7 +43,8 @@ var _ = Describe("SonicSimilarityPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) - Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Song.Artists).To(HaveLen(1)) + Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) Expect(results[0].Similarity).To(Equal(1.0)) Expect(results[1].Similarity).To(Equal(0.9)) Expect(results[2].Similarity).To(Equal(0.8)) @@ -68,7 +69,8 @@ var _ = Describe("SonicSimilarityPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) - Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Song.Artists).To(HaveLen(1)) + Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) Expect(results[0].Similarity).To(Equal(1.0)) Expect(results[1].Similarity).To(Equal(0.95)) Expect(results[2].Similarity).To(Equal(0.9)) From 13e96a0e815546d1de986a95783325e0cac3678c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 27 Jun 2026 10:37:25 -0400 Subject: [PATCH 315/596] fix(lyrics): correct TTML background-vocal cue timing and whitespace (#5672) * fix(lyrics): correct TTML background-vocal cue timing and whitespace Two parsing defects surfaced by Apple Music TTML files that mix a main vocal with an x-bg (background) span group within the same line: - Cue end-time normalization ran over the whole line's cue list in document order. Background cues are stored after the main cues but interleave earlier on the timeline, so the next-cue clamp collapsed the last main cue's end down to its own start (start == end). End times are now normalized per agent group, matching how the Subsonic serializer already groups cues, so parallel layers no longer corrupt each other. - Whitespace between elements was treated as significant: pretty-printed (indented) TTML injected spurious newlines into the line text, turning one line into many. Per TTML2 default xml:space handling (linefeeds treat-as-space, whitespace-collapse), formatting whitespace now collapses to a single space and hard line breaks come only from <br/>. The line-level value and per-agent cueLine.value remain the full line text, as required by the OpenSubsonic songLyrics v2 contract; the per-agent text is carried in each cueLine's cue[] array. Two existing tests that encoded the buggy newline-as-break behavior are corrected; new tests cover whitespace collapse, <br/> preservation, and interleaved background cue timing. * fix(lyrics): only collapse XML whitespace, preserve other Unicode spaces Whitespace collapsing used unicode.IsSpace, which matches more than the XML S production (space, tab, CR, LF): it also folds characters like NBSP and U+3000 into a regular space, silently altering content. Restrict collapsing to the four XML whitespace characters so other Unicode spaces pass through unchanged, and add a regression test. Also clarify the doc comment that collapsing is applied unconditionally (xml:space="preserve" is not supported). --- model/lyrics_normalize.go | 39 +++++++++++- model/lyrics_ttml.go | 80 +++++++++++++++--------- model/lyrics_ttml_test.go | 126 +++++++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 31 deletions(-) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go index 276aa4da3..a7d4b4e2a 100644 --- a/model/lyrics_normalize.go +++ b/model/lyrics_normalize.go @@ -85,10 +85,47 @@ func normalizeCueLine(line Line, fallbackEnd *int64) Line { if len(line.Cue) == 0 { return line } - line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) + line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd) return normalizeLineTiming(line) } +// normalizeCueEndsByAgent resolves cue end times independently per agent so that +// background (or other parallel) layers, whose cues interleave with the main +// timeline but are stored together in document order, do not clamp each other's +// ends. Each agent group is normalized in its own document order; results are +// reassembled into the original cue positions. +func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue { + groups := make(map[string][]int) + order := make([]string, 0, 2) + for i := range cues { + id := cues[i].AgentID + if _, ok := groups[id]; !ok { + order = append(order, id) + } + groups[id] = append(groups[id], i) + } + + // Single agent: the document order already matches the timeline, so the + // straightforward normalization applies without regrouping. + if len(order) <= 1 { + return NormalizeCueEnds(cues, fallbackEnd) + } + + out := slices.Clone(cues) + for _, id := range order { + idxs := groups[id] + group := make([]Cue, len(idxs)) + for gi, pos := range idxs { + group[gi] = cues[pos] + } + group = NormalizeCueEnds(group, fallbackEnd) + for gi, pos := range idxs { + out[pos] = group[gi] + } + } + return out +} + // NormalizeCueEnds resolves missing cue end times within a single ordered cue // group: each end is filled from the next cue's start, then from fallbackEnd, // and is clamped so it never precedes the cue's own start nor overruns the next diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go index 95aab3485..43d4699af 100644 --- a/model/lyrics_ttml.go +++ b/model/lyrics_ttml.go @@ -77,8 +77,9 @@ type ttmlDefinedAgent struct { } type ttmlPiece struct { - raw string - cue *Cue + raw string + cue *Cue + isBreak bool } type ttmlParser struct { @@ -382,7 +383,7 @@ func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, er func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) { local := strings.ToLower(start.Name.Local) if local == "br" { - return []ttmlPiece{{raw: "\n"}}, nil + return []ttmlPiece{{isBreak: true}}, nil } ctx := p.childContext(start.Attr, parent) @@ -442,7 +443,7 @@ func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimin } func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { - finalized := finalizeTTMLLines(splitTTMLPiecesByNewline(pieces)) + finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces)) for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { finalized = finalized[1:] } @@ -488,34 +489,30 @@ func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine { return finalized } -func splitTTMLPiecesByNewline(pieces []ttmlPiece) [][]ttmlPiece { +func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece { lines := [][]ttmlPiece{{}} + prevEndedWithSpace := true // leading whitespace on a fresh line is dropped for _, piece := range pieces { - raw := normalizeTTMLPieceRaw(piece.raw) - if raw == "" { + if piece.isBreak { + lines = append(lines, []ttmlPiece{}) + prevEndedWithSpace = true continue } - start := 0 - for i := 0; i < len(raw); i++ { - if raw[i] != '\n' { - continue - } - if start < i { - lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ - raw: raw[start:i], - cue: gg.Clone(piece.cue), - }) - } - lines = append(lines, []ttmlPiece{}) - start = i + 1 + raw := normalizeTTMLPieceRaw(piece.raw) + // Collapse whitespace across piece boundaries: a piece's leading space is + // redundant when the text emitted so far already ends with one. + if prevEndedWithSpace { + raw = strings.TrimPrefix(raw, " ") } - if start < len(raw) { - lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ - raw: raw[start:], - cue: gg.Clone(piece.cue), - }) + if raw == "" { + continue } + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw, + cue: gg.Clone(piece.cue), + }) + prevEndedWithSpace = strings.HasSuffix(raw, " ") } return lines } @@ -555,11 +552,38 @@ func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) { return trimmed, cues } +// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode +// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that +// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so +// linefeeds and other whitespace runs collapse to a single space. Collapsing is +// applied unconditionally; xml:space="preserve" is not supported (no lyric +// source in practice relies on it). Hard line breaks come only from <br/> +// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed +// indentation between elements does not inject spurious newlines. func normalizeTTMLPieceRaw(raw string) string { raw = str.SanitizeText(raw) - raw = strings.ReplaceAll(raw, "\r\n", "\n") - raw = strings.ReplaceAll(raw, "\r", "\n") - return raw + return collapseTTMLWhitespace(raw) +} + +func collapseTTMLWhitespace(raw string) string { + var b strings.Builder + b.Grow(len(raw)) + prevSpace := false + for _, r := range raw { + // Only the XML S production (space, tab, CR, LF) is collapsible whitespace. + // Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not + // whitespace, so they pass through unchanged. + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + } + return b.String() } func concatTTMLPieceRaw(pieces []ttmlPiece) string { diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go index a175eef0e..bbdf5c7c4 100644 --- a/model/lyrics_ttml_test.go +++ b/model/lyrics_ttml_test.go @@ -135,7 +135,7 @@ var _ = Describe("parseTTML", func() { line := list[0].Line[0] Expect(line.Start).To(Equal(new(int64(1000)))) - Expect(line.Value).To(Equal("Hello\necho")) + Expect(line.Value).To(Equal("Hello echo")) Expect(line.End).To(Equal(new(int64(3000)))) Expect(line.Cue).To(HaveLen(3)) @@ -269,6 +269,128 @@ var _ = Describe("parseTTML", func() { }) }) + Describe("Whitespace handling", func() { + It("should collapse pretty-print indentation between spans into single spaces, not line breaks", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <body xml:lang="eng"> + <div> + <p begin="1:22.889" end="1:26.859" ttm:agent="v2"> + <span begin="1:22.889" end="1:23.127">It</span> + <span begin="1:23.374" end="1:23.938">in,</span> + <span ttm:role="x-bg"> + <span begin="1:23.881" end="1:24.243">(When you</span> + <span begin="1:26.232" end="1:26.859">slide)</span> + </span> + </p> + </div> + </body> +</tt>`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Value).To(Equal("It in, (When you slide)")) + Expect(line.Value).ToNot(ContainSubstring("\n")) + Expect(line.Cue).To(HaveLen(4)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(82889)), End: new(int64(83127)), Value: "It", ByteStart: 0, ByteEnd: 1, AgentID: "v2"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(83374)), End: new(int64(83938)), Value: "in,", ByteStart: 3, ByteEnd: 5, AgentID: "v2"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(83881)), End: new(int64(84243)), Value: "(When you", ByteStart: 7, ByteEnd: 15, AgentID: "__nd_bg__|v2"})) + Expect(line.Cue[3]).To(Equal(Cue{Start: new(int64(86232)), End: new(int64(86859)), Value: "slide)", ByteStart: 17, ByteEnd: 22, AgentID: "__nd_bg__|v2"})) + }) + + It("should preserve explicit <br/> as a line break", func() { + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <body xml:lang="eng"> + <div> + <p begin="00:01.000" end="00:03.000"> + <span begin="00:01.000" end="00:01.400">first</span> + <br/> + <span begin="00:02.000" end="00:02.500">second</span> + </p> + </div> + </body> +</tt>`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("first\nsecond")) + }) + + It("should only collapse XML whitespace, leaving other Unicode spaces intact", func() { + // Whitespace collapsing only touches the XML S characters + // (space/tab/CR/LF). Other Unicode spaces like U+3000 are left as-is: + // the U+3000 inside a span survives, while the pretty-print newline + // between spans still collapses to a single space. + content := []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + + "<tt xmlns=\"http://www.w3.org/ns/ttml\">\n" + + " <body xml:lang=\"jpn\">\n" + + " <div>\n" + + " <p begin=\"00:01.000\" end=\"00:03.000\">\n" + + " <span begin=\"00:01.000\" end=\"00:01.400\">あ い</span>\n" + + " <span begin=\"00:02.000\" end=\"00:02.500\">う</span>\n" + + " </p>\n" + + " </div>\n" + + " </body>\n" + + "</tt>") + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("あ い う")) + Expect(list[0].Line[0].Cue[0].Value).To(Equal("あ い")) + }) + }) + + Describe("Interleaved background cue timing", func() { + It("should not corrupt a main cue's end time when a background cue is earlier in time", func() { + // Background spans (x-bg) appear after the main spans in document order + // but their timings interleave with the main timeline. End-time + // normalization must be per agent so the last main cue keeps its real + // end instead of collapsing to its own start. + content := []byte(`<?xml version="1.0" encoding="UTF-8"?> +<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata"> + <body xml:lang="eng"> + <div> + <p begin="1:22.889" end="1:26.859" ttm:agent="v2"><span begin="1:25.593" end="1:25.934">real</span> <span begin="1:25.934" end="1:26.751">slow</span> <span ttm:role="x-bg"><span begin="1:23.881" end="1:24.243">(When you</span> <span begin="1:26.232" end="1:26.859">slide)</span></span></p> + </div> + </body> +</tt>`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Cue).To(HaveLen(4)) + + cuesByAgent := map[string][]Cue{} + for _, c := range line.Cue { + cuesByAgent[c.AgentID] = append(cuesByAgent[c.AgentID], c) + } + + mainCues := cuesByAgent["v2"] + Expect(mainCues).To(HaveLen(2)) + Expect(*mainCues[0].End).To(Equal(int64(85934))) // "real" + // "slow" must keep its real end (86751), not collapse to its start. + Expect(*mainCues[1].Start).To(Equal(int64(85934))) + Expect(*mainCues[1].End).To(Equal(int64(86751))) + + bgCues := cuesByAgent["__nd_bg__|v2"] + Expect(bgCues).To(HaveLen(2)) + Expect(*bgCues[0].End).To(Equal(int64(84243))) // "(When you" + Expect(*bgCues[1].End).To(Equal(int64(86859))) // "slide)" + }) + }) + Describe("Ambiguous decimal timing", func() { It("should prefer absolute timing when values fall inside parent window", func() { content := []byte(`<?xml version="1.0" encoding="UTF-8"?> @@ -290,7 +412,7 @@ var _ = Describe("parseTTML", func() { line := list[0].Line[0] Expect(line.Start).To(Equal(new(int64(43444)))) - Expect(line.Value).To(Equal("go\ngo")) + Expect(line.Value).To(Equal("go go")) Expect(line.End).To(Equal(new(int64(45570)))) Expect(line.Cue).To(HaveLen(2)) Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1})) From 11f5441eb5e5ab0ca1f43b82d60489bc19b4844b Mon Sep 17 00:00:00 2001 From: Jorge Pardo <78924065+J0R6IT0@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:11:08 +0200 Subject: [PATCH 316/596] fix(lyrics): consider trailing timestamp in ELRC lyrics (#5677) * fix: take into account trailing timestamp in elrc * refactor: slightly simplify the loop --- model/lyrics_lrc.go | 8 ++++++++ model/lyrics_lrc_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go index 6fde0c8f5..2cccb9d51 100644 --- a/model/lyrics_lrc.go +++ b/model/lyrics_lrc.go @@ -189,6 +189,7 @@ func parseEnhancedLine(text string) (string, []Cue) { segments := make([]segment, 0, len(matches)) var rawValue strings.Builder + var trailingEnd *int64 for i, match := range matches { timeMs, err := parseTime( // Rewrite <...> as [...] so parseTime can handle it with the same logic @@ -217,6 +218,9 @@ func parseEnhancedLine(text string) (string, []Cue) { word := text[textStart:textEnd] if word == "" { + if i == len(matches)-1 { + trailingEnd = &timeMs + } continue } @@ -258,6 +262,10 @@ func parseEnhancedLine(text string) (string, []Cue) { }) } + if trailingEnd != nil && len(cues) > 0 { + cues[len(cues)-1].End = trailingEnd + } + return strings.TrimSpace(finalRaw), cues } diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go index 38f03587a..87514caf1 100644 --- a/model/lyrics_lrc_test.go +++ b/model/lyrics_lrc_test.go @@ -188,6 +188,42 @@ var _ = Describe("parseLRC", func() { })) }) + It("should use a trailing Enhanced LRC marker as the end of the last word", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics<00:02.00>\n[00:30.00]Instrumental over") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000 := int64(1000), int64(1500), int64(2000) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Some lyrics")) + Expect(line.End).To(Equal(&t2000)) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + }) + + It("should shift a trailing Enhanced LRC marker for repeated line occurrences", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world<00:10.90>") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10100, t10500, t10900 := int64(10100), int64(10500), int64(10900) + t30100, t30500, t30900 := int64(30100), int64(30500), int64(30900) + + Expect(lyrics.Line[0].End).To(Equal(&t10900)) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t10900, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].End).To(Equal(&t30900)) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, End: &t30500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, End: &t30900, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") Expect(err).ToNot(HaveOccurred()) From a1412ef1c27277a18d8e8aa0767a1d98086883d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 28 Jun 2026 10:39:38 -0400 Subject: [PATCH 317/596] fix(ui): bump navidrome-music-player to 4.25.3, fix transient wrong-song jump (#5676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a transient jump to the wrong song when switching the play queue. When a new queue was loaded at a non-zero index (e.g. playing a different album/playlist from a track other than the first, or playing a new album after closing the player), the web player briefly loaded and played the track that sat at the *previous* internal index in the new queue before correcting to the chosen one — an audible "skip to a random song, then back to the song I chose". The root cause was in the player library: when loading a new audio list, the initial track was picked using the stale internal play index instead of the requested playIndex. Fixed in navidrome-music-player 4.25.3 (navidrome/react-music-player), which derives the initial track from the requested playIndex. --- ui/package-lock.json | 47 ++++++++++++++++++++++++++++++++++++++++---- ui/package.json | 2 +- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 1f95f14f8..54cfe8b1c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.2", + "navidrome-music-player": "4.25.3", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", @@ -129,6 +129,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1743,6 +1744,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1766,6 +1768,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2471,6 +2474,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2524,6 +2528,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2539,6 +2544,7 @@ "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2585,6 +2591,7 @@ "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -3308,6 +3315,7 @@ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", + "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3360,6 +3368,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3381,6 +3390,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3550,6 +3560,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3883,6 +3894,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4567,6 +4579,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4942,6 +4955,7 @@ "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5822,6 +5836,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6405,6 +6420,7 @@ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -6421,6 +6437,7 @@ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", + "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6835,6 +6852,7 @@ "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6948,6 +6966,7 @@ "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -8550,6 +8569,7 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -8593,9 +8613,9 @@ "license": "MIT" }, "node_modules/navidrome-music-player": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.2.tgz", - "integrity": "sha512-k7RXHOOKHeJRCsfmpmQ+TkErndckFfvYMjzwVAKZvViw2PL9ubKWziPfruHZVQr4FiJd2oYKEuTNiWZgAK87CA==", + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.3.tgz", + "integrity": "sha512-0T87Mmbs6ls9g9CgwJwXBL7X11u0+6znBFJTG8HUPgBT0r8W6epVKqJhfdg8vqnT86QDByvvb1Hxp/W2M7Yacw==", "license": "MIT", "dependencies": { "@react-icons/all-files": "^4.1.0", @@ -9298,6 +9318,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9389,6 +9410,7 @@ "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", + "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9803,6 +9825,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9884,6 +9907,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9956,6 +9980,7 @@ "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -9973,6 +9998,7 @@ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -10078,6 +10104,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -10113,6 +10140,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10133,6 +10161,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10314,6 +10343,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -10323,6 +10353,7 @@ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", + "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -10582,6 +10613,7 @@ "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11511,6 +11543,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11732,6 +11765,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11976,6 +12010,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12100,6 +12135,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12113,6 +12149,7 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", @@ -12632,6 +12669,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12741,6 +12779,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/package.json b/ui/package.json index b440f0595..20f4324f7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,7 +32,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.2", + "navidrome-music-player": "4.25.3", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", From 5a3ac80a8a9dc5229a1dc0fd3105b88265517f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 28 Jun 2026 13:26:32 -0400 Subject: [PATCH 318/596] feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): export ReadPackageManifest and ValidatePackage for off-disk inspection * test(plugins): cover ValidatePackage cross-field validation branch * feat(cmd): add 'plugin list' command * refactor(cmd): drop premature pluginManager interface until first use * feat(cmd): add 'plugin enable' and 'plugin disable' commands * feat(cmd): add 'plugin edit' for config and permission updates * test(cmd): cover plugin edit aborting on config validation failure * feat(cmd): add 'plugin info' and 'plugin validate' (installed id or .ndp file) * test(cmd): unit-test formatManifestInfo nil-guard and json branches * feat(cmd): add 'plugin rescan' command * feat(cmd): enrich plugin info text output and re-validate config on validate * fix(cmd): omit zero-value timestamps in plugin info text output * fix(cmd): give plugin list and info separate format flag vars A shared package-global bound to both commands' --format flag caused the later init() registration (info, default "text") to clobber the earlier one (list, default "table"), so 'plugin list' with no -f failed with 'invalid output format "text"'. Split into pluginListFormat / pluginInfoFormat and add a regression test on the registered defaults. * fix(plugins): address code-review findings on plugin CLI - edit: read-modify-write merge so flipping one permission flag no longer wipes unspecified fields (matches the native API); reject non-JSON --users/--libraries values. - info: return an error on unknown --format (was silently text); include sha256 in off-disk JSON output; align the text columns. - move declaredPermissions onto plugins.Permissions.DeclaredNames() as the single source of truth; drop ValidatePackage's redundant Validate call. - readConfigFile: pass ctx to log.Fatal; copy flag globals before taking their address; trim comments that restated the code. * refactor(plugins): export ReadManifest/ComputeFileSHA256, drop thin CLI wrappers Remove the ReadPackageManifest/ComputeFileSHA256/ValidatePackage wrappers in favor of exporting the underlying readManifest->ReadManifest and computeFileSHA256->ComputeFileSHA256 directly. ValidatePackage was identical to ReadPackageManifest (readManifest already validates via ParseManifest), so the CLI's validate path now calls ReadManifest too. * test(plugins): consolidate ReadManifest specs and move ComputeFileSHA256 tests Merge the two ReadManifest Describe blocks into one, and move the ComputeFileSHA256 tests out of package_test.go into a new manager_sync_test.go (where the function now lives), deduping the overlapping hash specs. * test(plugins): use createTestPackage everywhere, drop writeNDP helper The schema-invalid and cross-field ReadManifest specs now build packages with createTestPackage (typed Manifest) instead of the raw-JSON writeNDP helper, so there is a single package-building helper. Also trims the gratuitous 1MB wasm buffer in the read-only spec to a few bytes. * test(plugins): drop duplicate missing-manifest spec from ReadManifest The openPackage block already covers the missing-manifest.json error path (shared zip-walk code); ReadManifest's identical copy added no distinct coverage. * test(plugins): fix misleading ReadManifest spec name and drop unused wasm bytes The spec was named 'should read only the manifest without loading wasm' but ReadManifest returns only (*Manifest, error) — it cannot assert whether wasm was loaded. Rename to what it actually verifies (manifest parses from a package that also contains a wasm entry) and pass nil wasm bytes, since the contents are never observed. * fix(cmd): address PR review feedback on plugin CLI - edit --users/--libraries now accept both comma-separated and JSON-array input (CSV is converted to the JSON the manager stores); help text documents both. - Permissions.DeclaredNames() derives names by reflecting over the generated struct's json tags instead of a hand-maintained list, so new permission types are picked up automatically. - isPackagePath checks only the .ndp suffix, so a mistyped package path yields a precise 'no such file' error instead of falling back to a misleading 'Plugin not found'. - Restore a ReadManifest test for the missing-manifest.json error path (it has its own branch, separate from openPackage). * docs(cmd): trim verbose comments to the why * fix(cmd): setting an explicit users/libraries list clears the all-* flag Per Codex review: with allUsers/allLibraries true, 'plugin edit --users <list>' preserved the all-* flag so the allow-list was silently ignored, and the mutually-exclusive flags meant it couldn't be corrected in one command. An explicit list now implies all-*=false. --- cmd/plugin.go | 558 +++++++++++++++++++++++++++++++++++ cmd/plugin_test.go | 360 ++++++++++++++++++++++ plugins/manager.go | 6 +- plugins/manager_loader.go | 4 +- plugins/manager_sync.go | 6 +- plugins/manager_sync_test.go | 30 ++ plugins/manager_watcher.go | 2 +- plugins/manifest.go | 27 ++ plugins/manifest_test.go | 24 ++ plugins/package.go | 7 +- plugins/package_test.go | 65 ++-- 11 files changed, 1046 insertions(+), 43 deletions(-) create mode 100644 cmd/plugin.go create mode 100644 cmd/plugin_test.go create mode 100644 plugins/manager_sync_test.go diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 000000000..6cce8ea5f --- /dev/null +++ b/cmd/plugin.go @@ -0,0 +1,558 @@ +package cmd + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/spf13/cobra" +) + +// pluginManager is the subset of *plugins.Manager the CLI needs. +type pluginManager interface { + EnablePlugin(ctx context.Context, id string) error + DisablePlugin(ctx context.Context, id string) error + ValidatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error + UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error + RescanPlugins(ctx context.Context) error +} + +var ( + pluginListFormat string + pluginInfoFormat string +) + +var ( + editConfig string + editConfigFile string + editUsers string + editAllUsers bool + editLibraries string + editAllLibs bool + editWriteAccess bool + editNoWrite bool +) + +func init() { + rootCmd.AddCommand(pluginRoot) + + pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]") + pluginRoot.AddCommand(pluginListCmd) + pluginRoot.AddCommand(pluginEnableCmd) + pluginRoot.AddCommand(pluginDisableCmd) + + pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON") + pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)") + pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file") + pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`) + pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users") + pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users") + pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`) + pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries") + pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries") + pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries") + pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries") + pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access") + pluginRoot.AddCommand(pluginEditCmd) + + pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]") + pluginRoot.AddCommand(pluginInfoCmd) + pluginRoot.AddCommand(pluginValidateCmd) + pluginRoot.AddCommand(pluginRescanCmd) +} + +var ( + pluginRoot = &cobra.Command{ + Use: "plugin", + Short: "Manage and inspect plugins", + Long: "List, inspect, enable, disable, configure, rescan, and validate plugins", + } + + pluginListCmd = &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Run: func(cmd *cobra.Command, args []string) { + runPluginList(cmd.Context()) + }, + } + + pluginEnableCmd = &cobra.Command{ + Use: "enable <id>", + Short: "Enable a plugin", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := enablePlugin(ctx, mgr, args[0]); err != nil { + log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err) + } + }, + } + + pluginDisableCmd = &cobra.Command{ + Use: "disable <id>", + Short: "Disable a plugin", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := disablePlugin(ctx, mgr, args[0]); err != nil { + log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err) + } + }, + } +) + +var ( + pluginInfoCmd = &cobra.Command{ + Use: "info <id|file.ndp>", + Short: "Show details for an installed plugin or a .ndp package", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runPluginInfo(cmd.Context(), args[0]) + }, + } + + pluginValidateCmd = &cobra.Command{ + Use: "validate <id|file.ndp>", + Short: "Validate an installed plugin or a .ndp package manifest", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runPluginValidate(cmd.Context(), args[0]) + }, + } +) + +// isPackagePath checks only the extension (not existence) so a mistyped path +// still routes to ReadManifest, which reports a precise "no such file" error. +func isPackagePath(arg string) bool { + return strings.HasSuffix(arg, plugins.PackageExtension) +} + +func formatPluginInfo(p *model.Plugin, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "text": + default: + return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) + } + name, version := manifestSummary(*p) + var sb strings.Builder + fmt.Fprintf(&sb, "ID: %s\n", p.ID) + fmt.Fprintf(&sb, "Name: %s\n", name) + fmt.Fprintf(&sb, "Version: %s\n", version) + fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled) + fmt.Fprintf(&sb, "Path: %s\n", p.Path) + fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256) + fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers) + fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries) + fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess) + if p.Users != "" { + fmt.Fprintf(&sb, "Users: %s\n", p.Users) + } + if p.Libraries != "" { + fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries) + } + if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil { + if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { + fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) + } + } + if !p.CreatedAt.IsZero() { + fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339)) + } + if !p.UpdatedAt.IsZero() { + fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339)) + } + if p.Config != "" { + fmt.Fprintf(&sb, "Config: %s\n", p.Config) + } + if p.LastError != "" { + fmt.Fprintf(&sb, "Last error: %s\n", p.LastError) + } + return sb.String(), nil +} + +func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(struct { + *plugins.Manifest + SHA256 string `json:"sha256"` + }{m, sha256}, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "text": + default: + return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) + } + var sb strings.Builder + fmt.Fprintf(&sb, "Name: %s\n", m.Name) + fmt.Fprintf(&sb, "Version: %s\n", m.Version) + fmt.Fprintf(&sb, "Author: %s\n", m.Author) + if m.Description != nil { + fmt.Fprintf(&sb, "Description: %s\n", *m.Description) + } + if m.Website != nil { + fmt.Fprintf(&sb, "Website: %s\n", *m.Website) + } + if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { + fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) + } + fmt.Fprintf(&sb, "SHA256: %s\n", sha256) + return sb.String(), nil +} + +func runPluginInfo(ctx context.Context, arg string) { + if isPackagePath(arg) { + m, err := plugins.ReadManifest(arg) + if err != nil { + log.Fatal(ctx, "Failed to read package", "path", arg, err) + } + sha, err := plugins.ComputeFileSHA256(arg) + if err != nil { + log.Fatal(ctx, "Failed to hash package", "path", arg, err) + } + out, err := formatManifestInfo(m, sha, pluginInfoFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) + return + } + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + p, err := ds.Plugin(ctx).Get(arg) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", arg, err) + } + out, err := formatPluginInfo(p, pluginInfoFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) +} + +func runPluginValidate(ctx context.Context, arg string) { + if isPackagePath(arg) { + if _, err := plugins.ReadManifest(arg); err != nil { + log.Fatal(ctx, "Validation failed", "path", arg, err) + } + fmt.Printf("%s: OK\n", arg) + return + } + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + p, err := ds.Plugin(ctx).Get(arg) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", arg, err) + } + if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil { + log.Fatal(ctx, "Validation failed", "id", arg, err) + } + if p.Config != "" { + mgr := GetPluginManager(ctx) + if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil { + log.Fatal(ctx, "Config validation failed", "id", arg, err) + } + } + fmt.Printf("%s: OK\n", arg) +} + +// manifestSummary extracts the display name and version from a stored manifest JSON, falling +// back to the plugin ID when the manifest can't be parsed. +func manifestSummary(p model.Plugin) (name, version string) { + var m struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil { + return p.ID, "" + } + return m.Name, m.Version +} + +func formatPluginList(list model.Plugins, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "csv": + var sb strings.Builder + w := csv.NewWriter(&sb) + _ = w.Write([]string{"id", "name", "version", "enabled", "last error"}) + for _, p := range list { + name, version := manifestSummary(p) + _ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError}) + } + w.Flush() + return sb.String(), w.Error() + case "table": + var sb strings.Builder + w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR") + for _, p := range list { + name, version := manifestSummary(p) + fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError) + } + w.Flush() + return sb.String(), nil + default: + return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format) + } +} + +func runPluginList(ctx context.Context) { + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + list, err := ds.Plugin(ctx).GetAll() + if err != nil { + log.Fatal(ctx, "Failed to list plugins", err) + } + out, err := formatPluginList(list, pluginListFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) +} + +// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp +// inspection deliberately skips this so it works without a configured server. +func requirePluginsEnabled(ctx context.Context) { + if !conf.Server.Plugins.Enabled { + log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)") + } +} + +func enablePlugin(ctx context.Context, mgr pluginManager, id string) error { + return mgr.EnablePlugin(ctx, id) +} + +func disablePlugin(ctx context.Context, mgr pluginManager, id string) error { + return mgr.DisablePlugin(ctx, id) +} + +type pluginEditOptions struct { + config *string // nil = leave unchanged + users *string + allUsers *bool + libraries *string + allLibraries *bool + writeAccess *bool +} + +var pluginEditCmd = &cobra.Command{ + Use: "edit <id>", + Short: "Update a plugin's config and/or permissions", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + ds, ctx := getAdminContext(cmd.Context()) + cur, err := ds.Plugin(ctx).Get(args[0]) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", args[0], err) + } + mgr := GetPluginManager(ctx) + opts := buildEditOptionsFromFlags(ctx, cmd) + if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil { + log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err) + } + }, +} + +func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions { + var opts pluginEditOptions + switch { + case cmd.Flags().Changed("config"): + c := editConfig + opts.config = &c + case cmd.Flags().Changed("config-file"): + c := readConfigFile(ctx, editConfigFile) + opts.config = &c + } + if cmd.Flags().Changed("users") { + u := editUsers + opts.users = &u + } + if cmd.Flags().Changed("all-users") { + v := editAllUsers + opts.allUsers = &v + } + if cmd.Flags().Changed("libraries") { + l := editLibraries + opts.libraries = &l + } + if cmd.Flags().Changed("all-libraries") { + v := editAllLibs + opts.allLibraries = &v + } + if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") { + // write-access is part of the library-permission group, so it is updated + // alongside the (preserved) library list rather than on its own. + wa := editWriteAccess && !editNoWrite + opts.writeAccess = &wa + } + return opts +} + +func readConfigFile(ctx context.Context, path string) string { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + log.Fatal(ctx, "Failed to read config file", "path", path, err) + } + return string(data) +} + +// applyPluginEdit applies the requested changes on top of the plugin's current +// state. Like the native API, it reads the existing users/libraries before +// updating so that flipping one flag (e.g. --write-access) does not wipe +// unspecified fields, and rejects non-JSON users/libraries values. +func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error { + if opts.config == nil && opts.users == nil && opts.allUsers == nil && + opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil { + return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access") + } + id := cur.ID + if opts.config != nil { + if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil { + return err + } + } + if opts.users != nil || opts.allUsers != nil { + users, allUsers := cur.Users, cur.AllUsers + if opts.users != nil { + parsed, err := usersToJSON(*opts.users) + if err != nil { + return err + } + users = parsed + allUsers = false // an explicit list means "restrict to these users" + } + if opts.allUsers != nil { + allUsers = *opts.allUsers + } + if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil { + return err + } + } + if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil { + libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess + if opts.libraries != nil { + parsed, err := librariesToJSON(*opts.libraries) + if err != nil { + return err + } + libs = parsed + allLibs = false // an explicit list means "restrict to these libraries" + } + if opts.allLibraries != nil { + allLibs = *opts.allLibraries + } + if opts.writeAccess != nil { + writeAccess = *opts.writeAccess + } + if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil { + return err + } + } + return nil +} + +// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated +// list and returns the JSON-array form the manager stores. +func usersToJSON(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "[]", nil + } + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if !json.Valid([]byte(value)) { + return "", fmt.Errorf("invalid JSON in --users") + } + return value, nil + } + var names []string + for _, u := range strings.Split(value, ",") { + if u = strings.TrimSpace(u); u != "" { + names = append(names, u) + } + } + b, _ := json.Marshal(names) + return string(b), nil +} + +// librariesToJSON accepts either a JSON array (starts with '[') or a +// comma-separated list of integer IDs and returns the JSON-array form stored. +func librariesToJSON(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "[]", nil + } + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if !json.Valid([]byte(value)) { + return "", fmt.Errorf("invalid JSON in --libraries") + } + return value, nil + } + ids := []int{} + for _, l := range strings.Split(value, ",") { + if l = strings.TrimSpace(l); l != "" { + id, err := strconv.Atoi(l) + if err != nil { + return "", fmt.Errorf("invalid library ID %q: must be an integer", l) + } + ids = append(ids, id) + } + } + b, _ := json.Marshal(ids) + return string(b), nil +} + +var pluginRescanCmd = &cobra.Command{ + Use: "rescan", + Short: "Re-discover plugins in the plugins folder", + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := rescanPlugins(ctx, mgr); err != nil { + log.Fatal(ctx, "Failed to rescan plugins", err) + } + }, +} + +func rescanPlugins(ctx context.Context, mgr pluginManager) error { + return mgr.RescanPlugins(ctx) +} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go new file mode 100644 index 000000000..b6233057f --- /dev/null +++ b/cmd/plugin_test.go @@ -0,0 +1,360 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var samplePlugins = model.Plugins{ + {ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}, + {ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"}, +} + +var _ = Describe("plugin command format flags", func() { + // Regression: list and info must not share a format variable. Binding the + // same var on both commands makes the last init() registration clobber the + // other's default, breaking `plugin list` with no -f flag. + It("defaults `list -f` to table", func() { + Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table")) + }) + + It("defaults `info -f` to text", func() { + Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text")) + }) +}) + +var _ = Describe("formatPluginList", func() { + It("renders csv with a header and one row per plugin", func() { + out, err := formatPluginList(samplePlugins, "csv") + Expect(err).ToNot(HaveOccurred()) + lines := strings.Split(strings.TrimSpace(out), "\n") + Expect(lines).To(HaveLen(3)) // header + 2 rows + Expect(lines[0]).To(ContainSubstring("id")) + Expect(out).To(ContainSubstring("alpha")) + Expect(out).To(ContainSubstring("beta")) + }) + + It("renders valid json", func() { + out, err := formatPluginList(samplePlugins, "json") + Expect(err).ToNot(HaveOccurred()) + var got []map[string]any + Expect(json.Unmarshal([]byte(out), &got)).To(Succeed()) + Expect(got).To(HaveLen(2)) + }) + + It("renders a human table by default", func() { + out, err := formatPluginList(samplePlugins, "table") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("Alpha")) + Expect(out).To(ContainSubstring("1.0.0")) + }) + + It("errors on an unknown format", func() { + _, err := formatPluginList(samplePlugins, "yaml") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("enable/disable plugin", func() { + It("calls EnablePlugin on the manager", func() { + mgr := &tests.MockPluginManager{} + err := enablePlugin(context.Background(), mgr, "alpha") + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"})) + }) + + It("calls DisablePlugin on the manager", func() { + mgr := &tests.MockPluginManager{} + err := disablePlugin(context.Background(), mgr, "beta") + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"})) + }) +}) + +var _ = Describe("applyPluginEdit", func() { + var cur *model.Plugin + BeforeEach(func() { + cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true} + }) + + It("validates then updates config when config is provided", func() { + mgr := &tests.MockPluginManager{} + cfg := `{"key":"val"}` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) + Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) + }) + + It("updates users with allUsers flag", func() { + mgr := &tests.MockPluginManager{} + all := true + err := applyPluginEdit(context.Background(), mgr, cur, + pluginEditOptions{allUsers: &all}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) + }) + + It("updates libraries with allLibraries and write access", func() { + mgr := &tests.MockPluginManager{} + all := true + wr := true + err := applyPluginEdit(context.Background(), mgr, cur, + pluginEditOptions{allLibraries: &all, writeAccess: &wr}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue()) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue()) + }) + + It("preserves existing fields when only the write-access flag changes", func() { + mgr := &tests.MockPluginManager{} + no := false + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped + Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse()) + }) + + It("preserves existing users when only the all-users flag changes", func() { + mgr := &tests.MockPluginManager{} + all := true + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped + }) + + It("parses a comma-separated users value into a JSON array", func() { + mgr := &tests.MockPluginManager{} + users := "alice, bob" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) + }) + + It("passes a JSON-array users value through unchanged", func() { + mgr := &tests.MockPluginManager{} + users := `["alice","bob"]` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) + }) + + It("rejects a malformed JSON-array users value", func() { + mgr := &tests.MockPluginManager{} + users := `["alice"` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).To(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty()) + }) + + It("parses a comma-separated libraries value into a JSON array of ints", func() { + mgr := &tests.MockPluginManager{} + libs := "1, 2" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) + }) + + It("rejects a non-integer library ID", func() { + mgr := &tests.MockPluginManager{} + libs := "1,abc" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).To(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty()) + }) + + It("clears allUsers when an explicit users list is set", func() { + mgr := &tests.MockPluginManager{} + cur.AllUsers = true + users := "alice" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`)) + Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse()) + }) + + It("clears allLibraries when an explicit libraries list is set", func() { + mgr := &tests.MockPluginManager{} + cur.AllLibraries = true + libs := "1" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`)) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse()) + }) + + It("does nothing and errors when no fields are set", func() { + mgr := &tests.MockPluginManager{} + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{}) + Expect(err).To(HaveOccurred()) + }) + + It("aborts the config update when validation fails", func() { + mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")} + cfg := `{"key":"val"}` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) + Expect(err).To(HaveOccurred()) + Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty()) + }) +}) + +var _ = Describe("isPackagePath", func() { + It("is true for any .ndp path", func() { + Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue()) + }) + It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() { + Expect(isPackagePath("/nope/x.ndp")).To(BeTrue()) + }) + It("is false for a bare plugin id", func() { + Expect(isPackagePath("my-plugin")).To(BeFalse()) + }) +}) + +var _ = Describe("formatPluginInfo", func() { + It("renders installed plugin details as text", func() { + p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true} + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alpha")) + Expect(out).To(ContainSubstring("Alpha")) + }) + It("renders json", func() { + p := &model.Plugin{ID: "alpha", Manifest: `{}`} + out, err := formatPluginInfo(p, "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alpha")) + }) +}) + +var _ = Describe("formatManifestInfo", func() { + It("renders text with name, version, author", func() { + m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("My Plugin")) + Expect(out).To(ContainSubstring("2.0.0")) + Expect(out).To(ContainSubstring("me")) + }) + + It("omits Description and Website when nil", func() { + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Description:")) + Expect(out).ToNot(ContainSubstring("Website:")) + }) + + It("includes Description and Website when set", func() { + desc := "a cool plugin" + site := "https://example.com" + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("a cool plugin")) + Expect(out).To(ContainSubstring("https://example.com")) + }) + + It("renders valid json", func() { + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("\"name\"")) + }) +}) + +var _ = Describe("formatPluginInfo enriched text", func() { + var fixedTime time.Time + + BeforeEach(func() { + fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + }) + + It("includes Users, Libraries, Permissions, Created, Updated in text output", func() { + p := &model.Plugin{ + ID: "myplugin", + Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`, + Enabled: true, + Users: "alice,bob", + Libraries: "1,2", + CreatedAt: fixedTime, + UpdatedAt: fixedTime.Add(time.Hour), + } + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alice,bob")) + Expect(out).To(ContainSubstring("1,2")) + Expect(out).To(ContainSubstring("subsonicapi")) + Expect(out).To(ContainSubstring("users")) + Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z")) + }) + + It("omits Users and Libraries lines when empty", func() { + p := &model.Plugin{ + ID: "myplugin", + Manifest: `{"name":"X","version":"1.0.0","author":"me"}`, + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + } + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Users:")) + Expect(out).ToNot(ContainSubstring("Libraries:")) + }) + + It("does not alter json output", func() { + p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"} + out, err := formatPluginInfo(p, "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring(`"x"`)) + }) +}) + +var _ = Describe("formatManifestInfo enriched text", func() { + It("includes Permissions when declared", func() { + p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("http")) + Expect(out).To(ContainSubstring("Permissions:")) + }) + + It("omits Permissions line when no permissions declared", func() { + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Permissions:")) + }) + + It("does not alter json output", func() { + p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} + out, err := formatManifestInfo(m, "abc123", "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring(`"name"`)) + }) +}) + +var _ = Describe("rescanPlugins", func() { + It("calls RescanPlugins on the manager", func() { + mgr := &tests.MockPluginManager{} + err := rescanPlugins(context.Background(), mgr) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.RescanPluginsCalls).To(Equal(1)) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go index a7649d47e..b3a71ce6c 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -382,7 +382,7 @@ func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON strin return fmt.Errorf("getting plugin from DB: %w", err) } - manifest, err := readManifest(plugin.Path) + manifest, err := ReadManifest(plugin.Path) if err != nil { return fmt.Errorf("reading manifest: %w", err) } @@ -460,7 +460,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn shouldDisable := false disableReason := "" if wasEnabled { - manifest, err := readManifest(plugin.Path) + manifest, err := ReadManifest(plugin.Path) if err == nil && manifest.Permissions != nil { if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) { shouldDisable = true @@ -591,7 +591,7 @@ func (m *Manager) UnloadDisabledPlugins(ctx context.Context) { // before a plugin can be enabled. Returns an error if any gate condition fails. func (m *Manager) checkPermissionGates(p *model.Plugin) error { // Parse manifest to check permissions - manifest, err := readManifest(p.Path) + manifest, err := ReadManifest(p.Path) if err != nil { return fmt.Errorf("reading manifest: %w", err) } diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index ccda9e4cb..115c6c2b0 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -155,12 +155,12 @@ func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) { return nil, fmt.Errorf("manager is stopped") } - manifest, err := readManifest(ndpPath) + manifest, err := ReadManifest(ndpPath) if err != nil { return nil, err } - sha256Hash, err := computeFileSHA256(ndpPath) + sha256Hash, err := ComputeFileSHA256(ndpPath) if err != nil { return nil, fmt.Errorf("computing hash: %w", err) } diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index f97069e74..23d904309 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -36,9 +36,9 @@ func marshalManifest(m *Manifest) string { return string(b) } -// computeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. +// ComputeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. // This is used for quick change detection before full plugin compilation. -func computeFileSHA256(path string) (string, error) { +func ComputeFileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err @@ -165,7 +165,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { dbPlugin, exists := pluginsInDB[name] // Compute SHA256 first (lightweight operation) to check if plugin changed - sha256Hash, err := computeFileSHA256(path) + sha256Hash, err := ComputeFileSHA256(path) if err != nil { log.Error(ctx, "Failed to compute SHA256 for plugin", "plugin", name, "path", path, err) continue diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go new file mode 100644 index 000000000..e2adebfad --- /dev/null +++ b/plugins/manager_sync_test.go @@ -0,0 +1,30 @@ +package plugins + +import ( + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ComputeFileSHA256", func() { + It("returns a consistent 64-char lowercase hex hash for the same file", func() { + dir := GinkgoT().TempDir() + ndpPath := filepath.Join(dir, "test.ndp") + err := createTestPackage(ndpPath, &Manifest{Name: "S", Author: "a", Version: "1.0.0"}, []byte{0x00, 0x61, 0x73, 0x6d}) + Expect(err).ToNot(HaveOccurred()) + + hash1, err := ComputeFileSHA256(ndpPath) + Expect(err).ToNot(HaveOccurred()) + hash2, err := ComputeFileSHA256(ndpPath) + Expect(err).ToNot(HaveOccurred()) + + Expect(hash1).To(Equal(hash2)) + Expect(hash1).To(MatchRegexp(`^[0-9a-f]{64}$`)) + }) + + It("returns an error for a non-existent path", func() { + _, err := ComputeFileSHA256(filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp")) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index b7022b46e..d666d8620 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -158,7 +158,7 @@ func (m *Manager) processPluginEvent(pluginName string) { switch action { case actionUpdate: // File changed - check SHA256 first, then extract manifest if needed - sha256Hash, err := computeFileSHA256(ndpPath) + sha256Hash, err := ComputeFileSHA256(ndpPath) if err != nil { log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err) return diff --git a/plugins/manifest.go b/plugins/manifest.go index 7484718e3..990272855 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -3,10 +3,37 @@ package plugins import ( "encoding/json" "fmt" + "reflect" + "sort" + "strings" "github.com/santhosh-tekuri/jsonschema/v6" ) +// DeclaredNames returns the sorted names of the non-nil permission fields. It +// reflects over the generated json tags so new permission types are picked up +// automatically rather than via a hand-maintained list. +func (p *Permissions) DeclaredNames() []string { + if p == nil { + return nil + } + var names []string + v := reflect.ValueOf(*p) + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := v.Field(i) + if f.Kind() != reflect.Pointer || f.IsNil() { + continue + } + tag := t.Field(i).Tag.Get("json") + if name, _, _ := strings.Cut(tag, ","); name != "" && name != "-" { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + //go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json // ParseManifest unmarshals manifest JSON and performs cross-field validation. diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 0d371d14a..cae043fc7 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -464,3 +464,27 @@ var _ = Describe("Manifest", func() { }) }) }) + +var _ = Describe("Permissions.DeclaredNames", func() { + It("returns nil for a nil receiver", func() { + var p *Permissions + Expect(p.DeclaredNames()).To(BeEmpty()) + }) + + It("returns declared names sorted", func() { + p := &Permissions{ + Subsonicapi: &SubsonicAPIPermission{}, + Users: &UsersPermission{}, + } + Expect(p.DeclaredNames()).To(Equal([]string{"subsonicapi", "users"})) + }) + + It("returns all declared names sorted regardless of field order", func() { + p := &Permissions{ + Http: &HTTPPermission{}, + Artwork: &ArtworkPermission{}, + Cache: &CachePermission{}, + } + Expect(p.DeclaredNames()).To(Equal([]string{"artwork", "cache", "http"})) + }) +}) diff --git a/plugins/package.go b/plugins/package.go index 475761231..e0405d4d8 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -72,9 +72,10 @@ func openPackage(ndpPath string) (*ndpPackage, error) { }, nil } -// readManifest reads only the manifest from an .ndp file without loading the wasm bytes. -// This is useful for quick plugin discovery. -func readManifest(ndpPath string) (*Manifest, error) { +// ReadManifest reads and validates the manifest from a .ndp file without loading +// the wasm bytes (it runs ParseManifest, so JSON-schema and cross-field +// validation are applied). Useful for quick plugin discovery and validation. +func ReadManifest(ndpPath string) (*Manifest, error) { // Open the zip archive zr, err := zip.OpenReader(ndpPath) if err != nil { diff --git a/plugins/package_test.go b/plugins/package_test.go index 3d3fec022..4a37f4352 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -132,8 +132,8 @@ var _ = Describe("ndpPackage", func() { }) }) - Describe("readManifest", func() { - It("should read only the manifest without loading wasm", func() { + Describe("ReadManifest", func() { + It("parses the manifest from a package that also contains wasm", func() { ndpPath := filepath.Join(tmpDir, "test.ndp") manifest := &Manifest{ Name: "Test Plugin", @@ -141,57 +141,60 @@ var _ = Describe("ndpPackage", func() { Version: "1.0.0", Description: new("A test plugin"), } - wasmBytes := make([]byte, 1024*1024) // 1MB of zeros - err := createTestPackage(ndpPath, manifest, wasmBytes) + err := createTestPackage(ndpPath, manifest, nil) Expect(err).ToNot(HaveOccurred()) - m, err := readManifest(ndpPath) + m, err := ReadManifest(ndpPath) Expect(err).ToNot(HaveOccurred()) Expect(m.Name).To(Equal("Test Plugin")) Expect(*m.Description).To(Equal("A test plugin")) }) - It("should return error for missing manifest", func() { - ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") + It("returns an error for a non-existent file", func() { + _, err := ReadManifest(filepath.Join(tmpDir, "does-not-exist.ndp")) + Expect(err).To(HaveOccurred()) + }) + It("returns a specific error for a package missing manifest.json", func() { + ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") f, err := os.Create(ndpPath) Expect(err).ToNot(HaveOccurred()) defer f.Close() - zw := newTestZipWriter(f) - err = zw.addFile("plugin.wasm", []byte{0x00}) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) + Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed()) + Expect(zw.close()).To(Succeed()) - _, err = readManifest(ndpPath) + _, err = ReadManifest(ndpPath) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing manifest.json")) }) - }) - Describe("ComputePackageSHA256", func() { - It("should compute consistent hash for same file", func() { - ndpPath := filepath.Join(tmpDir, "test.ndp") + It("fails for a package with a schema-invalid manifest", func() { + ndp := filepath.Join(tmpDir, "bad.ndp") + // empty required fields violate the manifest JSON schema + err := createTestPackage(ndp, &Manifest{}, nil) + Expect(err).ToNot(HaveOccurred()) + _, err = ReadManifest(ndp) + Expect(err).To(HaveOccurred()) + }) + + It("enforces cross-field validation", func() { + ndp := filepath.Join(tmpDir, "crossfield.ndp") + // subsonicapi permission without users: violates cross-field rule manifest := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", + Name: "X", + Author: "me", + Version: "1.0.0", + Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}}, } - wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} - - err := createTestPackage(ndpPath, manifest, wasmBytes) + err := createTestPackage(ndp, manifest, nil) Expect(err).ToNot(HaveOccurred()) - hash1, err := computeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - hash2, err := computeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - Expect(hash1).To(Equal(hash2)) - Expect(hash1).To(HaveLen(64)) // SHA-256 produces 64 hex characters + _, err = ReadManifest(ndp) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("subsonicapi")) + Expect(err.Error()).To(ContainSubstring("users")) }) }) }) From 9b862e88337e5f3fe7f8d8d6ed1fdac2c6c5151b Mon Sep 17 00:00:00 2001 From: Yuuta <61791392+ranokay@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:14:18 +0300 Subject: [PATCH 319/596] fix(subsonic): emit agent-specific cueLine values (#5679) * fix(subsonic): emit agent-specific cue line values * fix(subsonic): preserve cue line edge text --- server/subsonic/lyrics.go | 88 ++++++++++++- server/subsonic/lyrics_test.go | 222 ++++++++++++++++++++++++++++++++- 2 files changed, 304 insertions(+), 6 deletions(-) diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go index ce3c3fae4..bfe1f1899 100644 --- a/server/subsonic/lyrics.go +++ b/server/subsonic/lyrics.go @@ -114,12 +114,17 @@ func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses cueLines := make([]responses.CueLine, 0, len(agentOrder)) for _, agentID := range agentOrder { + value := line.Value + cues := cuesByAgent[agentID] + if len(agentOrder) > 1 { + value, cues = buildAgentCueLineValue(line.Value, cues, line.Cue, agentID) + } cueLine := responses.CueLine{ Index: index, Start: line.Start, End: line.End, - Value: line.Value, - Cue: buildLyricCues(cuesByAgent[agentID], line.End), + Value: value, + Cue: buildLyricCues(cues, line.End), } if agentID != "" { cueLine.AgentID = agentID @@ -149,6 +154,85 @@ func (a lyricAgents) less(left, right string, origI, origJ int) bool { return origI < origJ } +func buildAgentCueLineValue(lineValue string, cues, allCues []model.Cue, agentID string) (string, []model.Cue) { + if len(cues) == 0 { + return "", nil + } + + remapped := slices.Clone(cues) + var value strings.Builder + leadingGap := cueLineGap(lineValue, 0, remapped[0].ByteStart, allCues, agentID) + if strings.TrimSpace(leadingGap) != "" { + value.WriteString(leadingGap) + } + + previousEnd := -1 + for i := range remapped { + originalStart := remapped[i].ByteStart + originalEnd := remapped[i].ByteEnd + if i > 0 { + value.WriteString(cueLineGap(lineValue, previousEnd+1, originalStart, allCues, agentID)) + } + + remapped[i].ByteStart = value.Len() + value.WriteString(remapped[i].Value) + remapped[i].ByteEnd = value.Len() - 1 + previousEnd = originalEnd + } + + trailingGap := cueLineGap(lineValue, previousEnd+1, len(lineValue), allCues, agentID) + if strings.TrimSpace(trailingGap) != "" { + value.WriteString(trailingGap) + } + return value.String(), remapped +} + +type byteRange struct { + start int + end int +} + +func cueLineGap(source string, start, end int, allCues []model.Cue, agentID string) string { + start = max(start, 0) + end = min(end, len(source)) + if start >= end { + return "" + } + + excluded := make([]byteRange, 0, 1) + for _, cue := range allCues { + if strings.TrimSpace(cue.AgentID) == agentID { + continue + } + cueStart := max(cue.ByteStart, start) + cueEnd := min(cue.ByteEnd+1, end) + if cueStart < cueEnd { + excluded = append(excluded, byteRange{start: cueStart, end: cueEnd}) + } + } + + if len(excluded) == 0 { + return source[start:end] + } + + sort.SliceStable(excluded, func(i, j int) bool { + return excluded[i].start < excluded[j].start + }) + + var gap strings.Builder + cursor := start + for _, r := range excluded { + if r.start > cursor { + gap.WriteString(source[cursor:r.start]) + } + cursor = max(cursor, r.end) + } + if cursor < end { + gap.WriteString(source[cursor:end]) + } + return gap.String() +} + func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { if len(cues) == 0 { return nil diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index f4ba6208a..3d9881872 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -431,7 +431,7 @@ var _ = Describe("GetLyricsBySongId", func() { Index: 0, Start: &lineStart, End: &lineEnd, - Value: "Hello echo", + Value: "Hello", AgentID: "lead", Cue: []responses.LyricCue{ { @@ -447,14 +447,14 @@ var _ = Describe("GetLyricsBySongId", func() { Index: 0, Start: &lineStart, End: &lineEnd, - Value: "Hello echo", + Value: "echo", AgentID: "__nd_bg__|lead", Cue: []responses.LyricCue{ { Start: tokenStartB, End: &tokenEndB, - ByteStart: 6, - ByteEnd: 9, + ByteStart: 0, + ByteEnd: 3, Value: "echo", }, }, @@ -465,6 +465,220 @@ var _ = Describe("GetLyricsBySongId", func() { }) }) + It("should preserve shared edge text when remapping agent cue lines", func() { + lineStart := int64(1000) + lineEnd := int64(2000) + cueStart := int64(1200) + cueEnd := int64(1800) + + cueLines := buildCueLines(model.Line{ + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + Cue: []model.Cue{ + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "lead", + }, + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "__nd_bg__|lead", + }, + }, + }, 0, newLyricAgents([]model.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + })) + + Expect(cueLines).To(Equal([]responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + })) + }) + + It("should remap cue offsets for interleaved agent cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(82889) + lineEnd := int64(86859) + realStart := int64(85593) + realEnd := int64(85934) + slowStart := int64(85934) + slowEnd := int64(86751) + bgStartA := int64(83881) + bgEndA := int64(84243) + bgStartB := int64(86232) + bgEndB := int64(86859) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "real slow (When you slide)", + Cue: []model.Cue{ + { + Start: &realStart, + End: &realEnd, + Value: "real", + ByteStart: 0, + ByteEnd: 3, + AgentID: "v2", + }, + { + Start: &slowStart, + End: &slowEnd, + Value: "slow", + ByteStart: 5, + ByteEnd: 8, + AgentID: "v2", + }, + { + Start: &bgStartA, + End: &bgEndA, + Value: "(When you", + ByteStart: 10, + ByteEnd: 18, + AgentID: "__nd_bg__|v2", + }, + { + Start: &bgStartB, + End: &bgEndB, + Value: "slide)", + ByteStart: 20, + ByteEnd: 25, + AgentID: "__nd_bg__|v2", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "v2", Role: "main"}, + {ID: "__nd_bg__|v2", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "real slow (When you slide)", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "real slow", + AgentID: "v2", + Cue: []responses.LyricCue{ + { + Start: realStart, + End: &realEnd, + ByteStart: 0, + ByteEnd: 3, + Value: "real", + }, + { + Start: slowStart, + End: &slowEnd, + ByteStart: 5, + ByteEnd: 8, + Value: "slow", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(When you slide)", + AgentID: "__nd_bg__|v2", + Cue: []responses.LyricCue{ + { + Start: bgStartA, + End: &bgEndA, + ByteStart: 0, + ByteEnd: 8, + Value: "(When you", + }, + { + Start: bgStartB, + End: &bgEndB, + ByteStart: 10, + ByteEnd: 15, + Value: "slide)", + }, + }, + }, + }, + }, + }, + }) + }) + It("should keep enhanced line-level lyrics when no cue data is available", func() { r := newGetRequest("id=1&enhanced=true") From 7303c9ca474df801d1824a6ca5e799895e38c7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 29 Jun 2026 08:19:27 -0400 Subject: [PATCH 320/596] fix(lyrics): bump navidrome-music-player to 4.25.4 (#5661) Pulls in the lyric timing fixes: lyrics no longer stack from the previous song on rapid track changes (#5661), stay in sync after seeking/scrubbing (including while paused), and show a music-note placeholder during intros and gaps instead of the 'no lyrics' message. --- ui/package-lock.json | 8 ++++---- ui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 54cfe8b1c..0feab6e7d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.3", + "navidrome-music-player": "4.25.4", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", @@ -8613,9 +8613,9 @@ "license": "MIT" }, "node_modules/navidrome-music-player": { - "version": "4.25.3", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.3.tgz", - "integrity": "sha512-0T87Mmbs6ls9g9CgwJwXBL7X11u0+6znBFJTG8HUPgBT0r8W6epVKqJhfdg8vqnT86QDByvvb1Hxp/W2M7Yacw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz", + "integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==", "license": "MIT", "dependencies": { "@react-icons/all-files": "^4.1.0", diff --git a/ui/package.json b/ui/package.json index 20f4324f7..b02012104 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,7 +32,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.3", + "navidrome-music-player": "4.25.4", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", From 0fab1861a0d99b2b9aa5d05b7a335ffde160860f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 29 Jun 2026 16:18:29 -0400 Subject: [PATCH 321/596] fix(subsonic): make "recently added" order reproducible and consistent with RecentlyAddedByModTime (#5678) * fix(subsonic): align album `created` with RecentlyAddedByModTime sort The album `created` attribute returned by search3, getAlbumList2 and the other album endpoints was always sourced from the album's CreatedAt (oldest song birth time), while the "recently added" sort is governed by RecentlyAddedByModTime: it orders by album.updated_at when that option is enabled and album.created_at otherwise. As a result, when RecentlyAddedByModTime was enabled, clients that cache album results and sort locally by `created` (e.g. for a "Date added" view) could not reproduce the order returned by getAlbumList2?type=newest, since the exposed value did not match the column driving the sort. Make albumCreatedAt config-aware so the primary timestamp it returns mirrors recentlyAddedSort: UpdatedAt when RecentlyAddedByModTime is set, CreatedAt otherwise. The existing zero-value fallback chain is preserved so this required OpenSubsonic field is never emitted as zero on legacy rows. Note: this is a behavior change for the contractual `created` attribute. With RecentlyAddedByModTime enabled, an album's reported `created` now reflects the newest song modification time and can change when files are modified. Scope is limited to albums; the song-level `created` (BirthTime) is unchanged. * fix(subsonic): order Recently Added by full-precision timestamp with tiebreak The recently_added sort wrapped the timestamp in datetime(), truncating it to whole seconds, and had no secondary sort key. Album timestamps carry sub-second precision (aggregated from song file birth-times), so on a fresh scan many albums tie at the second; SQLite then returns ties in query-plan order, which changes when a library filter is applied. This made the web UI "Recently Added" order invert between library selections and diverge from getAlbumList2?type=newest, and clients receiving the full-precision created value could never reproduce the server order. Sort on the raw, full-precision column with an album.id / media_file.id tiebreak instead. A new migration swaps the album datetime() expression indexes (and the plain media_file indexes) for composite (col, id) indexes that cover the new sort. Timestamps were already normalized to space-format by 20260316000000_normalize_timestamps, so raw-string comparison is safe. * fix(subsonic): make song created follow RecentlyAddedByModTime The song Created field returned BirthTime (file ctime), but the recently_added sort (shared by the Subsonic and native APIs, and the web UI) orders by created_at, or updated_at when RecentlyAddedByModTime is set. A Subsonic client, which can only sort by the created value it receives, could therefore never reproduce the server's "recently added" order in either mode. Add mediaFileCreatedAt mirroring albumCreatedAt and use it for child.Created: CreatedAt by default, UpdatedAt under RecentlyAddedByModTime, with BirthTime as a legacy fallback. This aligns song created with the sort column, matching how album created already works and what the native API/web UI present. --- ...629123100_recently_added_plain_indexes.sql | 28 ++++ persistence/album_repository.go | 4 +- persistence/album_repository_test.go | 81 ++++++----- persistence/mediafile_repository.go | 4 +- persistence/mediafile_repository_test.go | 28 ++++ server/subsonic/helpers.go | 38 +++-- server/subsonic/helpers_test.go | 131 +++++++++++++++--- 7 files changed, 248 insertions(+), 66 deletions(-) create mode 100644 db/migrations/20260629123100_recently_added_plain_indexes.sql diff --git a/db/migrations/20260629123100_recently_added_plain_indexes.sql b/db/migrations/20260629123100_recently_added_plain_indexes.sql new file mode 100644 index 000000000..731959e62 --- /dev/null +++ b/db/migrations/20260629123100_recently_added_plain_indexes.sql @@ -0,0 +1,28 @@ +-- +goose Up + +-- The "Recently Added" sort now uses the raw timestamp with an id tiebreak +-- instead of datetime(), so the indexes become plain composite (col, id) to +-- cover it. Timestamps were already normalized to space-format by +-- 20260316000000_normalize_timestamps, so raw-string comparison is safe. + +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(created_at, id); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(updated_at, id); + +DROP INDEX IF EXISTS media_file_created_at; +CREATE INDEX media_file_created_at ON media_file(created_at, id); +DROP INDEX IF EXISTS media_file_updated_at; +CREATE INDEX media_file_updated_at ON media_file(updated_at, id); + +-- +goose Down + +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(datetime(created_at)); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(datetime(updated_at)); + +DROP INDEX IF EXISTS media_file_created_at; +CREATE INDEX media_file_created_at ON media_file(created_at); +DROP INDEX IF EXISTS media_file_updated_at; +CREATE INDEX media_file_updated_at ON media_file(updated_at); diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 99ed10877..1d65215f7 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "datetime(album.updated_at)" + return "album.updated_at, album.id" } - return "datetime(album.created_at)" + return "album.created_at, album.id" } func recentlyPlayedFilter(string, any) Sqlizer { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index a6270933f..f72f778db 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -112,49 +112,66 @@ var _ = Describe("AlbumRepository", func() { }) Describe("recently_added sort", func() { - It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() { - // Both timestamps share the same date prefix "2024-01-15" so the T vs space - // character at position 10 determines sort order in raw string comparison. - // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older - // T-format timestamp sort AFTER the newer space-format one. + AfterEach(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album"). + Where(squirrel.Like{"id": "ra-%"})) + }) - // Older album: morning of Jan 15, stored in T-format - olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"} - Expect(albumRepo.Put(olderAlbum)).To(Succeed()) + // Sub-second precision must survive, and ties must break deterministically + // so the order is independent of any filter (issue #5673). + indexOf := func(albums model.Albums, id string) int { + for i, a := range albums { + if a.ID == id { + return i + } + } + return -1 + } + + It("orders by sub-second precision, not truncated to the second", func() { + // Same second, different nanoseconds: datetime() would tie these. + earlier := &model.Album{LibraryID: 1, ID: "ra-earlier", Name: "Earlier"} + later := &model.Album{LibraryID: 1, ID: "ra-later", Name: "Later"} + Expect(albumRepo.Put(earlier)).To(Succeed()) + Expect(albumRepo.Put(later)).To(Succeed()) _, err := albumRepo.executeSQL(squirrel.Update("album"). - Set("created_at", "2024-01-15T08:00:00Z"). - Where(squirrel.Eq{"id": "ts-older"})) + Set("created_at", "2024-01-15 10:00:00.100000000+00:00"). + Where(squirrel.Eq{"id": "ra-earlier"})) Expect(err).ToNot(HaveOccurred()) - - // Newer album: evening of Jan 15, stored in space-format - newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"} - Expect(albumRepo.Put(newerAlbum)).To(Succeed()) _, err = albumRepo.executeSQL(squirrel.Update("album"). - Set("created_at", "2024-01-15 20:00:00+00:00"). - Where(squirrel.Eq{"id": "ts-newer"})) + Set("created_at", "2024-01-15 10:00:00.900000000+00:00"). + Where(squirrel.Eq{"id": "ra-later"})) Expect(err).ToNot(HaveOccurred()) albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) Expect(err).ToNot(HaveOccurred()) + Expect(indexOf(albums, "ra-later")).To(BeNumerically("<", indexOf(albums, "ra-earlier")), + ".900 should sort before .100 in desc order") + }) - // Find positions of our test albums - olderIdx, newerIdx := -1, -1 - for i, a := range albums { - switch a.ID { - case "ts-older": - olderIdx = i - case "ts-newer": - newerIdx = i - } + It("breaks ties deterministically and consistently across filters", func() { + // All sharing one created_at: the relative order of any subset must + // match the unfiltered order (the inversion mechanism in #5673). + ids := []string{"ra-t1", "ra-t2", "ra-t3", "ra-t4"} + for _, aid := range ids { + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) } - Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results") - Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results") - // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order - Expect(newerIdx).To(BeNumerically("<", olderIdx), - "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order") + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-02-20 12:00:00+00:00"). + Where(squirrel.Eq{"id": ids})) + Expect(err).ToNot(HaveOccurred()) - // Clean up - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) + all, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + subset, err := albumRepo.GetAll(model.QueryOptions{ + Sort: "recently_added", Order: "desc", + Filters: squirrel.Eq{"album.id": []string{"ra-t1", "ra-t3"}}}) + Expect(err).ToNot(HaveOccurred()) + + Expect(indexOf(all, "ra-t1") < indexOf(all, "ra-t3")). + To(Equal(indexOf(subset, "ra-t1") < indexOf(subset, "ra-t3")), + "tied albums must keep the same relative order with and without a filter") }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 740ca4956..28c11f686 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -117,9 +117,9 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { func mediaFileRecentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "media_file.updated_at" + return "media_file.updated_at, media_file.id" } - return "media_file.created_at" + return "media_file.created_at, media_file.id" } func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index d99bb65a7..ba4747cdc 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -576,6 +576,34 @@ var _ = Describe("MediaRepository", func() { }) }) + It("breaks ties deterministically when files share the same created_at", func() { + conf.Server.RecentlyAddedByModTime = false + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + repo := NewMediaFileRepository(ctx, GetDBXBuilder()) + + ids := []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID} + sameTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + _, err := GetDBXBuilder().Update("media_file", + dbx.Params{"created_at": sameTime}, + dbx.In("id", ids[0], ids[1], ids[2])).Execute() + Expect(err).ToNot(HaveOccurred()) + + order := func() []string { + res, err := repo.GetAll(model.QueryOptions{ + Sort: "recently_added", Order: "desc", + Filters: squirrel.Eq{"media_file.id": ids}}) + Expect(err).ToNot(HaveOccurred()) + out := make([]string, len(res)) + for i, mf := range res { + out[i] = mf.ID + } + return out + } + // Stable across repeated queries (no query-plan-dependent reordering). + Expect(order()).To(Equal(order())) + }) + }) }) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index a76165cc1..b986200ae 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -217,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = new(mf.BirthTime) + child.Created = new(mediaFileCreatedAt(mf)) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -326,18 +326,36 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } -// albumCreatedAt returns a best-effort timestamp for the album's `created` -// field, which is required by the OpenSubsonic spec but may be zero on legacy -// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all -// three are unset. +// albumCreatedAt mirrors the column used by recentlyAddedSort so clients can +// reproduce the "recently added" order locally: UpdatedAt when +// RecentlyAddedByModTime is set, CreatedAt otherwise. The other timestamps are +// fallbacks for legacy rows; returns zero only when all three are unset. func albumCreatedAt(al model.Album) time.Time { - if !al.CreatedAt.IsZero() { - return al.CreatedAt + candidates := []time.Time{al.CreatedAt, al.UpdatedAt, al.ImportedAt} + if conf.Server.RecentlyAddedByModTime { + candidates = []time.Time{al.UpdatedAt, al.CreatedAt, al.ImportedAt} } - if !al.UpdatedAt.IsZero() { - return al.UpdatedAt + for _, t := range candidates { + if !t.IsZero() { + return t + } } - return al.ImportedAt + return time.Time{} +} + +// mediaFileCreatedAt is the song counterpart of albumCreatedAt, tracking +// mediaFileRecentlyAddedSort; BirthTime is the legacy fallback. +func mediaFileCreatedAt(mf model.MediaFile) time.Time { + candidates := []time.Time{mf.CreatedAt, mf.UpdatedAt, mf.BirthTime} + if conf.Server.RecentlyAddedByModTime { + candidates = []time.Time{mf.UpdatedAt, mf.CreatedAt, mf.BirthTime} + } + for _, t := range candidates { + if !t.IsZero() { + return t + } + } + return time.Time{} } func childFromAlbum(ctx context.Context, al model.Album) responses.Child { diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index ed8f257d1..3741462a2 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -619,31 +619,122 @@ var _ = Describe("helpers", func() { }) Describe("buildAlbumID3 Created field", func() { - It("uses CreatedAt when set", func() { - t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - al := model.Album{ID: "a1", Name: "A", CreatedAt: t} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(t)) + When("RecentlyAddedByModTime is false", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + }) + + It("uses CreatedAt when set", func() { + t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a1", Name: "A", CreatedAt: t} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(t)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(updated)) + }) + + It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(imported)) + }) + + It("leaves Created as zero time when all timestamps are zero", func() { + al := model.Album{ID: "a4", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created.IsZero()).To(BeTrue()) + }) }) - It("falls back to UpdatedAt when CreatedAt is zero", func() { - updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) - al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(updated)) + When("RecentlyAddedByModTime is true", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + }) + + It("uses UpdatedAt even when CreatedAt is also set", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) + al := model.Album{ID: "a5", Name: "A", CreatedAt: created, UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(updated)) + }) + + It("falls back to CreatedAt when UpdatedAt is zero", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a6", Name: "A", CreatedAt: created} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(created)) + }) + + It("falls back to ImportedAt when UpdatedAt and CreatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a7", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(imported)) + }) + + It("leaves Created as zero time when all timestamps are zero", func() { + al := model.Album{ID: "a8", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created.IsZero()).To(BeTrue()) + }) + }) + }) + + Describe("childFromMediaFile Created field", func() { + birth := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC) + + When("RecentlyAddedByModTime is false", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + }) + + It("uses CreatedAt, not BirthTime", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + mf := model.MediaFile{ID: "s1", BirthTime: birth, CreatedAt: created} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(created)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + mf := model.MediaFile{ID: "s2", BirthTime: birth, UpdatedAt: updated} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(updated)) + }) + + It("falls back to BirthTime when CreatedAt and UpdatedAt are zero", func() { + mf := model.MediaFile{ID: "s3", BirthTime: birth} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(birth)) + }) }) - It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { - imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) - al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(imported)) - }) + When("RecentlyAddedByModTime is true", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + }) - It("leaves Created as zero time when all timestamps are zero", func() { - al := model.Album{ID: "a4", Name: "A"} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created.IsZero()).To(BeTrue()) + It("uses UpdatedAt even when CreatedAt is also set", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) + mf := model.MediaFile{ID: "s4", BirthTime: birth, CreatedAt: created, UpdatedAt: updated} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(updated)) + }) + + It("falls back to CreatedAt when UpdatedAt is zero", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + mf := model.MediaFile{ID: "s5", BirthTime: birth, CreatedAt: created} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(created)) + }) }) }) From 0e5b9e3263d87395c78f16948e1234e292fb02bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 29 Jun 2026 21:20:33 -0400 Subject: [PATCH 322/596] feat(plugins): share plugin DTOs via a types package (#5655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(plugins): remove Python PDK generation from ndpgen * feat(plugins): parse Go type aliases distinctly in ndpgen * feat(plugins): resolve shared-type aliases against a registry in ndpgen * fix(plugins): resolve host-service shared aliases package-wide Mirror the capability approach in ParseDirectoryWithShared: do a first pass over all package files to build a package-wide alias map, then pass it into parseServiceFile so that a shared-type alias declared in a sibling file is visible when resolving types in the service interface file. Add a focused test that writes the alias in one file and the hostservice in another, confirming RED before the fix and GREEN after. Also strengthens the existing Task 3 test with an ArtistRef.Target assertion. * feat(plugins): add ndpgen -shared-types mode for the Go types package * feat(plugins): generate the nd-pdk-types Rust crate from -shared-types * feat(plugins): inject types import and emit deprecated aliases in Go output * feat(plugins): emit deprecated Rust aliases to the shared types crate * feat(plugins): inline shared-type shapes into XTP schemas * feat(plugins): add nd-pdk-types crate and wire dependents * feat(plugins): move shared capability types to plugins/types with deprecated aliases * fix(plugins): point Rust deprecated-alias note at the replacement type * fix(plugins): include shared aliases in KnownStructs so Rust fields keep their type Capability.KnownStructs() and Service.KnownStructs() previously only registered names from .Structs. After the shared-types migration, types like ArtistRef/TrackInfo/SongRef live in .SharedAliases instead, so ToRustTypeWithStructs could not find them and fell back to serde_json::Value for every struct field referencing a shared type. Add the shared-alias names to the knownStructs map in both methods. Regenerate the Rust capability files; track/song/artist fields now render as their named types (TrackInfo, SongRef, ArtistRef, etc.). Add a regression test that verifies a struct field whose type is only in SharedAliases renders as the named type and not serde_json::Value. * docs(plugins): remove stale Python references from ndpgen and plugins READMEs ndpgen no longer has a -python flag; remove it from the usage synopsis, flags table, and defaults note in ndpgen/README.md. Delete the "Python Client Library" section that described its output. plugins/README.md referenced plugins/pdk/python/host/ (deleted) as the source for Python host-service stubs. Remove that paragraph; Python plugins still work via the XTP-schema / extism-py path (see examples/*-py). * refactor(plugins): dedupe ndpgen helpers and tidy shared-type codegen * docs(plugins): restore Python as a supported XTP schema target The ndpgen-generated Python PDK was removed, but the XTP YAML schemas are language-neutral and the XTP CLI still generates Python bindings from them (as the extism-py examples demonstrate). Only the ndpgen Python output was dropped, not Python support itself. * test(plugins): use the shared types package in test plugins The test fixtures referenced the now-deprecated capability aliases (sonicsimilarity.SongRef, metadata.ArtistRef/SongRef). Point them at the canonical types package so our own fixtures don't depend on symbols slated for removal. * refactor(plugins): use the shared types package in host adapters Replace deprecated capabilities.TrackInfo, capabilities.ArtistRef, and capabilities.SongRef aliases with the canonical types.TrackInfo, types.ArtistRef, and types.SongRef from plugins/types. * fix(plugins): reference shared types by canonical path in generated Rust Previously the generator emitted `pub field: SongRef` (the local deprecated alias) for struct fields whose type came from SharedAliases. Refactored ToRustTypeWithStructs into a private toRustType that accepts a shared map, and added ToRustTypeWithShared which resolves shared-alias names to their canonical nd_pdk_types::X path before falling through to the knownStructs check. Both rustCapabilityFuncMap and rustFuncMap now build the shared map from SharedAliases and use it for fieldRustType, so the generated capability files reference nd_pdk_types::SongRef / nd_pdk_types::TrackInfo directly. The deprecated pub type aliases remain in place as the external back-compat surface. Deprecation warning count from cargo build drops to 0. * fix(examples): implement missing Scrobbler.playback_report in Rust examples The Scrobbler trait gained a playback_report method but the two Rust example plugins (webhook-rs and discord-rich-presence-rs) were not updated, causing E0046 compile errors. Added the missing fn playback_report to both: webhook-rs logs and returns Ok(()) mirroring its now_playing handler; discord-rich-presence-rs is a no-op since Discord presence does not need playback reports. make all-rust now exits 0. * refactor(plugins): point Rust deprecation notes at the nd_pdk::types umbrella path Plugin authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types as 'types', so the migration target they should type is nd_pdk::types::X. The alias target stays nd_pdk_types::X (the real path inside nd-pdk-capabilities). * fix(plugins): error when a shared-type alias can't be resolved against the registry * refactor(plugins): parse each Go source file once in ndpgen * fix(plugins): correct ndpgen review nits (flag name, unused dep, docs) * refactor(plugins): drop the now-unused path param from parseServiceFile * refactor(plugins): use shared types directly, rename TrackInfo to Track Capability interfaces now reference the shared `types` package by qualified name (types.Track, types.SongRef, types.ArtistRef) instead of the package-local deprecated aliases, and the shared TrackInfo type is renamed to Track to match its role as the plugin-facing projection of a library media file. The deprecated bare aliases (scrobbler.TrackInfo, metadata.ArtistRef, sonicsimilarity.SongRef, etc.) are kept as re-exports so existing plugins keep compiling, with a deprecation warning steering them to the canonical types. To support this, ndpgen now resolves qualified types.X references: it collects them during type discovery, maps each used canonical type back to its declared deprecated alias for re-export, emits nd_pdk_types::X paths in Rust, and names the XTP schema components by their canonical type. Regenerated the Go and Rust PDK and the XTP schemas, and added generator tests covering the qualified-ref path. Also adds clarifying doc comments to the shared types. * refactor(plugins): extract shared types selector into a named const Replace the "types." string literal that detects and strips the shared types package selector with a single sharedTypesPrefix constant across the ndpgen generator (parser, types, generator, xtp_schema), giving the package one source of truth for the selector. Also restore the single reused scratch map (cleared each iteration) in the resolveSharedAliases BFS instead of allocating a fresh map per shared-struct field, matching the prior implementation. Pure cleanup from a /simplify pass: regeneration produces byte-for-byte identical Go, Rust, and XTP output. * refactor(plugins): keep TrackInfo in the capability package for now Move the track type back out of the shared plugins/types package: it is again defined inline as TrackInfo in plugins/capabilities/scrobbler.go and referenced directly by the scrobbler and lyrics capabilities, reverting the rename to types.Track. The host helper is renamed back to mediaFileToTrackInfo and now returns capabilities.TrackInfo. SongRef and ArtistRef stay in the shared types package; TrackInfo keeps using types.ArtistRef for its artist lists. This type is expected to be reshaped in upcoming work, so leaving it in the capability package avoids churning the shared types twice. Regenerated the Go and Rust PDK and the XTP schemas accordingly. * fix(plugins): emit the Go types import for direct shared-type refs ndpgen's Capability/Service.ImportsSharedTypes only reported a shared-types dependency when a deprecated re-export alias (type X = types.X) was declared. A struct field referencing the canonical form directly (e.g. types.SongRef) with no such alias produced an empty SharedAliases slice, so the Go templates skipped the import while still emitting fields/signatures using types.SongRef — leaving generated PDK code for new shared DTOs uncompilable unless an otherwise unnecessary alias was added. ImportsSharedTypes now also returns true when any struct field references the types. package by qualified name, via a new structsReferenceSharedTypes helper that reuses collectReferencedTypes (so []types.X and map[...]types.X are covered too). * fix(plugins): preserve base64 encoding for shared byte fields in Rust The Rust shared-types crate template rendered a []byte field as a plain Vec<u8> without the base64_bytes serde override used by the capability/client templates. Go's encoding/json serializes []byte as a base64 string, so a Rust plugin using nd_pdk::types would have serialized an array of numbers instead of the wire format the Go/server side expects. GenerateSharedTypesRust now registers the base64_bytes partial and passes a HasByteFields flag (new anyFieldIsByteSlice helper); types.rs.tmpl emits the base64_bytes module and a #[serde(with = "base64_bytes")] attribute on []byte fields, mirroring the capability template. * fix(plugins): include directly-referenced shared types in XTP schemas buildSchemas registered shared types into the schema components only by iterating cap.SharedAliases, which records deprecated re-export aliases. A capability that referenced a shared DTO solely as types.Foo (no declared alias) therefore never got Foo into the component set, so the self-contained XTP schema rendered the field as a generic object (or emitted a dangling $ref), breaking the direct shared-type use case enabled by -shared. resolveSharedAliases now also returns the resolved shapes of every used shared type (alias or not); these are carried on the new Capability.SharedTypes field and registered by buildSchemas alongside SharedAliases. Validated end-to-end with the xtp CLI: a direct types.Foo reference now produces a proper component plus a $ref, so xtp generates a typed struct instead of an untyped serde_json::Map. * fix(plugins): resolve renamed shared aliases to canonical schema refs When a deprecated alias renames its canonical type (e.g. type TrackInfo = types.Track) and a capability field is typed with the alias name (TrackInfo), buildProperty emitted a $ref to #/components/schemas/TrackInfo. Components are keyed by the canonical name (Track), so no TrackInfo component was emitted, leaving a dangling reference that crashes the xtp code generator. buildSchemas now builds an alias->canonical map; buildProperty (and the slice item path) resolves $ref targets through it, and a used alias name marks its canonical component used so it is emitted. Validated with the xtp CLI: the renamed-alias schema previously crashed xtp and now generates cleanly. * fix(plugins): detect shared types used directly in method signatures ImportsSharedTypes only inspected struct fields, so a capability method using a shared type directly in its signature (e.g. types.SongRef as input/output rather than inside a local struct) was not detected. The generated Go templates still rendered the provider/export signatures with types.SongRef, so the capability package omitted the types import and failed to compile; the same gap applied to service params/returns. ImportsSharedTypes now also scans capability method input/output types and service method params/returns, via a typeReferencesSharedTypes helper that reuses collectReferencedTypes (covering pointer/slice/map wrappers). * fix(plugins): add base64 dependency to the shared Rust types crate When a shared DTO has a []byte field, ndpgen emits the base64_bytes serde helper and use base64::... imports into nd-pdk-types/src/lib.rs, but the crate manifest declared only serde. In that case make gen produced a crate that failed to compile with 'unresolved module base64'. Add base64 = "0.22" (matching nd-pdk-capabilities) so the generated shared types crate compiles whenever a []byte field is present. Verified by generating a shared crate with a []byte field and confirming cargo check fails before and passes after. * fix(plugins): translate shared method types in generated Rust A capability method using a shared DTO directly as input/output (e.g. types.SongRef) was passed through rustOutputType unchanged, so the Rust template emitted invalid trait and extism_pdk::Json<$crate::pkg::types.SongRef> signatures that do not compile. Method input/output types now resolve through the shared registry: trait signatures use rustTraitType (shared -> nd_pdk_types::X, locals stay bare) and the export macros use rustMethodType (fully qualified: shared -> nd_pdk_types::X, primitives -> Rust, locals -> $crate::<pkg>::X). Verified end-to-end by compiling a generated capability that takes types.SongRef directly against the real nd-pdk-types crate. * fix(plugins): canonicalize XTP export refs for renamed shared aliases buildSchemas canonicalized alias-to-canonical references for struct-field $ref targets, but buildExport built export input/output $refs straight from fieldBaseType. A capability whose export used a renamed deprecated alias directly (e.g. type TrackInfo = types.Track with NowPlaying(TrackInfo)) emitted $ref: #/components/schemas/TrackInfo, while the component is emitted under the canonical name Track — a dangling export reference. Lift the alias-to-canonical map into GenerateSchema (buildAliasToCanonical) and apply it to export refs via canonicalRefName, the same resolution already used for field properties. * fix(plugins): route shared macro types through $crate for plugin builds When a capability method used a shared type directly, the generated export macro named the type as nd_pdk_types::SongRef. The macro expands in the downstream plugin crate, which depends on the umbrella nd-pdk crate and not on nd-pdk-types directly, so that path is unresolvable there and the plugin fails to build. rustMethodType (macro-facing) now emits $crate::types::X, and the generated nd-pdk-capabilities lib.rs re-exports nd_pdk_types as types so $crate resolves it. Trait signatures keep nd_pdk_types::X since they live in nd-pdk-capabilities, which has the direct dependency. Verified end-to-end: a plugin crate depending only on the umbrella that uses a capability with a direct types.X method now compiles via the macro. * fix(plugins): add nd-pdk-types dependency to the Rust host crate When a host service uses a shared type, ndpgen emits nd_pdk_types::X into the generated nd-pdk-host client wrappers, but the host crate's manifest did not depend on nd-pdk-types, so the crate failed to compile with 'unresolved module nd_pdk_types'. Host client wrappers are plain functions resolved in the host crate's own context (not macros expanded downstream), so a direct dependency is the right fix. Add nd-pdk-types = { path = "../nd-pdk-types" } to nd-pdk-host, mirroring nd-pdk-capabilities. Found while auditing all Rust paths against the realistic crate topology after the capability-side $crate fix; verified by generating a host service with a shared-type return and confirming cargo check fails before and passes after. * fix(plugins): resolve shared aliases in Rust host signatures The Rust host client rendered method params and returns through RustTypeWithStructs, which only consults KnownStructs. A host service using a shared alias in a signature (e.g. type Track = types.Track plus MatchSongs(...) ([]Track, error)) therefore emitted a bare Vec<Track>, but the client template emits no Track alias or import, so the generated nd-pdk-host crate did not compile. Only struct fields went through the shared map. rustType/rustParamType now use the shared map too (RustTypeWithShared / RustParamTypeWithShared), so an aliased param/return resolves to its canonical nd_pdk_types::X path, matching field handling. Verified by generating a host service returning a shared alias and confirming cargo check fails before and passes after. --- Makefile | 9 +- plugins/README.md | 2 - plugins/capabilities/README.md | 2 +- plugins/capabilities/lyrics.yaml | 32 +- plugins/capabilities/metadata_agent.go | 36 +- plugins/capabilities/metadata_agent.yaml | 99 ++-- plugins/capabilities/scrobbler.go | 16 +- plugins/capabilities/scrobbler.yaml | 32 +- plugins/capabilities/sonic_similarity.go | 16 +- plugins/capabilities/sonic_similarity.yaml | 75 +-- plugins/cmd/ndpgen/README.md | 9 +- plugins/cmd/ndpgen/integration_test.go | 173 ++---- plugins/cmd/ndpgen/internal/generator.go | 250 ++++++--- plugins/cmd/ndpgen/internal/generator_test.go | 518 +++++++++--------- plugins/cmd/ndpgen/internal/parser.go | 330 ++++++++--- plugins/cmd/ndpgen/internal/parser_test.go | 248 +++++++++ .../internal/templates/capability.go.tmpl | 12 + .../internal/templates/capability.rs.tmpl | 17 +- .../templates/capability_stub.go.tmpl | 13 + .../ndpgen/internal/templates/client.go.tmpl | 13 + .../ndpgen/internal/templates/client.py.tmpl | 111 ---- .../internal/templates/client_stub.go.tmpl | 16 +- .../ndpgen/internal/templates/types.go.tmpl | 23 + .../ndpgen/internal/templates/types.rs.tmpl | 49 ++ plugins/cmd/ndpgen/internal/types.go | 234 +++++--- plugins/cmd/ndpgen/internal/xtp_schema.go | 126 ++++- .../cmd/ndpgen/internal/xtp_schema_test.go | 109 ++++ plugins/cmd/ndpgen/main.go | 163 ++++-- .../ndpgen/testdata/codec_client_expected.py | 53 -- .../testdata/comprehensive_client_expected.py | 342 ------------ .../ndpgen/testdata/config_client_expected.py | 126 ----- .../testdata/counter_client_expected.py | 49 -- .../ndpgen/testdata/echo_client_expected.py | 52 -- .../ndpgen/testdata/list_client_expected.py | 54 -- .../ndpgen/testdata/math_client_expected.py | 54 -- .../ndpgen/testdata/meta_client_expected.py | 81 --- .../ndpgen/testdata/ping_client_expected.py | 42 -- .../ndpgen/testdata/search_client_expected.py | 62 --- .../ndpgen/testdata/store_client_expected.py | 52 -- .../ndpgen/testdata/users_client_expected.py | 54 -- .../discord-rich-presence-rs/src/lib.rs | 7 +- plugins/examples/webhook-rs/src/lib.rs | 11 +- plugins/metadata_agent.go | 5 +- plugins/pdk/go/host/nd_host_artwork_stub.go | 4 +- plugins/pdk/go/host/nd_host_cache_stub.go | 4 +- plugins/pdk/go/host/nd_host_config_stub.go | 4 +- plugins/pdk/go/host/nd_host_http_stub.go | 4 +- plugins/pdk/go/host/nd_host_kvstore_stub.go | 4 +- plugins/pdk/go/host/nd_host_library_stub.go | 4 +- plugins/pdk/go/host/nd_host_scheduler_stub.go | 4 +- .../pdk/go/host/nd_host_subsonicapi_stub.go | 4 +- plugins/pdk/go/host/nd_host_task_stub.go | 4 +- plugins/pdk/go/host/nd_host_users_stub.go | 4 +- plugins/pdk/go/host/nd_host_websocket_stub.go | 4 +- plugins/pdk/go/lyrics/lyrics.go | 16 +- plugins/pdk/go/lyrics/lyrics_stub.go | 17 +- plugins/pdk/go/metadata/metadata.go | 47 +- plugins/pdk/go/metadata/metadata_stub.go | 48 +- plugins/pdk/go/scrobbler/scrobbler.go | 18 +- plugins/pdk/go/scrobbler/scrobbler_stub.go | 19 +- .../pdk/go/sonicsimilarity/sonicsimilarity.go | 53 +- .../sonicsimilarity/sonicsimilarity_stub.go | 54 +- plugins/pdk/go/types/types.go | 48 ++ plugins/pdk/python/host/nd_host_artwork.py | 183 ------- plugins/pdk/python/host/nd_host_cache.py | 448 --------------- plugins/pdk/python/host/nd_host_config.py | 145 ----- plugins/pdk/python/host/nd_host_http.py | 60 -- plugins/pdk/python/host/nd_host_httpclient.py | 59 -- plugins/pdk/python/host/nd_host_kvstore.py | 362 ------------ plugins/pdk/python/host/nd_host_library.py | 86 --- plugins/pdk/python/host/nd_host_scheduler.py | 143 ----- .../pdk/python/host/nd_host_subsonicapi.py | 101 ---- plugins/pdk/python/host/nd_host_task.py | 188 ------- plugins/pdk/python/host/nd_host_users.py | 80 --- plugins/pdk/python/host/nd_host_websocket.py | 182 ------ .../pdk/rust/nd-pdk-capabilities/Cargo.toml | 1 + .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 2 + .../rust/nd-pdk-capabilities/src/lyrics.rs | 21 +- .../rust/nd-pdk-capabilities/src/metadata.rs | 61 +-- .../rust/nd-pdk-capabilities/src/scrobbler.rs | 21 +- .../src/sonicsimilarity.rs | 63 +-- plugins/pdk/rust/nd-pdk-host/Cargo.toml | 1 + plugins/pdk/rust/nd-pdk-types/Cargo.toml | 15 + plugins/pdk/rust/nd-pdk-types/src/lib.rs | 75 +++ plugins/pdk/rust/nd-pdk/Cargo.toml | 1 + plugins/pdk/rust/nd-pdk/src/lib.rs | 3 + plugins/scrobbler_adapter.go | 7 +- plugins/sonic_similarity_adapter.go | 5 +- plugins/testdata/test-metadata-agent/main.go | 21 +- .../testdata/test-sonic-similarity/main.go | 5 +- plugins/types/types.go | 42 ++ 91 files changed, 2130 insertions(+), 4392 deletions(-) delete mode 100644 plugins/cmd/ndpgen/internal/templates/client.py.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/types.go.tmpl create mode 100644 plugins/cmd/ndpgen/internal/templates/types.rs.tmpl delete mode 100644 plugins/cmd/ndpgen/testdata/codec_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/config_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/counter_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/echo_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/list_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/math_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/meta_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/ping_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/search_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/store_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/users_client_expected.py create mode 100644 plugins/pdk/go/types/types.go delete mode 100644 plugins/pdk/python/host/nd_host_artwork.py delete mode 100644 plugins/pdk/python/host/nd_host_cache.py delete mode 100644 plugins/pdk/python/host/nd_host_config.py delete mode 100644 plugins/pdk/python/host/nd_host_http.py delete mode 100644 plugins/pdk/python/host/nd_host_httpclient.py delete mode 100644 plugins/pdk/python/host/nd_host_kvstore.py delete mode 100644 plugins/pdk/python/host/nd_host_library.py delete mode 100644 plugins/pdk/python/host/nd_host_scheduler.py delete mode 100644 plugins/pdk/python/host/nd_host_subsonicapi.py delete mode 100644 plugins/pdk/python/host/nd_host_task.py delete mode 100644 plugins/pdk/python/host/nd_host_users.py delete mode 100644 plugins/pdk/python/host/nd_host_websocket.py create mode 100644 plugins/pdk/rust/nd-pdk-types/Cargo.toml create mode 100644 plugins/pdk/rust/nd-pdk-types/src/lib.rs create mode 100644 plugins/types/types.go diff --git a/Makefile b/Makefile index e303017c7..90a405de8 100644 --- a/Makefile +++ b/Makefile @@ -113,10 +113,11 @@ wire: check_go_env ##@Development Update Dependency Injection gen: check_go_env ##@Development Run go generate for code generation go generate ./... - cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host - cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust - cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust - cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities + cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust + cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types + cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types go mod tidy -C plugins/pdk/go .PHONY: gen diff --git a/plugins/README.md b/plugins/README.md index 048cf549d..b9118d36f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1030,8 +1030,6 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` -**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). - ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md index fca3cbd31..2ad4a82da 100644 --- a/plugins/capabilities/README.md +++ b/plugins/capabilities/README.md @@ -76,7 +76,7 @@ The YAML schemas in this package are automatically generated from the capability To regenerate the schemas after modifying the interfaces, run: ```bash -cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities +cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types ``` ## Resources diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 04dd283dd..a64897509 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -9,20 +9,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name GetLyricsRequest: description: GetLyricsRequest contains the track information for lyrics lookup. properties: @@ -124,3 +110,21 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 935594abd..f856562c6 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // MetadataAgent provides artist and album metadata retrieval. // This capability allows plugins to provide external metadata for artists and albums, // such as biographies, images, similar artists, and top songs. @@ -102,10 +104,13 @@ type SimilarArtistsRequest struct { Limit int32 `json:"limit"` } +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // ImageInfo represents an image with URL and size. @@ -134,34 +139,13 @@ type TopSongsRequest struct { Count int32 `json:"count"` } -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` -} +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // AlbumRequest is the common request for album-related functions. @@ -235,5 +219,5 @@ type SimilarSongsByArtistRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index c90ac1ce0..cae23eadc 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -173,20 +173,6 @@ components: description: MBID is the MusicBrainz ID for the artist. required: - mbid - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name ArtistRequest: description: ArtistRequest is the common request for artist-related functions. properties: @@ -331,8 +317,61 @@ components: $ref: '#/components/schemas/SongRef' required: - songs + TopSongsRequest: + description: TopSongsRequest is the request for GetArtistTopSongs. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID. + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist (if known). + count: + type: integer + format: int32 + description: Count is the maximum number of top songs to return. + required: + - id + - name + - count + TopSongsResponse: + description: TopSongsResponse is the response for GetArtistTopSongs. + properties: + songs: + type: array + description: Songs is the list of top songs. + items: + $ref: '#/components/schemas/SongRef' + required: + - songs + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name SongRef: - description: SongRef is a reference to a song with metadata for matching. + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. properties: id: type: string @@ -369,33 +408,3 @@ components: description: Duration is the song duration in seconds. required: - name - TopSongsRequest: - description: TopSongsRequest is the request for GetArtistTopSongs. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist (if known). - count: - type: integer - format: int32 - description: Count is the maximum number of top songs to return. - required: - - id - - name - - count - TopSongsResponse: - description: TopSongsResponse is the response for GetArtistTopSongs. - properties: - songs: - type: array - description: Songs is the list of top songs. - items: - $ref: '#/components/schemas/SongRef' - required: - - songs diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 4918d5e8f..c1c05839a 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // Scrobbler provides scrobbling functionality to external services. // This capability allows plugins to submit listening history to services like Last.fm, // ListenBrainz, or custom scrobbling backends. @@ -32,16 +34,6 @@ type IsAuthorizedRequest struct { Username string `json:"username"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. @@ -55,9 +47,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 9d5cfed30..07af8ec7a 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -25,20 +25,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name IsAuthorizedRequest: description: IsAuthorizedRequest is the request for authorization check. properties: @@ -194,3 +180,21 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go index aadb9396e..a35d9d923 100644 --- a/plugins/capabilities/sonic_similarity.go +++ b/plugins/capabilities/sonic_similarity.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // SonicSimilarity provides audio-similarity based track discovery. // //nd:capability name=sonicsimilarity required=true @@ -12,14 +14,14 @@ type SonicSimilarity interface { } type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } type SonicSimilarityResponse struct { @@ -27,6 +29,6 @@ type SonicSimilarityResponse struct { } type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index 1d35e1e92..f0951fcec 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -16,20 +16,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name FindSonicPathRequest: properties: startSong: @@ -53,8 +39,49 @@ components: required: - song - count + SonicMatch: + properties: + song: + $ref: '#/components/schemas/SongRef' + similarity: + type: number + format: float + required: + - song + - similarity + SonicSimilarityResponse: + properties: + matches: + type: array + items: + $ref: '#/components/schemas/SonicMatch' + required: + - matches + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name SongRef: - description: SongRef is a reference to a song with metadata for matching. + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. properties: id: type: string @@ -91,21 +118,3 @@ components: description: Duration is the song duration in seconds. required: - name - SonicMatch: - properties: - song: - $ref: '#/components/schemas/SongRef' - similarity: - type: number - format: float - required: - - song - - similarity - SonicSimilarityResponse: - properties: - matches: - type: array - items: - $ref: '#/components/schemas/SonicMatch' - required: - - matches diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md index d2f67a60c..7487db892 100644 --- a/plugins/cmd/ndpgen/README.md +++ b/plugins/cmd/ndpgen/README.md @@ -7,7 +7,7 @@ This tool is the unified code generator that handle both host function wrappers ## Usage ```bash -ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust] +ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-rust] ``` ### Flags @@ -21,10 +21,9 @@ ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] | `-dry-run` | Parse and validate without writing files | `false` | | `-host-only` | Generate only host function wrappers (capability support TBD) | `true` | | `-go` | Generate Go client wrappers | `true`* | -| `-python` | Generate Python client wrappers | `false` | | `-rust` | Generate Rust client wrappers | `false` | -\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages. +\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages. ### Example @@ -150,10 +149,6 @@ func TestMyPluginFunction(t *testing.T) { If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations. -### Python Client Library - -When using `-python`, Python client files are generated in a `python/` subdirectory. - ### Rust Client Library When using `-rust`, Rust client files are generated in a `rust/` subdirectory. diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index db500c1fc..d8bc5859d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -176,16 +176,15 @@ type ServiceB interface { Describe("code generation", func() { DescribeTable("generates correct client output", - func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) { + func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { serviceCode := readTestdata(serviceFile) goClientExpected := readTestdata(goClientExpectedFile) - pyClientExpected := readTestdata(pyClientExpectedFile) rsClientExpected := readTestdata(rsClientExpectedFile) Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - // Generate all client code (Go, Python, Rust) - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust") + // Generate all client code (Go, Rust) + cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) @@ -217,17 +216,6 @@ type ServiceB interface { Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch") - // Verify Python client code (now in $output/python/host/) - pythonHostDir := filepath.Join(outputDir, "python", "host") - pyClientEntries, err := os.ReadDir(pythonHostDir) - Expect(err).ToNot(HaveOccurred()) - Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file") - - pyClientActual, err := os.ReadFile(filepath.Join(pythonHostDir, pyClientEntries[0].Name())) - Expect(err).ToNot(HaveOccurred()) - - Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch") - // Verify Rust client code (now in $output/rust/nd-pdk-host/src/) rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src") rsClientEntries, err := os.ReadDir(rustSrcDir) @@ -251,39 +239,59 @@ type ServiceB interface { }, Entry("simple string params", - "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"), + "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), Entry("multiple simple params (int32)", - "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"), + "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), Entry("struct param with request type", - "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"), + "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), Entry("mixed simple and complex params", - "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"), + "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), Entry("method without error", - "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"), + "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), Entry("no params, error only", - "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"), + "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), Entry("map and interface types", - "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"), + "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), Entry("pointer types", - "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"), + "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), Entry("multiple returns", - "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"), + "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), Entry("bytes", - "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"), + "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), Entry("option pattern (value, exists bool)", - "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), + "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), ) + It("generates the shared Go types package with -shared-types", func() { + typesSrc := `package types + +// ArtistRef references an artist. +type ArtistRef struct { + ID string ` + "`json:\"id,omitempty\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed()) + cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go") + out, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out) + + content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("package types")) + Expect(string(content)).To(ContainSubstring("type ArtistRef struct {")) + }) + It("generates compilable client code for comprehensive service", func() { serviceCode := readTestdata("comprehensive_service.go.txt") @@ -386,119 +394,6 @@ var _ = ndpdk.ComprehensiveNoParams Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile()) }) - It("generates Python client code with -python flag", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - - pythonFile := filepath.Join(pythonHostDir, "nd_host_test.py") - Expect(pythonFile).To(BeAnExistingFile()) - - content, err := os.ReadFile(pythonFile) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):")) - Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`)) - Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:")) - }) - - It("generates both Go and Python client code with -go -python flags", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Go client code exists in $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile()) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - Expect(filepath.Join(pythonHostDir, "nd_host_test.py")).To(BeAnExistingFile()) - }) - - It("generates Python code with dataclass for multi-value returns", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Cache permission=cache -type CacheService interface { - //nd:hostfunc - GetString(ctx context.Context, key string) (value string, exists bool, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_cache.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("@dataclass")) - Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(contentStr).To(ContainSubstring("value: str")) - Expect(contentStr).To(ContainSubstring("exists: bool")) - Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - }) - - It("generates Python code for methods with no parameters", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Ping(ctx context.Context) (status string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_test.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("def test_ping() -> str:")) - Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) }) }) diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 514da578b..50f53cd37 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -4,6 +4,7 @@ import ( "bytes" "embed" "fmt" + "slices" "strings" "text/template" ) @@ -79,17 +80,6 @@ func mockAccessor(typ string, idx int) string { } } -// pythonFuncMap returns the template functions for Python client code generation. -func pythonFuncMap(svc Service) template.FuncMap { - return template.FuncMap{ - "lower": strings.ToLower, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) }, - "pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) }, - "pythonDefault": pythonDefaultValue, - } -} - // GenerateHost generates the host function wrapper code for a service. func GenerateHost(svc Service, pkgName string) ([]byte, error) { tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl") @@ -186,51 +176,13 @@ func formatDoc(doc string) string { return strings.Join(result, "\n") } -// GenerateClientPython generates Python client wrapper code for plugins. -func GenerateClientPython(svc Service) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Python client template: %w", err) - } - - tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// pythonDefaultValue returns a Python default value for response.get() calls. -func pythonDefaultValue(p Param) string { - switch p.Type { - case "string": - return `, ""` - case "int", "int32", "int64": - return ", 0" - case "float32", "float64": - return ", 0.0" - case "bool": - return ", False" - case "[]byte": - return ", b\"\"" - default: - return ", None" - } -} - // rustFuncMap returns the template functions for Rust client code generation. func rustFuncMap(svc Service) template.FuncMap { knownStructs := svc.KnownStructs() + shared := make(map[string]string) + for _, a := range svc.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "lower": strings.ToLower, "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, @@ -238,9 +190,9 @@ func rustFuncMap(svc Service) template.FuncMap { "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, "rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) }, "rustDocComment": RustDocComment, - "rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) }, - "rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, + "rustType": func(p Param) string { return p.RustTypeWithShared(knownStructs, shared) }, + "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) }, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, } } @@ -388,6 +340,18 @@ func indentText(n int, s string) string { return strings.Join(lines, "\n") } +// indentSpaces adds n spaces to each non-empty line of text. +func indentSpaces(spaces int, s string) string { + ind := strings.Repeat(" ", spaces) + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = ind + line + } + } + return strings.Join(lines, "\n") +} + // capabilityAgentName returns the interface name for a capability. // Uses the Go interface name stripped of common suffixes. func capabilityAgentName(cap Capability) string { @@ -459,6 +423,10 @@ func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) { // rustCapabilityFuncMap returns template functions for Rust capability code generation. func rustCapabilityFuncMap(cap Capability) template.FuncMap { knownStructs := cap.KnownStructs() + shared := make(map[string]string) + for _, a := range cap.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "rustDocComment": RustDocComment, "rustTypeAlias": rustTypeAlias, @@ -466,25 +434,25 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap { "rustConstName": rustConstName, "rustFieldName": func(name string) string { return ToSnakeCase(name) }, "rustMethodName": func(name string) string { return ToSnakeCase(name) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, - "rustOutputType": rustOutputType, - "isPrimitiveRust": isPrimitiveRustType, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, + "rustOutputType": func(goType string) string { return rustTraitType(goType, shared) }, + "rustMethodType": func(goType string) string { return rustMethodType(goType, cap.Name, shared) }, "skipSerializingFunc": skipSerializingFunc, "hasHashMap": hasHashMap, "agentName": capabilityAgentName, "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, - "snakeCase": ToSnakeCase, - "indent": func(spaces int, s string) string { - indent := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") + "rustSharedTarget": func(target string) string { + return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix) }, + // rustSharedNote is the human-facing path for deprecation notes: plugin + // authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types + // as `types`, so they reference these via nd_pdk::types::X. + "rustSharedNote": func(target string) string { + return "nd_pdk::types::" + strings.TrimPrefix(target, sharedTypesPrefix) + }, + "snakeCase": ToSnakeCase, + "indent": indentSpaces, } } @@ -526,6 +494,46 @@ func rustConstType(goType string) string { // TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly. // Currently "*string" returns "string" instead of "String". This would generate invalid // Rust code. No current capability uses this pattern, but it should be fixed if needed. +// rustMethodType returns the fully-qualified Rust type for a capability method +// input/output as referenced inside the generated export macro. The macro expands +// in the downstream plugin crate, which depends on the umbrella nd-pdk crate and +// not on nd-pdk-types directly, so shared types must be reached through $crate +// (the defining nd-pdk-capabilities crate, which re-exports nd_pdk_types as +// `types`) rather than by naming the transitive crate. Primitives map to their +// Rust name; any other named type is a capability-local struct, qualified as +// $crate::<package>::X. This is used instead of hand-assembling +// "$crate::<pkg>::" + rustOutputType, which produced invalid paths like +// "$crate::demo::types.SongRef" for shared types used directly in a signature. +func rustMethodType(goType, pkg string, shared map[string]string) string { + goType = strings.TrimPrefix(goType, "*") + if isPrimitiveRustType(goType) { + return rustOutputType(goType) + } + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "$crate::types::" + rest + } + if t, ok := shared[goType]; ok { + return "$crate::types::" + strings.TrimPrefix(t, "nd_pdk_types::") + } + return "$crate::" + ToSnakeCase(pkg) + "::" + goType +} + +// rustTraitType returns the Rust type for a capability trait method signature. +// The trait lives in the capability module alongside its local structs, so those +// stay bare; shared types must still resolve to their nd_pdk_types::X crate path +// (a shared type used directly in a signature would otherwise pass through as the +// invalid Go selector "types.SongRef"). +func rustTraitType(goType string, shared map[string]string) string { + stripped := strings.TrimPrefix(goType, "*") + if rest, ok := strings.CutPrefix(stripped, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + if t, ok := shared[stripped]; ok { + return t + } + return rustOutputType(goType) +} + func rustOutputType(goType string) string { // Strip pointer prefix - capability outputs use Result<T, Error> for optionality if strings.HasPrefix(goType, "*") { @@ -601,9 +609,9 @@ func skipSerializingFunc(goType string) string { } } -// hasHashMap returns true if any struct in the capability uses HashMap. -func hasHashMap(cap Capability) bool { - for _, st := range cap.Structs { +// anyFieldUsesHashMap returns true if any field in the given structs uses a map type. +func anyFieldUsesHashMap(structs []StructDef) bool { + for _, st := range structs { for _, f := range st.Fields { if strings.HasPrefix(f.Type, "map[") { return true @@ -613,6 +621,32 @@ func hasHashMap(cap Capability) bool { return false } +// anyFieldIsByteSlice reports whether any field across the given structs is a +// []byte, which Go's JSON encoder serializes as a base64 string. The Rust +// shared-types crate must match that with a base64_bytes serde override. +func anyFieldIsByteSlice(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + +// hasHashMap returns true if any struct in the capability uses HashMap. +func hasHashMap(cap Capability) bool { + return anyFieldUsesHashMap(cap.Structs) +} + +// sortedStructs returns a sorted copy of structs, ordered by name. +func sortedStructs(structs []StructDef) []StructDef { + sorted := append([]StructDef(nil), structs...) + slices.SortFunc(sorted, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return sorted +} + // registerMacroName returns the macro name for registering an optional method. // For package "websocket" and method "OnClose", returns "register_websocket_close". func registerMacroName(pkg, name string) string { @@ -669,6 +703,11 @@ func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) { buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n") buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n") + // Re-export the shared types so generated registration macros can reference them + // via $crate::types::X. The macro expands in the downstream plugin crate, which + // depends on the umbrella nd-pdk crate and not on nd-pdk-types directly. + buf.WriteString("pub use nd_pdk_types as types;\n\n") + // Module declarations for _, cap := range capabilities { moduleName := ToSnakeCase(cap.Name) @@ -894,3 +933,70 @@ func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) { return buf.Bytes(), nil } + +// GenerateSharedTypesRust generates the nd-pdk-types crate root (lib.rs). +func GenerateSharedTypesRust(structs []StructDef) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types rust template: %w", err) + } + sorted := sortedStructs(structs) + known := map[string]bool{} + for _, s := range sorted { + known[s.Name] = true + } + tmpl, err := template.New("types_rs").Funcs(template.FuncMap{ + "rustDocComment": RustDocComment, + "rustFieldName": func(n string) string { return ToSnakeCase(n) }, + "fieldRustType": func(f FieldDef) string { return f.RustType(known) }, + "skipSerializingFunc": skipSerializingFunc, + "indent": indentSpaces, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + + data := struct { + Structs []StructDef + HasHashMap bool + HasByteFields bool + }{Structs: sorted, HasHashMap: anyFieldUsesHashMap(sorted), HasByteFields: anyFieldIsByteSlice(sorted)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} + +// GenerateSharedTypesGo generates the shared `types` package (plain data structs). +func GenerateSharedTypesGo(structs []StructDef, pkgName string) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.go.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types template: %w", err) + } + tmpl, err := template.New("types").Funcs(template.FuncMap{ + "formatDoc": formatDoc, + "indent": indentText, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + data := struct { + Package string + Structs []StructDef + }{Package: pkgName, Structs: sortedStructs(structs)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 052aa146c..d52d6dcbf 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -418,266 +418,19 @@ var _ = Describe("Generator", func() { }) }) - Describe("Python type and name helpers", func() { - Describe("ToPythonType", func() { - It("should map Go types to Python types", func() { - Expect(ToPythonType("string")).To(Equal("str")) - Expect(ToPythonType("int")).To(Equal("int")) - Expect(ToPythonType("int32")).To(Equal("int")) - Expect(ToPythonType("int64")).To(Equal("int")) - Expect(ToPythonType("float32")).To(Equal("float")) - Expect(ToPythonType("float64")).To(Equal("float")) - Expect(ToPythonType("bool")).To(Equal("bool")) - Expect(ToPythonType("[]byte")).To(Equal("bytes")) - Expect(ToPythonType("unknown")).To(Equal("Any")) - }) + Describe("ToSnakeCase", func() { + It("should convert PascalCase to snake_case", func() { + Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) + Expect(ToSnakeCase("GetString")).To(Equal("get_string")) + Expect(ToSnakeCase("simple")).To(Equal("simple")) }) - Describe("ToSnakeCase", func() { - It("should convert PascalCase to snake_case", func() { - Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) - Expect(ToSnakeCase("GetString")).To(Equal("get_string")) - Expect(ToSnakeCase("simple")).To(Equal("simple")) - }) - - It("should handle acronyms correctly", func() { - Expect(ToSnakeCase("ID")).To(Equal("id")) - Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) - Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) - Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) - Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) - }) - }) - - Describe("Method.PythonFunctionName", func() { - It("should generate snake_case function name with service prefix", func() { - m := Method{Name: "GetString"} - Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string")) - }) - }) - - Describe("Param.PythonType", func() { - It("should return Python type for parameter", func() { - p := NewParam("value", "string") - Expect(p.PythonType()).To(Equal("str")) - }) - }) - - Describe("Param.PythonName", func() { - It("should return snake_case name for parameter", func() { - p := NewParam("ttlSeconds", "int64") - Expect(p.PythonName()).To(Equal("ttl_seconds")) - }) - }) - }) - - Describe("GenerateClientPython", func() { - It("should generate valid Python code for a simple service", func() { - svc := Service{ - Name: "SubsonicAPI", - Permission: "subsonicapi", - Interface: "SubsonicAPIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("responseJSON", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for imports - Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass")) - Expect(codeStr).To(ContainSubstring("import extism")) - Expect(codeStr).To(ContainSubstring("import json")) - - // Check for exception class - Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):")) - - // Check for raw import function - Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`)) - Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:")) - - // Check for wrapper function with type hints - Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:")) - - // Check for error handling - Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response[")) - }) - - It("should generate dataclass for multi-value returns", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Methods: []Method{ - { - Name: "GetString", - HasError: true, - Params: []Param{NewParam("key", "string")}, - Returns: []Param{ - NewParam("value", "string"), - NewParam("exists", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for dataclass - Expect(codeStr).To(ContainSubstring("@dataclass")) - Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("value: str")) - Expect(codeStr).To(ContainSubstring("exists: bool")) - - // Check that function returns dataclass - Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("return CacheGetStringResult(")) - }) - - It("should handle methods with no parameters", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoParams", - HasError: true, - Returns: []Param{NewParam("result", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function with no params - Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:")) - // Empty request - Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) - - It("should handle methods with no return values", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoReturn", - HasError: true, - Params: []Param{NewParam("input", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function returns None - Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:")) - }) - - It("should generate correct Python defaults for different types", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "AllTypes", - HasError: true, - Returns: []Param{ - NewParam("strVal", "string"), - NewParam("intVal", "int64"), - NewParam("floatVal", "float64"), - NewParam("boolVal", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check defaults in response.get() calls - Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`)) - Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) - }) - - It("should not import base64 for non-byte services", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - Expect(codeStr).NotTo(ContainSubstring("import base64")) - }) - - It("should generate base64 encoding/decoding for byte fields", func() { - svc := Service{ - Name: "Codec", - Permission: "codec", - Interface: "CodecService", - Methods: []Method{ - { - Name: "Encode", - HasError: true, - Params: []Param{NewParam("data", "[]byte")}, - Returns: []Param{NewParam("result", "[]byte")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should import base64 - Expect(codeStr).To(ContainSubstring("import base64")) - - // Should base64-encode byte params in request - Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`)) - - // Should base64-decode byte returns in response - Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`)) + It("should handle acronyms correctly", func() { + Expect(ToSnakeCase("ID")).To(Equal("id")) + Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) + Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) + Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) + Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) }) }) @@ -1186,6 +939,73 @@ type TestService interface { Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType =")) Expect(codeStr).To(ContainSubstring(`"retry"`)) }) + + It("emits a deprecated alias and types import for a shared-aliased capability", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}, + }}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo.")) + Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo")) + Expect(out).NotTo(ContainSubstring("type TrackInfo struct")) + }) + + It("emits the types import for a direct types.X field with no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}, + }}}, + // No SharedAliases: the field references the canonical type directly. + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) + + It("emits the types import for a direct types.X method input/output", func() { + cap := Capability{ + Name: "demo", + Interface: "Demo", + Required: true, + Methods: []Export{{ + Name: "Lookup", ExportName: "nd_demo_lookup", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}, + }}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityGo(cap, "demo") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) }) Describe("GenerateCapabilityGoStub", func() { @@ -1287,6 +1107,55 @@ type OnInitOutput struct { }) }) +var _ = Describe("Shared Types Generation", func() { + It("emits a Rust types crate root with serde derives", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};")) + Expect(out).To(ContainSubstring("pub struct ArtistRef {")) + Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`)) + Expect(out).To(ContainSubstring("pub name: String,")) + }) + + It("emits a flat Go types package with no imports", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesGo(structs, "types") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("package types")) + Expect(out).To(ContainSubstring("type ArtistRef struct {")) + Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`")) + Expect(out).To(ContainSubstring("Name string `json:\"name\"`")) + Expect(out).NotTo(ContainSubstring("import")) + }) + + It("emits base64 serde for Vec<u8> fields in the Rust types crate", func() { + structs := []StructDef{ + {Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{ + {Name: "Data", Type: "[]byte", JSONTag: "data"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("mod base64_bytes")) + Expect(out).To(ContainSubstring("use base64::Engine as _")) + Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) + }) +}) + var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { It("should return Option::is_none for pointer types", func() { @@ -1486,6 +1355,109 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("Option<")) }) + It("translates a shared type used directly as a method input/output", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The shared type must resolve to the canonical crate path, not pass through + // as the invalid Go selector `types.SongRef`. + Expect(out).To(ContainSubstring("nd_pdk_types::SongRef")) + Expect(out).NotTo(ContainSubstring("types.SongRef")) + }) + + It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Inside the export macro (expanded in the downstream plugin crate, which depends + // on the umbrella nd-pdk only), the shared type must be reachable via $crate, not + // by naming the transitive nd_pdk_types crate directly. + Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>")) + Expect(out).NotTo(ContainSubstring("extism_pdk::Json<nd_pdk_types::SongRef>")) + }) + + It("emits a deprecated Rust type alias for shared types", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`); + // the alias target stays the real crate path so it resolves inside nd-pdk-capabilities. + Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`)) + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;")) + }) + + It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() { + // Wrapper has a field whose type is only in SharedAliases, not Structs. + // The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical + // path), not as the local deprecated alias and not as serde_json::Value. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field must use the canonical nd_pdk_types:: path, not the local alias. + Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,")) + }) + + It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() { + // The capability references the shared type by its canonical qualified + // name (types.Track) while the deprecated alias keeps the old name. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Doc: "Deprecated: use types.Track.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field uses the canonical qualified path (resolved from the types. prefix). + Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,")) + // The deprecated alias is still re-exported under its kept name. + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + }) + It("should include all float types correctly", func() { cap := Capability{ Name: "test", @@ -1637,6 +1609,36 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) }) + It("resolves a shared alias used in a method param/return to its canonical crate path", func() { + svc := Service{ + Name: "Matcher", + Permission: "matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("query", "string")}, + // Return uses the deprecated alias name directly. + Returns: []Param{NewParam("matches", "[]Track")}, + }, + }, + SharedAliases: []SharedAlias{{ + Name: "Track", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The alias must resolve to the shared crate type; a bare `Track` is undefined + // in nd-pdk-host and would not compile. + Expect(out).To(ContainSubstring("nd_pdk_types::Track")) + Expect(out).NotTo(ContainSubstring("Vec<Track>")) + }) + It("should not generate base64 module when no byte fields", func() { svc := Service{ Name: "Test", diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d4..4b832d22d 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -27,31 +27,81 @@ var ( keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`) ) -// ParseDirectory parses all Go source files in a directory and extracts host services. -func ParseDirectory(dir string) ([]Service, error) { +// parsedGoFile pairs a source path with its already-parsed AST. +type parsedGoFile struct { + path string + file *ast.File +} + +// parseGoFiles returns the eligible Go source files in dir, each parsed once. +func parseGoFiles(dir string, fset *token.FileSet) ([]parsedGoFile, error) { + paths, err := goSourceFiles(dir) + if err != nil { + return nil, err + } + out := make([]parsedGoFile, 0, len(paths)) + for _, p := range paths { + f, err := parser.ParseFile(fset, p, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(p), err) + } + out = append(out, parsedGoFile{path: p, file: f}) + } + return out, nil +} + +// goSourceFiles returns the Go source file paths in dir, excluding generated, +// test, and doc files. +func goSourceFiles(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading directory: %w", err) } - - var services []Service - fset := token.NewFileSet() - + var paths []string for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { continue } - // Skip generated files and test files - if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") { + if strings.HasSuffix(name, "_gen.go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" { continue } + paths = append(paths, filepath.Join(dir, name)) + } + return paths, nil +} - path := filepath.Join(dir, entry.Name()) - parsed, err := parseFile(fset, path) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) +// ParseDirectory parses all Go source files in a directory and extracts host services. +func ParseDirectory(dir string) ([]Service, error) { + return ParseDirectoryWithShared(dir, nil) +} + +// ParseDirectoryWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) + if err != nil { + return nil, err + } + + // First pass: collect all type aliases from every file so that an alias + // declared in one file is visible when resolving types in a sibling file. + pkgAliasMap := make(map[string]TypeAlias) + for _, pf := range parsed { + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a } - services = append(services, parsed...) + } + + // Second pass: parse services using the package-level alias map. + var services []Service + for _, pf := range parsed { + svcList, err := parseServiceFile(pf.file, pkgAliasMap, shared) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) + } + services = append(services, svcList...) } return services, nil @@ -59,66 +109,72 @@ func ParseDirectory(dir string) ([]Service, error) { // ParseCapabilities parses all Go source files in a directory and extracts capabilities. func ParseCapabilities(dir string) ([]Capability, error) { - entries, err := os.ReadDir(dir) + return ParseCapabilitiesWithShared(dir, nil) +} + +// ParseCapabilitiesWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) if err != nil { - return nil, fmt.Errorf("reading directory: %w", err) + return nil, err } - fset := token.NewFileSet() - - // First pass: collect all structs and type aliases from all files in the package - sharedStructMap := make(map[string]StructDef) - sharedAliasMap := make(map[string]TypeAlias) + // First pass: collect all structs, type aliases, and const groups. + pkgStructMap := make(map[string]StructDef) + pkgAliasMap := make(map[string]TypeAlias) var allConstGroups []ConstGroup - var goFiles []string - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { - continue + for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s } - // Skip generated files, test files, and doc.go - if strings.HasSuffix(entry.Name(), "_gen.go") || - strings.HasSuffix(entry.Name(), "_test.go") || - entry.Name() == "doc.go" { - continue + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a } - goFiles = append(goFiles, filepath.Join(dir, entry.Name())) + allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...) } - for _, path := range goFiles { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing %s for types: %w", filepath.Base(path), err) - } - for _, s := range parseStructs(f) { - sharedStructMap[s.Name] = s - } - for _, a := range parseTypeAliases(f) { - sharedAliasMap[a.Name] = a - } - allConstGroups = append(allConstGroups, parseConstGroups(f)...) - } - - // Second pass: parse capabilities using the shared type maps + // Second pass: parse capabilities using the package-level type maps. var capabilities []Capability - for _, path := range goFiles { - parsed, err := parseCapabilityFile(fset, path, sharedStructMap, sharedAliasMap, allConstGroups) + for _, pf := range parsed { + capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared) if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) } - capabilities = append(capabilities, parsed...) + capabilities = append(capabilities, capList...) } return capabilities, nil } -// parseCapabilityFile parses a single Go source file and extracts capabilities. -func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup) ([]Capability, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err +// LoadSharedTypes parses every struct defined in dir (the shared `types` source +// package) and returns them keyed by name. dir == "" yields an empty map. +func LoadSharedTypes(dir string) (map[string]StructDef, error) { + result := map[string]StructDef{} + if dir == "" { + return result, nil } + paths, err := goSourceFiles(dir) + if err != nil { + return nil, fmt.Errorf("reading shared types directory: %w", err) + } + fset := token.NewFileSet() + for _, path := range paths { + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + } + for _, s := range parseStructs(f) { + result[s.Name] = s + } + } + return result, nil +} +// parseCapabilityFile parses a single Go source file and extracts capabilities. +func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup, shared map[string]StructDef) ([]Capability, error) { var capabilities []Capability for _, decl := range f.Decls { @@ -190,6 +246,21 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // Recursively collect all struct dependencies collectAllStructDependencies(referencedTypes, structMap) + // Resolve shared-type aliases against the registry + sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared) + if err != nil { + return nil, err + } + capability.SharedAliases = sharedAliases + capability.SharedTypes = sharedTypes + + // Build a set of names already covered by SharedAliases so we don't + // emit them again in TypeAliases (which would cause a redeclaration). + sharedAliasNames := make(map[string]bool, len(capability.SharedAliases)) + for _, sa := range capability.SharedAliases { + sharedAliasNames[sa.Name] = true + } + // Sort type names for stable output order sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes)) @@ -200,8 +271,11 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] } } - // Attach referenced type aliases + // Attach referenced type aliases (skip those already in SharedAliases) for _, typeName := range sortedTypeNames { + if sharedAliasNames[typeName] { + continue + } if a, exists := aliasMap[typeName]; exists { capability.TypeAliases = append(capability.TypeAliases, a) } @@ -211,6 +285,9 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // This supports error types that are not directly referenced in method signatures interfaceName := typeSpec.Name.Name for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) { + if sharedAliasNames[typeName] { + continue + } a := aliasMap[typeName] if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] { capability.TypeAliases = append(capability.TypeAliases, a) @@ -237,6 +314,111 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] return capabilities, nil } +// resolveSharedAliases determines which shared `types` package structs a host +// service or capability uses and returns the deprecated re-export aliases to emit +// for them. +// +// A shared type counts as used when a field references it by qualified name +// (e.g. types.Track) or via a declared alias used by bare name (e.g. a field of +// type TrackInfo where `type TrackInfo = types.Track`). The shared struct's own +// fields are followed transitively so nested shared types are picked up too. For +// every used canonical type, each declared `type X = types.Canonical` alias is +// emitted as a SharedAlias so the generated PDK keeps re-exporting it for +// backwards compatibility. +// +// It returns the deprecated re-export aliases to emit and the resolved shapes of +// every used shared type (alias or not, for schema inlining). +// +// Returns an error if a referenced shared type cannot be found in the shared registry. +func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) { + // Index declared shared aliases by the canonical type they target, e.g. + // "Track" -> [TrackInfo]. A canonical type may have more than one alias. + aliasesByCanonical := map[string][]TypeAlias{} + for _, a := range aliasMap { + if a.IsSharedAlias() { + canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix) + aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a) + } + } + + // Walk the referenced types, following nested shared references inside the + // shared structs, to find the set of canonical shared types used. + used := map[string]bool{} + var queue []string + for name := range referenced { + if c, ok := seedSharedCanonical(name, aliasMap); ok { + queue = append(queue, c) + } + } + fieldRefs := map[string]bool{} + for len(queue) > 0 { + canonical := queue[0] + queue = queue[1:] + if used[canonical] { + continue + } + def, ok := shared[canonical] + if !ok { + return nil, nil, fmt.Errorf( + "shared type %q could not be resolved: pass -shared=<dir> pointing at the shared types package, and ensure %s is defined there", + canonical, sharedTypesPrefix+canonical, + ) + } + used[canonical] = true + for _, f := range def.Fields { + clear(fieldRefs) + collectReferencedTypes(f.Type, fieldRefs) + for t := range fieldRefs { + if c, ok := nestedSharedCanonical(t, shared); ok { + queue = append(queue, c) + } + } + } + } + + var out []SharedAlias + var usedDefs []StructDef + for canonical := range used { + usedDefs = append(usedDefs, shared[canonical]) + for _, a := range aliasesByCanonical[canonical] { + out = append(out, SharedAlias{Name: a.Name, Target: a.Type, Doc: a.Doc, Def: shared[canonical]}) + } + } + slices.SortFunc(out, func(a, b SharedAlias) int { return strings.Compare(a.Name, b.Name) }) + slices.SortFunc(usedDefs, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return out, usedDefs, nil +} + +// seedSharedCanonical maps a type token referenced by a capability/service field +// to the canonical shared type it denotes. It recognizes qualified references +// (types.X -> X) and declared shared aliases used by bare name (X where +// `type X = types.Y` -> Y). A bare name that is not a declared shared alias is +// not treated as shared, so a local struct sharing a name with a shared type is +// never misclassified. +func seedSharedCanonical(name string, aliasMap map[string]TypeAlias) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if a, ok := aliasMap[name]; ok && a.IsSharedAlias() { + return strings.TrimPrefix(a.Type, sharedTypesPrefix), true + } + return "", false +} + +// nestedSharedCanonical maps a type token found inside a shared struct's own +// fields to a canonical shared type. Within the shared package, types reference +// each other by bare name (e.g. Track.Artists is []ArtistRef), so any bare name +// present in the shared registry counts. +func nestedSharedCanonical(name string, shared map[string]StructDef) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if _, ok := shared[name]; ok { + return name, true + } + return "", false +} + // collectAllStructDependencies recursively collects all struct types referenced by other structs. func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) { // Keep iterating until no new types are added @@ -301,14 +483,10 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri return export, nil } -// parseFile parses a single Go source file and extracts host services. -func parseFile(fset *token.FileSet, path string) ([]Service, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err - } - - // First pass: collect all struct definitions in the file +// parseServiceFile parses a single Go source file and extracts host services. +// pkgAliasMap is the package-wide alias map built from all files in the package. +func parseServiceFile(f *ast.File, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { + // Collect all struct definitions in the file. allStructs := parseStructs(f) structMap := make(map[string]StructDef) for _, s := range allStructs { @@ -382,6 +560,15 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) { } } + // Resolve shared-type aliases against the registry. Host-service schemas + // are not generated (the -schemas pass is capability-only), so the resolved + // shared shapes are not needed here. + sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared) + if err != nil { + return nil, err + } + service.SharedAliases = sharedAliases + // Attach referenced structs to the service (sorted for stable output) for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { if s, exists := structMap[typeName]; exists { @@ -469,9 +656,10 @@ func parseTypeAliases(f *ast.File) []TypeAlias { docText, _ := getDocComment(genDecl, typeSpec) aliases = append(aliases, TypeAlias{ - Name: typeSpec.Name.Name, - Type: typeToString(typeSpec.Type), - Doc: cleanDoc(docText), + Name: typeSpec.Name.Name, + Type: typeToString(typeSpec.Type), + Doc: cleanDoc(docText), + IsAlias: typeSpec.Assign.IsValid(), }) } } @@ -640,6 +828,14 @@ func collectReferencedTypes(goType string, refs map[string]bool) { return } + // Qualified reference to the shared `types` package (e.g. types.Track). + // These start with a lowercase package selector, so they must be collected + // before the uppercase check below would skip them. + if strings.HasPrefix(goType, sharedTypesPrefix) { + refs[goType] = true + return + } + // Check if it's a custom type (starts with uppercase, not a builtin) if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' { switch goType { diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f43578397..93d00f657 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -212,6 +212,78 @@ type RegularInterface interface { Expect(err).NotTo(HaveOccurred()) Expect(services).To(BeEmpty()) }) + + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +type TrackInfo = types.TrackInfo +` + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + Match(ctx context.Context, t TrackInfo) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + _, err := ParseDirectoryWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases declared in a sibling file (package-wide alias map)", func() { + // File A: declares the shared-type alias in the same package + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type Track = types.Track +` + // File B: declares the host service that references Track from file A + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + MatchSongs(ctx context.Context, query string) (results []Track, err error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "Track": { + Name: "Track", + Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artist", Type: "string", JSONTag: "artist"}, + }, + }, + } + + services, err := ParseDirectoryWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range services[0].SharedAliases { + byName[a.Name] = a + } + // Track alias is in a sibling file — must be resolved package-wide + Expect(byName).To(HaveKey("Track")) + Expect(byName["Track"].Target).To(Equal("types.Track")) + }) }) Describe("parseKeyValuePairs", func() { @@ -520,6 +592,182 @@ type Output struct { Expect(capabilities[0].Methods).To(HaveLen(1)) Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod")) }) + + It("distinguishes Go type aliases from defined types", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// ScrobblerError is a sentinel error string. +type ScrobblerError string + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_check + Check(ArtistRef) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + } + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + // ArtistRef is a shared-type alias (types.*): it lands in SharedAliases, not TypeAliases. + sharedByName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + sharedByName[a.Name] = a + } + Expect(sharedByName).To(HaveKey("ArtistRef")) + Expect(sharedByName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // ScrobblerError is a plain defined type: it stays in TypeAliases. + typeByName := map[string]TypeAlias{} + for _, a := range caps[0].TypeAliases { + typeByName[a.Name] = a + } + Expect(typeByName).To(HaveKey("ScrobblerError")) + Expect(typeByName["ScrobblerError"].IsAlias).To(BeFalse()) + }) + }) + + Describe("ParseCapabilitiesWithShared", func() { + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// TrackInfo is an alias for the shared type. +type TrackInfo = types.TrackInfo + +//nd:capability name=nowplaying required=true +type NowPlaying interface { + //nd:export name=nd_now_playing + NowPlaying(TrackInfo) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "nowplaying.go"), []byte(src), 0600)).To(Succeed()) + + _, err := ParseCapabilitiesWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases against the registry", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "TrackInfo": {Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.TrackInfo. +type TrackInfo = types.TrackInfo + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track TrackInfo ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + names := []string{} + for _, a := range caps[0].SharedAliases { + names = append(names, a.Name) + } + // TrackInfo is referenced directly; ArtistRef is pulled in transitively via TrackInfo.Artists. + Expect(names).To(ContainElements("TrackInfo", "ArtistRef")) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + Expect(byName["TrackInfo"].Target).To(Equal("types.TrackInfo")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + }) + + It("resolves shared types from qualified types.X references with a renamed alias", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "Track": {Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type TrackInfo = types.Track + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track types.Track ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + // The deprecated alias keeps its name (TrackInfo) but now targets types.Track. + // ArtistRef is pulled in transitively via Track.Artists. + Expect(byName).To(HaveKey("TrackInfo")) + Expect(byName).To(HaveKey("ArtistRef")) + Expect(byName["TrackInfo"].Target).To(Equal("types.Track")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // The capability struct field keeps the canonical qualified reference. + var nowPlaying StructDef + for _, st := range caps[0].Structs { + if st.Name == "NowPlayingRequest" { + nowPlaying = st + } + } + Expect(nowPlaying.Fields).To(HaveLen(1)) + Expect(nowPlaying.Fields[0].Type).To(Equal("types.Track")) + }) }) Describe("Export helpers", func() { diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index ebcd80739..8b88f367c 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -8,9 +8,21 @@ package {{.Package}} import ( +{{- if .Capability.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 790ed93e4..2ee7ece1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -2,7 +2,7 @@ // // This file contains export wrappers for the {{.Capability.Interface}} capability. // It is intended for use in Navidrome plugins built with extism-pdk. -{{if .Capability.Structs}} +{{if or .Capability.Structs .Capability.SharedAliases}} use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; @@ -24,6 +24,13 @@ fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } {{- end}} +{{- /* Generate deprecated aliases to the shared types crate */ -}} +{{- range .Capability.SharedAliases}} + +#[deprecated(note = "use {{rustSharedNote .Target}}")] +pub type {{.Name}} = {{rustSharedTarget .Target}}; +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} @@ -131,9 +138,9 @@ macro_rules! register_{{snakeCase .Package}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; @@ -178,9 +185,9 @@ macro_rules! {{registerMacroName .Name}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index 90f72be93..59a886f08 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -7,6 +7,19 @@ //go:build !wasip1 package {{.Package}} +{{- if .Capability.ImportsSharedTypes}} + +import "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee04446..b49680d1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -12,10 +12,23 @@ import ( {{- if .Service.HasErrors}} "errors" {{- end}} +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate struct definitions */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl deleted file mode 100644 index 7ccaa6106..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ /dev/null @@ -1,111 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the {{.Service.Name}} host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -{{- if .Service.HasByteFields}} -import base64 -{{- end}} - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - -{{- /* Generate raw host function imports */ -}} -{{range .Service.Methods}} - - -@extism.import_fn("extism:host/user", "{{exportName .}}") -def _{{exportName .}}(offset: int) -> int: - """Raw host function - do not call directly.""" - ... -{{- end}} -{{- /* Generate dataclasses for multi-value returns */ -}} -{{range .Service.Methods}} -{{- if .NeedsResultClass}} - - -@dataclass -class {{pythonResultType .}}: - """Result type for {{pythonFunc .}}.""" -{{- range .Returns}} - {{.PythonName}}: {{.PythonType}} -{{- end}} -{{- end}} -{{- end}} -{{- /* Generate wrapper functions */ -}} -{{range .Service.Methods}} - - -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: - """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} -{{- if .HasParams}} - - Args: -{{- range .Params}} - {{.PythonName}}: {{.PythonType}} parameter. -{{- end}} -{{- end}} -{{- if .HasReturns}} - - Returns: -{{- if .NeedsResultClass}} - {{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}. -{{- else}} - {{(index .Returns 0).PythonType}}: The result value. -{{- end}} -{{- end}} - - Raises: - HostFunctionError: If the host function returns an error. - """ -{{- if .HasParams}} - request = { -{{- range .Params}} -{{- if .IsByteSlice}} - "{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"), -{{- else}} - "{{.JSONName}}": {{.PythonName}}, -{{- end}} -{{- end}} - } - request_bytes = json.dumps(request).encode("utf-8") -{{- else}} - request_bytes = b"{}" -{{- end}} - request_mem = extism.memory.alloc(request_bytes) - response_offset = _{{exportName .}}(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) -{{if .HasError}} - if response.get("error"): - raise HostFunctionError(response["error"]) -{{end}} -{{- if .NeedsResultClass}} - return {{pythonResultType .}}( -{{- range .Returns}} -{{- if .IsByteSlice}} - {{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")), -{{- else}} - {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}), -{{- end}} -{{- end}} - ) -{{- else if .HasReturns}} -{{- if (index .Returns 0).IsByteSlice}} - return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", "")) -{{- else}} - return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) -{{- end}} -{{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index da19df666..d2a8f3f27 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -8,7 +8,21 @@ package {{.Package}} -import "github.com/stretchr/testify/mock" +import ( +{{- if .Service.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + "github.com/stretchr/testify/mock" +) + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl new file mode 100644 index 000000000..698429b96 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl @@ -0,0 +1,23 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package {{.Package}} holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package {{.Package}} +{{- range .Structs}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- else}} +// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +type {{.Name}} struct { +{{- range .Fields}} +{{- if .Doc}} +{{formatDoc .Doc | indent 1}} +{{- end}} + {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl new file mode 100644 index 000000000..b7bc131ff --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl @@ -0,0 +1,49 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; +{{- if .HasHashMap}} +use std::collections::HashMap; +{{- end}} +{{- if .HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +{{- range .Structs}} + +{{- if .Doc}} +{{rustDocComment .Doc}} +{{- else}} +/// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct {{.Name}} { +{{- range .Fields}} +{{- if .Doc}} +{{rustDocComment .Doc | indent 4}} +{{- end}} +{{- if .OmitEmpty}} + #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] +{{- else}} + #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} + pub {{rustFieldName .Name}}: {{fieldRustType .}}, +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 6132dfbc4..09657d09e 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -5,34 +5,59 @@ import ( "unicode" ) +// sharedTypesPrefix is the package selector a capability or host-service source +// uses to reference the shared types package (e.g. types.Track). +const sharedTypesPrefix = "types." + // Service represents a parsed host service interface. type Service struct { - Name string // Service name from annotation (e.g., "SubsonicAPI") - Permission string // Manifest permission key (e.g., "subsonicapi") - Interface string // Go interface name (e.g., "SubsonicAPIService") - Methods []Method // Methods marked with //nd:hostfunc - Doc string // Documentation comment for the service - Structs []StructDef // Structs used by this service + Name string // Service name from annotation (e.g., "SubsonicAPI") + Permission string // Manifest permission key (e.g., "subsonicapi") + Interface string // Go interface name (e.g., "SubsonicAPIService") + Methods []Method // Methods marked with //nd:hostfunc + Doc string // Documentation comment for the service + Structs []StructDef // Structs used by this service + SharedAliases []SharedAlias // Aliases to types in the shared `types` package } // Capability represents a parsed capability interface for plugin exports. type Capability struct { - Name string // Package name from annotation (e.g., "metadata") - Interface string // Go interface name (e.g., "MetadataAgent") - Required bool // If true, all methods must be implemented - Methods []Export // Methods marked with //nd:export - Doc string // Documentation comment for the capability - Structs []StructDef // Structs used by this capability - TypeAliases []TypeAlias // Type aliases used by this capability - Consts []ConstGroup // Const groups used by this capability - SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + Name string // Package name from annotation (e.g., "metadata") + Interface string // Go interface name (e.g., "MetadataAgent") + Required bool // If true, all methods must be implemented + Methods []Export // Methods marked with //nd:export + Doc string // Documentation comment for the capability + Structs []StructDef // Structs used by this capability + TypeAliases []TypeAlias // Type aliases used by this capability + Consts []ConstGroup // Const groups used by this capability + SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + SharedAliases []SharedAlias // Aliases to types in the shared `types` package + SharedTypes []StructDef // Resolved shapes of every used shared type, keyed by canonical name (for schema inlining, alias or not) } -// TypeAlias represents a type alias definition (e.g., type ScrobblerErrorType string). +// TypeAlias represents a type declaration (e.g. type ScrobblerErrorType string) +// or a Go type alias (e.g. type TrackInfo = types.Track). type TypeAlias struct { - Name string // Type name - Type string // Underlying type - Doc string // Documentation comment + Name string // Type name + Type string // Underlying type (or alias target, e.g. "types.Track") + Doc string // Documentation comment + IsAlias bool // true for `type X = Y` (alias); false for `type X Y` (defined type) +} + +// IsDeprecated reports whether the alias carries a `Deprecated:` doc line. +func (t TypeAlias) IsDeprecated() bool { + for _, line := range strings.Split(t.Doc, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "Deprecated:") { + return true + } + } + return false +} + +// IsSharedAlias reports whether this alias targets the shared types package +// (e.g. `type TrackInfo = types.Track`). +func (t TypeAlias) IsSharedAlias() bool { + return t.IsAlias && strings.HasPrefix(t.Type, sharedTypesPrefix) } // ConstGroup represents a group of const definitions. @@ -48,12 +73,90 @@ type ConstDef struct { Doc string // Documentation comment } -// KnownStructs returns a map of struct names defined in this capability. +// SharedAlias is a deprecated alias from a capability/host package to a type in +// the shared `types` package (e.g. type TrackInfo = types.Track). Def is the +// resolved shared struct, kept for XTP schema inlining. +type SharedAlias struct { + Name string // local name, e.g. "TrackInfo" + Target string // alias target, e.g. "types.Track" + Doc string // doc comment (carries the Deprecated: line) + Def StructDef // resolved shared struct shape +} + +// ImportsSharedTypes reports whether this capability references the shared types package. +// A reference can come from a deprecated re-export alias (e.g. type SongRef = types.SongRef) +// or directly from the canonical form (e.g. types.SongRef) in a struct field or a method +// signature, so the generated Go import must be emitted even when no alias is declared. +func (c Capability) ImportsSharedTypes() bool { + if len(c.SharedAliases) > 0 || structsReferenceSharedTypes(c.Structs) { + return true + } + for _, m := range c.Methods { + if typeReferencesSharedTypes(m.Input.Type) || typeReferencesSharedTypes(m.Output.Type) { + return true + } + } + return false +} + +// ImportsSharedTypes reports whether this service references the shared types package. +func (s Service) ImportsSharedTypes() bool { + if len(s.SharedAliases) > 0 || structsReferenceSharedTypes(s.Structs) { + return true + } + for _, m := range s.Methods { + for _, p := range m.Params { + if typeReferencesSharedTypes(p.Type) { + return true + } + } + for _, r := range m.Returns { + if typeReferencesSharedTypes(r.Type) { + return true + } + } + } + return false +} + +// structsReferenceSharedTypes reports whether any field across the given structs +// refers to the shared types package by its qualified name (e.g. types.SongRef, +// []types.SongRef, map[string]types.SongRef). +func structsReferenceSharedTypes(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if typeReferencesSharedTypes(f.Type) { + return true + } + } + } + return false +} + +// typeReferencesSharedTypes reports whether a Go type expression refers to the +// shared types package by its qualified name, accounting for pointer, slice, and +// map wrappers (e.g. types.SongRef, []types.SongRef, map[string]types.SongRef). +func typeReferencesSharedTypes(goType string) bool { + refs := map[string]bool{} + collectReferencedTypes(goType, refs) + for t := range refs { + if strings.HasPrefix(t, sharedTypesPrefix) { + return true + } + } + return false +} + +// KnownStructs returns a map of struct names defined in this capability, +// including shared-alias names so Rust field-type resolution finds them. func (c Capability) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range c.Structs { result[st.Name] = true } + for _, sa := range c.SharedAliases { + result[sa.Name] = true + } return result } @@ -154,12 +257,16 @@ func (s Service) ExportPrefix() string { return strings.ToLower(s.Name) } -// KnownStructs returns a map of struct names defined in this service. +// KnownStructs returns a map of struct names defined in this service, +// including shared-alias names so Rust field-type resolution finds them. func (s Service) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range s.Structs { result[st.Name] = true } + for _, sa := range s.SharedAliases { + result[sa.Name] = true + } return result } @@ -427,24 +534,6 @@ func toJSONName(name string) string { return string(result) } -// ToPythonType converts a Go type to its Python equivalent. -func ToPythonType(goType string) string { - switch goType { - case "string": - return "str" - case "int", "int32", "int64": - return "int" - case "float32", "float64": - return "float" - case "bool": - return "bool" - case "[]byte": - return "bytes" - default: - return "Any" - } -} - // ToSnakeCase converts a PascalCase or camelCase string to snake_case. // It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id"). func ToSnakeCase(s string) string { @@ -469,31 +558,6 @@ func ToSnakeCase(s string) string { return strings.ToLower(result.String()) } -// PythonFunctionName returns the Python function name for a method. -func (m Method) PythonFunctionName(servicePrefix string) string { - return ToSnakeCase(servicePrefix + m.Name) -} - -// PythonResultTypeName returns the Python dataclass name for multi-value returns. -func (m Method) PythonResultTypeName(serviceName string) string { - return serviceName + m.Name + "Result" -} - -// NeedsResultClass returns true if the method needs a dataclass for returns. -func (m Method) NeedsResultClass() bool { - return len(m.Returns) > 1 -} - -// PythonType returns the Python type for this parameter. -func (p Param) PythonType() string { - return ToPythonType(p.Type) -} - -// PythonName returns the snake_case Python name for this parameter. -func (p Param) PythonName() string { - return ToSnakeCase(p.Name) -} - // ToRustType converts a Go type to its Rust equivalent. func ToRustType(goType string) string { return ToRustTypeWithStructs(goType, nil) @@ -562,6 +626,13 @@ func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustTypeWithShared returns the Rust type, resolving shared-alias names to their +// canonical nd_pdk_types::X crate path (e.g. a return of []Track where +// type Track = types.Track renders as Vec<nd_pdk_types::Track>). +func (p Param) RustTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustParamType returns the Rust type for this parameter when used as a function argument. func (p Param) RustParamType() string { return RustParamType(p.Type) @@ -575,6 +646,15 @@ func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustParamTypeWithShared returns the Rust param type, resolving shared-alias +// names to their canonical nd_pdk_types::X crate path. +func (p Param) RustParamTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + if p.Type == "string" { + return "&str" + } + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustName returns the snake_case Rust name for this parameter. func (p Param) RustName() string { return ToSnakeCase(p.Name) @@ -604,9 +684,18 @@ func (f FieldDef) NeedsDefault() bool { // ToRustTypeWithStructs converts a Go type to its Rust equivalent, // using known struct names instead of serde_json::Value. func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { + return toRustType(goType, knownStructs, nil) +} + +// ToRustTypeWithShared resolves shared-alias names to their canonical nd_pdk_types::X path. +func ToRustTypeWithShared(goType string, knownStructs map[string]bool, shared map[string]string) string { + return toRustType(goType, knownStructs, shared) +} + +func toRustType(goType string, knownStructs map[string]bool, shared map[string]string) string { // Handle pointer types if strings.HasPrefix(goType, "*") { - inner := ToRustTypeWithStructs(goType[1:], knownStructs) + inner := toRustType(goType[1:], knownStructs, shared) return "Option<" + inner + ">" } // Handle slice types @@ -614,7 +703,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { if goType == "[]byte" { return "Vec<u8>" } - inner := ToRustTypeWithStructs(goType[2:], knownStructs) + inner := toRustType(goType[2:], knownStructs, shared) return "Vec<" + inner + ">" } // Handle map types @@ -636,7 +725,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { } keyType := rest[:keyEnd] valueType := rest[keyEnd+1:] - return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">" + return "std::collections::HashMap<" + toRustType(keyType, knownStructs, shared) + ", " + toRustType(valueType, knownStructs, shared) + ">" } switch goType { @@ -659,6 +748,17 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { case "interface{}", "any": return "serde_json::Value" default: + // Qualified reference to the shared types crate (e.g. types.Track -> + // nd_pdk_types::Track). + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + // Resolve shared-alias names to their canonical nd_pdk_types:: path. + if shared != nil { + if t, ok := shared[goType]; ok { + return t + } + } // Check if this is a known struct type if knownStructs != nil && knownStructs[goType] { return goType diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index cc2a7d0e0..20c067ffa 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -60,16 +60,18 @@ type ( func GenerateSchema(cap Capability) ([]byte, error) { schema := xtpSchema{Version: "v1-draft"} + aliasToCanonical := buildAliasToCanonical(cap) + // Build exports as ordered map if len(cap.Methods) > 0 { schema.Exports = yaml.Node{Kind: yaml.MappingNode} for _, export := range cap.Methods { - addToMap(&schema.Exports, export.ExportName, buildExport(export)) + addToMap(&schema.Exports, export.ExportName, buildExport(export, aliasToCanonical)) } } // Build components/schemas - schemas := buildSchemas(cap) + schemas := buildSchemas(cap, aliasToCanonical) if len(schemas.Content) > 0 { schema.Components = &xtpComponents{Schemas: schemas} } @@ -77,11 +79,22 @@ func GenerateSchema(cap Capability) ([]byte, error) { return yaml.Marshal(schema) } -func buildExport(export Export) xtpExport { +// buildAliasToCanonical maps each deprecated shared-alias name to the canonical +// shared type it targets (e.g. TrackInfo -> Track). Schema components are emitted +// under the canonical name, so every $ref site must resolve through this map. +func buildAliasToCanonical(cap Capability) map[string]string { + m := map[string]string{} + for _, a := range cap.SharedAliases { + m[a.Name] = strings.TrimPrefix(a.Target, sharedTypesPrefix) + } + return m +} + +func buildExport(export Export, aliasToCanonical map[string]string) xtpExport { e := xtpExport{Description: cleanDocForYAML(export.Doc)} if export.Input.Type != "" { e.Input = &xtpIOParam{ - Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"), + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(export.Input.Type), aliasToCanonical), ContentType: "application/json", } } @@ -95,7 +108,7 @@ func buildExport(export Export) xtpExport { } } else { e.Output = &xtpIOParam{ - Ref: "#/components/schemas/" + outputType, + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(outputType), aliasToCanonical), ContentType: "application/json", } } @@ -112,15 +125,38 @@ func isPrimitiveGoType(goType string) bool { return false } -func buildSchemas(cap Capability) yaml.Node { +func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node { schemas := yaml.Node{Kind: yaml.MappingNode} knownTypes := cap.KnownStructs() for _, alias := range cap.TypeAliases { knownTypes[alias.Name] = true } + // Register shared types under their canonical name (e.g. types.Track -> Track) + // and stash their struct shapes for inlining. SharedTypes covers every used + // shared type, including ones referenced directly as types.X with no declared + // deprecated alias; SharedAliases is folded in for completeness. + sharedDefs := map[string]StructDef{} + for _, def := range cap.SharedTypes { + knownTypes[def.Name] = true + sharedDefs[def.Name] = def + } + for _, a := range cap.SharedAliases { + canonical := strings.TrimPrefix(a.Target, sharedTypesPrefix) + knownTypes[canonical] = true + sharedDefs[canonical] = a.Def + } + // Collect types that are actually used by exports - usedTypes := collectUsedTypes(cap, knownTypes) + usedTypes := collectUsedTypes(cap, knownTypes, sharedDefs) + + // A used alias name (e.g. TrackInfo) implies its canonical component (Track) is + // used, since the alias-typed field's $ref resolves to the canonical name. + for alias, canonical := range aliasToCanonical { + if usedTypes[alias] { + usedTypes[canonical] = true + } + } // Sort structs by name for consistent output structNames := make([]string, 0, len(cap.Structs)) @@ -135,7 +171,19 @@ func buildSchemas(cap Capability) yaml.Node { for _, name := range structNames { st := structMap[name] - addToMap(&schemas, name, buildObjectSchema(st, knownTypes)) + addToMap(&schemas, name, buildObjectSchema(st, knownTypes, aliasToCanonical)) + } + + // Emit components for used shared aliases (sorted for deterministic output). + sharedNames := make([]string, 0, len(sharedDefs)) + for name, def := range sharedDefs { + if usedTypes[name] && len(def.Fields) > 0 { + sharedNames = append(sharedNames, name) + } + } + sort.Strings(sharedNames) + for _, name := range sharedNames { + addToMap(&schemas, name, buildObjectSchema(sharedDefs[name], knownTypes, aliasToCanonical)) } // Build enum types from type aliases (only if used by exports) @@ -157,18 +205,18 @@ func buildSchemas(cap Capability) yaml.Node { } // collectUsedTypes returns a set of type names that are reachable from exports. -func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]bool { +func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef) map[string]bool { used := make(map[string]bool) // Start with types directly referenced by exports for _, export := range cap.Methods { if export.Input.Type != "" { - addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used) + addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, sharedDefs, used) } if export.Output.Type != "" { outputType := strings.TrimPrefix(export.Output.Type, "*") if !isPrimitiveGoType(outputType) { - addTypeAndDeps(outputType, cap, knownTypes, used) + addTypeAndDeps(outputType, cap, knownTypes, sharedDefs, used) } } } @@ -177,28 +225,55 @@ func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]boo } // addTypeAndDeps adds a type and all its dependencies to the used set. -func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) { +func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef, used map[string]bool) { + typeName = strings.TrimPrefix(typeName, sharedTypesPrefix) if used[typeName] || !knownTypes[typeName] { return } used[typeName] = true - // Find the struct and add its field types + // Walk fields of capability-local structs. for _, st := range cap.Structs { if st.Name == typeName { for _, field := range st.Fields { - fieldType := strings.TrimPrefix(field.Type, "*") - fieldType = strings.TrimPrefix(fieldType, "[]") - if knownTypes[fieldType] { - addTypeAndDeps(fieldType, cap, knownTypes, used) + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) } } return } } + + // Walk fields of shared structs so their nested refs are also marked used. + if def, ok := sharedDefs[typeName]; ok { + for _, field := range def.Fields { + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) + } + } + } } -func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema { +// fieldBaseType reduces a field type to the base named type used for schema +// lookups: it strips a leading pointer/slice and any shared `types.` selector. +func fieldBaseType(goType string) string { + goType = strings.TrimPrefix(goType, "*") + goType = strings.TrimPrefix(goType, "[]") + return strings.TrimPrefix(goType, sharedTypesPrefix) +} + +// canonicalRefName resolves a deprecated shared-alias name to the canonical type +// the schema component is emitted under (e.g. TrackInfo -> Track). Non-alias +// names pass through unchanged, so $ref targets always point at an emitted +// component instead of a dangling alias name. +func canonicalRefName(name string, aliasToCanonical map[string]string) string { + if canonical, ok := aliasToCanonical[name]; ok { + return canonical + } + return name +} + +func buildObjectSchema(st StructDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpObjectSchema { schema := xtpObjectSchema{ Description: cleanDocForYAML(st.Doc), Properties: yaml.Node{Kind: yaml.MappingNode}, @@ -206,7 +281,7 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema for _, field := range st.Fields { propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) + addToMap(&schema.Properties, propName, buildProperty(field, knownTypes, aliasToCanonical)) if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { schema.Required = append(schema.Required, propName) @@ -228,7 +303,7 @@ func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema { } } -func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { +func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpProperty { goType := field.Type isPointer := strings.HasPrefix(goType, "*") if isPointer { @@ -240,9 +315,10 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { Nullable: isPointer, } - // Handle reference types (use $ref instead of type) - if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") { - prop.Ref = "#/components/schemas/" + goType + // Handle reference types (use $ref instead of type). Qualified shared + // references (types.X) are referenced by their canonical name. + if refType := strings.TrimPrefix(goType, sharedTypesPrefix); isKnownType(refType, knownTypes) && !strings.HasPrefix(goType, "[]") { + prop.Ref = "#/components/schemas/" + canonicalRefName(refType, aliasToCanonical) return prop } @@ -254,11 +330,11 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { // Handle slice types if strings.HasPrefix(goType, "[]") { - elemType := goType[2:] + elemType := strings.TrimPrefix(goType[2:], sharedTypesPrefix) prop.Type = "array" prop.Items = &xtpProperty{} if isKnownType(elemType, knownTypes) { - prop.Items.Ref = "#/components/schemas/" + elemType + prop.Items.Ref = "#/components/schemas/" + canonicalRefName(elemType, aliasToCanonical) } else { prop.Items.Type = goTypeToXTPType(elemType) } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 2e28a75d8..f8702aa4d 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -700,6 +700,115 @@ var _ = Describe("XTP Schema Generation", func() { }) }) + Describe("GenerateSchema with shared aliases", func() { + It("inlines shared-alias shapes as schema components", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + Expect(string(out)).To(ContainSubstring("TrackInfo:")) + Expect(string(out)).To(ContainSubstring("title:")) + }) + + It("names the shared component by its canonical type for qualified types.X fields", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Component is named by the canonical type (Track), not the deprecated alias. + Expect(s).To(ContainSubstring("Track:")) + Expect(s).NotTo(ContainSubstring("TrackInfo:")) + // The field $ref points at the canonical component. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).To(ContainSubstring("title:")) + }) + + It("points an alias-named field at the canonical component for a renamed alias", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "PlayRequest"}}}, + Structs: []StructDef{{Name: "PlayRequest", Fields: []FieldDef{ + // Field is typed with the deprecated alias name, not the canonical types.Track. + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // The component is emitted under the canonical name, and the field $ref must + // point at it — not at a non-existent TrackInfo component (dangling reference). + Expect(s).To(ContainSubstring("Track:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("points an alias-named export input/output at the canonical component", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + // The method takes/returns the deprecated alias name directly. + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "TrackInfo"}, + Output: Param{Name: "output", Type: "TrackInfo"}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Export $ref must resolve to the canonical component, not a missing TrackInfo. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("inlines a directly-referenced shared type that has no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}}}}, + // No SharedAliases: the field references the canonical type directly. + SharedTypes: []StructDef{{Name: "SongRef", Fields: []FieldDef{ + {Name: "Name", Type: "string", JSONTag: "name"}}}}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + Expect(s).To(ContainSubstring("SongRef:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/SongRef'")) + Expect(s).To(ContainSubstring("name:")) + }) + }) + Describe("GenerateSchema enum filtering", func() { It("should only include enums that are actually used by exports", func() { capability := Capability{ diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index b34ee4296..6520a3ecd 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -19,7 +19,7 @@ // // Output directories: // - Host wrappers: $input/<servicename>_gen.go (server-side, used by Navidrome) -// - Host functions: $output/go/host/, $output/python/host/, $output/rust/host/ +// - Host functions: $output/go/host/, $output/rust/host/ // - Capabilities: $output/go/<capability>/ (e.g., $output/go/metadata/) // - Schemas: $input/<capability>.yaml (co-located with Go sources) // @@ -32,8 +32,7 @@ // -host-only Generate PDK client wrappers for calling host functions // -capability-only Generate only capability export wrappers // -schemas Generate XTP YAML schemas from capabilities -// -go Generate Go client wrappers (default: true when not using -python/-rust) -// -python Generate Python client wrappers (default: false) +// -go Generate Go client wrappers (default: true when not using -rust) // -rust Generate Rust client wrappers (default: false) // -v Verbose output // -dry-run Preview generated code without writing files @@ -43,8 +42,10 @@ import ( "flag" "fmt" "go/format" + "maps" "os" "path/filepath" + "slices" "strings" "github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal" @@ -55,16 +56,16 @@ type config struct { inputDir string outputDir string // Base output directory (e.g., plugins/pdk) goOutputDir string // Go output: $outputDir/go/host (for host-only) - pythonOutputDir string // Python output: $outputDir/python/host rustOutputDir string // Rust output: $outputDir/rust/host pkgName string hostOnly bool hostWrappers bool // Generate host wrappers (used by Navidrome server) capabilityOnly bool - schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) - pdkOnly bool // Generate PDK abstraction layer wrapper + schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) + pdkOnly bool // Generate PDK abstraction layer wrapper + sharedTypes bool // Generate the shared types package + sharedDir string // Directory of shared types to load as a registry generateGoClient bool - generatePyClient bool generateRsClient bool verbose bool dryRun bool @@ -85,6 +86,14 @@ func main() { return } + if cfg.sharedTypes { + if err := runSharedTypesGeneration(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + if cfg.pdkOnly { if err := runPDKGeneration(cfg); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -157,6 +166,69 @@ func runSchemaGeneration(cfg *config) error { return generateSchemas(cfg, capabilities) } +// writeGenerated creates dir (if needed) and writes content to name inside it. +// In dry-run mode it prints the content instead. +func writeGenerated(dir, name string, content []byte, dryRun, verbose bool) error { + path := filepath.Join(dir, name) + if dryRun { + fmt.Printf("=== %s ===\n%s\n", path, content) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory %s: %w", dir, err) + } + if err := os.WriteFile(path, content, 0600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + if verbose { + fmt.Printf("Generated: %s\n", path) + } + return nil +} + +// runSharedTypesGeneration handles shared types package generation. +func runSharedTypesGeneration(cfg *config) error { + structs, err := internal.LoadSharedTypes(cfg.inputDir) + if err != nil { + return err + } + if len(structs) == 0 { + return nil + } + list := slices.Collect(maps.Values(structs)) + if cfg.generateGoClient { + code, err := internal.GenerateSharedTypesGo(list, "types") + if err != nil { + return fmt.Errorf("generating Go types: %w", err) + } + formatted, err := format.Source(code) + if err != nil { + return fmt.Errorf("formatting Go types: %w\n%s", err, code) + } + dir := filepath.Join(cfg.outputDir, "go", "types") + if err := writeGenerated(dir, "types.go", formatted, cfg.dryRun, cfg.verbose); err != nil { + return err + } + } + if cfg.generateRsClient { + if err := generateSharedTypesRust(list, cfg); err != nil { + return err + } + } + return nil +} + +// generateSharedTypesRust writes the nd-pdk-types crate root to +// <outputDir>/rust/nd-pdk-types/src/lib.rs. +func generateSharedTypesRust(structs []internal.StructDef, cfg *config) error { + code, err := internal.GenerateSharedTypesRust(structs) + if err != nil { + return fmt.Errorf("generating Rust types: %w", err) + } + dir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-types", "src") + return writeGenerated(dir, "lib.rs", code, cfg.dryRun, cfg.verbose) +} + // runPDKGeneration handles PDK abstraction layer code generation. // This generates the pdk wrapper package that wraps extism/go-pdk // with mockable implementations for unit testing on native platforms. @@ -315,8 +387,9 @@ func parseConfig() (*config, error) { capabilityOnly = flag.Bool("capability-only", false, "Generate only capability export wrappers") schemasOnly = flag.Bool("schemas", false, "Generate XTP YAML schemas from capabilities (output to input directory)") pdkOnly = flag.Bool("extism-pdk", false, "Generate PDK abstraction layer by parsing extism/go-pdk") + sharedTypes = flag.Bool("shared-types", false, "Generate the shared types package") + shared = flag.String("shared", "", "Directory of shared types to load as a registry") goClient = flag.Bool("go", false, "Generate Go client wrappers") - pyClient = flag.Bool("python", false, "Generate Python client wrappers") rsClient = flag.Bool("rust", false, "Generate Rust client wrappers") verbose = flag.Bool("v", false, "Verbose output") dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files") @@ -340,6 +413,9 @@ func parseConfig() (*config, error) { if *pdkOnly { modeCount++ } + if *sharedTypes { + modeCount++ + } // Default to host-only if no mode is specified if modeCount == 0 { @@ -348,7 +424,7 @@ func parseConfig() (*config, error) { // Cannot specify multiple modes if modeCount > 1 { - return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -pdk)") + return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -extism-pdk, -shared-types)") } if *outputDir == "" { @@ -372,23 +448,28 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("resolving output path: %w", err) } + absShared := "" + if *shared != "" { + absShared, err = filepath.Abs(*shared) + if err != nil { + return nil, fmt.Errorf("resolving shared path: %w", err) + } + } + // Set output directories for each language // Go host wrappers: $output/go/host/ - // Python host wrappers: $output/python/host/ // Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate) absGoOutput := filepath.Join(absOutput, "go", "host") - absPythonOutput := filepath.Join(absOutput, "python", "host") absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host") // Determine what to generate // Default: generate Go clients if no language flag is specified - anyLangFlag := *goClient || *pyClient || *rsClient + anyLangFlag := *goClient || *rsClient return &config{ inputDir: absInput, outputDir: absOutput, goOutputDir: absGoOutput, - pythonOutputDir: absPythonOutput, rustOutputDir: absRustOutput, pkgName: *pkgName, hostOnly: *hostOnly, @@ -396,8 +477,9 @@ func parseConfig() (*config, error) { capabilityOnly: *capabilityOnly, schemasOnly: *schemasOnly, pdkOnly: *pdkOnly, + sharedTypes: *sharedTypes, + sharedDir: absShared, generateGoClient: *goClient || !anyLangFlag, - generatePyClient: *pyClient, generateRsClient: *rsClient, verbose: *verbose, dryRun: *dryRun, @@ -412,20 +494,21 @@ func parseServices(cfg *config) ([]internal.Service, error) { if cfg.generateGoClient { fmt.Printf("Go output directory: %s\n", cfg.goOutputDir) } - if cfg.generatePyClient { - fmt.Printf("Python output directory: %s\n", cfg.pythonOutputDir) - } if cfg.generateRsClient { fmt.Printf("Rust output directory: %s\n", cfg.rustOutputDir) } fmt.Printf("Package name: %s\n", cfg.pkgName) fmt.Printf("Host-only mode: %v\n", cfg.hostOnly) fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient) - fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient) fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient) } - services, err := internal.ParseDirectory(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + services, err := internal.ParseDirectoryWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing source files: %w", err) } @@ -455,7 +538,12 @@ func parseCapabilities(cfg *config) ([]internal.Capability, error) { fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly) } - capabilities, err := internal.ParseCapabilities(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + capabilities, err := internal.ParseCapabilitiesWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing capability files: %w", err) } @@ -619,11 +707,6 @@ func generateAllCode(cfg *config, services []internal.Service) error { return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err) } } - if cfg.generatePyClient { - if err := generatePythonClientCode(svc, cfg.pythonOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err) - } - } if cfg.generateRsClient { if err := generateRustClientCode(svc, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err) @@ -757,36 +840,6 @@ func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, d return nil } -// generatePythonClientCode generates Python client-side code for a service. -func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateClientPython(svc) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - // Python code goes directly in the output directory - clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".py") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", clientFile, code) - return nil - } - - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating python client directory: %w", err) - } - - if err := os.WriteFile(clientFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Python client code: %s\n", clientFile) - } - return nil -} - // generateRustClientCode generates Rust client-side code for a service. func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { code, err := internal.GenerateClientRust(svc) diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.py b/plugins/cmd/ndpgen/testdata/codec_client_expected.py deleted file mode 100644 index 5142ffd0e..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.py +++ /dev/null @@ -1,53 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Codec host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "codec_encode") -def _codec_encode(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def codec_encode(data: bytes) -> bytes: - """Call the codec_encode host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _codec_encode(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py deleted file mode 100644 index 93370ddcf..000000000 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py +++ /dev/null @@ -1,342 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Comprehensive host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "comprehensive_simpleparams") -def _comprehensive_simpleparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_structparam") -def _comprehensive_structparam(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mixedparams") -def _comprehensive_mixedparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noerror") -def _comprehensive_noerror(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparams") -def _comprehensive_noparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparamsnoreturns") -def _comprehensive_noparamsnoreturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_pointerparams") -def _comprehensive_pointerparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mapparams") -def _comprehensive_mapparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_multiplereturns") -def _comprehensive_multiplereturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_byteslice") -def _comprehensive_byteslice(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ComprehensiveMultipleReturnsResult: - """Result type for comprehensive_multiple_returns.""" - results: Any - total: int - - -def comprehensive_simple_params(name: str, count: int) -> str: - """Call the comprehensive_simpleparams host function. - - Args: - name: str parameter. - count: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "count": count, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_simpleparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_struct_param(user: Any) -> None: - """Call the comprehensive_structparam host function. - - Args: - user: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_structparam(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_mixed_params(id: str, filter: Any) -> int: - """Call the comprehensive_mixedparams host function. - - Args: - id: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mixedparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) - - -def comprehensive_no_error(name: str) -> str: - """Call the comprehensive_noerror host function. - - Args: - name: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noerror(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_no_params() -> None: - """Call the comprehensive_noparams host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_no_params_no_returns() -> None: - """Call the comprehensive_noparamsnoreturns host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparamsnoreturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_pointer_params(id: Any, user: Any) -> Any: - """Call the comprehensive_pointerparams host function. - - Args: - id: Any parameter. - user: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_pointerparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_map_params(data: Any) -> Any: - """Call the comprehensive_mapparams host function. - - Args: - data: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mapparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_multiple_returns(query: str) -> ComprehensiveMultipleReturnsResult: - """Call the comprehensive_multiplereturns host function. - - Args: - query: str parameter. - - Returns: - ComprehensiveMultipleReturnsResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_multiplereturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ComprehensiveMultipleReturnsResult( - results=response.get("results", None), - total=response.get("total", 0), - ) - - -def comprehensive_byte_slice(data: bytes) -> bytes: - """Call the comprehensive_byteslice host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_byteslice(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.py b/plugins/cmd/ndpgen/testdata/config_client_expected.py deleted file mode 100644 index 370de6d10..000000000 --- a/plugins/cmd/ndpgen/testdata/config_client_expected.py +++ /dev/null @@ -1,126 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_set") -def _config_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_has") -def _config_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Call the config_get host function. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_set(key: str, value: str) -> None: - """Call the config_set host function. - - Args: - key: str parameter. - value: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def config_has(key: str) -> bool: - """Call the config_has host function. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) diff --git a/plugins/cmd/ndpgen/testdata/counter_client_expected.py b/plugins/cmd/ndpgen/testdata/counter_client_expected.py deleted file mode 100644 index 872d407bb..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_client_expected.py +++ /dev/null @@ -1,49 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Counter host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "counter_count") -def _counter_count(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def counter_count(name: str) -> int: - """Call the counter_count host function. - - Args: - name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _counter_count(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("value", 0) diff --git a/plugins/cmd/ndpgen/testdata/echo_client_expected.py b/plugins/cmd/ndpgen/testdata/echo_client_expected.py deleted file mode 100644 index 06565b0d6..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Echo host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "echo_echo") -def _echo_echo(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def echo_echo(message: str) -> str: - """Call the echo_echo host function. - - Args: - message: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _echo_echo(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("reply", "") diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.py b/plugins/cmd/ndpgen/testdata/list_client_expected.py deleted file mode 100644 index 58ccad146..000000000 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the List host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "list_items") -def _list_items(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def list_items(name: str, filter: Any) -> int: - """Call the list_items host function. - - Args: - name: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _list_items(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("count", 0) diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.py b/plugins/cmd/ndpgen/testdata/math_client_expected.py deleted file mode 100644 index f3ea53335..000000000 --- a/plugins/cmd/ndpgen/testdata/math_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Math host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "math_add") -def _math_add(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def math_add(a: int, b: int) -> int: - """Call the math_add host function. - - Args: - a: int parameter. - b: int parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "a": a, - "b": b, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _math_add(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/cmd/ndpgen/testdata/meta_client_expected.py b/plugins/cmd/ndpgen/testdata/meta_client_expected.py deleted file mode 100644 index 4d20c73ff..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_client_expected.py +++ /dev/null @@ -1,81 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Meta host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "meta_get") -def _meta_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "meta_set") -def _meta_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def meta_get(key: str) -> Any: - """Call the meta_get host function. - - Args: - key: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("value", None) - - -def meta_set(data: Any) -> None: - """Call the meta_set host function. - - Args: - data: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/ping_client_expected.py b/plugins/cmd/ndpgen/testdata/ping_client_expected.py deleted file mode 100644 index 4c7d41d8e..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_client_expected.py +++ /dev/null @@ -1,42 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Ping host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "ping_ping") -def _ping_ping(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def ping_ping() -> None: - """Call the ping_ping host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _ping_ping(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.py b/plugins/cmd/ndpgen/testdata/search_client_expected.py deleted file mode 100644 index aa2e98a36..000000000 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.py +++ /dev/null @@ -1,62 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Search host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "search_find") -def _search_find(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SearchFindResult: - """Result type for search_find.""" - results: Any - total: int - - -def search_find(query: str) -> SearchFindResult: - """Call the search_find host function. - - Args: - query: str parameter. - - Returns: - SearchFindResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _search_find(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SearchFindResult( - results=response.get("results", None), - total=response.get("total", 0), - ) diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.py b/plugins/cmd/ndpgen/testdata/store_client_expected.py deleted file mode 100644 index 4a964a497..000000000 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Store host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "store_save") -def _store_save(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def store_save(item: Any) -> str: - """Call the store_save host function. - - Args: - item: Any parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "item": item, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _store_save(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("id", "") diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.py b/plugins/cmd/ndpgen/testdata/users_client_expected.py deleted file mode 100644 index 468b87b98..000000000 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_get") -def _users_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get(id: Any, filter: Any) -> Any: - """Call the users_get host function. - - Args: - id: Any parameter. - filter: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 12bf9ed3e..10c8f8c66 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -18,7 +18,7 @@ use extism_pdk::*; use nd_pdk::host::{artwork, config, scheduler}; use nd_pdk::scrobbler::{ - Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, + Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER, }; use nd_pdk::scheduler::{ @@ -207,6 +207,11 @@ impl Scrobbler for DiscordPlugin { // Discord Rich Presence doesn't need scrobble events - success Ok(()) } + + fn playback_report(&self, _req: PlaybackReportRequest) -> Result<(), ScrobblerError> { + // Discord Rich Presence doesn't need playback reports - success + Ok(()) + } } // ============================================================================ diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index e872d845d..743c03744 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -14,7 +14,7 @@ use extism_pdk::{config, error, http, info, warn, HttpRequest}; use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest, + Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, }; @@ -45,6 +45,15 @@ impl Scrobbler for WebhookPlugin { Ok(()) } + /// Handles playback state reports. This plugin ignores them (webhooks only on scrobble). + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error> { + info!( + "Playback report (ignored): {} - {} for user {} (state: {})", + req.track.artist, req.track.title, req.username, req.state + ); + Ok(()) + } + /// Handles scrobble events by sending HTTP GET requests to configured URLs. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { // Get configured URLs diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index b565ef6f2..fa3e0082e 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" "github.com/navidrome/navidrome/utils/slice" ) @@ -230,7 +231,7 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m // songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single // Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead // of the artists array, they are folded into a one-element Artists list here. -func songRefToAgentSong(s capabilities.SongRef) agents.Song { +func songRefToAgentSong(s types.SongRef) agents.Song { var artists []agents.Artist switch { case len(s.Artists) > 0: @@ -254,7 +255,7 @@ func songRefToAgentSong(s capabilities.SongRef) agents.Song { } // songRefsToAgentSongs converts a slice of SongRef to agents.Song -func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { +func songRefsToAgentSongs(refs []types.SongRef) []agents.Song { return slice.Map(refs, songRefToAgentSong) } diff --git a/plugins/pdk/go/host/nd_host_artwork_stub.go b/plugins/pdk/go/host/nd_host_artwork_stub.go index aa41e440c..3b81e0d6b 100644 --- a/plugins/pdk/go/host/nd_host_artwork_stub.go +++ b/plugins/pdk/go/host/nd_host_artwork_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockArtworkService is the mock implementation for testing. type mockArtworkService struct { diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go index fbd80d13f..46bb44bcf 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockCacheService is the mock implementation for testing. type mockCacheService struct { diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 2b8485ce9..463c29b76 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockConfigService is the mock implementation for testing. type mockConfigService struct { diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 2f15a91a9..09c4d0fcf 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index 83b55d3a8..fce038aa1 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockKVStoreService is the mock implementation for testing. type mockKVStoreService struct { diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 9ad0d97e7..1e4c230c3 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // Library represents the Library data structure. // Library represents a music library with metadata. diff --git a/plugins/pdk/go/host/nd_host_scheduler_stub.go b/plugins/pdk/go/host/nd_host_scheduler_stub.go index 3eaa0087a..ac2a8821f 100644 --- a/plugins/pdk/go/host/nd_host_scheduler_stub.go +++ b/plugins/pdk/go/host/nd_host_scheduler_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSchedulerService is the mock implementation for testing. type mockSchedulerService struct { diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 2fdaf2403..6d3a56b35 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSubsonicAPIService is the mock implementation for testing. type mockSubsonicAPIService struct { diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index 4dde0e234..286d14035 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // QueueConfig represents the QueueConfig data structure. // QueueConfig holds configuration for a task queue. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index f76854894..8858b2109 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // User represents the User data structure. // User represents a Navidrome user with minimal information exposed to plugins. diff --git a/plugins/pdk/go/host/nd_host_websocket_stub.go b/plugins/pdk/go/host/nd_host_websocket_stub.go index 23ac382f0..ca6e39c44 100644 --- a/plugins/pdk/go/host/nd_host_websocket_stub.go +++ b/plugins/pdk/go/host/nd_host_websocket_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockWebSocketService is the mock implementation for testing. type mockWebSocketService struct { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 6696bc4a1..b3c7f29ac 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -9,17 +9,11 @@ package lyrics import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -51,9 +45,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index fb3e3fb1a..76c54d2e5 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -8,15 +8,10 @@ package lyrics -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -48,9 +43,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index cec6ce339..c561c2893 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -9,8 +9,15 @@ package metadata import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -65,16 +72,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -114,7 +111,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -160,31 +157,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -202,7 +175,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index b71a4902f..e72cca103 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -8,6 +8,14 @@ package metadata +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -62,16 +70,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -111,7 +109,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -157,31 +155,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -199,7 +173,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 0d045e597..22ae4c3bf 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -9,8 +9,12 @@ package scrobbler import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -26,16 +30,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -95,9 +89,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index b35e7c40e..722ec5462 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -8,6 +8,11 @@ package scrobbler +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -23,16 +28,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -92,9 +87,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go index 864d13311..fca8bd2c1 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -9,59 +9,32 @@ package sonicsimilarity import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go index cf4cfc7ef..518a166e5 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -8,57 +8,31 @@ package sonicsimilarity -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go new file mode 100644 index 000000000..856be90e1 --- /dev/null +++ b/plugins/pdk/go/types/types.go @@ -0,0 +1,48 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package types holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} diff --git a/plugins/pdk/python/host/nd_host_artwork.py b/plugins/pdk/python/host/nd_host_artwork.py deleted file mode 100644 index 9bcb529ae..000000000 --- a/plugins/pdk/python/host/nd_host_artwork.py +++ /dev/null @@ -1,183 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Artwork host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "artwork_getartisturl") -def _artwork_getartisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getalbumurl") -def _artwork_getalbumurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_gettrackurl") -def _artwork_gettrackurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getplaylisturl") -def _artwork_getplaylisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def artwork_get_artist_url(id: str, size: int) -> str: - """GetArtistUrl generates a public URL for an artist's artwork. - -Parameters: - - id: The artist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getartisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_album_url(id: str, size: int) -> str: - """GetAlbumUrl generates a public URL for an album's artwork. - -Parameters: - - id: The album's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getalbumurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_track_url(id: str, size: int) -> str: - """GetTrackUrl generates a public URL for a track's artwork. - -Parameters: - - id: The track's (media file) unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_gettrackurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_playlist_url(id: str, size: int) -> str: - """GetPlaylistUrl generates a public URL for a playlist's artwork. - -Parameters: - - id: The playlist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getplaylisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") diff --git a/plugins/pdk/python/host/nd_host_cache.py b/plugins/pdk/python/host/nd_host_cache.py deleted file mode 100644 index b24e983cc..000000000 --- a/plugins/pdk/python/host/nd_host_cache.py +++ /dev/null @@ -1,448 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Cache host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "cache_setstring") -def _cache_setstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getstring") -def _cache_getstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setint") -def _cache_setint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getint") -def _cache_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setfloat") -def _cache_setfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getfloat") -def _cache_getfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setbytes") -def _cache_setbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getbytes") -def _cache_getbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_has") -def _cache_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_remove") -def _cache_remove(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class CacheGetStringResult: - """Result type for cache_get_string.""" - value: str - exists: bool - - -@dataclass -class CacheGetIntResult: - """Result type for cache_get_int.""" - value: int - exists: bool - - -@dataclass -class CacheGetFloatResult: - """Result type for cache_get_float.""" - value: float - exists: bool - - -@dataclass -class CacheGetBytesResult: - """Result type for cache_get_bytes.""" - value: bytes - exists: bool - - -def cache_set_string(key: str, value: str, ttl_seconds: int) -> None: - """SetString stores a string value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The string value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: str parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_string(key: str) -> CacheGetStringResult: - """GetString retrieves a string value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a string, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetStringResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetStringResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def cache_set_int(key: str, value: int, ttl_seconds: int) -> None: - """SetInt stores an integer value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The integer value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: int parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_int(key: str) -> CacheGetIntResult: - """GetInt retrieves an integer value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def cache_set_float(key: str, value: float, ttl_seconds: int) -> None: - """SetFloat stores a float value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The float value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: float parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_float(key: str) -> CacheGetFloatResult: - """GetFloat retrieves a float value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a float, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetFloatResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetFloatResult( - value=response.get("value", 0.0), - exists=response.get("exists", False), - ) - - -def cache_set_bytes(key: str, value: bytes, ttl_seconds: int) -> None: - """SetBytes stores a byte slice in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_bytes(key: str) -> CacheGetBytesResult: - """GetBytes retrieves a byte slice from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a byte slice, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetBytesResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetBytesResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def cache_has(key: str) -> bool: - """Has checks if a key exists in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns true if the key exists and has not expired. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def cache_remove(key: str) -> None: - """Remove deletes a value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_remove(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_config.py b/plugins/pdk/python/host/nd_host_config.py deleted file mode 100644 index 1dab2fe0e..000000000 --- a/plugins/pdk/python/host/nd_host_config.py +++ /dev/null @@ -1,145 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_getint") -def _config_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_keys") -def _config_keys(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -@dataclass -class ConfigGetIntResult: - """Result type for config_get_int.""" - value: int - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Get retrieves a configuration value as a string. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_get_int(key: str) -> ConfigGetIntResult: - """GetInt retrieves a configuration value as an integer. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. If the key exists but the -value cannot be parsed as an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - ConfigGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def config_keys(prefix: str) -> Any: - """Keys returns configuration keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by. If empty, returns all keys. - -Returns a sorted slice of matching configuration keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_keys(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("keys", None) diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py deleted file mode 100644 index a806c8456..000000000 --- a/plugins/pdk/python/host/nd_host_http.py +++ /dev/null @@ -1,60 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as Go errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_httpclient.py b/plugins/pdk/python/host/nd_host_httpclient.py deleted file mode 100644 index c6bfb77c0..000000000 --- a/plugins/pdk/python/host/nd_host_httpclient.py +++ /dev/null @@ -1,59 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py deleted file mode 100644 index 33eaffc52..000000000 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ /dev/null @@ -1,362 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the KVStore host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "kvstore_set") -def _kvstore_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_setwithttl") -def _kvstore_setwithttl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_get") -def _kvstore_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getmany") -def _kvstore_getmany(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_has") -def _kvstore_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_list") -def _kvstore_list(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_delete") -def _kvstore_delete(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_deletebyprefix") -def _kvstore_deletebyprefix(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getstorageused") -def _kvstore_getstorageused(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class KVStoreGetResult: - """Result type for kvstore_get.""" - value: bytes - exists: bool - - -def kvstore_set(key: str, value: bytes) -> None: - """Set stores a byte value with the given key. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None: - """SetWithTTL stores a byte value with the given key and a time-to-live. - -After ttlSeconds, the key is treated as non-existent and will be -cleaned up lazily. ttlSeconds must be greater than 0. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (must be > 0) - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_setwithttl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_get(key: str) -> KVStoreGetResult: - """Get retrieves a byte value from storage. - -Parameters: - - key: The storage key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - KVStoreGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return KVStoreGetResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def kvstore_get_many(keys: Any) -> Any: - """GetMany retrieves multiple values in a single call. - -Parameters: - - keys: The storage keys to retrieve - -Returns a map of key to value for keys that exist and have not expired. -Missing or expired keys are omitted from the result. - - Args: - keys: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "keys": keys, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getmany(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("values", None) - - -def kvstore_has(key: str) -> bool: - """Has checks if a key exists in storage. - -Parameters: - - key: The storage key - -Returns true if the key exists. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def kvstore_list(prefix: str) -> Any: - """List returns all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by (empty string returns all keys) - -Returns a slice of matching keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_list(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("keys", None) - - -def kvstore_delete(key: str) -> None: - """Delete removes a value from storage. - -Parameters: - - key: The storage key - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_delete(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_delete_by_prefix(prefix: str) -> int: - """DeleteByPrefix removes all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to match (must not be empty) - -Returns the number of keys deleted. Includes expired keys. - - Args: - prefix: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_deletebyprefix(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("deletedCount", 0) - - -def kvstore_get_storage_used() -> int: - """GetStorageUsed returns the total storage used by this plugin in bytes. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getstorageused(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("bytes", 0) diff --git a/plugins/pdk/python/host/nd_host_library.py b/plugins/pdk/python/host/nd_host_library.py deleted file mode 100644 index 12e1bc4eb..000000000 --- a/plugins/pdk/python/host/nd_host_library.py +++ /dev/null @@ -1,86 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Library host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "library_getlibrary") -def _library_getlibrary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "library_getalllibraries") -def _library_getalllibraries(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def library_get_library(id: int) -> Any: - """GetLibrary retrieves metadata for a specific library by ID. - -Parameters: - - id: The library's unique identifier - -Returns the library metadata, or an error if the library is not found. - - Args: - id: int parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getlibrary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def library_get_all_libraries() -> Any: - """GetAllLibraries retrieves metadata for all configured libraries. - -Returns a slice of all libraries with their metadata. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getalllibraries(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_scheduler.py b/plugins/pdk/python/host/nd_host_scheduler.py deleted file mode 100644 index 7f0d19241..000000000 --- a/plugins/pdk/python/host/nd_host_scheduler.py +++ /dev/null @@ -1,143 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Scheduler host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "scheduler_scheduleonetime") -def _scheduler_scheduleonetime(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_schedulerecurring") -def _scheduler_schedulerecurring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_cancelschedule") -def _scheduler_cancelschedule(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_id: str) -> str: - """ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - delaySeconds: Number of seconds to wait before triggering the event - - payload: Data to be passed to the scheduled event handler - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - delay_seconds: int parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "delaySeconds": delay_seconds, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_scheduleonetime(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str: - """ScheduleRecurring schedules a recurring event using a cron expression. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) - - payload: Data to be passed to each scheduled event handler invocation - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - cron_expression: str parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "cronExpression": cron_expression, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_schedulerecurring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_cancel_schedule(schedule_id: str) -> None: - """CancelSchedule cancels a scheduled job identified by its schedule ID. - -This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -any future events. - -Returns an error if the schedule ID is not found or if cancellation fails. - - Args: - schedule_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_cancelschedule(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py deleted file mode 100644 index cf35bc043..000000000 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ /dev/null @@ -1,101 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the SubsonicAPI host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "subsonicapi_callraw") -def _subsonicapi_callraw(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SubsonicAPICallRawResult: - """Result type for subsonicapi_call_raw.""" - content_type: str - data: bytes - - -def subsonicapi_call(uri: str) -> str: - """Call executes a Subsonic API request and returns the JSON response. - -The uri parameter should be the Subsonic API path without the server prefix, -e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. - - Args: - uri: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_call(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("responseJson", "") - - -def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult: - """CallRaw executes a Subsonic API request and returns the raw binary response. -Designed for binary endpoints like getCoverArt and stream that return -non-JSON data. The data is base64-encoded over JSON on the wire. - - Args: - uri: str parameter. - - Returns: - SubsonicAPICallRawResult containing content_type, data,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_callraw(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SubsonicAPICallRawResult( - content_type=response.get("contentType", ""), - data=base64.b64decode(response.get("data", "")), - ) diff --git a/plugins/pdk/python/host/nd_host_task.py b/plugins/pdk/python/host/nd_host_task.py deleted file mode 100644 index 5d6e7474c..000000000 --- a/plugins/pdk/python/host/nd_host_task.py +++ /dev/null @@ -1,188 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Task host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "task_createqueue") -def _task_createqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_enqueue") -def _task_enqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_get") -def _task_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_cancel") -def _task_cancel(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_clearqueue") -def _task_clearqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def task_create_queue(name: str, config: Any) -> None: - """CreateQueue creates a named task queue with the given configuration. -Zero-value fields in config use sensible defaults. -If a queue with the same name already exists, returns an error. -On startup, this also recovers any stale "running" tasks from a previous crash. - - Args: - name: str parameter. - config: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "config": config, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_createqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_enqueue(queue_name: str, payload: bytes) -> str: - """Enqueue adds a task to the named queue. Returns the task ID. -payload is opaque bytes passed back to the plugin on execution. - - Args: - queue_name: str parameter. - payload: bytes parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - "payload": base64.b64encode(payload).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_enqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def task_get(task_id: str) -> Any: - """Get returns the current state of a task including its status, -message, and attempt count. - - Args: - task_id: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def task_cancel(task_id: str) -> None: - """Cancel cancels a pending task. Returns error if already -running, completed, or failed. - - Args: - task_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_cancel(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_clear_queue(queue_name: str) -> int: - """ClearQueue removes all pending tasks from the named queue. -Running tasks are not affected. Returns the number of tasks removed. - - Args: - queue_name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_clearqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/pdk/python/host/nd_host_users.py b/plugins/pdk/python/host/nd_host_users.py deleted file mode 100644 index a325156a7..000000000 --- a/plugins/pdk/python/host/nd_host_users.py +++ /dev/null @@ -1,80 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_getusers") -def _users_getusers(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "users_getadmins") -def _users_getadmins(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get_users() -> Any: - """GetUsers returns all users the plugin has been granted access to. -Only minimal user information (userName, name, isAdmin) is returned. -Sensitive fields like password and email are never exposed. - -Returns a slice of users the plugin can access, or an empty slice if none configured. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getusers(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def users_get_admins() -> Any: - """GetAdmins returns only admin users the plugin has been granted access to. -This is a convenience method that filters GetUsers results to include only admins. - -Returns a slice of admin users the plugin can access, or an empty slice if none. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getadmins(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_websocket.py b/plugins/pdk/python/host/nd_host_websocket.py deleted file mode 100644 index 4e882914c..000000000 --- a/plugins/pdk/python/host/nd_host_websocket.py +++ /dev/null @@ -1,182 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the WebSocket host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "websocket_connect") -def _websocket_connect(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendtext") -def _websocket_sendtext(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendbinary") -def _websocket_sendbinary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_closeconnection") -def _websocket_closeconnection(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def websocket_connect(url: str, headers: Any, connection_id: str) -> str: - """Connect establishes a WebSocket connection to the specified URL. - -Plugins that use this function must also implement the WebSocketCallback capability -to receive incoming messages and connection events. - -Parameters: - - url: The WebSocket URL to connect to (ws:// or wss://) - - headers: Optional HTTP headers to include in the handshake request - - connectionID: Optional unique identifier for the connection. If empty, one will be generated - -Returns the connection ID that can be used to send messages or close the connection, -or an error if the connection fails. - - Args: - url: str parameter. - headers: Any parameter. - connection_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "url": url, - "headers": headers, - "connectionId": connection_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_connect(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newConnectionId", "") - - -def websocket_send_text(connection_id: str, message: str) -> None: - """SendText sends a text message over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - message: The text message to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - message: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendtext(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_send_binary(connection_id: str, data: bytes) -> None: - """SendBinary sends binary data over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - data: The binary data to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - data: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendbinary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_close_connection(connection_id: str, code: int, reason: str) -> None: - """CloseConnection gracefully closes a WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - code: WebSocket close status code (e.g., 1000 for normal closure) - - reason: Optional human-readable reason for closing - -Returns an error if the connection is not found or if closing fails. - - Args: - connection_id: str parameter. - code: int parameter. - reason: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "code": code, - "reason": reason, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_closeconnection(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml index 443f19da5..e9fe367da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" crate-type = ["rlib"] [dependencies] +nd-pdk-types = { path = "../nd-pdk-types" } base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index b0361a3a3..d3d2b19aa 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -5,6 +5,8 @@ //! This crate provides type definitions, traits, and registration macros //! for implementing Navidrome plugin capabilities in Rust. +pub use nd_pdk_types as types; + pub mod lifecycle; pub mod lyrics; pub mod metadata; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 0a861a1ed..a8449a5ec 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -18,20 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// GetLyricsRequest contains the track information for lyrics lookup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -77,10 +66,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec<ArtistRef>, + pub artists: Vec<nd_pdk_types::ArtistRef>, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec<ArtistRef>, + pub album_artists: Vec<nd_pdk_types::ArtistRef>, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 1a51ad9fd..38fcae9da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -18,6 +18,12 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -92,20 +98,6 @@ pub struct ArtistMBIDResponse { #[serde(default)] pub mbid: String, } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// ArtistRequest is the common request for artist-related functions. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -162,7 +154,7 @@ pub struct SimilarArtistsRequest { pub struct SimilarArtistsResponse { /// Artists is the list of similar artists. #[serde(default)] - pub artists: Vec<ArtistRef>, + pub artists: Vec<nd_pdk_types::ArtistRef>, } /// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -227,42 +219,7 @@ pub struct SimilarSongsByTrackRequest { pub struct SimilarSongsResponse { /// Songs is the list of similar songs. #[serde(default)] - pub songs: Vec<SongRef>, -} -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artists: Vec<ArtistRef>, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, + pub songs: Vec<nd_pdk_types::SongRef>, } /// TopSongsRequest is the request for GetArtistTopSongs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -287,7 +244,7 @@ pub struct TopSongsRequest { pub struct TopSongsResponse { /// Songs is the list of top songs. #[serde(default)] - pub songs: Vec<SongRef>, + pub songs: Vec<nd_pdk_types::SongRef>, } /// Error represents an error from a capability method. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 1e9c51375..b0b843e6c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -18,6 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// ScrobblerError represents an error type for scrobbling operations. pub type ScrobblerError = &'static str; /// ScrobblerErrorNotAuthorized indicates the user is not authorized. @@ -26,20 +29,6 @@ pub const SCROBBLER_ERROR_NOT_AUTHORIZED: ScrobblerError = "scrobbler(not_author pub const SCROBBLER_ERROR_RETRY_LATER: ScrobblerError = "scrobbler(retry_later)"; /// ScrobblerErrorUnrecoverable indicates an unrecoverable error. pub const SCROBBLER_ERROR_UNRECOVERABLE: ScrobblerError = "scrobbler(unrecoverable)"; -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// IsAuthorizedRequest is the request for authorization check. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -126,10 +115,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec<ArtistRef>, + pub artists: Vec<nd_pdk_types::ArtistRef>, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec<ArtistRef>, + pub album_artists: Vec<nd_pdk_types::ArtistRef>, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs index 81b163e5f..a28df9c0a 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -18,28 +18,20 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// FindSonicPathRequest represents the FindSonicPathRequest data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FindSonicPathRequest { #[serde(default)] - pub start_song: SongRef, + pub start_song: nd_pdk_types::SongRef, #[serde(default)] - pub end_song: SongRef, + pub end_song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } @@ -48,51 +40,16 @@ pub struct FindSonicPathRequest { #[serde(rename_all = "camelCase")] pub struct GetSonicSimilarTracksRequest { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artists: Vec<ArtistRef>, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, -} /// SonicMatch represents the SonicMatch data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SonicMatch { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub similarity: f64, } diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml index 519096110..100ce6a35 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -13,5 +13,6 @@ crate-type = ["rlib"] [dependencies] base64 = "0.22" extism-pdk = "1.2" +nd-pdk-types = { path = "../nd-pdk-types" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-types/Cargo.toml b/plugins/pdk/rust/nd-pdk-types/Cargo.toml new file mode 100644 index 000000000..e20e07021 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nd-pdk-types" +version = "0.1.0" +edition = "2021" +description = "Navidrome shared plugin data types for Rust" +authors = ["Navidrome Team"] +license = "GPL-3.0" + +[lib] +path = "src/lib.rs" +crate-type = ["rlib"] + +[dependencies] +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs new file mode 100644 index 000000000..9b40c4d56 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/src/lib.rs @@ -0,0 +1,75 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// ArtistRef is the minimal information a plugin returns for Navidrome to match an +/// artist against the library. It is a reference, not a full artist entity: it +/// carries only matching keys (name and optional internal/MusicBrainz IDs), never +/// descriptive data such as biographies or images. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// ID is the internal Navidrome artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// SongRef is the minimal information exchanged between a plugin and Navidrome to +/// match a song. It is used both as input (a song Navidrome already has) and as +/// output (a song a plugin suggests, which may not be in the library yet). Unlike +/// Track, it is an abstract recording reference carrying only matching keys (IDs, +/// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec<ArtistRef>, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} diff --git a/plugins/pdk/rust/nd-pdk/Cargo.toml b/plugins/pdk/rust/nd-pdk/Cargo.toml index 34fe9f032..8ca457914 100644 --- a/plugins/pdk/rust/nd-pdk/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["rlib"] [dependencies] nd-pdk-host = { path = "../nd-pdk-host" } nd-pdk-capabilities = { path = "../nd-pdk-capabilities" } +nd-pdk-types = { path = "../nd-pdk-types" } extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk/src/lib.rs b/plugins/pdk/rust/nd-pdk/src/lib.rs index b1389938b..36b2ec316 100644 --- a/plugins/pdk/rust/nd-pdk/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk/src/lib.rs @@ -31,5 +31,8 @@ pub use nd_pdk_host as host; /// Capability wrappers for implementing plugin exports. pub use nd_pdk_capabilities::*; +/// Shared plugin data types. +pub use nd_pdk_types as types; + /// Re-export extism-pdk for convenience. pub use extism_pdk; diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 8abdccf07..b3203a352 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) // CapabilityScrobbler indicates the plugin can receive scrobble events. @@ -154,10 +155,10 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef -func participantsToArtistRefs(participants model.ParticipantList) []capabilities.ArtistRef { - refs := make([]capabilities.ArtistRef, len(participants)) +func participantsToArtistRefs(participants model.ParticipantList) []types.ArtistRef { + refs := make([]types.ArtistRef, len(participants)) for i, p := range participants { - refs[i] = capabilities.ArtistRef{ + refs[i] = types.ArtistRef{ ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index e29684512..2d48b28f0 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) const CapabilitySonicSimilarity Capability = "SonicSimilarity" @@ -61,8 +62,8 @@ func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endM return sonicMatchesToSimilarResults(resp.Matches), nil } -func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { - ref := capabilities.SongRef{ +func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { + ref := types.SongRef{ ID: mf.ID, Name: mf.Title, MBID: mf.MbzRecordingID, diff --git a/plugins/testdata/test-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go index 23e933eb3..bb8a092c6 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/metadata" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -67,9 +68,9 @@ func (t *testMetadataAgent) GetSimilarArtists(input metadata.SimilarArtistsReque if limit == 0 { limit = 5 } - artists := make([]metadata.ArtistRef, 0, limit) + artists := make([]types.ArtistRef, 0, limit) for i := range limit { - artists = append(artists, metadata.ArtistRef{ + artists = append(artists, types.ArtistRef{ ID: "similar-artist-id-" + strconv.Itoa(i+1), Name: input.Name + " Similar " + string(rune('A'+i)), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -86,9 +87,9 @@ func (t *testMetadataAgent) GetArtistTopSongs(input metadata.TopSongsRequest) (* if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "song-id-" + strconv.Itoa(i+1), Name: input.Name + " Song " + strconv.Itoa(i+1), MBID: "song-mbid-" + strconv.Itoa(i+1), @@ -128,9 +129,9 @@ func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "similar-track-id-" + strconv.Itoa(i+1), Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -150,9 +151,9 @@ func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "album-similar-id-" + strconv.Itoa(i+1), Name: "Album Similar #" + strconv.Itoa(i+1), Artist: input.Artist, @@ -170,9 +171,9 @@ func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsB if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "artist-similar-id-" + strconv.Itoa(i+1), Name: input.Name + " Style Song #" + strconv.Itoa(i+1), Artist: input.Name + " Similar Artist", diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go index e315bff15..538e9c8f3 100644 --- a/plugins/testdata/test-sonic-similarity/main.go +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -35,7 +36,7 @@ func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSon matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "similar-track-" + strconv.Itoa(i+1), Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), Artist: input.Song.Artist, @@ -57,7 +58,7 @@ func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathR matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "path-track-" + strconv.Itoa(i+1), Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), Artist: input.StartSong.Artist, diff --git a/plugins/types/types.go b/plugins/types/types.go new file mode 100644 index 000000000..eaccf3469 --- /dev/null +++ b/plugins/types/types.go @@ -0,0 +1,42 @@ +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` // TODO: rename to Title to align with Track.Title and model.MediaFile.Title; kept as Name for now for compatibility. + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} From 5f2b7ee1050a177785864ab50db4c1c8c118ea20 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 29 Jun 2026 21:24:02 -0400 Subject: [PATCH 323/596] chore(deps): update webp, gomega, and go-toml dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 91da9e52f..4803c23ee 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.6.0 + github.com/gen2brain/webp v0.6.3 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 @@ -40,8 +40,8 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 - github.com/onsi/gomega v1.42.0 - github.com/pelletier/go-toml/v2 v2.4.1 + github.com/onsi/gomega v1.42.1 + github.com/pelletier/go-toml/v2 v2.4.2 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 @@ -135,8 +135,8 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.37.0 // indirect - golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 1c0dcc9fe..1972e34c2 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.6.0 h1:VN/cmeDv78sAKbJayyB1YVm9SHlz5qXO06bo4v6V3hY= -github.com/gen2brain/webp v0.6.0/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= +github.com/gen2brain/webp v0.6.3 h1:DbXXCkiHN6zq2qIuTsPSZQhVi2VcQ0UPzKbKqKENfsQ= +github.com/gen2brain/webp v0.6.3/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -194,10 +194,10 @@ github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= -github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/pelletier/go-toml/v2 v2.4.1 h1:j5OMOImsH+j2k7GJ5YO+RxfWwohNiH6t5zB/+h3bagc= -github.com/pelletier/go-toml/v2 v2.4.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= +github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -328,8 +328,8 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ= -golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -340,8 +340,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From 385e75e9a978622ad647c38114e44d0b9095ca6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 30 Jun 2026 22:50:43 -0400 Subject: [PATCH 324/596] perf(db): skip annotation join in CountAll when unused (#5694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(persistence): skip annotation join in CountAll when unused The Native API list endpoints (/api/song, /api/album, /api/artist) issue a pagination count on every request via rest.GetAll. CountAll unconditionally added a LEFT JOIN on the annotation table to a count(distinct id) query. The join's columns are stripped by count(), but the distinct-over-join forced SQLite to stream and dedup every row, making the count dominate the request time on large libraries (e.g. ~190ms cold for 95k songs, seconds for non-admin users behind the library subquery). Gate the annotation join: only add it when a filter actually references an annotation column. The need is detected by rendering the query to SQL and matching annotation column names as whole words, which covers both named filters (starred, has_rating) and raw squirrel filters. The column set is derived from model.Annotations so it tracks schema changes; average_rating is excluded because it lives on the base table, and word-boundary matching keeps it from matching the annotation column rating. Counts are unchanged; only the query plan changes. Unfiltered song counts drop from ~37ms to ~4ms (warm) on a 95k-song library. * test(persistence): make annotation-join detection case-insensitive Address review feedback: SQLite column names are case-insensitive, so a raw filter using e.g. "RATING" would previously evade the case-sensitive column regex and wrongly drop the annotation join. Add the (?i) flag (average_rating stays excluded — the underscore still prevents a word boundary before rating) and cover it with mixed-case tests. Also make the starred-count assertion an exact value instead of a range. --- persistence/album_repository.go | 4 +- persistence/artist_repository.go | 6 +- persistence/artist_repository_test.go | 17 +++++ persistence/mediafile_repository.go | 5 +- persistence/mediafile_repository_test.go | 31 ++++++++ persistence/sql_annotations.go | 44 +++++++++++ persistence/sql_annotations_test.go | 94 ++++++++++++++++++++++++ 7 files changed, 198 insertions(+), 3 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 1d65215f7..31a0f5c91 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -186,8 +186,10 @@ func allRolesFilter(_ string, value any) Sqlizer { func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() - query = r.withAnnotation(query, "album.id") query = r.applyLibraryFilter(query) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "album.id") + } return r.count(query, options...) } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 56843b911..2c6b77054 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -201,7 +201,11 @@ func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBui func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() query = r.applyLibraryFilterToArtistQuery(query) - query = r.withAnnotation(query, "artist.id") + // Only the annotation join is gated; the library_artist join above (and its count(distinct)) + // must stay, since an artist can span multiple libraries. + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "artist.id") + } return r.count(query, options...) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 603c5dd5e..d7b695ade 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -273,6 +273,23 @@ var _ = Describe("ArtistRepository", func() { It("returns the number of artists in the DB", func() { Expect(repo.CountAll()).To(Equal(int64(4))) }) + + It("counts starred artists when an annotation filter is present", func() { + // The Beatles (id 3) is starred for the admin user in the seed data + count, err := repo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("counts with has_rating=false without a 'no such column' error (join kept)", func() { + count, err := repo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("rating")("rating", "false"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) }) Describe("Exists", func() { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 28c11f686..d7d892ed1 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -124,8 +124,11 @@ func mediaFileRecentlyAddedSort() string { func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() - query = r.withAnnotation(query, "media_file.id") query = r.applyLibraryFilter(query) + // The annotation join is expensive with count(distinct) and pointless unless a filter uses it. + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "media_file.id") + } return r.count(query, options...) } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index ba4747cdc..80d440c41 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -45,6 +45,37 @@ var _ = Describe("MediaRepository", func() { Expect(mr.CountAll()).To(Equal(int64(13))) }) + Describe("CountAll annotation-join gating", func() { + var adminRepo model.MediaFileRepository + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) + adminRepo = NewMediaFileRepository(adminCtx, GetDBXBuilder()) + }) + + It("counts starred songs when an annotation filter is present", func() { + // Come Together (id 1002) is starred for the admin user in the seed data + count, err := adminRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("counts with starred=false without a 'no such column' error (join kept)", func() { + count, err := adminRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "false"), + }) + Expect(err).ToNot(HaveOccurred()) + // All songs except the one starred one + Expect(count).To(Equal(int64(12))) + }) + + It("counts unfiltered with the join dropped", func() { + Expect(adminRepo.CountAll()).To(Equal(int64(13))) + }) + }) + Describe("CountBySuffix", func() { var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 07bd96975..78b7938a1 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -4,17 +4,61 @@ import ( "database/sql" "errors" "fmt" + "regexp" + "sort" "strings" + "sync" "time" . "github.com/Masterminds/squirrel" + "github.com/fatih/structs" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" ) const annotationTable = "annotation" +// annotationColumns are the columns withAnnotation's LEFT JOIN contributes, derived from +// model.Annotations so the set tracks schema changes. average_rating is excluded: it lives on the +// base table, not the annotation join. +var annotationColumns = sync.OnceValue(func() map[string]struct{} { + cols := map[string]struct{}{} + for name := range structs.Map(model.Annotations{}) { + if name == "average_rating" { + continue + } + cols[name] = struct{}{} + } + return cols +}) + +// annotationColumnRE matches any annotation column as a whole word. The word boundaries keep the +// base-table column average_rating from matching the annotation column rating (Go's \b treats '_' +// as a word char). It is case-insensitive because SQLite column names are, so a raw filter using +// e.g. "RATING" must still be detected. +var annotationColumnRE = sync.OnceValue(func() *regexp.Regexp { + cols := make([]string, 0, len(annotationColumns())) + for col := range annotationColumns() { + cols = append(cols, regexp.QuoteMeta(col)) + } + sort.Strings(cols) // map iteration is random; sort for a stable pattern + return regexp.MustCompile(`(?i)\b(?:` + strings.Join(cols, "|") + `)\b`) +}) + +// filtersNeedAnnotation reports whether the rendered query references an annotation column, i.e. +// whether the annotation LEFT JOIN must be kept. Scanning the rendered SQL catches every filter +// path. The placeholder column is needed because squirrel won't render a column-less SELECT; on a +// render error, keep the join to be safe. +func filtersNeedAnnotation(query SelectBuilder) bool { + sql, _, err := query.Columns("1").ToSql() + if err != nil { + return true + } + return annotationColumnRE().MatchString(sql) +} + func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { userID := loggedUser(r.ctx).ID if userID == invalidUserId { diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 15efc5dc7..5766f687f 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -150,4 +150,98 @@ var _ = Describe("Annotation Filters", func() { } Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored") }) + + Describe("annotationColumns", func() { + It("derives the annotation join columns from model.Annotations, excluding average_rating", func() { + cols := annotationColumns() + Expect(cols).To(HaveKey("starred")) + Expect(cols).To(HaveKey("starred_at")) + Expect(cols).To(HaveKey("rating")) + Expect(cols).To(HaveKey("rated_at")) + Expect(cols).To(HaveKey("play_count")) + Expect(cols).To(HaveKey("play_date")) + Expect(cols).To(HaveLen(6), "expected exactly the 6 annotation-join columns") + Expect(cols).ToNot(HaveKey("average_rating"), "average_rating lives on the base table, not the annotation join") + }) + }) + + Describe("filtersNeedAnnotation", func() { + It("is true when the query references an annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"starred": true}) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is true for a raw expression referencing an annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("rating > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is false for a query that references no annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"missing": false}) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + + It("is false for a filter on average_rating (base-table column, not the annotation rating)", func() { + // Regression: average_rating must not match the annotation column "rating". + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Gt{"average_rating": 3}) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + + It("is true when both average_rating and a real annotation column are referenced", func() { + q := squirrel.Select("count(1)").From("media_file"). + Where(squirrel.Gt{"average_rating": 3}). + Where(squirrel.Expr("COALESCE(rating, 0) > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is true for uppercase/mixed-case annotation columns (SQLite is case-insensitive)", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("RATING > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is false for uppercase average_rating (still excluded case-insensitively)", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("AVERAGE_RATING > 3")) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + }) + + Describe("CountAll annotation-join gating", func() { + It("counts all items unfiltered (join dropped)", func() { + total, err := albumRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">=", int64(1))) + + filtered, err := albumRepo.CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(filtered).To(Equal(int64(1))) + }) + + It("counts starred items correctly (named annotation filter keeps the join)", func() { + starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1} + Expect(albumRepo.Put(&starredAlbum)).To(Succeed()) + Expect(albumRepo.SetStar(true, starredAlbum.ID)).To(Succeed()) + defer func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID})) + }() + + // Exactly two albums are starred for this user: the one created above and + // albumRadioactivity (id 103) from the seed data. + count, err := albumRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) + }) + + It("counts via a raw annotation filter without a 'no such column' error", func() { + count, err := albumRepo.CountAll(model.QueryOptions{ + Filters: squirrel.Expr("COALESCE(rating, 0) > 0"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", int64(0))) + }) + }) }) From f580d3ea76ca05ceef497e783de8ff6e65968ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 1 Jul 2026 11:19:15 -0400 Subject: [PATCH 325/596] feat(plugins): expose the song Matcher as a host service (#5643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): add public Track and Artist DTOs for host services * feat(plugins): add Matcher host-service interface and MatchSong DTO * feat(plugins): generate Matcher host wrappers, PDK clients, and matcher permission * feat(plugins): implement Matcher host service and MediaFile-to-Track converter Also fixes an ndpgen bug where ParseDirectory parsed each host-service file in isolation, so a service method referencing a struct defined in another file of the same package (host.Track in track.go) could not be resolved. ParseDirectory now collects package-wide structs in a first pass, mirroring ParseCapabilities; PDK clients regenerated cleanly via make gen. * feat(plugins): register Matcher host service in the manager * test(plugins): add Matcher host service integration test plugin * refactor(plugins): simplify matcher converter and parser file collection - toTrack: use gg.V for nil-able field derefs and slice.Map for genres/ participants, removing the repeated nil-guard blocks and inner loop - manager_loader: drop the redundant ds==nil guard (loadEnabledPlugins already gates a nil DataStore), matching the other service entries - ndpgen parser: extract collectGoFiles, shared by ParseDirectory and ParseCapabilities instead of duplicating the file-filter loop * fix(plugins): keep nullable Track numerics as pointers ReplayGain values, BitDepth, and BPM are nullable in model.MediaFile, and 0 is a valid measured ReplayGain value. Flattening them to value types with omitempty made a real 0 indistinguishable from absent. Model them as *float64 /*int32 so plugins can tell 'no data' from a measured 0. Regenerated PDK clients; converter passes the model pointers through (RG) or maps *int->*int32 (BitDepth/BPM). * refactor(plugins): trim redundant pass labels in ndpgen ParseDirectory The function doc already explains the two-pass approach; the inline labels restated it. Reduce to bare waypoints. * fix(plugins): gate Track.Path on library filesystem permission MatchSongs copied mf.Path into every result unconditionally, letting a plugin with only the matcher permission enumerate on-disk file paths by matching known songs. Gate Path behind library.filesystem, matching the Library host service. toTrack is now a method carrying the permission flag. * refactor(plugins): align MatchSong JSON casing and parse Go files once - MatchSong: artistMBID/albumMBID JSON tags -> artistMbid/albumMbid so the Go wire format matches the Rust SDK's camelCase serialization (cross-SDK fix) - MatchSongs doc reworded to language-neutral 'empty (absent)' so generated Rust/Python client docs no longer say Go-specific 'nil' - ndpgen: parse each package file once (parseGoFiles) and reuse the ASTs across both passes in ParseDirectory and ParseCapabilities, instead of re-parsing * refactor(plugins): use shared types for Matcher host service Move the Matcher host service onto the shared plugins/types package instead of the host-local MatchSong and Track structs. MatchSongs now takes []types.SongRef and returns []*types.Track, dropping host.MatchSong and moving host.Track (with its host.Artist dependency collapsed onto types.ArtistRef) into plugins/types. ArtistRef gains SortName and SubRole so it can back a track's Participants. SongRef gains a millisecond-precision DurationMs field that supersedes the now deprecated seconds-based Duration, with DurationInMs() resolving the effective value and SetDurationMs() keeping both fields in sync when populating a SongRef to send to a plugin. The ndpgen host-wrapper template only ever imported context, json and extism, so a host service referencing the shared types package produced uncompilable code. Emit the plugins/types import when the service references shared types directly (gated on the existing Service.ImportsSharedTypes), matching the client template, and cover it with GenerateHost tests. This removes the need for host-local re-export aliases. Regenerated the Go/Rust/Python PDK and capability schemas accordingly. * test(plugins): cover SongRef duration and artist conversion Add unit coverage for the new SongRef behavior: SetDurationMs populating both DurationMs and the deprecated seconds field, and the SongRef-to-agents.Song conversion preferring DurationMs over Duration and the Artists list over the scalar Artist/ArtistMBID. Extract the inline SongRef-to-agents.Song closure in MatchSongs into a named toAgentSong function so the conversion can be asserted directly rather than only through the opaque matcher. The end-to-end wire shape of the moved types is already validated by the existing MatcherService integration test, so no new WASM-boundary test is needed. * fix(plugins): harden and unify SongRef-to-agents.Song duration conversion Address findings from a code review of the matcher host service: - DurationInMs now clamps a negative deprecated-seconds value to 0 instead of converting it through uint32, which previously wrapped a value like -1s into a ~49-day duration that corrupted the matcher's duration-proximity tiebreaker. - Replace the unused SetDurationMs(uint32) with SetDuration(seconds float32), which takes the unit callers actually hold (model.MediaFile.Duration is float32 seconds) and centralizes the seconds-to-ms conversion. Wire it into mediaFileToSongRef so outbound SongRefs carry both duration fields in sync. - Make the metadata-agent path use DurationInMs() so every consumer of the shared SongRef honors the DurationMs-over-Duration precedence contract; a plugin sending only DurationMs no longer loses its duration on that path. - Collapse the matcher's duplicate toAgentSong/agentArtists helpers into the existing songRefToAgentSong converter, so there is a single SongRef-to-Song mapping. Tests narrowed to the duration cases, with artist precedence still covered in metadata_agent_test.go. * feat(plugins): allow Matcher host service to scope a match to a user Add an options struct to the Matcher host service so a plugin can run a match as a specific user. When MatchOptions.Username is set, the match is run in that user's context: their favourites and ratings inform the matcher's tiebreaker, and the returned tracks carry that user's per-user annotations (Starred, StarredAt, Rating, PlayCount, PlayDate, added to types.Track). An empty username preserves the previous unscoped behaviour. Cross-user access is gated by the same allowedUsers/allUsers permission the Users and SubsonicAPI host services use: an unknown username, or one the plugin is not permitted to act as, returns an error. User-library access applies automatically once the user is in context (applyLibraryFilter). Independently, results are now restricted to the libraries the plugin itself may access via the precomputed libraryAccess set, dropping any matched track outside that set (the input index stays unmatched) — this applies even without a username and even for an admin-scoped user. core/matcher is unchanged: it already loads and uses annotations and applies user-library filtering from context, so the feature works by deriving the request context and post-filtering by plugin library access in the host adapter. The new opts parameter and the Track annotation fields are propagated to all PDK clients (Go/Rust/Python) by make gen. * fix(plugins): correct Matcher library scope and unify user-access checks Address findings from a code review of the user-scoped Matcher host service: - The plugin-library post-filter previously dropped every match for a plugin that holds only the matcher permission, because library config is tied to the Library permission and a matcher-only plugin has none (empty allowedLibraries, AllLibraries=false). Gate the filter on whether the plugin actually declared the Library permission: matcher-only plugins are no longer library-restricted, while plugins that opt into a library scope are enforced as before. The per-user library filter (applyLibraryFilter) still applies whenever a non-admin user is scoped. - resolveUser collapsed every FindByUsername error (including transient DB failures) into a misleading "not found". Extract a shared userAccess type (alongside libraryAccess) whose resolve() distinguishes model.ErrNotFound from a real backend error and authorizes the user against the allowed set. The Matcher service now uses it, and host_subsonicapi shares the same userAccess type for its permission check (preserving its existing error messages), removing a third divergent copy of the resolve-and-authorize logic. - Document in the matcher tests that the mock MediaFileRepo returns annotations unconditionally, so the unit tests cover the adapter's scoped-flag gating and access checks but not the SQL per-user join. Add tests for the library-permission gating and for surfacing a backend error instead of masking it as not-found. * fix(plugins): require a library scope for Matcher, fail closed Reverse the permissive default introduced when fixing the library post-filter: a Matcher plugin now must be granted a library scope (all libraries, or at least one specific library) and MatchSongs rejects the request with "no libraries configured" when it has none, instead of either silently matching nothing or defaulting to every library. This mirrors how the SubsonicAPI host service requires a user scope (checkPermissions errors with "no users configured" when none is set): the check is a runtime guard via libraryAccess.configured(), needs no manifest changes, and keeps the failure loud rather than silent. The per-match library post-filter then always applies, and the restrictLibraries flag added in the previous commit is removed. * fix(plugins): require library permission for matcher; guard nil user Close the gap where a plugin declaring only the matcher permission loaded successfully but failed every MatchSongs call with "no libraries configured", with no way for an admin to grant a library scope (the library-config UI is gated on the library permission). Add a cross-field manifest rule, mirroring the existing "subsonicapi requires users" rule, so the matcher permission requires the library permission to be declared. A matcher plugin therefore also surfaces the library-config panel and is subject to the existing load/enable-time library configuration gate, making the fail-closed library check reachable and fixable rather than a silent dead end. The test plugin manifest now declares the library permission accordingly. Also restore a defensive nil-user guard in userAccess.resolve: if a DataStore's FindByUsername ever returns (nil, nil) instead of model.ErrNotFound, return a clean "not found" error rather than dereferencing a nil *model.User. * feat(plugins): expose track AverageRating in Matcher results Add AverageRating to the Matcher's Track DTO. Unlike the per-user annotations (Starred, Rating, PlayCount, ...), AverageRating is an aggregate stored on the track itself and is loaded regardless of the request user, so it is populated unconditionally rather than gated on a scoped username. Propagated to the PDK types by make gen. Signed-off-by: Deluan <deluan@navidrome.org> * style(plugins): trim verbose comments in matcher host service Condense the over-long explanatory comments added across the matcher host service to one-liners that state the why, and simplify the ptrInt32/unixPtr helpers to Go 1.26's new(value). No behavior change. * refactor(plugins): pass userAccess into newSubsonicAPIService Move newUserAccess construction to the loader call site so the SubsonicAPI service constructor takes a userAccess value directly, matching newMatcherService. Pure refactor: the service already stored a userAccess internally, so behavior and error messages are unchanged. * fix(plugins): regenerate PDK and drop omitempty from AverageRating Re-run make gen so the generated PDK doc comments match the source comment trimmed in an earlier commit (the source was simplified but the PDK was not regenerated, leaving the committed files stale — a 'generated files up to date' hazard). Also drop omitempty from Track.AverageRating: it is always set (0 when unrated), so it should be present in the payload like the other always-set fields (BirthTime/CreatedAt/UpdatedAt), not dropped at zero. Tag change propagated to the PDK by the same regeneration. * fix(plugins): reject user-scoped match before lookup when plugin has no user scope A matcher plugin requires the library permission but not the users permission, so a matcher-only plugin always has an empty user scope (allUsers=false, no allowed users). MatchSongs still ran FindByUsername for any opts.Username before checking authorization and returned distinguishable errors ('user X not found' vs 'not allowed to act as user X'), letting such a plugin enumerate account names from the error text. Guard userAccess.resolve to reject with a single fixed error before the lookup when the plugin has no user scope, mirroring how the SubsonicAPI service short-circuits with 'no users configured'. The unscoped match path (no username) is unaffected, so matcher-only plugins still match normally. * fix(plugins): run unscoped matcher as admin, not the inherited request user A matcher host call can arrive on a context that already carries a request user (e.g. a plugin capability invoked while serving that user's request — extism propagates the call context into host functions). With no opts.Username, MatchSongs passed that context straight through, so the media-file repository applied the caller's library filter and per-user annotation ranking to an explicitly unscoped match. Set the user context explicitly: a username scopes to that user (overriding any inherited one), and an unscoped match runs under adminContext so only the plugin's own library scope constrains results. Adds tests using a context-capturing DataStore to assert the user the matcher resolves in both cases. * chore(plugins): drop the generated Python matcher PDK The Python plugin PDK is no longer supported (ndpgen generates only Go and Rust clients), so remove the stale generated nd_host_matcher.py rather than leave a client that drifts from the host interface. * docs(plugins): deprecate SongRef.Artist/ArtistMBID in favor of Artists Mark the scalar single-artist fields deprecated; Artists (the ArtistRef list) is the preferred way to supply artist data and already takes precedence for matching. Propagated to the PDK and capability schemas by make gen. * refactor(plugins): flatten Track.Participants and add Role to ArtistRef Change Track.Participants from map[role][]ArtistRef to a flat []ArtistRef, and give ArtistRef a Role field (the participation category: artist/composer/performer/...) alongside SubRole (a specialization within a role, e.g. the instrument for a performer). In the flat list each entry now self-describes its role rather than relying on a map key, matching how SongRef.Artists is already a flat list; the converter tags each entry with its role and emits them in a stable role order. Propagated to the PDK and capability schemas by make gen. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- plugins/capabilities/lyrics.yaml | 12 +- plugins/capabilities/metadata_agent.yaml | 35 +- plugins/capabilities/scrobbler.yaml | 12 +- plugins/capabilities/sonic_similarity.yaml | 35 +- plugins/cmd/ndpgen/internal/generator_test.go | 36 ++ plugins/cmd/ndpgen/internal/parser.go | 32 +- plugins/cmd/ndpgen/internal/parser_test.go | 58 +++ .../ndpgen/internal/templates/host.go.tmpl | 4 + plugins/host/matcher.go | 27 + plugins/host/matcher_gen.go | 91 ++++ plugins/host_matcher.go | 194 +++++++ plugins/host_matcher_test.go | 490 ++++++++++++++++++ plugins/host_subsonicapi.go | 32 +- plugins/host_subsonicapi_test.go | 36 +- plugins/manager_loader.go | 15 +- plugins/manager_plugin.go | 57 ++ plugins/manifest-schema.json | 14 + plugins/manifest.go | 8 + plugins/manifest_gen.go | 9 + plugins/manifest_test.go | 31 ++ plugins/metadata_agent.go | 2 +- plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_matcher.go | 77 +++ plugins/pdk/go/host/nd_host_matcher_stub.go | 44 ++ plugins/pdk/go/types/types.go | 102 +++- plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../rust/nd-pdk-host/src/nd_host_matcher.rs | 65 +++ plugins/pdk/rust/nd-pdk-types/src/lib.rs | 166 +++++- plugins/sonic_similarity_adapter.go | 4 +- plugins/sonic_similarity_adapter_test.go | 4 +- plugins/testdata/test-matcher/go.mod | 16 + plugins/testdata/test-matcher/go.sum | 14 + plugins/testdata/test-matcher/main.go | 56 ++ plugins/testdata/test-matcher/manifest.json | 14 + plugins/types/track.go | 93 ++++ plugins/types/types.go | 47 +- plugins/types/types_suite_test.go | 17 + plugins/types/types_test.go | 50 ++ 38 files changed, 1937 insertions(+), 71 deletions(-) create mode 100644 plugins/host/matcher.go create mode 100644 plugins/host/matcher_gen.go create mode 100644 plugins/host_matcher.go create mode 100644 plugins/host_matcher_test.go create mode 100644 plugins/pdk/go/host/nd_host_matcher.go create mode 100644 plugins/pdk/go/host/nd_host_matcher_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs create mode 100644 plugins/testdata/test-matcher/go.mod create mode 100644 plugins/testdata/test-matcher/go.sum create mode 100644 plugins/testdata/test-matcher/main.go create mode 100644 plugins/testdata/test-matcher/manifest.json create mode 100644 plugins/types/track.go create mode 100644 plugins/types/types_suite_test.go create mode 100644 plugins/types/types_test.go diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index a64897509..7336124fc 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -114,7 +114,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -126,5 +127,14 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index cae23eadc..6e528c7e7 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -351,7 +351,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -363,6 +364,15 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name SongRef: @@ -387,10 +397,16 @@ components: description: ISRC is the International Standard Recording Code for the song. artist: type: string - description: Artist is the artist name. + description: |- + Artist is the artist name. + + Deprecated: use Artists. artistMbid: type: string - description: ArtistMBID is the MusicBrainz artist ID. + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. artists: type: array description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. @@ -405,6 +421,17 @@ components: duration: type: number format: float - description: Duration is the song duration in seconds. + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. required: - name diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 07af8ec7a..2b862b964 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -184,7 +184,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -196,5 +197,14 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index f0951fcec..a336fbce8 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -61,7 +61,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -73,6 +74,15 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name SongRef: @@ -97,10 +107,16 @@ components: description: ISRC is the International Standard Recording Code for the song. artist: type: string - description: Artist is the artist name. + description: |- + Artist is the artist name. + + Deprecated: use Artists. artistMbid: type: string - description: ArtistMBID is the MusicBrainz artist ID. + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. artists: type: array description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. @@ -115,6 +131,17 @@ components: duration: type: number format: float - description: Duration is the song duration in seconds. + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. required: - name diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index d52d6dcbf..15b97e1ae 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -287,6 +287,42 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`"encoding/json"`)) Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) + + It("imports the shared types package when a method references types directly", func() { + svc := Service{ + Name: "Matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("songs", "[]types.SongRef")}, + Returns: []Param{NewParam("results", "[]*types.Track")}, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + Expect(codeStr).To(ContainSubstring("Songs []types.SongRef")) + }) + + It("does not import the shared types package when no method references types", func() { + svc := Service{ + Name: "Test", + Interface: "TestService", + Methods: []Method{ + {Name: "Method", Params: []Param{NewParam("count", "int32")}}, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + }) }) Describe("toJSONName", func() { diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4b832d22d..9f305efec 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -85,19 +85,24 @@ func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Servic return nil, err } - // First pass: collect all type aliases from every file so that an alias - // declared in one file is visible when resolving types in a sibling file. + // First pass: collect all struct definitions and type aliases from every file + // so that a struct or alias declared in one file is visible when resolving + // types in a sibling file. + pkgStructMap := make(map[string]StructDef) pkgAliasMap := make(map[string]TypeAlias) for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s + } for _, a := range parseTypeAliases(pf.file) { pkgAliasMap[a.Name] = a } } - // Second pass: parse services using the package-level alias map. + // Second pass: parse services using the package-level maps. var services []Service for _, pf := range parsed { - svcList, err := parseServiceFile(pf.file, pkgAliasMap, shared) + svcList, err := parseServiceFile(pf.file, pkgStructMap, pkgAliasMap, shared) if err != nil { return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) } @@ -484,15 +489,10 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri } // parseServiceFile parses a single Go source file and extracts host services. -// pkgAliasMap is the package-wide alias map built from all files in the package. -func parseServiceFile(f *ast.File, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { - // Collect all struct definitions in the file. - allStructs := parseStructs(f) - structMap := make(map[string]StructDef) - for _, s := range allStructs { - structMap[s.Name] = s - } - +// pkgStructMap and pkgAliasMap are the package-wide struct and alias maps built +// from all files in the package, so a host-service interface can reference types +// defined in a sibling file. +func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { var services []Service for _, decl := range f.Decls { @@ -569,9 +569,13 @@ func parseServiceFile(f *ast.File, pkgAliasMap map[string]TypeAlias, shared map[ } service.SharedAliases = sharedAliases + // Recursively collect all struct dependencies so types referenced only + // transitively (e.g. a field type of a referenced struct) are attached. + collectAllStructDependencies(referencedTypes, pkgStructMap) + // Attach referenced structs to the service (sorted for stable output) for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { - if s, exists := structMap[typeName]; exists { + if s, exists := pkgStructMap[typeName]; exists { service.Structs = append(service.Structs, s) } } diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index 93d00f657..bfdb93db5 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -213,6 +213,64 @@ type RegularInterface interface { Expect(services).To(BeEmpty()) }) + It("should resolve structs defined in a sibling file of the same package", func() { + // types.go defines Track and Artist — no host service here + typesSrc := `package host + +// Artist is a track participant. +type Artist struct { + // ID is the artist identifier. + ID string ` + "`json:\"id\"`" + ` + // Name is the artist name. + Name string ` + "`json:\"name\"`" + ` +} + +// Track is a media file projection. +type Track struct { + // ID is the track identifier. + ID string ` + "`json:\"id\"`" + ` + // Title is the track title. + Title string ` + "`json:\"title\"`" + ` + // Participants maps role to artists (transitive dependency test). + Participants map[string][]Artist ` + "`json:\"participants\"`" + ` +} +` + // service.go defines the host service that references Track from types.go + serviceSrc := `package host + +import "context" + +// MatcherService matches tracks. +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchTrack finds a matching track. + //nd:hostfunc + MatchTrack(ctx context.Context, t Track) (matched bool, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(typesSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(serviceSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + svc := services[0] + Expect(svc.Name).To(Equal("Matcher")) + Expect(svc.Methods).To(HaveLen(1)) + Expect(svc.Methods[0].Name).To(Equal("MatchTrack")) + + // Track must be resolved from the sibling file, not just the service file. + // Artist must also be transitively resolved (Track.Participants references it). + structNames := make([]string, len(svc.Structs)) + for i, s := range svc.Structs { + structNames[i] = s.Name + } + Expect(structNames).To(ConsistOf("Track", "Artist")) + }) + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { fileA := `package host diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index 083f7577e..b1ac4dd36 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -7,6 +7,10 @@ import ( "encoding/json" extism "github.com/extism/go-sdk" +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/types" +{{- end}} ) {{- /* Generate request/response types for all methods */ -}} diff --git a/plugins/host/matcher.go b/plugins/host/matcher.go new file mode 100644 index 000000000..678d3bfaa --- /dev/null +++ b/plugins/host/matcher.go @@ -0,0 +1,27 @@ +package host + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + // Username runs the match as that user (case-insensitive): their favourites and + // ratings inform tiebreaking, and the returned tracks carry their annotations. + Username string `json:"username,omitempty"` +} + +// MatcherService resolves externally-obtained songs to local library tracks, +// reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). +// +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchSongs resolves each input song to its best-matching library track. + // It returns one entry per input song, in the same order as the input; the + // entry for an input song that had no match is empty (absent). Results are + // limited to the libraries the plugin (and the scoped user, if any) can access. + //nd:hostfunc + MatchSongs(ctx context.Context, songs []types.SongRef, opts MatchOptions) (results []*types.Track, err error) +} diff --git a/plugins/host/matcher_gen.go b/plugins/host/matcher_gen.go new file mode 100644 index 000000000..1bcd0dc3b --- /dev/null +++ b/plugins/host/matcher_gen.go @@ -0,0 +1,91 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatcherMatchSongsRequest is the request type for Matcher.MatchSongs. +type MatcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +// MatcherMatchSongsResponse is the response type for Matcher.MatchSongs. +type MatcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterMatcherHostFunctions registers Matcher service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterMatcherHostFunctions(service MatcherService) []extism.HostFunction { + return []extism.HostFunction{ + newMatcherMatchSongsHostFunction(service), + } +} + +func newMatcherMatchSongsHostFunction(service MatcherService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "matcher_matchsongs", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + matcherWriteError(p, stack, err) + return + } + var req MatcherMatchSongsRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + matcherWriteError(p, stack, err) + return + } + + // Call the service method + results, svcErr := service.MatchSongs(ctx, req.Songs, req.Opts) + if svcErr != nil { + matcherWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := MatcherMatchSongsResponse{ + Results: results, + } + matcherWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// matcherWriteResponse writes a JSON response to plugin memory. +func matcherWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + matcherWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// matcherWriteError writes an error response to plugin memory. +func matcherWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_matcher.go b/plugins/host_matcher.go new file mode 100644 index 000000000..9b65e7933 --- /dev/null +++ b/plugins/host_matcher.go @@ -0,0 +1,194 @@ +package plugins + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/utils/slice" +) + +type matcherServiceImpl struct { + ds model.DataStore + hasFilesystemPerm bool + users userAccess + libs libraryAccess +} + +func newMatcherService(ds model.DataStore, hasFilesystemPerm bool, users userAccess, libs libraryAccess) host.MatcherService { + return &matcherServiceImpl{ + ds: ds, + hasFilesystemPerm: hasFilesystemPerm, + users: users, + libs: libs, + } +} + +func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongRef, opts host.MatchOptions) ([]*types.Track, error) { + results := make([]*types.Track, len(songs)) + if len(songs) == 0 { + return results, nil + } + + // Fail closed when the plugin has no library scope, rather than matching nothing. + if !s.libs.configured() { + return nil, fmt.Errorf("matcher: no libraries configured for this plugin") + } + + // Set the user context explicitly so the match never inherits the request user + // of whatever invoked the plugin: a username scopes to that user (loading their + // annotations and library access), and an unscoped match runs as admin so only + // the plugin's own library scope constrains the results. + scoped := opts.Username != "" + if scoped { + usr, err := s.users.resolve(ctx, s.ds, opts.Username) + if err != nil { + return nil, fmt.Errorf("matcher: %w", err) + } + ctx = request.WithUser(ctx, *usr) + } else { + ctx = adminContext(ctx) + } + + agentSongs := slice.Map(songs, songRefToAgentSong) + + matched, err := matcher.New(s.ds).MatchSongsIndexed(ctx, agentSongs) + if err != nil { + return nil, err + } + for i, mf := range matched { + // Drop tracks outside the plugin's library scope, leaving that index unmatched. + if !s.libs.contains(mf.LibraryID) { + continue + } + results[i] = s.toTrack(&mf, scoped) + } + return results, nil +} + +// toTrack projects a MediaFile into the public Track DTO. Path needs filesystem +// permission; per-user annotations are only set for a scoped match. +func (s *matcherServiceImpl) toTrack(mf *model.MediaFile, scoped bool) *types.Track { + t := &types.Track{ + ID: mf.ID, + LibraryID: int32(mf.LibraryID), + LibraryName: mf.LibraryName, + Missing: mf.Missing, + Title: mf.Title, + Album: mf.Album, + Artist: mf.Artist, + AlbumArtist: mf.AlbumArtist, + AlbumID: mf.AlbumID, + SortTitle: mf.SortTitle, + SortAlbumName: mf.SortAlbumName, + SortArtistName: mf.SortArtistName, + TrackNumber: int32(mf.TrackNumber), + DiscNumber: int32(mf.DiscNumber), + DiscSubtitle: mf.DiscSubtitle, + Year: int32(mf.Year), + Date: mf.Date, + OriginalYear: int32(mf.OriginalYear), + OriginalDate: mf.OriginalDate, + ReleaseYear: int32(mf.ReleaseYear), + ReleaseDate: mf.ReleaseDate, + Size: mf.Size, + Suffix: mf.Suffix, + Duration: float64(mf.Duration), + BitRate: int32(mf.BitRate), + SampleRate: int32(mf.SampleRate), + BitDepth: ptrInt32(mf.BitDepth), + Channels: int32(mf.Channels), + Codec: mf.Codec, + Comment: mf.Comment, + BPM: ptrInt32(mf.BPM), + ExplicitStatus: mf.ExplicitStatus, + CatalogNum: mf.CatalogNum, + Compilation: mf.Compilation, + HasCoverArt: mf.HasCoverArt, + MbzRecordingID: mf.MbzRecordingID, + MbzReleaseTrackID: mf.MbzReleaseTrackID, + MbzAlbumID: mf.MbzAlbumID, + MbzReleaseGroupID: mf.MbzReleaseGroupID, + MbzAlbumType: mf.MbzAlbumType, + MbzAlbumComment: mf.MbzAlbumComment, + RGAlbumGain: mf.RGAlbumGain, + RGAlbumPeak: mf.RGAlbumPeak, + RGTrackGain: mf.RGTrackGain, + RGTrackPeak: mf.RGTrackPeak, + AverageRating: mf.AverageRating, // aggregate, not user-scoped + BirthTime: unixOrZero(mf.BirthTime), + CreatedAt: unixOrZero(mf.CreatedAt), + UpdatedAt: unixOrZero(mf.UpdatedAt), + } + if s.hasFilesystemPerm { + t.Path = mf.Path + } + if len(mf.Genres) > 0 { + t.Genres = slice.Map(mf.Genres, func(g model.Genre) string { return g.Name }) + } + if len(mf.Tags) > 0 { + t.Tags = make(map[string][]string, len(mf.Tags)) + for name, values := range mf.Tags { + t.Tags[string(name)] = values + } + } + if len(mf.Participants) > 0 { + // Flatten the role→artists map into a role-tagged list, in stable role order. + roles := slices.SortedFunc(maps.Keys(mf.Participants), func(a, b model.Role) int { + return cmp.Compare(a.String(), b.String()) + }) + for _, role := range roles { + for _, p := range mf.Participants[role] { + t.Participants = append(t.Participants, types.ArtistRef{ + ID: p.ID, + Name: p.Name, + MBID: p.MbzArtistID, + SortName: p.SortArtistName, + Role: role.String(), + SubRole: p.SubRole, + }) + } + } + } + if scoped { + t.Starred = mf.Starred + t.StarredAt = unixPtr(mf.StarredAt) + t.Rating = int32(mf.Rating) + t.PlayCount = mf.PlayCount + t.PlayDate = unixPtr(mf.PlayDate) + } + return t +} + +func unixOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +// unixPtr maps a nullable time to Unix seconds, keeping nil distinct from the epoch. +func unixPtr(t *time.Time) *int64 { + if t == nil || t.IsZero() { + return nil + } + return new(t.Unix()) +} + +// ptrInt32 narrows a nullable *int to *int32, keeping nil distinct from a real 0. +func ptrInt32(p *int) *int32 { + if p == nil { + return nil + } + return new(int32(*p)) +} + +var _ host.MatcherService = (*matcherServiceImpl)(nil) diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go new file mode 100644 index 000000000..9d44ee581 --- /dev/null +++ b/plugins/host_matcher_test.go @@ -0,0 +1,490 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MatcherService", Ordered, func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + // newConverter returns the service as its concrete type so converter unit + // tests can call toTrack directly with a chosen filesystem-permission flag. + newConverter := func(hasFilesystemPerm bool) *matcherServiceImpl { + return newMatcherService(nil, hasFilesystemPerm, newUserAccess(nil, true), newLibraryAccess(nil, true)).(*matcherServiceImpl) + } + + Describe("toTrack", func() { + It("projects a MediaFile into a public Track", func() { + bitDepth := 24 + bpm := 128 + rgGain := -7.5 + created := time.Unix(1700000000, 0) + updated := time.Unix(1700000500, 0) + birth := time.Unix(1699999000, 0) + + mf := &model.MediaFile{ + ID: "mf-1", + LibraryID: 3, + LibraryName: "Main", + Path: "/music/song.flac", + Title: "My Song", + Album: "My Album", + Artist: "My Artist", + AlbumArtist: "My Artist", + AlbumID: "al-1", + SortTitle: "my song", + TrackNumber: 4, + DiscNumber: 1, + Year: 2020, + Size: 1234, + Suffix: "flac", + Duration: 210.5, + BitRate: 1000, + SampleRate: 44100, + BitDepth: &bitDepth, + Channels: 2, + Codec: "flac", + Genre: "Rock", + BPM: &bpm, + ExplicitStatus: "c", + Compilation: true, + HasCoverArt: true, + MbzRecordingID: "rec-1", + RGTrackGain: &rgGain, + CreatedAt: created, + UpdatedAt: updated, + BirthTime: birth, + Genres: model.Genres{{Name: "Rock"}, {Name: "Pop"}}, + Tags: model.Tags{model.TagName("isrc"): []string{"US-XXX-00"}}, + } + mf.AverageRating = 4.2 + mf.Participants = model.Participants{} + mf.Participants.Add(model.RoleArtist, model.Artist{ + ID: "ar-1", Name: "My Artist", SortArtistName: "artist, my", MbzArtistID: "mbz-ar-1", + }) + mf.Participants.AddWithSubRole(model.RolePerformer, "violin", model.Artist{ + ID: "ar-2", Name: "A Fiddler", + }) + + track := newConverter(true).toTrack(mf, false) + + Expect(track.ID).To(Equal("mf-1")) + Expect(track.LibraryID).To(Equal(int32(3))) + Expect(track.LibraryName).To(Equal("Main")) + Expect(track.Path).To(Equal("/music/song.flac")) + Expect(track.Title).To(Equal("My Song")) + Expect(track.Duration).To(Equal(210.5)) + Expect(track.BitDepth).To(HaveValue(Equal(int32(24)))) + Expect(track.BPM).To(HaveValue(Equal(int32(128)))) + Expect(track.RGTrackGain).To(HaveValue(Equal(-7.5))) + Expect(track.Compilation).To(BeTrue()) + Expect(track.MbzRecordingID).To(Equal("rec-1")) + Expect(track.Genres).To(Equal([]string{"Rock", "Pop"})) + Expect(track.CreatedAt).To(Equal(int64(1700000000))) + Expect(track.UpdatedAt).To(Equal(int64(1700000500))) + Expect(track.BirthTime).To(Equal(int64(1699999000))) + Expect(track.Tags).To(HaveKeyWithValue("isrc", []string{"US-XXX-00"})) + // Flat, role-tagged, role-sorted. + Expect(track.Participants).To(HaveLen(2)) + Expect(track.Participants[0]).To(Equal(types.ArtistRef{ + ID: "ar-1", Name: "My Artist", SortName: "artist, my", MBID: "mbz-ar-1", Role: "artist", + })) + Expect(track.Participants[1]).To(Equal(types.ArtistRef{ + ID: "ar-2", Name: "A Fiddler", Role: "performer", SubRole: "violin", + })) + // AverageRating is an aggregate, exposed even though the match is unscoped. + Expect(track.AverageRating).To(Equal(4.2)) + }) + + It("leaves nil-able numeric fields nil when absent", func() { + mf := &model.MediaFile{ID: "mf-2", Title: "No Optionals"} + track := newConverter(true).toTrack(mf, false) + Expect(track.BitDepth).To(BeNil()) + Expect(track.BPM).To(BeNil()) + Expect(track.RGAlbumGain).To(BeNil()) + Expect(track.RGAlbumPeak).To(BeNil()) + Expect(track.RGTrackGain).To(BeNil()) + Expect(track.RGTrackPeak).To(BeNil()) + }) + + It("preserves a real 0 ReplayGain value as non-nil", func() { + zero := 0.0 + mf := &model.MediaFile{ID: "mf-3", Title: "Zero RG", RGTrackGain: &zero} + track := newConverter(true).toTrack(mf, false) + Expect(track.RGTrackGain).To(HaveValue(Equal(0.0))) + Expect(track.RGAlbumGain).To(BeNil()) + }) + + It("exposes Path only when the plugin has filesystem permission", func() { + mf := &model.MediaFile{ID: "mf-4", Title: "With Path", Path: "/music/x.flac"} + Expect(newConverter(true).toTrack(mf, false).Path).To(Equal("/music/x.flac")) + Expect(newConverter(false).toTrack(mf, false).Path).To(BeEmpty()) + }) + }) + + Describe("MatchSongs", func() { + // The mock MediaFileRepo returns stored files (with annotations) verbatim, + // ignoring QueryOptions and the context user. These tests therefore cover the + // adapter's gating/access logic, not the SQL per-user join (a persistence-layer + // concern). + + // allowAll returns a service permitted to match as any user across all + // libraries. + allowAll := func(ds model.DataStore) host.MatcherService { + return newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + } + + It("returns one entry per input song in order, with nil for no-match", func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + // First (ID) phase returns the match for input song 0 only. + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-100", Title: "Hit", Artist: "Band"}, + }) + ds := &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), []types.SongRef{ + {ID: "mf-100", Name: "Hit", Artist: "Band"}, + {ID: "missing-id", Name: "Ghost", Artist: "Nobody"}, + }, host.MatchOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-100")) + Expect(results[1]).To(BeNil()) + }) + + It("returns an empty slice for empty input", func() { + ds := &tests.MockDataStore{MockedMediaFile: tests.CreateMockMediaFileRepo()} + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), nil, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + Context("with a scoped user", func() { + var ds *tests.MockDataStore + var userRepo *tests.MockedUserRepo + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mf := model.MediaFile{ID: "mf-1", Title: "Hit", Artist: "Band", LibraryID: 1} + mf.Starred = true + mf.Rating = 5 + mediaFileRepo.SetData(model.MediaFiles{mf}) + + userRepo = tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo, MockedUser: userRepo} + }) + + input := []types.SongRef{{ID: "mf-1", Name: "Hit", Artist: "Band"}} + + It("does not expose annotations when no username is given", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeFalse()) + Expect(results[0].Rating).To(BeZero()) + }) + + It("exposes the user's annotations when an allowed username is given", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-alice"}, false), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeTrue()) + Expect(results[0].Rating).To(Equal(int32(5))) + }) + + It("allows any username when allUsers is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0].Starred).To(BeTrue()) + }) + + It("returns an error for an unknown username", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + Expect(err).To(MatchError(ContainSubstring("not found"))) + }) + + It("surfaces a backend error rather than masking it as not-found", func() { + userRepo.Error = errors.New("db is locked") + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("db is locked"))) + Expect(err.Error()).ToNot(ContainSubstring("not found")) + }) + + It("returns an error for a username the plugin is not allowed to use", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-bob"}, false), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("not allowed"))) + }) + + It("rejects a username with the same error whether it exists, when the plugin has no user scope", func() { + // A plugin with no user scope (the only state a matcher-only plugin can + // be in) must not leak whether a username exists via the error text. + svc := newMatcherService(ds, false, newUserAccess(nil, false), newLibraryAccess(nil, true)) + + _, errExisting := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + _, errMissing := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + + Expect(errExisting).To(HaveOccurred()) + Expect(errExisting.Error()).To(Equal(errMissing.Error())) + Expect(errExisting.Error()).ToNot(ContainSubstring("not found")) + Expect(errExisting.Error()).To(ContainSubstring("not authorized to scope by user")) + }) + + It("does not inherit the caller's request user for an unscoped match", func() { + // The plugin may be invoked while handling another user's request; an + // unscoped match must run as admin, not as that inherited user. + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.IsAdmin).To(BeTrue()) + Expect(usr.ID).ToNot(Equal("u-caller")) + }) + + It("uses the requested user, overriding an inherited caller user", func() { + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.ID).To(Equal("u-alice")) + }) + }) + + Context("with plugin library access", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-lib1", Title: "A", Artist: "Band", LibraryID: 1}, + {ID: "mf-lib2", Title: "B", Artist: "Band", LibraryID: 2}, + }) + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + }) + + input := []types.SongRef{ + {ID: "mf-lib1", Name: "A", Artist: "Band"}, + {ID: "mf-lib2", Name: "B", Artist: "Band"}, + } + + It("drops matches from libraries the plugin cannot access", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess([]int{1}, false)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-lib1")) + Expect(results[1]).To(BeNil()) // library 2 not permitted + }) + + It("keeps all matches when allLibraries is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[1]).ToNot(BeNil()) + }) + + It("errors when the plugin has no library scope configured", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, false)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).To(MatchError(ContainSubstring("no libraries configured"))) + }) + }) + }) + + // Artist precedence for songRefToAgentSong is covered in metadata_agent_test.go; + // here we cover the duration normalization the matcher path relies on. + Describe("songRefToAgentSong duration", func() { + It("prefers DurationMs over the deprecated seconds field", func() { + song := songRefToAgentSong(types.SongRef{DurationMs: 247333, Duration: 99}) + Expect(song.Duration).To(Equal(uint32(247333))) + }) + + It("falls back to the seconds field when DurationMs is zero", func() { + song := songRefToAgentSong(types.SongRef{Duration: 210.5}) + Expect(song.Duration).To(Equal(uint32(210500))) + }) + + It("clamps a negative seconds duration to zero instead of overflowing", func() { + song := songRefToAgentSong(types.SongRef{Duration: -1}) + Expect(song.Duration).To(BeZero()) + }) + }) +}) + +var _ = Describe("MatcherService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "matcher-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + srcPath := filepath.Join(testdataDir, "test-matcher"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-matcher"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + // AllLibraries: the matcher requires a library scope. + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-matcher", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, + AllLibraries: true, + }}) + + mediaFileRepo := tests.CreateMockMediaFileRepo() + hit := model.MediaFile{ID: "mf-hit", Title: "Hit", Artist: "Band"} + hit.Starred = true + mediaFileRepo.SetData(model.MediaFiles{hit}) + + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + dataStore := &tests.MockDataStore{ + MockedPlugin: mockPluginRepo, + MockedMediaFile: mediaFileRepo, + MockedUser: userRepo, + } + + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + It("loads the plugin with the matcher permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-matcher"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Matcher).ToNot(BeNil()) + }) + + It("matches songs through the host boundary, preserving order and nils", func() { + ctx := GinkgoT().Context() + manager.mu.RLock() + p := manager.plugins["test-matcher"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(ctx) + + type tIn struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` + } + type tOut struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` + } + + call := func(in tIn) tOut { + inputBytes, err := json.Marshal(in) + Expect(err).ToNot(HaveOccurred()) + _, outputBytes, err := instance.Call("nd_test_matcher", inputBytes) + Expect(err).ToNot(HaveOccurred()) + var out tOut + Expect(json.Unmarshal(outputBytes, &out)).To(Succeed()) + Expect(out.Error).To(BeNil()) + return out + } + + songs := []types.SongRef{ + {ID: "mf-hit", Name: "Hit", Artist: "Band"}, + {ID: "nope", Name: "Ghost", Artist: "Nobody"}, + } + + By("matching without a user, preserving order and nils") + out := call(tIn{Songs: songs}) + Expect(out.MatchedIDs).To(HaveLen(2)) + Expect(out.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(out.MatchedIDs[1]).To(BeEmpty()) + Expect(out.Starred[0]).To(BeFalse()) // no user scope → no annotations + + By("matching as a user, exposing that user's annotations across the boundary") + scoped := call(tIn{Songs: songs, Username: "alice"}) + Expect(scoped.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(scoped.Starred[0]).To(BeTrue()) + }) +}) + +// ctxCapturingDataStore records the context passed to MediaFile so tests can assert +// which user the matcher resolved before querying the library. +type ctxCapturingDataStore struct { + *tests.MockDataStore + lastMediaFileCtx context.Context +} + +func (d *ctxCapturingDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + d.lastMediaFileCtx = ctx + return d.MockDataStore.MediaFile(ctx) +} diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index 01a33c039..dba58d795 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1" // URL Format: Only the path and query parameters are used - host/protocol are ignored. // Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { - pluginID string - router SubsonicRouter - ds model.DataStore - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - userIDMap map[string]struct{} + pluginID string + router SubsonicRouter + ds model.DataStore + users userAccess // users this plugin may act as (from DB configuration) } // newSubsonicAPIService creates a new SubsonicAPIService for a plugin. -func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService { - userIDMap := make(map[string]struct{}) - for _, id := range allowedUserIDs { - userIDMap[id] = struct{}{} - } +func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService { return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - allowedUserIDs: allowedUserIDs, - allUsers: allUsers, - userIDMap: userIDMap, + pluginID: pluginID, + router: router, + ds: ds, + users: users, } } @@ -136,12 +128,12 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user - if s.allUsers { + if s.users.allUsers { return nil } // Must have at least one allowed user ID configured - if len(s.allowedUserIDs) == 0 { + if len(s.users.userIDSet) == 0 { return fmt.Errorf("no users configured for plugin %s", s.pluginID) } @@ -155,7 +147,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Check if the user's ID is in the allowed list - if _, ok := s.userIDMap[usr.ID]; !ok { + if !s.users.allows(usr.ID) { return fmt.Errorf("user %s is not authorized for this plugin", username) } diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 6f7ff4dd3..c3d9ffe8a 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -267,7 +267,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with specific user IDs allowed", func() { It("blocks users not in the allowed list", func() { // allowedUserIDs contains "user2", but testuser is "user1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -277,7 +277,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows users in the allowed list", func() { // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") @@ -287,7 +287,7 @@ var _ = Describe("SubsonicAPIService", func() { It("blocks admin users when not in allowed list", func() { // allowedUserIDs only contains "user1" (testuser), not "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") @@ -297,7 +297,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when in allowed list", func() { // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -308,7 +308,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with allUsers=true", func() { It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") @@ -317,7 +317,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -328,7 +328,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with no users configured", func() { It("returns error when no users are configured", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -337,7 +337,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -349,7 +349,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("URL Handling", func() { It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") @@ -358,7 +358,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") @@ -367,7 +367,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") @@ -380,7 +380,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("CallRaw", func() { It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -390,7 +390,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -402,7 +402,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -411,7 +411,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") @@ -420,7 +420,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") @@ -429,7 +429,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") @@ -440,7 +440,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("Router Availability", func() { It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 115c6c2b0..604fba3a7 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -52,7 +52,7 @@ var hostServices = []hostServiceEntry{ name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { - service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) + service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) return host.RegisterSubsonicAPIHostFunctions(service), nil }, }, @@ -119,6 +119,19 @@ var hostServices = []hostServiceEntry{ return host.RegisterUsersHostFunctions(service), nil }, }, + { + name: "Matcher", + hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem + service := newMatcherService( + ctx.manager.ds, hasFilesystemPerm, + newUserAccess(ctx.allowedUsers, ctx.allUsers), + newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries), + ) + return host.RegisterMatcherHostFunctions(service), nil + }, + }, { name: "HTTP", hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 1d4a8c301..f0c7c56d5 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -4,9 +4,11 @@ import ( "context" "crypto/rand" "errors" + "fmt" "io" extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/model" "github.com/tetratelabs/wazero" ) @@ -75,3 +77,58 @@ func (a libraryAccess) contains(libID int) bool { _, ok := a.libraryIDSet[libID] return ok } + +// configured reports whether the plugin has any library scope (all, or specific). +func (a libraryAccess) configured() bool { + return a.allLibraries || len(a.libraryIDSet) > 0 +} + +// userAccess captures the set of users a plugin is permitted to act as, +// precomputed at load time for O(1) lookup. +type userAccess struct { + allUsers bool + userIDSet map[string]struct{} +} + +func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess { + set := make(map[string]struct{}, len(allowedUserIDs)) + for _, id := range allowedUserIDs { + set[id] = struct{}{} + } + return userAccess{allUsers: allUsers, userIDSet: set} +} + +// allows reports whether the plugin may act as the given user ID. +func (a userAccess) allows(userID string) bool { + if a.allUsers { + return true + } + _, ok := a.userIDSet[userID] + return ok +} + +// resolve looks up a user by username and authorizes it against this access set, +// distinguishing an absent user from a backend failure. +// +// When the plugin has no user scope at all, it rejects before the lookup with a +// single fixed error, so a caller cannot tell a real account from a missing one by +// the error text (username enumeration). +func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) { + if !a.allUsers && len(a.userIDSet) == 0 { + return nil, fmt.Errorf("plugin is not authorized to scope by user") + } + usr, err := ds.User(ctx).FindByUsername(username) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("user %q not found", username) + } + return nil, fmt.Errorf("looking up user %q: %w", username, err) + } + if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil) + return nil, fmt.Errorf("user %q not found", username) + } + if !a.allows(usr.ID) { + return nil, fmt.Errorf("plugin is not allowed to act as user %q", username) + } + return usr, nil +} diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index c15a3bf3d..29e5d1fc7 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -113,6 +113,9 @@ }, "taskqueue": { "$ref": "#/$defs/TaskQueuePermission" + }, + "matcher": { + "$ref": "#/$defs/MatcherPermission" } } }, @@ -254,6 +257,17 @@ "description": "Explanation for why users access is needed" } } + }, + "MatcherPermission": { + "type": "object", + "description": "Matcher service permissions for resolving external songs to local library tracks", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why matcher access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 990272855..6bd0e8049 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -59,6 +59,14 @@ func (m *Manifest) Validate() error { } } + // Matcher returns library content, so it requires the library permission (which + // is what exposes a library scope for configuration). + if m.Permissions != nil && m.Permissions.Matcher != nil { + if m.Permissions.Library == nil { + return fmt.Errorf("'matcher' permission requires 'library' permission to be declared") + } + } + // Validate config schema if present if m.Config != nil && m.Config.Schema != nil { if err := validateConfigSchema(m.Config.Schema); err != nil { diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index efe93e05f..3599eafc4 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -158,6 +158,12 @@ func (j *Manifest) UnmarshalJSON(value []byte) error { return nil } +// Matcher service permissions for resolving external songs to local library tracks +type MatcherPermission struct { + // Explanation for why matcher access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Permissions required by the plugin type Permissions struct { // Artwork corresponds to the JSON schema field "artwork". @@ -175,6 +181,9 @@ type Permissions struct { // Library corresponds to the JSON schema field "library". Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"` + // Matcher corresponds to the JSON schema field "matcher". + Matcher *MatcherPermission `json:"matcher,omitempty" yaml:"matcher,omitempty" mapstructure:"matcher,omitempty"` + // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index cae043fc7..2a8b0dcfa 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -261,6 +261,37 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("subsonicapi")) }) + It("validates manifest with matcher and library permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + Library: &LibraryPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when matcher without library permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("matcher")) + Expect(err.Error()).To(ContainSubstring("library")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index fa3e0082e..e5d6d43fc 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -250,7 +250,7 @@ func songRefToAgentSong(s types.SongRef) agents.Song { Artists: artists, Album: s.Album, AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), + Duration: s.DurationInMs(), } } diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 5781a04c1..ff2c2a07f 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -41,6 +41,7 @@ The following host services are available: - HTTP: provides outbound HTTP request capabilities for plugins. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. + - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_matcher.go b/plugins/pdk/go/host/nd_host_matcher.go new file mode 100644 index 000000000..b32ac6f9d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher.go @@ -0,0 +1,77 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/types" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// matcher_matchsongs is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user matcher_matchsongs +func matcher_matchsongs(uint64) uint64 + +type matcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +type matcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// MatcherMatchSongs calls the matcher_matchsongs host function. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + // Marshal request to JSON + req := matcherMatchSongsRequest{ + Songs: songs, + Opts: opts, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := matcher_matchsongs(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response matcherMatchSongsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Results, nil +} diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go new file mode 100644 index 000000000..07b34bb82 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher_stub.go @@ -0,0 +1,44 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/types" + "github.com/stretchr/testify/mock" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// mockMatcherService is the mock implementation for testing. +type mockMatcherService struct { + mock.Mock +} + +// MatcherMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.MatcherMock.On("MethodName", args...).Return(values...) +var MatcherMock = &mockMatcherService{} + +// MatchSongs is the mock method for MatcherMatchSongs. +func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + args := m.Called(songs, opts) + return args.Get(0).([]*types.Track), args.Error(1) +} + +// MatcherMatchSongs delegates to the mock instance. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + return MatcherMock.MatchSongs(songs, opts) +} diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go index 856be90e1..4d4cdedd2 100644 --- a/plugins/pdk/go/types/types.go +++ b/plugins/pdk/go/types/types.go @@ -8,7 +8,8 @@ package types // ArtistRef is the minimal information a plugin returns for Navidrome to match an // artist against the library. It is a reference, not a full artist entity: it -// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never // descriptive data such as biographies or images. type ArtistRef struct { // ID is the internal Navidrome artist ID (if known). @@ -17,6 +18,12 @@ type ArtistRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the artist. MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` } // SongRef is the minimal information exchanged between a plugin and Navidrome to @@ -34,8 +41,12 @@ type SongRef struct { // ISRC is the International Standard Recording Code for the song. ISRC string `json:"isrc,omitempty"` // Artist is the artist name. + // + // Deprecated: use Artists. Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. ArtistMBID string `json:"artistMbid,omitempty"` // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. Artists []ArtistRef `json:"artists,omitempty"` @@ -44,5 +55,94 @@ type SongRef struct { // AlbumMBID is the MusicBrainz release ID. AlbumMBID string `json:"albumMbid,omitempty"` // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` } diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3a31bc489..cc1fdc190 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -38,6 +38,7 @@ //! - [`http`] - provides outbound HTTP request capabilities for plugins. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. +//! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -86,6 +87,13 @@ pub mod library { pub use super::nd_host_library::*; } +#[doc(hidden)] +mod nd_host_matcher; +/// resolves externally-obtained songs to local library tracks, +pub mod matcher { + pub use super::nd_host_matcher::*; +} + #[doc(hidden)] mod nd_host_scheduler; /// provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs new file mode 100644 index 000000000..be2819257 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs @@ -0,0 +1,65 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// MatchOptions carries optional parameters for a match request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchOptions { + #[serde(default)] + pub username: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsRequest { + songs: Vec<nd_pdk_types::SongRef>, + opts: MatchOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsResponse { + #[serde(default)] + results: Vec<Option<nd_pdk_types::Track>>, + #[serde(default)] + error: Option<String>, +} + +#[host_fn] +extern "ExtismHost" { + fn matcher_matchsongs(input: Json<MatcherMatchSongsRequest>) -> Json<MatcherMatchSongsResponse>; +} + +/// MatchSongs resolves each input song to its best-matching library track. +/// It returns one entry per input song, in the same order as the input; the +/// entry for an input song that had no match is empty (absent). Results are +/// limited to the libraries the plugin (and the scoped user, if any) can access. +/// +/// # Arguments +/// * `songs` - Vec<nd_pdk_types::SongRef> parameter. +/// * `opts` - MatchOptions parameter. +/// +/// # Returns +/// The results value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn match_songs(songs: Vec<nd_pdk_types::SongRef>, opts: MatchOptions) -> Result<Vec<Option<nd_pdk_types::Track>>, Error> { + let response = unsafe { + matcher_matchsongs(Json(MatcherMatchSongsRequest { + songs: songs, + opts: opts, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.results) +} diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs index 9b40c4d56..fa1a87048 100644 --- a/plugins/pdk/rust/nd-pdk-types/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-types/src/lib.rs @@ -3,6 +3,7 @@ //! Navidrome shared plugin data types. use serde::{Deserialize, Serialize}; +use std::collections::HashMap; // Helper functions for skip_serializing_if with numeric types #[allow(dead_code)] @@ -19,7 +20,8 @@ fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } /// ArtistRef is the minimal information a plugin returns for Navidrome to match an /// artist against the library. It is a reference, not a full artist entity: it -/// carries only matching keys (name and optional internal/MusicBrainz IDs), never +/// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +/// few projection fields used when describing a track's participants, never /// descriptive data such as biographies or images. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -33,6 +35,15 @@ pub struct ArtistRef { /// MBID is the MusicBrainz ID for the artist. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbid: String, + /// SortName is the artist name used for sorting (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_name: String, + /// Role is the participation category (e.g. "artist", "composer", "performer"). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub role: String, + /// SubRole is a specialization within Role (e.g. the instrument for a performer). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sub_role: String, } /// SongRef is the minimal information exchanged between a plugin and Navidrome to /// match a song. It is used both as input (a song Navidrome already has) and as @@ -55,9 +66,13 @@ pub struct SongRef { #[serde(default, skip_serializing_if = "String::is_empty")] pub isrc: String, /// Artist is the artist name. + /// + /// Deprecated: use Artists. #[serde(default, skip_serializing_if = "String::is_empty")] pub artist: String, /// ArtistMBID is the MusicBrainz artist ID. + /// + /// Deprecated: use Artists. #[serde(default, skip_serializing_if = "String::is_empty")] pub artist_mbid: String, /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. @@ -70,6 +85,155 @@ pub struct SongRef { #[serde(default, skip_serializing_if = "String::is_empty")] pub album_mbid: String, /// Duration is the song duration in seconds. + /// + /// Deprecated: use DurationMs, which carries millisecond precision. When + /// DurationMs is non-zero it takes precedence; Duration is kept only for + /// backwards compatibility with plugins that still send seconds. #[serde(default, skip_serializing_if = "is_zero_f32")] pub duration: f32, + /// DurationMs is the song duration in milliseconds. It supersedes Duration + /// when non-zero. + #[serde(default, skip_serializing_if = "is_zero_u32")] + pub duration_ms: u32, +} +/// Track is a stable, public projection of a library media file for plugin consumption. +/// It is a sane subset of the internal model.MediaFile, intended for reuse across host +/// services and capabilities. Timestamps are Unix epoch seconds. +/// +/// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +/// Track is a concrete library entity: it identifies a specific media file that exists +/// (or once existed) in the library and exposes its full descriptive metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Track { + /// Identity & location + #[serde(default)] + pub id: String, + #[serde(default)] + pub library_id: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub library_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, + #[serde(default)] + pub missing: bool, + /// Core metadata + #[serde(default)] + pub title: String, + #[serde(default)] + pub album: String, + #[serde(default)] + pub artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_title: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_album_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_artist_name: String, + /// Track / disc / dates + #[serde(default)] + pub track_number: i32, + #[serde(default)] + pub disc_number: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub disc_subtitle: String, + #[serde(default)] + pub year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub date: String, + #[serde(default)] + pub original_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub original_date: String, + #[serde(default)] + pub release_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub release_date: String, + /// Audio / file + #[serde(default)] + pub size: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub suffix: String, + #[serde(default)] + pub duration: f64, + #[serde(default)] + pub bit_rate: i32, + #[serde(default)] + pub sample_rate: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bit_depth: Option<i32>, + #[serde(default)] + pub channels: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub codec: String, + /// Descriptive + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub genres: Vec<String>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub comment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bpm: Option<i32>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub explicit_status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub catalog_num: String, + #[serde(default)] + pub compilation: bool, + #[serde(default)] + pub has_cover_art: bool, + /// MusicBrainz + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_recording_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_track_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_group_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_comment: String, + /// ReplayGain — nil means no data; 0 is a valid measured value, so these + /// must stay pointers to distinguish "absent" from "0". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_gain: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_peak: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_gain: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_peak: Option<f64>, + /// Timestamps (Unix epoch seconds) + #[serde(default)] + pub birth_time: i64, + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub updated_at: i64, + /// AverageRating is the track's mean rating across all users (always set; 0 when unrated). + #[serde(default)] + pub average_rating: f64, + /// Per-user annotations, set only for a user-scoped match. Timestamps are Unix + /// seconds; a nil pointer means "no value". + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub starred: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub starred_at: Option<i64>, + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub rating: i32, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub play_count: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub play_date: Option<i64>, + /// Composite + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tags: std::collections::HashMap<String, Vec<String>>, + /// Participants lists the track's artists across all roles, each tagged with its Role. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub participants: Vec<ArtistRef>, } diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index 2d48b28f0..a4f7d73f8 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -71,10 +71,10 @@ func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { ArtistMBID: mf.MbzArtistID, Album: mf.Album, AlbumMBID: mf.MbzAlbumID, - Duration: mf.Duration, } + ref.SetDuration(mf.Duration) for _, p := range mf.Participants[model.RoleArtist] { - ref.Artists = append(ref.Artists, capabilities.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID}) + ref.Artists = append(ref.Artists, types.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, SortName: p.SortArtistName, Role: model.RoleArtist.String()}) } if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { ref.ISRC = isrcs[0] diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index 52079732b..cb08b9acd 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -122,8 +122,8 @@ var _ = Describe("mediaFileToSongRef multi-artist", func() { }} ref := mediaFileToSongRef(mf) Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{ - {ID: "ar-drake", Name: "Drake", MBID: "m-drake"}, - {ID: "ar-future", Name: "Future", MBID: "m-future"}, + {ID: "ar-drake", Name: "Drake", MBID: "m-drake", Role: "artist"}, + {ID: "ar-future", Name: "Future", MBID: "m-future", Role: "artist"}, })) }) It("leaves Artists nil when the track has no role=artist participants", func() { diff --git a/plugins/testdata/test-matcher/go.mod b/plugins/testdata/test-matcher/go.mod new file mode 100644 index 000000000..7abbc93c6 --- /dev/null +++ b/plugins/testdata/test-matcher/go.mod @@ -0,0 +1,16 @@ +module test-matcher + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-matcher/go.sum b/plugins/testdata/test-matcher/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-matcher/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-matcher/main.go b/plugins/testdata/test-matcher/main.go new file mode 100644 index 000000000..80d650b7b --- /dev/null +++ b/plugins/testdata/test-matcher/main.go @@ -0,0 +1,56 @@ +// Test Matcher plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-matcher.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" +) + +// TestMatcherInput is the input for the nd_test_matcher callback. +type TestMatcherInput struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` +} + +// TestMatcherOutput is the output from the nd_test_matcher callback. +// MatchedIDs and Starred are aligned to the input: an empty string at index i +// means no match; Starred[i] reflects the matched track's starred flag. +type TestMatcherOutput struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` +} + +// nd_test_matcher forwards the input song list to the host matcher and returns matched track IDs. +// +//go:wasmexport nd_test_matcher +func ndTestMatcher() int32 { + var input TestMatcherInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + results, err := host.MatcherMatchSongs(input.Songs, host.MatchOptions{Username: input.Username}) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + ids := make([]string, len(results)) + starred := make([]bool, len(results)) + for i, t := range results { + if t != nil { + ids[i] = t.ID + starred[i] = t.Starred + } + } + pdk.OutputJSON(TestMatcherOutput{MatchedIDs: ids, Starred: starred}) + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-matcher/manifest.json b/plugins/testdata/test-matcher/manifest.json new file mode 100644 index 000000000..26be5c577 --- /dev/null +++ b/plugins/testdata/test-matcher/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Matcher Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for Matcher integration testing", + "permissions": { + "matcher": { + "reason": "For testing matcher operations" + }, + "library": { + "reason": "Matching returns library tracks" + } + } +} diff --git a/plugins/types/track.go b/plugins/types/track.go new file mode 100644 index 000000000..9b63c0f26 --- /dev/null +++ b/plugins/types/track.go @@ -0,0 +1,93 @@ +package types + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` +} diff --git a/plugins/types/types.go b/plugins/types/types.go index eaccf3469..71928a924 100644 --- a/plugins/types/types.go +++ b/plugins/types/types.go @@ -2,7 +2,8 @@ package types // ArtistRef is the minimal information a plugin returns for Navidrome to match an // artist against the library. It is a reference, not a full artist entity: it -// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never // descriptive data such as biographies or images. type ArtistRef struct { // ID is the internal Navidrome artist ID (if known). @@ -11,6 +12,12 @@ type ArtistRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the artist. MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` } // SongRef is the minimal information exchanged between a plugin and Navidrome to @@ -28,8 +35,12 @@ type SongRef struct { // ISRC is the International Standard Recording Code for the song. ISRC string `json:"isrc,omitempty"` // Artist is the artist name. + // + // Deprecated: use Artists. Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. ArtistMBID string `json:"artistMbid,omitempty"` // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. Artists []ArtistRef `json:"artists,omitempty"` @@ -38,5 +49,39 @@ type SongRef struct { // AlbumMBID is the MusicBrainz release ID. AlbumMBID string `json:"albumMbid,omitempty"` // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// DurationInMs returns the song duration in milliseconds, preferring the +// millisecond-precision DurationMs and falling back to the deprecated +// seconds-based Duration. It returns 0 when neither is set, and clamps a +// negative seconds value to 0 to avoid an unsigned-conversion wraparound. +func (s SongRef) DurationInMs() uint32 { + if s.DurationMs != 0 { + return s.DurationMs + } + if s.Duration < 0 { + return 0 + } + return uint32(s.Duration * 1000) +} + +// SetDuration sets the song duration from a value in seconds, populating both the +// millisecond-precision DurationMs and the deprecated seconds-based Duration so +// that plugins reading either field see a consistent value. Use this when +// building a SongRef to send to a plugin. +func (s *SongRef) SetDuration(seconds float32) { + s.Duration = seconds + if seconds < 0 { + s.DurationMs = 0 + return + } + s.DurationMs = uint32(seconds * 1000) } diff --git a/plugins/types/types_suite_test.go b/plugins/types/types_suite_test.go new file mode 100644 index 000000000..9c058ec4f --- /dev/null +++ b/plugins/types/types_suite_test.go @@ -0,0 +1,17 @@ +package types_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTypes(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Types Suite") +} diff --git a/plugins/types/types_test.go b/plugins/types/types_test.go new file mode 100644 index 000000000..ae6d40a25 --- /dev/null +++ b/plugins/types/types_test.go @@ -0,0 +1,50 @@ +package types_test + +import ( + "github.com/navidrome/navidrome/plugins/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SongRef", func() { + Describe("DurationInMs", func() { + It("returns DurationMs when set", func() { + s := types.SongRef{DurationMs: 247333, Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247333))) + }) + + It("falls back to Duration (seconds) when DurationMs is zero", func() { + s := types.SongRef{Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247500))) + }) + + It("returns 0 when neither is set", func() { + Expect(types.SongRef{}.DurationInMs()).To(BeZero()) + }) + + It("clamps a negative seconds value to 0 instead of overflowing", func() { + Expect(types.SongRef{Duration: -1}.DurationInMs()).To(BeZero()) + }) + }) + + Describe("SetDuration", func() { + It("populates both DurationMs and the deprecated Duration from seconds", func() { + var s types.SongRef + s.SetDuration(247.333) + Expect(s.Duration).To(BeNumerically("~", 247.333, 0.001)) + Expect(s.DurationMs).To(Equal(uint32(247333))) + }) + + It("keeps DurationInMs consistent with what was set", func() { + var s types.SongRef + s.SetDuration(60) + Expect(s.DurationInMs()).To(Equal(uint32(60000))) + }) + + It("clamps a negative duration to a zero DurationMs", func() { + var s types.SongRef + s.SetDuration(-1) + Expect(s.DurationMs).To(BeZero()) + }) + }) +}) From 318bb4944fce293043bb8ab16df87e9cfb0ef747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 1 Jul 2026 13:12:29 -0400 Subject: [PATCH 326/596] perf(db): skip library filter when a non-admin sees all libraries (#5696) * perf(db): skip library filter when a non-admin sees all libraries applyLibraryFilter already short-circuits for admins and headless contexts, but a non-admin who has been granted every library still paid for the correlated user_library subquery, which filters out nothing yet is the slow non-admin list/count path. Reuse the visibility check search3 already had: skip the subquery when the user's granted libraries cover the whole library table. The two helpers (userSeesAllLibraries/visibleLibraryIDs) are promoted from artist_repository to the base sqlRepository so all ~13 call sites benefit and search3 shares the single implementation. The skip is strictly gated on granted count >= total library count, never on an empty/unknown library set, so access control is unchanged for restricted users. * refactor(db): make all-libraries skip fail closed; test cleanups - userSeesAllLibraries: require len(visible) == total (not >=) so the filter skip can never over-grant if the visible set is ever inflated. - tests: assert ToSql() returns no error; restore r.db in AfterEach to avoid leaking mutated state to other specs. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/artist_repository.go | 26 --------- persistence/sql_base_repository.go | 32 +++++++++++ persistence/sql_base_repository_test.go | 70 ++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 33 deletions(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 2c6b77054..5152e774f 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -668,32 +668,6 @@ func isLibraryIDFilter(filter Sqlizer) bool { return ok } -// userSeesAllLibraries reports whether the visible set already covers every library, so a search -// needs no library filter at all. -func (r *artistRepository) userSeesAllLibraries(visible []int) bool { - user := loggedUser(r.ctx) - if user.IsAdmin || user.ID == invalidUserId { - return true // visible is the whole library table - } - total, err := NewLibraryRepository(r.ctx, r.db).CountAll() - if err != nil || total == 0 { - return false - } - return int64(len(visible)) >= total -} - -// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and -// headless processes, otherwise the user's granted libraries. -func (r *artistRepository) visibleLibraryIDs() ([]int, error) { - user := loggedUser(r.ctx) - if user.IsAdmin || user.ID == invalidUserId { - var ids []int - err := r.queryAllSlice(Select("id").From("library"), &ids) - return ids, err - } - return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil -} - func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 321e790db..ce5221d19 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -229,6 +229,12 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) return sq } + // A non-admin granted every library sees everything the subquery would return, so applying it is + // pure overhead. Skip it in that case (same fast path admins get). + if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + return sq + } + table := r.tableName if len(tableName) > 0 { table = tableName[0] @@ -240,6 +246,32 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID)) } +// userSeesAllLibraries reports whether the visible set already covers every library, so a +// library filter would exclude nothing. +func (r sqlRepository) userSeesAllLibraries(visible []int) bool { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + return true // visible is the whole library table + } + total, err := NewLibraryRepository(r.ctx, r.db).CountAll() + if err != nil || total == 0 { + return false + } + return int64(len(visible)) == total +} + +// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and +// headless processes, otherwise the user's granted libraries. +func (r sqlRepository) visibleLibraryIDs() ([]int, error) { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + var ids []int + err := r.queryAllSlice(Select("id").From("library"), &ids) + return ids, err + } + return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil +} + func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index b46e2066b..2cd6ab8a3 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -226,9 +226,21 @@ var _ = Describe("sqlRepository", func() { Describe("applyLibraryFilter", func() { var sq squirrel.SelectBuilder + var savedDB = r.db BeforeEach(func() { sq = squirrel.Select("*").From("test_table") + // Add library 2 so a user granted only library 1 is a genuine strict subset. + savedDB = r.db + r.db = GetDBXBuilder() + _, err := r.db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Lib 2', '/lib2')").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + _, err := r.db.NewQuery("DELETE FROM library WHERE id = 2").Execute() + Expect(err).ToNot(HaveOccurred()) + r.db = savedDB }) Context("Admin User", func() { @@ -238,31 +250,73 @@ var _ = Describe("sqlRepository", func() { It("should not apply library filter for admin users", func() { result := r.applyLibraryFilter(sq) - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) - Context("Regular User", func() { + Context("Regular User with a subset of libraries", func() { BeforeEach(func() { - r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false}) + // Strict subset: granted lib 1, DB has libs 1 and 2, so the filter must apply. + r.ctx = request.WithUser(context.Background(), model.User{ + ID: "user123", IsAdmin: false, Libraries: model.Libraries{{ID: 1}}, + }) }) It("should apply library filter for regular users", func() { result := r.applyLibraryFilter(sq) - sql, args, _ := result.ToSql() + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) Expect(args).To(ContainElement("user123")) }) It("should use custom table name when provided", func() { result := r.applyLibraryFilter(sq, "custom_table") - sql, args, _ := result.ToSql() + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("custom_table.library_id IN")) Expect(args).To(ContainElement("user123")) }) }) + Context("Regular User with no libraries", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "empty", IsAdmin: false}) + }) + + It("should apply the library filter (never skip on empty)", func() { + result := r.applyLibraryFilter(sq) + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) + }) + }) + + Context("Regular User who can see all libraries", func() { + BeforeEach(func() { + // Granted every library in the DB, so the filter would exclude nothing. + r.ctx = request.WithUser(context.Background(), model.User{ + ID: "alllibs", IsAdmin: false, Libraries: model.Libraries{{ID: 1}, {ID: 2}}, + }) + }) + + It("should not apply the library filter (subquery would filter nothing)", func() { + result := r.applyLibraryFilter(sq) + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + + It("should not apply the filter even with a custom table name", func() { + result := r.applyLibraryFilter(sq, "custom_table") + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + Context("Headless Process (No User Context)", func() { BeforeEach(func() { r.ctx = context.Background() // No user context @@ -270,13 +324,15 @@ var _ = Describe("sqlRepository", func() { It("should not apply library filter for headless processes", func() { result := r.applyLibraryFilter(sq) - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) It("should not apply library filter even with custom table name", func() { result := r.applyLibraryFilter(sq, "custom_table") - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) From 2d53360f89ac7c8f99fbcec5fb81610dce27f30a Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 1 Jul 2026 13:20:31 -0400 Subject: [PATCH 327/596] docs(plugins): document plugin library scope authorization logic Signed-off-by: Deluan <deluan@navidrome.org> --- plugins/host_matcher.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/host_matcher.go b/plugins/host_matcher.go index 9b65e7933..125d16649 100644 --- a/plugins/host_matcher.go +++ b/plugins/host_matcher.go @@ -64,6 +64,10 @@ func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongR if err != nil { return nil, err } + // The plugin's library scope is a second, independent authorization on top of the context + // user's own access, so it's applied here rather than in-query: the unscoped path runs as + // admin (which applyLibraryFilter skips), and folding s.libs into the context user would + // conflate the two scopes instead of intersecting them. for i, mf := range matched { // Drop tracks outside the plugin's library scope, leaving that index unmatched. if !s.libs.contains(mf.LibraryID) { From 08cfc55d528cc3a898b075947127f115469a13e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 1 Jul 2026 13:58:33 -0400 Subject: [PATCH 328/596] test(db): fix flaky applyLibraryFilter specs on shared DB (#5697) The 'sees all libraries' specs hard-coded the user's libraries as {1, 2} and assumed the shared test DB held exactly two libraries. applyLibraryFilter only skips the filter when granted count == total library count, so when another spec left an extra library behind (Ginkgo randomizes spec order), the count was 3, the filter was applied, and the SQL assertion failed. This surfaced on the Windows CI run but reproduces on any platform. Grant the user exactly the library IDs that actually exist in the DB at runtime instead of hard-coding them. --- persistence/sql_base_repository_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 2cd6ab8a3..9c6c6007f 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -296,9 +296,18 @@ var _ = Describe("sqlRepository", func() { Context("Regular User who can see all libraries", func() { BeforeEach(func() { - // Granted every library in the DB, so the filter would exclude nothing. + // Grant every library that currently exists in the (shared) DB, so the filter + // would exclude nothing. Querying the real IDs keeps this correct even if other + // specs left extra libraries behind, which happens under Ginkgo's randomized order. + var ids []int + err := r.db.NewQuery("SELECT id FROM library ORDER BY id").Column(&ids) + Expect(err).ToNot(HaveOccurred()) + libs := make(model.Libraries, 0, len(ids)) + for _, id := range ids { + libs = append(libs, model.Library{ID: id}) + } r.ctx = request.WithUser(context.Background(), model.User{ - ID: "alllibs", IsAdmin: false, Libraries: model.Libraries{{ID: 1}, {ID: 2}}, + ID: "alllibs", IsAdmin: false, Libraries: libs, }) }) From b405252f51c6ddf90863f993cf2b6aea8d45765f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:24:47 -0400 Subject: [PATCH 329/596] chore(deps): bump golang.org/x/net in /plugins/cmd/ndpgen (#5698) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.55.0. - [Commits](https://github.com/golang/net/compare/v0.49.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- plugins/cmd/ndpgen/go.mod | 14 +++++++------- plugins/cmd/ndpgen/go.sum | 24 ++++++++++++------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod index af9fce441..6b081a826 100644 --- a/plugins/cmd/ndpgen/go.mod +++ b/plugins/cmd/ndpgen/go.mod @@ -1,13 +1,13 @@ module github.com/navidrome/navidrome/plugins/cmd/ndpgen -go 1.25 +go 1.25.0 require ( github.com/extism/go-pdk v1.1.3 github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - golang.org/x/tools v0.41.0 + golang.org/x/tools v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,9 +18,9 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum index 952672d0e..828273cab 100644 --- a/plugins/cmd/ndpgen/go.sum +++ b/plugins/cmd/ndpgen/go.sum @@ -54,18 +54,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From e80a7937e86e715e4022ef8dac18301a25f9bf4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 1 Jul 2026 21:31:29 -0400 Subject: [PATCH 330/596] fix(ui): make self-service profile edits report their outcome (#5699) * fix(ui): make self-service profile edits report their outcome When a non-admin user saved their own profile (e.g. changing their password via EnableUserEditing), the data provider followed the user update with a call to the admin-only PUT /api/user/{id}/library endpoint, which always failed with 403. The save error handler then crashed reading error.body.errors on the plain-text response, so the user got no notification at all - while the profile change had in fact already been applied. This made password changes look like they were silently ignored, and follow-up attempts failed with 'password does not match' since the current password had already changed. Present since the multi-library support introduced in v0.58.0 (#4181). Only call the user-library association endpoint when the logged-in user is an admin (the server manages assignments for self-edits), and make the save error handler tolerate error bodies without field errors, notifying a generic error instead of crashing. * fix(ui): tolerate nullish rejection values in user save handler Address review feedback: use optional chaining on the error itself in the UserEdit save handler, so a nullish rejection value also results in the generic error notification instead of a TypeError. --- ui/src/dataProvider/wrapperDataProvider.js | 5 +- .../dataProvider/wrapperDataProvider.test.js | 90 +++++++++++++++++++ ui/src/user/UserEdit.jsx | 3 +- ui/src/user/UserEdit.test.jsx | 79 ++++++++++++++-- 4 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 ui/src/dataProvider/wrapperDataProvider.test.js diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 268d3668d..f5004308b 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -137,8 +137,9 @@ const updateUser = async (params) => { data: userData, }) - // Then handle library associations for non-admin users - if (!userData.isAdmin && libraryIds !== undefined) { + // Then handle library associations for non-admin users. Only admins can call + // this endpoint; for self-edits the server manages library assignments + if (isAdmin() && !userData.isAdmin && libraryIds !== undefined) { await handleUserLibraryAssociation(userId, libraryIds) } diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js new file mode 100644 index 000000000..fbc82f969 --- /dev/null +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import wrapperDataProvider from './wrapperDataProvider' + +const { mockProvider, mockHttpClient } = vi.hoisted(() => ({ + mockProvider: { + update: vi.fn(), + create: vi.fn(), + getOne: vi.fn(), + }, + mockHttpClient: vi.fn(), +})) + +vi.mock('ra-data-json-server', () => ({ default: () => mockProvider })) +vi.mock('./httpClient', () => ({ default: mockHttpClient })) + +describe('wrapperDataProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockProvider.update.mockResolvedValue({ data: { id: 'u1' } }) + mockProvider.create.mockResolvedValue({ data: { id: 'u1' } }) + mockHttpClient.mockResolvedValue({ json: [] }) + }) + + describe('update user', () => { + it('sets library associations when an admin edits a non-admin user', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ id: 'u1' }), + ) + expect(mockHttpClient).toHaveBeenCalledWith('/api/user/u1/library', { + method: 'PUT', + body: JSON.stringify({ libraryIds: [1] }), + }) + }) + + it('does not call the admin-only library endpoint when a non-admin edits their own profile', async () => { + localStorage.setItem('role', 'regular') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { + name: 'Sam', + isAdmin: false, + libraryIds: [1], + currentPassword: 'old', + password: 'new', + }, + }) + + expect(mockProvider.update).toHaveBeenCalled() + expect(mockHttpClient).not.toHaveBeenCalled() + }) + + it('does not set library associations when the edited user is an admin', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: true, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalled() + expect(mockHttpClient).not.toHaveBeenCalled() + }) + + it('strips libraryIds from the user update payload', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ + data: { name: 'Sam', isAdmin: false }, + }), + ) + }) + }) +}) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index 2283dd8bc..d8302a9f9 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -96,9 +96,10 @@ const UserEdit = (props) => { }) permissions === 'admin' ? redirect('/user') : refresh() } catch (error) { - if (error.body.errors) { + if (error?.body?.errors) { return error.body.errors } + notify('ra.page.error', 'warning') } }, [mutate, notify, permissions, redirect, refresh], diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 75a9a1ada..1d8290569 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -27,6 +27,14 @@ const adminUser = { isAdmin: true, } +const hooks = vi.hoisted(() => ({ + save: null, + mutate: vi.fn(), + notify: vi.fn(), + redirect: vi.fn(), + refresh: vi.fn(), +})) + // Mock React-Admin completely with simpler implementations vi.mock('react-admin', () => ({ Edit: ({ children, title }) => ( @@ -35,9 +43,10 @@ vi.mock('react-admin', () => ({ {children} </div> ), - SimpleForm: ({ children }) => ( - <form data-testid="simple-form">{children}</form> - ), + SimpleForm: ({ children, save }) => { + hooks.save = save + return <form data-testid="simple-form">{children}</form> + }, TextInput: ({ source }) => <input data-testid={`text-input-${source}`} />, BooleanInput: ({ source }) => ( <input type="checkbox" data-testid={`boolean-input-${source}`} /> @@ -54,10 +63,10 @@ vi.mock('react-admin', () => ({ Typography: ({ children }) => <p>{children}</p>, required: () => () => null, email: () => () => null, - useMutation: () => [vi.fn()], - useNotify: () => vi.fn(), - useRedirect: () => vi.fn(), - useRefresh: () => vi.fn(), + useMutation: () => [hooks.mutate], + useNotify: () => hooks.notify, + useRedirect: () => hooks.redirect, + useRefresh: () => hooks.refresh, usePermissions: () => ({ permissions: 'admin' }), useTranslate: () => (key) => key, })) @@ -127,4 +136,60 @@ describe('<UserEdit />', () => { expect(screen.getByTestId('text-input-name')).toBeInTheDocument() expect(screen.getByTestId('text-input-email')).toBeInTheDocument() }) + + describe('save', () => { + beforeEach(() => { + vi.clearAllMocks() + hooks.save = null + }) + + it('notifies success and redirects when the update succeeds', async () => { + hooks.mutate.mockResolvedValue({ data: defaultUser }) + render(<UserEdit id="user1" permissions="admin" />) + + await hooks.save({ id: 'user1', name: 'New Name' }) + + expect(hooks.notify).toHaveBeenCalledWith( + 'resources.user.notifications.updated', + 'info', + { smart_count: 1 }, + ) + expect(hooks.redirect).toHaveBeenCalledWith('/user') + }) + + it('returns field errors when the update fails validation', async () => { + const fieldErrors = { currentPassword: 'ra.validation.required' } + hooks.mutate.mockRejectedValue({ body: { errors: fieldErrors } }) + render(<UserEdit id="user1" permissions="admin" />) + + const result = await hooks.save({ id: 'user1' }) + + expect(result).toEqual(fieldErrors) + expect(hooks.notify).not.toHaveBeenCalledWith( + 'resources.user.notifications.updated', + 'info', + { smart_count: 1 }, + ) + }) + + it('notifies an error when the update fails without field errors', async () => { + hooks.mutate.mockRejectedValue(new Error('Forbidden')) + render(<UserEdit id="user1" permissions="admin" />) + + await hooks.save({ id: 'user1' }) + + expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(hooks.redirect).not.toHaveBeenCalled() + }) + + it('notifies an error when the update rejects with a non-object error', async () => { + hooks.mutate.mockRejectedValue(undefined) + render(<UserEdit id="user1" permissions="admin" />) + + await hooks.save({ id: 'user1' }) + + expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(hooks.redirect).not.toHaveBeenCalled() + }) + }) }) From a77834ce73e90348003ac58a3dbd5359712ba453 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:27:39 +0000 Subject: [PATCH 331/596] fix(logger): handle file log exception for log.Log (#5700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(logger): handle file log exception for log.Log * simplify logger, enhance test --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- log/log.go | 17 +++++++++++------ log/log_test.go | 30 ++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/log/log.go b/log/log.go index 2764d80e5..b1d6eee10 100644 --- a/log/log.go +++ b/log/log.go @@ -193,34 +193,39 @@ func IsGreaterOrEqualTo(level Level) bool { } func Fatal(args ...any) { - Log(LevelFatal, args...) + log(LevelFatal, args...) os.Exit(1) } func Error(args ...any) { - Log(LevelError, args...) + log(LevelError, args...) } func Warn(args ...any) { - Log(LevelWarn, args...) + log(LevelWarn, args...) } func Info(args ...any) { - Log(LevelInfo, args...) + log(LevelInfo, args...) } func Debug(args ...any) { - Log(LevelDebug, args...) + log(LevelDebug, args...) } func Trace(args ...any) { - Log(LevelTrace, args...) + log(LevelTrace, args...) } func Log(level Level, args ...any) { + log(level, args...) +} + +func log(level Level, args ...any) { if !shouldLog(level, 3) { return } + logger, msg := parseArgs(args) logger.Log(logrus.Level(level), msg) } diff --git a/log/log_test.go b/log/log_test.go index a1f3b6ba0..7e1f3f3cc 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -135,18 +135,32 @@ var _ = Describe("Logger", func() { }) Describe("LogLevels", func() { - It("logs at specific levels", func() { - SetLevel(LevelError) - Debug("message 1") + BeforeEach(func() { + SetLevel(LevelFatal) + SetLogLevels(nil) + }) + + DescribeTable("logs at specific levels", func(logger func(...any), level Level) { + logger("message 1") Expect(hook.LastEntry()).To(BeNil()) - SetLogLevels(map[string]string{ - "log/log_test": "debug", - }) + Log(level, "message 1.5") + Expect(hook.LastEntry()).To(BeNil()) - Debug("message 2") + SetLogLevels(map[string]string{"log/log_test": "trace"}) + + logger("message 2") Expect(hook.LastEntry().Message).To(Equal("message 2")) - }) + + Log(level, "message 2.5") + Expect(hook.LastEntry().Message).To(Equal("message 2.5")) + }, + Entry("Error", Error, LevelError), + Entry("Warn", Warn, LevelWarn), + Entry("Info", Info, LevelInfo), + Entry("Debug", Debug, LevelDebug), + Entry("Trace", Trace, LevelTrace), + ) }) Describe("IsGreaterOrEqualTo", func() { From 4cbba2ae49401d7ccda606a2dbfcbee41d787857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 2 Jul 2026 08:29:44 -0400 Subject: [PATCH 332/596] feat(scanner): add ArtistSplitExceptions to protect artist names from splitting (#5701) * refactor(scanner): make tag value splitting position-based Replaces the ZWSP substitution trick with index-based cutting, in preparation for artist split exceptions, which need match positions. * feat(scanner): protect whitelisted names in tag value splitting Separator matches inside word-bounded exception matches no longer split. Matching is case-insensitive and longest-first; boundaries are rune-aware. * feat(scanner): add Scanner.ArtistSplitExceptions config option * feat(scanner): honor artist split exceptions for participant tags Applies Scanner.ArtistSplitExceptions to artist, albumartist and role tag splitting. Generic tags (genre, mood, ...) are unaffected. * fix(scanner): apply split exceptions when per-tag Split overrides participant tags Per-tag Tags.<name>.Split makes the generic ingestion path split the tag before participant mapping runs, bypassing the whitelist. Attach the exceptions to participant tag mappings (including sort variants) in clean(). * feat(scanner): split performer names and honor split exceptions Performer pair values were never split; multiple names in one PERFORMER value stayed a single artist. Split them with the roles separators, using the same whitelist protection as other participant tags. * test(scanner): lock MBID ordering for split performer values * refactor(scanner): consolidate split-exception wiring and drop hot-path lock ArtistSplitExceptionsRx is called per tag mapping per scanned file across concurrent goroutines; replace the mutex+joined-key cache with an atomic pointer compared via slices.Equal. Route all participant call sites through WithParticipantExceptions and a shared splitParticipantValues helper. * refactor(scanner): unexport artistSplitExceptionsRx All external callers go through WithParticipantExceptions, so the accessor does not need to be part of the model package API. --- conf/configuration.go | 24 ++-- model/metadata/map_participants.go | 42 ++++--- model/metadata/map_participants_test.go | 89 ++++++++++++++ model/metadata/metadata.go | 1 + model/tag_mappings.go | 154 +++++++++++++++++++++--- model/tag_mappings_test.go | 130 ++++++++++++++++++++ 6 files changed, 396 insertions(+), 44 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 665a7992f..17abfe400 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -153,17 +153,18 @@ type configOptions struct { } type scannerOptions struct { - Enabled bool - Schedule string - WatcherWait time.Duration - ScanOnStartup bool - Extractor string - ArtistJoiner string - GenreSeparators string // Deprecated: Use Tags.genre.Split instead - GroupAlbumReleases bool // Deprecated: Use PID.Album instead - FollowSymlinks bool // Whether to follow symlinks when scanning directories - IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning - PurgeMissing string // Values: "never", "always", "full" + Enabled bool + Schedule string + WatcherWait time.Duration + ScanOnStartup bool + Extractor string + ArtistJoiner string + ArtistSplitExceptions []string // Artist names never split by tag separators + GenreSeparators string // Deprecated: Use Tags.genre.Split instead + GroupAlbumReleases bool // Deprecated: Use PID.Album instead + FollowSymlinks bool // Whether to follow symlinks when scanning directories + IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning + PurgeMissing string // Values: "never", "always", "full" } type transcodingOptions struct { @@ -819,6 +820,7 @@ func setViperDefaults() { viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) viper.SetDefault("scanner.scanonstartup", true) viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) + viper.SetDefault("scanner.artistsplitexceptions", []string{}) viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) diff --git a/model/metadata/map_participants.go b/model/metadata/map_participants.go index e8be6aaab..35f112a92 100644 --- a/model/metadata/map_participants.go +++ b/model/metadata/map_participants.go @@ -94,18 +94,20 @@ func (md Metadata) processPerformers(participants model.Participants, rolesMbzId roleIdx[role] = 0 } + conf := model.TagRolesConf().WithParticipantExceptions(model.TagPerformer) titleCaser := cases.Title(language.Und) for _, performer := range md.Pairs(model.TagPerformer) { - name := performer.Value() subRole := titleCaser.String(performer.Key()) - - artist := model.Artist{ - ID: md.artistID(name), - Name: name, - OrderArtistName: str.SanitizeFieldForSortingNoArticle(name), - MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx), + names := splitParticipantValues(conf, []string{performer.Value()}) + for _, name := range names { + artist := model.Artist{ + ID: md.artistID(name), + Name: name, + OrderArtistName: str.SanitizeFieldForSortingNoArticle(name), + MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx), + } + participants.AddWithSubRole(model.RolePerformer, subRole, artist) } - participants.AddWithSubRole(model.RolePerformer, subRole, artist) } } @@ -171,6 +173,16 @@ func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist { return artists } +// splitParticipantValues splits values by the conf separators, dropping +// duplicated or empty entries. Values are returned unchanged when the conf +// has no separators. +func splitParticipantValues(conf model.TagConf, values []string) []string { + if len(conf.Split) == 0 { + return values + } + return filterDuplicatedOrEmptyValues(conf.SplitTagValue(values)) +} + // getRoleValues returns the values of a role tag, splitting them if necessary func (md Metadata) getRoleValues(role model.TagName) []string { values := md.Strings(role) @@ -181,11 +193,8 @@ func (md Metadata) getRoleValues(role model.TagName) []string { if conf.Split == nil { conf = model.TagRolesConf() } - if len(conf.Split) > 0 { - values = conf.SplitTagValue(values) - return filterDuplicatedOrEmptyValues(values) - } - return values + conf = conf.WithParticipantExceptions(role) + return splitParticipantValues(conf, values) } // getArtistValues returns the values of a single or multi artist tag, splitting them if necessary @@ -202,11 +211,8 @@ func (md Metadata) getArtistValues(single, multi model.TagName) []string { if conf.Split == nil { conf = model.TagArtistsConf() } - if len(conf.Split) > 0 { - vSingle = conf.SplitTagValue(vSingle) - return filterDuplicatedOrEmptyValues(vSingle) - } - return vSingle + conf = conf.WithParticipantExceptions(single) + return splitParticipantValues(conf, vSingle) } func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string { diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index 5ee802ced..ec66e12b9 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -4,6 +4,8 @@ import ( "os" "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" @@ -563,6 +565,37 @@ var _ = Describe("Participants", func() { matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"), )) }) + + It("should split multiple names in a single value", func() { + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"Eric Clapton/B.B. King"}, + "PERFORMER:BASS": {"Nathan East"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3))) + + p := participants[model.RolePerformer] + Expect(p).To(ContainElements( + matchPerformer("Eric Clapton", "eric clapton", "Guitar"), + matchPerformer("B.B. King", "b.b. king", "Guitar"), + matchPerformer("Nathan East", "nathan east", "Bass"), + )) + }) + + It("should assign MBIDs in order to names split from a single value", func() { + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"Eric Clapton/B.B. King"}, + "MUSICBRAINZ_PERFORMERID:GUITAR": {mbid1, mbid2}, + }) + + p := mf.Participants[model.RolePerformer] + Expect(p).To(HaveLen(2)) + Expect(p[0].Name).To(Equal("Eric Clapton")) + Expect(p[0].MbzArtistID).To(Equal(mbid1)) + Expect(p[1].Name).To(Equal("B.B. King")) + Expect(p[1].MbzArtistID).To(Equal(mbid2)) + }) }) When("MUSICBRAINZ_PERFORMERID tag is set", func() { @@ -802,4 +835,60 @@ var _ = Describe("Participants", func() { } }) }) + + Describe("Artist split exceptions", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does not split a whitelisted artist name on the default separators", func() { + // " feat. " is a default artists separator (mappings.yaml) + conf.Server.Scanner.ArtistSplitExceptions = []string{"Someone feat. Else"} + mf = toMediaFile(model.RawTags{ + "ARTIST": {"Artist Name feat. Someone feat. Else"}, + }) + + artists := mf.Participants[model.RoleArtist] + Expect(artists).To(HaveLen(2)) + Expect(artists[0].Name).To(Equal("Artist Name")) + Expect(artists[1].Name).To(Equal("Someone feat. Else")) + }) + + It("does not split a whitelisted name in role tags", func() { + // "/" is a default roles separator (mappings.yaml) + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + mf = toMediaFile(model.RawTags{ + "COMPOSER": {"AC/DC/John Doe"}, + }) + + composers := mf.Participants[model.RoleComposer] + Expect(composers).To(HaveLen(2)) + Expect(composers[0].Name).To(Equal("AC/DC")) + Expect(composers[1].Name).To(Equal("John Doe")) + }) + + It("splits normally when the exception does not match", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + mf = toMediaFile(model.RawTags{ + "ARTIST": {"Artist Name feat. Someone Else"}, + }) + + artists := mf.Participants[model.RoleArtist] + Expect(artists).To(HaveLen(2)) + Expect(artists[0].Name).To(Equal("Artist Name")) + Expect(artists[1].Name).To(Equal("Someone Else")) + }) + + It("does not split a whitelisted name in performer tags", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"AC/DC/Brian Johnson"}, + }) + + performers := mf.Participants[model.RolePerformer] + Expect(performers).To(HaveLen(2)) + Expect(performers[0].Name).To(Equal("AC/DC")) + Expect(performers[1].Name).To(Equal("Brian Johnson")) + }) + }) }) diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 48928f989..729e83564 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -205,6 +205,7 @@ func clean(filePath string, tags model.RawTags) model.Tags { cleaned := make(model.Tags, len(mappings)) for name, mapping := range mappings { + mapping = mapping.WithParticipantExceptions(name) var values []string switch mapping.Type { case model.TagTypePair: diff --git a/model/tag_mappings.go b/model/tag_mappings.go index dd19a157b..ce7d2f37b 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -7,9 +7,11 @@ import ( "slices" "strings" "sync" + "sync/atomic" + "unicode" + "unicode/utf8" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/resources" @@ -26,12 +28,13 @@ type mappingsConf struct { type tagMappings map[TagName]TagConf type TagConf struct { - Aliases []string `yaml:"aliases"` - Type TagType `yaml:"type"` - MaxLength int `yaml:"maxLength"` - Split []string `yaml:"split"` - Album bool `yaml:"album"` - SplitRx *regexp.Regexp `yaml:"-"` + Aliases []string `yaml:"aliases"` + Type TagType `yaml:"type"` + MaxLength int `yaml:"maxLength"` + Split []string `yaml:"split"` + Album bool `yaml:"album"` + SplitRx *regexp.Regexp `yaml:"-"` + ExceptionsRx *regexp.Regexp `yaml:"-"` } // SplitTagValue splits tag values by the configured split separators. @@ -43,18 +46,139 @@ func (c TagConf) SplitTagValue(values []string) []string { var result []string for _, tag := range values { - // Replace all occurrences of any separator with the zero-width space. - tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) - - // Split by the zero-width space and trim each substring. - parts := strings.SplitSeq(tag, consts.Zwsp) - for part := range parts { - result = append(result, strings.TrimSpace(part)) - } + result = append(result, c.splitValue(tag)...) } return result } +func (c TagConf) splitValue(tag string) []string { + protected := protectedSpans(tag, c.ExceptionsRx) + var parts []string + start := 0 + for _, sep := range c.SplitRx.FindAllStringIndex(tag, -1) { + if overlapsAny(sep, protected) { + continue + } + parts = append(parts, strings.TrimSpace(tag[start:sep[0]])) + start = sep[1] + } + return append(parts, strings.TrimSpace(tag[start:])) +} + +// protectedSpans returns the spans of rx matches that sit on word boundaries. +// Boundaries are checked here, rune-aware, because RE2's \b is ASCII-only and +// would silently never match names starting/ending with accented letters. +func protectedSpans(tag string, rx *regexp.Regexp) [][]int { + if rx == nil { + return nil + } + var spans [][]int + for _, span := range rx.FindAllStringIndex(tag, -1) { + if isWordBounded(tag, span[0], span[1]) { + spans = append(spans, span) + } + } + return spans +} + +func isWordBounded(s string, start, end int) bool { + isWord := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } + before, _ := utf8.DecodeLastRuneInString(s[:start]) + after, _ := utf8.DecodeRuneInString(s[end:]) + return !isWord(before) && !isWord(after) +} + +func overlapsAny(span []int, spans [][]int) bool { + for _, s := range spans { + if span[0] < s[1] && s[0] < span[1] { + return true + } + } + return false +} + +// compileExceptionsRegex builds a case-insensitive regex matching any of the +// given literal names, or nil if there are none. +func compileExceptionsRegex(exceptions []string) *regexp.Regexp { + var names []string + for _, e := range exceptions { + if e = strings.TrimSpace(e); e != "" { + names = append(names, e) + } + } + if len(names) == 0 { + return nil + } + // Longest-first: Go regex alternation is leftmost-first, so with overlapping + // entries (e.g. "Iron and Wine Duo" vs "Iron and Wine") the longer name must + // come first to win. Ties broken lexicographically for determinism. + slices.SortFunc(names, func(a, b string) int { + if c := cmp.Compare(len(b), len(a)); c != 0 { + return c + } + return cmp.Compare(a, b) + }) + escaped := make([]string, len(names)) + for i, name := range names { + escaped[i] = regexp.QuoteMeta(name) + } + rx, err := regexp.Compile("(?i)(" + strings.Join(escaped, "|") + ")") + if err != nil { + log.Warn("Error compiling split exceptions regexp", "exceptions", exceptions, err) + return nil + } + return rx +} + +type artistSplitExceptionsCache struct { + names []string + rx *regexp.Regexp +} + +var artistSplitExceptions atomic.Pointer[artistSplitExceptionsCache] + +// artistSplitExceptionsRx returns the regex for Scanner.ArtistSplitExceptions, +// or nil if none are configured. Compiled lazily (config hooks only run once +// per process, before tests can override the option) and cached until the +// configured list changes. Lock-free on the cache-hit path, as this is called +// per tag mapping per scanned file, across concurrent scanner goroutines. +func artistSplitExceptionsRx() *regexp.Regexp { + names := conf.Server.Scanner.ArtistSplitExceptions + if c := artistSplitExceptions.Load(); c != nil && slices.Equal(c.names, names) { + return c.rx + } + c := &artistSplitExceptionsCache{names: slices.Clone(names), rx: compileExceptionsRegex(names)} + artistSplitExceptions.Store(c) + return c.rx +} + +// participantTagNames are the tags that hold artist names (or their sort +// values), where split exceptions apply. +var participantTagNames = sync.OnceValue(func() map[TagName]struct{} { + names := []TagName{ + TagTrackArtist, TagTrackArtists, TagTrackArtistSort, TagTrackArtistsSort, + TagAlbumArtist, TagAlbumArtists, TagAlbumArtistSort, TagAlbumArtistsSort, + } + set := make(map[TagName]struct{}, len(names)+2*len(AllRoles)) + for _, n := range names { + set[n] = struct{}{} + } + for role := range AllRoles { + set[TagName(role)] = struct{}{} + set[TagName(role+"sort")] = struct{}{} + } + return set +}) + +// WithParticipantExceptions returns the conf with the global artist split +// exceptions attached when name is a participant (artist/role) tag. +func (c TagConf) WithParticipantExceptions(name TagName) TagConf { + if _, ok := participantTagNames()[name]; ok { + c.ExceptionsRx = artistSplitExceptionsRx() + } + return c +} + type TagType string const ( diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go index 1665d557b..e582c3f2f 100644 --- a/model/tag_mappings_test.go +++ b/model/tag_mappings_test.go @@ -1,6 +1,8 @@ package model import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -60,5 +62,133 @@ var _ = Describe("TagConf", func() { // filterDuplicatedOrEmptyValues in the metadata pipeline. Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) }) + + Context("with split exceptions", func() { + BeforeEach(func() { + conf = TagConf{Split: []string{" and ", ";", "/"}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + conf.ExceptionsRx = compileExceptionsRegex([]string{ + "Iron and Wine", + "Iron and Wine Duo", + "Ella and Louis", + "AC/DC", + "Ólafur Arnalds and Nils Frahm", + }) + }) + + It("does not split a value that is exactly an exception", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine"})).To(Equal([]string{"Iron and Wine"})) + }) + + It("protects an exception embedded in a multi-artist value", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine and Bob"})). + To(Equal([]string{"Iron and Wine", "Bob"})) + }) + + It("protects every occurrence, not just the first", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine; Bob; Iron and Wine"})). + To(Equal([]string{"Iron and Wine", "Bob", "Iron and Wine"})) + }) + + It("matches exceptions case-insensitively and keeps the tag's casing", func() { + Expect(conf.SplitTagValue([]string{"IRON AND WINE and Bob"})). + To(Equal([]string{"IRON AND WINE", "Bob"})) + }) + + It("prefers the longest exception when entries overlap", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine Duo and Bob"})). + To(Equal([]string{"Iron and Wine Duo", "Bob"})) + }) + + It("protects exceptions containing separator characters", func() { + Expect(conf.SplitTagValue([]string{"AC/DC/Queen"})). + To(Equal([]string{"AC/DC", "Queen"})) + }) + + It("does not protect an exception embedded in a longer word", func() { + // "Ella and Louis" must not match inside "Ella and Louise" + Expect(conf.SplitTagValue([]string{"Ella and Louise"})). + To(Equal([]string{"Ella", "Louise"})) + }) + + It("handles names with non-ASCII edges", func() { + Expect(conf.SplitTagValue([]string{"Ólafur Arnalds and Nils Frahm and Bob"})). + To(Equal([]string{"Ólafur Arnalds and Nils Frahm", "Bob"})) + }) + + It("splits normally when no exception matches", func() { + Expect(conf.SplitTagValue([]string{"Foo and Bar"})).To(Equal([]string{"Foo", "Bar"})) + }) + }) + }) + + Describe("compileExceptionsRegex", func() { + It("returns nil for an empty list", func() { + Expect(compileExceptionsRegex(nil)).To(BeNil()) + Expect(compileExceptionsRegex([]string{})).To(BeNil()) + }) + + It("returns nil when all entries are blank", func() { + Expect(compileExceptionsRegex([]string{"", " "})).To(BeNil()) + }) + + It("escapes regex metacharacters in names", func() { + rx := compileExceptionsRegex([]string{"Sigur (Rós)"}) + Expect(rx.FindString("Sigur (Rós)")).To(Equal("Sigur (Rós)")) + Expect(rx.MatchString("Sigur xRósx")).To(BeFalse()) + }) + + It("matches case-insensitively", func() { + rx := compileExceptionsRegex([]string{"Iron and Wine"}) + Expect(rx.MatchString("IRON AND WINE")).To(BeTrue()) + }) + }) + + Describe("artistSplitExceptionsRx", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns nil when no exceptions are configured", func() { + conf.Server.Scanner.ArtistSplitExceptions = nil + Expect(artistSplitExceptionsRx()).To(BeNil()) + }) + + It("compiles the configured exceptions", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + rx := artistSplitExceptionsRx() + Expect(rx).ToNot(BeNil()) + Expect(rx.MatchString("iron and wine")).To(BeTrue()) + }) + + It("caches the compiled regex until the configuration changes", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + first := artistSplitExceptionsRx() + Expect(artistSplitExceptionsRx()).To(BeIdenticalTo(first)) + + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + second := artistSplitExceptionsRx() + Expect(second).ToNot(BeIdenticalTo(first)) + Expect(second.MatchString("AC/DC")).To(BeTrue()) + }) + }) + + Describe("WithParticipantExceptions", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + }) + + It("attaches the exceptions regex to participant tags", func() { + for _, tag := range []TagName{"artist", "albumartist", "artists", "artistsort", "composer", "lyricist", "composersort"} { + Expect(TagConf{}.WithParticipantExceptions(tag).ExceptionsRx).ToNot(BeNil(), string(tag)) + } + }) + + It("does not attach the exceptions regex to non-participant tags", func() { + for _, tag := range []TagName{"genre", "mood", "title", "releasetype"} { + Expect(TagConf{}.WithParticipantExceptions(tag).ExceptionsRx).To(BeNil(), string(tag)) + } + }) }) }) From 01b7c86f90cd22ba518abbc9b4bba081ffd1156f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 2 Jul 2026 09:46:57 -0400 Subject: [PATCH 333/596] fix(scanner): stop logging expected lyrics sniff misses as warnings (#5702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scanner): stop logging expected lyrics sniff misses as warnings During a scan, embedded lyrics are parsed with an empty suffix, which puts ParseLyrics into content-sniffing mode: it tries the TTML, SRT and Lyricsfile YAML parsers in turn before falling back to plain text. Every plain-text or LRC lyric therefore fails the structured probes on its way to the fallback, and each failure was logged at warning level with no indication of which file triggered it, flooding the scan log with benign "Error parsing lyrics, falling back to plain text" messages. A probe rejecting content it does not own during sniffing is expected control flow, so it is now logged at trace instead. A parse failure under an explicitly requested suffix (e.g. a malformed .yaml/.srt/.ttml sidecar) still warns, since the user declared that format. ParseLyrics gains ctx and path parameters so any warning names the offending file and carries request context where available; all call sites are updated accordingly. Also fixes a test-isolation bug in the new logging spec: the BeforeEach swapped the process-global default logger via SetDefaultLogger but only restored the log level on cleanup, leaking the null logger and its hook into later specs in the shared model suite. * test: use spec-scoped contexts instead of context.Background in lyrics tests Replace context.Background() with GinkgoT().Context() (and b.Context() in the parse benchmarks) across the lyrics-related tests, so contexts are cancelled when each spec ends. The embeddedLyrics fixture in core/lyrics is now a hand-written literal like its sibling fixtures, removing the construction-time ParseLyrics call that could not use a spec-scoped context. * refactor(model): attach lyrics parse log attribution via context Narrow ParseLyrics back to (ctx, suffix, lang, contents), dropping the path parameter added by the previous commit. Attribution now uses the codebase's existing idiom: callers that know the source attach it with log.NewContext (e.g. "file" for the media file or sidecar), and the plugin adapter tags both the plugin name and the track, fixing probe-miss logs that misattributed plugin-returned content to the file's own tags. This removes three adjacent string parameters that were easy to swap silently, and the "" placeholder most call sites had to pass. Also hardens the logging spec from the previous commit: the null test logger is now swapped in before raising the level (SetLevel forces the current default logger to trace, so the old order left the null logger at info and trace entries never reached the hook), the sniff test now asserts probe misses are observable at trace with file attribution instead of only asserting the absence of warnings, and cleanup restores the actual previous logger — via a new return value on log.SetDefaultLogger — instead of a bare logrus.New() that would discard hooks configured on the process-wide logger. * refactor(lyrics): hoist attributed log contexts out of loops Address review feedback on #5702: build the log-attributed context once per operation instead of per iteration, and reuse it on the surrounding log calls so the error/trace lines around ParseLyrics carry the same attribution fields. In fromExternalFile the sidecar path now rides the context for all log lines in the function, replacing the repeated explicit "path" field. * style(model): pass lyrics parse errors as final log arguments Per the project logging convention, errors go as the last argument (the log package normalizes them via its error case) instead of a keyed "error" pair, which stores the raw error value and bypasses that handling. Flagged by review on #5702; the keyed form was inherited from the original warning line. --- core/lyrics/lyrics_test.go | 15 ++-- core/lyrics/sources.go | 11 +-- core/lyrics/sources_test.go | 10 ++- .../20231209211223_alter_lyric_column.go | 2 +- log/log.go | 6 +- model/lyrics_benchmark_test.go | 2 +- model/lyrics_parse.go | 19 ++++- model/lyrics_parse_test.go | 78 ++++++++++++++++--- model/metadata/map_mediafile.go | 6 +- plugins/lyrics_adapter.go | 8 +- scanner/metadata_old/metadata.go | 5 +- server/subsonic/lyrics_test.go | 6 +- server/subsonic/media_retrieval_test.go | 6 +- 13 files changed, 131 insertions(+), 43 deletions(-) diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 6baacbe71..b00bcd576 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -21,10 +21,15 @@ var _ = Describe("Lyrics", func() { var mf model.MediaFile var ctx context.Context - const badLyrics = "This is a set of lyrics\nThat is not good" - unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics)) - unsynced, _ := unsyncedList.Main() - embeddedLyrics := model.LyricList{unsynced} + embeddedLyrics := model.LyricList{ + model.Lyrics{ + Lang: "xxx", + Line: []model.Line{ + {Value: "This is a set of lyrics"}, + {Value: "That is not good"}, + }, + }, + } syncedLyrics := model.LyricList{ model.Lyrics{ @@ -390,7 +395,7 @@ var _ = Describe("Lyrics", func() { }) It("resolves lyrics from the matched media files", func() { - embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line")) + embeddedList, err := model.ParseLyrics(ctx, ".lrc", "eng", []byte("Embedded lyrics line")) Expect(err).ToNot(HaveOccurred()) embedded, _ := embeddedList.Main() embeddedJSON, err := json.Marshal(model.LyricList{embedded}) diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 9de2f6a18..23c20122d 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -28,6 +28,7 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { ext := path.Ext(mf.Path) sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix + ctx = log.NewContext(ctx, "file", sidecarRelPath) store, err := storage.For(mf.LibraryPath) if err != nil { @@ -40,7 +41,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( f, err := fsys.Open(sidecarRelPath) if errors.Is(err, fs.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath) + log.Trace(ctx, "no lyrics found at path") return nil, nil } else if err != nil { return nil, err @@ -52,18 +53,18 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( return nil, err } - list, err := model.ParseLyrics(suffix, "xxx", contents) + list, err := model.ParseLyrics(ctx, suffix, "xxx", contents) if err != nil { - log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err) + log.Error(ctx, "error parsing external lyric file", err) return nil, err } if len(list) == 0 { - log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath) + log.Trace(ctx, "empty lyrics from external file") return nil, nil } - log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath) + log.Trace(ctx, "retrieved lyrics from external file") return list, nil } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 68f45424e..7c7922bfd 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -11,7 +11,11 @@ import ( ) var _ = Describe("sources", func() { - ctx := context.Background() + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) Describe("fromEmbedded", func() { It("should return nothing for a media file with no lyrics", func() { @@ -26,8 +30,8 @@ var _ = Describe("sources", func() { const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) - unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics)) synced, _ := syncedList.Main() unsynced, _ := unsyncedList.Main() diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index 259a37745..7f1ad2f38 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -46,7 +46,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { continue } - parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String)) + parsed, err := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(lyrics.String)) if err != nil { return err } diff --git a/log/log.go b/log/log.go index b1d6eee10..eaea75fb9 100644 --- a/log/log.go +++ b/log/log.go @@ -175,10 +175,14 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } -func SetDefaultLogger(l *logrus.Logger) { +// SetDefaultLogger swaps the process-wide logger and returns the previous one, +// so tests can restore the original (with its hooks and formatter) on cleanup. +func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { loggerMu.Lock() defer loggerMu.Unlock() + prev := defaultLogger defaultLogger = l + return prev } func CurrentLevel() Level { diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go index 0de2549e7..5a7d7871e 100644 --- a/model/lyrics_benchmark_test.go +++ b/model/lyrics_benchmark_test.go @@ -24,7 +24,7 @@ func benchmarkParse(b *testing.B, suffix, fixture string) { b.ReportAllocs() b.SetBytes(int64(len(contents))) for b.Loop() { - if _, err := ParseLyrics(suffix, "eng", contents); err != nil { + if _, err := ParseLyrics(b.Context(), suffix, "eng", contents); err != nil { b.Fatal(err) } } diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go index 4bfaa29e8..8aa095c50 100644 --- a/model/lyrics_parse.go +++ b/model/lyrics_parse.go @@ -2,6 +2,7 @@ package model import ( "bytes" + "context" "fmt" "slices" "strings" @@ -28,7 +29,10 @@ var lyricFormats = []struct { // ParseLyrics is the single entry point for parsing lyrics. A known suffix routes // to that format's parser; an empty or "auto" suffix content-sniffs. Either way, // a structured parser that does not match falls back to the LRC/plain-text floor. -func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { +// +// Parse failures are logged through ctx; callers that know the source should +// attach it for attribution, e.g. log.NewContext(ctx, "file", path). +func ParseLyrics(ctx context.Context, suffix, lang string, contents []byte) (LyricList, error) { contents = stripBOM(contents) suffix = strings.ToLower(suffix) sniff := suffix == "" || suffix == "auto" @@ -41,17 +45,24 @@ func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { candidates = append(candidates, f.parse) } } - return parseFirstMatch(lang, contents, candidates...) + return parseFirstMatch(ctx, sniff, lang, contents, candidates...) } -func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { +func parseFirstMatch(ctx context.Context, sniff bool, lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { for _, parse := range candidates { list, err := parse(lang, contents) if err == nil && len(list) > 0 { return list, nil } if err != nil { - log.Warn("Error parsing lyrics, falling back to plain text", "error", err) + // While sniffing, a probe rejecting content it does not own is expected + // control flow, so keep it at trace. A failure under an explicit suffix + // means the declared format is malformed and deserves a warning. + if sniff { + log.Trace(ctx, "Lyrics probe did not match, trying next format", err) + } else { + log.Warn(ctx, "Error parsing lyrics, falling back to plain text", err) + } } } return plainLRC(lang, contents) diff --git a/model/lyrics_parse_test.go b/model/lyrics_parse_test.go index eb58a29ef..0b47e55e9 100644 --- a/model/lyrics_parse_test.go +++ b/model/lyrics_parse_test.go @@ -3,14 +3,17 @@ package model import ( "strings" + "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" ) var _ = Describe("ParseLyrics", func() { DescribeTable("known suffix routes to the matching parser", func(suffix, contents string, wantSynced bool, wantFirst string) { - list, err := ParseLyrics(suffix, "eng", []byte(contents)) + list, err := ParseLyrics(GinkgoT().Context(), suffix, "eng", []byte(contents)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Synced).To(Equal(wantSynced)) @@ -25,7 +28,7 @@ var _ = Describe("ParseLyrics", func() { It("empty suffix content-sniffs (TTML)", func() { ttml := `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">auto ttml</p></div></body></tt>` - list, err := ParseLyrics("", "eng", []byte(ttml)) + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(ttml)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line[0].Value).To(Equal("auto ttml")) @@ -33,19 +36,72 @@ var _ = Describe("ParseLyrics", func() { It("empty suffix content-sniffs (YAML)", func() { yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n" - list, err := ParseLyrics("auto", "eng", []byte(yaml)) + list, err := ParseLyrics(GinkgoT().Context(), "auto", "eng", []byte(yaml)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line[0].Value).To(Equal("auto yaml")) }) It("falls back to plain text when a known suffix fails to parse structurally", func() { - list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file")) + list, err := ParseLyrics(GinkgoT().Context(), ".srt", "eng", []byte("not actually an srt file")) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Synced).To(BeFalse()) Expect(list[0].Line[0].Value).To(Equal("not actually an srt file")) }) + + Describe("logging on parser probe failures", func() { + var hook *test.Hook + + BeforeEach(func() { + prevLevel := log.CurrentLevel() + l, h := test.NewNullLogger() + hook = h + // Swap the logger before raising the level: SetLevel also forces the + // current default logger to logrus.TraceLevel, and the null logger would + // otherwise stay at Info and drop Trace entries before the hook sees them. + prevLogger := log.SetDefaultLogger(l) + log.SetLevel(log.LevelTrace) + DeferCleanup(func() { + log.SetDefaultLogger(prevLogger) + log.SetLevel(prevLevel) + }) + }) + + // This is the source of the full-scan log spam: embedded lyrics are parsed + // with an empty suffix (sniff mode), so every plain-text lyric fails the + // YAML/SRT/TTML probes on its way to the plain-text fallback. A probe miss + // during sniffing is expected control flow, not a warning. + It("logs sniff probe misses at trace only, with file attribution", func() { + ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.mp3") + list, err := ParseLyrics(ctx, "", "eng", []byte("Just a plain\nlyric line\n")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("Just a plain")) + entries := hook.AllEntries() + Expect(entries).ToNot(BeEmpty(), "probe misses should be observable at trace") + for _, e := range entries { + Expect(e.Level).To(Equal(logrus.TraceLevel), + "sniff-mode probe misses must not be logged above Trace") + Expect(e.Data).To(HaveKeyWithValue("file", "/music/song.mp3")) + } + }) + + // A specific suffix means the user declared the format, so a structural + // failure is worth surfacing loudly — and it must name the file. + It("warns and names the file when a requested suffix fails to parse", func() { + ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.yaml") + list, err := ParseLyrics(ctx, ".yaml", "eng", []byte("not: [valid, yaml\n")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) // still falls back to plain text + entry := hook.LastEntry() + Expect(entry).ToNot(BeNil()) + Expect(entry.Level).To(Equal(logrus.WarnLevel)) + Expect(entry.Data).To(HaveKeyWithValue("file", "/music/song.yaml")) + }) + }) }) var _ = Describe("ParseLyrics content-sniffing", func() { @@ -67,7 +123,7 @@ var _ = Describe("ParseLyrics content-sniffing", func() { </body> </tt>` - list, err := ParseLyrics("", "ENG", []byte(content)) + list, err := ParseLyrics(GinkgoT().Context(), "", "ENG", []byte(content)) // ParseLyrics's job is to detect TTML and apply the tag language as the // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. @@ -104,7 +160,7 @@ var _ = Describe("ParseLyrics content-sniffing", func() { </body> </tt>` - list, err := ParseLyrics("", "eng", []byte(content)) + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(3)) @@ -129,7 +185,7 @@ We're from subtitles 00:00:22,801 --> 00:00:26,000 Another subtitle line` - list, err := ParseLyrics("", "POR", []byte(content)) + list, err := ParseLyrics(GinkgoT().Context(), "", "POR", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(Equal(LyricList{ @@ -155,7 +211,7 @@ Another subtitle line` It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" - list, err := ParseLyrics("", "eng", []byte(content)) + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -168,7 +224,7 @@ Another subtitle line` It("should keep embedded enhanced LRC cues", func() { content := "[00:01.00]<00:01.00>Lead <00:01.50>words" - list, err := ParseLyrics("", "eng", []byte(content)) + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -185,7 +241,7 @@ Another subtitle line` </body> </tt>` - list, err := ParseLyrics("", "eng", []byte(content)) + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -202,7 +258,7 @@ Another subtitle line` It("detects a Lyricsfile YAML payload via content-sniffing", func() { yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" - list, err := ParseLyrics("", "eng", []byte(yaml)) + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(yaml)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index de2ba813e..b3ce4ef02 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -2,6 +2,7 @@ package metadata import ( "cmp" + "context" "encoding/json" "maps" "math" @@ -139,13 +140,14 @@ func (md Metadata) mapLyrics() string { lyricList := make(model.LyricList, 0, len(rawLyrics)) + ctx := log.NewContext(context.Background(), "file", md.filePath) for _, raw := range rawLyrics { lang := raw.Key() text := raw.Value() - lyrics, err := model.ParseLyrics("", lang, []byte(text)) + lyrics, err := model.ParseLyrics(ctx, "", lang, []byte(text)) if err != nil { - log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) + log.Warn(ctx, "Unexpected failure occurred when parsing lyrics", err) continue } for _, lyric := range lyrics { diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 12d84f60d..281f022fb 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -44,15 +44,19 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode return nil, err } + // The lyric text comes from the plugin, not the media file's own tags, so + // attribute logs to both the plugin and the track it was fetched for. + ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path) + var result model.LyricList for _, lt := range resp.Lyrics { lang := lt.Lang if lang == "" { lang = "xxx" } - parsed, err := model.ParseLyrics("", lang, []byte(lt.Text)) + parsed, err := model.ParseLyrics(ctx, "", lang, []byte(lt.Text)) if err != nil { - log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) + log.Warn(ctx, "Error parsing plugin lyrics", err) continue } for _, lyric := range parsed { diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 8cde586b9..2906a2c09 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -1,6 +1,7 @@ package metadata_old import ( + "context" "encoding/json" "fmt" "math" @@ -205,7 +206,7 @@ func (t Tags) Lyrics() string { basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") for _, value := range basicLyrics { - parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(value)) + parsed, err := model.ParseLyrics(context.Background(), ".lrc", "xxx", []byte(value)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue @@ -224,7 +225,7 @@ func (t Tags) Lyrics() string { } for _, text := range value { - parsed, err := model.ParseLyrics(".lrc", language, []byte(text)) + parsed, err := model.ParseLyrics(context.Background(), ".lrc", language, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index 3d9881872..8713b7a3b 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -100,8 +100,8 @@ var _ = Describe("GetLyricsBySongId", func() { It("should return mixed lyrics", func() { r := newGetRequest("id=1") - syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) - unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "xxx", []byte(unsyncedLyrics)) synced, _ := syncedList.Main() unsynced, _ := unsyncedList.Main() lyricsJson, err := json.Marshal(model.LyricList{ @@ -158,7 +158,7 @@ var _ = Describe("GetLyricsBySongId", func() { It("should parse lrc metadata", func() { r := newGetRequest("id=1") - syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) + syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) synced, _ := syncedList.Main() lyricsJson, err := json.Marshal(model.LyricList{ synced, diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 228427d5a..9331dfbe4 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -78,7 +78,7 @@ var _ = Describe("MediaRetrievalController", func() { When("client disconnects (context is cancelled)", func() { It("should not call the service if cancelled before the call", func() { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(GinkgoT().Context()) r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) cancel() @@ -93,7 +93,7 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should not return data if cancelled during the call", func() { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(GinkgoT().Context()) defer cancel() r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) @@ -113,7 +113,7 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) + lyricsList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) lyrics, _ := lyricsList.Main() lyricsJson, err := json.Marshal(model.LyricList{ lyrics, From 427d4b9bce198df09aff18be1beddb85aaa3f725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 2 Jul 2026 12:53:10 -0400 Subject: [PATCH 334/596] fix(search): artists with atomic non-ASCII names unfindable after FTS5 migration (#5703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scanner): update artist search_normalized when rescanning The FTS5 migration back-fills artist.search_normalized with a SQL punctuation-strip approximation, relying on the next scan to compute the precise value in Go (normalizeForFTS transliterates atomic letters like Ø/æ/ß that FTS5's remove_diacritics cannot fold). But the scanner persisted artists with an explicit column list that omitted search_normalized, so not even a full scan ever repaired it: an artist migrated from a pre-FTS database (e.g. "GØGGS") stayed unfindable by any ASCII search, while their albums and songs, which are saved with all columns, were fixed by a full scan. Add search_normalized to the column list so a full scan re-indexes the artist via the artist_fts trigger. * refactor(persistence): move normalizeForFTS to utils/str Export it as str.NormalizeForFTS so the upcoming migration can reuse the exact index-time normalization. Migrations cannot import the persistence package (persistence -> db -> db/migrations would be an import cycle). * fix(persistence): backfill artist search_normalized via migration Recompute artist.search_normalized with the precise Go normalization for databases migrated from pre-FTS5 versions, where the SQL back-fill could not transliterate atomic letters (Ø/æ/ß) and the scanner never rewrote the column. Only changed rows are updated, so the artist_fts update trigger re-indexes exactly the affected artists, making artists like GØGGS or MØ findable again without requiring a full scan. * refactor(persistence): share FTS punctuation-strip regex via utils/str Index-time normalization (NormalizeForFTS) and query-time processing (buildFTS5Query/ftsQueryDegraded) must produce matching tokens, so keep the punctuation-strip pattern in a single exported symbol instead of two identical private copies that could drift. Also document that derived columns computed in dbArtist.PostMapArgs must be listed in the scanner's artist Put, which is how search_normalized went stale in the first place. * chore(migrations): announce artist search backfill in the log Match the FTS5 migration's notice() pattern so startup isn't silent while the backfill runs on large libraries. * docs: tighten comments added in this branch * docs: describe FTSPunctStrip by what it matches, not one replacement --- ...52457_backfill_artist_search_normalized.go | 58 +++++++++++++++++++ persistence/album_repository.go | 3 +- persistence/artist_repository.go | 5 +- persistence/mediafile_repository.go | 3 +- persistence/sql_search_fts.go | 43 ++------------ persistence/sql_search_fts_test.go | 22 ------- scanner/phase_1_folders.go | 2 +- scanner/scanner_test.go | 28 +++++++++ utils/str/normalize_fts.go | 45 ++++++++++++++ utils/str/normalize_fts_test.go | 29 ++++++++++ 10 files changed, 173 insertions(+), 65 deletions(-) create mode 100644 db/migrations/20260702152457_backfill_artist_search_normalized.go create mode 100644 utils/str/normalize_fts.go create mode 100644 utils/str/normalize_fts_test.go diff --git a/db/migrations/20260702152457_backfill_artist_search_normalized.go b/db/migrations/20260702152457_backfill_artist_search_normalized.go new file mode 100644 index 000000000..93902e230 --- /dev/null +++ b/db/migrations/20260702152457_backfill_artist_search_normalized.go @@ -0,0 +1,58 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/navidrome/navidrome/utils/str" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upBackfillArtistSearchNormalized, downBackfillArtistSearchNormalized) +} + +// The FTS5 migration back-filled artist.search_normalized with a SQL approximation that +// cannot transliterate atomic letters (Ø, æ, ß, ...), and the scanner never rewrote the +// column, leaving artists like "GØGGS" unfindable by ASCII searches. Recompute it in Go; +// the artist_fts update trigger re-indexes every row that changes. +func upBackfillArtistSearchNormalized(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "Rebuilding artist search index data. This may take a moment on large libraries.") + + rows, err := tx.QueryContext(ctx, "SELECT id, name, search_normalized FROM artist") + if err != nil { + return fmt.Errorf("querying artists: %w", err) + } + defer rows.Close() + + updates := map[string]string{} + for rows.Next() { + var id, name, current string + if err := rows.Scan(&id, &name, ¤t); err != nil { + return fmt.Errorf("scanning artist: %w", err) + } + if expected := str.NormalizeForFTS(name); expected != current { + updates[id] = expected + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterating artists: %w", err) + } + + stmt, err := tx.PrepareContext(ctx, "UPDATE artist SET search_normalized = ? WHERE id = ?") + if err != nil { + return fmt.Errorf("preparing update: %w", err) + } + defer stmt.Close() + for id, normalized := range updates { + if _, err := stmt.ExecContext(ctx, normalized, id); err != nil { + return fmt.Errorf("updating artist %s: %w", id, err) + } + } + return nil +} + +func downBackfillArtistSearchNormalized(context.Context, *sql.Tx) error { + return nil +} diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 31a0f5c91..34845be15 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -69,7 +70,7 @@ func (a *dbAlbum) PostMapArgs(args map[string]any) error { fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...) args["full_text"] = formatFullText(fullText...) args["search_participants"] = strings.Join(participantNames, " ") - args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist) + args["search_normalized"] = str.NormalizeForFTS(a.Name, a.AlbumArtist) args["tags"] = marshalTags(a.Album.Tags) args["participants"] = marshalParticipants(a.Album.Participants) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 5152e774f..f84f410e9 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -102,8 +103,10 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error { } similarArtists, _ := json.Marshal(sa) m["similar_artists"] = string(similarArtists) + // When adding a derived column here, also add it to the scanner's artist Put column list + // in phase_1_folders.go, or rescans will never update it (how search_normalized went stale). m["full_text"] = formatFullText(a.Name, a.SortArtistName) - m["search_normalized"] = normalizeForFTS(a.Name) + m["search_normalized"] = str.NormalizeForFTS(a.Name) // Do not override the sort_artist_name and mbz_artist_id fields if they are empty // TODO: Better way to handle this? diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index d7d892ed1..094268783 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -62,7 +63,7 @@ func (m *dbMediaFile) PostMapArgs(args map[string]any) error { fullText = append(fullText, participantNames...) args["full_text"] = formatFullText(fullText...) args["search_participants"] = strings.Join(participantNames, " ") - args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) + args["search_normalized"] = str.NormalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) args["tags"] = marshalTags(m.MediaFile.Tags) args["participants"] = marshalParticipants(m.MediaFile.Participants) return nil diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index bbae47fe8..999c904af 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -11,6 +11,7 @@ import ( "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" ) // containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters. @@ -35,48 +36,12 @@ func containsCJK(s string) bool { // as unbalanced string delimiters. var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`) -// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes). -// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM"). -var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`) - // fts5Operators matches FTS5 boolean operators as whole words (case-insensitive). var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) // fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) -// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of -// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) -// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed -// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — -// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree -// without an explicit transliterated entry here. -func normalizeForFTS(values ...string) string { - seen := make(map[string]struct{}) - var result []string - add := func(orig, variant string) { - if variant == "" || variant == orig { - return - } - lower := strings.ToLower(variant) - if _, ok := seen[lower]; ok { - return - } - seen[lower] = struct{}{} - result = append(result, variant) - } - for _, v := range values { - for word := range strings.FieldsSeq(v) { - transliterated := sanitize.Accents(word) - // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. - add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) - // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). - add(word, transliterated) - } - } - return strings.Join(result, " ") -} - // isSingleUnicodeLetter returns true if token is exactly one Unicode letter. func isSingleUnicodeLetter(token string) bool { r, size := utf8.DecodeRuneInString(token) @@ -100,7 +65,7 @@ func processPunctuatedWords(input string, phrases []string) (string, []string) { result = append(result, w) continue } - concat := fts5PunctStrip.ReplaceAllString(w, "") + concat := str.FTSPunctStrip.ReplaceAllString(w, "") if concat == "" || concat == w { result = append(result, w) continue @@ -329,7 +294,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Strip quotes from original for comparison — we want the raw content stripped := strings.ReplaceAll(original, `"`, "") // Extract the alphanumeric content from the original query - alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "") + alphaNum := str.FTSPunctStrip.ReplaceAllString(stripped, "") // If the original is entirely alphanumeric, nothing was stripped — not degraded if len(alphaNum) == len(stripped) { return false @@ -353,7 +318,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool { if strings.HasPrefix(t, `"`) { // Extract content between quotes inner := strings.Trim(t, `"`) - innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") + innerAlpha := str.FTSPunctStrip.ReplaceAllString(inner, " ") for it := range strings.FieldsSeq(innerAlpha) { if len(it) > 2 { return false diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index b54e5856a..6c975c601 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -74,28 +74,6 @@ var _ = DescribeTable("ftsQueryDegraded", Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false), ) -var _ = DescribeTable("normalizeForFTS", - func(expected string, values ...string) { - Expect(normalizeForFTS(values...)).To(Equal(expected)) - }, - Entry("strips dots and concatenates", "REM", "R.E.M."), - Entry("strips slash", "ACDC", "AC/DC"), - Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged ASCII words", "", "The Beatles"), - Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), - Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), - Entry("strips apostrophe from word", "N", "Guns N' Roses"), - Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), - Entry("transliterates ø to o", "Bjork", "Bjørk"), - Entry("transliterates Ø to O", "Oystein", "Øystein"), - Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), - Entry("transliterates Latin diacritics", "cafe", "café"), - Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), - Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), - Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), - Entry("transliterates ß to ss", "Strasse", "Straße"), -) - var _ = DescribeTable("containsCJK", func(input string, expected bool) { Expect(containsCJK(input)).To(Equal(expected)) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 38967832c..5e898590b 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -360,7 +360,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later for i := range entry.artists { err = artistRepo.Put(&entry.artists[i], "name", - "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "updated_at") + "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") if err != nil { log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) return err diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index cc3732bc3..7f3dca775 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -189,6 +189,34 @@ var _ = Describe("Scanner", Ordered, func() { }) }) + Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() { + BeforeEach(func() { + goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018}) + createFS(fstest.MapFS{ + "GØGGS/Pre Strike Sweep/01 - Falling For You.mp3": goggs(track(1, "Falling For You")), + }) + }) + + searchNormalized := func() string { + var sn string + Expect(db.Db().QueryRowContext(ctx, + "SELECT search_normalized FROM artist WHERE name = 'GØGGS'").Scan(&sn)).To(Succeed()) + return sn + } + + It("repopulates a stale search_normalized on a full rescan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(searchNormalized()).To(Equal("GOGGS")) + + // Simulate the stale value left by the FTS5 migration's SQL back-fill + _, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'") + Expect(err).ToNot(HaveOccurred()) + + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(searchNormalized()).To(Equal("GOGGS")) + }) + }) + Context("Ignored entries", func() { BeforeEach(func() { revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) diff --git a/utils/str/normalize_fts.go b/utils/str/normalize_fts.go new file mode 100644 index 000000000..994f77cc9 --- /dev/null +++ b/utils/str/normalize_fts.go @@ -0,0 +1,45 @@ +package str + +import ( + "regexp" + "strings" + + "github.com/deluan/sanitize" +) + +// FTSPunctStrip matches any character that is not a letter or number. Index-time +// normalization (NormalizeForFTS) and query-time processing in persistence share it +// so both sides produce matching tokens. +var FTSPunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`) + +// NormalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of +// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) +// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed +// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — +// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree +// without an explicit transliterated entry here. +func NormalizeForFTS(values ...string) string { + seen := make(map[string]struct{}) + var result []string + add := func(orig, variant string) { + if variant == "" || variant == orig { + return + } + lower := strings.ToLower(variant) + if _, ok := seen[lower]; ok { + return + } + seen[lower] = struct{}{} + result = append(result, variant) + } + for _, v := range values { + for word := range strings.FieldsSeq(v) { + transliterated := sanitize.Accents(word) + // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. + add(word, FTSPunctStrip.ReplaceAllString(transliterated, "")) + // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). + add(word, transliterated) + } + } + return strings.Join(result, " ") +} diff --git a/utils/str/normalize_fts_test.go b/utils/str/normalize_fts_test.go new file mode 100644 index 000000000..52387cb1d --- /dev/null +++ b/utils/str/normalize_fts_test.go @@ -0,0 +1,29 @@ +package str_test + +import ( + "github.com/navidrome/navidrome/utils/str" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("NormalizeForFTS", + func(expected string, values ...string) { + Expect(str.NormalizeForFTS(values...)).To(Equal(expected)) + }, + Entry("strips dots and concatenates", "REM", "R.E.M."), + Entry("strips slash", "ACDC", "AC/DC"), + Entry("strips hyphen", "Aha", "A-ha"), + Entry("skips unchanged ASCII words", "", "The Beatles"), + Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), + Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), + Entry("strips apostrophe from word", "N", "Guns N' Roses"), + Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), + Entry("transliterates ø to o", "Bjork", "Bjørk"), + Entry("transliterates Ø to O", "Oystein", "Øystein"), + Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), + Entry("transliterates Latin diacritics", "cafe", "café"), + Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), + Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), + Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), + Entry("transliterates ß to ss", "Strasse", "Straße"), +) From ae9b8a5fe6e43bfe3d4dd07c24efe6c0ddca729c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 2 Jul 2026 15:51:03 -0400 Subject: [PATCH 335/596] feat(search): rank exact matches above prefix matches (#5704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): boost exact token matches over prefix matches buildFTS5Query now emits (word OR word*) instead of word* for plain tokens. The match set is unchanged (exact is a subset of prefix), but bm25 gives the rare exact token a high-IDF contribution, so rows containing the literal query word rank above prefix-only matches. The degraded-query check keeps evaluating the plain prefix form, preserving the LIKE fallback for queries like "1+" and "C++". * feat(search): weight artist search_normalized equal to name in bm25 For the artist table, search_normalized holds only the artist's name in alternate spelling (transliterated/punctuation-stripped), so a hit there is as meaningful as a name hit. Combined with exact-token boosting, artists like MØ now rank in the top results for the query "MO" instead of dead last. media_file and album keep weight 1.0 because their search_normalized mixes title, album, and artist variants. * test(persistence): add exact-match ranking regression test Seeds MØ, Morrissey, and Modest Mouse and asserts MØ ranks first for the queries "MO" and "MØ": the exact transliterated hit in search_normalized must outrank name-prefix matches. The corpus deliberately has no competing exact-word names, since exact-vs-exact ordering depends on corpus statistics rather than the guaranteed exact-over-prefix property. Rows are inserted per-test (with their library_artist associations) and cleaned up to avoid disturbing the shared seed fixtures and their count assertions. * docs(search): document exact-token OR emission in buildFTS5Query * test(persistence): harden exact-match ranking test fixtures Register the corpus cleanup before the insert loop so a mid-loop assertion failure cannot leak fts-rank-% rows into the shared integration DB, and reuse the existing createArtistWithLibrary helper instead of hand-rolling Put+AddArtist (which also replaces the ad-hoc context.TODO with the helper's GinkgoT().Context). * fix(search): flag multi-word degraded queries for the LIKE fallback The degradation probe was joined with explicit " AND " like the real query, so ftsQueryDegraded counted the literal AND as a long token and never flagged queries where every term degrades to a short token (e.g. "1+ 2+"). This predates this branch (the old code passed the same AND-joined string), but the probe now exists separately, so join it with spaces — it only feeds ftsQueryDegraded, which needs no explicit operators. --- persistence/sql_search_fts.go | 40 ++++++++--- persistence/sql_search_fts_test.go | 107 +++++++++++++++++++++-------- 2 files changed, 107 insertions(+), 40 deletions(-) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 999c904af..fce77afbb 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -105,13 +105,15 @@ func isDottedAbbreviation(w string, subTokens []string) bool { } // buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression. +// Plain tokens are emitted as (token OR token*) so bm25 ranks exact-token hits above prefix-only matches. // It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators // (by lowercasing them, since FTS5 operators are case-sensitive) and strips // special characters to prevent query injection. -func buildFTS5Query(userInput string) string { +// The second return reports whether tokenization degraded the query (see ftsQueryDegraded). +func buildFTS5Query(userInput string) (string, bool) { q := strings.TrimSpace(userInput) if q == "" || q == `""` { - return "" + return "", false } var phrases []string @@ -151,25 +153,38 @@ func buildFTS5Query(userInput string) string { result = fts5LeadingStar.ReplaceAllString(result, "$1") tokens := strings.Fields(result) - // Append * to plain tokens for prefix matching (e.g., "love" → "love*"). - // Skip tokens that are already wildcarded or are quoted phrase placeholders. + // Two forms per token: a plain prefix form (love*) used only to evaluate query + // degradation, and the final (love OR love*) form. The OR adds no matches + // (exact ⊂ prefix) but gives bm25 a high-IDF exact-term hit, ranking rows that + // contain the literal word above prefix-only matches. Placeholders and + // user-supplied wildcards pass through untouched in both forms. + prefixTokens := make([]string, len(tokens)) + wrappedTokens := make([]string, len(tokens)) for i, t := range tokens { if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") { + prefixTokens[i], wrappedTokens[i] = t, t continue } - tokens[i] = t + "*" + prefixTokens[i] = t + "*" + wrappedTokens[i] = "(" + t + " OR " + t + "*)" } // Use explicit AND between tokens — FTS5's implicit AND (space-separated) - // doesn't work correctly with parenthesized OR groups from processPunctuatedWords. - result = strings.Join(tokens, " AND ") + // doesn't work correctly with parenthesized OR groups. The prefix form is + // space-joined instead: it only feeds ftsQueryDegraded, which would count a + // literal "AND" as a long token and never flag all-short-token queries. + prefixQuery := strings.Join(prefixTokens, " ") + result = strings.Join(wrappedTokens, " AND ") for i, phrase := range phrases { placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) + prefixQuery = strings.ReplaceAll(prefixQuery, placeholder, phrase) result = strings.ReplaceAll(result, placeholder, phrase) } - return result + // Degradation is evaluated on the prefix form: ftsQueryDegraded treats + // leading-( tokens as punctuated-word groups and would never flag wrapped ones. + return result, ftsQueryDegraded(userInput, prefixQuery) } // ftsColumn pairs an FTS5 column name with its BM25 relevance weight. @@ -209,7 +224,10 @@ var ftsColumnDefs = map[string][]ftsColumn{ "artist": { {"name", 10.0}, {"sort_artist_name", 1.0}, - {"search_normalized", 1.0}, + // Same weight as name: for artists this column is purely the name in + // alternate spelling (unlike media_file/album, where it mixes + // title/album/artist variants and full weight would distort ranking). + {"search_normalized", 10.0}, }, } @@ -338,8 +356,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // tokenization stripped significant content from the query (e.g., "1+" → "1*"). // Returns nil when the query produces no searchable tokens at all. func newFTSSearch(tableName, query string) searchStrategy { - q := buildFTS5Query(query) - if q == "" || ftsQueryDegraded(query, q) { + q, degraded := buildFTS5Query(query) + if q == "" || degraded { // Fallback: try LIKE search with the raw query cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, "")) if cleaned != "" { diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index 6c975c601..d0b26e8d5 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -12,44 +12,45 @@ import ( var _ = DescribeTable("buildFTS5Query", func(input, expected string) { - Expect(buildFTS5Query(input)).To(Equal(expected)) + q, _ := buildFTS5Query(input) + Expect(q).To(Equal(expected)) }, Entry("returns empty string for empty input", "", ""), Entry("returns empty string for whitespace-only input", " ", ""), - Entry("appends * to a single word for prefix matching", "beatles", "beatles*"), - Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"), - Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`), - Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"), - Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"), - Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"), - Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`), - Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"), - Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"), - Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"), - Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"), + Entry("wraps a single word as exact OR prefix", "beatles", "(beatles OR beatles*)"), + Entry("wraps each word as exact OR prefix", "abbey road", "(abbey OR abbey*) AND (road OR road*)"), + Entry("preserves quoted phrases without wrapping", `"the beatles"`, `"the beatles"`), + Entry("does not wrap user-supplied prefix wildcard", "beat*", "beat*"), + Entry("strips FTS5 operators and wraps lowercased words", "AND OR NOT NEAR", "(and OR and*) AND (or OR or*) AND (not OR not*) AND (near OR near*)"), + Entry("strips special FTS5 syntax characters and wraps", "test^col:val", "(test OR test*) AND (col OR col*) AND (val OR val*)"), + Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND (abbey OR abbey*)`), + Entry("handles prefix with multiple words", "beat* abbey", "beat* AND (abbey OR abbey*)"), + Entry("collapses multiple spaces", "abbey road", "(abbey OR abbey*) AND (road OR road*)"), + Entry("strips leading * from tokens and wraps", "*livia", "(livia OR livia*)"), + Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "(livia OR livia*) AND oliv*"), Entry("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"), + Entry("strips apostrophe from input", "Guns N' Roses", "(Guns OR Guns*) AND (N OR N*) AND (Roses OR Roses*)"), Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), - Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), - Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), - Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), - Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"), - Entry("transliterates ø to o", "Øystein", "Oystein*"), - Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"), - Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"), - Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"), - Entry("transliterates ß to ss", "Straße", "Strasse*"), + Entry("handles punctuated word mixed with plain words", "best of a-ha", `(best OR best*) AND (of OR of*) AND ("a ha" OR aha*)`), + Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND (got OR got*)`), + Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "(rock OR rock*) AND (roll OR roll*) AND (vol OR vol*) AND (2 OR 2*)"), + Entry("transliterates NFKD-decomposable diacritics", "Björk début", "(Bjork OR Bjork*) AND (debut OR debut*)"), + Entry("transliterates ø to o", "Øystein", "(Oystein OR Oystein*)"), + Entry("transliterates œ ligature to oe", "œuvre", "(oeuvre OR oeuvre*)"), + Entry("transliterates æ ligature to ae", "Brennæ", "(Brennae OR Brennae*)"), + Entry("transliterates mixed unicode words", "Mø Sigur Rós", "(Mo OR Mo*) AND (Sigur OR Sigur*) AND (Ros OR Ros*)"), + Entry("transliterates ß to ss", "Straße", "(Strasse OR Strasse*)"), Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), - Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `(best OR best*) AND (of OR of*) AND "R E M"`), Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), - Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"), - Entry("does not collapse single standalone letter", "A test", "A* AND test*"), + Entry("does not collapse single letter surrounded by words", "I am fine", "(I OR I*) AND (am OR am*) AND (fine OR fine*)"), + Entry("does not collapse single standalone letter", "A test", "(A OR A*) AND (test OR test*)"), Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), @@ -57,6 +58,20 @@ var _ = DescribeTable("buildFTS5Query", Entry("returns empty string for empty quoted phrase", `""`, ""), ) +var _ = DescribeTable("buildFTS5Query degraded flag", + func(input string, expected bool) { + _, degraded := buildFTS5Query(input) + Expect(degraded).To(Equal(expected)) + }, + Entry("plain words are not degraded", "beatles", false), + Entry("special chars stripped leaving short token is degraded", "1+", true), + Entry("multiple short tokens are degraded", "1+ 2+", true), + Entry("short tokens mixed with a long word are not degraded", "1+ beatles", false), + Entry("quoted short-token phrase is degraded", `"1+"`, true), + Entry("punctuated-name group is not degraded", "AC/DC", false), + Entry("empty input is not degraded", "", false), +) + var _ = DescribeTable("ftsQueryDegraded", func(original, ftsQuery string, expected bool) { Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected)) @@ -143,7 +158,7 @@ var _ = Describe("ftsColumnDefs helpers", func() { It("returns weight CSV for artist", func() { Expect(ftsBM25Weights).To(HaveKeyWithValue("artist", - "10.0, 1.0, 1.0", + "10.0, 1.0, 10.0", )) }) @@ -237,18 +252,18 @@ var _ = Describe("newFTSSearch", func() { Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank")) }) - It("wraps query with column filter for known tables", func() { + It("wraps query with column filter", func() { strategy := newFTSSearch("artist", "Beatles") fts, ok := strategy.(*ftsSearch) Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) + Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : ((Beatles OR Beatles*))")) }) It("passes query without column filter for unknown tables", func() { strategy := newFTSSearch("unknown_table", "test") fts, ok := strategy.(*ftsSearch) Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(Equal("test*")) + Expect(fts.matchExpr).To(Equal("(test OR test*)")) }) It("preserves phrase queries inside column filter", func() { @@ -425,4 +440,38 @@ var _ = Describe("FTS5 Integration Search", func() { Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) }) + + Describe("Exact-match ranking", func() { + BeforeEach(func() { + // Registered before the inserts so a mid-loop failure cannot leak corpus rows. + DeferCleanup(func() { + // library_artist rows are removed by the artist_id ON DELETE CASCADE FK. + _, err := GetDBXBuilder().NewQuery("DELETE FROM artist WHERE id LIKE 'fts-rank-%'").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + // Corpus has no competing exact-word names ("Mo X"): exact-vs-exact order depends + // on corpus statistics; the guaranteed property is exact > prefix. + for _, a := range []model.Artist{ + {ID: "fts-rank-1", Name: "MØ", OrderArtistName: "mø"}, + {ID: "fts-rank-2", Name: "Modest Mouse", OrderArtistName: "modest mouse"}, + {ID: "fts-rank-3", Name: "Morrissey", OrderArtistName: "morrissey"}, + } { + Expect(createArtistWithLibrary(arr, &a, 1)).To(Succeed()) + } + }) + + It("ranks the exact transliterated match first for 'MO'", func() { + results, err := arr.Search("MO", model.QueryOptions{Max: 10}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Name).To(Equal("MØ"), "exact match via search_normalized must outrank prefix matches") + }) + + It("ranks the exact match first for the accented query 'MØ'", func() { + results, err := arr.Search("MØ", model.QueryOptions{Max: 10}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + Expect(results[0].Name).To(Equal("MØ")) + }) + }) }) From d4387c550279cf9fcb8f3f5614f694aaa9b3b165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 3 Jul 2026 08:58:55 -0400 Subject: [PATCH 336/596] perf(db): index media_file album/artist sort orders (#5706) * perf(db): add composite indexes for song list album/artist sorts The media_file sort mappings for album, artist and albumArtist expand to multi-column ORDER BY clauses that no existing index could satisfy, so SQLite fell back to a full table scan plus a temp B-tree sort of every row (including the large lyrics/tags/full_text columns) even for a single 15-item page. On a 96K-track library this made /api/song?_sort=album take 3.6s on a cold cache. Add composite indexes matching the three sort mappings, allowing the query to walk the index and stop at the page size, in both directions. Drop the now redundant single-column order_album_name/order_artist_name indexes (strict prefixes of the new composites) and three indexes with no query path: birth_time is only read in Go code, and artist/album_artist text column lookups go through the media_file_artists table instead. * fix(ui): make composer and track number columns non-sortable in song list Clicking the Composer header was a silent no-op: composer is not a media_file column, so the native API's sanitizeSort drops the sort and returns rows in table order. Track number sorting across the whole library is not meaningful and cannot use an index (the existing index leads with disc_number). Mark both columns sortable={false}, like quality and mood. * test(persistence): add sort index coverage test for large tables Guard against sort options silently losing index support: every sort mapping on media_file, album and artist is now verified with EXPLAIN QUERY PLAN to be satisfiable by an index (both directions), so adding a mapping or dropping an index that reintroduces a full-table temp B-tree sort fails the test. Sorts that genuinely cannot use an index (random, annotation-join columns, JSON expressions) must be declared in an exceptions list with the reason, keeping the trade-off visible in review. To make the sort mappings the complete declared sort surface, add identity mappings for the media_file columns the UI sorts by without a mapping (year, genre, duration, channels, bpm, path, comment, play_count, play_date, rating). These are behaviorally no-ops: the same ORDER BY was previously produced by the field whitelist fallback. * perf(db): drop PreferSortTags expression indexes from media_file The media_file sort_title/sort_artist_name/sort_album_name expression indexes are only usable when PreferSortTags is enabled - a config reported by ~0.1% of installations (insights, week of 2026-06-22) - yet every install pays their storage (~8.6MB on a 96K-track library) and scanner write overhead. Drop them: PreferSortTags installs fall back to a full sort for title/artist/album orders, everyone else gets smaller DBs and cheaper writes. The order_album_name and order_artist_name collation checks remain valid, now satisfied by the composite sort indexes. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- ...13908_optimize_media_file_sort_indexes.sql | 56 ++++++ persistence/collation_test.go | 3 - persistence/mediafile_repository.go | 10 ++ persistence/sort_index_coverage_test.go | 168 ++++++++++++++++++ ui/src/song/SongList.jsx | 6 +- 5 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 db/migrations/20260703013908_optimize_media_file_sort_indexes.sql create mode 100644 persistence/sort_index_coverage_test.go diff --git a/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql b/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql new file mode 100644 index 000000000..dd36bf4c2 --- /dev/null +++ b/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- +goose StatementBegin + +-- Composite indexes matching the media_file sort mappings for album, artist and +-- albumArtist. Without them, SQLite cannot satisfy the multi-column ORDER BY and +-- falls back to a full scan + temp B-tree sort of the whole table (including all +-- its large columns) even for a small LIMIT. +create index if not exists media_file_album_sort + on media_file(order_album_name, album_id, disc_number, track_number, order_artist_name, title); +create index if not exists media_file_artist_sort + on media_file(order_artist_name, order_album_name, release_date, disc_number, track_number); +create index if not exists media_file_album_artist_sort + on media_file(order_album_artist_name, order_album_name, release_date, disc_number, track_number); + +-- These two are strict prefixes of the composites above, so they are redundant now. +drop index if exists media_file_order_album_name; +drop index if exists media_file_order_artist_name; + +-- No query filters or sorts on these columns: birth_time is only read in Go code; +-- artist/album_artist conditions go through the media_file_artists table. +drop index if exists media_file_birth_time; +drop index if exists media_file_artist; +drop index if exists media_file_album_artist; + +-- These expression indexes are only usable when PreferSortTags is enabled, a +-- config used by ~0.1% of installations (per insights), yet they are maintained +-- on every write of every install. Dropping them means those installs fall back +-- to a full sort; everyone else saves the space and the scanner write overhead. +drop index if exists media_file_sort_title; +drop index if exists media_file_sort_artist_name; +drop index if exists media_file_sort_album_name; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop index if exists media_file_album_sort; +drop index if exists media_file_artist_sort; +drop index if exists media_file_album_artist_sort; + +create index if not exists media_file_order_album_name + on media_file(order_album_name); +create index if not exists media_file_order_artist_name + on media_file(order_artist_name); +create index if not exists media_file_birth_time + on media_file(birth_time); +create index if not exists media_file_artist + on media_file(artist); +create index if not exists media_file_album_artist + on media_file(album_artist); +create index if not exists media_file_sort_title + on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE); +create index if not exists media_file_sort_artist_name + on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE); +create index if not exists media_file_sort_album_name + on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE); +-- +goose StatementEnd diff --git a/persistence/collation_test.go b/persistence/collation_test.go index bb1276577..dff91148e 100644 --- a/persistence/collation_test.go +++ b/persistence/collation_test.go @@ -50,9 +50,6 @@ var _ = Describe("Collation", func() { 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 nocase"), Entry("radio.name", "radio", "name collate nocase"), diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 094268783..b4979ca77 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -91,6 +91,16 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile "recently_added": mediaFileRecentlyAddedSort(), "starred_at": "starred, starred_at", "rated_at": "rating, rated_at", + "year": "year", + "genre": "genre", + "duration": "duration", + "channels": "channels", + "bpm": "bpm", + "path": "path", + "comment": "comment", + "play_count": "play_count", + "play_date": "play_date", + "rating": "rating", }) return r } diff --git a/persistence/sort_index_coverage_test.go b/persistence/sort_index_coverage_test.go new file mode 100644 index 000000000..b5dea231d --- /dev/null +++ b/persistence/sort_index_coverage_test.go @@ -0,0 +1,168 @@ +package persistence + +import ( + "context" + "database/sql" + "fmt" + "maps" + "regexp" + "slices" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// These tests guard against sort options silently losing index support: adding or +// changing a sort mapping, or dropping/renaming an index in a migration, must not +// reintroduce full-table temp B-tree sorts on the large tables. Those are +// catastrophic on big libraries but invisible on dev-sized ones, which is how the +// unindexed album/artist song sorts went unnoticed for years. +// +// Every sort mapping is checked automatically: the real ORDER BY is built via +// buildSortOrder (both directions) and verified with EXPLAIN QUERY PLAN against +// the migrated test schema. The planner's choice is deterministic even on an +// empty table. A sort passes when the plan has no full "USE TEMP B-TREE FOR +// ORDER BY" step; an incremental sort of tie groups ("... FOR LAST TERM OF ORDER +// BY") is fine, as it only sorts rows with equal leading columns. +// +// A new sort mapping therefore fails this test until a matching index is created. +// The only escape hatch is exceptions, for sorts that genuinely cannot be +// served by a table index (random, annotation-join columns, JSON expressions): +// declaring one requires writing down the reason, making the trade-off visible in +// review. The checks run with the default config: PreferSortTags=true rewrites +// mappings to coalesce expressions with no matching indexes (used by ~0.1% of +// installations, per insights), and is out of scope here. +var _ = Describe("Sort index coverage", func() { + conn := db.Db() + + type repoCase struct { + table string + newRepo func(ctx context.Context) *sqlRepository + // sort mapping -> reason it cannot be served by an index + exceptions map[string]string + } + + cases := []repoCase{ + { + table: "media_file", + newRepo: func(ctx context.Context) *sqlRepository { + return &NewMediaFileRepository(ctx, GetDBXBuilder()).(*mediaFileRepository).sqlRepository + }, + exceptions: map[string]string{ + "random": "not a column sort", + "starred_at": "sorts on annotation join columns", + "rated_at": "sorts on annotation join columns", + "play_count": "sorts on annotation join columns", + "play_date": "sorts on annotation join columns", + "rating": "sorts on annotation join columns", + "comment": "UI-sortable but rarely used; not worth an index", + }, + }, + { + table: "album", + newRepo: func(ctx context.Context) *sqlRepository { + return &NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository).sqlRepository + }, + exceptions: map[string]string{ + "random": "not a column sort", + "starred_at": "sorts on annotation join columns", + "rated_at": "sorts on annotation join columns", + "max_year": "coalesce expression over original_date/max_year, no expression index", + }, + }, + { + table: "artist", + newRepo: func(ctx context.Context) *sqlRepository { + return &NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository).sqlRepository + }, + exceptions: map[string]string{ //nolint:gosec // G101 false positive, same as the artist sortMappings + "starred_at": "sorts on annotation join columns", + "rated_at": "sorts on annotation join columns", + "song_count": "JSON expression over stats column", + "album_count": "JSON expression over stats column", + "size": "JSON expression over stats column", + "maincredit_song_count": "aggregate over JSON stats", + "maincredit_album_count": "aggregate over JSON stats", + "maincredit_size": "aggregate over JSON stats", + }, + }, + } + + newCtx := func() context.Context { + ctx := log.NewContext(GinkgoT().Context()) + return request.WithUser(ctx, model.User{ID: "userid"}) + } + + for _, c := range cases { + It(fmt.Sprintf("uses an index for every sort mapping on %s", c.table), func() { + r := c.newRepo(newCtx()) + for _, sort := range slices.Sorted(maps.Keys(r.sortMappings)) { + if _, ok := c.exceptions[sort]; ok { + continue + } + for _, dir := range []string{"asc", "desc"} { + orderBy := r.buildSortOrder(sort, dir) + Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(), + "sort %q (%s) on table %q needs an index. Create one matching its ORDER BY, or, if it cannot be served by an index, add it to exceptions with the reason", + sort, dir, c.table) + } + } + }) + + It(fmt.Sprintf("has no stale exceptions entries for %s", c.table), func() { + r := c.newRepo(newCtx()) + for _, sort := range slices.Sorted(maps.Keys(c.exceptions)) { + Expect(r.sortMappings).To(HaveKey(sort), + "exceptions entry %q on table %q does not match any sort mapping - remove it", sort, c.table) + } + }) + } + + It("uses an index for recently_added when RecentlyAddedByModTime is enabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.RecentlyAddedByModTime = true + for _, c := range cases[:2] { // media_file and album + r := c.newRepo(newCtx()) + for _, dir := range []string{"asc", "desc"} { + orderBy := r.buildSortOrder("recently_added", dir) + Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(), + "sort recently_added (%s) on table %q", dir, c.table) + } + } + }) +}) + +// Matches the full-sort step only: incremental tie-group sorts are reported as +// "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (or "LAST N TERMS") and are allowed. +var fullTempBTreeSort = regexp.MustCompile(`USE TEMP B-TREE FOR ORDER BY`) + +func checkSortUsesIndex(conn *sql.DB, table, orderBy string) error { + rows, err := conn.Query(fmt.Sprintf("explain query plan select * from %s order by %s limit 15", table, orderBy)) + if err != nil { + return fmt.Errorf("explain query plan failed for order by %q: %w", orderBy, err) + } + defer rows.Close() + + var details []string + for rows.Next() { + var id, parent, notUsed int + var detail string + if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { + return err + } + details = append(details, detail) + } + if err := rows.Err(); err != nil { + return err + } + if slices.ContainsFunc(details, fullTempBTreeSort.MatchString) { + return fmt.Errorf("no index satisfies ORDER BY %s - plan: %v", orderBy, details) + } + return nil +} diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index d928af549..d44992d0c 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -143,9 +143,11 @@ const SongList = (props) => { return { album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />, artist: <ArtistLinkField source="artist" />, - composer: <ArtistLinkField source="composer" />, + composer: <ArtistLinkField source="composer" sortable={false} />, albumArtist: <ArtistLinkField source="albumArtist" />, - trackNumber: isDesktop && <NumberField source="trackNumber" />, + trackNumber: isDesktop && ( + <NumberField source="trackNumber" sortable={false} /> + ), playCount: isDesktop && ( <NumberField source="playCount" sortByOrder={'DESC'} /> ), From 89aa58a7137f6dbe6e750ae439902c0beb5d0a39 Mon Sep 17 00:00:00 2001 From: Patrick <52888461+draconivis@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:53:57 +0200 Subject: [PATCH 337/596] feat(ui): add rose pine themes (#5664) * feat(theme): add rose pine themes fix checkboxes * feat(theme): apply review suggestions * feat(theme): fix toolbar background in mobile view * feat(theme): small css improvements --- ui/src/themes/index.js | 6 + ui/src/themes/rosePine.css.js | 148 ++++++++++++++++++++++ ui/src/themes/rosePine.js | 108 ++++++++++++++++ ui/src/themes/rosePineDawn.css.js | 198 ++++++++++++++++++++++++++++++ ui/src/themes/rosePineDawn.js | 108 ++++++++++++++++ ui/src/themes/rosePineMoon.css.js | 148 ++++++++++++++++++++++ ui/src/themes/rosePineMoon.js | 108 ++++++++++++++++ 7 files changed, 824 insertions(+) create mode 100644 ui/src/themes/rosePine.css.js create mode 100644 ui/src/themes/rosePine.js create mode 100644 ui/src/themes/rosePineDawn.css.js create mode 100644 ui/src/themes/rosePineDawn.js create mode 100644 ui/src/themes/rosePineMoon.css.js create mode 100644 ui/src/themes/rosePineMoon.js diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index f79a6a999..98705da30 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -13,6 +13,9 @@ import CatppuccinLatteTheme from './catppuccinLatte' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' import NutballTheme from './nutball' +import RosePineTheme from './rosePine' +import RosePineDawnTheme from './rosePineDawn' +import RosePineMoonTheme from './rosePineMoon' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' @@ -43,6 +46,9 @@ export default { NordTheme, NuclearTheme, NutballTheme, + RosePineDawnTheme, + RosePineMoonTheme, + RosePineTheme, SpotifyTheme, SquiddiesGlassTheme, TokyoNightLightTheme, diff --git a/ui/src/themes/rosePine.css.js b/ui/src/themes/rosePine.css.js new file mode 100644 index 000000000..a1c21f562 --- /dev/null +++ b/ui/src/themes/rosePine.css.js @@ -0,0 +1,148 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #c4a7e7 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #ebbcba +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #ebbcba; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #ebbcba +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #ebbcba +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #ebbcba !important +} + +.react-jinke-music-player-main .loading svg { + color: #ebbcba !important +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: none; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #1f1d2e; + color: #e0def4; + box-shadow: 0 0 8px rgba(25, 23, 36, 0.35); +} + +.audio-lists-panel { + background-color: #1f1d2e; + bottom: 6.25rem; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #908caa; + -webkit-text-stroke: 0.5px #191724; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #908caa !important; +} + +.audio-lists-panel-header { + border-bottom:1px solid #26233a; + box-shadow:none; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #ebbcba +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #ebbcba +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #26233a; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + background-color: #1f1d2e; + border-color: #1f1d2e; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; + color: #ebbcba; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(196,167,231,.3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #ebbcba; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/rosePine.js b/ui/src/themes/rosePine.js new file mode 100644 index 000000000..547a1f764 --- /dev/null +++ b/ui/src/themes/rosePine.js @@ -0,0 +1,108 @@ +import stylesheet from './rosePine.css.js' + +export default { + themeName: 'Rosé Pine', + palette: { + primary: { + main: '#ebbcba', + }, + secondary: { + main: '#1f1d2e', + contrastText: '#e0def4', + }, + type: 'dark', + background: { + default: '#191724', + paper: '#1f1d2e', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e0def4', + backgroundColor: '#1f1d2e', + }, + }, + MuiButton: { + textPrimary: { + color: '#31748f', + }, + textSecondary: { + color: '#e0def4', + }, + }, + MuiIconButton: { + colorSecondary: { + color: '#6e6a86', + }, + }, + MuiChip: { + clickable: { + background: '#26233a', + }, + }, + MuiCheckbox: { + colorSecondary: { + color: '#6e6a86', + '&$checked': { + color: '#ebbcba', + }, + }, + }, + MuiFormGroup: { + root: { + color: '#e0def4', + }, + }, + MuiFormHelperText: { + root: { + '&$error': { + color: '#eb6f92', + }, + }, + }, + MuiTableHead: { + root: { + color: '#e0def4', + background: '#1f1d2e', + }, + }, + MuiTableCell: { + root: { + color: '#e0def4', + background: '#1f1d2e !important', + }, + head: { + color: '#e0def4', + background: '#1f1d2e !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#ebbcba', + }, + icon: {}, + welcome: { + color: '#e0def4', + }, + card: { + minWidth: 300, + background: '#191724', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px rgba(25, 23, 36, 0.35)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(25, 23, 36, 0.72), rgb(25, 23, 36))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/rosePineDawn.css.js b/ui/src/themes/rosePineDawn.css.js new file mode 100644 index 000000000..e3c882815 --- /dev/null +++ b/ui/src/themes/rosePineDawn.css.js @@ -0,0 +1,198 @@ +const stylesheet = ` + .react-jinke-music-player-main.light-theme svg, + .react-jinke-music-player .music-player-controller, + .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { + color: #797593; + stroke: #797593; + } + + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #907aa9; + } + + .react-jinke-music-player-main.light-theme svg:active, + .react-jinke-music-player-main.light-theme svg:hover { + color: #907aa9; + } + + .react-jinke-music-player-mobile-play-model-tip, + .react-jinke-music-player-main.light-theme .play-mode-title { + background-color: #d7827e; + color: #faf4ed; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #d7827e; + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #d7827e; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #d7827e; + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #d7827e; + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #d7827e !important; + } + + .react-jinke-music-player-main .loading svg { + color: #d7827e !important; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: none; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .rc-slider-rail, + .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #d7827e !important; + border: 1px solid #d7827e; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #fffaf3; + color: #464261; + box-shadow: 0 0 8px rgba(70, 66, 97, 0.12); + } + + .react-jinke-music-player-main.light-theme .music-player-panel { + color: #464261; + } + + .audio-lists-panel { + background-color: #fffaf3; + bottom: 6.25rem; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-header { + border-bottom: 1px solid #f2e9e1; + box-shadow: none; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; + } + + .react-jinke-music-player-main.light-theme .audio-lists-panel-header { + background-color: #fffaf3; + color: #464261; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + color: #464261; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #797593; + -webkit-text-stroke: 0.35px #faf4ed; + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #797593 !important; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #d7827e; + } + + .audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, + .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #d7827e; + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #f2e9e1; + } + + /* Mobile */ + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + background-color: #fffaf3; + border-color: #fffaf3; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + color: #d7827e; + } + + .react-jinke-music-player .music-player-controller.music-player-playing:before { + border: 1px solid rgba(70, 66, 97, 0.18); + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + background: rgba(215, 130, 126, 0.2); + color: #faf4ed; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, + .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #d7827e; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/rosePineDawn.js b/ui/src/themes/rosePineDawn.js new file mode 100644 index 000000000..aea903c1a --- /dev/null +++ b/ui/src/themes/rosePineDawn.js @@ -0,0 +1,108 @@ +import stylesheet from './rosePineDawn.css.js' + +export default { + themeName: 'Rosé Pine Dawn', + palette: { + primary: { + main: '#d7827e', + }, + secondary: { + main: '#fffaf3', + contrastText: '#464261', + }, + type: 'light', + background: { + default: '#faf4ed', + paper: '#fffaf3', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#464261', + backgroundColor: '#fffaf3', + }, + }, + MuiButton: { + textPrimary: { + color: '#286983', + }, + textSecondary: { + color: '#464261', + }, + }, + MuiIconButton: { + colorSecondary: { + color: '#9893a5', + }, + }, + MuiChip: { + clickable: { + background: '#f2e9e1', + }, + }, + MuiCheckbox: { + colorSecondary: { + color: '#9893a5', + '&$checked': { + color: '#d7827e', + }, + }, + }, + MuiFormGroup: { + root: { + color: '#464261', + }, + }, + MuiFormHelperText: { + root: { + '&$error': { + color: '#b4637a', + }, + }, + }, + MuiTableHead: { + root: { + color: '#464261', + background: '#fffaf3', + }, + }, + MuiTableCell: { + root: { + color: '#464261', + background: '#fffaf3 !important', + }, + head: { + color: '#464261', + background: '#fffaf3 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#d7827e', + }, + icon: {}, + welcome: { + color: '#464261', + }, + card: { + minWidth: 300, + background: '#faf4ed', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px rgba(87, 82, 121, 0.12)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(250, 244, 237, 0.72), rgb(250, 244, 237))!important', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/rosePineMoon.css.js b/ui/src/themes/rosePineMoon.css.js new file mode 100644 index 000000000..0eb7aaf57 --- /dev/null +++ b/ui/src/themes/rosePineMoon.css.js @@ -0,0 +1,148 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #c4a7e7 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #ea9a97 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #ea9a97; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #ea9a97 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #ea9a97 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #ea9a97 !important +} + +.react-jinke-music-player-main .loading svg { + color: #ea9a97 !important +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: none; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #2a273f; + color: #e0def4; + box-shadow: 0 0 8px rgba(35, 33, 54, 0.35); +} + +.audio-lists-panel { + background-color: #2a273f; + bottom: 6.25rem; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #908caa; + -webkit-text-stroke: 0.5px #232136; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #908caa !important; +} + +.audio-lists-panel-header { + border-bottom:1px solid #393552; + box-shadow:none; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #ea9a97 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #ea9a97 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #393552; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + background-color: #2a273f; + border-color: #2a273f; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; + color: #ea9a97; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(196,167,231,.3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #ea9a97; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/rosePineMoon.js b/ui/src/themes/rosePineMoon.js new file mode 100644 index 000000000..facf09446 --- /dev/null +++ b/ui/src/themes/rosePineMoon.js @@ -0,0 +1,108 @@ +import stylesheet from './rosePineMoon.css.js' + +export default { + themeName: 'Rosé Pine Moon', + palette: { + primary: { + main: '#ea9a97', + }, + secondary: { + main: '#2a273f', + contrastText: '#e0def4', + }, + type: 'dark', + background: { + default: '#232136', + paper: '#2a273f', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e0def4', + backgroundColor: '#2a273f', + }, + }, + MuiButton: { + textPrimary: { + color: '#3e8fb0', + }, + textSecondary: { + color: '#e0def4', + }, + }, + MuiIconButton: { + colorSecondary: { + color: '#6e6a86', + }, + }, + MuiChip: { + clickable: { + background: '#393552', + }, + }, + MuiCheckbox: { + colorSecondary: { + color: '#6e6a86', + '&$checked': { + color: '#ea9a97', + }, + }, + }, + MuiFormGroup: { + root: { + color: '#e0def4', + }, + }, + MuiFormHelperText: { + root: { + '&$error': { + color: '#eb6f92', + }, + }, + }, + MuiTableHead: { + root: { + color: '#e0def4', + background: '#2a273f', + }, + }, + MuiTableCell: { + root: { + color: '#e0def4', + background: '#2a273f !important', + }, + head: { + color: '#e0def4', + background: '#2a273f !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#ea9a97', + }, + icon: {}, + welcome: { + color: '#e0def4', + }, + card: { + minWidth: 300, + background: '#232136', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px rgba(35, 33, 54, 0.35)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(35, 33, 54, 0.72), rgb(35, 33, 54))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} From 37e75c435402f413ff72e3a61cbf222ff6c86b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 4 Jul 2026 19:55:34 -0400 Subject: [PATCH 338/596] feat(sharing): enable sharing by default (#5714) Flip the EnableSharing default from false to true so new installations have the sharing feature available out of the box. Users can still disable it via the EnableSharing config option. The native API only registers the /share route when sharing is enabled, so the nativeapi tests that build the router without wiring a share service now explicitly disable sharing in their setup to avoid registering a route backed by a nil service. --- conf/configuration.go | 2 +- server/nativeapi/config_test.go | 1 + server/nativeapi/library_test.go | 2 ++ server/nativeapi/native_api_song_test.go | 1 + server/nativeapi/playlists_test.go | 1 + server/nativeapi/plugin_test.go | 1 + 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/conf/configuration.go b/conf/configuration.go index 17abfe400..8646bf075 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -794,7 +794,7 @@ func setViperDefaults() { viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) - viper.SetDefault("enablesharing", false) + viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) viper.SetDefault("defaultdownloadableshare", false) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 4e6e9e89b..107b01e01 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -25,6 +25,7 @@ var _ = Describe("Config API", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index ed5564a41..9b7061845 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -27,6 +28,7 @@ var _ = Describe("Library API", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index f0ee50ebb..b1ed09d65 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -32,6 +32,7 @@ var _ = Describe("Song Endpoints", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute // Setup mock repositories diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index e1c933709..9bf502687 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -76,6 +76,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute plsSvc = &mockPlaylistsService{} diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 8fc88e09c..aa91a7951 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -29,6 +29,7 @@ var _ = Describe("Plugin API", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.Plugins.Enabled = true ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} From 4f6afbbe671611f4d77db2eac79fd4036d7536e1 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 4 Jul 2026 21:38:01 -0400 Subject: [PATCH 339/596] fix(ci): pin GoReleaser version to 2.16.0 Signed-off-by: Deluan <deluan@navidrome.org> --- .github/workflows/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8d3cd3ad0..8a01dc028 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -491,7 +491,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: - version: '~> v2' + version: '2.16.0' args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e3297831125d49593123d0c93cb5cb928e3635ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 5 Jul 2026 00:15:59 -0400 Subject: [PATCH 340/596] fix(build): derive version from reachable git tag (#5711) * fix(build): derive version from reachable git tag * fix(build): fall back gracefully when no tag is reachable git describe --tags --abbrev=0 exits with an error when the checkout has no reachable tag (e.g. tagless forks or pre-first-tag commits). In the Makefile this printed a fatal message and produced a bare -SNAPSHOT version, and in the CI git-version step the non-zero exit would abort the job under bash -e before the empty-tag guard could run. Silence stderr and fall back to v0.0.0 in the Makefile, and to an empty string in the workflow so the existing guard keeps skipping the output as it did before. --- .github/workflows/pipeline.yml | 4 ++-- Makefile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8a01dc028..228bac9e7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -32,7 +32,7 @@ jobs: - name: Show git version info run: | echo "git describe (dirty): $(git describe --dirty --always --tags)" - echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)" + echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)" echo "git tag: $(git tag --sort=-committerdate | head -n 1)" echo "github_ref: $GITHUB_REF" echo "github_head_sha: ${{ github.event.pull_request.head.sha }}" @@ -40,7 +40,7 @@ jobs: - name: Determine git current SHA and latest tag id: git-version run: | - GIT_TAG=$(git tag --sort=-committerdate | head -n 1) + GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) if [ -n "$GIT_TAG" ]; then if [[ "$GITHUB_REF" != refs/tags/* ]]; then GIT_TAG=${GIT_TAG}-SNAPSHOT diff --git a/Makefile b/Makefile index 90a405de8..fa0d10475 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ export ND_ENABLEINSIGHTSCOLLECTOR=false ifneq ("$(wildcard .git/HEAD)","") GIT_SHA=$(shell git rev-parse --short HEAD) -GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT +GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT else GIT_SHA=source_archive GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT From b2b4fc19433c522f0806c6801482ecae71fbb4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 6 Jul 2026 22:32:43 -0400 Subject: [PATCH 341/596] fix(ui): update Turkish translations from POEditor (#5685) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/tr.json | 97 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index d1fdb2ed4..ff387aff8 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -36,7 +36,11 @@ "bitDepth": "Bit derinliği", "sampleRate": "Örnekleme Oranı", "missing": "Eksik", - "libraryName": "Kütüphane" + "libraryName": "Kütüphane", + "composer": "Besteci", + "disc": "Disk %{discNumber}", + "albumGain": "Albüm Kazancı", + "trackGain": "Parça Kazancı" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -46,7 +50,8 @@ "download": "İndir", "playNext": "Dinlenenden Sonra Oynat", "info": "Bilgiler", - "showInPlaylist": "Çalma Listesinde Göster" + "showInPlaylist": "Çalma Listesinde Göster", + "instantMix": "Anında Karışım" } }, "album": { @@ -328,6 +333,82 @@ "scanInProgress": "Tarama devam ediyor...", "noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı" } + }, + "plugin": { + "name": "Eklenti |||| Eklentiler", + "fields": { + "id": "Kimlik", + "name": "Ad", + "description": "Açıklama", + "version": "Sürüm", + "author": "Geliştirici", + "website": "Web Sitesi", + "permissions": "İzinler", + "enabled": "Etkin", + "status": "Durum", + "path": "Yol", + "lastError": "Son Hata", + "hasError": "Hata", + "updatedAt": "Güncellendi", + "createdAt": "Yüklendi", + "configKey": "Anahtar", + "configValue": "Değer", + "allUsers": "Tüm Kullanıcılara İzin Ver", + "selectedUsers": "Seçili Kullanıcılar", + "allLibraries": "Tüm Kütüphanelere İzin Ver", + "selectedLibraries": "Seçili Kütüphaneler", + "allowWriteAccess": "Yazma Erişimine İzin Ver" + }, + "sections": { + "status": "Durum", + "info": "Eklenti Bilgileri", + "configuration": "Yapılandırma", + "manifest": "Manifest", + "usersPermission": "Kullanıcı İzinleri", + "libraryPermission": "Kütüphane İzinleri" + }, + "status": { + "enabled": "Etkin", + "disabled": "Devre Dışı" + }, + "actions": { + "enable": "Etkinleştir", + "disable": "Devre Dışı Bırak", + "disabledDueToError": "Etkinleştirmeden Önce Hatayı Düzeltin", + "disabledUsersRequired": "Etkinleştirmeden Önce Kullanıcı Seçin", + "disabledLibrariesRequired": "Etkinleştirmeden Önce Kütüphane Seçin", + "addConfig": "Yapılandırma Ekle", + "rescan": "Yeniden Tara" + }, + "notifications": { + "enabled": "Eklenti etkinleştirildi", + "disabled": "Eklenti devre dışı bırakıldı", + "updated": "Eklenti güncellendi", + "error": "Eklenti güncellenirken hata oluştu" + }, + "validation": { + "invalidJson": "Yapılandırma geçerli bir JSON olmalı" + }, + "messages": { + "configHelp": "Eklentiyi anahtar-değer çiftleriyle yapılandırın. Eklenti yapılandırma gerektirmiyorsa boş bırakın.", + "clickPermissions": "Ayrıntıları görmek için bir izne tıklayın", + "noConfig": "Yapılandırma ayarlanmamış", + "allUsersHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kullanıcılara erişebilir.", + "noUsers": "Kullanıcı seçilmedi", + "permissionReason": "Gerekçe", + "usersRequired": "Bu eklenti kullanıcı bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kullanıcıları seçin veya 'Tüm Kullanıcılara İzin Ver' seçeneğini etkinleştirin.", + "allLibrariesHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kütüphanelere erişebilir.", + "noLibraries": "Kütüphane seçilmedi", + "librariesRequired": "Bu eklenti kütüphane bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kütüphaneleri seçin veya 'Tüm Kütüphanelere İzin Ver' \nseçeneğini etkinleştirin.", + "requiredHosts": "Gerekli Sunucular", + "configValidationError": "Yapılandırma doğrulanamadı:", + "schemaRenderError": "Yapılandırma formu oluşturulamadı. Eklentinin şeması geçersiz olabilir.", + "allowWriteAccessHelp": "Etkinleştirildiğinde eklenti, kütüphane dizinlerindeki dosyaları değiştirebilir. Eklentiler varsayılan olarak salt okunur erişime sahiptir." + }, + "placeholders": { + "configKey": "anahtar", + "configValue": "değer" + } } }, "ra": { @@ -511,7 +592,14 @@ "remove_all_missing_title": "Tüm eksik dosyaları kaldırın", "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.", "noSimilarSongsFound": "Benzer şarkı bulunamadı", - "noTopSongsFound": "En iyi şarkı listesi boş" + "noTopSongsFound": "En iyi şarkı listesi boş", + "startingInstantMix": "Anında Karışım yükleniyor...", + "uploadCover": "Kapak Görseli Yükle", + "removeCover": "Kapak Görselini Kaldır", + "coverUploaded": "Kapak görseli güncellendi", + "coverRemoved": "Kapak görseli kaldırıldı", + "coverUploadError": "Kapak görseli yüklenirken hata oluştu", + "coverRemoveError": "Kapak görseli kaldırılırken hata oluştu" }, "menu": { "library": "Kütüphane", @@ -597,7 +685,8 @@ "exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı", "exportFailed": "Yapılandırma kopyalanamadı", "devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)", - "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir" + "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir", + "downloadToml": "Yapılandırmayı İndir (TOML)" } }, "activity": { From 6c95a66ad6e49632f2e9656d9bc2ca8008ed85e0 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 6 Jul 2026 22:34:46 -0400 Subject: [PATCH 342/596] chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 16 ++++++++-------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 4803c23ee..71aabdcd7 100644 --- a/go.mod +++ b/go.mod @@ -19,10 +19,10 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.6.3 - github.com/go-chi/chi/v5 v5.3.0 + github.com/gen2brain/webp v0.6.4 + github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/cors v1.2.2 - github.com/go-chi/httprate v0.15.0 + github.com/go-chi/httprate v0.16.0 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 @@ -33,7 +33,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.4.1 - github.com/kardianos/service v1.2.4 + github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/mattn/go-sqlite3 v1.14.47 @@ -41,10 +41,10 @@ require ( github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 - github.com/pelletier/go-toml/v2 v2.4.2 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.1 + github.com/pressly/goose/v3 v3.27.2 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -64,7 +64,7 @@ require ( golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 - golang.org/x/text v0.38.0 + golang.org/x/text v0.39.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -96,7 +96,7 @@ require ( github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect diff --git a/go.sum b/go.sum index 1972e34c2..ec532b0a0 100644 --- a/go.sum +++ b/go.sum @@ -65,27 +65,27 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.6.3 h1:DbXXCkiHN6zq2qIuTsPSZQhVi2VcQ0UPzKbKqKENfsQ= -github.com/gen2brain/webp v0.6.3/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= +github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= +github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= -github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= +github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= @@ -134,14 +134,14 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= -github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= +github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -196,8 +196,8 @@ github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= -github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= -github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -206,8 +206,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/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/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= -github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -334,8 +334,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -356,11 +356,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= From f48943c05873dad7cab4e159747453a5d7677f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 8 Jul 2026 12:37:17 -0400 Subject: [PATCH 343/596] fix(plugins): discard buffered scrobbles when a plugin is removed (#5737) * fix(plugins): discard buffered scrobbles when a plugin is removed Scrobbles are buffered in the DB per service, keyed by the plugin name. When a plugin was removed (deleted from the plugins folder and detected by the sync), its pending buffer entries were left behind forever: the drain goroutine is stopped on the next scrobbler refresh, so the rows were never retried nor discarded. Worse, if a plugin with the same name was installed later, the stale entries would be drained into it - potentially a completely unrelated plugin that just reuses the name. Add a Discard(service) method to ScrobbleBufferRepository and call it from removePluginFromDB, right after the plugin record is deleted. Disabling a plugin intentionally keeps its buffered scrobbles, consistent with the buffer's purpose of surviving temporary outages, and transient unload/reload cycles during config updates are unaffected since they never delete the plugin record. * fix(plugins): don't wipe builtin scrobbler queues on plugin removal Buffer entries are keyed by service name only, and removePluginFromDB runs for any removed plugin file, so removing a plugin named e.g. lastfm.ndp - regardless of its capability - would discard the builtin Last.fm retry queue. Skip the discard when the plugin name is owned by a registered builtin scrobbler, exposed via a new scrobbler.IsBuiltinScrobbler helper. Reported by Codex review on the PR. Also drop the testBroker usage from the new removePluginFromDB spec: it is defined in manager_test.go which is excluded on Windows, breaking the Windows test build. sendPluginRefreshEvent is nil-safe, so no broker is needed. --- core/scrobbler/play_tracker.go | 7 +++ core/scrobbler/play_tracker_test.go | 7 +++ model/scrobble_buffer.go | 1 + persistence/scrobble_buffer_repository.go | 4 ++ .../scrobble_buffer_repository_test.go | 22 ++++++++ plugins/manager_sync.go | 11 ++++ plugins/manager_sync_test.go | 55 +++++++++++++++++++ tests/mock_scrobble_buffer_repo.go | 16 ++++++ 8 files changed, 123 insertions(+) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 860a80bce..a2d7e8639 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -491,3 +491,10 @@ func Register(name string, init Constructor) { } constructors[name] = init } + +// IsBuiltinScrobbler reports whether name belongs to a registered builtin +// scrobbler (e.g. "lastfm", "listenbrainz"). +func IsBuiltinScrobbler(name string) bool { + _, ok := constructors[name] + return ok +} diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index b5a478c2a..382abb811 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -104,6 +104,13 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) + Describe("IsBuiltinScrobbler", func() { + It("reports whether the name belongs to a registered builtin scrobbler", func() { + Expect(IsBuiltinScrobbler("fake")).To(BeTrue()) + Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse()) + }) + }) + Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index c75a82853..43ee2cc01 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -20,4 +20,5 @@ type ScrobbleBufferRepository interface { Next(service string, userId string) (*ScrobbleEntry, error) Dequeue(entry *ScrobbleEntry) error Length() (int64, error) + Discard(service string) error } diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index 3cfb836bf..cf54c664a 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -93,6 +93,10 @@ func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { return r.delete(Eq{"id": entry.ID}) } +func (r *scrobbleBufferRepository) Discard(service string) error { + return r.delete(Eq{"service": service}) +} + func (r *scrobbleBufferRepository) Length() (int64, error) { return r.count(Select()) } diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index edf59ce49..3aa71070e 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -191,6 +191,28 @@ var _ = Describe("ScrobbleBufferRepository", func() { }) + Describe("Discard", func() { + It("deletes all entries for a service, keeping other services intact", func() { + Expect(scrobble.Discard("a")).To(Succeed()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + entry, err := scrobble.Next("b", "2222") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil()) + }) + + It("is a no-op for a service without entries", func() { + Expect(scrobble.Discard("nonexistent")).To(Succeed()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) + }) + Describe("UserIds", func() { It("should return ordered list for services", func() { ids, err := scrobble.UserIDs("a") diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 23d904309..2119f1a5a 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -107,6 +108,16 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos if err := repo.Delete(pluginID); err != nil { return fmt.Errorf("deleting plugin from DB: %w", err) } + // Discard any scrobbles still buffered for the removed plugin, so they are + // not delivered to an unrelated plugin that reuses the same name later. + // Skip names owned by builtin scrobblers: buffer entries are keyed by + // service name, so removing a plugin file named e.g. "lastfm.ndp" must not + // wipe the builtin Last.fm retry queue. + if scrobbler.IsBuiltinScrobbler(pluginID) { + log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID) + } else if err := m.ds.ScrobbleBuffer(ctx).Discard(pluginID); err != nil { + log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, err) + } log.Info(ctx, "Plugin removed", "plugin", pluginID) m.sendPluginRefreshEvent(ctx, events.Any) return nil diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index e2adebfad..26da2079b 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -1,12 +1,67 @@ package plugins import ( + "context" "path/filepath" + "time" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("removePluginFromDB", func() { + It("discards buffered scrobbles for the removed plugin", func() { + ctx := context.Background() + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("my-plugin", "user1", "track1", time.Now())).To(Succeed()) + Expect(buffer.Enqueue("other-plugin", "user1", "track2", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "my-plugin", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + // No broker: sendPluginRefreshEvent is nil-safe, and testBroker is + // defined in manager_test.go, which is excluded on Windows. + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).To(MatchError(model.ErrNotFound)) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1))) + entry, err := buffer.Next("other-plugin", "user1") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil(), "entries of other services must be kept") + }) + + It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() { + ctx := context.Background() + scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("builtin-svc", "user1", "track1", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "builtin-svc", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped") + }) +}) + var _ = Describe("ComputeFileSHA256", func() { It("returns a consistent 64-char lowercase hex hash for the same file", func() { dir := GinkgoT().TempDir() diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 5865f423a..2eb5e8a93 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -83,6 +83,22 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { return nil } +func (m *MockedScrobbleBufferRepo) Discard(service string) error { + if m.Error != nil { + return m.Error + } + m.mu.Lock() + defer m.mu.Unlock() + newData := model.ScrobbleEntries{} + for _, e := range m.Data { + if e.Service != service { + newData = append(newData, e) + } + } + m.Data = newData + return nil +} + func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error From 4652b4660205abf38a484a2dc7856b4f6d5c24aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 8 Jul 2026 12:37:42 -0400 Subject: [PATCH 344/596] fix(plugins): populate username for buffered plugin scrobbles (#5736) Plugin scrobblers read the username from the request context via getUsernameFromContext, but buffered scrobbles are persisted to the DB scrobble buffer (which stores only the userId) and later drained by a background worker running on context.Background(). That context carries no authenticated user, so ScrobbleRequest.Username was always empty for WASM scrobbler plugins. NowPlaying is unaffected because it is dispatched synchronously on context.WithoutCancel(requestCtx), which retains the user. Restore the user in the drain path: processUserQueue now looks up the user by the buffered userId and injects it into the context via request.WithUser before dispatching, mirroring the pattern already used in play_tracker. Builtin scrobblers are unaffected as they resolve the account from the userId argument rather than the context. --- core/scrobbler/buffered_scrobbler.go | 9 +++++++++ core/scrobbler/buffered_scrobbler_test.go | 13 +++++++++++++ core/scrobbler/play_tracker_test.go | 17 +++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 67593e9eb..408ab410d 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // Loader is a function that loads a scrobbler by name. @@ -129,6 +130,14 @@ func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { } func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { + // Scrobbles are drained on a background context that no longer carries the + // request's authenticated user. Restore it from the buffered userId so that + // scrobblers relying on the user in the context (e.g. plugins) still get it. + if user, err := b.ds.User(ctx).Get(userId); err != nil { + log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err) + } else { + ctx = request.WithUser(ctx, *user) + } buffer := b.ds.ScrobbleBuffer(ctx) for { entry, err := buffer.Next(b.service, userId) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index 9fbca6f71..d11f0b003 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -20,8 +20,11 @@ var _ = Describe("BufferedScrobbler", func() { BeforeEach(func() { ctx = context.Background() buffer = tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{ MockedScrobbleBuffer: buffer, + MockedUser: userRepo, } scr = &fakeScrobbler{Authorized: true} bs = newBufferedScrobbler(ds, scr, "test") @@ -62,6 +65,16 @@ var _ = Describe("BufferedScrobbler", func() { Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now)) }) + It("restores the user in the context when draining buffered scrobbles", func() { + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + scrobble := Scrobble{MediaFile: track, TimeStamp: time.Now()} + + Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) + + Eventually(scr.ScrobbleCalled.Load).Should(BeTrue()) + Expect(scr.GetUsername()).To(Equal("alice")) + }) + It("stops the background goroutine when Stop is called", func() { // Replace the real run method with one that signals when it exits done := make(chan struct{}) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 382abb811..74b4be893 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -1098,6 +1098,13 @@ func (f *fakeScrobbler) GetUserID() string { return "" } +func (f *fakeScrobbler) GetUsername() string { + if p := f.username.Load(); p != nil { + return *p + } + return "" +} + func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } @@ -1129,6 +1136,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { f.userID.Store(&userId) + // Capture username from context (this is what plugin scrobblers do) + username, _ := request.UsernameFrom(ctx) + if username == "" { + if u, ok := request.UserFrom(ctx); ok { + username = u.UserName + } + } + if username != "" { + f.username.Store(&username) + } f.LastScrobble.Store(&s) f.ScrobbleCalled.Store(true) if f.Error != nil { From 42d4363f6135167b7fce5c8fd9070a25fab10172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 8 Jul 2026 16:40:07 -0400 Subject: [PATCH 345/596] fix(service): rewrite systemd service template for kardianos/service v1.3.0 (#5743) kardianos/service v1.3.0 (shipped in Navidrome 0.63.0) replaced Go's text/template with a small custom engine that uses bare keys ({{Description}}) instead of dotted fields ({{.Description}}). Our custom SystemdScript was still written in text/template syntax, so 'navidrome service install' failed with 'FATA service: unknown template key ".Description"', breaking the .deb postinst and leaving an empty/masked systemd unit. Rewrite the template in the new engine's syntax and add a test that validates every key and pipeline function used in the template against the set the library provides to systemd templates, so future engine/key drift is caught at test time. Verified end-to-end on Linux: the previous binary reproduces the reported fatal error and the fixed one generates a complete unit file. Fixes #5742 --- cmd/svc.go | 23 ++++++++++----------- cmd/svc_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 cmd/svc_test.go diff --git a/cmd/svc.go b/cmd/svc.go index cc8d6bb54..7fec708ff 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -232,22 +232,21 @@ func buildExecuteCmd() *cobra.Command { } const systemdScript = `[Unit] -Description={{.Description}} -ConditionFileIsExecutable={{.Path|cmdEscape}} -{{range $i, $dep := .Dependencies}} -{{$dep}} {{end}} - +Description={{Description}} +ConditionFileIsExecutable={{Path | cmdEscape}} +{{range Dependencies}}{{.}} +{{end}} [Service] StartLimitInterval=5 StartLimitBurst=10 -ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}} -{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}} -{{if .UserName}}User={{.UserName}}{{end}} -{{if .Restart}}Restart={{.Restart}}{{end}} -{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}} +ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}} +{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}} +{{if UserName}}User={{UserName}}{{end}} +{{if Restart}}Restart={{Restart}}{{end}} +{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}} TimeoutStopSec=20 RestartSec=120 -EnvironmentFile=-/etc/sysconfig/{{.Name}} +EnvironmentFile=-/etc/sysconfig/{{Name}} Environment="ND_SYSTEMD_PRIORITY_LOGGING=1" DevicePolicy=closed @@ -260,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap -{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}} +{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}} ProtectSystem=full [Install] diff --git a/cmd/svc_test.go b/cmd/svc_test.go new file mode 100644 index 000000000..7c34563b3 --- /dev/null +++ b/cmd/svc_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "regexp" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("systemdScript template", func() { + systemdKeys := map[string]bool{ + "Description": true, "Path": true, "Name": true, "Dependencies": true, + "Arguments": true, "ChRoot": true, "WorkingDirectory": true, + "UserName": true, "ReloadSignal": true, "PIDFile": true, + "LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true, + "Restart": true, "SuccessExitStatus": true, "EnvVars": true, + } + systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true} + + actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`) + + parseAction := func(action string) (key string, funcs []string) { + action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-")) + kw, rest, _ := strings.Cut(action, " ") + switch kw { + case "end", "else": + return "", nil + case "if", "range": + return strings.TrimSpace(rest), nil + } + parts := strings.Split(action, "|") + for _, p := range parts[1:] { + funcs = append(funcs, strings.TrimSpace(p)) + } + return strings.TrimSpace(parts[0]), funcs + } + + It("only references keys and functions the service library provides", func() { + matches := actionRe.FindAllStringSubmatch(systemdScript, -1) + Expect(matches).ToNot(BeEmpty()) + + for _, m := range matches { + key, funcs := parseAction(m[1]) + if key != "" && key != "." { + Expect(systemdKeys).To(HaveKey(key), + "template action %q uses a key unknown to kardianos/service", m[0]) + } + for _, fn := range funcs { + Expect(systemdFuncs).To(HaveKey(fn), + "template action %q uses an unknown pipeline function", m[0]) + } + } + }) +}) From 052f10fd68984a01cc2c87eee9edcc16dbfa6ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 9 Jul 2026 06:30:44 -0400 Subject: [PATCH 346/596] fix(build): prevent 32-bit startup crash (segfault/SIGILL) in downloads binaries (#5739) * fix(build): force nodynamic webp tag on 32-bit standalone binaries gen2brain/webp's native libwebp backend links ebitengine/purego, whose reverse callbacks are unsupported on 32-bit ARM and x86. purego registers its callback in package init(), so the binary crashes at startup (SIGSEGV or SIGILL) before any Navidrome code runs. The nodynamic build tag from #5606 forces the safe WASM path, but it was only applied to the Docker-image build stage. The standalone build stage, which produces the downloads-page tarballs and the deb/rpm packages, still linked purego, so the armv7/v6/v5 and 386 downloads crashed on launch (#5738, #5735). Move the tag decision into release/build-tags.sh, shared by both build stages so they can no longer drift, and add release/verify-binary.sh as a build-time guard that fails if a 32-bit binary links purego. * fix(build): harden webp build-tag scripts per review - verify-binary.sh: fail loudly when the target binary is missing (e.g. an unmatched glob) instead of letting `go version -m` fail inside a pipeline and silently pass, which would bypass the guard. - build-tags.sh / verify-binary.sh: fall back to `go env GOARCH` when xx-info is unavailable, so the scripts stay correct outside the xx build image. (Not `uname -m`, which reports the build host, not the cross target.) - Dockerfile: use `set -e` in the standalone build block and drop the redundant `|| exit 1` suffixes; keep the debug GOENV dump non-fatal. * chore(build): quote -tags argument in both build stages Defensive quoting per review; the value comes from release/build-tags.sh and contains no whitespace today, but quoting prevents word-splitting if it ever does. * fix(build): link 32-bit arm binaries with LLD to fix startup crash The standalone armv7/v6/v5 binaries of 0.63.0 crash before main() with SIGSEGV/SIGILL (issues #5738, #5735). Root cause, established from a core dump of the crashing binary under qemu: GNU ld emits corrupt R_ARM_IRELATIVE addends for libatomic's ifunc resolvers (wrong address and missing Thumb bit) once .text outgrows the 16MB Thumb branch range. glibc's static-init ifunc resolution then does `blx` into ARM-mode garbage and the process dies before any log output. v0.62.0 was unaffected only because its .text was still under 16MB (15.1MB); v0.63.0 crossed the line (17.5MB), so every 0.63.0 32-bit arm build crashes regardless of Go or dependency versions. Link 32-bit arm with LLD (already installed in the build stage), which emits correct IRELATIVE addends. Verified under qemu: the armv7 artifact built by the unchanged pipeline now boots to "Navidrome server is ready" with SQLite migrations working, where the previous binary segfaulted at startup. Also add a CI smoke test that runs each cross-compiled linux binary under binfmt/qemu right after building it, so any future crashes-at-startup-on-some-arch regression fails the pipeline instead of shipping in a release. --- .github/workflows/pipeline.yml | 15 +++++++++++++++ Dockerfile | 29 ++++++++++++++++++----------- release/build-tags.sh | 22 ++++++++++++++++++++++ release/verify-binary.sh | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) create mode 100755 release/build-tags.sh create mode 100755 release/verify-binary.sh diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 228bac9e7..8c714945e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -300,6 +300,21 @@ jobs: GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} + - name: Set up QEMU for smoke test + if: env.IS_LINUX == 'true' + uses: docker/setup-qemu-action@v3 + + # The binary is static, so binfmt+qemu runs it directly on the runner. + # Catches startup crashes in cross-compiled binaries before they ship, + # e.g. the broken ifunc relocations on 32-bit arm from issue #5738. + - name: Smoke-test binary + if: env.IS_LINUX == 'true' + run: | + BIN=./output/${{ env.PLATFORM }}/navidrome + chmod +x "$BIN" + "$BIN" --help >/dev/null + echo "OK: ${{ matrix.platform }} binary starts" + - name: Upload Binaries uses: actions/upload-artifact@v7 with: diff --git a/Dockerfile b/Dockerfile index e8a00f470..df5df52ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,20 +69,15 @@ RUN --mount=type=bind,source=. \ set -e xx-go --wrap export CGO_ENABLED=1 - # Native libwebp (gen2brain/webp) uses ebitengine/purego reverse callbacks, - # which purego does not support on 32-bit ARM or x86 and crash with a SIGSEGV - # (issue #5597). Build those arches with the "nodynamic" tag so gen2brain/webp - # is WASM-only and never links the purego path. 64-bit arches keep native libwebp. - BUILD_TAGS=netgo,sqlite_fts5 - if [ "$(xx-info arch)" = "arm" ] || [ "$(xx-info arch)" = "386" ]; then - BUILD_TAGS=${BUILD_TAGS},nodynamic - fi + BUILD_TAGS=$(./release/build-tags.sh) # -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve. - go build -tags=${BUILD_TAGS} -ldflags="-w -s \ + go build -tags="${BUILD_TAGS}" -ldflags="-w -s \ -linkmode=external -extldflags '-latomic' \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome . + # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). + ./release/verify-binary.sh /out/navidrome # Fail the build if the binary is accidentally statically linked: dlopen (and # therefore native libwebp detection) only works with a dynamic interpreter. file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; } @@ -116,11 +111,12 @@ RUN --mount=type=bind,source=. \ --mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \ --mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/go/pkg/mod <<EOT + set -e # Setup CGO cross-compilation environment xx-go --wrap export CGO_ENABLED=1 - cat $(go env GOENV) + cat "$(go env GOENV)" 2>/dev/null || true # Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler. # So let's use gcc for everything except Darwin. @@ -129,14 +125,25 @@ RUN --mount=type=bind,source=. \ export CXX=$(xx-info)-g++ export LD_EXTRA="-extldflags '-static -latomic'" fi + # GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers + # (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch + # range, making static arm binaries jump to garbage inside glibc's ifunc + # resolution and crash before main() (issue #5738). Link 32-bit arm with LLD, + # which emits correct addends. + if [ "$(xx-info arch)" = "arm" ]; then + export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'" + fi if [ "$(xx-info os)" = "windows" ]; then export EXT=".exe" fi - go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \ + BUILD_TAGS=$(./release/build-tags.sh) + go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome${EXT} . + # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). + ./release/verify-binary.sh /out/navidrome* EOT # Verify if the binary was built for the correct platform and it is statically linked diff --git a/release/build-tags.sh b/release/build-tags.sh new file mode 100755 index 000000000..f719117ff --- /dev/null +++ b/release/build-tags.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Print the Go build tags for the xx-cc target platform (used by the Dockerfile). +# +# gen2brain/webp's native libwebp backend links ebitengine/purego, whose reverse +# callbacks are unsupported on 32-bit ARM and x86 and SIGSEGV at package-init time, +# taking the whole process down at startup (issues #5597 / #5606 / #5738). Force the +# WASM-only path there with the "nodynamic" tag; 64-bit arches keep native libwebp. +# +# This is the single source of truth for the tag decision: both Dockerfile build +# stages (Docker-image and standalone downloads) call it so they cannot drift apart. +set -e + +# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the +# script is still correct when run outside the xx environment. Both report the +# cross-compilation target, unlike `uname -m`, which would report the build host. +arch=$(xx-info arch 2>/dev/null || go env GOARCH) + +tags="netgo,sqlite_fts5" +case "${arch}" in + arm | 386) tags="${tags},nodynamic" ;; +esac +printf '%s' "${tags}" diff --git a/release/verify-binary.sh b/release/verify-binary.sh new file mode 100755 index 000000000..cde775992 --- /dev/null +++ b/release/verify-binary.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Fail the build if a 32-bit ARM/x86 binary links ebitengine/purego, which would +# SIGSEGV at startup on those arches (issue #5738). +# +# Independent safety net for build-tags.sh: it inspects the actual build metadata +# recorded in the binary (survives stripping) instead of trusting the requested +# tags, so it still fires if the tag decision is wrong or gen2brain/webp changes +# its build-tag semantics. Runs in the Dockerfile, where xx-info and go are present. +# +# Usage: verify-binary.sh <binary> [<binary>...] +set -e + +# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the +# check is still correct when run outside the xx environment. +arch=$(xx-info arch 2>/dev/null || go env GOARCH) + +case "${arch}" in + arm | 386) ;; + *) exit 0 ;; # 64-bit arches legitimately link purego for native libwebp +esac + +for bin in "$@"; do + # Fail loudly if the expected binary is missing (e.g. an unmatched glob), rather + # than letting `go version -m` fail inside the pipeline and silently pass. + if [ ! -f "${bin}" ]; then + echo "ERROR: expected binary '${bin}' not found; purego verification did not run." + exit 1 + fi + if go version -m "${bin}" | grep -q "ebitengine/purego"; then + echo "ERROR: 32-bit binary '${bin}' links ebitengine/purego; it will SIGSEGV at startup (issue #5738)." + echo " Ensure the 'nodynamic' build tag is applied (see release/build-tags.sh)." + exit 1 + fi +done From 4381366e663b4647cfec1eb5597c39c3c42e9d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 9 Jul 2026 19:43:04 -0400 Subject: [PATCH 347/596] ci: validate DB migration order on pull requests (#5750) * ci: add DB migration ordering/naming validation script * ci: run DB migration validation on pull requests * ci: don't reject non-migration .go files * ci: fetch latest master before validating migration order * ci: annotate the offending migration file on validation failure * ci: gate Build on the migration check so a bad migration fails fast * ci: reject migrations placed in a subdirectory of db/migrations * ci: reword fetch-step comment to not hard-code the base branch name --- .github/workflows/pipeline.yml | 23 +++- .github/workflows/validate-migrations.sh | 150 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100755 .github/workflows/validate-migrations.sh diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8c714945e..d21d0a681 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -96,6 +96,23 @@ jobs: exit 1 fi + validate-migrations: + name: Validate DB migrations + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Refresh the base branch so the check compares against its CURRENT tip, + # not the (possibly stale) commit the PR was opened against. + - name: Fetch latest base branch + run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" + - name: Validate migration ordering and naming + env: + BASE_REF: origin/${{ github.event.pull_request.base.ref }} + run: ./.github/workflows/validate-migrations.sh + go: name: Test Go code runs-on: ubuntu-latest @@ -257,7 +274,11 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled] + needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + # validate-migrations only runs on pull_request, so it is "skipped" on push/tag + # builds. Run Build unless a dependency actually failed — a *skipped* dependency + # (the migration check on non-PR events) must not block release builds. + if: ${{ !cancelled() && !failure() }} strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] diff --git a/.github/workflows/validate-migrations.sh b/.github/workflows/validate-migrations.sh new file mode 100755 index 000000000..07d05c3a6 --- /dev/null +++ b/.github/workflows/validate-migrations.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# +# Validates DB migrations added by a pull request: +# 1. Ordering - an added migration must be NEWER than the latest migration +# already on the base branch. Goose applies migrations in +# timestamp order, so an older-timestamped migration would be +# silently skipped on databases already upgraded past it. +# 2. Uniqueness - no two migration files may share a timestamp. +# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go). +# +# On failure it prints a human-readable message and, when running in GitHub +# Actions, emits an error annotation bound to the offending file so the message +# also renders inline in the PR "Files changed" tab. +# +# Compares HEAD against $BASE_REF (default origin/master). Requires full history +# (fetch-depth: 0 in CI). +# -e is intentionally omitted: the script accumulates violations into $status +# and must not exit on the first non-zero command (grep no-match, a false [[ ]] +# in an if, `is_migration || continue`). +set -uo pipefail +export LC_ALL=C + +MIGRATIONS_DIR="db/migrations" +BASE_REF="${BASE_REF:-origin/master}" +NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$' + +status=0 + +# Log a message to stderr and mark the run as failed. +fail() { + printf '%s\n' "$1" >&2 + status=1 +} + +# Emit a GitHub Actions error annotation bound to a file, so the message renders +# inline on the offending migration in the PR "Files changed" tab. No-op outside +# CI. `%`, newline and CR are encoded as required by the workflow-command syntax +# (the `%` replacement must run first so the encodings we add aren't re-escaped). +annotate() { # $1=file $2=message + [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 + local msg="$2" + msg="${msg//'%'/%25}" + msg="${msg//$'\n'/%0A}" + msg="${msg//$'\r'/%0D}" + printf '::error file=%s,line=1::%s\n' "$1" "$msg" +} + +# Report a migration problem: log it, annotate the offending file, mark failed. +report() { # $1=file $2=message + fail "$2" + printf '\n' >&2 + annotate "$1" "$2" +} + +human_ts() { + local t="$1" + printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}" +} + +is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix + local b="$1" + case "$b" in + *.sql | *.go) ;; + *) return 1 ;; + esac + [[ "${b%%_*}" =~ ^[0-9]{14}$ ]] +} + +if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then + printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2 + exit 1 +fi + +# --- Newest timestamp already on the base branch --- +base_max="" +base_max_file="" +while IFS= read -r f; do + [ -z "$f" ] && continue + b="$(basename "$f")" + is_migration "$b" || continue + ts="${b%%_*}" + if [[ "$ts" > "$base_max" ]]; then + base_max="$ts" + base_max_file="$f" + fi +done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null) + +# --- Ordering + naming on files added by this PR --- +while IFS= read -r f; do + [ -z "$f" ] && continue + b="$(basename "$f")" + case "$b" in + *.sql) ;; # any .sql in this dir must be a migration + *.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip + *) continue ;; + esac + if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then + report "$f" "❌ Migration file in a subdirectory: $f + Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and + top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED. + Move it to $MIGRATIONS_DIR/$b." + continue + fi + if ! [[ "$b" =~ $NAME_RE ]]; then + report "$f" "❌ Malformed migration filename: $f + Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase. + Regenerate with: make migration-sql name=<description> (or make migration-go name=<description>)" + continue + fi + ts="${b%%_*}" + if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then + report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts")) + is older than (or equal to) the newest migration already on ${BASE_REF#origin/}: + $base_max_file ($(human_ts "$base_max")) + + Goose applies migrations in timestamp order, so databases already upgraded + past that point would SILENTLY SKIP your migration. + + Fix: regenerate it with a current timestamp: + make migration-sql name=<description> (or make migration-go name=<description>) + then move your SQL/Go body into the new file and delete the old one." + fi +done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null) + +# --- Duplicate timestamps across the merged set (HEAD) --- +all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)" +dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do + b="$(basename "$f")" + is_migration "$b" || continue + printf '%s\n' "${b%%_*}" +done | sort | uniq -d)" +if [ -n "$dups" ]; then + while IFS= read -r ts; do + [ -z "$ts" ] && continue + colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)" + printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2 + while IFS= read -r cf; do + [ -z "$cf" ] && continue + printf ' %s\n' "$cf" >&2 + annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*." + done <<< "$colliding" + printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2 + status=1 + done <<< "$dups" +fi + +if [ "$status" -eq 0 ]; then + echo "✅ DB migrations OK (ordering, uniqueness, naming)." +fi +exit "$status" From 7fa13761d734159e2f0a502a31affacc88e66d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 10 Jul 2026 10:52:05 -0400 Subject: [PATCH 348/596] fix(scanner): resolve file symlinks with the production local storage FS (#5755) * fix(scanner): resolve file symlinks with the production local storage FS The symlink classification added for GHSA-r5qr-m328-qcf4 relied on fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS interface, hiding its ReadLinkFS implementation. Every resolution failed at the first hop, so the scanner silently skipped ALL file symlinks, regardless of target or the FollowSymlinks setting. Libraries made of symlinks (e.g. shared-pool setups) lost all their tracks after upgrading to 0.63. The local storage now exposes full OS-level resolution (EvalSymlinks) through a new optional storage.SymlinkResolverFS interface, which the scanner prefers over the fs.ReadLink hop loop. This also classifies a chain by its FINAL target even when it passes through an audio-named intermediate outside the library, closing a bypass the hop loop had. Regular (non-symlink) entries keep the same early-return path, so scan performance is unaffected for normal libraries. Fixes #5752 * fix(test): keep watcher specs off the real local storage The watcher specs spawn watchLibrary goroutines that are not joined on spec teardown. Now that the scanner test binary registers the file:// storage, those leaked goroutines reached newLocalStorage, which reads conf.Server on construction, racing with the configtest cleanup that restores the config snapshot (caught by CI's race detector). Point the mock libraries at a fake storage scheme, which never touches the config and does not support watching, so the goroutine exits immediately. * fix(storage): reject invalid fs paths in ResolveSymlink Defense-in-depth for the SymlinkResolverFS contract: names must be valid fs.FS paths. A lexical ".." in the name would otherwise escape the library root via filepath.Join cleaning. No current caller can produce such a name (they come from ReadDir walks), but the guard enforces the documented contract at the boundary. --- core/storage/interface.go | 8 ++++ core/storage/local/local.go | 13 +++++- core/storage/local/local_test.go | 72 +++++++++++++++++++++++++++++++ scanner/scanner_suite_test.go | 17 ++++++++ scanner/walk_dir_tree.go | 16 +++++++ scanner/walk_dir_tree_test.go | 73 ++++++++++++++++++++++++++++++++ scanner/watcher_test.go | 9 +++- 7 files changed, 205 insertions(+), 3 deletions(-) diff --git a/core/storage/interface.go b/core/storage/interface.go index dc08ca00a..02c1d14d9 100644 --- a/core/storage/interface.go +++ b/core/storage/interface.go @@ -17,6 +17,14 @@ type MusicFS interface { ReadTags(path ...string) (map[string]metadata.Info, error) } +// SymlinkResolverFS is an optional interface for MusicFS implementations backed by a real +// filesystem. ResolveSymlink resolves the whole symlink chain of the named entry at the OS +// level and returns the final target's path — including targets outside the FS root, which +// fs.ReadLink-based resolution cannot follow. +type SymlinkResolverFS interface { + ResolveSymlink(name string) (string, error) +} + // Watcher is a storage with the ability watch the FS and notify changes type Watcher interface { // Start starts a watcher on the whole FS and returns a channel to send detected changes. diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 5384581e0..32aff0955 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -54,12 +54,23 @@ func (s *localStorage) FS() (storage.MusicFS, error) { 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 + return &localFS{FS: os.DirFS(path), extractor: s.extractor, root: path}, nil } type localFS struct { fs.FS extractor Extractor + root string +} + +// ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the +// OS level, so links whose targets live outside the library folder (not reachable through +// the fs.FS abstraction) still resolve to their final target. +func (lfs *localFS) ResolveSymlink(name string) (string, error) { + if !fs.ValidPath(name) { + return "", &fs.PathError{Op: "resolvesymlink", Path: name, Err: fs.ErrInvalid} + } + return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) } func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index d65d8214a..90bdd4b5b 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -199,6 +199,78 @@ var _ = Describe("LocalStorage", func() { }) }) + Describe("localFS.ResolveSymlink", func() { + var musicFS storage.MusicFS + + BeforeEach(func() { + if runtime.GOOS == "windows" { + Skip("symlink semantics") + } + u, err := storage.LocalPathToURL(tempDir) + Expect(err).ToNot(HaveOccurred()) + musicFS, err = newLocalStorage(u).FS() + Expect(err).ToNot(HaveOccurred()) + }) + + It("implements storage.SymlinkResolverFS", func() { + _, ok := musicFS.(storage.SymlinkResolverFS) + Expect(ok).To(BeTrue()) + }) + + It("resolves a chain that leaves the library folder to its final target", func() { + outside, err := os.MkdirTemp("", "navidrome-symlink-outside-") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(outside) }) + + target := filepath.Join(outside, "final.txt") + Expect(os.WriteFile(target, []byte("data"), 0600)).To(Succeed()) + mid := filepath.Join(outside, "mid.wav") + Expect(os.Symlink(target, mid)).To(Succeed()) + Expect(os.Symlink(mid, filepath.Join(tempDir, "link.wav"))).To(Succeed()) + + resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("link.wav") + Expect(err).ToNot(HaveOccurred()) + expected, err := filepath.EvalSymlinks(target) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(expected)) + }) + + It("resolves entries in subfolders (slash-separated fs paths)", func() { + Expect(os.MkdirAll(filepath.Join(tempDir, "sub"), 0755)).To(Succeed()) + target := filepath.Join(tempDir, "real.mp3") + Expect(os.WriteFile(target, []byte("audio"), 0600)).To(Succeed()) + Expect(os.Symlink(target, filepath.Join(tempDir, "sub", "link.mp3"))).To(Succeed()) + + resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("sub/link.mp3") + Expect(err).ToNot(HaveOccurred()) + expected, err := filepath.EvalSymlinks(target) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(expected)) + }) + + It("returns an error for a broken symlink", func() { + Expect(os.Symlink(filepath.Join(tempDir, "missing.mp3"), filepath.Join(tempDir, "broken.mp3"))).To(Succeed()) + + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("broken.mp3") + Expect(err).To(HaveOccurred()) + }) + + It("rejects names that are not valid fs paths", func() { + for _, name := range []string{"../outside.mp3", "/etc/hosts", "sub/../../outside.mp3", ""} { + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink(name) + Expect(err).To(MatchError(fs.ErrInvalid), name) + } + }) + + It("returns an error for a symlink loop", func() { + Expect(os.Symlink(filepath.Join(tempDir, "loop2.mp3"), filepath.Join(tempDir, "loop1.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(tempDir, "loop1.mp3"), filepath.Join(tempDir, "loop2.mp3"))).To(Succeed()) + + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("loop1.mp3") + Expect(err).To(HaveOccurred()) + }) + }) + Describe("localFS.ReadTags", func() { var testFile string diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 9ee6fc89b..10be0401f 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -2,17 +2,34 @@ package scanner_test import ( "context" + "io/fs" "os" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" ) +// The local storage is registered in this test binary, so any spec (or background watcher) +// touching a file:// library needs a default extractor to avoid a startup fatal. +type noopSuiteExtractor struct{} + +func (noopSuiteExtractor) Parse(...string) (map[string]metadata.Info, error) { return nil, nil } +func (noopSuiteExtractor) Version() string { return "0" } + +func init() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return noopSuiteExtractor{} + }) +} + func TestScanner(t *testing.T) { // Only run goleak checks when the GOLEAK env var is set if os.Getenv("GOLEAK") != "" { diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 55bbab684..887344b1b 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -5,11 +5,13 @@ import ( "io/fs" "maps" "path" + "path/filepath" "slices" "sort" "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -232,6 +234,20 @@ func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs. log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) return "", false } + // OS-backed filesystems can resolve the whole chain, even when it leaves the FS root + // (e.g. a link into another folder/drive), so the final target is always what gets + // classified. The fs.ReadLink loop below can't see past the root: it classifies by the + // last in-chain name it can reach. + if resolver, ok := fsys.(storage.SymlinkResolverFS); ok { + target, err := resolver.ResolveSymlink(linkPath) + if err != nil { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := filepath.Base(target) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", target, "name", resolved) + return resolved, true + } cur := linkPath for hop := 0; hop < maxSymlinkHops; hop++ { target, err := fs.ReadLink(fsys, cur) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index f3b13a4ef..9fb650c4d 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -432,6 +432,79 @@ var _ = Describe("walk_dir_tree", func() { }) }) + // Regression for #5752: the production localFS must resolve file symlinks. + // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based + // resolution is not available and full OS-level resolution is required. + Context("production local storage FS", func() { + var libRoot string + var musicFS storage.MusicFS + + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + + // Reproduces the reported layout: a "pool" with the real files and a + // library containing only symlinks into the pool. + base := GinkgoT().TempDir() + pool := filepath.Join(base, "pool") + libRoot = filepath.Join(base, "userlib") + Expect(os.MkdirAll(pool, 0755)).To(Succeed()) + Expect(os.MkdirAll(libRoot, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pool, "real.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pool, "secrets.txt"), []byte("TOPSECRET"), 0600)).To(Succeed()) + // mid.wav lives OUTSIDE the library and has an audio name, but points at a + // non-audio file. A chain through it must be classified by the FINAL target. + Expect(os.Symlink(filepath.Join(pool, "secrets.txt"), filepath.Join(pool, "mid.wav"))).To(Succeed()) + + Expect(os.Symlink("../pool/real.mp3", filepath.Join(libRoot, "relative.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "real.mp3"), filepath.Join(libRoot, "absolute.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed()) + + u, err := storage.LocalPathToURL(libRoot) + Expect(err).ToNot(HaveOccurred()) + s, err := storage.For(u.String()) + Expect(err).ToNot(HaveOccurred()) + musicFS, err = s.FS() + Expect(err).ToNot(HaveOccurred()) + }) + + walkRoot := func() *folderEntry { + job := &scanJob{fs: musicFS, lib: model.Library{Path: libRoot}} + results, err := walkDirTree(GinkgoT().Context(), job) + Expect(err).ToNot(HaveOccurred()) + var root *folderEntry + for folder := range results { + if folder.path == "." { + root = folder + } + } + Expect(root).ToNot(BeNil()) + return root + } + + It("imports symlinks to out-of-library audio files", func() { + root := walkRoot() + Expect(root.audioFiles).To(HaveKey("relative.mp3")) + Expect(root.audioFiles).To(HaveKey("absolute.mp3")) + }) + + It("rejects a chain that ends in a non-audio file, even through an audio-named intermediate", func() { + root := walkRoot() + Expect(root.audioFiles).ToNot(HaveKey("evil.wav")) + }) + + It("skips broken symlinks", func() { + root := walkRoot() + Expect(root.audioFiles).ToNot(HaveKey("broken.mp3")) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + root := walkRoot() + Expect(root.audioFiles).To(BeEmpty()) + }) + }) + Context("out-of-tree escape (temp dir)", func() { var root string BeforeEach(func() { diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index ffe9f8b15..15e49e195 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -30,10 +31,14 @@ var _ = Describe("Watcher", func() { ctx, cancel = context.WithCancel(GinkgoT().Context()) DeferCleanup(cancel) + // Use a fake storage scheme: watchLibrary goroutines spawned by Run/Watch are not + // joined on spec teardown, and the real file:// storage reads conf.Server on + // construction, racing with the configtest cleanup that restores the config. + storagetest.Register("fake-watcher", &storagetest.FakeFS{}) lib = &model.Library{ ID: 1, Name: "Test Library", - Path: "/test/library", + Path: "fake-watcher:///test/library", } // Set up mocks @@ -234,7 +239,7 @@ var _ = Describe("Watcher", func() { lib2 = &model.Library{ ID: 2, Name: "Test Library 2", - Path: "/test/library2", + Path: "fake-watcher:///test/library2", } mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo) From 116a4407184b6f21221a6aa7702fb738a3915773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 10 Jul 2026 18:43:31 -0400 Subject: [PATCH 349/596] test(scanner): fix flaky Windows search_normalized rescan test (#5758) The 'repopulates a stale search_normalized on a full rescan' spec runs two full scans back-to-back. Whether the second scan refreshes the unchanged artist depends on folderEntry.isOutdated(), which compares folder.updated_at (written during the first scan) against the second scan's library.last_scan_started_at using a strict time.Before(). Both are time.Now() values captured milliseconds apart. On Linux's fine-grained clock they are always distinct, so the test passes. On Windows the coarse wall-clock granularity frequently makes the two timestamps land in the same tick and compare equal, so Before() returns false, the folder is treated as up-to-date and skipped, the artist is never re-persisted, and search_normalized stays empty -- failing the assertion intermittently across unrelated PRs. Backdate the folder's updated_at an hour before the second scan so the comparison is unambiguous on every platform. This is a test-only timing artifact (real rescans never run milliseconds apart on an unchanged library), so no production code changes are needed. --- scanner/scanner_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 7f3dca775..cc3720717 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -6,6 +6,7 @@ import ( "errors" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/google/uuid" @@ -212,6 +213,15 @@ var _ = Describe("Scanner", Ordered, func() { _, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'") Expect(err).ToNot(HaveOccurred()) + // Backdate the folder so the next full scan reliably sees it as outdated. + // isOutdated() compares folder.updated_at (written by this scan) against the + // next scan's last_scan_started_at with a strict Before(); back-to-back scans + // can capture both within one clock tick on Windows (coarse wall-clock), making + // the refresh flaky. Backdating forces the comparison to be unambiguous. + _, err = db.Db().ExecContext(ctx, + "UPDATE folder SET updated_at = ?", time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(runScanner(ctx, true)).To(Succeed()) Expect(searchNormalized()).To(Equal("GOGGS")) }) From 205c85da5523338807808202d8877ccaa6e82776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 10 Jul 2026 19:15:49 -0400 Subject: [PATCH 350/596] fix(plugins): surface host service failures when loading plugins (#5756) * fix(plugins): surface host service failures when loading plugins When a host service failed to initialize during plugin load (e.g. the taskqueue database could not be created because the data folder is not writable), the error was logged and swallowed, and its host functions were silently omitted. Instantiation then failed with a misleading error such as '"task_createqueue" is not exported in module "extism:host/user"', which reads as a plugin/host API mismatch and gets wrongly blamed on plugin authors (see kgarner7/navidrome-listenbrainz-daily-playlist#26). Host service factories now return an error, and loadPluginWithConfig fails fast with the actual cause (e.g. 'creating Task service: creating plugin data directory: ...'), which is also stored in the plugin's last_error. Closers accumulated before a load failure are now closed (via a deferred guard covering all failure paths), so partially-created services no longer leak goroutines or database handles. Also fix a panic in 'navidrome plugin enable': CLI commands use the plugin Manager without calling Start, so manager.ctx was nil and newTaskQueueService panicked in context.WithCancel. Long-lived host services (taskqueue, kvstore, websocket) now receive their lifecycle context explicitly in the constructor, sourced from serviceContext.baseCtx(), which falls back to context.Background() for the unstarted-manager case. * docs(plugins): correct websocket readLoop lifecycle comment The comment claimed the read loop's context is always cancelled during application shutdown, which is not true when the manager was never started (one-shot CLI runs, where baseCtx falls back to context.Background()). Clarify that connection closure via Close() on plugin unload is what ends the read loop, with context cancellation as a server-shutdown backstop. Addresses review feedback on #5756. * test(plugins): exclude plugin-loading specs from Windows builds The new loadPluginWithConfig specs reference test suite helpers (testdataDir, noopMetricsRecorder) defined in plugins_suite_test.go, which is excluded on Windows, breaking test compilation there. Move the specs to their own file with the same build constraint, keeping the pure-function specs in manager_loader_test.go running on Windows as before. --- plugins/host_taskqueue.go | 5 +- plugins/host_taskqueue_test.go | 16 +---- plugins/host_websocket.go | 15 +++-- plugins/manager_loader.go | 98 +++++++++++++++++++---------- plugins/manager_loader_load_test.go | 78 +++++++++++++++++++++++ 5 files changed, 158 insertions(+), 54 deletions(-) create mode 100644 plugins/manager_loader_load_test.go diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index a5db3344f..2f74c0aa4 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -82,7 +82,8 @@ type taskQueueServiceImpl struct { } // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. -func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { +// The given ctx bounds the service's background work (queue workers, cleanup loop). +func newTaskQueueService(ctx context.Context, pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) @@ -102,7 +103,7 @@ func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int return nil, fmt.Errorf("creating taskqueue schema: %w", err) } - ctx, cancel := context.WithCancel(manager.ctx) //nolint:gosec // cancel is stored in struct and called in Close() + ctx, cancel := context.WithCancel(ctx) //nolint:gosec // cancel is stored in struct and called in Close() s := &taskQueueServiceImpl{ pluginName: pluginName, diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index faff79c8e..d459fd69b 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -42,15 +42,11 @@ var _ = Describe("TaskQueueService", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DataFolder = conf.NewDir(tmpDir) - // Create a mock manager with context - managerCtx, cancel := context.WithCancel(ctx) manager = &Manager{ plugins: make(map[string]*plugin), - ctx: managerCtx, } - DeferCleanup(cancel) - service, err = newTaskQueueService("test_plugin", manager, 5) + service, err = newTaskQueueService(ctx, "test_plugin", manager, 5) Expect(err).ToNot(HaveOccurred()) }) @@ -730,14 +726,11 @@ var _ = Describe("TaskQueueService", func() { service.Close() // Create a new service pointing to the same DB - managerCtx2, cancel2 := context.WithCancel(ctx) - DeferCleanup(cancel2) manager2 := &Manager{ plugins: make(map[string]*plugin), - ctx: managerCtx2, } - service, err = newTaskQueueService("test_plugin", manager2, 5) + service, err = newTaskQueueService(ctx, "test_plugin", manager2, 5) Expect(err).ToNot(HaveOccurred()) // Override callback to succeed @@ -775,14 +768,11 @@ var _ = Describe("TaskQueueService", func() { Describe("Plugin isolation", func() { It("uses separate databases for different plugins", func() { - managerCtx2, cancel2 := context.WithCancel(ctx) - DeferCleanup(cancel2) manager2 := &Manager{ plugins: make(map[string]*plugin), - ctx: managerCtx2, } - service2, err := newTaskQueueService("other_plugin", manager2, 5) + service2, err := newTaskQueueService(ctx, "other_plugin", manager2, 5) Expect(err).ToNot(HaveOccurred()) defer service2.Close() diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index eef1e6236..90403f4c0 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -54,6 +54,7 @@ type wsConnection struct { // webSocketServiceImpl implements host.WebSocketService. // It provides plugins with WebSocket communication capabilities. type webSocketServiceImpl struct { + baseCtx context.Context // bounds the read loops, which outlive the Connect() call pluginName string manager *Manager requiredHosts []string @@ -63,8 +64,9 @@ type webSocketServiceImpl struct { } // newWebSocketService creates a new WebSocketService for a plugin. -func newWebSocketService(pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { +func newWebSocketService(ctx context.Context, pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { return &webSocketServiceImpl{ + baseCtx: ctx, pluginName: pluginName, manager: manager, requiredHosts: permission.RequiredHosts, @@ -129,11 +131,12 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade s.connections[connectionID] = wsConn s.mu.Unlock() - // Start read goroutine with manager's context. - // We use manager.ctx instead of the caller's ctx because the readLoop must - // outlive the Connect() call. The manager's context is cancelled during - // application shutdown, ensuring graceful cleanup. - go s.readLoop(s.manager.ctx, connectionID, wsConn) + // Start read goroutine with the service's base context instead of the + // caller's ctx, because the readLoop must outlive the Connect() call. + // Connections are closed by Close() when the plugin is unloaded, which ends + // the readLoop; the base context is a backstop that also ends it on server + // shutdown (it is never cancelled in one-shot CLI runs). + go s.readLoop(s.baseCtx, connectionID, wsConn) log.Debug(ctx, "WebSocket connected", "plugin", s.pluginName, "connectionID", connectionID, "url", urlStr) return connectionID, nil diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 604fba3a7..757ededb5 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -30,11 +30,23 @@ type serviceContext struct { allLibraries bool // If true, plugin can access all libraries } +// baseCtx returns the manager's lifecycle context, for host services that +// outlive the plugin call that created them. It falls back to +// context.Background() when the manager was never started, which is the case +// for CLI commands (e.g. `navidrome plugin enable`) that load plugins without +// calling Start. +func (c *serviceContext) baseCtx() context.Context { + if c.manager.ctx == nil { + return context.Background() + } + return c.manager.ctx +} + // hostServiceEntry defines a host service for table-driven registration. type hostServiceEntry struct { name string hasPermission func(*Permissions) bool - create func(*serviceContext) ([]extism.HostFunction, io.Closer) + create func(*serviceContext) ([]extism.HostFunction, io.Closer, error) } // hostServices defines all available host services. @@ -43,119 +55,117 @@ var hostServices = []hostServiceEntry{ { name: "Config", hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newConfigService(ctx.pluginName, ctx.config) - return host.RegisterConfigHostFunctions(service), nil + return host.RegisterConfigHostFunctions(service), nil, nil }, }, { name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) - return host.RegisterSubsonicAPIHostFunctions(service), nil + return host.RegisterSubsonicAPIHostFunctions(service), nil, nil }, }, { name: "Scheduler", hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance()) - return host.RegisterSchedulerHostFunctions(service), service + return host.RegisterSchedulerHostFunctions(service), service, nil }, }, { name: "WebSocket", hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Websocket - service := newWebSocketService(ctx.pluginName, ctx.manager, perm) - return host.RegisterWebSocketHostFunctions(service), service + service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm) + return host.RegisterWebSocketHostFunctions(service), service, nil }, }, { name: "Artwork", hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newArtworkService() - return host.RegisterArtworkHostFunctions(service), nil + return host.RegisterArtworkHostFunctions(service), nil, nil }, }, { name: "Cache", hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newCacheService(ctx.pluginName) - return host.RegisterCacheHostFunctions(service), service + return host.RegisterCacheHostFunctions(service), service, nil }, }, { name: "Library", hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Library service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries) - return host.RegisterLibraryHostFunctions(service), nil + return host.RegisterLibraryHostFunctions(service), nil, nil }, }, { name: "KVStore", hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Kvstore - service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm) + service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm) if err != nil { - log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err) - return nil, nil + return nil, nil, err } - return host.RegisterKVStoreHostFunctions(service), service + return host.RegisterKVStoreHostFunctions(service), service, nil }, }, { name: "Users", hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) - return host.RegisterUsersHostFunctions(service), nil + return host.RegisterUsersHostFunctions(service), nil, nil }, }, { name: "Matcher", hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem service := newMatcherService( ctx.manager.ds, hasFilesystemPerm, newUserAccess(ctx.allowedUsers, ctx.allUsers), newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries), ) - return host.RegisterMatcherHostFunctions(service), nil + return host.RegisterMatcherHostFunctions(service), nil, nil }, }, { name: "HTTP", hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Http service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), nil + return host.RegisterHTTPHostFunctions(service), nil, nil }, }, { name: "Task", hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Taskqueue maxConcurrency := int32(1) if perm.MaxConcurrency > 0 { maxConcurrency = int32(perm.MaxConcurrency) } - service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency) + service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency) if err != nil { - log.Error("Failed to create Task service", "plugin", ctx.pluginName, err) - return nil, nil + return nil, nil, err } - return host.RegisterTaskHostFunctions(service), service + return host.RegisterTaskHostFunctions(service), service, nil }, }, } @@ -256,6 +266,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { // loadPluginWithConfig loads a plugin with configuration from DB. // The p.Path should point to an .ndp package file. func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { + // NewContext falls back to context.Background() when m.ctx is nil (unstarted manager) ctx := log.NewContext(m.ctx, "plugin", p.ID) if m.stopped.Load() { @@ -328,6 +339,15 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { // Build host functions based on permissions from manifest var hostFunctions []extism.HostFunction var closers []io.Closer + loaded := false + // On success the closers are owned by the registered plugin; on any + // failure past this point, close them so partially-created services + // don't leak goroutines or file handles. + defer func() { + if !loaded { + closeAll(closers) + } + }() svcCtx := &serviceContext{ pluginName: p.ID, @@ -341,7 +361,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } for _, entry := range hostServices { if entry.hasPermission(pkg.Manifest.Permissions) { - funcs, closer := entry.create(svcCtx) + funcs, closer, err := entry.create(svcCtx) + if err != nil { + return fmt.Errorf("creating %s service: %w", entry.name, err) + } hostFunctions = append(hostFunctions, funcs...) if closer != nil { closers = append(closers, closer) @@ -400,6 +423,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), } m.mu.Unlock() + loaded = true // Call plugin init function callPluginInit(ctx, m.plugins[p.ID]) @@ -407,6 +431,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return nil } +// closeAll closes host service closers accumulated before a load failure, +// so partially-created services don't leak goroutines or file handles. +func closeAll(closers []io.Closer) { + for _, c := range closers { + _ = c.Close() + } +} + // parsePluginConfig parses a JSON config string into a map of string values. // For Extism, all config values must be strings, so non-string values are serialized as JSON. func parsePluginConfig(configJSON string) (map[string]string, error) { diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go new file mode 100644 index 000000000..8f35548af --- /dev/null +++ b/plugins/manager_loader_load_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadPluginWithConfig", func() { + var manager *Manager + var dataDir string + + BeforeEach(func() { + pluginsDir := GinkgoT().TempDir() + dataDir = GinkgoT().TempDir() + + src := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension) + data, err := os.ReadFile(src) + Expect(err).ToNot(HaveOccurred()) + dest := filepath.Join(pluginsDir, "test-taskqueue"+PackageExtension) + Expect(os.WriteFile(dest, data, 0600)).To(Succeed()) + hash := sha256.Sum256(data) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(pluginsDir) + conf.Server.Plugins.AutoReload = false + conf.Server.DataFolder = conf.NewDir(dataDir) + + repo := tests.CreateMockPluginRepo() + repo.Permitted = true + repo.SetData(model.Plugins{{ + ID: "test-taskqueue", + Path: dest, + SHA256: hex.EncodeToString(hash[:]), + Enabled: false, + }}) + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: repo}, + metrics: noopMetricsRecorder{}, + subsonicRouter: http.NotFoundHandler(), + } + }) + + Describe("host service creation failures", func() { + It("reports the Task service creation error instead of a missing host function", func() { + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + DeferCleanup(func() { _ = manager.Stop() }) + + // Block the taskqueue data dir by creating a file where the directory should be + Expect(os.WriteFile(filepath.Join(dataDir, "plugins"), nil, 0600)).To(Succeed()) + + err := manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue") + Expect(err).To(MatchError(ContainSubstring("creating Task service"))) + Expect(err).ToNot(MatchError(ContainSubstring("not exported"))) + }) + }) + + Describe("unstarted manager", func() { + It("enables a taskqueue plugin on a manager that was never started", func() { + // CLI commands (navidrome plugin enable) use the manager without calling Start + Expect(manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue")).To(Succeed()) + DeferCleanup(func() { _ = manager.unloadPlugin("test-taskqueue") }) + }) + }) +}) From e91687e760a8b4e19dc74b6a9b1ff7f3674565ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 10 Jul 2026 20:27:29 -0400 Subject: [PATCH 351/596] fix(smartplaylist): reject NSP mixing top-level 'any' and 'all' (#5759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(scanner): fix flaky Windows search_normalized rescan test The 'repopulates a stale search_normalized on a full rescan' spec runs two full scans back-to-back. Whether the second scan refreshes the unchanged artist depends on folderEntry.isOutdated(), which compares folder.updated_at (written during the first scan) against the second scan's library.last_scan_started_at using a strict time.Before(). Both are time.Now() values captured milliseconds apart. On Linux's fine-grained clock they are always distinct, so the test passes. On Windows the coarse wall-clock granularity frequently makes the two timestamps land in the same tick and compare equal, so Before() returns false, the folder is treated as up-to-date and skipped, the artist is never re-persisted, and search_normalized stays empty -- failing the assertion intermittently across unrelated PRs. Backdate the folder's updated_at an hour before the second scan so the comparison is unambiguous on every platform. This is a test-only timing artifact (real rescans never run milliseconds apart on an unchanged library), so no production code changes are needed. * fix(smartplaylist): reject NSP mixing top-level 'any' and 'all' A smart playlist (.nsp) that specified both a top-level "any" and a top-level "all" group was imported by silently keeping only "any" and discarding "all", regardless of key order. The Criteria model holds a single top-level Expression, so it cannot represent both groups, and the parser picked "any" without reporting the dropped rules. Make Criteria.UnmarshalJSON return an error when both keys are present at the top level, so the scanner fails loudly (logging the playlist as invalid) instead of silently losing rules. Users should nest one group inside the other, as shown in the documented examples. Fixes #5757 * fix(smartplaylist): reject top-level any+all by key presence Address code review feedback: the previous guard checked decoded slice lengths, so it only rejected the mixed top-level any/all form when both groups were non-empty. An input like {"any":[],"all":[...]} (or a null group) slipped past and silently used just one group — the same class of silent drop this change set out to prevent. Decode the two keys as json.RawMessage and detect presence by key rather than length, so any file that provides both top-level keys is rejected regardless of whether one group is empty or null. * refactor(smartplaylist): detect top-level any+all via presence type Replace the json.RawMessage + manual double-unmarshal in Criteria.UnmarshalJSON with a small optionalConjunction wrapper whose UnmarshalJSON records that its key was present. Because encoding/json invokes UnmarshalJSON even for a JSON null, this keeps the exact behavior (a present-but-empty or null group still counts, so mixing both top-level keys is rejected) while decoding in a single pass — no raw-message capture, no re-decode, no shadow variables. No behavior change; existing tests pass unchanged. --- core/playlists/parse_nsp_test.go | 14 ++++++++++++++ model/criteria/criteria.go | 27 ++++++++++++++++----------- model/criteria/criteria_test.go | 22 ++++++++++++++++++++++ model/criteria/json.go | 14 ++++++++++++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go index 516a5355d..d6d69866f 100644 --- a/core/playlists/parse_nsp_test.go +++ b/core/playlists/parse_nsp_test.go @@ -113,6 +113,20 @@ var _ = Describe("parseNSP", func() { Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) }) + It("rejects a NSP that mixes top-level 'any' and 'all' instead of silently dropping a group", func() { + nsp := `{ + "name": "Overplayed Favorites", + "any": [{"inPlaylist": {"path": "most-played-favorites.nsp"}}], + "all": [{"notInPlaylist": {"path": "favorites-not-played-in-4-yrs.nsp"}}], + "sort": "playCount, lastPlayed" + }` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) + Expect(err.Error()).To(And(ContainSubstring("all"), ContainSubstring("any"))) + }) + It("gracefully handles non-string name field", func() { nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}` pls := &model.Playlist{Name: "Original"} diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 31d208d08..8c3d183a9 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -103,21 +103,26 @@ func (c Criteria) MarshalJSON() ([]byte, error) { func (c *Criteria) UnmarshalJSON(data []byte) error { var aux struct { - All unmarshalConjunctionType `json:"all"` - Any unmarshalConjunctionType `json:"any"` - Sort string `json:"sort"` - Order string `json:"order"` - Limit int `json:"limit"` - LimitPercent int `json:"limitPercent"` - Offset int `json:"offset"` + All optionalConjunction `json:"all"` + Any optionalConjunction `json:"any"` + Sort string `json:"sort"` + Order string `json:"order"` + Limit int `json:"limit"` + LimitPercent int `json:"limitPercent"` + Offset int `json:"offset"` } if err := json.Unmarshal(data, &aux); err != nil { return err } - if len(aux.Any) > 0 { - c.Expression = Any(aux.Any) - } else if len(aux.All) > 0 { - c.Expression = All(aux.All) + // A Criteria has a single top-level group. Reject files that provide both keys + // (even when one is [] or null) rather than silently dropping one of them. + if aux.All.present && aux.Any.present { + return errors.New("invalid criteria json: 'all' and 'any' cannot both be used at the top level; nest one inside the other instead") + } + if len(aux.Any.rules) > 0 { + c.Expression = Any(aux.Any.rules) + } else if len(aux.All.rules) > 0 { + c.Expression = All(aux.All.rules) } else { return errors.New("invalid criteria json. missing rules (key 'all' or 'any')") } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 092cfd36a..7f214e703 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -80,6 +80,28 @@ var _ = Describe("Criteria", func() { }) }) + Context("with both top-level 'all' and 'any'", func() { + It("returns an error instead of silently dropping one of the groups", func() { + jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}` + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) + }) + + DescribeTable("rejects both keys even when one group is present but empty", + func(jsonStr string) { + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) + }, + Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`), + Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`), + Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`), + ) + }) + Describe("LimitPercent", func() { Describe("JSON round-trip", func() { It("marshals and unmarshals limitPercent", func() { diff --git a/model/criteria/json.go b/model/criteria/json.go index beded9d1f..d0f453524 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -33,6 +33,20 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error { return nil } +// optionalConjunction is a top-level "all"/"any" value that remembers whether its +// key was present at all, so a Criteria providing both can be rejected. encoding/json +// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears +// — including as [] or null — while an absent key leaves it false. +type optionalConjunction struct { + present bool + rules unmarshalConjunctionType +} + +func (o *optionalConjunction) UnmarshalJSON(data []byte) error { + o.present = true + return json.Unmarshal(data, &o.rules) +} + func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { m := make(map[string]any) err := json.Unmarshal(rawValue, &m) From be10f89c117925fabf10394b8d2962a370108b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 11 Jul 2026 09:18:14 -0400 Subject: [PATCH 352/596] ci: don't skip release jobs after the DB migration check on tag pushes (#5760) * ci: don't skip release jobs after the DB migration check on tag pushes The validate-migrations job added in #5750 was gated at job level with if: github.event_name == 'pull_request', so it concluded "skipped" on tag pushes. GitHub Actions propagates a skipped job transitively through the needs chain (actions/runner#491): even though Build overrode its own gate with !cancelled() && !failure() and ran successfully, every job downstream of Build (msi, release, push-manifest-*, PKG uploads) still failed the implicit success() check and was skipped, which broke the v0.63.2 release. Move the pull_request gate from the job to its steps. On non-PR events all steps are skipped and the job concludes "success", so downstream jobs run normally. This also restores the default success() gate on Build, keeping the fail-fast behavior on PRs with a bad migration. * ci: trim workflow comment Condense the explanation of the step-level pull_request gate on the validate-migrations job to the essential rationale. --- .github/workflows/pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index d21d0a681..86a1055f8 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -99,16 +99,20 @@ jobs: validate-migrations: name: Validate DB migrations runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + # PR-only gate is at step level: a job-level skip would propagate through + # the needs chain (actions/runner#491) and skip all release jobs on tag pushes. steps: - uses: actions/checkout@v7 + if: github.event_name == 'pull_request' with: fetch-depth: 0 # Refresh the base branch so the check compares against its CURRENT tip, # not the (possibly stale) commit the PR was opened against. - name: Fetch latest base branch + if: github.event_name == 'pull_request' run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - name: Validate migration ordering and naming + if: github.event_name == 'pull_request' env: BASE_REF: origin/${{ github.event.pull_request.base.ref }} run: ./.github/workflows/validate-migrations.sh @@ -275,10 +279,6 @@ jobs: build: name: Build needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] - # validate-migrations only runs on pull_request, so it is "skipped" on push/tag - # builds. Run Build unless a dependency actually failed — a *skipped* dependency - # (the migration check on non-PR events) must not block release builds. - if: ${{ !cancelled() && !failure() }} strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] From a5efba9a08e349b12cdc25f5c511080cd5f417ad Mon Sep 17 00:00:00 2001 From: Andrew Katsikas <apkatsikas@gmail.com> Date: Sun, 12 Jul 2026 12:25:23 -0400 Subject: [PATCH 353/596] fix(ui): Set Cache-Control: no-cache on index.html - #5766 (#5767) * fix(serve_index): Set Cache-Control: no-cache on index.html - #5766 Signed-off-by: apkatsikas <apkatsikas@gmail.com> * fix(serve_index): Set Cache-Control: no-cache, no-store and must-revalidate on index.html - #5766 Signed-off-by: apkatsikas <apkatsikas@gmail.com> --------- Signed-off-by: apkatsikas <apkatsikas@gmail.com> --- server/serve_index.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/serve_index.go b/server/serve_index.go index 13fa4a9ce..a538daf1a 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -107,6 +107,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl addShareData(r, data, shareInfo) w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") err = t.Execute(w, data) if err != nil { log.Error(r, "Could not execute `index.html` template", err) From 6b9f85efcc42aab1156ada175a18390f5cb0fbb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 12 Jul 2026 13:27:01 -0400 Subject: [PATCH 354/596] fix(subsonic): omit bit depth for lossy targets in transcode decision (#5768) getTranscodeDecision was copying the source file's bit depth into the transcodeStream details, so a 24-bit FLAC negotiated to Opus reported audioBitdepth=24. Lossy codecs (Opus, MP3, AAC) have no PCM bit depth, and ffmpeg only honors a bit depth constraint (-sample_fmt) for lossless outputs, so the value was both meaningless and misleading to clients that use it as a quality indicator. Only set the transcoded stream's bit depth when the target format is lossless; a zero value omits audioBitdepth from the response. This also makes audioBitdepth codec limitations a no-op for lossy targets instead of rejecting the profile. Lossless targets (e.g. FLAC->FLAC downconvert) keep reporting and clamping bit depth as before. --- core/stream/codec.go | 15 ++++++++----- core/stream/decider.go | 2 +- core/stream/decider_test.go | 44 ++++++++++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/core/stream/codec.go b/core/stream/codec.go index 28bff75c4..56d163324 100644 --- a/core/stream/codec.go +++ b/core/stream/codec.go @@ -43,14 +43,17 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int { return sampleRate } -// normalizeSourceBitDepth adjusts the source bit depth for codecs that use -// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is -// what ffmpeg produces). For other codecs, returns the depth unchanged. -func normalizeSourceBitDepth(bitDepth int, codec string) int { - if strings.EqualFold(codec, "dsd") && bitDepth == 1 { +// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy +// targets (they have no PCM bit depth), otherwise the source depth, with DSD +// adjusted to the 24-bit PCM that ffmpeg produces. +func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int { + if !targetIsLossless { + return 0 + } + if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 { return 24 } - return bitDepth + return srcBitDepth } // codecFixedOutputSampleRate returns the mandatory output sample rate for codecs diff --git a/core/stream/decider.go b/core/stream/decider.go index 7940c6862..3c6b01e05 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -269,7 +269,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai Codec: strings.ToLower(profile.AudioCodec), SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), Channels: src.Channels, - BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), + BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless), IsLossless: targetIsLossless, } if ts.Codec == "" { diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 03c4ea437..577207636 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -656,6 +656,44 @@ var _ = Describe("Decider", func() { Expect(decision.TargetBitDepth).To(Equal(24)) }) + It("omits bit depth when transcoding to a lossy format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + Expect(decision.TargetBitDepth).To(BeZero()) + }) + + It("ignores audioBitdepth limitation when transcoding to a lossy format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "opus", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + }) + It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ @@ -695,9 +733,9 @@ var _ = Describe("Decider", func() { // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) Expect(decision.TargetSampleRate).To(Equal(48000)) - // DSD 1-bit → 24-bit PCM - Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) - Expect(decision.TargetBitDepth).To(Equal(24)) + // MP3 is lossy: no bit depth on the transcoded stream + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + Expect(decision.TargetBitDepth).To(BeZero()) }) It("converts DSD sample rate for FLAC target without codec limit", func() { From 969e7e108ca4874d28bcf1e83401b454db65b81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 13 Jul 2026 09:04:24 -0400 Subject: [PATCH 355/596] fix(share): enforce track membership on public share streams (#5769) * fix(share): enforce track membership on public share streams The public share stream endpoint (GET /share/s/{jwt}) validated that the share existed, was unexpired, and that the share owner had library access to the requested track, but it never verified that the track was actually a member of the share. It also accepted stream tokens with no share id (sid) claim, skipping share checks entirely. Enforce that the requested media file belongs to share.Tracks, and make the sid claim mandatory on the stream path. The only producer of stream tokens (encodeMediafileShare) always sets sid, so no legitimate flow is affected; the image endpoint decodes independently and is unchanged. Also document why a JWT is used to represent a shared track: it is a signed, scoped capability for a single public share, not part of authentication. * docs(share): clarify JWT usage comment wording --- server/public/handle_shares.go | 16 ++++++++++ server/public/handle_streams.go | 47 +++++++++++++++++----------- server/public/handle_streams_test.go | 36 +++++++++++++++------ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 18bfcc01c..76f674483 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,6 +97,22 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { return &s } +// encodeMediafileShare builds the signed token embedded in a public share link +// for a single track. +// +// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication. +// The token is a signed, opaque capability that identifies one shared track +// (plus its transcode format/bitrate and the parent share id). We use a JWT here +// (reusing the library we already have) because it is a simple way to get three +// properties for a public link: the embedded ids can't be enumerated by guessing, +// the signature +// makes the claims tamper-evident, and the self-contained exp lets us reject +// stale links without a DB lookup. It carries no user identity (no subject, no +// admin flag) and grants access to nothing beyond the share it belongs to; the +// stream handler still verifies the share exists, is unexpired, and that the +// track is actually a member of it. An attacker who can forge these tokens +// necessarily already holds the signing secret, which also signs real user +// sessions, so that scenario is out of scope for the share boundary specifically. func encodeMediafileShare(s model.Share, id string) string { claims := auth.Claims{ ID: id, diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 8fc407e9e..15abab693 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -3,6 +3,7 @@ package public import ( "errors" "net/http" + "slices" "strconv" "time" @@ -25,23 +26,20 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - var shareOwner *model.User - if info.shareID != "" { - share, err := pub.ds.Share(ctx).Get(info.shareID) - if err != nil { - checkShareError(ctx, w, err, info.shareID) - return - } - if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { - checkShareError(ctx, w, model.ErrExpired, info.shareID) - return - } - shareOwner, err = pub.ds.User(ctx).Get(share.UserID) - if err != nil { - log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + shareOwner, err := pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -56,7 +54,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { } // 404 rather than 403 so the response doesn't reveal whether the id exists. - if shareOwner != nil && !shareOwner.HasLibraryAccess(mf.LibraryID) { + // The track must belong to the share AND be within the owner's libraries. + if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) { http.Error(w, "not found", http.StatusNotFound) return } @@ -98,6 +97,15 @@ type shareTrackInfo struct { shareID string } +func shareContainsTrack(share *model.Share, mediaFileID string) bool { + return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool { + return mf.ID == mediaFileID + }) +} + +// decodeStreamInfo decodes the signed share-link token. This is a scoped +// public-share capability, not an auth credential; see encodeMediafileShare for +// why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { c, err := auth.Validate(tokenString) if err != nil { @@ -106,6 +114,9 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } + if c.ShareID == "" { + return shareTrackInfo{}, errors.New("required claim \"sid\" not found") + } return shareTrackInfo{ id: c.ID, format: c.Format, diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 6fa083045..2f32ea6f2 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -71,14 +71,11 @@ var _ = Describe("decodeStreamInfo", func() { Expect(err).To(HaveOccurred()) }) - It("handles tokens without shareID (backward compat)", func() { + It("rejects a token without a shareID claim", func() { claims := auth.Claims{ID: "mf-123", Format: "opus"} token, _ := auth.CreatePublicToken(claims) - info, err := decodeStreamInfo(token) - Expect(err).NotTo(HaveOccurred()) - Expect(info.id).To(Equal("mf-123")) - Expect(info.format).To(Equal("opus")) - Expect(info.shareID).To(BeEmpty()) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) }) }) @@ -133,7 +130,7 @@ var _ = Describe("handleStream", func() { shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID} + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} userRepo := tests.CreateMockUserRepo() Expect(userRepo.Put(&owner)).To(Succeed()) ds.MockedUser = userRepo @@ -171,6 +168,25 @@ var _ = Describe("handleStream", func() { Expect(streamer.called).To(BeFalse()) }) + It("returns 404 when the track is not a member of the share", func() { + owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) + ds.MockedMediaFile = mfRepo + shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}} + + claims := auth.Claims{ID: "mf-other", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + It("streams a track inside the share owner's libraries", func() { shareOwnedBy( model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, @@ -217,12 +233,12 @@ var _ = Describe("handleStream", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) - It("skips share check for tokens without shareID (backward compat)", func() { + It("returns 400 for tokens without a shareID", func() { claims := auth.Claims{ID: "mf-123"} token, _ := auth.CreatePublicToken(claims) w := makeRequest(token) - // Should get past share check, then fail on media file lookup (no mock data) - Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(streamer.called).To(BeFalse()) }) It("returns 400 for an invalid token", func() { From 4998ac2c591d7c1b17bf36f5109f64f521d1169c Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:32:03 +0000 Subject: [PATCH 356/596] feat(server): add scrobble history Native API (#5761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial scrobble api * feat: add scrobble retrieval api * address feedback (1) * fix spelling * be explicit about get * add primary key field, update index, remove rowid references * use unix timestamp for input and output --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- core/scrobbler/play_tracker_test.go | 2 +- ...ary_key_and_update_index_for_scrobbles.sql | 39 ++++ model/scrobble.go | 12 +- persistence/persistence.go | 2 + persistence/persistence_suite_test.go | 20 ++ persistence/scrobble_repository.go | 65 +++++++ persistence/scrobble_repository_test.go | 181 +++++++++++++++--- server/nativeapi/native_api.go | 3 +- tests/mock_scrobble_repo.go | 23 ++- 9 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 74b4be893..831b0ce0d 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -290,7 +290,7 @@ var _ = Describe("PlayTracker", func() { Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) - Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) + Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix())) }) It("does not record scrobble when history is disabled", func() { diff --git a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql new file mode 100644 index 000000000..220d7cf75 --- /dev/null +++ b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql @@ -0,0 +1,39 @@ +-- +goose Up +CREATE TABLE scrobbles_tmp( + id INTEGER PRIMARY KEY, + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_date; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time); + + +-- +goose Down +CREATE TABLE scrobbles_tmp( + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_user_time; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_date ON scrobbles(submission_time); \ No newline at end of file diff --git a/model/scrobble.go b/model/scrobble.go index e1567abc3..a8022fc16 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,11 +3,17 @@ package model import "time" type Scrobble struct { - MediaFileID string - UserID string - SubmissionTime time.Time + ID int64 `structs:"id" json:"id"` + MediaFileID string `structs:"media_file_id" json:"mediaFileId"` + UserID string `json:"-"` + SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` } type ScrobbleRepository interface { + CountAll(options ...QueryOptions) (int64, error) + Get(id string) (*Scrobble, error) + GetAll(options ...QueryOptions) (Scrobbles, error) RecordScrobble(mediaFileID string, submissionTime time.Time) error } + +type Scrobbles []Scrobble diff --git a/persistence/persistence.go b/persistence/persistence.go index 83211bdd5..1164eb70f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -123,6 +123,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository return s.Tag(ctx).(model.ResourceRepository) case model.Plugin: return s.Plugin(ctx).(model.ResourceRepository) + case model.Scrobble: + return s.Scrobble(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index abc5c4b6a..4f2fd7fe2 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" @@ -157,6 +158,13 @@ var ( testUsers = model.Users{adminUser, regularUser, thirdUser} ) +var ( + firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()} + secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()} + thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()} + scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} +) + func p(path string) string { return filepath.FromSlash(path) } @@ -304,6 +312,18 @@ var _ = BeforeSuite(func() { songComeTogether.Starred = true songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether + + scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository) + for _, s := range scrobbles { + _, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{ + "media_file_id": s.MediaFileID, + "user_id": s.UserID, + "submission_time": s.SubmissionTime, + })) + if err != nil { + panic(err) + } + } }) func GetDBXBuilder() *dbx.DB { diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..7cc60ae23 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -5,6 +5,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -13,11 +14,34 @@ type scrobbleRepository struct { sqlRepository } +func fromTs(_ string, value any) Sqlizer { + return GtOrEq{"scrobbles.submission_time": value} +} + +func toTs(_ string, value any) Sqlizer { + return LtOrEq{"scrobbles.submission_time": value} +} + +func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder { + user := loggedUser(r.ctx) + + return r.newSelect(options...). + Columns("id", "media_file_id", "submission_time"). + Where(Eq{"scrobbles.user_id": user.ID}) +} + func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { r := &scrobbleRepository{} r.ctx = ctx r.db = db r.tableName = "scrobbles" + r.registerModel(&model.Scrobble{}, map[string]filterFunc{ + "from": fromTs, + "to": toTs, + }) + r.setSortMappings(map[string]string{ + "submission_time": "submission_time", + }) return r } @@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t _, err := r.executeSQL(insert) return err } + +func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.baseQuery(), options...) +} + +func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { + sel := r.baseQuery().Where(Eq{"id": id}) + var res model.Scrobble + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + sel := r.baseQuery(options...) + var scrobbles model.Scrobbles + err := r.queryAll(sel, &scrobbles) + return scrobbles, err +} + +func (r *scrobbleRepository) Read(id string) (any, error) { + return r.Get(id) +} + +func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) EntityName() string { + return "scrobble" +} + +func (r *scrobbleRepository) NewInstance() any { + return &model.Scrobble{} +} + +var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) +var _ model.ResourceRepository = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index d43848d03..e9103b127 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -15,32 +16,33 @@ import ( var _ = Describe("ScrobbleRepository", func() { var repo model.ScrobbleRepository - var rawRepo sqlRepository var ctx context.Context - var fileID string - var userID string - - BeforeEach(func() { - fileID = id.NewRandom() - userID = id.NewRandom() - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) - db := GetDBXBuilder() - repo = NewScrobbleRepository(ctx, db) - - rawRepo = sqlRepository{ - ctx: ctx, - tableName: "scrobbles", - db: db, - } - }) - - AfterEach(func() { - _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() - _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() - _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() - }) Describe("RecordScrobble", func() { + var fileID string + var userID string + var rawRepo sqlRepository + + BeforeEach(func() { + fileID = id.NewRandom() + userID = id.NewRandom() + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) + db := GetDBXBuilder() + repo = NewScrobbleRepository(ctx, db) + + rawRepo = sqlRepository{ + ctx: ctx, + tableName: "scrobbles", + db: db, + } + }) + + AfterEach(func() { + _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() + _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() + _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() + }) + It("records a scrobble event", func() { submissionTime := time.Now().UTC() @@ -81,4 +83,137 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix())) }) }) + + Context("admin user (id userid)", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("1") + Expect(err).To(BeNil()) + Expect(scrobble.ID).To(Equal(int64(1))) + Expect(scrobble.MediaFileID).To(Equal("1001")) + Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("2") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(2)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + + Expect(scrobbles[1].ID).To(Equal(int64(1))) + Expect(scrobbles[1].MediaFileID).To(Equal("1001")) + Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + }) + }) + }) + + Context("non-admin user", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), regularUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(1))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("2") + Expect(err).To(BeNil()) + Expect(scrobble.ID).To(Equal(int64(2))) + Expect(scrobble.MediaFileID).To(Equal("1003")) + Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + }) + }) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..5a7023eb6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -72,7 +72,8 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - api.R(r, "/tag", model.Tag{}, true) + api.R(r, "/tag", model.Tag{}, false) + api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) } diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 34561c257..d6d88d221 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -2,6 +2,7 @@ package tests import ( "context" + "strconv" "time" "github.com/navidrome/navidrome/model" @@ -13,12 +14,32 @@ type MockScrobbleRepo struct { ctx context.Context } +func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { + for idx := range m.RecordedScrobbles { + if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { + return &m.RecordedScrobbles[idx], nil + } + } + + return nil, model.ErrNotFound +} + +func (m *MockScrobbleRepo) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + return m.RecordedScrobbles, nil +} + +func (m *MockScrobbleRepo) CountAll(options ...model.QueryOptions) (int64, error) { + return int64(len(m.RecordedScrobbles)), nil +} + func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { user, _ := request.UserFrom(m.ctx) m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ MediaFileID: fileID, UserID: user.ID, - SubmissionTime: submissionTime, + SubmissionTime: submissionTime.Unix(), }) return nil } + +var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) From cc315dcc8c38fc40e721e90d9d22c6f0aeec97d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 13 Jul 2026 12:04:29 -0400 Subject: [PATCH 357/596] perf(db): keep query planner statistics trustworthy with full ANALYZE (#5740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(db): keep query planner statistics trustworthy with full ANALYZE PRAGMA optimize's internal ANALYZE runs with a limited analysis budget (~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality indexes: on a 96K-track library it claimed (missing, library_id) narrows to ~2000 rows when it matches the whole table. The planner then prefers that index over the sort index and falls back to a full-table temp B-tree sort per request, turning paginated song listings into multi-second queries (reproduced at 5.5s on real hardware; ~90x slower than with correct stats). Every index-creating migration re-triggered the poisoning via the post-migration optimize, and the daily optimizer could re-trigger it on large library changes. Setting analysis_limit on the connection does not help: optimize ignores it. Run a plain full ANALYZE instead: after migrations with schema changes, and in db.Optimize (daily schedule and scan-end). Stats are stored in the database file, so one connection suffices and the per-connection pool loop is gone. The Optimize call at shutdown is removed: stats are maintained at migration/scan/daily points, and an ANALYZE during shutdown only delays it and races container stop timeouts. * perf(db): drop startup PRAGMA optimize that re-poisons planner stats The startup PRAGMA optimize=0x10002 runs SQLite's budget-limited internal ANALYZE (bit 0x02), which writes truncated sqlite_stat1 rows for low-cardinality indexes -- the exact statistics-poisoning this PR set out to eliminate. Because DevOptimizeDB defaults to true, a restart with no pending migrations would re-poison the planner until the next scan or daily Optimize. Remove it: statistics are already refreshed with a full ANALYZE after schema-changing migrations (Init) and via Optimize at scan-end and on the daily schedule, so nothing on the startup path needs to touch them. Also clarify that Optimize is a no-op unless DevOptimizeDB is enabled. * chore(db): remove the DevOptimizeDB flag and skip Optimize on quick scans The flag only gated the optimize/ANALYZE maintenance calls and there is no reason to leave planner statistics unmaintained; the guards are gone along with the flag. The scan-end Optimize now runs only after full scans — quick scans barely move the statistics, and the daily schedule covers drift. * style(scanner): drop redundant comment in runOptimize * chore(persistence): drop the no-op PRAGMA optimize from ScanEnd Mask 0x10000 only selects candidate tables by size change; without the 0x02 action bit optimize does nothing (verified: sqlite_stat1 stays stale after a 100x table growth). The scan-end statistics refresh is db.Optimize's full ANALYZE, and the expression-collation-index concern the old comment guarded against no longer applies. * fix(scanner): run the post-scan ANALYZE in the server process With the external scanner (the default), the scan pipeline runs in a subprocess, so its ANALYZE was invisible to the server: SQLite loads sqlite_stat1 into the process's shared schema cache, and an ANALYZE from another process does not refresh it — verified with the production DSN that even brand-new pool connections keep planning with the old statistics until the server restarts. An in-process ANALYZE, by contrast, is immediately visible to every pooled connection through the same shared cache. Move the full-scan Optimize from the scanner pipeline to the scan controller, which always runs in the server process. * fix(scanner): honor promoted full scans in the optimize gate A quick scan resuming an interrupted full scan is promoted inside the scanner (possibly in a subprocess); mirror the promotion in the controller so the post-scan ANALYZE isn't skipped. * refactor: apply cleanup review findings - drop forceFullRescan's inline ANALYZE: Init already runs a full ANALYZE after any migration batch with schema changes, so upgrades including a full-rescan migration analyzed the whole DB twice - resumingFullScan uses a filtered CountAll instead of fetching and scanning all libraries - document why CallScan (CLI) deliberately skips the post-scan Optimize * perf(db): make planner analysis maintenance resilient Check analysis freshness every 30 minutes and refresh statistics when the last successful run is over 24 hours old or a scan marked them pending. Persist successful analysis state, retry skipped or failed maintenance, coordinate checks with scans, and cover standalone CLI full scans. * perf(db): avoid analyzing routine quick-scan changes Reserve pending analysis for full scans, unscanned libraries, and retry state. Incremental quick scans now rely on the 24-hour freshness window instead of triggering a full ANALYZE at the next maintenance check. * fix(scan): analyze resumed full scans in CLI * fix(db): back off failed analysis retries * feat(db): allow disabling scheduled analysis * test(db): remove redundant analysis coverage * refactor(db): split ANALYZE maintenance into optimize.go and dedupe call sites - move query-planner statistics code from db.go to its own optimize.go (and matching optimize_test.go) - log ANALYZE elapsed time inside Optimize/OptimizeIfNeeded instead of repeating the timing block at every call site - drop the LastDBAnalyzeAttemptAt write on success: it is only read while failures >= 1, and every failure rewrites it first - extract runPostScanAnalysis (cmd) and anyIncludedLibrary (scanner) helpers --- cmd/root.go | 22 ++- cmd/scan.go | 37 ++++- cmd/scan_test.go | 15 ++ conf/configuration.go | 4 +- conf/configuration_test.go | 13 ++ consts/consts.go | 7 +- core/metrics/insights.go | 1 + core/metrics/insights/data.go | 79 +++++------ db/db.go | 49 +------ db/export_test.go | 7 +- db/migrations/migration.go | 8 -- db/optimize.go | 224 ++++++++++++++++++++++++++++++ db/optimize_test.go | 162 +++++++++++++++++++++ persistence/library_repository.go | 9 -- scanner/controller.go | 79 ++++++++++- scanner/controller_test.go | 38 +++++ scanner/scanner.go | 13 -- scanner/scanner_selective_test.go | 36 ++++- 18 files changed, 673 insertions(+), 130 deletions(-) create mode 100644 db/optimize.go create mode 100644 db/optimize_test.go diff --git a/cmd/root.go b/cmd/root.go index 08773176a..b231aae0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startPlaybackServer(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBOptimizer(ctx)) + g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { @@ -275,16 +275,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error { } } -func scheduleDBOptimizer(ctx context.Context) func() error { +func scheduleDBAnalyzer(ctx context.Context) func() error { return func() error { - log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) + if !conf.Server.EnableScheduledDBAnalyze { + log.Info(ctx, "Scheduled DB analysis is DISABLED") + return nil + } + log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { - if scanner.IsScanning() { - log.Debug(ctx, "Skipping DB optimization because a scan is in progress") + _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { + release, ok := scanner.LockForMaintenance() + if !ok { + log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") return } - db.Optimize(ctx) + defer release() + if _, err := db.OptimizeIfNeeded(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } }) return err } diff --git a/cmd/scan.go b/cmd/scan.go index d8a563396..320b401d4 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/gob" + "errors" "fmt" "os" "strings" @@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{ }, } -func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { +func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) { + var changesDetected bool + var scanErrors []error for status := range pl.ReadOrDone(ctx, progress) { if status.Warning != "" { log.Warn(ctx, "Scan warning", "error", status.Warning) } if status.Error != "" { log.Error(ctx, "Scan error", "error", status.Error) + scanErrors = append(scanErrors, errors.New(status.Error)) + } + if status.ChangesDetected { + changesDetected = true } - // Discard the progress status, we only care about errors } if fullScan { @@ -59,6 +65,7 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre } else { log.Info("Finished rescan") } + return changesDetected, errors.Join(scanErrors...) } func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { @@ -95,6 +102,16 @@ func runScanner(ctx context.Context) { log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) } + effectiveFullScan := fullScan + if !subprocess { + effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets) + if effectiveFullScan { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + } + progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) @@ -104,7 +121,21 @@ func runScanner(ctx context.Context) { if subprocess { trackScanAsSubprocess(ctx, progress) } else { - trackScanInteractively(ctx, progress) + changesDetected, scanErr := trackScanInteractively(ctx, progress) + runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr) + } +} + +func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) { + if changesDetected { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + if effectiveFullScan && scanErr == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index beeecca19..309d09f98 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -1,14 +1,29 @@ package cmd import ( + "context" "os" "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("trackScanInteractively", func() { + It("reports changes and scan errors", func() { + progress := make(chan *scanner.ProgressInfo, 2) + progress <- &scanner.ProgressInfo{ChangesDetected: true} + progress <- &scanner.ProgressInfo{Error: "scan failed"} + close(progress) + + changesDetected, err := trackScanInteractively(context.Background(), progress) + Expect(changesDetected).To(BeTrue()) + Expect(err).To(MatchError("scan failed")) + }) +}) + var _ = Describe("readTargetsFromFile", func() { var tempDir string diff --git a/conf/configuration.go b/conf/configuration.go index 8646bf075..e939ebb7e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -51,6 +51,7 @@ type configOptions struct { EnableExternalServices bool EnableM3UExternalAlbumArt bool EnableInsightsCollector bool + EnableScheduledDBAnalyze bool EnableMediaFileCoverArt bool TranscodingCacheSize string ImageCacheSize string @@ -147,7 +148,6 @@ type configOptions struct { DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 - DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool } @@ -800,6 +800,7 @@ func setViperDefaults() { viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) + viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) @@ -891,7 +892,6 @@ func setViperDefaults() { viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) - viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 9c25a0d19..e43c91a4b 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -58,6 +58,19 @@ var _ = Describe("Configuration", func() { }) }) + Describe("scheduled DB analysis", func() { + It("is enabled by default", func() { + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) + }) + + It("can be disabled", func() { + viper.Set("enablescheduleddbanalyze", false) + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) + }) + }) + Describe("ValidateURL", func() { It("accepts a valid http URL", func() { fn := conf.ValidateURL("TestOption", "http://example.com/path") diff --git a/consts/consts.go b/consts/consts.go index 3795b590a..73f89b450 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -20,6 +20,10 @@ const ( LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" + LastDBAnalyzeAtKey = "LastDBAnalyzeAt" + LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" + DBAnalyzePendingKey = "DBAnalyzePending" + DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -28,7 +32,8 @@ const ( DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - OptimizeDBSchedule = "@every 24h" + DBAnalyzeCheckSchedule = "@every 30m" + DBAnalyzeMaxAge = 24 * time.Hour // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option diff --git a/core/metrics/insights.go b/core/metrics/insights.go index bcd0343c2..78391779a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -223,6 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 34648a49b..126d759bc 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -43,45 +43,46 @@ type Data struct { FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"` } `json:"library"` Config struct { - LogLevel string `json:"logLevel,omitempty"` - LogFileConfigured bool `json:"logFileConfigured,omitempty"` - TLSConfigured bool `json:"tlsConfigured,omitempty"` - ScannerEnabled bool `json:"scannerEnabled,omitempty"` - ScannerExtractor string `json:"scannerExtractor,omitempty"` - ScanSchedule string `json:"scanSchedule,omitempty"` - ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` - ScanOnStartup bool `json:"scanOnStartup,omitempty"` - TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` - ImageCacheSize string `json:"imageCacheSize,omitempty"` - EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` - EnableDownloads bool `json:"enableDownloads,omitempty"` - EnableSharing bool `json:"enableSharing,omitempty"` - EnableStarRating bool `json:"enableStarRating,omitempty"` - EnableLastFM bool `json:"enableLastFM,omitempty"` - EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` - EnableDeezer bool `json:"enableDeezer,omitempty"` - EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` - EnableJukebox bool `json:"enableJukebox,omitempty"` - EnablePrometheus bool `json:"enablePrometheus,omitempty"` - EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` - CoverArtQuality int `json:"coverArtQuality,omitempty"` - EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` - UICoverArtSize int `json:"uiCoverArtSize,omitempty"` - EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` - EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` - SessionTimeout uint64 `json:"sessionTimeout,omitempty"` - SearchFullString bool `json:"searchFullString,omitempty"` - SearchBackend string `json:"searchBackend,omitempty"` - RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` - PreferSortTags bool `json:"preferSortTags,omitempty"` - BackupSchedule string `json:"backupSchedule,omitempty"` - BackupCount int `json:"backupCount,omitempty"` - DevActivityPanel bool `json:"devActivityPanel,omitempty"` - DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` - HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` - ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` - HasCustomPID bool `json:"hasCustomPID,omitempty"` - HasCustomTags bool `json:"hasCustomTags,omitempty"` + LogLevel string `json:"logLevel,omitempty"` + LogFileConfigured bool `json:"logFileConfigured,omitempty"` + TLSConfigured bool `json:"tlsConfigured,omitempty"` + ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScannerExtractor string `json:"scannerExtractor,omitempty"` + ScanSchedule string `json:"scanSchedule,omitempty"` + ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` + ScanOnStartup bool `json:"scanOnStartup,omitempty"` + EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"` + TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` + ImageCacheSize string `json:"imageCacheSize,omitempty"` + EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` + EnableDownloads bool `json:"enableDownloads,omitempty"` + EnableSharing bool `json:"enableSharing,omitempty"` + EnableStarRating bool `json:"enableStarRating,omitempty"` + EnableLastFM bool `json:"enableLastFM,omitempty"` + EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` + EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` + EnableJukebox bool `json:"enableJukebox,omitempty"` + EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` + EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` + SessionTimeout uint64 `json:"sessionTimeout,omitempty"` + SearchFullString bool `json:"searchFullString,omitempty"` + SearchBackend string `json:"searchBackend,omitempty"` + RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` + PreferSortTags bool `json:"preferSortTags,omitempty"` + BackupSchedule string `json:"backupSchedule,omitempty"` + BackupCount int `json:"backupCount,omitempty"` + DevActivityPanel bool `json:"devActivityPanel,omitempty"` + DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` Plugins map[string]PluginInfo `json:"plugins,omitempty"` } diff --git a/db/db.go b/db/db.go index 6e5b2f569..3f3f61d71 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "fmt" "runtime" + "time" "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" @@ -47,12 +48,6 @@ func Db() *sql.DB { if err != nil { log.Fatal("Error opening database", err) } - if conf.Server.DevOptimizeDB { - _, err = db.Exec("PRAGMA optimize=0x10002") - if err != nil { - log.Error("Error applying PRAGMA optimize", err) - } - } return db }) } @@ -61,9 +56,6 @@ func Close(ctx context.Context) { // Ignore cancellations when closing the DB ctx = context.WithoutCancel(ctx) - // Run optimize before closing - Optimize(ctx) - log.Info(ctx, "Closing Database") err := Db().Close() if err != nil { @@ -102,11 +94,11 @@ func Init(ctx context.Context) func() { log.Fatal(ctx, "Failed to apply new migrations", err) } - if hasSchemaChanges && conf.Server.DevOptimizeDB { - log.Debug(ctx, "Applying PRAGMA optimize after schema changes") - _, err = db.ExecContext(ctx, "PRAGMA optimize") + if hasSchemaChanges { + log.Debug(ctx, "Running ANALYZE after schema changes") + err = optimizeAt(ctx, db, time.Now()) if err != nil { - log.Error(ctx, "Error applying PRAGMA optimize", err) + log.Error(ctx, "Error running ANALYZE", err) } } @@ -115,37 +107,6 @@ func Init(ctx context.Context) func() { } } -// Optimize runs PRAGMA optimize on each connection in the pool -func Optimize(ctx context.Context) { - if !conf.Server.DevOptimizeDB { - return - } - numConns := Db().Stats().OpenConnections - if numConns == 0 { - log.Debug(ctx, "No open connections to optimize") - return - } - log.Debug(ctx, "Optimizing open connections", "numConns", numConns) - var conns []*sql.Conn - for range numConns { - conn, err := Db().Conn(ctx) - conns = append(conns, conn) - if err != nil { - log.Error(ctx, "Error getting connection from pool", err) - continue - } - _, err = conn.ExecContext(ctx, "PRAGMA optimize;") - if err != nil { - log.Error(ctx, "Error running PRAGMA optimize", err) - } - } - - // Return all connections to the Connection Pool - for _, conn := range conns { - conn.Close() - } -} - type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/export_test.go b/db/export_test.go index 734a4462f..02b88cd66 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,6 +2,9 @@ package db // Definitions for testing private methods var ( - IsSchemaEmpty = isSchemaEmpty - BackupPath = backupPath + IsSchemaEmpty = isSchemaEmpty + BackupPath = backupPath + OptimizeDBAt = optimizeAt + OptimizeDBIfNeeded = optimizeIfNeeded + RecordAnalyzeFailure = recordAnalyzeFailure ) diff --git a/db/migrations/migration.go b/db/migrations/migration.go index 9b1098af1..df1c392a5 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" ) @@ -21,13 +20,6 @@ func notice(ctx context.Context, tx *sql.Tx, msg string) { // Call this in migrations that requires a full rescan func forceFullRescan(ctx context.Context, tx *sql.Tx) error { - // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. - if conf.Server.DevOptimizeDB { - _, err := tx.ExecContext(ctx, `ANALYZE;`) - if err != nil { - return err - } - } _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) diff --git a/db/optimize.go b/db/optimize.go new file mode 100644 index 000000000..f46906c4e --- /dev/null +++ b/db/optimize.go @@ -0,0 +1,224 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" +) + +var analyzeMux sync.Mutex + +// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided +// because its limited analysis misestimates Navidrome's low-cardinality indexes. +func Optimize(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + if err := optimizeAt(ctx, Db(), start); err != nil { + return err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return nil +} + +// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation +// marked them for refresh. +func OptimizeIfNeeded(ctx context.Context) (bool, error) { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + ran, err := optimizeIfNeeded(ctx, Db(), start) + if err != nil || !ran { + return ran, err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return true, nil +} + +func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + due, err := optimizeDue(ctx, db, now) + if err != nil || !due { + return false, err + } + return true, optimizeAt(ctx, db, now) +} + +func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + backingOff, err := analyzeRetryBackoffActive(ctx, db, now) + if err != nil || backingOff { + return false, err + } + + pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey) + if err != nil { + return false, err + } + if found && pending == "1" { + return true, nil + } + + value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey) + if err != nil { + return false, err + } + if !found { + return true, nil + } + + lastAnalyze, valid := parseAnalyzeTime(value) + if !valid || lastAnalyze.After(now) { + return true, nil + } + return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil +} + +func parseAnalyzeTime(value string) (time.Time, bool) { + parsed, err := time.Parse(time.RFC3339Nano, value) + return parsed, err == nil +} + +func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey) + if err != nil || !found { + return false, err + } + failures, _ := strconv.Atoi(value) + if failures < 1 { + return false, nil + } + + value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey) + if err != nil || !found { + return false, err + } + lastAttempt, valid := parseAnalyzeTime(value) + if !valid || lastAttempt.After(now) { + return false, nil + } + return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil +} + +func analyzeRetryDelay(failures int) time.Duration { + switch failures { + case 1: + return 30 * time.Minute + case 2: + return time.Hour + case 3: + return 2 * time.Hour + default: + return 24 * time.Hour + } +} + +// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check. +func MarkOptimizePending(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + return markOptimizePending(ctx, Db()) +} + +func markOptimizePending(ctx context.Context, db *sql.DB) error { + return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1") +} + +func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error { + if err := markOptimizePending(ctx, db); err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err)) + } + log.Debug(ctx, "Refreshing query planner statistics") + _, err := db.ExecContext(ctx, "ANALYZE") + if err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err)) + } + if err = recordAnalyzeSuccess(ctx, db, now); err != nil { + return recordAnalyzeError(ctx, db, now, err) + } + return nil +} + +func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil { + return fmt.Errorf("clearing pending ANALYZE: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil { + return fmt.Errorf("clearing ANALYZE failure count: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("recording ANALYZE state: %w", err) + } + return nil +} + +func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error { + if err := recordAnalyzeFailure(ctx, db, now); err != nil { + return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err)) + } + return analyzeErr +} + +func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey) + if err != nil { + return err + } + failures := 0 + if found { + failures, _ = strconv.Atoi(value) + failures = max(failures, 0) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +type sqlExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +type sqlQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func putProperty(ctx context.Context, db sqlExecer, key, value string) error { + _, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + return err +} + +func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) { + var value string + err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return value, err == nil, err +} diff --git a/db/optimize_test.go b/db/optimize_test.go new file mode 100644 index 000000000..da9b3b9c9 --- /dev/null +++ b/db/optimize_test.go @@ -0,0 +1,162 @@ +package db_test + +import ( + "context" + "database/sql" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/db" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Optimize", func() { + var ( + ctx context.Context + database *sql.DB + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC) + var err error + database, err = sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(database.Close) + + _, err = database.Exec(`create table property( + id varchar(255) primary key, + value varchar(255) not null default '' + )`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create table analyze_probe(id integer primary key, flag int)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec(`insert into analyze_probe(flag) + with recursive s(x) as (select 1 union all select x+1 from s where x < 3000) + select 0 from s`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create index probe_flag on analyze_probe(flag)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("analyze") + Expect(err).ToNot(HaveOccurred()) + }) + + putProperty := func(key, value string) { + _, err := database.Exec(`insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + Expect(err).ToNot(HaveOccurred()) + } + + getProperty := func(key string) string { + var value string + Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed()) + return value + } + + poisonStats := func() { + _, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'") + Expect(err).ToNot(HaveOccurred()) + } + + It("replaces poisoned planner statistics with full-quality ones", func() { + poisonStats() + putProperty(consts.DBAnalyzePendingKey, "1") + + Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) + + var stat string + err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat) + Expect(err).ToNot(HaveOccurred()) + // A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count. + Expect(stat).To(Equal("3000 3000")) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("runs when no previous analysis was recorded", func() { + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("skips a recent analysis when no refresh is pending", func() { + lastAnalyze := now.Add(-23 * time.Hour) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + poisonStats() + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano))) + + var stat string + Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed()) + Expect(stat).To(Equal("3000 50")) + }) + + It("runs when the previous analysis is stale", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("runs when a refresh is pending even if the previous analysis is recent", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "1") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + DescribeTable("backs off after consecutive analysis failures", + func(failures string, retryDelay time.Duration) { + putProperty(consts.DBAnalyzePendingKey, "1") + putProperty(consts.DBAnalyzeFailureCountKey, failures) + putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano)) + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + + ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0")) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }, + Entry("for 30 minutes after the first failure", "1", 30*time.Minute), + Entry("for one hour after the second failure", "2", time.Hour), + Entry("for two hours after the third failure", "3", 2*time.Hour), + Entry("for 24 hours after the fourth failure", "4", 24*time.Hour), + ) + + It("records consecutive analysis failures", func() { + putProperty(consts.DBAnalyzeFailureCountKey, "2") + + Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed()) + + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3")) + Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + + It("does not record success when analysis fails", func() { + lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze) + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled"))) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 3789a71c9..5a0142423 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -173,15 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error { Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) _, err := r.executeSQL(sq) - if err != nil { - return err - } - // https://www.sqlite.org/pragma.html#pragma_optimize - // Use mask 0x10000 to check table sizes without running ANALYZE - // Running ANALYZE can cause query planner issues with expression-based collation indexes - if conf.Server.DevOptimizeDB { - _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) - } return err } diff --git a/scanner/controller.go b/scanner/controller.go index 175b92e26..463718ba3 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "sync" "sync/atomic" "time" @@ -13,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -211,6 +214,16 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ ctx := request.AddValues(s.rootCtx, requestCtx) ctx = auth.WithAdminUser(ctx, s.ds) + // A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens + // inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must + // be read before the scan: ScanEnd clears the flag. + effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets) + if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Scanner: Error marking DB analysis pending", err) + } + } + // Send the initial scan status event s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) progress := make(chan *ProgressInfo, 100) @@ -229,6 +242,15 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ if scanError != nil { _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) } + // Refresh the query-planner statistics after a successful full scan. This must run in the + // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the + // server's pooled connections; their shared schema cache keeps the old statistics until the + // process restarts. + if effectiveFullScan && scanError == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Scanner: Error analyzing DB", err) + } + } // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") @@ -255,18 +277,73 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ // This is a global variable that is used to prevent multiple scans from running at the same time. // "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg -var running atomic.Bool +var ( + running atomic.Bool + scanMaintenanceMux sync.Mutex +) func lockScan(ctx context.Context) (func(), error) { if !running.CompareAndSwap(false, true) { log.Debug(ctx, "Scanner already running, ignoring request") return func() {}, ErrAlreadyScanning } + scanMaintenanceMux.Lock() return func() { + scanMaintenanceMux.Unlock() running.Store(false) }, nil } +// LockForMaintenance prevents a scan from starting while database maintenance is running. +func LockForMaintenance() (func(), bool) { + if !scanMaintenanceMux.TryLock() { + return func() {}, false + } + if running.Load() { + scanMaintenanceMux.Unlock() + return func() {}, false + } + return scanMaintenanceMux.Unlock, true +} + +// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted +// full scan in one of the included libraries. +func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { + if fullScan { + return true + } + return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { + return library.FullScanInProgress + }) +} + +func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool { + return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool { + return library.LastScanAt.IsZero() + }) +} + +// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is +// empty) matches pred. +func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { + libraries, err := ds.Library(ctx).GetAll() + if err != nil { + return false + } + if len(targets) == 0 { + return slices.ContainsFunc(libraries, pred) + } + + targeted := make(map[int]struct{}, len(targets)) + for _, target := range targets { + targeted[target.LibraryID] = struct{}{} + } + return slices.ContainsFunc(libraries, func(library model.Library) bool { + _, ok := targeted[library.ID] + return ok && pred(library) + }) +} + func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) { s.count.Store(0) s.folderCount.Store(0) diff --git a/scanner/controller_test.go b/scanner/controller_test.go index d60d432b4..e4814da64 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -55,3 +55,41 @@ var _ = Describe("Controller", func() { }) }) }) + +var _ = Describe("LockForMaintenance", func() { + It("allows only one database maintenance operation at a time", func() { + release, ok := scanner.LockForMaintenance() + Expect(ok).To(BeTrue()) + DeferCleanup(release) + + _, ok = scanner.LockForMaintenance() + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("EffectiveFullScan", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + libraries := &tests.MockLibraryRepo{} + libraries.SetData(model.Libraries{ + {ID: 1, FullScanInProgress: true}, + {ID: 2}, + }) + ds = &tests.MockDataStore{MockedLibrary: libraries} + }) + + It("detects an interrupted full scan in a targeted library", func() { + targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) + }) + + It("detects an interrupted full scan when scanning all libraries", func() { + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue()) + }) + + It("ignores interrupted full scans in untargeted libraries", func() { + targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) + }) +}) diff --git a/scanner/scanner.go b/scanner/scanner.go index 871b0c696..27e2b19d2 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -161,9 +160,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Update last_scan_completed_at for all libraries s.runUpdateLibraries(ctx, &state), - - // Optimize DB - s.runOptimize(ctx), ) if err != nil { log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) @@ -280,15 +276,6 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun } } -func (s *scannerImpl) runOptimize(ctx context.Context) func() error { - return func() error { - start := time.Now() - db.Optimize(ctx) - log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) - return nil - } -} - func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6c70eb268..17772bf9d 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -4,10 +4,12 @@ import ( "context" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" @@ -80,7 +82,7 @@ var _ = Describe("ScanFolders", Ordered, func() { rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) - createFS(fstest.MapFS{ + fsys = createFS(fstest.MapFS{ "rock/track1.mp3": rock(track(1, "Rock Track 1")), "rock/track2.mp3": rock(track(2, "Rock Track 2")), "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), @@ -122,6 +124,38 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + }) + + Describe("Planner statistics maintenance", func() { + It("does not mark routine quick-scan changes for immediate analysis", func() { + rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) + fsys = createFS(fstest.MapFS{ + "rock/track1.mp3": rock(track(1, "Rock Track 1")), + }) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + + fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("does not treat an interrupted scan in an untargeted library as a full scan", func() { + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed()) + + lastAnalyze := "2026-07-09T12:00:00Z" + Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) From edddc1acb5a566919cf3cfb026cf4c2a702c00fd Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 13 Jul 2026 14:06:50 -0400 Subject: [PATCH 358/596] chore(deps): update go-sqlite3, reflex, and golang.org/x dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 26 +++++++++++++------------- go.sum | 58 ++++++++++++++++++++++++++-------------------------------- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 71aabdcd7..014a43a56 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.47 + github.com/mattn/go-sqlite3 v1.14.48 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 @@ -59,12 +59,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.43.0 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 - golang.org/x/term v0.44.0 - golang.org/x/text v0.39.0 + golang.org/x/image v0.44.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 + golang.org/x/text v0.40.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -75,7 +75,7 @@ require ( github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/reflex v0.3.1 // indirect + github.com/cespare/reflex v0.3.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect + github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index ec532b0a0..064974edb 100644 --- a/go.sum +++ b/go.sum @@ -16,13 +16,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= -github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= +github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= +github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,7 +61,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= @@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -143,11 +141,8 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -174,8 +169,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= -github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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= @@ -309,39 +304,38 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From feda8de7e9e172aeeba9ebbf3ccb5ba454bb85af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 13 Jul 2026 16:08:07 -0400 Subject: [PATCH 359/596] ci: bump github-script, cache and setup-qemu actions to latest majors (#5778) Update the GitHub Actions that had newer major versions available; all other actions in the workflows were already pinned to their latest major tag. - actions/github-script: v7 -> v9 - actions/cache: v5 -> v6 - docker/setup-qemu-action: v3 -> v4 --- .github/workflows/download-link-on-pr.yml | 2 +- .github/workflows/pipeline.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 076f963d4..5b421331b 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 86a1055f8..8e6e8126a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -166,7 +166,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 @@ -323,7 +323,7 @@ jobs: - name: Set up QEMU for smoke test if: env.IS_LINUX == 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # The binary is static, so binfmt+qemu runs it directly on the runner. # Catches startup crashes in cross-compiled binaries before they ship, From 9ae252c418fe3884524a33eea3d262e569c3ac6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 14 Jul 2026 07:20:17 -0400 Subject: [PATCH 360/596] feat(ui): add Artists, Songs, and Playlists to Default View options (#5754) * Add resource lists to default view options * refactor(ui): reuse getStoredDefaultView in AlbumList default-view redirect Avoid duplicating the localStorage fallback logic and skip the unused albumLists lookup in the resource-redirect branch, per PR review feedback. --- ui/src/album/AlbumList.jsx | 12 +++++-- ui/src/personal/SelectDefaultView.jsx | 9 ++--- ui/src/personal/defaultViews.js | 20 +++++++++++ ui/src/personal/defaultViews.test.js | 48 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 ui/src/personal/defaultViews.js create mode 100644 ui/src/personal/defaultViews.test.js diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 0b8c256df..a860c85bb 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -28,7 +28,11 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' -import albumLists, { defaultAlbumList } from './albumLists' +import albumLists from './albumLists' +import { + getStoredDefaultView, + isResourceDefaultView, +} from '../personal/defaultViews' import config from '../config' import AlbumInfo from './AlbumInfo' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' @@ -220,8 +224,10 @@ const AlbumList = (props) => { // If it does not have filter/sort params (usually coming from Menu), // reload with correct filter/sort params if (!location.search) { - const type = - albumListType || localStorage.getItem('defaultView') || defaultAlbumList + const type = albumListType || getStoredDefaultView() + if (isResourceDefaultView(type)) { + return <Redirect to={`/${type}`} /> + } const listParams = albumLists[type] if (type === 'random') { refresh() diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx index 71c87305c..e90fd65bc 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.jsx @@ -1,13 +1,10 @@ import { SelectInput, useTranslate } from 'react-admin' -import albumLists, { defaultAlbumList } from '../album/albumLists' +import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = localStorage.getItem('defaultView') || defaultAlbumList - const choices = Object.keys(albumLists).map((type) => ({ - id: type, - name: translate(`resources.album.lists.${type}`), - })) + const current = getStoredDefaultView() + const choices = getDefaultViewChoices(translate) return ( <SelectInput diff --git a/ui/src/personal/defaultViews.js b/ui/src/personal/defaultViews.js new file mode 100644 index 000000000..09e615419 --- /dev/null +++ b/ui/src/personal/defaultViews.js @@ -0,0 +1,20 @@ +import albumLists, { defaultAlbumList } from '../album/albumLists' + +export const resourceDefaultViews = ['artist', 'song', 'playlist'] + +export const isResourceDefaultView = (defaultView) => + resourceDefaultViews.includes(defaultView) + +export const getDefaultViewChoices = (translate) => [ + ...Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })), + ...resourceDefaultViews.map((resource) => ({ + id: resource, + name: translate(`resources.${resource}.name`, { smart_count: 2 }), + })), +] + +export const getStoredDefaultView = () => + localStorage.getItem('defaultView') || defaultAlbumList diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js new file mode 100644 index 000000000..44057a736 --- /dev/null +++ b/ui/src/personal/defaultViews.test.js @@ -0,0 +1,48 @@ +import { + getDefaultViewChoices, + getStoredDefaultView, + isResourceDefaultView, + resourceDefaultViews, +} from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' + +describe('defaultViews', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('includes album lists and top-level resource lists as choices', () => { + const choices = getDefaultViewChoices((key, options) => + options?.smart_count ? `${key}:${options.smart_count}` : key, + ) + + expect(choices.map((choice) => choice.id)).toEqual([ + ...Object.keys(albumLists), + ...resourceDefaultViews, + ]) + expect(choices).toEqual( + expect.arrayContaining([ + { id: 'artist', name: 'resources.artist.name:2' }, + { id: 'song', name: 'resources.song.name:2' }, + { id: 'playlist', name: 'resources.playlist.name:2' }, + ]), + ) + }) + + it('identifies resource-backed default views', () => { + expect(isResourceDefaultView('artist')).toBe(true) + expect(isResourceDefaultView('song')).toBe(true) + expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('recentlyAdded')).toBe(false) + }) + + it('falls back to the default album list when no default view is stored', () => { + expect(getStoredDefaultView()).toBe(defaultAlbumList) + }) + + it('returns the stored default view', () => { + localStorage.setItem('defaultView', 'playlist') + + expect(getStoredDefaultView()).toBe('playlist') + }) +}) From ca27335d0671a08210fb7e4663ab21d7291ff651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 14 Jul 2026 07:38:25 -0400 Subject: [PATCH 361/596] feat(playlists): per-user starred/rating annotations (backend) (#5749) * feat(playlists): add average_rating column to playlist table * feat(playlists): store and read per-user starred/rating annotations * feat(playlists): clean up annotations when a playlist is deleted * feat(subsonic): route star/unstar of a playlist to the playlist repository * feat(subsonic): route setRating of a playlist to the playlist repository * test(subsonic): guard that playlist responses never expose annotations * fix(playlists): clean stale mis-typed annotations on upgrade; cover GetAll read-back * fix(playlists): scope annotation join by item_type and harden delete Address code-review findings on the playlist-annotations branch: - withAnnotation: add an item_type predicate to the LEFT JOIN so a mis-typed annotation row sharing an id can no longer leak into (or duplicate) another entity's read. Correct for every caller since each repo writes annotations with item_type = tableName. Regression test added. - migration: reclassify legacy media_file-typed rows for playlist ids to item_type='playlist' (instead of deleting them), preserving users' prior playlist star/rating; run before the average_rating backfill so those ratings are included. - playlist Delete: replace the per-request full-table cleanAnnotations() anti-join with a targeted, permission-safe (rows-affected gated), best-effort delete so a cleanup failure no longer misreports an already-committed delete as an error. - MockPlaylistRepo: implement GetAll/IncPlayCount/ReassignAnnotation to remove the dead All field and the nil-interface panic traps. - test: use slices.IndexFunc instead of a hand-rolled find loop. * feat(playlists): streamline playlist deletion by relying on annotation sweep * docs(playlists): trim comments in annotation migration and test Condense the verbose comments added in this branch per the project's comment-minimalism guideline, keeping only the non-obvious rationale. The migration's reclassify block is shortened while preserving the safety invariant (playlist and media_file ids never collide, so the item_type rewrite touches only mis-typed rows and cannot violate the unique key) and the ordering note. The redundant 'Populate average_rating' comment is dropped since the UPDATE is self-evident. The repository test's leakage comment is condensed to two lines. No code behavior changes. * refactor(subsonic): resolve setStar targets via GetEntityByID Replace setStar's Album/Artist/Playlist Exists probe chain with a single model.GetEntityByID lookup and a type switch, mirroring setRating. This removes three per-id existence queries and keeps the two annotation paths consistent. An id that resolves to no known entity is logged and skipped rather than filed as a spurious media_file annotation, and a lookup failure on one id no longer aborts the whole batch. Also drop a duplicate empty-ids guard. * refactor(playlists): drop no-op reclassify/backfill from migration The average_rating migration carried two data-fix UPDATEs that are no-ops on any real database: - The media_file->playlist reclassification only matches rows no released build ever created: playlists were never annotatable, so star/setRating of a playlist id was never written as item_type='playlist'. Any stray media_file-typed row for a playlist id is already removed by the media_file annotation GC sweep (item_id not in media_file). - The average_rating backfill runs before any item_type='playlist' row can exist, so it can only ever write the default 0. Going forward SetRating keeps average_rating current via updateAvgRating. Reduce the migration to the column add/drop. * refactor(persistence): bind annotation join params, derive idField from tableName Address PR review: use Squirrel parameter binding for item_type/user_id in the shared withAnnotation join instead of string concatenation, and pass r.tableName+".id" from selectPlaylist so the join field stays consistent with the surrounding r.tableName usage. * fix(subsonic): surface datastore errors in setStar instead of skipping Address PR review: setStar swallowed every GetEntityByID error and continued, so a real datastore failure would still commit the transaction and emit a refresh event as if the star succeeded. Skip only on model.ErrNotFound (an unknown id); return any other error so the request fails and rolls back. * test(subsonic): assert absent JSON keys instead of substring matches Address PR review: substring checks are brittle ("starred" matches "starredAt", "rating" matches "userRating"). Unmarshal the response and assert the annotation keys are absent. * fix(subsonic): skip refresh broadcast when a star request changes nothing Address PR review (Codex): once setStar began skipping unknown ids, a request containing only unresolvable ids left the RefreshResource empty, which SendMessage serializes as a {*:*} wildcard that forces every client to refresh. Only broadcast when at least one id was actually starred. * fix(db): rebase playlist average_rating migration timestamp past master The 20260708011823 migration predated the newest migration merged to master (20260712211040_add_primary_key...), which Goose would silently skip on already-upgraded databases. Rename it to a current timestamp so it applies in order. --- ...0714120000_add_playlist_average_rating.sql | 5 + model/playlist.go | 3 + persistence/persistence.go | 1 + persistence/playlist_repository.go | 3 +- persistence/playlist_repository_test.go | 95 +++++++++++++++++++ persistence/sql_annotations.go | 3 +- server/subsonic/media_annotation.go | 62 ++++++------ server/subsonic/media_annotation_test.go | 58 +++++++++++ server/subsonic/playlists_test.go | 22 +++++ tests/mock_playlist_repo.go | 57 +++++++++++ 10 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 db/migrations/20260714120000_add_playlist_average_rating.sql diff --git a/db/migrations/20260714120000_add_playlist_average_rating.sql b/db/migrations/20260714120000_add_playlist_average_rating.sql new file mode 100644 index 000000000..5db642986 --- /dev/null +++ b/db/migrations/20260714120000_add_playlist_average_rating.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE playlist DROP COLUMN average_rating; diff --git a/model/playlist.go b/model/playlist.go index dc549f039..262774aa7 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -10,6 +10,8 @@ import ( ) type Playlist struct { + Annotations `structs:"-"` + ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` Comment string `structs:"comment" json:"comment"` @@ -121,6 +123,7 @@ type Playlists []Playlist type PlaylistRepository interface { ResourceRepository + AnnotatedRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) Put(pls *Playlist, cols ...string) error diff --git a/persistence/persistence.go b/persistence/persistence.go index 1164eb70f..93f0e3e71 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -193,6 +193,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), + trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }), trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }), trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }), trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 4152505d2..fe1f50689 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -203,8 +203,9 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, } func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user on user.id = owner_id"). + sel := r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") + return r.withAnnotation(sel, r.tableName+".id") } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index cfabd0983..c5b16b88f 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -71,6 +74,98 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Annotations", func() { + var plsID string + + BeforeEach(func() { + pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + plsID = pls.ID + }) + + countAnnotations := func() int { + var count int + Expect(GetDBXBuilder().NewQuery( + "SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}"). + Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed()) + return count + } + + It("stores and reads back starred", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeTrue()) + Expect(p.StarredAt).ToNot(BeNil()) + }) + + It("stores and reads back rating and average_rating", func() { + Expect(repo.SetRating(4, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Rating).To(Equal(4)) + Expect(p.RatedAt).ToNot(BeNil()) + Expect(p.AverageRating).To(Equal(4.0)) + }) + + It("keeps annotations isolated per user", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), + model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) + otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + + p, err := otherRepo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + }) + + It("reads starred back through GetAll", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) + Expect(idx).To(BeNumerically(">=", 0)) + Expect(all[idx].Starred).To(BeTrue()) + }) + + It("does not leak an annotation row of another item_type sharing the playlist id", func() { + // Older builds (and the star fallthrough) can leave a media_file-typed row + // under a playlist id; the item_type-scoped join must not surface or dupe it. + _, err := GetDBXBuilder().NewQuery( + "INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)"). + Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + matches := 0 + for _, pl := range all { + if pl.ID == plsID { + matches++ + } + } + Expect(matches).To(Equal(1)) + }) + + It("relies on the annotation sweep, not Delete, to clean up annotations", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + Expect(repo.Delete(plsID)).To(Succeed()) + Expect(countAnnotations()).To(Equal(1)) + + Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed()) + Expect(countAnnotations()).To(Equal(0)) + }) + }) + It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 78b7938a1..46ad6a0de 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -67,7 +67,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec query = query. LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - " AND annotation.user_id = '"+userID+"')"). + " AND annotation.item_type = ?"+ + " AND annotation.user_id = ?)", r.tableName, userID). Columns( "coalesce(starred, 0) as starred", "coalesce(rating, 0) as rating", diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index e8b0278c1..27170c11b 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "errors" "fmt" "math" "net/http" @@ -52,6 +53,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { case *model.Album: repo = api.ds.Album(ctx) resource = "album" + case *model.Playlist: + repo = api.ds.Playlist(ctx) + resource = "playlist" default: repo = api.ds.MediaFile(ctx) resource = "song" @@ -104,48 +108,50 @@ func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { - if len(ids) == 0 { - return nil - } - log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) if len(ids) == 0 { log.Warn(ctx, "Cannot star/unstar an empty list of ids") return nil } - event := &events.RefreshResource{} + log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + event := &events.RefreshResource{} + changed := false for _, id := range ids { - exist, err := tx.Album(ctx).Exists(id) + var repo model.AnnotatedRepository + var resource string + entity, err := model.GetEntityByID(ctx, tx, id) if err != nil { - return err - } - if exist { - err = tx.Album(ctx).SetStar(star, id) - if err != nil { + if !errors.Is(err, model.ErrNotFound) { return err } - event = event.With("album", id) + log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) continue } - exist, err = tx.Artist(ctx).Exists(id) - if err != nil { + switch entity.(type) { + case *model.Artist: + repo = tx.Artist(ctx) + resource = "artist" + case *model.Album: + repo = tx.Album(ctx) + resource = "album" + case *model.Playlist: + repo = tx.Playlist(ctx) + resource = "playlist" + default: + repo = tx.MediaFile(ctx) + resource = "song" + } + if err := repo.SetStar(star, id); err != nil { return err } - if exist { - err = tx.Artist(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("artist", id) - continue - } - err = tx.MediaFile(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("song", id) + event = event.With(resource, id) + changed = true + } + // Skip the broadcast when nothing changed: an empty RefreshResource + // serializes as a "{*:*}" wildcard, forcing every client to refresh. + if changed { + api.broker.SendMessage(ctx, event) } - api.broker.SendMessage(ctx, event) return nil }) if err != nil { diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 487335d1a..1b16dfc68 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -185,6 +185,64 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) }) }) + + Describe("Star/Unstar playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("stars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", true)) + }) + + It("unstars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Unstar(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", false)) + }) + }) + + Describe("SetRating playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("rates a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1", "rating=4") + + _, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4)) + }) + }) + + Describe("Star with an unresolvable id", func() { + It("skips the id without broadcasting an empty (wildcard) refresh", func() { + r := newGetRequest("id=does-not-exist") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.Events).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 1d5f6a70a..697dd5852 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "time" "github.com/navidrome/navidrome/conf" @@ -248,6 +249,27 @@ var _ = Describe("buildPlaylist", func() { }) }) }) + + Describe("annotation leakage", func() { + It("does not serialize starred/rating even when the model carries them", func() { + p := model.Playlist{ID: "pl-1", Name: "My Playlist"} + p.Starred = true + p.Rating = 5 + + resp := router.buildPlaylist(ctx, p) + + data, err := json.Marshal(resp) + Expect(err).ToNot(HaveOccurred()) + var fields map[string]any + Expect(json.Unmarshal(data, &fields)).To(Succeed()) + Expect(fields).ToNot(HaveKey("starred")) + Expect(fields).ToNot(HaveKey("starredAt")) + Expect(fields).ToNot(HaveKey("rating")) + Expect(fields).ToNot(HaveKey("userRating")) + Expect(fields).ToNot(HaveKey("averageRating")) + Expect(fields).ToNot(HaveKey("playCount")) + }) + }) }) var _ = Describe("UpdatePlaylist", func() { diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9b38ea5b5..b7df5361f 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -2,6 +2,7 @@ package tests import ( "errors" + "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -19,8 +20,11 @@ type MockPlaylistRepo struct { model.PlaylistRepository Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path + All model.Playlists Last *model.Playlist Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating Err bool TracksRepo model.PlaylistTrackRepository } @@ -29,6 +33,14 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } +func (m *MockPlaylistRepo) SetData(pls model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(pls)) + m.All = pls + for i, p := range m.All { + m.Data[p.ID] = &m.All[i] + } +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -45,6 +57,13 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } +func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") @@ -79,6 +98,44 @@ func (m *MockPlaylistRepo) Delete(id string) error { return nil } +func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { + if m.Err { + return errors.New("error") + } + if m.Starred == nil { + m.Starred = map[string]bool{} + } + for _, id := range ids { + m.Starred[id] = starred + } + return nil +} + +func (m *MockPlaylistRepo) SetRating(rating int, id string) error { + if m.Err { + return errors.New("error") + } + if m.Ratings == nil { + m.Ratings = map[string]int{} + } + m.Ratings[id] = rating + return nil +} + +func (m *MockPlaylistRepo) IncPlayCount(string, time.Time) error { + if m.Err { + return errors.New("error") + } + return nil +} + +func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { + if m.Err { + return errors.New("error") + } + return nil +} + func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { return m.TracksRepo } From 3cd4f1eb24a0fb743bad0f1ad011168d6d50ffe3 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:40:55 +0800 Subject: [PATCH 362/596] fix(ui): update Chinese Simplified translation (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update Chinese (Simplified) translation * Update Chinese (Simplified) translation --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/zh-Hans.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 63ea5cf60..21778506a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -6,7 +6,7 @@ "fields": { "albumArtist": "专辑艺人", "duration": "时长", - "trackNumber": "音轨号", + "trackNumber": "曲目序号", "playCount": "播放次数", "title": "标题", "artist": "艺人", @@ -22,6 +22,8 @@ "bitRate": "比特率", "bitDepth": "位深度", "sampleRate": "采样率", + "albumGain": "专辑增益", + "trackGain": "曲目增益", "channels": "声道", "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", @@ -142,7 +144,7 @@ "name": "用户", "fields": { "userName": "用户名", - "isAdmin": "是否管理员", + "isAdmin": "是否为管理员", "lastLoginAt": "上次登录", "lastAccessAt": "上次访问", "updatedAt": "更新于", @@ -623,11 +625,11 @@ "lastfmScrobbling": "启用 Last.fm 的个性化记录", "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", "replaygain": "回放增益", - "preAmp": "前置放大器 (dB)", + "preAmp": "回放增益 - 前置放大 (dB)", "gain": { - "none": "禁用增益", - "album": "使用专辑增益信息", - "track": "使用歌曲增益信息" + "none": "禁用", + "album": "使用专辑增益", + "track": "使用曲目增益" } } }, From fe6ac2e577f14090a2fb33a9e4c91c1bee6196a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 14 Jul 2026 11:46:37 -0400 Subject: [PATCH 363/596] feat(jellyfin): experimental Jellyfin Music API support (#5730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sharing): enable sharing by default Flip the EnableSharing default from false to true so new installations have the sharing feature available out of the box. Users can still disable it via the EnableSharing config option. The native API only registers the /share route when sharing is enabled, so the nativeapi tests that build the router without wiring a share service now explicitly disable sharing in their setup to avoid registering a route backed by a nil service. * feat(jellyfin): add config flag and URL path constant Adds the disabled-by-default Server.Jellyfin config option (Enabled, ServerName) and consts.URLPathJellyfinAPI, following the existing LastFM/ListenBrainz patterns. Later tasks will use these to mount the Jellyfin-compatible API router. * fix(jellyfin): default Jellyfin ServerName to "Navidrome" * feat(jellyfin): package skeleton with System handshake endpoints Adds server/jellyfin: the Router (mirrors server/subsonic and server/public), its Wire-friendly New(...) constructor, a chi routes() table, and the ok() JSON response helper. Implements the unauthenticated handshake surface Jellyfin clients probe first: GET /System/Info/Public, GET+POST /System/Ping, and GET /QuickConnect/Enabled (quick connect is unsupported, so it always reports disabled). The public info payload's Id must be stable across restarts (Jellyfin clients cache ServerId), so it reuses the get-or-create Property pattern already used for InsightsID in core/metrics/insights.go: fetch consts.JellyfinServerIDKey from the Property repository, generating and persisting a new UUID on first read. A dedicated key (rather than the existing InsightsID) keeps the anonymous telemetry identifier from being exposed on this unauthenticated endpoint. * test(jellyfin): cover ping and quickConnectEnabled handlers Both were added alongside the System/Info/Public handshake endpoint but had no direct test coverage. * fix(jellyfin): memoize stable server Id and cover get-or-create path * feat(jellyfin): wire and mount the router behind Jellyfin.Enabled Add jellyfin.New to the shared Wire provider set and a CreateJellyfinAPIRouter injector, then mount the router at /jellyfin in startServer(), gated by conf.Server.Jellyfin.Enabled, mirroring the existing LastFM/ListenBrainz router mounts. * feat(jellyfin): add Jellyfin DTOs and model mappers Adds BaseItemDto, QueryResult, UserItemDataDto, UserDto, AuthenticationResult, MediaSourceInfo, PlaybackInfoResponse, NameGuidPair and SessionInfo DTOs to server/jellyfin/dto, plus model-to-DTO mappers for songs, albums, artists and genres that later browse/stream/write endpoints will consume. * test(jellyfin): cover GenreToBaseItem mapper * feat(jellyfin): advertise Jellyfin-compatible version, brand ServerName with Navidrome version * fix(jellyfin): map LastPlayedDate and omit zero track/disc numbers * feat(jellyfin): authentication middleware and AuthenticateByName login * fix(jellyfin): reject empty login password, wire ServerName, add negative auth tests * refactor(jellyfin): use new(x) builtin instead of intPtr helper * feat(jellyfin): user views and current-user endpoints * feat(jellyfin): Items query engine, item detail, and latest Adds the /Items universal query endpoint, dispatching by IncludeItemTypes over albums/artists/songs/genres with ParentId, SearchTerm, Filters=IsFavorite, SortBy/SortOrder and StartIndex/Limit support, plus GET /Items/{itemId} and /Users/{userId}/Items/Latest. Reuses server/subsonic/filter builders (by artist/album/starred) instead of hand-rolled squirrel filters, and resolves sort keys per item type since each repository maps sort names to different real/aliased columns. Also adds the missing CountAll to tests.MockArtistRepo, exposed by this task (the mock embedded a nil ArtistRepository for it and would panic on use). * refactor(server): extract shared query filter builders to server/filter Moves server/subsonic/filter to server/filter so server/jellyfin can use the shared query-option builders without importing server/subsonic, enforcing the rule that no API package imports another API's package. * feat(jellyfin): full multi-library support with per-user access scoping Replace the single hardcoded "music" UserView with one CollectionFolder view per library the user can access, and scope every /Items browse query (albums, songs, artists, /Latest) to those libraries via server/filter's ApplyLibraryFilter/ApplyArtistLibraryFilter. ParentId is now disambiguated: a numeric value the user has access to is treated as a library scope (browsing a UserView), otherwise it falls through as an entity id (artist/album), which safely matches nothing rather than leaking another library's content. getItem now 404s when fetching an album or song outside the user's accessible libraries; artists are skipped (they can span multiple libraries) with a TODO. Genres remain unscoped since they're global tags, not per-library entities. * test(jellyfin): cover admin library-access path and drop nil test contexts * feat(jellyfin): Artists and Genres endpoints Add /Artists, /Artists/AlbumArtists, /Genres and /MusicGenres. Artists listing is library-scoped (defaulting to the user's accessible libraries, narrowed by an accessible ParentId), delegating access control to listArtists/ApplyArtistLibraryFilter. Genres are global and unscoped, matching the ML decision already made for listGenres. * refactor(jellyfin): share resolveLibraryScope between items and artists * feat(jellyfin): item image endpoint via artwork service * fix(jellyfin): let net/http sniff image Content-Type instead of forcing jpeg * feat(jellyfin): audio streaming and PlaybackInfo with library access control * feat(jellyfin): favorites and rating write-back with library access control Adds POST/DELETE handlers for /Users/{userId}/FavoriteItems/{itemId} and /Users/{userId}/Items/{itemId}/Rating. A shared resolveAnnotated helper probes album/artist/media file (mirroring getItem's order) and 404s before writing if the user lacks access to the album/song's library; artists are exempt since they span multiple libraries. Ratings are halved coming in (Jellyfin 0-10 -> Navidrome 0-5) to match the doubling in dto.UserData. Also teaches MockMediaFileRepo and MockArtistRepo's SetStar/SetRating (and MockAlbumRepo's, previously a no-op) to actually mutate the backing data so write-back can be asserted in tests. * feat(jellyfin): playback reporting and scrobbling Add /Sessions/Playing[/Progress|/Stopped] and /Sessions/Capabilities[/Full] handlers, backed by core/scrobbler.PlayTracker. A new withPlayer middleware resolves/registers a model.Player from the Emby DeviceId header (used directly as the stable player id, unlike Subsonic's cookie fallback) and injects it into the request context for scrobbling. The Stopped report calls ReportPlayback with IgnoreScrobble to end the now-playing session without double-counting, then calls Submit as the single source of the play-count increment and external scrobble. * feat(jellyfin): playlist read and write-back Adds POST /Playlists, GET/POST/DELETE /Playlists/{id}/Items. Tags each playlist item with PlaylistItemId (the entry's position within the playlist) so DELETE .../Items?EntryIds=... can remove a specific occurrence by the id core/playlists.RemoveTracks actually expects, rather than the song id used everywhere else. * test(jellyfin): cover empty Ids/EntryIds in playlist add/remove * feat(jellyfin): unknown-route logging, generic 500s, rating clamp, docs Hardening pass ahead of real client testing: unmatched routes and unsupported methods now return a logged, JSON 404 instead of chi's default plain-text response, so a missing endpoint a client needs is easy to spot in the logs. Internal errors (ffmpeg output, file paths, etc.) no longer leak into 500 response bodies -- a shared internalError helper logs the real error server-side and always returns a generic message. Inbound Jellyfin ratings are clamped to 0-10 before being halved into Navidrome's 0-5 scale, and /System/Ping now replies with a bare plain-text body as real Jellyfin servers do. Adds a README with an enable/curl walkthrough and known limitations. * docs(jellyfin): clarify public image endpoint and accessibleLibraryIDs comments * fix(jellyfin): case-insensitive path routing for Jellyfin client compatibility * refactor(server): extract case-insensitive path routing to a shared helper * fix(jellyfin): return User Policy and Configuration so Finamp completes login * fix(jellyfin): support Playlist type, multi-type Items queries, and PlayCount/DatePlayed sort Real Finamp requests break against three /Items query engine bugs: Playlist requests fell through to albums, multi-type IncludeItemTypes (e.g. Finamp's favorites screen) only returned the first requested type, and comma-separated SortBy lists (e.g. "DateCreated,SortName") were matched as one opaque string so they always fell through to the repo default. Also extends tests/mock_playlist_repo.go with SetData/GetAll so playlist listing is testable like the other mock repos. * feat(jellyfin): implement /socket WebSocket for real-time client sessions * fix(jellyfin): resolve library-view ids in getItem so clients can load the library * fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param * fix(jellyfin): resolve playlist ids in getItem * fix(jellyfin): read lowercase ids/entryIds playlist params and add playlist-users endpoints * fix(jellyfin): include MediaSources with Size/Bitrate on tracks so clients show download size * fix(jellyfin): populate all required MediaSourceInfo bool/array fields to match Jellyfin * fix(jellyfin): hex-encode item ids at the API boundary for Jellyfin client compatibility Finamp (and presumably other clients) parses ids as radix-16, but Navidrome's base62 nanoids aren't valid hex and crash its queue packing. Hex-encode every id emitted by the Jellyfin API and decode every id received, keeping the transform reversible and stateless at the boundary rather than touching any model id. * fix(jellyfin): populate MediaStreams with the audio stream so clients can size/transcode * fix(jellyfin): support Ids batch-fetch in /Items so clients can fetch items by id * fix(jellyfin): do not disable Jellyfin server when disabling external services * fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning * fix(jellyfin): support playlist cover upload/delete and display via item image endpoints * feat(jellyfin): implement GET /Playlists/{id} for playlist visibility Finamp's playlist edit screen calls GET /Playlists/{id} to read the OpenAccess (public visibility) flag; we only had the sub-routes, so it 404'd and the edit screen failed to load. Return the Jellyfin PlaylistDto shape (OpenAccess from Public, empty Shares, media item ids). * fix(jellyfin): display playlist cover art Uploaded playlist covers never showed in clients for two reasons: the playlist BaseItemDto advertised no Primary ImageTag (so clients didn't know to fetch a cover), and the public image endpoint resolved artwork under the request's anonymous context, so a private playlist failed its visibility filter and fell back to the placeholder. Advertise the Primary image tag/blurhash on playlists, and resolve artwork under an elevated context (as core/artwork's cache warmer does). * fix(jellyfin): expand album/artist/playlist ids when building playlists Jellyfin clients (Finamp) send container ids — an album, artist or playlist — in a playlist's Ids list and expect the server to expand each into its child tracks. core/playlists only understands media file ids, so creating or adding with an album id silently produced an empty playlist. Expand container ids to their tracks (in order) before create/add; bare song ids still pass through. Adds filter.SongsByArtistID. * fix(jellyfin): support playlist deletion via DELETE /Items/{id} Finamp deletes a playlist with DELETE /Items/{id}, which we didn't route, so deletion silently failed. Implement it via core/playlists.Delete (which enforces ownership and removes the cover file). Only playlists are deletable through this API; non-playlist ids return 404, non-owners 403. * test(jellyfin): add e2e suite harness + smoke tests * test(jellyfin): e2e for system, auth, routing, browsing, annotations * test(jellyfin): e2e for playlists (CRUD, expansion, cover) and item images * test(jellyfin): e2e for streaming, sessions, and multi-user access control * fix(jellyfin): artist search 500 (library filter leaked into FTS query) getArtists/listArtists reused the browse-path filters (notMissing + ApplyArtistLibraryFilter) for the search path, but artist Search expects a sole Eq{library_id} filter it can consume as a scope — artists have no library_id column, so the compound/join filter leaked into the FTS query and 500'd. Every Finamp artist search failed (the filter is applied even unscoped, since admins resolve to all library ids). Build search filters separately. Adds e2e search coverage. * feat(jellyfin): implement POST /Playlists/{id} to update name, visibility, tracks Finamp edits a playlist (make public, rename, reorder) via POST /Playlists/{id}, which we didn't route, so every edit 404'd. Implement it: Ids present -> replace track list (Create with existing id, preserving name); otherwise update Name/IsPublic via core/playlists.Update. Adds a shared playlistError helper (403/404/500) reused by deleteItem, plus e2e coverage. * docs(jellyfin): update README for playlists, images, id encoding, and e2e * fix(jellyfin): default album track listing to track order Browsing an album's tracks (Items?ParentId=<albumId>&IncludeItemTypes=Audio) took only SongsByAlbum's filters and dropped its Sort, so tracks came back in arbitrary order. Default opts.Sort to the album (disc+track) order when browsing an album without an explicit SortBy — matching Subsonic's GetAlbum and real Jellyfin. An explicit SortBy still wins. * fix(jellyfin): filter items by AlbumArtistIds/ArtistIds An artist's page in Finamp sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist itself, but queryItems only honored ParentId, so an artist's albums and tracks came back unfiltered (every artist's content). Parse the artist-id params and apply AlbumsByArtistID (albums) / SongsByArtistID (tracks). Adds e2e coverage. * fix(jellyfin): only count a play past the scrobble threshold reportPlaybackStopped set IgnoreScrobble and force-submitted a play on every Stopped report, so a briefly-played track (e.g. an immediate skip) was marked played. Finamp sends Stopped on every track switch, so the threshold must be applied server-side. Let ReportPlayback's StateStopped logic decide (play + scrobble only past 50% of the track / 4-minute cap) instead. Subsonic differs because there the client gates submission. * fix(jellyfin): sort album tracks by track number when client sends IndexNumber SortBy Finamp's album view requests SortBy=ParentIndexNumber,IndexNumber,SortName (disc, track, name), but applySort didn't recognize ParentIndexNumber or IndexNumber and fell through to SortName, sorting tracks alphabetically by title. Map both keys to the album (disc+track) sort. The reversed-title fixture lets the e2e tell track order from title order. * fix(jellyfin): honor the isFavorite query param for favorites filtering Finamp's artist 'Favourite tracks' widget requests favorites via the standalone isFavorite=true query param, not Filters=IsFavorite, so the filter was ignored and non-favorited tracks were returned. Detect both forms. (The widget's reshuffling is Finamp's explicit SortBy=Random.) * feat(jellyfin): emit DateCreated (Date Added) on items BaseItemDto had no DateCreated, so clients showed 'No Date Added' and had nothing to sort 'Recently Added' by. Emit it as ISO 8601 from each entity's CreatedAt (the same field the recently_added sort uses) for songs, albums and artists. * fix(jellyfin): set ArtistItems/AlbumArtists on songs (Now Playing artist) SongToBaseItem only set Artists (names) and AlbumArtist, not the structured ArtistItems/AlbumArtists. Finamp's Now Playing screen reads ArtistItems and shows 'Unknown Artist' when it's absent. Populate both (track artist and album artist) as name+id pairs, mirroring AlbumToBaseItem. * docs(jellyfin): note synthetic blurhash as a follow-up Document that ImageBlurHashes are derived from the item id (a solid-color placeholder), not computed from the cover art like real Jellyfin, and outline what a proper implementation would take. * docs(jellyfin): note WebSocket events and favourited playlists as follow-ups * fix(jellyfin): filter the Artists page by role (album artist vs performer) /Artists and /Artists/AlbumArtists both called the same role-agnostic handler, so Navidrome's per-role artist entries (composers, arrangers, performers) all showed as Album Artists, and the two tabs were identical. Filter /Artists/AlbumArtists to RoleAlbumArtist and /Artists to RoleArtist (and the MusicArtist browse to album artists), via filter.ArtistsByRole. Verified live: Beatles Singles' album-artists dropped 138->5, composers excluded, performers (Billy Preston) show only under /Artists. * fix(jellyfin): sort playlists by name (missing Playlist sort mapping) applySort had no sortColumnsByType entry for the Playlist type, so SortBy=SortName was ignored and the Playlists screen showed them in the repo's default order. Map SortName/Name -> name (as Subsonic does) and DateCreated -> created_at. Verified live: case-insensitive alphabetical. * fix(jellyfin): read query params case-insensitively (Jellify support) Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase (parentId, albumArtistIds, artistIds, includeItemTypes), where Finamp sends PascalCase. Our handlers read fixed-case keys, so every Jellify filter/sort/paging param was silently dropped: - an artist's page listed albums and tracks from all artists - opening an album listed every album instead of its tracks Real Jellyfin binds query params case-insensitively (ASP.NET model binding), so add a normalizeQueryKeys middleware that folds every query key to lowercase once, and read params by their lowercase name. This also removes the scattered dual-case hacks (Ids/ids, IsFavorite, ContributingArtistIds, api_key/ApiKey, queryParam) that let this bug class through. Also infer the child type from an album parent: Jellify browses an album with only parentId (no IncludeItemTypes), and Jellyfin infers Audio from the parent; without it we fell back to listing all albums. Verified live against Finamp+Jellify and with 4 new e2e specs replaying Jellify's exact request shapes. * fix(jellyfin): advertise LocalAddress in the public system info handshake Jellify (and other @jellyfin/sdk clients) that connect by raw address fall back to HTTP when TLS isn't available, then adopt the handshake's LocalAddress as their server base URL. We never populated it, so Jellify's SDK `api` object was undefined and sign-in crashed with "Cannot read property 'configuration' of undefined" (getUserApi(api!) with an undefined api). Over an HTTPS connection Jellify uses connectionType=hostname and never reads LocalAddress, which is why it worked while an HTTPS proxy was in front. Populate LocalAddress from the request — scheme + host (honoring X-Forwarded-*), plus the /jellyfin mount path — matching real Jellyfin, which always sends it. Export server.ServerAddress for the resolution. * fix(jellyfin): separate "Featured On" from an artist's own discography Jellify's artist page fetches the discography via albumArtistIds and the "Featured On" section via contributingArtistIds, relying on the server to return disjoint sets. We collapsed albumArtistIds/artistIds/ contributingArtistIds into one AlbumsByArtistID filter, so an artist's own albums appeared in both sections. Add AlbumsByContributingArtistID — albums where the artist is a track artist but NOT the album artist — matching Jellyfin's ContributingArtistIds (in Artists, not in AlbumArtists), and route contributingArtistIds to it. * fix(jellyfin): accept a bare Authorization token for audio streaming Jellify's native player (react-native-nitro-player) authenticates the audio stream by setting a bare Authorization header carrying the raw access token ({ AUTHORIZATION: api.accessToken }), not the "MediaBrowser ... Token=" scheme parseEmbyAuth understands. tokenFromRequest didn't recognize it, so every /Audio/{id}/stream request from the native player 401'd and no audio played (the JS client still optimistically posted playback progress, masking it). Accept a bare (or "Bearer <token>") Authorization header, as real Jellyfin does. Verified live: the native player's exact request now streams 200. * fix(jellyfin): embed a self-authenticating stream URL in PlaybackInfo Jellify's native audio player (react-native-nitro-player / ExoPlayer) fetches the stream without forwarding any auth — captured request headers were only Connection/Icy-Metadata/Accept-Encoding/User-Agent, no Authorization and no api_key — so every /Audio/{id}/stream request 401'd and nothing played (the JS client still optimistically posted progress, masking it). Real Jellyfin returns stream URLs with the token embedded; we returned none, so Jellify fell back to a token-less DirectPlay URL. Populate MediaSources[0].TranscodingUrl with /Audio/{id}/universal?api_key=<caller token>, which Jellify's player uses verbatim. Direct-play clients (Finamp builds its own /Items/{id}/File?ApiKey URL) ignore the field, so they're unaffected. * feat(jellyfin): serve per-item UserData (GET /UserItems/{id}/UserData) Jellify fetches this per item to render played/favourite indicators; we 404'd it. Resolve the item via the existing getItem resolver (which loads the caller's annotations and enforces the same library-access gate) and return its UserData, falling back to an empty-but-valid object for items without annotations (e.g. playlists). Also registers the legacy /Users/{userId}/Items/{itemId}/UserData spelling. * refactor(jellyfin): drop unused bare-Authorization token support Added mid-troubleshooting on the theory that Jellify's native player sends a bare Authorization header, but the debug capture showed it sends no auth header at all — the real fix was embedding api_key in PlaybackInfo's TranscodingUrl. No client reaches this path (Jellify JS uses the MediaBrowser scheme, Finamp uses X-Emby-Token, the native player uses the api_key URL), so remove it and its tests per YAGNI. Effectively reverts 4f8ac1e0. * feat(jellyfin): implement Similar endpoints via the external provider Add GET /Artists/{id}/Similar (related artists) and GET /Items/{id}/Similar (similar songs for a track, similar albums for an album, related artists for an artist), sourced from the same external.Provider (Last.fm etc.) that powers Subsonic's getArtistInfo2/getSimilarSongs. Only library-present artists are returned so each is navigable; provider errors and unknown ids degrade to an empty 200 result, so clients (Jellify) stop hammering these with 404 retries. Injects external.Provider into the Router (regenerated via make wire). * perf(jellyfin): make Similar endpoints non-blocking (bounded quick wait) Each /Similar request fetched from Last.fm synchronously (500ms-1.4s), and Jellify requests Similar for many items at once on the home/artist screens, so the whole screen stalled — a home pull-to-refresh dragged for seconds. Run the external lookup on a background context and return within a 500ms quick wait: a cached artist resolves instantly, a cold one returns empty now while the lookup finishes caching in the background, so a later load is fast and populated. Verified live: cold calls bounded at ~500ms (was up to 1.4s), warm calls 3-8ms. * fix(jellyfin): answer ManualPlaylistsFolder so the home stops stalling Jellify resolves its "playlists library" via IncludeItemTypes= ManualPlaylistsFolder, then lists playlists with ParentId set to that folder's id. We didn't recognize the type, so parseTypes fell back to MusicAlbum and returned the album list. Jellify's query then found no item with CollectionType=playlists and resolved undefined — which React Query rejects, retrying it in a backoff loop that stalled the home pull-to-refresh for ~5s (every response was fast server-side; the delay was the client's retries). Return a synthetic "playlists" folder (CollectionType=playlists) for the ManualPlaylistsFolder query, resolve ParentId=<that folder> to the user's playlists, and give playlists a Path under "data" (Jellify drops playlists whose Path lacks it). Adds a Path field to BaseItemDto. * docs(jellyfin): note lyrics and InstantMix/sonic-similarity as follow-ups * fix(jellyfin): genre paging params and same-key casing collisions getGenres read StartIndex/Limit in PascalCase, which normalizeQueryKeys had already folded to lowercase, so genre paging was silently ignored; totals now come from the full (small) genre list instead of the page length. normalizeQueryKeys now merges values when two casings of a key collide, instead of nondeterministically keeping one. * fix(jellyfin): round ratings to the nearest star instead of truncating Rating is a nullable double 0-10 in Jellyfin's contract. Truncating integer division stored 9 as 4 stars, and both Rating=1 and fractional values (which failed integer parsing) became 0 — silently deleting the rating. Parse as float, round, and floor nonzero input at one star. * fix(jellyfin): apply Name/IsPublic sent together with a track replacement Jellyfin's UpdatePlaylist applies every provided field, but the Ids branch returned early, silently discarding a rename or visibility change sent in the same body (core/playlists.Create with an existing id ignores the name). * fix(jellyfin): don't rotate the stored server id on transient DB errors serverID treated any Property.Get error as "no id yet" and persisted a fresh UUID over JellyfinServerID, with sync.Once pinning it for the process lifetime — a busy DB or canceled request context on the first request would break every client's cached ServerId. Only ErrNotFound mints a new id now, and failures yield an uncached temporary value so the next request retries. * fix(jellyfin): report real search totals instead of page length or unfiltered counts Artist search returned TotalRecordCount = len(page), so clients stopped after the first page; album/song search counted via CountAll, which can't see the search term, so clients paged through phantom results. The repos' Search API has no match count, so fetch one row beyond the page: offset+len is exact on the last page and a strictly growing lower bound before it — paging clients terminate exactly at the last match. * fix(jellyfin): browse playlists via the generic /Items path and resolve the playlists folder by id A typeless /Items?ParentId=<playlistId> (legal in real Jellyfin, used by generic clients) fell through to the MusicAlbum default and returned an empty list; it now returns the playlist's tracks, paginated, with visibility enforced by GetWithTracks. resolveItemByID also answers the synthetic playlists-folder id the server itself advertises instead of 404ing it. * perf(jellyfin): cap per-type queries in multi-type /Items requests The multi-type merge path queried each type with a zero-value QueryOptions — no LIMIT in SQL — materializing every matching row (each with embedded MediaSources) just to slice out one page in memory. Each type now fetches at most StartIndex+Limit rows, the worst case one type can contribute to the merged window; totals still come from CountAll. * fix(jellyfin): dedupe and bound the background Similar fetches awaitSimilar spawned a detached, deadline-free goroutine per request; clients re-polling after the empty quick-wait response piled up duplicate provider chains (no singleflight anywhere below) racing writes on the same artist row. Identical in-flight requests now share one fetch — keyed per user, since the mapped items embed the user's annotations — and the background context gets a one-minute deadline so a hung provider can't hold goroutines forever. * fix(jellyfin): gate private playlist covers on the public image route The unauthenticated image endpoint elevated every request to an admin context, so anyone who knew or guessed a playlist id could fetch another user's private uploaded cover. Library artwork still resolves elevated (Jellyfin clients fetch images without auth headers), but playlist covers are now served only when the playlist is public or the request's optional token identifies its owner or an admin; everyone else gets the placeholder. * fix(log): redact full api_key values, including JWTs The api_key pattern matched only word characters, stopping at a JWT's first '.' — the Jellyfin API embeds the session JWT as api_key in TranscodingUrl, so request logs kept its payload and signature, enough to reconstruct a replayable token by prepending the constant header. Match to the next query separator instead, like the sibling s=/p=/jwt= patterns. * fix(jellyfin): record LastLoginAt on Jellyfin logins authenticateByName re-implements credential validation and skipped the UpdateLastLoginAt call the web UI's validateLogin makes, so users who only log in via Jellyfin clients showed a never/stale Last Login in the admin UI. * perf(jellyfin): batch song resolution in /Items?ids= and playlist expansion Both paths probed up to four repositories per client-supplied id. Songs — the common case — now resolve via chunked media_file.id IN queries (same pattern as playqueue's loadTracks); only the residue pays the container probes. * fix(jellyfin): wait for the real Similar result instead of answering a cacheable empty list The 500ms quick wait returned an empty 200 for any cold lookup — indistinguishable from "no similar items exist", so clients cached the wrong answer until an app restart. With fetches deduplicated, wait up to the agents' HTTP timeout for the actual result; only a hung provider now yields the empty fallback, and its fetch still warms the cache in the background. Also makes the dedup test deterministic (the old one raced its release channel). * refactor(tests): extract the shared e2e harness into tests/harness The Subsonic and Jellyfin e2e suites each carried their own copy of the golden-DB lifecycle (boot, seed users/library, scan, WAL snapshot), the ATTACH-DATABASE restore, fixture-FS registration, and the SpyStreamer / NoopFFmpeg doubles. Those now live in one importable package (same pattern as core/storage/storagetest); fixture libraries and request helpers stay per-suite since they encode each API's test expectations. * docs(jellyfin): make comments concise Compress the narrative comments accumulated during live client testing into short why-only notes; keep the client quirks, security rationales and gotchas, drop the exposition. Comments-only change (net -141 lines). * fix(tests): silence gosec taint false-positive in harness snapshot write The write moved from a _test.go file (which gosec skips) into the importable harness package; the path derives from GinkgoT().TempDir(). * fix(jellyfin): route the current /UserFavoriteItems favorite endpoint @jellyfin/sdk 0.13.0 (used by Jellify) posts favorites to POST/DELETE /UserFavoriteItems/{itemId}, while we only routed the legacy /Users/{userId}/FavoriteItems/{itemId} (Finamp). Jellify favorites 404'd ("Failed to add favourite"). Route both spellings to the same handlers. * feat(jellyfin): honor Fields on /Items and add missing conformance fields Match real Jellyfin's response shape: gate MediaSources (and MediaStreams) behind Fields=MediaSources instead of always embedding them — clients that need Size request it, as Finamp does — which cuts a 46-track artist response from ~87KB to a fraction. Also emit the always-present fields Jellyfin sets: ServerId (stamped centrally in ok), LocationType, HasLyrics, and SortName (when Fields=SortName). PlaybackInfo still carries MediaSources (its purpose). * fix(jellyfin): address code review security and correctness findings Applies the reviewed findings from PR #5730: - Gate similar songs/albums on the caller's library access, so the external provider can't surface metadata from libraries the user cannot see. Also clamp the client-supplied limit before it sizes any allocation or provider fetch (CodeQL user-controlled allocation). - Prepend the /jellyfin mount prefix to the PlaybackInfo TranscodingUrl, so a client resolving it as an absolute host path still reaches the mounted router. - Recognize WebP and GIF magic numbers on raw cover uploads, matching the formats Navidrome already supports. - Bound the playlist cover upload: honor EnableArtworkUpload for non-admins and cap the body with MaxBytesReader/MaxImageUploadSize, mirroring the native image endpoint. - Propagate non-not-found repository errors from resolveAnnotated as 500 instead of silently returning 404. - Normalize the literal prefix of mixed literal.param path segments (e.g. STREAM.mp3) so case-insensitive routing reaches stream.{container}. - Rate-limit POST /Users/AuthenticateByName with the same per-IP limiter as /auth/login when AuthRequestLimit is set. - Guard against a nil user in authenticateByName. * fix(jellyfin): correct playlist track browsing and multi-id edits Fixes three playlist issues, two found testing against Jellify and one from the PR review: - Resolve a playlist ParentId to its tracks even when the client sends IncludeItemTypes=Audio. Jellify opens a playlist with ParentId=<playlist>&IncludeItemTypes=Audio; the id was routed through listSongs as an album id, returning an empty list. - Read repeated id query params (ids=X&ids=Y), not just the first value. Jellify's @jellyfin/sdk serializes id arrays as repeated params, so adding an album (which it expands client-side into many ids) only added the first track. Applies to both add and remove; the comma-separated form other clients use still works. - Let an explicit empty Ids array clear a playlist. Ids is now a pointer so an omitted field still means 'leave unchanged', while an empty list clears the tracks (via RemoveTracks, since the repository skips track writes for an empty list). * fix(jellyfin): register clients as players on any authenticated request Jellyfin clients did not appear in the players list. Unlike Subsonic, whose getPlayer middleware runs on every authenticated endpoint, the Jellyfin router only registered a player on the /Sessions/Playing reports, so browsing or streaming never created one. Apply withPlayer to the whole authenticated group, mirroring Subsonic, so the calling device registers (and scrobbling has a player) as soon as it makes any authenticated request. Two follow-ups found while testing: - Skip registration when the request carries no client/device info (no X-Emby-Authorization, e.g. the /socket handshake that auths via ?api_key= only), which otherwise created a junk player named ' []'. - URL-decode the X-Emby-Authorization field values. Jellify's @jellyfin/sdk percent-encodes them (Device='Pixel%208%20Pro') while Finamp sends them raw, so the player name showed as 'Jellify [Pixel%208%20Pro]'. * refactor(jellyfin): move case-insensitive routing into the jellyfin package The case-insensitive path normalization lived in the server package but was only ever used by the Jellyfin router (its whole purpose is that Jellyfin clients route case-insensitively while chi does not). Move it into server/jellyfin and unexport it, so it sits with its only caller and no longer needs to be exported across a package boundary. * docs(jellyfin): document player registration, playlist and image behavior Updates the package README for the behavior added/fixed this round: - New 'Players and sessions' section: any authenticated request now registers the device as a player (like Subsonic), with the Client [Device] naming, URL-decoding of the Emby auth fields, and the /socket skip that avoids a nameless player. - Authentication: note AuthenticateByName is rate-limited per IP. - Playlists: repeated vs comma-separated id params, and that an explicit empty Ids clears the playlist while an omitted Ids leaves it untouched. - Cover art: WebP/GIF magic-number detection plus the MaxImageUploadSize and EnableArtworkUpload gates. - Endpoints table: add the /Similar and /UserFavoriteItems / /UserItems/.../UserData routes that were already served but unlisted. * feat(jellyfin): expose configured users on the login user-picker Adds Jellyfin.ExposedPublicUsers, a comma-separated allowlist of usernames that GET /Users/Public advertises so Jellyfin clients (Finamp, Jellify) can show a login user-picker instead of a blank username field. The endpoint is unauthenticated, so it defaults to exposing no users and never lists the full user table: only the admin-configured names are returned, resolved live per request (a name that doesn't exist is skipped and logged). Each entry is a minimal DTO (Name, Id) with no Policy/Configuration, so admin status isn't leaked pre-login, and no avatar since Navidrome has no per-user profile images. * style(jellyfin): trim redundant comments Remove comments that restated the code or duplicated an explanation already given nearby, keeping the ones that capture non-obvious rationale (client-specific quirks, gotchas). Comment-only change; no behavior difference. * perf(db): keep query planner statistics trustworthy with full ANALYZE PRAGMA optimize's internal ANALYZE runs with a limited analysis budget (~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality indexes: on a 96K-track library it claimed (missing, library_id) narrows to ~2000 rows when it matches the whole table. The planner then prefers that index over the sort index and falls back to a full-table temp B-tree sort per request, turning paginated song listings into multi-second queries (reproduced at 5.5s on real hardware; ~90x slower than with correct stats). Every index-creating migration re-triggered the poisoning via the post-migration optimize, and the daily optimizer could re-trigger it on large library changes. Setting analysis_limit on the connection does not help: optimize ignores it. Run a plain full ANALYZE instead: after migrations with schema changes, and in db.Optimize (daily schedule and scan-end). Stats are stored in the database file, so one connection suffices and the per-connection pool loop is gone. The Optimize call at shutdown is removed: stats are maintained at migration/scan/daily points, and an ANALYZE during shutdown only delays it and races container stop timeouts. * perf(db): add covering index for title-sorted song listings Deep pagination over songs sorted by title (WHERE missing/library_id, ORDER BY order_title LIMIT/OFFSET - the shape Jellyfin clients use to enumerate the library, and non-admin native/Subsonic song lists share) walked media_file_order_title and fetched the table row for every skipped entry just to evaluate the filter and the annotation/bookmark join keys: offset+limit random reads, seconds per page on cold spinning disks. The new (missing, library_id, order_title, id) index makes the offset skip fully index-resident: filter columns and the join key come from the index, and only the emitted page touches table rows. Measured on a 96K-track library: offset 50000 drops from ~6.5s (poisoned stats) / ~100ms (good stats, warm) to 24ms, and cold deep pages on NAS hardware from ~7s to ~0.5s. * feat(jellyfin): support transcoding via HLS playlist and server-forced player format - withPlayer now propagates the player's configured transcoding into the request context (like Subsonic's getPlayer), so a format forced in Settings > Players applies to the Jellyfin stream endpoints - new GET /Audio/{itemId}/main.m3u8, the endpoint Finamp plays through when its transcoding setting is enabled: a single-segment HLS VOD playlist pointing at the existing progressive transcode endpoint - streamAudio: treat audioBitRate as bits/sec (Jellyfin convention) and fall back to audioCodec as target format when no container is given * fix(jellyfin): honor GenreIds when browsing genre albums and tracks Finamp's genre screen sends ParentId=<libraryId> plus GenreIds=<genreId>, but /Items ignored the param, so every genre returned the whole library. - filter.ByGenreID delegates to persistence.TagIDFilter (exported, was tagIDFilter), the same mechanism behind the native API's genre_id filter - id lists are read via queryIDs, covering both spellings clients use (comma-separated and repeated params); /Items?ids= gains the repeated form too * feat(jellyfin): filter album artists by GenreIds Finamp's artist tab sends GenreIds to /Artists/AlbumArtists when a genre filter is active; the param was ignored, returning all artists. filter.ArtistsByGenreID matches artists credited as album artist on an album with the genre, via a non-correlated semi-join over album participants (86ms on a 29K-artist library; the correlated EXISTS form takes 11 minutes). Applied on the browse path of /Artists* and /Items?IncludeItemTypes=MusicArtist. The performers variant (/Artists?GenreIds=) still ignores the filter: it needs the same semi-join against media_file.participants, unmeasured on large libraries. * fix(jellyfin): version playlist image tag so clients refresh uploaded covers Uploads were stored and served correctly, but Finamp kept showing the old cover: it caches covers keyed by blurHash (its imageId is the item id, which never changes), and both our image tag and synthetic blurhash were derived from the playlist id alone. The tag is now <id>-<UpdatedAt millis hex> (SetImage/RemoveImage go through a full Put, which bumps UpdatedAt), and the blurhash derives from the tag, so every cover change rotates both. UpdatedAt over-invalidates (any playlist edit busts the cover cache), which only costs a refetch; hashing the actual image remains the proper long-term tag. An e2e test guards the whole chain, since a partial Put(pls, cols...) would silently stop bumping UpdatedAt. * fix(jellyfin): align cover upload limit and validation with the native endpoint Cover uploads of large photos failed with a silent 400: the MaxImageUploadSize cap was applied to the wire body, which Jellyfin clients base64-encode (4/3 inflation), so the effective raw-image limit was only ~7.5MB — and Finamp uploads picked photos uncompressed. Align with the native endpoint: - the limit caps the decoded image; the read cap allows for base64 inflation - validate by decoding (image.DecodeConfig) and take the storage extension from the real format instead of the Content-Type header, which clients get wrong (Finamp falls back to image/jpeg); a HEIC or corrupt file is now rejected instead of stored as a broken cover - rejected uploads log the reason (size/limit/decode error); diagnosing this from production logs previously required guesswork * fix(jellyfin): optimize SongsByArtistID filter to improve performance at library scale Signed-off-by: Deluan <deluan@navidrome.org> * fix(jellyfin): sort tracks by release year for SortBy=PremiereDate Finamp's "Latest Releases" artist section sends SortBy=PremiereDate,Album,... descending; PremiereDate wasn't in the Audio sort map, so applySort fell through to Album and the view came back in reverse album-name order (a 2002 remix album first, the 2013 release last). Map premieredate/productionyear to the year sort key, matching the ProductionYear the DTO exposes for songs and the existing MusicAlbum mapping (premieredate -> max_year). * feat(jellyfin): expose PremiereDate on tracks and albums Finamp's "Latest Releases" artist/genre sections re-sort the merged server responses client-side by PremiereDate and keep the top 5. Without the field every comparison returns equal and Dart's unstable sort leaves the picks in arbitrary order — a 2007 remix could lead the list even with the server sorting by year correctly. Serialize PremiereDate as ISO 8601 from the date tag (padding partial "2007"/"2007-02" values so DateTime.tryParse accepts them), falling back to the year; omitted when neither exists. * feat(jellyfin): resolve Finamp-truncated item ids (saved queue restore) Finamp persists its play queue by packing every item id into exactly 16 bytes (packIds assumes Jellyfin's 32-hex GUID ids), so our longer ids come back truncated after an app restart and "Failed to restore queue" loops forever: the /Items?ids= batch resolves nothing. Navidrome ids can't be made GUID-shaped (nanoid ids can exceed 128 bits), so compensate server-side: a 16-char id — a length no Navidrome id family uses — is resolved by unique-prefix range scan, with ambiguity failing safe. The ids= batch echoes the id as requested (Finamp matches restored items by its stored ids), and stream, image, item, user-data, favorite, rating and playback-report endpoints accept truncated ids transparently. The proper fix belongs upstream in Finamp's packIds; documented in the README so this layer can be removed once that ships. * feat(jellyfin): implement Items/{id}/InstantMix Finamp requests an instant mix on every track tap when its "start instant mix for individual tracks" setting is on (plus the long-press menus); the 404 made those taps fail with an error and play nothing. A track seed returns itself first — Finamp plays exactly what comes back — followed by the external provider's similar songs, capped at the requested limit and filtered to the caller's libraries. Container seeds (artist/album) return the provider's similar-songs blend. Provider errors and unknown seeds degrade to seed-only/empty results instead of 404s, reusing the Similar endpoints' bounded-wait singleflight (with a distinct cache key, since mixes and similar lists answer different shapes). Sonic-similarity backing stays a follow-up (see README). * style(jellyfin): trim wordy comments * refactor(jellyfin): apply cleanup review findings - batch truncated-id resolution in /Items?ids=: one chunked range query for all media-file prefixes instead of a query per id (a restored queue sends hundreds of truncated ids) - resolve truncated ids on the /Similar endpoints too, matching the neighboring InstantMix; document which entry points don't resolve - hoist maxImageUploadSize to core, deleting the byte-identical copies in nativeapi and jellyfin (tests moved to core) - drop the unused type parameter on premiereDate * fix(jellyfin): never drop the instant mix seed on a slow provider The seed track was built inside the awaited provider fetch, so when the external agent was slow or unreachable the request hit the 10s wait and answered a fully empty mix — Finamp then played nothing on tap, even though the seed needs no provider at all (seen live: Last.fm unreachable from the server, responseSize=49). Build the seed outside the await: only the similar-songs tail is fetched and bounded, and a timeout now degrades to a seed-only mix. Also folds instantMixForSong into getInstantMix, since the tail is exactly similarSongs. * fix(jellyfin): prefer the recommended Authorization scheme when picking a token Jellyfin's authorization guidance deprecates X-Emby-Token, X-MediaBrowser-Token, X-Emby-Authorization and api_key; the Authorization MediaBrowser scheme is the recommended form. All spellings stay accepted, but when a client sends several, the recommended one now wins. Adds coverage for the canonical Authorization header, which no test exercised directly. * refactor(jellyfin): rename parseEmbyAuth to parseMediaBrowserAuth The scheme is named MediaBrowser; the old name evoked the deprecated X-Emby-* spellings even though the function also parses the recommended Authorization header. * fix(jellyfin): prefer the Authorization header over X-Emby-Authorization The recommended header now wins when both carry MediaBrowser data — but only when it actually parses as MediaBrowser: a reverse proxy may inject Basic/Digest credentials into Authorization while the client sends the deprecated header, and those must not swallow the client's auth. * fix(jellyfin): require the MediaBrowser scheme when parsing auth headers The parser extracted key="value" pairs from any Authorization value; a foreign scheme whose parameters happened to use our field names would have been misread as client auth. Validate the scheme word instead (case-insensitively, per HTTP), accepting the legacy "Emby" spelling like real Jellyfin. Replaces the any-recognized-field heuristic for detecting proxy-injected Basic/Digest credentials. * Revert "perf(db): keep query planner statistics trustworthy with full ANALYZE" This reverts commit 118563e1053196f0e2e42dd4bee54e39a04ab145. The planner-statistics work now lives in its own PR (#5740); this branch keeps only the covering-index migration. * fix(db): renumber jellyfin covering-index migration after master's latest * fix(db): renumber Jellyfin covering-index migration * docs(jellyfin): document playlist annotations * refactor(log): clarify comments on external services query params * refactor(e2e): move Subsonic e2e suite to server/subsonic/e2e All files in server/e2e were Subsonic tests, so relocate the package under server/subsonic/e2e to sit alongside the API it exercises. The package name stays 'e2e'; only doc/comment references are updated. * refactor(filter): consolidate genre filters and unexport tagIDFilter Route filterByGenre, ByGenreID, and ArtistsByGenreID through a single genreTagFilter helper so the EXISTS json_tree(tags,"$.genre") predicate lives in one place. With server/filter no longer using it, persistence's TagIDFilter is only referenced within the package, so unexport it. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/root.go | 3 + cmd/wire_gen.go | 26 +- cmd/wire_injectors.go | 8 + conf/configuration.go | 11 + consts/consts.go | 5 + core/image_upload.go | 13 + core/image_upload_test.go | 26 + ...d_media_file_title_sort_covering_index.sql | 22 + log/log.go | 6 +- log/log_test.go | 5 + persistence/playlist_repository.go | 7 +- persistence/playlist_repository_test.go | 14 + persistence/sql_tags.go | 1 + server/{subsonic => }/filter/filters.go | 65 +- server/jellyfin/README.md | 329 ++++++++++ server/jellyfin/annotations.go | 131 ++++ server/jellyfin/annotations_test.go | 257 ++++++++ server/jellyfin/api.go | 200 ++++++ server/jellyfin/api_test.go | 87 +++ server/jellyfin/auth.go | 134 ++++ server/jellyfin/auth_test.go | 103 +++ server/jellyfin/browsing.go | 53 ++ server/jellyfin/browsing_test.go | 161 +++++ server/jellyfin/case_insensitive_routes.go | 68 ++ .../jellyfin/case_insensitive_routes_test.go | 90 +++ server/jellyfin/dto/blurhash.go | 36 ++ server/jellyfin/dto/blurhash_test.go | 27 + server/jellyfin/dto/dto.go | 258 ++++++++ server/jellyfin/dto/dto_suite_test.go | 17 + server/jellyfin/dto/fields.go | 24 + server/jellyfin/dto/ids.go | 23 + server/jellyfin/dto/ids_test.go | 35 + server/jellyfin/dto/mappers.go | 256 ++++++++ server/jellyfin/dto/mappers_test.go | 280 ++++++++ server/jellyfin/e2e/annotations_test.go | 142 ++++ server/jellyfin/e2e/auth_test.go | 120 ++++ server/jellyfin/e2e/browsing_test.go | 389 +++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 365 +++++++++++ server/jellyfin/e2e/images_test.go | 74 +++ server/jellyfin/e2e/multiuser_test.go | 64 ++ server/jellyfin/e2e/playlists_test.go | 311 +++++++++ server/jellyfin/e2e/routing_test.go | 31 + server/jellyfin/e2e/search_test.go | 76 +++ server/jellyfin/e2e/sessions_test.go | 62 ++ server/jellyfin/e2e/similar_test.go | 134 ++++ server/jellyfin/e2e/smoke_test.go | 49 ++ server/jellyfin/e2e/streaming_test.go | 128 ++++ server/jellyfin/e2e/system_test.go | 55 ++ server/jellyfin/images.go | 170 +++++ server/jellyfin/images_test.go | 394 ++++++++++++ server/jellyfin/items.go | 577 +++++++++++++++++ server/jellyfin/items_test.go | 608 ++++++++++++++++++ server/jellyfin/jellyfin_suite_test.go | 25 + server/jellyfin/library.go | 44 ++ server/jellyfin/middlewares.go | 187 ++++++ server/jellyfin/middlewares_test.go | 254 ++++++++ server/jellyfin/playlists.go | 257 ++++++++ server/jellyfin/playlists_test.go | 424 ++++++++++++ server/jellyfin/routing_test.go | 56 ++ server/jellyfin/sessions.go | 115 ++++ server/jellyfin/sessions_test.go | 216 +++++++ server/jellyfin/similar.go | 190 ++++++ server/jellyfin/similar_test.go | 131 ++++ server/jellyfin/socket.go | 55 ++ server/jellyfin/socket_test.go | 125 ++++ server/jellyfin/stream.go | 164 +++++ server/jellyfin/stream_test.go | 316 +++++++++ server/jellyfin/system.go | 96 +++ server/jellyfin/system_test.go | 124 ++++ server/jellyfin/truncated_ids.go | 113 ++++ server/jellyfin/users.go | 61 ++ server/jellyfin/users_test.go | 130 ++++ server/middlewares.go | 8 +- server/nativeapi/image_upload.go | 13 +- server/nativeapi/image_upload_test.go | 34 - server/subsonic/album_lists.go | 2 +- server/subsonic/browsing.go | 2 +- server/{ => subsonic}/e2e/doc.go | 2 +- server/{ => subsonic}/e2e/e2e_suite_test.go | 174 +---- .../e2e/subsonic_album_lists_test.go | 0 .../e2e/subsonic_bookmarks_test.go | 0 .../e2e/subsonic_browsing_test.go | 0 .../e2e/subsonic_lyrics_test.go | 0 .../e2e/subsonic_media_annotation_test.go | 0 .../e2e/subsonic_media_retrieval_test.go | 0 .../e2e/subsonic_multilibrary_test.go | 0 .../e2e/subsonic_multiuser_test.go | 0 .../e2e/subsonic_playlists_test.go | 0 .../{ => subsonic}/e2e/subsonic_radio_test.go | 0 .../{ => subsonic}/e2e/subsonic_scan_test.go | 0 .../e2e/subsonic_searching_test.go | 0 .../e2e/subsonic_sharing_test.go | 0 .../e2e/subsonic_sonic_similarity_test.go | 5 +- .../e2e/subsonic_stream_test.go | 0 .../e2e/subsonic_system_test.go | 0 .../e2e/subsonic_transcode_test.go | 0 .../{ => subsonic}/e2e/subsonic_users_test.go | 0 tests/harness/harness.go | 178 +++++ tests/mock_album_repo.go | 8 + tests/mock_artist_repo.go | 29 + tests/mock_mediafile_repo.go | 22 + tests/mock_playlist_repo.go | 24 +- 102 files changed, 9880 insertions(+), 234 deletions(-) create mode 100644 db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql rename server/{subsonic => }/filter/filters.go (59%) create mode 100644 server/jellyfin/README.md create mode 100644 server/jellyfin/annotations.go create mode 100644 server/jellyfin/annotations_test.go create mode 100644 server/jellyfin/api.go create mode 100644 server/jellyfin/api_test.go create mode 100644 server/jellyfin/auth.go create mode 100644 server/jellyfin/auth_test.go create mode 100644 server/jellyfin/browsing.go create mode 100644 server/jellyfin/browsing_test.go create mode 100644 server/jellyfin/case_insensitive_routes.go create mode 100644 server/jellyfin/case_insensitive_routes_test.go create mode 100644 server/jellyfin/dto/blurhash.go create mode 100644 server/jellyfin/dto/blurhash_test.go create mode 100644 server/jellyfin/dto/dto.go create mode 100644 server/jellyfin/dto/dto_suite_test.go create mode 100644 server/jellyfin/dto/fields.go create mode 100644 server/jellyfin/dto/ids.go create mode 100644 server/jellyfin/dto/ids_test.go create mode 100644 server/jellyfin/dto/mappers.go create mode 100644 server/jellyfin/dto/mappers_test.go create mode 100644 server/jellyfin/e2e/annotations_test.go create mode 100644 server/jellyfin/e2e/auth_test.go create mode 100644 server/jellyfin/e2e/browsing_test.go create mode 100644 server/jellyfin/e2e/e2e_suite_test.go create mode 100644 server/jellyfin/e2e/images_test.go create mode 100644 server/jellyfin/e2e/multiuser_test.go create mode 100644 server/jellyfin/e2e/playlists_test.go create mode 100644 server/jellyfin/e2e/routing_test.go create mode 100644 server/jellyfin/e2e/search_test.go create mode 100644 server/jellyfin/e2e/sessions_test.go create mode 100644 server/jellyfin/e2e/similar_test.go create mode 100644 server/jellyfin/e2e/smoke_test.go create mode 100644 server/jellyfin/e2e/streaming_test.go create mode 100644 server/jellyfin/e2e/system_test.go create mode 100644 server/jellyfin/images.go create mode 100644 server/jellyfin/images_test.go create mode 100644 server/jellyfin/items.go create mode 100644 server/jellyfin/items_test.go create mode 100644 server/jellyfin/jellyfin_suite_test.go create mode 100644 server/jellyfin/library.go create mode 100644 server/jellyfin/middlewares.go create mode 100644 server/jellyfin/middlewares_test.go create mode 100644 server/jellyfin/playlists.go create mode 100644 server/jellyfin/playlists_test.go create mode 100644 server/jellyfin/routing_test.go create mode 100644 server/jellyfin/sessions.go create mode 100644 server/jellyfin/sessions_test.go create mode 100644 server/jellyfin/similar.go create mode 100644 server/jellyfin/similar_test.go create mode 100644 server/jellyfin/socket.go create mode 100644 server/jellyfin/socket_test.go create mode 100644 server/jellyfin/stream.go create mode 100644 server/jellyfin/stream_test.go create mode 100644 server/jellyfin/system.go create mode 100644 server/jellyfin/system_test.go create mode 100644 server/jellyfin/truncated_ids.go create mode 100644 server/jellyfin/users.go create mode 100644 server/jellyfin/users_test.go delete mode 100644 server/nativeapi/image_upload_test.go rename server/{ => subsonic}/e2e/doc.go (98%) rename server/{ => subsonic}/e2e/e2e_suite_test.go (75%) rename server/{ => subsonic}/e2e/subsonic_album_lists_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_bookmarks_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_browsing_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_lyrics_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_media_annotation_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_media_retrieval_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_multilibrary_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_multiuser_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_playlists_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_radio_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_scan_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_searching_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_sharing_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_sonic_similarity_test.go (98%) rename server/{ => subsonic}/e2e/subsonic_stream_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_system_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_transcode_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_users_test.go (100%) create mode 100644 tests/harness/harness.go diff --git a/cmd/root.go b/cmd/root.go index b231aae0d..9e2b38cd8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error { if conf.Server.ListenBrainz.Enabled { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } + if conf.Server.Jellyfin.Enabled { + a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) + } if conf.Server.Prometheus.Enabled { p := CreatePrometheus() // blocking call because takes <100ms but useful if fails diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6ffc44d4..4a2b46289 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -31,6 +31,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -116,6 +117,29 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { return router } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + players := core.NewPlayers(dataStore) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider) + return router +} + func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -221,7 +245,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index bb5c5b5f3..0f6b73891 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -23,6 +23,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -33,6 +34,7 @@ var allProviders = wire.NewSet( artwork.Set, server.New, subsonic.New, + jellyfin.New, nativeapi.New, public.New, persistence.New, @@ -79,6 +81,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { )) } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + panic(wire.Build( + allProviders, + )) +} + func CreatePublicRouter() *public.Router { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index e939ebb7e..562f52465 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -117,6 +117,7 @@ type configOptions struct { LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` + Jellyfin jellyfinOptions `json:",omitzero"` EnableScrobbleHistory bool Tags map[string]TagConf `json:",omitempty"` Agents string @@ -218,6 +219,14 @@ type listenBrainzOptions struct { TrackAlgorithm string } +type jellyfinOptions struct { + Enabled bool + ServerName string + // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated + // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. + ExposedPublicUsers string +} + type httpHeaderOptions struct { FrameOptions string } @@ -849,6 +858,8 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) + viper.SetDefault("jellyfin.enabled", false) + viper.SetDefault("jellyfin.servername", "") viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/consts/consts.go b/consts/consts.go index 73f89b450..f453ac125 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -49,6 +49,11 @@ const ( URLPathSubsonicAPI = "/rest" URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" + URLPathJellyfinAPI = "/jellyfin" + + // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the + // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. + JellyfinServerIDKey = "JellyfinServerID" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome diff --git a/core/image_upload.go b/core/image_upload.go index c2432b647..eb61b225a 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -17,6 +20,16 @@ type ImageUploadService interface { RemoveImage(ctx context.Context, path string) error } +// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default +// when it's unset/invalid. Shared by every API that accepts image uploads. +func MaxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} + type imageUploadService struct{} func NewImageUploadService() ImageUploadService { diff --git a/core/image_upload_test.go b/core/image_upload_test.go index 265f60a95..e7648df34 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -97,3 +97,29 @@ var _ = Describe("ImageUploadService", func() { }) }) }) + +var _ = Describe("MaxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) diff --git a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql new file mode 100644 index 000000000..18666eef9 --- /dev/null +++ b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin + +-- Covering index for the title-sorted, library-scoped song listing: +-- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m +-- (Jellyfin clients page through the whole library this way; non-admin native and +-- Subsonic song lists produce the same shape.) +-- +-- Without it, SQLite walks media_file_order_title and must fetch the table row for +-- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit +-- random row reads (seconds on cold spinning disks). With the filter columns in the +-- index the skip is index-only. `id` is included because the annotation/bookmark +-- LEFT JOINs run per candidate row and need the join key; without it each skipped +-- entry still triggers a row fetch. +create index if not exists media_file_missing_library_order_title + on media_file(missing, library_id, order_title, id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop index if exists media_file_missing_library_order_title; +-- +goose StatementEnd diff --git a/log/log.go b/log/log.go index eaea75fb9..1c4ee3b4b 100644 --- a/log/log.go +++ b/log/log.go @@ -45,8 +45,10 @@ var redacted = &Hook{ "([^\\w]p=)[^&]+", "([^\\w]jwt=)[^&]+", - // External services query params - "([^\\w]api_key=)[\\w]+", + // External services query params. Values can be JWTs (dots, dashes), so match everything up + // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop + // at a JWT's first '.' and leak its payload and signature. + "([^\\w]api_key=)[^&\\s]+", }, } diff --git a/log/log_test.go b/log/log_test.go index 7e1f3f3cc..7b6ecfc32 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -259,5 +259,10 @@ var _ = Describe("Logger", func() { msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title" Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title")) }) + + It("redacts a whole JWT in api_key, not just up to its first dot", func() { + msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) + }) }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index fe1f50689..573f43c10 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -85,8 +85,11 @@ func (r *playlistRepository) userFilter() Sqlizer { } func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sq := Select().Where(r.userFilter()) - return r.count(sq, options...) + query := Select().Where(r.userFilter()) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "playlist.id") + } + return r.count(query, options...) } func (r *playlistRepository) Exists(id string) (bool, error) { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c5b16b88f..831e24453 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "slices" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -132,6 +133,19 @@ var _ = Describe("PlaylistRepository", func() { Expect(all[idx].Starred).To(BeTrue()) }) + It("counts playlists using annotation filters", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} + starred, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + + count, err := repo.CountAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(len(starred)))) + }) + It("does not leak an annotation row of another item_type sharing the playlist id", func() { // Older builds (and the star fallthrough) can leave a media_file-typed row // under a playlist id; the item_type-scoped join must not surface or dupe it. diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 88acebb7f..5177bc8e4 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,7 @@ func marshalTags(tags model.Tags) string { return string(res) } +// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "<name>_id" key maps to "$.<name>". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") return Exists( diff --git a/server/subsonic/filter/filters.go b/server/filter/filters.go similarity index 59% rename from server/subsonic/filter/filters.go rename to server/filter/filters.go index d19e163dd..e149dbced 100644 --- a/server/subsonic/filter/filters.go +++ b/server/filter/filters.go @@ -61,6 +61,19 @@ func AlbumsByArtistID(artistId string) Options { }) } +// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the +// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an +// artist's own discography never leaks into it. +func AlbumsByContributingArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "max_year", + Filters: And{ + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + }, + }) +} + func AlbumsByYear(fromYear, toYear int) Options { orderOption := "" if fromYear > toYear { @@ -90,6 +103,17 @@ func SongsByAlbum(albumId string) Options { }) } +// SongsByArtistID matches media files where the artist participates as album or track artist, in +// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale. +func SongsByArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "album", + Filters: Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))", + artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()), + }) +} + func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { options := Options{} ff := And{} @@ -138,6 +162,21 @@ func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { return opts } +// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist, +// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter). +func ArtistsByRole(opts Options, role model.Role) Options { + if _, ok := model.AllRoles[role.String()]; !ok { + return opts + } + roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL") + if opts.Filters == nil { + opts.Filters = roleFilter + } else { + opts.Filters = And{opts.Filters, roleFilter} + } + return opts +} + func ByGenre(genre string) Options { return addDefaultFilters(Options{ Sort: "name", @@ -145,11 +184,29 @@ func ByGenre(genre string) Options { }) } +// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids. +func ByGenreID(genreIds []string) Sqlizer { + return genreTagFilter(Eq{"value": genreIds}) +} + +// ArtistsByGenreID matches artists credited as album artist on an album with any of the given +// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. +func ArtistsByGenreID(genreIds []string) Sqlizer { + return Expr( + `artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt + WHERE jt.atom IS NOT NULL AND ?)`, + genreTagFilter(Eq{"value": genreIds}), + ) +} + +// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry +// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. +func genreTagFilter(cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +} + func filterByGenre(genre string) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{ - Like{"value": genre}, - NotEq{"atom": nil}, - }) + return genreTagFilter(Like{"value": genre}) } func ByRating() Options { diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md new file mode 100644 index 000000000..fb5c4a637 --- /dev/null +++ b/server/jellyfin/README.md @@ -0,0 +1,329 @@ +# Jellyfin API + +This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of +Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets +Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), +[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +requiring a real Jellyfin server. + +It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music +library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report +playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's +admin/dashboard APIs are out of scope. + +## Enabling + +The Jellyfin API is disabled by default. Enable it via `navidrome.toml`: + +```toml +[Jellyfin] +Enabled = true +# Optional: override the server name reported to clients (defaults to "Navidrome <version>") +ServerName = "My Music Server" +# Optional: usernames to show in the client login user-picker (default: none). See "Public user list". +ExposedPublicUsers = "alice, bob" +``` + +or via environment variables: + +```bash +ND_JELLYFIN_ENABLED=true +ND_JELLYFIN_SERVERNAME="My Music Server" +ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +``` + +Once enabled, the API is mounted at: + +``` +http://<host>:<port>/jellyfin +``` + +All the paths below are relative to that base URL (e.g. `System/Info/Public` means +`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, +since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. + +## Authentication + +Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome +username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on +every subsequent request as the `X-Emby-Token` header (or embedded in the +`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey` +query param — all forms are accepted, matching what different clients do). + +`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native +`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force +surface. + +### Public user list (login picker) + +`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the +password) instead of a blank username field. It's **unauthenticated**, so by default it exposes +**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise: + +```toml +[Jellyfin] +ExposedPublicUsers = "alice, bob" +``` + +Only the named users are listed (never the full user table), resolved live per request; a configured +name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`) +with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no +avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images). + +## Players and sessions + +Every authenticated request registers (or refreshes) the calling device as a Navidrome player, +mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling +has a player) as soon as it makes any authenticated call, not only when it reports playback. The +player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is +`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them +(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no +client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is +skipped, so it doesn't create a nameless player. + +## ID encoding + +Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id +is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients +parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`, +which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id +from an old migrated library is itself valid hex, correctness depends on every emit path encoding +and every receive path decoding — see `dto/ids.go`. + +## Multi-library behavior + +Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each +Navidrome library the current user can access is exposed as its own top-level Jellyfin +"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. +Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the +authenticated user has access to; a library (or item within it) the user cannot access returns +`404`, never `403`, so ids can't be used as an existence oracle. + +### Browsing filters + +`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id +for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into +an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or +tracks — Finamp's artist screen sends these *alongside* `ParentId=<libraryId>`); `GenreIds` (a +genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` +and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); +`SearchTerm`; +favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). + +## Implemented endpoints + +| Area | Endpoints | +|---|---| +| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | +| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | +| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | +| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` | +| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | +| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | +| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` | +| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | +| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | +| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | + +Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level +as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint +that isn't in the table above, check the server logs for these lines to see exactly what it's +requesting. + +## Playlist management + +Playlists are the main writable surface of this API: + +- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`) + or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album, + artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before + the write, matching how Jellyfin clients populate these lists. A bare song id passes through. +- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients + spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a + single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one + track of an expanded album. +- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp + uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an + omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to + Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`. +- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body, + JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it. + Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, + so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're + bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins. +- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the + playlist-track row id, distinct from the song id) so a client can echo it back via + `DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more + than once in the same playlist. + +Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if +it is visible to them (public) or `404` if it is not (private) — the API never reveals that +someone else's private playlist exists. + +## Images + +The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive, +matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is +therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache +warmer uses — so user-scoped items like private playlists still resolve their cover instead of +falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to +their Navidrome `ArtworkID`. + +## Finamp saved-queue id truncation + +Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that +when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16 +bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into +GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for +those **truncated ids** back when restoring the queue — item lookups, then streaming, images, +favorites and playback reports for the restored tracks. + +This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome +id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; +ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as +requested**, because Finamp matches restored items back to its stored ids, and the other item +endpoints accept truncated ids transparently. + +**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`) +should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the +queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids, +so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer +can be removed. + +## Streaming and transcoding + +The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint: + +- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the + `.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`. + `audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true` + forces direct play (raw), never a transcode. +- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin. + Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g. + DSF) can't be rescued server-side on this path. +- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting + is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive + transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. + Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`, + `mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic + transcoded streams. +- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings → + Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as + Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are + advertised and served but packed-audio players won't decode them. + +## curl walkthrough + +This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the +library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist. + +```bash +BASE=http://localhost:4533/jellyfin + +# 1. Handshake (no auth required) +curl -s "$BASE/System/Info/Public" | jq . + +# 2. Login - capture the AccessToken +TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \ + -H 'Content-Type: application/json' \ + -d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken) + +AUTH=(-H "X-Emby-Token: $TOKEN") + +# 3. List the user's views (one per accessible library) +curl -s "${AUTH[@]}" "$BASE/UserViews" | jq . + +# 4. Browse artists +curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq . +ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id') + +# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum) +ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id') + +# 6. List the album's songs +USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id) +SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \ + | jq -r '.Items[0].Id') + +# 7. Ask for playback info, then stream the song +curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq . +curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio + +# 8. Favorite the song +curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq . + +# 9. Report playback start/stop (also drives scrobbling) +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing" +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped" + +# 10. Create a playlist from a whole album (the album id is expanded to its tracks) +PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id) + +# 11. Make it public, then remove one entry +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID" +ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId') +curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID" + +# 12. Delete the playlist +curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" +``` + +## Testing + +Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in +[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and +real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot +isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with: + +```bash +make test PKG=./server/jellyfin/... +``` + +## Known limitations + +- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's + libraries (genre tags aren't per-library entities in Navidrome's model). +- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each + belong to exactly one library and are checked against `user.HasLibraryAccess` on every + fetch), an artist can have content across multiple libraries via `library_artist`, so there's + no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against. + Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` + *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client + that already has an artist id from elsewhere is not re-checked against library membership. +- **MD5-hash ids from old migrated libraries.** The hex id codec assumes ids are opaque; a raw + 32-char MD5 id is itself valid hex and so must be encoded/decoded symmetrically like any other. + This is handled, but is the most fragile id case — see the note in `dto/ids.go`. +- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is + populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** + blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a + multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and + stores it per image, so its placeholder approximates the art. Ours satisfies the protocol + (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash + warning) but renders as a flat color while art loads. A proper implementation would compute the + real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it + keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback + for art that hasn't been rendered yet. +- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a + `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a + working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up + would broadcast real session/playstate and library-change events over it (via `server/events`), + mirroring Jellyfin's session messages. +- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp + and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve + Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both + clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level + timing, so word-sync is out of scope.) +- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are + backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed + track followed by the provider's similar songs (with agents disabled it degrades to a seed-only + mix). A follow-up would back them with Navidrome's `core/sonic` provider — the same one behind + the OpenSubsonic `sonicSimilarity` extension (`getSonicSimilarTracks`) that AudioMuse-AI feeds + via its Navidrome plugin, and the exact endpoint AudioMuse's own Jellyfin plugin overrides. + Needs the `core/sonic.Sonic` service injected into the `Router` (wire change). diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go new file mode 100644 index 000000000..8f900ee87 --- /dev/null +++ b/server/jellyfin/annotations.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "errors" + "math" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't +// access their library; artists span libraries (library_artist), so have no single LibraryID to +// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. +// When ok is false the response has already been written, so callers must return without writing +// the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.Album(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if _, err := api.ds.Artist(ctx).Get(id); err == nil { + return api.ds.Artist(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.MediaFile(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + playlistRepo := api.ds.Playlist(ctx) + if _, err := playlistRepo.Get(id); err == nil { + return playlistRepo, true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false +} + +// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify +// fetches this per item to render played/favourite indicators; resolveItemByID enforces the +// library-access gate. +func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + item, ok := api.resolveItemByID(r.Context(), id, nil) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + data := item.UserData + if data == nil { + // Items without annotations still return a valid empty UserData. + data = dto.UserData(model.Annotations{}, id) + } + api.ok(w, r, data) +} + +func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetStar(starred, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) +} + +func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } +func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { + api.setFavorite(w, r, false) +} + +func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetRating(rating, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} + if rating > 0 { + jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData + d.Rating = &jfRating + } + api.ok(w, r, d) +} + +// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to +// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since +// SetRating(0) is the delete path. +func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { + jfRating := req.Params(r).Float64Or("rating", 0) + jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating + rating := int(math.Round(jfRating / 2)) + if jfRating > 0 { + rating = max(rating, 1) + } + api.setItemRating(w, r, rating) +} + +func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { + api.setItemRating(w, r, 0) +} diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go new file mode 100644 index 000000000..4a7efabd7 --- /dev/null +++ b/server/jellyfin/annotations_test.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + var api *Router + var ds *tests.MockDataStore + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("markFavorite / unmarkFavorite", func() { + It("stars a song and returns IsFavorite=true", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(mfRepo.Data["s1"].Starred).To(BeTrue()) + }) + + It("stars an album and returns IsFavorite=true", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(albumRepo.Data["a1"].Starred).To(BeTrue()) + }) + + It("stars an artist without checking library access (artists span multiple libraries)", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + // alice only has access to library 1, but artists aren't gated per-library. + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "ar1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(artistRepo.Data["ar1"].Starred).To(BeTrue()) + }) + + It("stars a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Starred["p1"]).To(BeTrue()) + }) + + It("unstars a song and returns IsFavorite=false", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.unmarkFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeFalse()) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star a song in a library the user can't access", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "x1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("setRating / removeRating", func() { + It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).NotTo(BeNil()) + Expect(*d.Rating).To(Equal(8.0)) + }) + + It("rates an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(5)) + }) + + It("rates a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Ratings["p1"]).To(Equal(4)) + }) + + It("removes a rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.removeRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).To(BeNil()) + }) + + It("returns 404 and does not rate an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(0)) + }) + + It("rounds an odd rating to the nearest star instead of truncating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("stores the minimum star for Rating=1 instead of clearing the rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(1)) + }) + + It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + }) + + It("clamps a Rating above 10 to Navidrome's max (5)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("clamps a negative Rating to Navidrome's min (0)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go new file mode 100644 index 000000000..fd523d154 --- /dev/null +++ b/server/jellyfin/api.go @@ -0,0 +1,200 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "golang.org/x/sync/singleflight" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type Router struct { + http.Handler + ds model.DataStore + artwork artwork.Artwork + streamer stream.MediaStreamer + transcodeDecider stream.TranscodeDecider + players core.Players + scrobbler scrobbler.PlayTracker + playlists playlists.Playlists + provider external.Provider + similarFlight singleflight.Group + serverIDMu sync.Mutex + serverIDVal string +} + +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, + transcodeDecider stream.TranscodeDecider, players core.Players, + scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider) *Router { + r := &Router{ + ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, + players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + } + r.Handler = r.routes() + return r +} + +func (api *Router) routes() http.Handler { + inner := chi.NewRouter() + + // Read query params case-insensitively, like real Jellyfin. Must precede all routes so every + // handler and the api_key check see folded keys. + inner.Use(normalizeQueryKeys) + + // Public (no auth): handshake + login. + inner.Get("/System/Info/Public", api.getPublicSystemInfo) + inner.Get("/System/Ping", api.ping) + inner.Post("/System/Ping", api.ping) + inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated + // brute-force surface, so it must share the same per-IP throttle when one is configured. + if conf.Server.AuthRequestLimit > 0 { + limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName) + } else { + inner.Post("/Users/AuthenticateByName", api.authenticateByName) + } + inner.Get("/Users/Public", api.getPublicUsers) + + // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. + inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + + inner.Group(func(r chi.Router) { + r.Use(api.authenticate) + // Register/refresh the calling device as a player on every authenticated request, like + // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a + // player) even before the first playback report. + r.Use(api.withPlayer) + r.Get("/UserViews", api.getUserViews) + r.Get("/Users/{userId}/Views", api.getUserViews) + r.Get("/Users/Me", api.getCurrentUser) + r.Get("/Users/{userId}", api.getCurrentUser) + + r.Get("/Items", api.getItems) + r.Get("/Users/{userId}/Items", api.getItems) + r.Get("/Items/{itemId}", api.getItem) + r.Get("/Users/{userId}/Items/{itemId}", api.getItem) + r.Delete("/Items/{itemId}", api.deleteItem) + r.Get("/Users/{userId}/Items/Latest", api.getLatest) + + // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the + // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. + r.Post("/UserFavoriteItems/{itemId}", api.markFavorite) + r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite) + r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite) + r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite) + r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating) + r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating) + + // Per-item play/favorite/rating state. Jellify uses the /UserItems form; + // /Users/{userId}/Items is the legacy spelling. + r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) + r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) + + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) + r.Get("/Items/{itemId}/Similar", api.getSimilarItems) + r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) + r.Get("/Genres", api.getGenres) + r.Get("/MusicGenres", api.getGenres) + + r.Post("/Playlists", api.createPlaylist) + r.Get("/Playlists/{playlistId}", api.getPlaylist) + r.Post("/Playlists/{playlistId}", api.updatePlaylist) + r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) + r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) + r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) + r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) + r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser) + + // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes + // above stay public. + r.Post("/Items/{itemId}/Images/{type}", api.postItemImage) + r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage) + + r.Get("/Audio/{itemId}/stream", api.streamAudio) + r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/Audio/{itemId}/universal", api.streamAudio) + r.Get("/Audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of + // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. + r.Get("/Items/{itemId}/File", api.streamFile) + r.Get("/Items/{itemId}/Download", api.streamFile) + + r.Post("/Sessions/Playing", api.reportPlaybackStart) + r.Post("/Sessions/Playing/Progress", api.reportPlaybackProgress) + r.Post("/Sessions/Playing/Stopped", api.reportPlaybackStopped) + r.Post("/Sessions/Capabilities", api.postCapabilities) + r.Post("/Sessions/Capabilities/Full", api.postCapabilities) + + // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. + r.Get("/socket", api.handleSocket) + }) + + // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected + // traffic, and this just surfaces what's missing. + inner.NotFound(api.notFound) + inner.MethodNotAllowed(api.notFound) + + // Real Jellyfin clients route case-insensitively; chi does not. + return caseInsensitivePaths(inner) +} + +// ok writes payload as JSON, stamping ServerId on any item(s) in it — real Jellyfin always sets it, +// and it's the same value for every item, so it's applied here rather than threaded through mappers. +func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { + switch p := payload.(type) { + case dto.QueryResult: + api.stampServerID(r.Context(), p.Items) + case []dto.BaseItemDto: + api.stampServerID(r.Context(), p) + case dto.BaseItemDto: + p.ServerId = api.serverID(r.Context()) + payload = p + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Error(r.Context(), "Jellyfin API: error encoding response", err) + } +} + +func (api *Router) stampServerID(ctx context.Context, items []dto.BaseItemDto) { + sid := api.serverID(ctx) + for i := range items { + items[i].ServerId = sid + } +} + +// notFound handles unmatched routes and unsupported methods, logging them so unimplemented +// endpoints surface instead of returning chi's default plain-text 404/405. +func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { + log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{}`)) +} + +// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output, +// file paths) never reaches the client. +func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) { + log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) +} diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go new file mode 100644 index 000000000..23504c73e --- /dev/null +++ b/server/jellyfin/api_test.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Router", func() { + It("serves the public handshake through the mounted handler", func() { + ds := &tests.MockDataStore{} + api := New(ds, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 JSON for unknown routes", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("returns 404 JSON for a known path with an unsupported method", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("registers a player on a general authenticated request, not just playback reports", func() { + ds := &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + fp := &fakePlayers{} + api := New(ds, nil, nil, nil, fp, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`) + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fp.registerCalls).To(Equal(1)) + Expect(fp.lastClient).To(Equal("Jellify")) + }) + + It("rate-limits AuthenticateByName by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + + login := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + api.ServeHTTP(w, r) + return w.Code + } + // The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429. + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusTooManyRequests)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go new file mode 100644 index 000000000..ecd129222 --- /dev/null +++ b/server/jellyfin/auth.go @@ -0,0 +1,134 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type authenticateByNameRequest struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body authenticateByNameRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. + usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) + if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { + log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a + // never/stale "Last Login" in the admin UI. + if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { + log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) + } + + token, err := auth.CreateToken(usr) + if err != nil { + api.internalError(w, r, err) + return + } + + // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's + // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + api.ok(w, r, dto.AuthenticationResult{ + User: userToDto(usr, api.serverName(), api.serverID(ctx)), + AccessToken: token, + ServerId: api.serverID(ctx), + }) +} + +// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after +// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults. +func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { + return &dto.UserDto{ + Name: u.UserName, + Id: u.ID, + ServerId: serverID, + ServerName: serverName, + HasPassword: true, + HasConfiguredPassword: true, + Policy: userPolicy(u), + Configuration: userConfiguration(), + } +} + +func userPolicy(u *model.User) *dto.UserPolicy { + return &dto.UserPolicy{ + IsAdministrator: u.IsAdmin, + IsHidden: false, + EnableCollectionManagement: false, + EnableSubtitleManagement: false, + EnableLyricManagement: false, + IsDisabled: false, + BlockedTags: []string{}, + AllowedTags: []string{}, + EnableUserPreferenceAccess: true, + AccessSchedules: []string{}, + BlockUnratedItems: []string{}, + EnableRemoteControlOfOtherUsers: false, + EnableSharedDeviceControl: false, + EnableRemoteAccess: true, + EnableLiveTvManagement: false, + EnableLiveTvAccess: false, + EnableMediaPlayback: true, + EnableAudioPlaybackTranscoding: true, + EnableVideoPlaybackTranscoding: true, + EnablePlaybackRemuxing: true, + ForceRemoteSourceTranscoding: false, + EnableContentDeletion: false, + EnableContentDeletionFromFolders: []string{}, + EnableContentDownloading: true, + EnableSyncTranscoding: true, + EnableMediaConversion: true, + EnabledDevices: []string{}, + EnableAllDevices: true, + EnabledChannels: []string{}, + EnableAllChannels: false, + EnabledFolders: []string{}, + EnableAllFolders: true, + InvalidLoginAttemptCount: 0, + LoginAttemptsBeforeLockout: -1, + MaxActiveSessions: 0, + EnablePublicSharing: true, + BlockedMediaFolders: []string{}, + BlockedChannels: []string{}, + RemoteClientBitrateLimit: 0, + AuthenticationProviderId: "", + PasswordResetProviderId: "", + SyncPlayAccess: "CreateAndJoinGroups", + } +} + +func userConfiguration() *dto.UserConfiguration { + return &dto.UserConfiguration{ + PlayDefaultAudioTrack: true, + SubtitleLanguagePreference: "", + DisplayMissingEpisodes: false, + GroupedFolders: []string{}, + SubtitleMode: "Default", + DisplayCollectionsView: false, + EnableLocalPassword: false, + OrderedViews: []string{}, + LatestItemsExcludes: []string{}, + MyMediaExcludes: []string{}, + HidePlayedInLatest: true, + RememberAudioSelections: true, + RememberSubtitleSelections: true, + EnableNextEpisodeAutoPlay: true, + CastReceiverId: "", + } +} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go new file mode 100644 index 000000000..b51420f1a --- /dev/null +++ b/server/jellyfin/auth_test.go @@ -0,0 +1,103 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AuthenticateByName", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + It("issues a token for valid credentials", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User.Name).To(Equal("alice")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + // Finamp reads Policy/Configuration right after login and null-crashes if they're absent. + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) + Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) + Expect(res.User.Configuration).ToNot(BeNil()) + + // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's + // login doesn't require it, so it should be omitted entirely rather than sent partial. + Expect(res.SessionInfo).To(BeNil()) + }) + + It("records the login time, like the web UI login does", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr, err := ur.FindByUsername("alice") + Expect(err).ToNot(HaveOccurred()) + Expect(usr.LastLoginAt).ToNot(BeNil()) + }) + + It("reflects an administrator in the User.Policy", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"root","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + }) + + It("rejects invalid credentials with 401", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password even for a user with an empty stored password with 401", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"empty","Pw":""}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go new file mode 100644 index 000000000..bf77c4594 --- /dev/null +++ b/server/jellyfin/browsing.go @@ -0,0 +1,53 @@ +package jellyfin + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists +// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers +// don't appear identically in both. +func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleArtist) +} + +func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleAlbumArtist) +} + +// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library +// when accessible (like queryItems) or all accessible libraries otherwise. +func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) + + scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Finamp's artist tab sends GenreIds when a genre filter is active. + genreIds := decodedQueryIDs(r, "genreids") + + res, err := api.listArtists(ctx, opts, genreIds, scopeIDs, p.StringOr("searchterm", ""), false, role) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies. +func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + res, err := api.listGenres(ctx, opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go new file mode 100644 index 000000000..7f50355e1 --- /dev/null +++ b/server/jellyfin/browsing_test.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Browsing", func() { + var api *Router + var ds *tests.MockDataStore + ctxUser := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("getArtists", func() { + It("lists artists via /Artists", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("handles /Artists/AlbumArtists the same way", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("scopes results to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes to a single library when ParentId is an accessible library id", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(2)) + Expect(args).NotTo(ContainElement(1)) + }) + + It("does not let ParentId=<inaccessible library id> narrow the scope", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Offset).To(Equal(5)) + Expect(artistRepo.Options.Max).To(Equal(10)) + }) + + It("does not restrict results for an admin user", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added. + if artistRepo.Options.Filters == nil { + return + } + sql, _, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) + }) + }) + + Describe("getGenres", func() { + It("lists genres via /Genres", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("handles /MusicGenres the same way", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) +}) diff --git a/server/jellyfin/case_insensitive_routes.go b/server/jellyfin/case_insensitive_routes.go new file mode 100644 index 000000000..cc95b526f --- /dev/null +++ b/server/jellyfin/case_insensitive_routes.go @@ -0,0 +1,68 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +// caseInsensitivePaths normalizes each request path's literal segments to the case they were +// registered with before delegating to r, since Jellyfin clients route case-insensitively but +// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id +// segments pass through untouched. +func caseInsensitivePaths(r chi.Router) http.Handler { + canon := canonicalRouteSegments(r) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + normalizeRequestPath(req, canon) + r.ServeHTTP(w, req) + }) +} + +// canonicalRouteSegments walks every registered route and records, for each literal (non-param) +// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. +// "audio" -> "Audio"). +func canonicalRouteSegments(router chi.Router) map[string]string { + canon := map[string]string{} + _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + for seg := range strings.SplitSeq(route, "/") { + if seg == "" || strings.Contains(seg, "{") { + continue + } + canon[strings.ToLower(seg)] = seg + } + return nil + }) + return canon +} + +// normalizeRequestPath rewrites literal path segments to the case routes were registered with. +// It must run before chi's matching. When the router is mounted under a parent, chi has already +// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so +// that's what must be normalized here. +func normalizeRequestPath(r *http.Request, canon map[string]string) { + if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) + return + } + r.URL.Path = normalizeCase(r.URL.Path, canon) +} + +// normalizeCase rewrites each "/"-separated literal segment of path to the case it was +// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. +// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"), +// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case +// the extension so chi's case-sensitive match still hits. +func normalizeCase(path string, canon map[string]string) string { + segs := strings.Split(path, "/") + for i, seg := range segs { + if canonical, ok := canon[strings.ToLower(seg)]; ok { + segs[i] = canonical + } else if prefix, suffix, found := strings.Cut(seg, "."); found { + if canonical, ok := canon[strings.ToLower(prefix)]; ok { + segs[i] = canonical + "." + strings.ToLower(suffix) + } + } + } + return strings.Join(segs, "/") +} diff --git a/server/jellyfin/case_insensitive_routes_test.go b/server/jellyfin/case_insensitive_routes_test.go new file mode 100644 index 000000000..4c9140f83 --- /dev/null +++ b/server/jellyfin/case_insensitive_routes_test.go @@ -0,0 +1,90 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID string + + var gotContainer string + + BeforeEach(func() { + gotID = "" + gotContainer = "" + r := chi.NewRouter() + r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + // A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream" + // literal prefix is registered separately via the bare /Foo/{id}/stream route below. + r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + It("normalizes the literal prefix of a mixed literal.param segment", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("matches a lower-cased request path against mixed-case registered literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("preserves the id segment's original casing", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(gotID).To(Equal("ID")) + }) + + It("leaves a real mixed-case id untouched while still matching literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) + }) +}) + +var _ = Describe("normalizeCase", func() { + It("rewrites known literal segments to their canonical case", func() { + canon := map[string]string{ + "audio": "Audio", + "stream": "stream", + } + got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) + }) + + It("normalizes the literal prefix of a mixed literal.extension segment", func() { + canon := map[string]string{"audio": "Audio", "stream": "stream"} + got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3")) + }) + + It("leaves a dotted segment untouched when its prefix isn't a known literal", func() { + canon := map[string]string{"audio": "Audio"} + got := normalizeCase("/audio/some.file.id", canon) + Expect(got).To(Equal("/Audio/some.file.id")) + }) +}) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go new file mode 100644 index 000000000..aaf6ff2af --- /dev/null +++ b/server/jellyfin/dto/blurhash.go @@ -0,0 +1,36 @@ +package dto + +import "hash/fnv" + +// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. +const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// base83 encodes value as a fixed-width, big-endian base83 string of the given length. +func base83(value, length int) string { + b := make([]byte, length) + for i := 1; i <= length; i++ { + digit := (value / pow83(length-i)) % 83 + b[i-1] = base83Alphabet[digit] + } + return string(b) +} + +func pow83(n int) int { + result := 1 + for range n { + result *= 83 + } + return result +} + +// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a +// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so +// a solid color unique to the tag satisfies both without decoding cover art. +func blurHash(seed string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + sum := h.Sum(nil) + r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) + dc := (r << 16) | (g << 8) | b + return "00" + base83(dc, 4) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go new file mode 100644 index 000000000..a6e36131d --- /dev/null +++ b/server/jellyfin/dto/blurhash_test.go @@ -0,0 +1,27 @@ +package dto + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("blurHash", func() { + It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { + h := blurHash("x") + Expect(h).To(HaveLen(6)) + Expect(h).To(HavePrefix("00")) + for _, c := range h { + Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("is deterministic for the same seed", func() { + Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) + }) + + It("differs for different seeds", func() { + Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) + }) +}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go new file mode 100644 index 000000000..7720640c5 --- /dev/null +++ b/server/jellyfin/dto/dto.go @@ -0,0 +1,258 @@ +package dto + +// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public). +type PublicSystemInfo struct { + LocalAddress string `json:"LocalAddress,omitempty"` + ServerName string `json:"ServerName"` + Version string `json:"Version"` + ProductName string `json:"ProductName"` + OperatingSystem string `json:"OperatingSystem,omitempty"` + Id string `json:"Id"` + StartupWizardCompleted bool `json:"StartupWizardCompleted"` +} + +// SystemInfo is the authenticated variant (GET /System/Info). +type SystemInfo struct { + PublicSystemInfo + HasPendingRestart bool `json:"HasPendingRestart"` + IsShuttingDown bool `json:"IsShuttingDown"` + SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"` + CachePath string `json:"CachePath,omitempty"` +} + +type NameGuidPair struct { + Name string `json:"Name"` + Id string `json:"Id"` +} + +type UserItemDataDto struct { + Rating *float64 `json:"Rating,omitempty"` + PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` + PlayCount int `json:"PlayCount"` + IsFavorite bool `json:"IsFavorite"` + Played bool `json:"Played"` + Key string `json:"Key"` + ItemId string `json:"ItemId,omitempty"` + LastPlayedDate *string `json:"LastPlayedDate,omitempty"` +} + +type BaseItemDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + Id string `json:"Id"` + // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), + // distinct from Id so a song appearing more than once can be removed by occurrence + // (DELETE .../Items?EntryIds=...) rather than by song id. + PlaylistItemId string `json:"PlaylistItemId,omitempty"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + LocationType string `json:"LocationType,omitempty"` + HasLyrics bool `json:"HasLyrics,omitempty"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + ParentId string `json:"ParentId,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + IndexNumber *int `json:"IndexNumber,omitempty"` + ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` + ProductionYear *int `json:"ProductionYear,omitempty"` + // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. + PremiereDate *string `json:"PremiereDate,omitempty"` + // DateCreated is the ISO 8601 date the item was added to the library; clients show it as + // "Date Added" and sort "Recently Added" by it. + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` + // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a + // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't + // calculating blurhashes. + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` +} + +// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which +// Finamp probes before allowing playlist edits. +type PlaylistUserPermissions struct { + UserId string `json:"UserId"` + CanEdit bool `json:"CanEdit"` +} + +// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not +// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle. +type PlaylistInfo struct { + OpenAccess bool `json:"OpenAccess"` + Shares []PlaylistUserPermissions `json:"Shares"` + ItemIds []string `json:"ItemIds"` +} + +type QueryResult struct { + Items []BaseItemDto `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` + StartIndex int `json:"StartIndex"` +} + +type UserDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + ServerName string `json:"ServerName,omitempty"` + Id string `json:"Id"` + HasPassword bool `json:"HasPassword"` + HasConfiguredPassword bool `json:"HasConfiguredPassword"` + HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"` + PrimaryImageTag string `json:"PrimaryImageTag,omitempty"` + Policy *UserPolicy `json:"Policy,omitempty"` + Configuration *UserConfiguration `json:"Configuration,omitempty"` +} + +// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if +// it's absent, so every field must be present even though Navidrome lacks most of these concepts. +type UserPolicy struct { + IsAdministrator bool `json:"IsAdministrator"` + IsHidden bool `json:"IsHidden"` + EnableCollectionManagement bool `json:"EnableCollectionManagement"` + EnableSubtitleManagement bool `json:"EnableSubtitleManagement"` + EnableLyricManagement bool `json:"EnableLyricManagement"` + IsDisabled bool `json:"IsDisabled"` + BlockedTags []string `json:"BlockedTags"` + AllowedTags []string `json:"AllowedTags"` + EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"` + AccessSchedules []string `json:"AccessSchedules"` + BlockUnratedItems []string `json:"BlockUnratedItems"` + EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"` + EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"` + EnableRemoteAccess bool `json:"EnableRemoteAccess"` + EnableLiveTvManagement bool `json:"EnableLiveTvManagement"` + EnableLiveTvAccess bool `json:"EnableLiveTvAccess"` + EnableMediaPlayback bool `json:"EnableMediaPlayback"` + EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"` + EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"` + EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"` + ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"` + EnableContentDeletion bool `json:"EnableContentDeletion"` + EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"` + EnableContentDownloading bool `json:"EnableContentDownloading"` + EnableSyncTranscoding bool `json:"EnableSyncTranscoding"` + EnableMediaConversion bool `json:"EnableMediaConversion"` + EnabledDevices []string `json:"EnabledDevices"` + EnableAllDevices bool `json:"EnableAllDevices"` + EnabledChannels []string `json:"EnabledChannels"` + EnableAllChannels bool `json:"EnableAllChannels"` + EnabledFolders []string `json:"EnabledFolders"` + EnableAllFolders bool `json:"EnableAllFolders"` + InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"` + LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"` + MaxActiveSessions int `json:"MaxActiveSessions"` + EnablePublicSharing bool `json:"EnablePublicSharing"` + BlockedMediaFolders []string `json:"BlockedMediaFolders"` + BlockedChannels []string `json:"BlockedChannels"` + RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"` + AuthenticationProviderId string `json:"AuthenticationProviderId"` + PasswordResetProviderId string `json:"PasswordResetProviderId"` + SyncPlayAccess string `json:"SyncPlayAccess"` +} + +// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it +// always present, even though most settings don't apply to Navidrome's audio-only library. +type UserConfiguration struct { + PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"` + SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"` + DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"` + GroupedFolders []string `json:"GroupedFolders"` + SubtitleMode string `json:"SubtitleMode"` + DisplayCollectionsView bool `json:"DisplayCollectionsView"` + EnableLocalPassword bool `json:"EnableLocalPassword"` + OrderedViews []string `json:"OrderedViews"` + LatestItemsExcludes []string `json:"LatestItemsExcludes"` + MyMediaExcludes []string `json:"MyMediaExcludes"` + HidePlayedInLatest bool `json:"HidePlayedInLatest"` + RememberAudioSelections bool `json:"RememberAudioSelections"` + RememberSubtitleSelections bool `json:"RememberSubtitleSelections"` + EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` + CastReceiverId string `json:"CastReceiverId"` +} + +type SessionInfo struct { + Id string `json:"Id"` + UserId string `json:"UserId"` +} + +type AuthenticationResult struct { + User *UserDto `json:"User"` + SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + AccessToken string `json:"AccessToken"` + ServerId string `json:"ServerId"` +} + +// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so +// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type +// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws. +type MediaStream struct { + Codec string `json:"Codec,omitempty"` + Type string `json:"Type"` + Index int `json:"Index"` + BitRate int `json:"BitRate,omitempty"` + Channels int `json:"Channels,omitempty"` + SampleRate int `json:"SampleRate,omitempty"` + ChannelLayout string `json:"ChannelLayout,omitempty"` + IsInterlaced bool `json:"IsInterlaced"` + IsDefault bool `json:"IsDefault"` + IsForced bool `json:"IsForced"` + IsExternal bool `json:"IsExternal"` + IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` + SupportsExternalStream bool `json:"SupportsExternalStream"` +} + +// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as +// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing +// of the whole item list; emit them always (no omitempty on bools). +type MediaSourceInfo struct { + Id string `json:"Id"` + Path string `json:"Path,omitempty"` + Protocol string `json:"Protocol"` + Container string `json:"Container,omitempty"` + TranscodingUrl string `json:"TranscodingUrl,omitempty"` + TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"` + Size int64 `json:"Size,omitempty"` + Name string `json:"Name,omitempty"` + IsRemote bool `json:"IsRemote"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + Bitrate int `json:"Bitrate,omitempty"` + SupportsTranscoding bool `json:"SupportsTranscoding"` + SupportsDirectStream bool `json:"SupportsDirectStream"` + SupportsDirectPlay bool `json:"SupportsDirectPlay"` + Type string `json:"Type"` + ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"` + IgnoreDts bool `json:"IgnoreDts"` + IgnoreIndex bool `json:"IgnoreIndex"` + GenPtsInput bool `json:"GenPtsInput"` + IsInfiniteStream bool `json:"IsInfiniteStream"` + UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"` + RequiresOpening bool `json:"RequiresOpening"` + RequiresClosing bool `json:"RequiresClosing"` + RequiresLooping bool `json:"RequiresLooping"` + SupportsProbing bool `json:"SupportsProbing"` + HasSegments bool `json:"HasSegments"` + MediaStreams []MediaStream `json:"MediaStreams"` + MediaAttachments []any `json:"MediaAttachments"` + Formats []string `json:"Formats"` +} + +type PlaybackInfoResponse struct { + MediaSources []MediaSourceInfo `json:"MediaSources"` + PlaySessionId string `json:"PlaySessionId"` +} diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go new file mode 100644 index 000000000..1d8ec47e4 --- /dev/null +++ b/server/jellyfin/dto/dto_suite_test.go @@ -0,0 +1,17 @@ +package dto + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDto(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin DTO Suite") +} diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go new file mode 100644 index 000000000..faa31ec0d --- /dev/null +++ b/server/jellyfin/dto/fields.go @@ -0,0 +1,24 @@ +package dto + +import "strings" + +// Fields is the parsed set of a Jellyfin request's Fields param (lowercased). It controls which +// conditional fields a mapped item carries — chiefly MediaSources — matching real Jellyfin, which +// omits those unless the client asks for them. +type Fields map[string]struct{} + +// ParseFields splits the comma-separated Fields param into a lowercased set. +func ParseFields(csv string) Fields { + f := Fields{} + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } + } + return f +} + +func (f Fields) Has(name string) bool { + _, ok := f[strings.ToLower(name)] + return ok +} diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go new file mode 100644 index 000000000..3490ba260 --- /dev/null +++ b/server/jellyfin/dto/ids.go @@ -0,0 +1,23 @@ +package dto + +import "encoding/hex" + +// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g. +// Finamp's queue packing) and crash on Navidrome's base62 nanoids if emitted as-is. +func EncodeID(id string) string { + if id == "" { + return "" + } + return hex.EncodeToString([]byte(id)) +} + +// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id. +func DecodeID(id string) string { + if id == "" { + return "" + } + if b, err := hex.DecodeString(id); err == nil && len(b) > 0 { + return string(b) + } + return id +} diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go new file mode 100644 index 000000000..26a957604 --- /dev/null +++ b/server/jellyfin/dto/ids_test.go @@ -0,0 +1,35 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("id codec", func() { + It("round-trips a base62 nanoid through Encode/Decode", func() { + id := "5QFKvMsJrd57QE2Le2dKKo" + Expect(DecodeID(EncodeID(id))).To(Equal(id)) + }) + + It("passes a raw (non-hex) id through DecodeID unchanged", func() { + Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo")) + }) + + It("produces valid lowercase hex", func() { + encoded := EncodeID("song-1") + Expect(encoded).To(MatchRegexp("^[0-9a-f]+$")) + Expect(encoded).To(HaveLen(len("song-1") * 2)) + }) + + It("round-trips the empty string", func() { + Expect(EncodeID("")).To(Equal("")) + Expect(DecodeID("")).To(Equal("")) + }) + + It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() { + // "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex + // string apart from one we encoded. Callers must always encode ids on emission and + // decode them on receipt so this ambiguity never surfaces in practice. + Expect(DecodeID(EncodeID("a1"))).To(Equal("a1")) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go new file mode 100644 index 000000000..bd817e432 --- /dev/null +++ b/server/jellyfin/dto/mappers.go @@ -0,0 +1,256 @@ +package dto + +import ( + "cmp" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" +) + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } + +// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 +// PremiereDate clients parse, falling back to year; nil when neither exists. +func premiereDate(date string, year int) *string { + d := date + switch len(d) { + case 4: + d += "-01-01" + case 7: + d += "-01" + case 10: // already yyyy-mm-dd + default: + if year <= 0 { + return nil + } + d = fmt.Sprintf("%04d-01-01", year) + } + s := d + "T00:00:00Z" + return &s +} + +// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the +// field is omitted rather than sent as a meaningless epoch. +func jellyfinDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. +func channelLayout(n int) string { + switch n { + case 1: + return "mono" + case 2: + return "stereo" + case 6: + return "5.1" + case 8: + return "7.1" + default: + return "" + } +} + +// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file. +// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo +// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). +func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + return MediaSourceInfo{ + Id: EncodeID(mf.ID), + Protocol: "Http", + Container: mf.Suffix, + Size: mf.Size, + Name: mf.Title, + Type: "Default", + RunTimeTicks: TicksFromSeconds(mf.Duration), + Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. + SupportsDirectPlay: true, + SupportsDirectStream: true, + SupportsTranscoding: true, + IsRemote: false, + SupportsProbing: true, + MediaStreams: []MediaStream{{ + Type: "Audio", + Index: 0, + Codec: mf.Codec, + BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. + Channels: mf.Channels, + SampleRate: mf.SampleRate, + ChannelLayout: channelLayout(mf.Channels), + }}, + MediaAttachments: []any{}, + Formats: []string{}, + } +} + +func UserData(a model.Annotations, itemID string) *UserItemDataDto { + // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. + encodedID := EncodeID(itemID) + d := &UserItemDataDto{ + PlayCount: int(a.PlayCount), + IsFavorite: a.Starred, + Played: a.PlayCount > 0, + Key: encodedID, + ItemId: encodedID, + } + if a.Rating > 0 { + r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 + d.Rating = &r + } + if a.PlayDate != nil { + s := a.PlayDate.UTC().Format(time.RFC3339) + d.LastPlayedDate = &s + } + return d +} + +// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached +// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a +// plain list response); a nil fields set means neither. +func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { + item := BaseItemDto{ + Name: mf.Title, + Id: EncodeID(mf.ID), + Type: "Audio", + MediaType: "Audio", + IsFolder: false, + LocationType: "FileSystem", + HasLyrics: mf.Lyrics != "", + ParentId: EncodeID(mf.AlbumID), + Album: mf.Album, + AlbumId: EncodeID(mf.AlbumID), + AlbumArtist: mf.AlbumArtist, + Artists: []string{mf.Artist}, + RunTimeTicks: TicksFromSeconds(mf.Duration), + DateCreated: jellyfinDate(&mf.CreatedAt), + Container: mf.Suffix, + CanDownload: true, + BackdropImageTags: []string{}, + UserData: UserData(mf.Annotations, mf.ID), + } + if fields.Has("MediaSources") { + item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} + } + if fields.Has("SortName") { + item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) + } + // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown + // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; + // AlbumArtists the album artist. + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } + if mf.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} + } + if mf.Year > 0 { + item.ProductionYear = new(mf.Year) + } + item.PremiereDate = premiereDate(mf.Date, mf.Year) + if mf.TrackNumber > 0 { + item.IndexNumber = new(mf.TrackNumber) + } + if mf.DiscNumber > 0 { + item.ParentIndexNumber = new(mf.DiscNumber) + } + if len(mf.Genres) > 0 { + for _, g := range mf.Genres { + item.Genres = append(item.Genres, g.Name) + } + } else if mf.Genre != "" { + item.Genres = []string{mf.Genre} + } + // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. + if mf.AlbumID != "" { + item.AlbumPrimaryImageTag = mf.AlbumID + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} + } + return item +} + +func AlbumToBaseItem(al model.Album) BaseItemDto { + item := BaseItemDto{ + Name: al.Name, + Id: EncodeID(al.ID), + Type: "MusicAlbum", + IsFolder: true, + ParentId: EncodeID(al.AlbumArtistID), + AlbumArtist: al.AlbumArtist, + Album: al.Name, + ChildCount: new(al.SongCount), + SongCount: new(al.SongCount), + RunTimeTicks: TicksFromSeconds(al.Duration), + DateCreated: jellyfinDate(&al.CreatedAt), + ImageTags: map[string]string{"Primary": al.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), + } + if al.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} + item.ArtistItems = item.AlbumArtists + } + if al.MaxYear > 0 { + item.ProductionYear = new(al.MaxYear) + } + item.PremiereDate = premiereDate(al.Date, al.MaxYear) + if len(al.Genres) > 0 { + for _, g := range al.Genres { + item.Genres = append(item.Genres, g.Name) + } + } + return item +} + +func ArtistToBaseItem(ar model.Artist) BaseItemDto { + return BaseItemDto{ + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageTags: map[string]string{"Primary": ar.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), + } +} + +func GenreToBaseItem(g model.Genre) BaseItemDto { + return BaseItemDto{ + Name: g.Name, + Id: EncodeID(g.ID), + Type: "MusicGenre", + IsFolder: true, + BackdropImageTags: []string{}, + } +} + +// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. +func PlaylistToBaseItem(p model.Playlist) BaseItemDto { + // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. + // UpdatedAt versions it (Put bumps it on upload); over-invalidation only costs a refetch. + tag := fmt.Sprintf("%s-%x", p.ID, p.UpdatedAt.UnixMilli()) + return BaseItemDto{ + Name: p.Name, + Id: EncodeID(p.ID), + Type: "Playlist", + // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin + // stores them under its data folder), so without this its Playlists tab hides them all. + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageTags: map[string]string{"Primary": tag}, + ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}}, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), + } +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go new file mode 100644 index 000000000..bd351bee9 --- /dev/null +++ b/server/jellyfin/dto/mappers_test.go @@ -0,0 +1,280 @@ +package dto + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("mappers", func() { + It("maps a song to an Audio BaseItemDto", func() { + mf := model.MediaFile{ + ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, + Year: 1999, Duration: 60, Size: 2_500_000, + } + mf.PlayCount = 2 + mf.Starred = true + item := SongToBaseItem(mf, nil) + Expect(item.Type).To(Equal("Audio")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(item.IsFolder).To(BeFalse()) + Expect(item.LocationType).To(Equal("FileSystem")) + Expect(item.Id).To(Equal(EncodeID("song-1"))) + Expect(item.AlbumId).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("alb-1"))) + Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) + Expect(*item.IndexNumber).To(Equal(3)) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(item.UserData.Played).To(BeTrue()) + Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) + Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) + Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + }) + + Describe("Fields gating (matches real Jellyfin)", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, + SortTitle: "sort song", Lyrics: `[{"line":"la"}]`} + + It("omits MediaSources and SortName when Fields does not ask for them", func() { + item := SongToBaseItem(mf, nil) + Expect(item.MediaSources).To(BeNil()) + Expect(item.SortName).To(BeEmpty()) + }) + + It("includes MediaSources only when Fields=MediaSources", func() { + item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName")) + Expect(item.MediaSources).To(HaveLen(1)) + Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) + }) + + It("includes SortName (from the sort title) only when Fields=SortName", func() { + Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song")) + }) + + It("sets HasLyrics from the media file's lyrics", func() { + Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) + Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + }) + }) + + It("omits ImageBlurHashes when a song has no album", func() { + mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60} + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("sets DateCreated from the media file's CreatedAt", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) + }) + + It("omits DateCreated when CreatedAt is the zero time", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty()) + }) + + It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Song", + Artist: "The Band", ArtistID: "ar-1", + AlbumArtist: "Various", AlbumArtistID: "ar-2", + } + item := SongToBaseItem(mf, nil) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}})) + }) + + It("omits ArtistItems when the track has no artist id", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) + }) + + It("builds a MediaSourceInfo from a media file", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + Expect(src.Id).To(Equal(EncodeID("s1"))) + Expect(src.Size).To(Equal(int64(5242880))) + Expect(src.Container).To(Equal("mp3")) + Expect(src.Bitrate).To(Equal(320_000)) + Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) + Expect(src.Protocol).To(Equal("Http")) + Expect(src.SupportsDirectPlay).To(BeTrue()) + }) + + It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { + mf := model.MediaFile{ + ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, + Channels: 2, SampleRate: 44100, Codec: "mp3", + } + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(1)) + stream := src.MediaStreams[0] + Expect(stream.Type).To(Equal("Audio")) + Expect(stream.Channels).To(Equal(2)) + Expect(stream.SampleRate).To(Equal(44100)) + Expect(stream.BitRate).To(Equal(320_000)) + Expect(stream.Codec).To(Equal("mp3")) + Expect(stream.ChannelLayout).To(Equal("stereo")) + }) + + It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + b, err := json.Marshal(src) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"SupportsProbing":true`)) + Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`)) + Expect(j).To(ContainSubstring(`"RequiresOpening":false`)) + Expect(j).To(ContainSubstring(`"MediaAttachments":[]`)) + Expect(j).To(ContainSubstring(`"Formats":[]`)) + }) + + It("serializes MediaStream's required non-nullable bools, never omitted", func() { + stream := MediaStream{Type: "Audio", Index: 0} + b, err := json.Marshal(stream) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Type":"Audio"`)) + Expect(j).To(ContainSubstring(`"IsDefault":false`)) + Expect(j).To(ContainSubstring(`"IsInterlaced":false`)) + Expect(j).To(ContainSubstring(`"IsForced":false`)) + Expect(j).To(ContainSubstring(`"IsExternal":false`)) + Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`)) + Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) + }) + + It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { + mf := model.MediaFile{ + ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, + Duration: 60, + } + item := SongToBaseItem(mf, nil) + Expect(item.IndexNumber).To(BeNil()) + Expect(item.ParentIndexNumber).To(BeNil()) + }) + + It("maps PlayDate to UserData.LastPlayedDate", func() { + playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) + mf := model.MediaFile{ + ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", Duration: 60, + } + mf.PlayDate = &playDate + item := SongToBaseItem(mf, nil) + Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) + Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339))) + }) + + It("maps an album to a MusicAlbum folder item", func() { + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + item := AlbumToBaseItem(al) + Expect(item.Type).To(Equal("MusicAlbum")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("art-1"))) + Expect(item.AlbumArtists).To(HaveLen(1)) + Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1"))) + Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) + Expect(*item.ProductionYear).To(Equal(1999)) + Expect(*item.ChildCount).To(Equal(10)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) + Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + }) + + It("maps an artist to a MusicArtist folder item", func() { + ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} + item := ArtistToBaseItem(ar) + Expect(item.Type).To(Equal("MusicArtist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("art-1"))) + Expect(*item.AlbumCount).To(Equal(2)) + }) + + It("maps a genre to a MusicGenre folder item", func() { + g := model.Genre{ID: "genre-1", Name: "Rock"} + item := GenreToBaseItem(g) + Expect(item.Type).To(Equal("MusicGenre")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("genre-1"))) + Expect(item.Name).To(Equal("Rock")) + }) + + Describe("premiereDate", func() { + // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. + It("serializes a full date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007} + item := SongToBaseItem(mf, nil) + Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("pads a year-only date so clients can parse it", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) + }) + + It("pads a year-month date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("falls back to the year when no date tag exists", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) + }) + + It("is omitted when the track has no date at all", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil()) + }) + + It("is set on albums from their date, falling back to MaxYear", func() { + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + }) + }) + + It("maps a playlist to a Playlist BaseItemDto", func() { + p := model.Playlist{ + ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, + Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, + } + item := PlaylistToBaseItem(p) + Expect(item.Type).To(Equal("Playlist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("pl-1"))) + Expect(item.Name).To(Equal("Chill")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(*item.ChildCount).To(Equal(7)) + Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000))) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(*item.UserData.Rating).To(Equal(8.0)) + tag := item.ImageTags["Primary"] + Expect(tag).ToNot(BeEmpty()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag)) + Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6)) + }) + + It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() { + p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + before := PlaylistToBaseItem(p) + p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) + after := PlaylistToBaseItem(p) + + // Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover. + Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"])) + Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"])) + }) + + It("keeps the playlist image tag stable when nothing changed", func() { + p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) + }) +}) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go new file mode 100644 index 000000000..b1ad850e3 --- /dev/null +++ b/server/jellyfin/e2e/annotations_test.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + BeforeEach(func() { setupTestDB() }) + + itemUserData := func(id string) *dto.UserItemDataDto { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + return item.UserData + } + + Describe("favorites", func() { + It("marks and unmarks an album as favorite", func() { + id := albumID("Abbey Road") + + var marked dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("marks a song as favorite", func() { + id := songID("So What") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + }) + + It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() { + id := songID("Come Together") + + var marked dto.UserItemDataDto + parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("filters items to favorites only", func() { + post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Abbey Road")) + }) + + It("marks and lists a playlist as favorite", func() { + id := createPlaylist("Favorite Mix", nil) + Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK)) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Favorite Mix")) + }) + + It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() { + // Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not + // Filters=IsFavorite), combined with ArtistIds. + post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "") + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true")) + Expect(names(q.Items)).To(ConsistOf("Help!")) + }) + + It("returns 404 when favoriting an unknown item", func() { + Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /UserItems/{id}/UserData", func() { + It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() { + id := songID("So What") + post("/Users/admin-1/FavoriteItems/"+enc(id), "") + + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data) + Expect(data.IsFavorite).To(BeTrue()) + Expect(data.ItemId).To(Equal(enc(id))) + }) + + It("returns a valid (unfavorited) UserData for an item with no annotations", func() { + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data) + Expect(data.IsFavorite).To(BeFalse()) + Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue")))) + }) + + It("returns 404 for an unknown item", func() { + Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ratings", func() { + It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { + id := albumID("IV") + + var set dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) + Expect(set.Rating).ToNot(BeNil()) + Expect(*set.Rating).To(Equal(float64(10))) + Expect(*itemUserData(id).Rating).To(Equal(float64(10))) + + // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` + // would leave the stale value. + var cleared dto.UserItemDataDto + parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) + Expect(cleared.Rating).To(BeNil()) + Expect(itemUserData(id).Rating).To(BeNil()) + }) + + It("sets and reads a playlist rating", func() { + id := createPlaylist("Rated Mix", nil) + Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) + Expect(*itemUserData(id).Rating).To(Equal(float64(8))) + }) + + It("clamps an out-of-range rating to the valid domain", func() { + id := albumID("Help!") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) + // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. + Expect(data.Rating).ToNot(BeNil()) + Expect(*data.Rating).To(Equal(float64(10))) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go new file mode 100644 index 000000000..f66833af2 --- /dev/null +++ b/server/jellyfin/e2e/auth_test.go @@ -0,0 +1,120 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Authentication", func() { + BeforeEach(func() { setupTestDB() }) + + authenticate := func(username, pw string) *httptest.ResponseRecorder { + body := `{"Username":"` + username + `","Pw":"` + pw + `"}` + return rawReq("POST", "/Users/AuthenticateByName", body) + } + + Describe("POST /Users/AuthenticateByName", func() { + It("authenticates a valid user and returns a usable token", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User).ToNot(BeNil()) + Expect(res.User.Name).To(Equal("admin")) + Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + Expect(res.ServerId).ToNot(BeEmpty()) + + // The returned token must actually authenticate a protected request. + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + }) + + It("marks a non-admin user's policy as non-administrator", func() { + w := authenticate("regular", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + }) + + It("rejects a wrong password", func() { + Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password", func() { + Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an unknown user", func() { + Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a malformed body", func() { + Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("GET /Users/Public", func() { + publicUsers := func() []dto.UserDto { + w := rawReq("GET", "/Users/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + parseInto(w, &users) + return users + } + + It("returns an empty list when no users are exposed", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users to an unauthenticated caller, without policy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "regular" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("regular")) + Expect(users[0].Id).To(Equal("regular-1")) + Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login + }) + }) + + Describe("current user", func() { + It("returns the caller from GET /Users/Me", func() { + var u dto.UserDto + parseInto(getAs(regularUser, "/Users/Me"), &u) + Expect(u.Name).To(Equal("regular")) + Expect(u.Id).To(Equal("regular-1")) + }) + + It("returns the caller from GET /Users/{userId}", func() { + var u dto.UserDto + parseInto(get("/Users/admin-1"), &u) + Expect(u.Name).To(Equal("admin")) + }) + }) + + Describe("auth enforcement", func() { + It("rejects a protected request with no token", func() { + Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a protected request with a bogus token", func() { + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", "not-a-valid-jwt") + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go new file mode 100644 index 000000000..af639ed29 --- /dev/null +++ b/server/jellyfin/e2e/browsing_test.go @@ -0,0 +1,389 @@ +package e2e + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func names(items []dto.BaseItemDto) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Name + } + return out +} + +var _ = Describe("Browsing", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /UserViews", func() { + It("returns the user's libraries as CollectionFolders", func() { + q := queryResult(get("/UserViews")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Music Library")) + Expect(q.Items[0].Type).To(Equal("CollectionFolder")) + Expect(q.Items[0].CollectionType).To(Equal("music")) + }) + }) + + Describe("GET /Items by type", func() { + It("lists all albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("lists all songs with Audio type and an AlbumId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(7)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Audio")) + Expect(it.MediaType).To(Equal("Audio")) + Expect(it.LocationType).To(Equal("FileSystem")) + Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it + Expect(it.AlbumId).ToNot(BeEmpty()) + } + }) + + // Real Jellyfin omits MediaSources from a plain list response, returning it only when the + // client asks via Fields=MediaSources (Finamp's download dialog does). + It("omits MediaSources unless Fields=MediaSources is requested", func() { + plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + for _, it := range plain.Items { + Expect(it.MediaSources).To(BeEmpty()) + } + withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources")) + for _, it := range withSources.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + + It("lists all album artists", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(4)) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + }) + + It("lists all genres", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(3)) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("returns no playlists when none exist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }) + + It("defaults to albums when IncludeItemTypes is unrecognized", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + }) + + Describe("ParentId browsing", func() { + It("browses an artist's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("browses an album's tracks in track order by default", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) + Expect(q.TotalRecordCount).To(Equal(2)) + // Track order (Something=1, Come Together=2) differs from alphabetical title order, + // proving the sort is by track number, not name. + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + Expect(*q.Items[0].IndexNumber).To(Equal(1)) + Expect(*q.Items[1].IndexNumber).To(Equal(2)) + }) + + // "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order. + It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) + + "&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending")) + got := names(q.Items) + Expect(got).To(HaveLen(3)) + Expect(got[:2]).To(ConsistOf("Come Together", "Something")) + Expect(got[2]).To(Equal("Help!")) + }) + + It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + }) + + // Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds + // for the actual artist filter, not ParentId=<artistId>. + Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { + lib1 := enc("1") + + It("filters albums by AlbumArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by ArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("filters albums by a single-album artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin")))) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("filters songs by a single-track artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) + Expect(names(q.Items)).To(ConsistOf("So What")) + }) + + // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears + // on, which must exclude their own discography (albums where they are the album artist). + It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) + Expect(names(q.Items)).To(ConsistOf("Singles")) + }) + + It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) + Expect(names(q.Items)).ToNot(ContainElement("Help!")) + }) + }) + + // Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>. + Describe("genre filtering (GenreIds)", func() { + lib1 := enc("1") + + It("filters albums by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("filters songs by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(4)) + }) + + It("matches any of multiple comma-separated GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("returns nothing for an unknown genre id", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + + It("filters album artists by GenreIds on /Artists/AlbumArtists", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("matches album artists of any of multiple GenreIds", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist")) + }) + + It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin")) + }) + + It("returns no artists for an unknown genre id", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase + // (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin + // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an + // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. + Describe("camelCase query params (Jellify / JS SDK)", func() { + lib1 := enc("1") + + It("filters albums by camelCase albumArtistIds", func() { + q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by camelCase artistIds", func() { + q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + + It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + }) + + Describe("search, batch and pagination", func() { + It("searches albums by term", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("batch-fetches specific items by Ids", func() { + ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) + q := queryResult(get("/Items?ids=" + ids)) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) + }) + + // Finamp restores its saved queue with ids truncated to 16 bytes (see README). + Describe("Finamp-truncated ids (saved queue restore)", func() { + It("resolves a truncated id by unique prefix and echoes the requested id", func() { + full := songID("Come Together") + truncated := full[:16] + q := queryResult(get("/Items?ids=" + enc(truncated))) + Expect(names(q.Items)).To(ConsistOf("Come Together")) + // Finamp matches restored items by its stored ids, so the requested id must be echoed. + Expect(q.Items[0].Id).To(Equal(enc(truncated))) + }) + + It("batch-resolves a mixed list of truncated and full ids, keeping order", func() { + ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16]) + q := queryResult(get("/Items?ids=" + ids)) + Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"})) + }) + + It("streams a track by its truncated id", func() { + full := songID("So What") + w := get("/Audio/" + enc(full[:16]) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(full)) + }) + + It("still 404s for a truncated id matching nothing", func() { + Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + It("applies Limit while reporting the full TotalRecordCount", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) + Expect(q.Items).To(HaveLen(2)) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("pages distinct items via StartIndex", func() { + p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) + Expect(p1.Items).To(HaveLen(2)) + Expect(p2.Items).To(HaveLen(2)) + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + }) + + It("merges multiple types into one paginated result", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs + }) + }) + + Describe("GET /Items/{id}", func() { + It("resolves an album", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) + Expect(item.Name).To(Equal("Kind of Blue")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("resolves a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.Type).To(Equal("Audio")) + }) + + It("includes a parseable DateCreated (Date Added) on a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.DateCreated).ToNot(BeEmpty()) + _, err := time.Parse(time.RFC3339, item.DateCreated) + Expect(err).ToNot(HaveOccurred()) + }) + + It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.ArtistItems).ToNot(BeEmpty()) + Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis")) + Expect(item.ArtistItems[0].Id).ToNot(BeEmpty()) + Expect(item.AlbumArtists).ToNot(BeEmpty()) + Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) + }) + + It("resolves an artist", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) + Expect(item.Type).To(Equal("MusicArtist")) + }) + + It("returns 404 for an unknown id", func() { + Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Users/{userId}/Items/Latest", func() { + It("returns recent albums as a bare array, respecting Limit", func() { + var items []dto.BaseItemDto + parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) + Expect(items).To(HaveLen(3)) + for _, it := range items { + Expect(it.Type).To(Equal("MusicAlbum")) + } + }) + }) + + Describe("GET /Artists and /Genres", func() { + It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { + names := names(queryResult(get("/Artists/AlbumArtists")).Items) + Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + Expect(names).ToNot(ContainElement("Featured Guest")) + }) + + It("lists performing artists on /Artists (includes a track's guest artist)", func() { + names := names(queryResult(get("/Artists")).Items) + Expect(names).To(ContainElement("Featured Guest")) + Expect(names).To(ContainElement("Solo Artist")) + }) + + It("returns different lists for album artists and performing artists", func() { + aa := names(queryResult(get("/Artists/AlbumArtists")).Items) + ar := names(queryResult(get("/Artists")).Items) + Expect(aa).ToNot(Equal(ar)) + }) + + It("lists genres", func() { + q := queryResult(get("/Genres")) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("pages genres with StartIndex/Limit and still reports the full total", func() { + q := queryResult(get("/Genres?StartIndex=1&Limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go new file mode 100644 index 000000000..02c5183f8 --- /dev/null +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -0,0 +1,365 @@ +// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API. +// +// These tests exercise the full HTTP request/response cycle through the Jellyfin API router, +// using a real SQLite database and real repository implementations while stubbing out external +// services (artwork, streaming, transcoding) with spy/noop implementations. +// +// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite +// database, seeds two users (admin + regular) and one library backed by a fake in-memory +// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores +// that snapshot and builds a fresh jellyfin.Router. +// +// # Seeded library (see buildTestFS) +// +// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) +// Rock/The Beatles/Help!/ 01 Help! (1965) +// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) +// Jazz/Miles Davis/Kind of Blue/01 So What (1959) +// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest") +// +// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists), +// 3 genres (Rock=4, Jazz=1, Pop=2). +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API E2E Suite") +} + +// Easy aliases for the storagetest package +type _t = map[string]any + +var ( + template = storagetest.Template + track = storagetest.Track +) + +// Shared test state +var ( + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + goldenDB *harness.DB + dataFolder string + + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + } + + regularUser = model.User{ + ID: "regular-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + } +) + +// buildTestFS creates the seeded test filesystem (see package doc for totals). +func buildTestFS() storagetest.FakeFS { + abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) + ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) + + return harness.CreateFS(fstest.MapFS{ + // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, + // Come Together=2) so tests can tell track-order sorting apart from title sorting. + "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), + "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), + "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), + // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a + // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. + "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), + }) +} + +// --- Request helpers --- + +// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. +func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +// rawReq performs a request with no authentication (for public routes). +func rawReq(method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") } +func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") } +func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) } +func postAs(u model.User, path, body string) *httptest.ResponseRecorder { + return jReq(u, "POST", path, body) +} +func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } +func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } + +// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). +func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", path, bytes.NewReader(body)) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + r.Header.Set("Content-Type", contentType) + router.ServeHTTP(w, r) + return w +} + +// parseInto asserts a 200 and unmarshals the JSON body into target. +func parseInto(w *httptest.ResponseRecorder, target any) { + Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) + Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed()) +} + +// queryResult asserts a 200 and returns the parsed QueryResult. +func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { + var q dto.QueryResult + parseInto(w, &q) + return q +} + +// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a +// client would send) and returns its decoded Navidrome id. +func createPlaylist(name string, encodedIds []string) string { + return createPlaylistAs(adminUser, name, encodedIds...) +} + +// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. +func createPlaylistAs(user model.User, name string, encodedIds ...string) string { + if encodedIds == nil { + encodedIds = []string{} + } + body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) + Expect(err).ToNot(HaveOccurred()) + var res map[string]string + parseInto(postAs(user, "/Playlists", string(body)), &res) + Expect(res["Id"]).ToNot(BeEmpty()) + return dto.DecodeID(res["Id"]) +} + +// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- + +func enc(id string) string { return dto.EncodeID(id) } + +// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than +// guessing repository filter column names. + +func albumID(name string) string { + albums, err := ds.Album(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range albums { + if a.Name == name { + return a.ID + } + } + Fail("album not found: " + name) + return "" +} + +func songID(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return mf.ID + } + } + Fail("song not found: " + title) + return "" +} + +func artistID(name string) string { + artists, err := ds.Artist(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range artists { + if a.Name == name { + return a.ID + } + } + Fail("artist not found: " + name) + return "" +} + +func genreID(name string) string { + genres, err := ds.Genre(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, g := range genres { + if g.Name == name { + return g.ID + } + } + Fail("genre not found: " + name) + return "" +} + +// --- Suite lifecycle --- + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + dataFolder = filepath.Join(GinkgoT().TempDir(), "data") + Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed()) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + + buildTestFS() + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from +// BeforeEach in each test container. +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + + goldenDB.Restore() + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + streamerSpy = &harness.SpyStreamer{} + artworkSpy = &spyArtwork{} + providerFake = &fakeExternalProvider{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + router = jellyfin.New( + ds, + artworkSpy, + streamerSpy, + decider, + core.NewPlayers(ds), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + providerFake, + ) +} + +// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return +// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses +// are overridden — the embedded interface panics for anything else, flagging unexpected calls. +type fakeExternalProvider struct { + external.Provider + similarArtists model.Artists + similarSongs model.MediaFiles +} + +func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) { + return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil +} + +func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return f.similarSongs, nil +} + +// --- Spy/noop dependencies (shared ones live in tests/harness) --- + +// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the +// resolved ArtworkID and that resolution runs under an elevated (admin) context. +type spyArtwork struct { + lastID string + lastCtx context.Context + data []byte +} + +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound +} + +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + s.lastID = id + s.lastCtx = c + d := s.data + if d == nil { + d = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil +} + +var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go new file mode 100644 index 000000000..0c53d75f3 --- /dev/null +++ b/server/jellyfin/e2e/images_test.go @@ -0,0 +1,74 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite +// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert +// resolution and elevation without needing real image processing. +var _ = Describe("Item images", func() { + BeforeEach(func() { setupTestDB() }) + + It("resolves an album's Primary image", func() { + id := albumID("Abbey Road") + w := get("/Items/" + enc(id) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves an artist's Primary image", func() { + id := artistID("Miles Davis") + Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves a private playlist's cover for its owner under an elevated context", func() { + // The route carries no user in ctx (public); the owner is identified by the request token, + // and resolution then runs elevated so the visibility filter doesn't eat the cover. + plID := createPlaylist("Private Mix", nil) + w := get("/Items/" + enc(plID) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + + u, ok := request.UserFrom(artworkSpy.lastCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) + + It("serves images without authentication (public route)", func() { + id := albumID("IV") + w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + }) + + Describe("private playlist covers", func() { + It("does not resolve a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("does not resolve a private playlist's cover for another user", func() { + plID := createPlaylist("Secret Mix", nil) + w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("resolves a public playlist's cover for anyone", func() { + plID := createPlaylist("Shared Mix", nil) + Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + }) + }) +}) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go new file mode 100644 index 000000000..015d82d91 --- /dev/null +++ b/server/jellyfin/e2e/multiuser_test.go @@ -0,0 +1,64 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-user access control", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("library scoping", func() { + It("lets a library member browse its content", func() { + q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("hides all content from a user with no library access", func() { + noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"} + Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername("noaccess") + Expect(err).ToNot(HaveOccurred()) + + q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("private playlists", func() { + It("does not expose another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + + // Owner sees it. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + // A different user does not. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + // And can't read its items. + Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) + }) + + It("does not let a non-owner delete another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — + // the API never reveals that someone else's private playlist exists. + Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) + // Still present for the owner. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("does not let a non-owner annotate another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) + Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) + }) + + It("lets each user manage their own playlist", func() { + regularPl := createPlaylistAs(regularUser, "Regular's Mix") + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go new file mode 100644 index 000000000..73b76e3d8 --- /dev/null +++ b/server/jellyfin/e2e/playlists_test.go @@ -0,0 +1,311 @@ +package e2e + +import ( + "bytes" + "image" + jpeglib "image/jpeg" + "net/http" + "os" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + BeforeEach(func() { setupTestDB() }) + + playlistItems := func(plID string) dto.QueryResult { + return queryResult(get("/Playlists/" + enc(plID) + "/Items")) + } + + Describe("create", func() { + It("creates an empty playlist", func() { + plID := createPlaylist("Empty", nil) + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeFalse()) + Expect(info.Shares).To(BeEmpty()) + Expect(info.ItemIds).To(BeEmpty()) + }) + + It("creates a playlist from song ids", func() { + plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("expands an album id into its tracks", func() { + plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + + It("expands an artist id into its tracks", func() { + plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) + }) + }) + + Describe("items", func() { + It("tags each entry with a PlaylistItemId", func() { + plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) + q := playlistItems(plID) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].Type).To(Equal("Audio")) + Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) + }) + }) + + Describe("add and remove", func() { + It("adds a song by id", func() { + plID := createPlaylist("Add", nil) + Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("adds an album (expanding to its tracks)", func() { + plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) + post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) + }) + + // Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a + // comma-joined value; all ids must be added, not just the first. + It("adds multiple songs sent as repeated ids params", func() { + plID := createPlaylist("Multi", nil) + url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + + "&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!")) + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) + }) + + It("removes an entry by its PlaylistItemId", func() { + plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) + entryID := playlistItems(plID).Items[0].PlaylistItemId + Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("removes multiple entries sent as repeated entryIds params", func() { + plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + items := playlistItems(plID).Items + url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId + Expect(del(url).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("users", func() { + It("reports the current user as an editor", func() { + plID := createPlaylist("Perms", nil) + var perms []dto.PlaylistUserPermissions + parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) + Expect(perms).To(HaveLen(1)) + Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].CanEdit).To(BeTrue()) + }) + }) + + Describe("listing", func() { + It("lists a created playlist advertising a Primary image tag", func() { + createPlaylist("Listed", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Listed")) + Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) + }) + + It("sorts playlists by name when SortBy=SortName", func() { + createPlaylist("Charlie", nil) + createPlaylist("Alpha", nil) + createPlaylist("Bravo", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"})) + }) + }) + + // Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists + // playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item + // whose CollectionType is "playlists", its query resolves undefined and React Query retries in a + // backoff loop that stalls the home screen. + Describe("playlists library folder (ManualPlaylistsFolder)", func() { + It("returns a synthetic playlists folder with CollectionType=playlists", func() { + q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].CollectionType).To(Equal("playlists")) + Expect(q.Items[0].Id).To(Equal(enc("playlists"))) + }) + + It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { + createPlaylist("My Mix", nil) + q := queryResult(get("/Items?parentId=" + enc("playlists"))) + Expect(names(q.Items)).To(ContainElement("My Mix")) + Expect(q.Items[0].Type).To(Equal("Playlist")) + // Jellify keeps only playlists whose Path contains "data". + Expect(q.Items[0].Path).To(ContainSubstring("data")) + }) + + It("resolves the synthetic playlists folder by its own advertised id", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc("playlists")), &item) + Expect(item.Type).To(Equal("ManualPlaylistsFolder")) + Expect(item.CollectionType).To(Equal("playlists")) + Expect(item.Id).To(Equal(enc("playlists"))) + }) + }) + + // Real Jellyfin returns a playlist's children for /Items?ParentId=<playlistId> with no + // IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way. + Describe("browsing a playlist via the generic /Items path", func() { + It("lists the playlist's tracks for a typeless ParentId query", func() { + plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID))) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("pages the playlist's tracks", func() { + plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + // Jellify opens a playlist with ParentId=<playlist>&IncludeItemTypes=Audio&Recursive=false. + // The playlist id must resolve to its tracks, not be treated as an album id (which returns none). + It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() { + plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + }) + }) + + Describe("cover art", func() { + // A real (decodable) image: the upload endpoint validates by decoding, like the native one. + var jpeg []byte + BeforeEach(func() { + var buf bytes.Buffer + Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + jpeg = buf.Bytes() + }) + + It("uploads and removes a playlist cover", func() { + plID := createPlaylist("Cover", nil) + + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + pls, err := ds.Playlist(ctx).Get(plID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.UploadedImage).ToNot(BeEmpty()) + _, statErr := os.Stat(pls.UploadedImagePath()) + Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") + + Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) + pls, _ = ds.Playlist(ctx).Get(plID) + Expect(pls.UploadedImage).To(BeEmpty()) + }) + + It("rejects cover upload for a non-playlist item", func() { + Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNotImplemented)) + }) + + // Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the + // tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever. + It("rotates the playlist's image tag and blurhash after a cover upload", func() { + plID := createPlaylist("Cover Tag", nil) + imageTag := func() string { + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items).To(HaveLen(1)) + return q.Items[0].ImageTags["Primary"] + } + before := imageTag() + Expect(before).ToNot(BeEmpty()) + + time.Sleep(2 * time.Millisecond) // UpdatedAt has millisecond resolution in the tag + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + after := imageTag() + Expect(after).ToNot(Equal(before)) + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after)) + }) + }) + + Describe("update", func() { + It("makes a playlist public", func() { + plID := createPlaylist("Make Public", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeTrue()) + // Now visible to other users. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("renames a playlist", func() { + plID := createPlaylist("Old Name", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("New Name")) + }) + + It("replaces the track list when Ids are provided", func() { + plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))}) + // Replace with a single different track. + Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + }) + + It("clears the track list when an explicit empty Ids array is sent", func() { + plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(0)) + }) + + It("leaves the track list intact when Ids is omitted (metadata-only update)", func() { + plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("applies Name and IsPublic sent together with a track replacement", func() { + plID := createPlaylist("Combo", []string{enc(songID("Come Together"))}) + body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}` + Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("Combo Renamed")) + Expect(pls.Public).To(BeTrue()) + }) + + It("forbids a non-owner from updating a public playlist", func() { + plID := createPlaylist("Owned", nil) + post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user + Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("delete", func() { + It("deletes a playlist", func() { + plID := createPlaylist("ToDelete", nil) + Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + }) + + It("returns 404 when deleting a non-playlist item", func() { + Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go new file mode 100644 index 000000000..49faad342 --- /dev/null +++ b/server/jellyfin/e2e/routing_test.go @@ -0,0 +1,31 @@ +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Routing", func() { + BeforeEach(func() { setupTestDB() }) + + It("routes authenticated endpoints case-insensitively", func() { + // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. + lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(lower.TotalRecordCount).To(Equal(5)) + }) + + It("returns a JSON 404 for an unknown route", func() { + w := get("/Nonexistent/Route") + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) + Expect(w.Body.String()).To(ContainSubstring("{}")) + }) + + It("returns 404 for an unsupported method on a known path", func() { + // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. + w := jReq(adminUser, "PUT", "/Items", "") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go new file mode 100644 index 000000000..6c26569fc --- /dev/null +++ b/server/jellyfin/e2e/search_test.go @@ -0,0 +1,76 @@ +package e2e + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the +// tricky case: they have no library_id column, so the repo's Search does its own scope handling. +var _ = Describe("Search", func() { + BeforeEach(func() { setupTestDB() }) + + lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + + Describe("artists", func() { + It("searches all album artists", func() { + q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("searches album artists scoped to a library (ParentId)", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("returns an empty result for a non-matching term", func() { + q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + Describe("albums and songs", func() { + It("searches albums scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("searches songs scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) + Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) + }) + }) + + Describe("pagination totals", func() { + It("reports the search match count, not the unfiltered library count", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey&Limit=50")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(1)) // not the 5-album library total + }) + + It("reaches the true total when paging song search results", func() { + // "So" prefix-matches several songs (titles and Solo Artist's tracks); learn the true + // count from an unpaged query, then walk one-item pages: the reported total must keep + // the client paging until the last match and stop it exactly there. + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So")) + total := all.TotalRecordCount + Expect(total).To(Equal(len(all.Items))) + Expect(total).To(BeNumerically(">=", 2)) + + var collected []string + for start := range total { + page := queryResult(get(fmt.Sprintf("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So&Limit=1&StartIndex=%d", start))) + Expect(page.Items).To(HaveLen(1)) + if start+1 < total { + Expect(page.TotalRecordCount).To(BeNumerically(">", start+1)) // more remain: keep paging + } else { + Expect(page.TotalRecordCount).To(Equal(total)) // last page: exact, so the client stops + } + collected = append(collected, page.Items[0].Name) + } + Expect(collected).To(ConsistOf(names(all.Items))) + }) + }) +}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go new file mode 100644 index 000000000..2057b43c6 --- /dev/null +++ b/server/jellyfin/e2e/sessions_test.go @@ -0,0 +1,62 @@ +package e2e + +import ( + "net/http" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sessions", func() { + BeforeEach(func() { setupTestDB() }) + + ticks := func(ms int64) int64 { return ms * 10_000 } + reportBody := func(itemID string, positionTicks int64) string { + return `{"ItemId":"` + enc(itemID) + `","PositionTicks":` + strconv.FormatInt(positionTicks, 10) + `}` + } + + Describe("playback reporting", func() { + It("accepts a playback start report", func() { + Expect(post("/Sessions/Playing", reportBody(songID("Come Together"), 0)).Code).To(Equal(http.StatusNoContent)) + }) + + It("accepts a playback progress report", func() { + Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"), ticks(5000))).Code).To(Equal(http.StatusNoContent)) + }) + + It("counts a play stopped past the threshold", func() { + id := songID("So What") + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) + + mf, err = ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(BeNumerically(">=", 1)) + }) + + It("does not count a brief play stopped before the threshold", func() { + // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip + // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% + // threshold is always well above 1s. + id := songID("Help!") + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) + + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("capabilities", func() { + It("acknowledges POST /Sessions/Capabilities", func() { + Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) + }) + + It("acknowledges POST /Sessions/Capabilities/Full", func() { + Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go new file mode 100644 index 000000000..43ef857fa --- /dev/null +++ b/server/jellyfin/e2e/similar_test.go @@ -0,0 +1,134 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Similar", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Artists/{id}/Similar", func() { + It("returns the provider's similar artists, excluding ones not in the library", func() { + providerFake.similarArtists = model.Artists{ + {ID: "z", Name: "Led Zeppelin"}, + {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded + } + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Led Zeppelin")) + Expect(q.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("returns an empty result (not 404) when the provider has nothing", func() { + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("GET /Items/{id}/Similar", func() { + It("returns similar songs for a track", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Similar Song")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("In Library")) + }) + + It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, + {ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once + {ID: "x3", AlbumID: albumID("Kind of Blue")}, + } + q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) + Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) + Expect(q.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("excludes similar albums from libraries the user can't access", func() { + // Seed an album in a second library the regular user has no access to, and point a + // provider similar-song at it. + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2} + Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed()) + + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible + {ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser + } + q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { + q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. + Describe("GET /Items/{id}/InstantMix", func() { + It("returns the seed track first, followed by similar songs", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("does not duplicate the seed when the provider returns it", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: songID("So What"), Title: "So What", LibraryID: 1}, + {ID: "x1", Title: "Similar Song", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + }) + + It("caps the mix at the requested limit", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "S1", LibraryID: 1}, + {ID: "x2", Title: "S2", LibraryID: 1}, + {ID: "x3", Title: "S3", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) + Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) + }) + + It("returns a mix of the provider's similar songs for an artist seed", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) + }) + + It("returns an empty result (not 404) for an unknown item", func() { + w := get("/Items/" + enc("does-not-exist") + "/InstantMix") + Expect(w.Code).To(Equal(200)) + Expect(queryResult(w).Items).To(BeEmpty()) + }) + + It("returns only the seed when the provider has nothing", func() { + q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Help!"})) + }) + }) +}) diff --git a/server/jellyfin/e2e/smoke_test.go b/server/jellyfin/e2e/smoke_test.go new file mode 100644 index 000000000..b26955632 --- /dev/null +++ b/server/jellyfin/e2e/smoke_test.go @@ -0,0 +1,49 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded +// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files. +var _ = Describe("Smoke", func() { + BeforeEach(func() { setupTestDB() }) + + It("serves public system info without auth", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info).To(HaveKey("ServerName")) + Expect(info).To(HaveKey("Version")) + }) + + It("rejects an authenticated endpoint without a token", func() { + w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists the seeded albums for an authenticated user", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("MusicAlbum")) + names = append(names, it.Name) + } + Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("resolves a seeded album id round-trip (encoded in the URL)", func() { + id := albumID("Abbey Road") + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + Expect(item.Id).To(Equal(enc(id))) + Expect(item.Name).To(Equal("Abbey Road")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) +}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go new file mode 100644 index 000000000..c096d904a --- /dev/null +++ b/server/jellyfin/e2e/streaming_test.go @@ -0,0 +1,128 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Streaming", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Audio/{id}/stream", func() { + It("streams the requested track", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("fake audio data")) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("streams via the /universal endpoint", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("serves the stream.{container} path form", func() { + id := songID("Help!") + Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("forces raw format when static=true", func() { + // With ffmpeg unavailable the decider direct-plays regardless, but static=true must + // never resolve to a transcode. + id := songID("Help!") + get("/Audio/" + enc(id) + "/stream?static=true") + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("returns 404 for an unknown track", func() { + Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { + It("returns a VOD playlist whose segment streams through the transcode pipeline", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/main.m3u8?audioCodec=aac&audioBitRate=320000") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + + // Fetch the advertised segment like an HLS player would. + var segment string + for _, line := range strings.Split(body, "\n") { + if line != "" && !strings.HasPrefix(line, "#") { + segment = line + } + } + Expect(segment).To(HavePrefix("stream.aac?")) + Expect(get("/Audio/" + enc(id) + "/" + segment).Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + Expect(streamerSpy.LastRequest.Format).To(Equal("aac")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(320)) + }) + + It("is reachable with Jellyfin's case-insensitive routing", func() { + id := songID("Come Together") + Expect(get("/audio/" + enc(id) + "/Main.m3u8").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("direct-file endpoints", func() { + It("serves /Items/{id}/File as direct play (raw)", func() { + id := songID("Something") + w := get("/Items/" + enc(id) + "/File") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("serves /Items/{id}/Download", func() { + id := songID("Something") + Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("PlaybackInfo", func() { + It("returns a single direct-play MediaSource via GET", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) + Expect(info.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns a MediaSource via POST", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + }) + + It("embeds a self-authenticating TranscodingUrl (for native players that omit auth headers)", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + streamURL := info.MediaSources[0].TranscodingUrl + // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute + // host path hits the mounted router. + Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + Expect(streamURL).To(ContainSubstring("api_key=")) + // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e + // router is mounted at the root, so strip the /jellyfin prefix before replaying. + replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) + w := rawReq("GET", replayURL, "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go new file mode 100644 index 000000000..d4d2f2777 --- /dev/null +++ b/server/jellyfin/e2e/system_test.go @@ -0,0 +1,55 @@ +package e2e + +import ( + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /System/Info/Public", func() { + It("returns public server info without authentication", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info["ServerName"]).To(HavePrefix("Navidrome")) + Expect(info["ProductName"]).To(Equal("Jellyfin Server")) + Expect(info["StartupWizardCompleted"]).To(BeTrue()) + Expect(info["Id"]).ToNot(BeEmpty()) + Expect(info["Version"]).ToNot(BeEmpty()) + }) + + It("routes case-insensitively (lowercase path)", func() { + w := rawReq("GET", "/system/info/public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GET/POST /System/Ping", func() { + It("answers GET with a plain-text server name", func() { + w := rawReq("GET", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("answers POST identically", func() { + w := rawReq("POST", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) + }) + }) + + Describe("GET /QuickConnect/Enabled", func() { + It("reports QuickConnect disabled", func() { + w := rawReq("GET", "/QuickConnect/Enabled", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) + }) + }) +}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go new file mode 100644 index 000000000..722e828c5 --- /dev/null +++ b/server/jellyfin/images.go @@ -0,0 +1,170 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "strconv" + + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + _ "golang.org/x/image/webp" +) + +func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { + // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs + // under an elevated context to bypass the persistence visibility filter; playlist access is + // gated inside resolveArtworkID. + ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) + itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + + artID := api.resolveArtworkID(ctx, r, itemId) + reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + switch { + case errors.Is(err, context.Canceled): + return + case err != nil: + log.Warn(ctx, "Error retrieving artwork", "id", itemId, err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer reader.Close() + // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). + _, _ = io.Copy(w, reader) +} + +// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing +// album -> artist -> media file -> playlist. +func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { + if al, err := api.ds.Album(ctx).Get(itemId); err == nil { + return al.CoverArtID().String() + } + if ar, err := api.ds.Artist(ctx).Get(itemId); err == nil { + return ar.CoverArtID().String() + } + if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil { + return mf.CoverArtID().String() + } + if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { + // Playlist covers are user-scoped: serve a private one only for a public playlist or a + // token identifying its owner/an admin, so this public route can't probe others' covers. + u, ok := api.userFromToken(r) + if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { + return pl.CoverArtID().String() + } + } + return (model.ArtworkID{}).String() +} + +// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come +// from scanning. The body is always drained first (even on the not-implemented path) because +// Finamp writes it synchronously and sees a broken pipe if we respond before reading it. +func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + // Honor the same artwork-upload gate and size cap as the native endpoint. + u, _ := request.UserFrom(ctx) + if !conf.Server.EnableArtworkUpload && !u.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode + // the wire body (4/3 bigger), so the read cap allows for inflation. + limit := core.MaxImageUploadSize() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", + "playlistId", id, "limit", humanize.Bytes(uint64(limit)), err) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + imgBytes, err := decodeImageBody(body) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + if int64(len(imgBytes)) > limit { + log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize", + "playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit))) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + // Validate by decoding and derive the extension from the real format — clients lie in Content-Type. + _, format, err := image.DecodeConfig(bytes.NewReader(imgBytes)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + ext := "." + format + + if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. +func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + if err := api.playlists.RemoveImage(ctx, id); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients +// send raw bytes, so input already starting with an image magic number is passed through as-is. +func decodeImageBody(body []byte) ([]byte, error) { + if isImageMagic(body) { + return body, nil + } + trimmed := bytes.TrimSpace(body) + return base64.StdEncoding.DecodeString(string(trimmed)) +} + +func isImageMagic(b []byte) bool { + switch { + case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG + return true + case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG + return true + case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a) + return true + case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP + return true + default: + return false + } +} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go new file mode 100644 index 000000000..8099fcf94 --- /dev/null +++ b/server/jellyfin/images_test.go @@ -0,0 +1,394 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeArtwork struct { + artwork.Artwork + recvId string + recvCtx context.Context + data []byte +} + +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + f.recvId = id + f.recvCtx = ctx + data := f.data + if data == nil { + data = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(data)), time.Now(), nil +} + +func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("itemId", itemId) + rctx.URLParams.Add("type", "Primary") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + return w, r +} + +var _ = Describe("Images", func() { + It("streams album artwork", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(fa.recvId).To(ContainSubstring("a1")) + }) + + It("sniffs the Content-Type instead of hardcoding it", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + + png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) + fa := &fakeArtwork{data: png} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) + }) + + It("resolves a public playlist id to its cover artwork", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).To(ContainSubstring("pl1")) + }) + + It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + }) + + // This endpoint is public (no user in the request), so artwork must be resolved under an + // elevated context; otherwise a private playlist's cover fails its visibility filter and + // silently falls back to the placeholder. + It("resolves artwork under an elevated admin context", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + u, ok := request.UserFrom(fa.recvCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) +}) + +// Real image fixtures: postItemImage validates uploads by decoding them. +func pngBytes() []byte { + var b bytes.Buffer + Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) + return b.Bytes() +} + +func jpegBytes() []byte { + var b bytes.Buffer + Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +func gifBytes() []byte { + var b bytes.Buffer + Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). +func webpBytes() []byte { + b, err := base64.StdEncoding.DecodeString( + "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") + Expect(err).ToNot(HaveOccurred()) + return b +} + +var _ = Describe("postItemImage", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api = &Router{playlists: fp} + }) + + It("uploads a raw JPEG body and returns 204", func() { + body := jpegBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".jpeg")) + }) + + It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { + raw := pngBytes() + encoded := base64.StdEncoding.EncodeToString(raw) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded))) + r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(raw)) + Expect(fp.setImageExt).To(Equal(".png")) + }) + + It("returns 501 for a non-playlist item, draining the body first", func() { + fp.getByIDPls = nil + fp.getByIDErr = model.ErrNotFound + bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + Expect(bodyReader.Len()).To(Equal(0)) + }) + + It("returns 500 when the service fails", func() { + fp.setImageErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("accepts a raw WebP body", func() { + body := webpBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/webp") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".webp")) + }) + + It("accepts a raw GIF body", func() { + body := gifBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/gif") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".gif")) + }) + + It("rejects an oversized body with 400, like the native endpoint", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MaxImageUploadSize = "16" // 16 bytes + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") + }) + + It("applies the size limit to the decoded image, not the base64 body", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + // The raw image is exactly at the limit; its base64 form is 4/3 bigger. + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) + body := []byte(base64.StdEncoding.EncodeToString(img)) + Expect(len(body)).To(BeNumerically(">", len(img))) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(img)) + }) + + It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) + body := []byte(base64.StdEncoding.EncodeToString(img)) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects a body that is neither an image nor base64 with 400", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!")) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { + body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("forbids a non-admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("still allows an admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + }) +}) + +var _ = Describe("deleteItemImage", func() { + It("removes the playlist image and returns 204", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + }) + + It("returns 501 for a non-playlist item", func() { + fp := &fakePlaylists{getByIDErr: model.ErrNotFound} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + }) + + It("returns 500 when the service fails", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) +}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go new file mode 100644 index 000000000..f8158bca4 --- /dev/null +++ b/server/jellyfin/items.go @@ -0,0 +1,577 @@ +package jellyfin + +import ( + "context" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// notMissing excludes items whose backing files are all gone ("missing" is a real column on +// album, artist and media_file). +var notMissing = squirrel.Eq{"missing": false} + +func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { + res, err := api.queryItems(r.Context(), r) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// queryItems is the /Items dispatcher: it parses entity types from IncludeItemTypes (defaulting to +// MusicAlbum), queries each via the matching listXxx, and merges multi-type results into one +// paginated list (as Finamp's favorites screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) { + p := req.Params(r) + // Query keys are read lowercase because normalizeQueryKeys folded them (Jellyfin binds + // case-insensitively). /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch below. + fields := dto.ParseFields(p.StringOr("fields", "")) + if ids := decodedQueryIDs(r, "ids"); len(ids) > 0 { + return api.itemsByIDs(ctx, ids, fields), nil + } + parentId := dto.DecodeID(p.StringOr("parentid", "")) + search := p.StringOr("searchterm", "") + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly := strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false) + sortBy := p.StringOr("sortby", "") + sortOrder := p.StringOr("sortorder", "") + offset := p.IntOr("startindex", 0) + limit := p.IntOr("limit", 0) + rawTypes := p.StringOr("includeitemtypes", "") + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + if strings.Contains(rawTypes, "ManualPlaylistsFolder") { + return result([]dto.BaseItemDto{playlistsFolder()}, 1, 0), nil + } + types := parseTypes(rawTypes) + // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistIds/artistIds + // select the artist's own discography; contributingArtistIds alone means albums they merely appear + // on (Jellyfin's "Featured On"), which must exclude that discography. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + contributingOnly := albumArtistScope == "" && contributingScope != "" + // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. + genreIds := decodedQueryIDs(r, "genreids") + + scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) + // A playlist parent always resolves to its tracks, whatever IncludeItemTypes says. Jellify opens + // a playlist with ParentId=<playlist>&IncludeItemTypes=Audio; routing that through listSongs would + // treat the playlist id as an album id and return nothing. + if parentId != "" && !isLibraryParent && parentId != playlistsFolderID { + if pls, err := api.playlists.GetWithTracks(ctx, parentId); err == nil { + // GetWithTracks enforces visibility (public or owned by the current user). + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + return result(paginate(items, offset, limit), len(items), offset), nil + } + } + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks + // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse + // its albums). + if rawTypes == "" && parentId != "" && !isLibraryParent { + if parentId == playlistsFolderID { + // Browsing into the synthetic playlists folder lists the user's playlists. + types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(parentId); err == nil { + types = []string{"Audio"} + } + } + entityParent := parentId + // ParentId-as-entity-id (artist for MusicAlbum, album for Audio) only makes sense for a single + // type; a multi-type query has no natural parent entity, so ParentId is only library scoping there. + if isLibraryParent || len(types) > 1 { + entityParent = "" + } + + if len(types) == 1 { + opts := model.QueryOptions{Offset: offset, Max: limit} + applySort(&opts, types[0], sortBy, sortOrder) + return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) + } + + var items []dto.BaseItemDto + total := 0 + for _, itemType := range types { + var opts model.QueryOptions + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window); without this cap each would fetch its whole table. + // Totals are unaffected — they come from CountAll. + if limit > 0 { + opts.Max = offset + limit + } + applySort(&opts, itemType, sortBy, sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) + if err != nil { + return dto.QueryResult{}, err + } + items = append(items, res.Items...) + total += res.TotalRecordCount + } + return result(paginate(items, offset, limit), total, offset), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, favOnly bool, fields dto.Fields) (dto.QueryResult, error) { + switch itemType { + case "Audio": + return api.listSongs(ctx, opts, entityParent, artistId, genreIds, scopeIDs, search, favOnly, fields) + case "MusicArtist": + // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. + return api.listArtists(ctx, opts, genreIds, scopeIDs, search, favOnly, model.RoleAlbumArtist) + case "MusicGenre": + return api.listGenres(ctx, opts) + case "Playlist": + return api.listPlaylists(ctx, opts, favOnly) + default: // MusicAlbum + return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly) + } +} + +// firstNonEmpty returns the first non-empty string, or "". +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. +func firstDecodedID(s string) string { + if s == "" { + return "" + } + first, _, _ := strings.Cut(s, ",") + return dto.DecodeID(strings.TrimSpace(first)) +} + +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. +func decodedQueryIDs(r *http.Request, key string) []string { + return slice.Map(queryIDs(r, key), dto.DecodeID) +} + +// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to +// {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums). +func parseTypes(types string) []string { + var recognized []string + for t := range strings.SplitSeq(types, ",") { + t = strings.TrimSpace(t) + switch t { + case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist": + recognized = append(recognized, t) + } + } + if len(recognized) == 0 { + return []string{"MusicAlbum"} + } + return recognized +} + +// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only +// (single-type queries push Offset/Max down to SQL instead). +func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { + if offset >= len(items) { + return []dto.BaseItemDto{} + } + items = items[offset:] + if limit > 0 && limit < len(items) { + items = items[:limit] + } + return items +} + +// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the +// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is +// exact once matches end (and a growing lower bound before), so paging terminates at the last match. +func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { + fetch := opts + if fetch.Max > 0 { + fetch.Max++ + } + rows, err := search(fetch) + if err != nil { + return nil, 0, err + } + total := opts.Offset + len(rows) + if opts.Max > 0 && len(rows) > opts.Max { + rows = rows[:opts.Max] + } + return rows, total, nil +} + +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { + repo := api.ds.Album(ctx) + filters := squirrel.And{} + // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's + // albums"; contributingArtistIds means "albums they only appear on" (Featured On). + switch { + case contributingOnly && artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) + case firstNonEmpty(artistId, parentId) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + default: + filters = append(filters, notMissing) + } + if len(genreIds) > 0 { + filters = append(filters, filter.ByGenreID(genreIds)) + } + if fav { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, scopeIDs) + + if search != "" { + albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset), nil + } + albums, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil +} + +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId, artistId string, genreIds []string, scopeIDs []int, search string, fav bool, fields dto.Fields) (dto.QueryResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, fields) } + repo := api.ds.MediaFile(ctx) + filters := squirrel.And{} + // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. + switch { + case artistId != "": + filters = append(filters, filter.SongsByArtistID(artistId).Filters) + case parentId != "": + filters = append(filters, filter.SongsByAlbum(parentId).Filters) + default: + filters = append(filters, notMissing) + } + if len(genreIds) > 0 { + filters = append(filters, filter.ByGenreID(genreIds)) + } + if fav { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, scopeIDs) + + if search != "" { + mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(mfs, toItem), total, opts.Offset), nil + } + // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an + // explicit client SortBy still wins, since applySort already set opts.Sort. + if artistId == "" && parentId != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(parentId).Sort + } + mfs, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(mfs, toItem), int(total), opts.Offset), nil +} + +// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, +// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. +// genreIds isn't applied to search — a name lookup, like role (see below). +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, genreIds []string, scopeIDs []int, search string, fav bool, role model.Role) (dto.QueryResult, error) { + repo := api.ds.Artist(ctx) + + // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a + // search scope (artists have no library_id column). A compound or join-based filter + // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build + // filters differently. Role isn't applied to search for the same reason — it's a name lookup. + if search != "" { + if len(scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": scopeIDs} + } + artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset), nil + } + + if fav { + opts.Filters = filter.ArtistsByStarred().Filters + } else { + opts.Filters = notMissing + } + if len(genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(genreIds)} + } + opts = filter.ArtistsByRole(opts, role) + opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) + artists, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil +} + +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. Paging is +// in-memory (GenreRepository has no CountAll, lists are small) so TotalRecordCount is the real total. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) { + genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) + if err != nil { + return dto.QueryResult{}, err + } + items := slice.Map(genres, dto.GenreToBaseItem) + return result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset), nil +} + +// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is +// enforced by playlistRepository, not scopeIDs. +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) { + if favOnly { + starred := squirrel.Eq{"starred": true} + if opts.Filters == nil { + opts.Filters = starred + } else { + opts.Filters = squirrel.And{opts.Filters, starred} + } + } + repo := api.ds.Playlist(ctx) + playlists, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil +} + +// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, +// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access +// to their library, so an id can't probe content outside the user's libraries. +func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { + // The synthetic playlists folder must resolve by the id we advertised, not 404. + if id == playlistsFolderID { + return playlistsFolder(), true + } + u, _ := request.UserFrom(ctx) + // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this + // the home screen and library tabs 404. + if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { + for _, lib := range u.Libraries { + if lib.ID == libID { + return libraryView(lib), true + } + } + // Admin bypass: Libraries is empty but all access is granted, so fetch the real library. + if lib, err := api.ds.Library(ctx).Get(libID); err == nil { + return libraryView(*lib), true + } + } + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.AlbumToBaseItem(*al), true + } + if ar, err := api.ds.Artist(ctx).Get(id); err == nil { + // TODO: an artist spans multiple libraries (library_artist), so there's no single + // LibraryID to gate here; artist access relies on list-time scoping and persistence. + return dto.ArtistToBaseItem(*ar), true + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.SongToBaseItem(*mf, fields), true + } + // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. + if pl, err := api.playlists.Get(ctx, id); err == nil { + return dto.PlaylistToBaseItem(*pl), true + } + return dto.BaseItemDto{}, false +} + +// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id. +func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile { + songs := make(map[string]model.MediaFile, len(ids)) + // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. + for chunk := range slice.CollectChunks(slices.Values(ids), 500) { + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) + if err != nil { + log.Error(ctx, "Jellyfin API: error fetching songs by id", err) + continue + } + for _, mf := range mfs { + songs[mf.ID] = mf + } + } + return songs +} + +// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. +// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored +// queue items against its stored (truncated) ids. +func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { + u, _ := request.UserFrom(ctx) + fullIDs := api.resolveItemIDs(ctx, ids) + songs := api.songsByIDs(ctx, fullIDs) + var items []dto.BaseItemDto + for i, id := range fullIDs { + var item dto.BaseItemDto + if mf, ok := songs[id]; ok { + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + item = dto.SongToBaseItem(mf, fields) + } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { + continue + } + if id != ids[i] { + item.Id = dto.EncodeID(ids[i]) + } + items = append(items, item) + } + return result(items, len(items), 0) +} + +func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { + api.ok(w, r, item) + return + } + http.Error(w, "Not Found", http.StatusNotFound) +} + +// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from +// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. +func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + if err := api.playlists.Delete(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := filter.AlbumsByNewest() + opts.Max = req.Params(r).IntOr("limit", 20) + opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + albums, err := api.ds.Album(ctx).GetAll(opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array +} + +func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { + if items == nil { + items = []dto.BaseItemDto{} + } + return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} +} + +// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the +// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); +// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), +// not passed through raw where it could produce an invalid ORDER BY. +func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + for key := range strings.SplitSeq(sortBy, ",") { + if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { + opts.Sort = col + break + } + } + if strings.EqualFold(order, "Descending") { + opts.Order = "desc" + } +} + +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type. Each repository maps +// logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no +// "random"). +var sortColumnsByType = map[string]map[string]string{ + "Audio": { + "sortname": "title", "name": "title", + "album": "album", + // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's + // "album" sort key is disc+track order within an album, so map both to it. + "indexnumber": "album", + "parentindexnumber": "album", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "random": "random", + // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. + "premieredate": "year", + "productionyear": "year", + }, + "MusicArtist": { + "sortname": "name", "name": "name", + "albumcount": "album_count", + "songcount": "song_count", + "datecreated": "created_at", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + }, + "MusicAlbum": { + "sortname": "name", "name": "name", "album": "name", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "random": "random", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "premieredate": "max_year", "productionyear": "max_year", + }, + "MusicGenre": { + "sortname": "name", "name": "name", + }, + "Playlist": { + "sortname": "name", "name": "name", + "datecreated": "created_at", + }, +} + +// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for +// itemType, reporting false when it isn't recognized for that type. +func sortColumn(itemType, sortBy string) (string, bool) { + col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)] + return col, ok +} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go new file mode 100644 index 000000000..8bd1bf052 --- /dev/null +++ b/server/jellyfin/items_test.go @@ -0,0 +1,608 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// withChiURLParam simulates chi's routing having captured a path parameter, since these +// tests call handlers directly instead of going through the full router. +func withChiURLParam(r *http.Request, key, value string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add(key, value) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +var _ = Describe("Items", func() { + var api *Router + var ds *tests.MockDataStore + var fp *fakePlaylists + // alice has access to library 1 only; used by tests that don't care about scoping. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + ctxUserWithLibraries := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + BeforeEach(func() { + ds = &tests.MockDataStore{} + fp = &fakePlaylists{} + api = &Router{ds: ds, playlists: fp} + }) + + Describe("getItems", func() { + It("lists albums when IncludeItemTypes=MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("lists an album's songs when ParentId is an album and type is Audio", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("json_tree")) + }) + + It("lists artists when IncludeItemTypes=MusicArtist", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("lists genres when IncludeItemTypes=MusicGenre", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("lists playlists when IncludeItemTypes=Playlist", func() { + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Playlist")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("merges results from every requested type in IncludeItemTypes", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("Audio", "MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("merges favorite songs, albums, and playlists", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(3)) + types := []string{res.Items[0].Type, res.Items[1].Type} + types = append(types, res.Items[2].Type) + Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist")) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + playlistSQL, _, err := playlistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(playlistSQL).To(ContainSubstring("starred")) + }) + + It("applies StartIndex/Limit to the merged multi-type result set", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(4)) + Expect(res.StartIndex).To(Equal(1)) + }) + + It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // The merged window is [1, 3): each type needs at most its first 3 rows, not the table. + Expect(mfRepo.Options.Max).To(Equal(3)) + Expect(albumRepo.Options.Max).To(Equal(3)) + }) + + It("applies a starred filter when Filters=IsFavorite", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("reports a search total beyond the fetched page instead of the page length", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ + {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.TotalRecordCount).To(Equal(3)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Offset).To(Equal(5)) + Expect(albumRepo.Options.Max).To(Equal(10)) + }) + + Describe("Ids batch-fetch", func() { + // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; without + // this, queryItems ignored Ids and returned the default type-dispatched list instead. + It("returns exactly the requested item when Ids has a single id", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Name).To(Equal("Song")) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("returns items of different types for a lowercase ids param with multiple ids", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + ids := []string{res.Items[0].Id, res.Items[1].Id} + Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1"))) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("MusicAlbum", "Audio")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("resolves song ids with one batched IN query, not a Get per id", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.id IN")) + Expect(args).To(ConsistOf("s1", "s2")) + }) + + It("omits an id in a library the user can't access, without erroring the whole batch", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("sorting", func() { + It("maps SortBy=PlayCount to the play_count column", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("play_count")) + }) + + It("maps SortBy=DatePlayed to the play_date column", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("play_date")) + }) + + It("uses the first recognized key in a comma-separated SortBy list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("recently_added")) + }) + + It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("title")) + }) + + It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("album")) + }) + + It("leaves Sort at the repo default when no SortBy key is recognized", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("")) + }) + }) + + Describe("library scoping", func() { + It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a MusicArtist listing to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("json_tree")) // not treated as an artist-parent filter + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(2)) + }) + + It("does not let ParentId=<inaccessible library id> scope results to that library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + // Falls back to treating "99" as an (empty-matching) artist-parent id... + Expect(sql).To(ContainSubstring("json_tree")) + // ...while still scoping to the user's own accessible libraries. + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("does not restrict a default MusicAlbum listing for an admin user", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyLibraryFilter([]) is a no-op: no library_id restriction is added. + if albumRepo.Options.Filters == nil { + return + } + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_id")) + }) + }) + }) + + Describe("getItem", func() { + It("returns an album by id", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an album in a library the user can't access", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a song in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns an album to an admin even when it's outside their (empty) Libraries", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + }) + + // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the + // library node before it can load the home screen or any library tab. + It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1, Name: "Music Library"}} + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + Expect(item.CollectionType).To(Equal("music")) + Expect(item.IsFolder).To(BeTrue()) + }) + + It("does not resolve a library-view id the user has no access to", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it + // 404s with "Could not fetch BaseItemDto <playlist> from server." + It("resolves a playlist id via the playlists service", func() { + fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("p1"))) + Expect(item.Name).To(Equal("My Mix")) + Expect(item.Type).To(Equal("Playlist")) + }) + + It("returns 404 for a non-owned or absent playlist id", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { + ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + }) + }) + + Describe("getLatest", func() { + It("returns a bare array of the newest albums", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var items []dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) + Expect(items).To(HaveLen(1)) + Expect(items[0].Id).To(Equal(dto.EncodeID("a1"))) + }) + + It("scopes to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + }) +}) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go new file mode 100644 index 000000000..aab9628a0 --- /dev/null +++ b/server/jellyfin/jellyfin_suite_test.go @@ -0,0 +1,25 @@ +package jellyfin + +import ( + "net/http" + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinApi(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API Suite") +} + +// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call +// handlers directly (with withChiURLParam for path params) instead of routing, so without this the +// case-insensitive query folding real requests get would be skipped and PascalCase params dropped. +func invoke(h http.HandlerFunc, w http.ResponseWriter, r *http.Request) { + normalizeQueryKeys(h).ServeHTTP(w, r) +} diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go new file mode 100644 index 000000000..2e486c36f --- /dev/null +++ b/server/jellyfin/library.go @@ -0,0 +1,44 @@ +package jellyfin + +import ( + "context" + "strconv" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty +// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters. +func accessibleLibraryIDs(ctx context.Context) []int { + u, _ := request.UserFrom(ctx) + return u.Libraries.IDs() +} + +// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an +// entity id (artist/album). It's treated as a library only when the user has access; otherwise +// isLibraryParent is false and callers fall through to entity-id handling. +func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) { + if parentId != "" { + if id, err := strconv.Atoi(parentId); err == nil { + if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) { + return []int{id}, true + } + } + } + return accessibleLibraryIDs(ctx), false +} + +// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. +// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. +func libraryView(lib model.Library) dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(strconv.Itoa(lib.ID)), + Name: lib.Name, + Type: "CollectionFolder", + CollectionType: "music", + IsFolder: true, + BackdropImageTags: []string{}, + } +} diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go new file mode 100644 index 000000000..2941d3c32 --- /dev/null +++ b/server/jellyfin/middlewares.go @@ -0,0 +1,187 @@ +package jellyfin + +import ( + "net" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params +// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, +// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one +// client's filters, sort and paging. Only keys are folded — values keep their case. The original +// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing. +func normalizeQueryKeys(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + folded := make(url.Values, len(q)) + changed := false + for k, vs := range q { + lk := strings.ToLower(k) + // Append, don't assign: two casings of the same key must merge, not overwrite. + folded[lk] = append(folded[lk], vs...) + if lk != k { + changed = true + } + } + if changed { + r2 := *r + u := *r.URL + u.RawQuery = folded.Encode() + r2.URL = &u + r = &r2 + } + next.ServeHTTP(w, r) + }) +} + +type mediaBrowserAuth struct { + Client, Device, DeviceId, Version, Token string +} + +var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) + +// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g. +// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. +// The recommended Authorization header is preferred, but only when it actually carries +// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client +// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk) +// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField +// leaves a raw value untouched. +func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth { + if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok { + return a + } + a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization")) + return a +} + +// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the +// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts). +func parseAuthHeader(h string) (mediaBrowserAuth, bool) { + var a mediaBrowserAuth + scheme, params, found := strings.Cut(h, " ") + if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) { + return a, false + } + for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) { + switch m[1] { + case "Client": + a.Client = unescapeField(m[2]) + case "Device": + a.Device = unescapeField(m[2]) + case "DeviceId": + a.DeviceId = unescapeField(m[2]) + case "Version": + a.Version = unescapeField(m[2]) + case "Token": + a.Token = unescapeField(m[2]) + } + } + return a, true +} + +// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't +// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not +// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space. +func unescapeField(v string) string { + if decoded, err := url.PathUnescape(v); err == nil { + return decoded + } + return v +} + +// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings +// deprecated by Jellyfin but still sent by clients. +func tokenFromRequest(r *http.Request) string { + if t := parseMediaBrowserAuth(r).Token; t != "" { + return t + } + if t := r.Header.Get("X-Emby-Token"); t != "" { + return t + } + if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { + return t + } + // api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't + // merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=). + if t := r.URL.Query().Get("api_key"); t != "" { + return t + } + return r.URL.Query().Get("apikey") +} + +// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token +// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +func (api *Router) userFromToken(r *http.Request) (model.User, bool) { + token := tokenFromRequest(r) + if token == "" { + return model.User{}, false + } + claims, err := auth.Validate(token) + if err != nil || claims.Subject == "" { + return model.User{}, false + } + usr, err := api.ds.User(r.Context()).FindByUsername(claims.Subject) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) + return model.User{}, false + } + return *usr, true +} + +func (api *Router) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + usr, ok := api.userFromToken(r) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := request.WithUser(r.Context(), usr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring +// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic), +// so it's used directly as the player id and reports from the same install share a player/scrobbling +// session. +func (api *Router) withPlayer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if api.players == nil { // fail open when players isn't wired (e.g. in unit tests) + next.ServeHTTP(w, r) + return + } + ctx := r.Context() + a := parseMediaBrowserAuth(r) + // Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g. + // the /socket handshake that authenticates via ?api_key= only). Otherwise Register would + // create a junk player with an empty name (" []"). + if a.Client == "" && a.DeviceId == "" { + next.ServeHTTP(w, r) + return + } + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) + if err != nil { + // Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers + // degrade gracefully. + log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) + next.ServeHTTP(w, r) + return + } + ctx = request.WithPlayer(ctx, *player) + // Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override. + if trc != nil { + ctx = request.WithTranscoding(ctx, *trc) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go new file mode 100644 index 000000000..2c0761834 --- /dev/null +++ b/server/jellyfin/middlewares_test.go @@ -0,0 +1,254 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authenticate middleware", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + tokenFor := func(name string) string { + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name}) + Expect(err).ToNot(HaveOccurred()) + return t + } + + It("passes with a valid X-Emby-Token and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", tokenFor("alice")) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("rejects a missing token with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a garbage token with 401 and does not call next", func() { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", "not-a-jwt") + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + It("rejects a valid token whose subject user does not exist with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", t) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var players *fakePlayers + + BeforeEach(func() { + players = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: players} + }) + + callWith := func() (model.Player, model.Transcoding, bool) { + var gotPlayer model.Player + var gotTrc model.Transcoding + var hasTrc bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, _ = request.PlayerFrom(r.Context()) + gotTrc, hasTrc = request.TranscodingFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + api.withPlayer(next).ServeHTTP(w, r) + return gotPlayer, gotTrc, hasTrc + } + + It("injects the registered player into the context", func() { + player, _, hasTrc := callWith() + Expect(player.ID).To(Equal("dev1")) + Expect(hasTrc).To(BeFalse()) + }) + + It("injects the player's server-forced transcoding into the context", func() { + players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + _, trc, hasTrc := callWith() + Expect(hasTrc).To(BeTrue()) + Expect(trc.TargetFormat).To(Equal("opus")) + }) +}) + +var _ = Describe("tokenFromRequest", func() { + It("accepts the recommended Authorization: MediaBrowser scheme", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("prefers the Authorization scheme token over deprecated token headers", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`) + r.Header.Set("X-Emby-Token", "legacy-token") + Expect(tokenFromRequest(r)).To(Equal("scheme-token")) + }) + + It("accepts the lowercase api_key query param", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) + Expect(got).To(Equal("tok123")) + }) +}) + +var _ = Describe("parseMediaBrowserAuth", func() { + authFor := func(header string) mediaBrowserAuth { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-Emby-Authorization", header) + return parseMediaBrowserAuth(r) + } + + It("reads Finamp's raw (unencoded) field values", func() { + a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + Expect(a.DeviceId).To(Equal("dev1")) + }) + + It("percent-decodes Jellify's URL-encoded field values", func() { + a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Jellify")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + }) + + It("keeps a literal '%' that isn't valid percent-encoding", func() { + a := authFor(`MediaBrowser Client="100% Player", Device="d"`) + Expect(a.Client).To(Equal("100% Player")) + }) + + It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("New")) + Expect(a.DeviceId).To(Equal("dev-new")) + }) + + It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() { + // A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must + // still be honored. + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Digest username="proxy", realm="site"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Token).To(Equal("tok")) + }) + + It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Custom Token="not-for-us"`) + Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty()) + }) + + It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() { + a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`) + Expect(a.Client).To(Equal("OldClient")) + Expect(a.Token).To(Equal("tok")) + }) + + It("matches the scheme case-insensitively (HTTP auth schemes are)", func() { + a := authFor(`mediabrowser Token="tok"`) + Expect(a.Token).To(Equal("tok")) + }) +}) + +var _ = Describe("normalizeQueryKeys", func() { + // keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for + // the given (lowercase) key — i.e. what a case-insensitive read would find. + keyFor := func(rawQuery, key string) string { + r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r) + return got + } + + It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() { + Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc")) + Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc")) + }) + + It("leaves values untouched", func() { + Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio")) + }) + + It("passes already-lowercase keys through unchanged", func() { + Expect(keyFor("container=mp3", "container")).To(Equal("mp3")) + }) + + It("merges values when two keys fold to the same name instead of dropping one", func() { + r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil) + var got []string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r) + Expect(got).To(ConsistOf("aaa", "bbb")) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go new file mode 100644 index 000000000..ffc2c6543 --- /dev/null +++ b/server/jellyfin/playlists.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve +// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal +// can't collide with real ids (those are hashes). +const playlistsFolderID = "playlists" + +// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be +// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. +func playlistsFolder() dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(playlistsFolderID), + Name: "Playlists", + Type: "ManualPlaylistsFolder", + CollectionType: "playlists", + IsFolder: true, + } +} + +// playlistError maps core/playlists write errors to HTTP status: ownership -> 403, missing/invisible +// -> 404 (never revealing another user's private playlist), else -> 500. +func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, model.ErrNotAuthorized): + http.Error(w, "Forbidden", http.StatusForbidden) + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Not Found", http.StatusNotFound) + default: + api.internalError(w, r, err) + } +} + +type createPlaylistRequest struct { + Name string `json:"Name"` + Ids []string `json:"Ids"` + MediaType string `json:"MediaType"` +} + +// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to +// replace an existing one), owned by the authenticated user. +func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { + var body createPlaylistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID)) + id, err := api.playlists.Create(r.Context(), "", body.Name, ids) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)}) +} + +// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means +// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear). +type updatePlaylistRequest struct { + Name *string `json:"Name"` + Ids *[]string `json:"Ids"` + IsPublic *bool `json:"IsPublic"` +} + +func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + var body updatePlaylistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // A present Ids replaces the track list. An empty list must clear it explicitly, since Create + // can't persist an empty track list (the repository skips track writes when the list is empty). + if body.Ids != nil { + if len(*body.Ids) == 0 { + if err := api.clearPlaylist(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + } else { + ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { + api.playlistError(w, r, err) + return + } + } + } + if body.Ids == nil || body.Name != nil || body.IsPublic != nil { + if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil { + api.playlistError(w, r, err) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership. +func (api *Router) clearPlaylist(ctx context.Context, id string) error { + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + return err + } + if len(pls.Tracks) == 0 { + return nil + } + entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID }) + return api.playlists.RemoveTracks(ctx, id, entryIDs) +} + +// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the +// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via +// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain +// individually removable. +func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { + item := dto.SongToBaseItem(t.MediaFile, fields) + item.PlaylistItemId = dto.EncodeID(t.ID) + return item +} + +// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the +// edit screen). GetWithTracks enforces visibility; any error maps to 404 so private playlists can't +// be probed. +func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + itemIds := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return dto.EncodeID(t.MediaFileID) }) + api.ok(w, r, dto.PlaylistInfo{ + OpenAccess: pls.Public, + Shares: []dto.PlaylistUserPermissions{}, + ItemIds: itemIds, + }) +} + +// getPlaylistItems relies on GetWithTracks to enforce visibility; any error maps to a generic 404 so +// a playlist id can't probe for private playlists. +func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)}) +} + +// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single +// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns +// the flattened, non-empty ids across both forms. +func queryIDs(r *http.Request, key string) []string { + var ids []string + for _, v := range r.URL.Query()[key] { + for id := range strings.SplitSeq(v, ",") { + if id != "" { + ids = append(ids, id) + } + } + } + return ids +} + +// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when +// building a playlist into their track ids, in order, since core/playlists only understands media +// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only +// the rest pays per-id container probes. +func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string { + songs := api.songsByIDs(ctx, ids) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if _, ok := songs[id]; ok { + out = append(out, id) // already a song + } else if _, err := api.ds.Album(ctx).Get(id); err == nil { + out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...) + } else if _, err := api.ds.Artist(ctx).Get(id); err == nil { + out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...) + } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { + out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) + } else { + out = append(out, id) // unknown id — pass through unchanged + } + } + return out +} + +func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string { + mfs, err := api.ds.MediaFile(ctx).GetAll(opts) + if err != nil { + log.Error(ctx, "Jellyfin: error expanding container to tracks", err) + return nil + } + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) +} + +// addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). +// AddTracks enforces ownership; any error maps to 404. +func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + ids := api.expandContainerIDs(ctx, slice.Map(queryIDs(r, "ids"), dto.DecodeID)) + if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media +// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces +// ownership; any error maps to 404. +func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + ids := slice.Map(queryIDs(r, "entryids"), dto.DecodeID) + if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// getPlaylistUsers and getPlaylistUser answer client probes (e.g. Finamp) made before allowing +// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still +// enforced by AddTracks/RemoveTracks. +func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: u.ID, CanEdit: true}}) +} + +func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { + userId := chi.URLParam(r, "userId") + api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true}) +} diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go new file mode 100644 index 000000000..804123003 --- /dev/null +++ b/server/jellyfin/playlists_test.go @@ -0,0 +1,424 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so +// unimplemented methods aren't needed here; only the ones this test exercises are overridden. +type fakePlaylists struct { + playlists.Playlists + + createdName string + createdIds []string + createErr error + + getPls *model.Playlist + getErr error + + getByIDPls *model.Playlist + getByIDErr error + + addPlaylistID string + addIds []string + addErr error + + removePlaylistID string + removeIds []string + removeErr error + + setImagePlaylistID string + setImageBytes []byte + setImageExt string + setImageErr error + + removeImagePlaylistID string + removeImageErr error + + deletePlaylistID string + deleteErr error +} + +func (f *fakePlaylists) Delete(_ context.Context, id string) error { + f.deletePlaylistID = id + return f.deleteErr +} + +func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) { + f.createdName = name + f.createdIds = ids + if f.createErr != nil { + return "", f.createErr + } + return "pl-new", nil +} + +// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real +// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't +// care about playlists leave it unconfigured. +func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) { + if f.getByIDErr != nil { + return nil, f.getByIDErr + } + if f.getByIDPls == nil { + return nil, model.ErrNotFound + } + return f.getByIDPls, nil +} + +func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound // mirror the real repo: never (nil, nil) + } + return f.getPls, nil +} + +func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { + f.addPlaylistID = playlistID + f.addIds = ids + return len(ids), f.addErr +} + +func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error { + f.removePlaylistID = playlistID + f.removeIds = trackIds + return f.removeErr +} + +func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error { + f.setImagePlaylistID = playlistID + f.setImageExt = ext + if reader != nil { + f.setImageBytes, _ = io.ReadAll(reader) + } + return f.setImageErr +} + +func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error { + f.removeImagePlaylistID = playlistID + return f.removeImageErr +} + +var _ = Describe("Playlists", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{} + api = &Router{ds: &tests.MockDataStore{}, playlists: fp} + }) + + Describe("createPlaylist", func() { + It("creates a playlist and returns its id", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res map[string]string + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new"))) + Expect(fp.createdName).To(Equal("Mix")) + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 400 on an invalid JSON body", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 500 when the service fails", func() { + fp.createErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("getPlaylistItems", func() { + It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(2)) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("container id expansion", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + api = &Router{ds: ds, playlists: fp} + }) + + createWith := func(id string) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + WithContext(ctx) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("passes a bare song id through unchanged", func() { + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}}) + createWith("s1") + Expect(fp.createdIds).To(Equal([]string{"s1"})) + }) + + It("expands an album id into its songs, filtered by album", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + }) + createWith("al1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters)) + }) + + It("expands an artist id into its songs", func() { + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}}) + createWith("ar1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters)) + }) + + It("expands a playlist id into its tracks' media file ids", func() { + fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"}, + }} + createWith("pl9") + Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + }) + }) + + Describe("getPlaylist", func() { + It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Public: true, + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistInfo + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.OpenAccess).To(BeTrue()) + Expect(res.Shares).To(BeEmpty()) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")})) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("deleteItem", func() { + deleteReq := func(id string) *http.Request { + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background()) + return withChiURLParam(r, "itemId", dto.EncodeID(id)) + } + + It("deletes the playlist and returns 204", func() { + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.deletePlaylistID).To(Equal("pl1")) + }) + + It("returns 403 when the user doesn't own the playlist", func() { + fp.deleteErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("returns 404 for a missing playlist or non-playlist id", func() { + fp.deleteErr = model.ErrNotFound + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("al1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 on an unexpected error", func() { + fp.deleteErr = errors.New("boom") + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("addToPlaylist", func() { + It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.addErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(BeEmpty()) + }) + }) + + Describe("removeFromPlaylist", func() { + It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.removeErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(BeEmpty()) + }) + }) + + Describe("getPlaylistUsers", func() { + It("returns the current user with CanEdit true", func() { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res []dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}})) + }) + }) + + Describe("getPlaylistUser", func() { + It("returns CanEdit true for the requested user", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background()) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("playlistId", "pl1") + rctx.URLParams.Add("userId", "u1") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + api.getPlaylistUser(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true})) + }) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go new file mode 100644 index 000000000..e3c9903a8 --- /dev/null +++ b/server/jellyfin/routing_test.go @@ -0,0 +1,56 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Real Jellyfin servers route path segments case-insensitively, but chi's default matching is +// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go +// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof +// that requests using non-canonical casing are still routed correctly, both when the router is used +// directly and when mounted under a parent (as it is in production via server.MountRouter). +var _ = Describe("Case-insensitive routing", func() { + var api *Router + + BeforeEach(func() { + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + }) + + It("serves a fully lowercase path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/system/info/public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a mixed/weird-case path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/SYSTEM/Info/PUBLIC", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a lowercase login path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/users/authenticatebyname", nil) + api.ServeHTTP(w, r) + // MockDataStore has no users, so authentication itself may fail downstream, but the + // route must be found (not a 404) to prove case-insensitive matching worked. + Expect(w.Code).ToNot(Equal(http.StatusNotFound)) + }) + + It("serves a lowercase path when mounted under a parent router, replicating production", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", api) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/jellyfin/system/info/public", nil) + parent.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go new file mode 100644 index 000000000..f462f283a --- /dev/null +++ b/server/jellyfin/sessions.go @@ -0,0 +1,115 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo +// fields Navidrome needs to keep its playback/scrobbling state in sync. +type playbackReport struct { + ItemId string `json:"ItemId"` + PositionTicks int64 `json:"PositionTicks"` + IsPaused bool `json:"IsPaused"` +} + +// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send +// it there) and is decoded here since it flows straight into scrobbler lookups by media file id. +// Finamp reports restored-queue playback with truncated ids, hence resolveItemID. +func (api *Router) decodeReport(r *http.Request) playbackReport { + var body playbackReport + _ = json.NewDecoder(r.Body).Decode(&body) + if body.ItemId == "" { + body.ItemId = r.URL.Query().Get("itemid") + } + body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId)) + return body +} + +// clientIdentity returns the scrobbler cache key/display name for the caller's +// player. Both are zero values if withPlayer could not resolve a player. +func clientIdentity(ctx context.Context) (id, name string) { + player, _ := request.PlayerFrom(ctx) + return player.ID, player.Client +} + +// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client starts an item. +// +// These Sessions endpoints report only the caller's own playback and never expose content, so unlike +// browse/stream they are intentionally not library-access-gated. +func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically +// (and on pause/resume/seek) while a client keeps playing an item. +func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + state := scrobbler.StatePlaying + if body.IsPaused { + state = scrobbler.StatePaused + } + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: state, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when playback ends. +// +// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch, +// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play +// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played. +func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StateStopped, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// postCapabilities acknowledges Jellyfin session-capability negotiation. +// Navidrome doesn't track per-session client capabilities, so this is a no-op. +func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go new file mode 100644 index 000000000..24f945efd --- /dev/null +++ b/server/jellyfin/sessions_test.go @@ -0,0 +1,216 @@ +package jellyfin + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring +// server/subsonic's fakePlayTracker. +type fakePlayTracker struct { + scrobbler.PlayTracker + reported []scrobbler.ReportPlaybackParams + submitted []scrobbler.Submission +} + +func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error { + f.reported = append(f.reported, p) + return nil +} + +func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error { + f.submitted = append(f.submitted, s...) + return nil +} + +// fakePlayers is a local double for core.Players, used to exercise withPlayer. +type fakePlayers struct { + core.Players + err error + registerCalls int + lastClient string + trc *model.Transcoding +} + +func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { + f.registerCalls++ + f.lastClient = client + if f.err != nil { + return nil, nil, f.err + } + return &model.Player{ID: id, Client: client}, f.trc, nil +} + +var _ = Describe("Sessions", func() { + var api *Router + var pt *fakePlayTracker + + authed := func(r *http.Request) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + pt = &fakePlayTracker{} + api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt} + }) + + Describe("reportPlaybackStart", func() { + It("reports playback start with the item id and position", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`))) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].ClientId).To(Equal("p1")) + Expect(pt.reported[0].ClientName).To(Equal("Finamp")) + }) + + It("falls back to the ItemId query param when the body has none", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil)) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s2")) + }) + }) + + Describe("reportPlaybackProgress", func() { + It("reports the playing state when not paused", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(2000))) + }) + + It("reports the paused state when IsPaused is true", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused)) + }) + }) + + Describe("reportPlaybackStopped", func() { + It("reports the stopped state and lets the scrobbler apply its play threshold", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + + invoke(api.reportPlaybackStopped, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) + // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides + // whether the play counts; we no longer force a Submit that would bypass it. + Expect(pt.reported[0].IgnoreScrobble).To(BeFalse()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) + + Describe("postCapabilities", func() { + It("returns 204 No Content and does not touch the scrobbler", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) + + api.postCapabilities(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(BeEmpty()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var fp *fakePlayers + + BeforeEach(func() { + fp = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: fp} + }) + + It("registers a player from the Emby device info and injects it into the context", func() { + var gotPlayer model.Player + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeTrue()) + Expect(gotPlayer.ID).To(Equal("dev1")) + Expect(gotPlayer.Client).To(Equal("Finamp")) + }) + + It("fails open (no player in context) when registration errors", func() { + fp.err = errors.New("boom") + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeFalse()) + }) + + // The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it + // carries no client/device info; registering it would create a junk player named " []". + It("skips registration when the request has no client or device info", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/socket?api_key=tok", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.registerCalls).To(Equal(0)) + }) +}) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go new file mode 100644 index 000000000..db3671fe4 --- /dev/null +++ b/server/jellyfin/similar.go @@ -0,0 +1,190 @@ +package jellyfin + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real +// result beats an instant empty list, which clients cache as "no similar items exist". A var so +// tests can shorten it. +var similarWait = 10 * time.Second + +const maxSimilarLimit = 100 + +// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine +// indefinitely. +const similarFetchTimeout = time.Minute + +// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the +// request times out or the client disconnects), waiting up to similarWait then answering empty. +// Identical concurrent requests share one fetch via singleflight; the key includes the user since +// mapped items embed that user's annotations. +func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult { + u, _ := request.UserFrom(ctx) + key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit) + ch := api.similarFlight.DoChan(key, func() (any, error) { + bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout) + defer cancel() + return fetch(bgCtx), nil + }) + select { + case res := <-ch: + return res.Val.(dto.QueryResult) + case <-time.After(similarWait): + return result(nil, 0, 0) + } +} + +// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same +// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are +// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. +func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarArtists(ctx, id, limit) + })) +} + +// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar +// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty +// result (not 404) so the client stops retrying. +func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult { + switch entity.(type) { + case *model.Artist: + return api.similarArtists(ctx, id, limit) + case *model.Album: + return api.similarAlbums(ctx, id, limit) + default: // *model.MediaFile + return api.similarSongs(ctx, id, limit) + } + })) +} + +// getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so +// a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty +// results, never a 404 the client would surface as an error. +func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + mf, isSong := entity.(*model.MediaFile) + if isSong { + if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) { + api.ok(w, r, result(nil, 0, 0)) + return + } + } + // Prefixed key: a mix must not share the singleflight/cache slot with a Similar request. + tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarSongs(ctx, id, limit) + }) + if !isSong { + // Container seeds: the provider's similar songs already blend the seed's own tracks. + api.ok(w, r, tail) + return + } + // The seed leads the mix and must not depend on the provider: a slow or failing provider times + // the await out with an empty tail, but the tapped track still plays. + items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)} + for _, it := range tail.Items { + if len(items) >= limit { + break + } + if it.Id != items[0].Id { + items = append(items, it) + } + } + api.ok(w, r, result(items, len(items), 0)) +} + +func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult { + artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err) + return result(nil, 0, 0) + } + present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) + items := slice.Map(present, dto.ArtistToBaseItem) + return result(items, len(items), 0) +} + +// clampLimit bounds a client-supplied limit so it can't drive an oversized allocation or provider +// fetch (flagged by CodeQL as a user-controlled allocation size). +func clampLimit(limit int) int { + if limit <= 0 { + return 20 + } + return min(limit, maxSimilarLimit) +} + +func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err) + return result(nil, 0, 0) + } + // Filter to the caller's libraries; the provider can return songs from any library. + u, _ := request.UserFrom(ctx) + var items []dto.BaseItemDto + for _, mf := range songs { + if u.HasLibraryAccess(mf.LibraryID) { + items = append(items, dto.SongToBaseItem(mf, nil)) + } + } + return result(items, len(items), 0) +} + +// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct +// "similar albums" source), keeping each album once in first-seen order and resolving it to a full +// model.Album for cover art and metadata. +func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit*5) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err) + return result(nil, 0, 0) + } + u, _ := request.UserFrom(ctx) + seen := make(map[string]bool, limit) + var items []dto.BaseItemDto + for _, s := range songs { + if s.AlbumID == "" || seen[s.AlbumID] { + continue + } + seen[s.AlbumID] = true + if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { + items = append(items, dto.AlbumToBaseItem(*al)) + if len(items) >= limit { + break + } + } + } + return result(items, len(items), 0) +} diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go new file mode 100644 index 000000000..cbb68e5cf --- /dev/null +++ b/server/jellyfin/similar_test.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("awaitSimilar", func() { + var api *Router + ctxFor := func(userID string) context.Context { + return request.WithUser(context.Background(), model.User{ID: userID}) + } + shortenWait := func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + } + + BeforeEach(func() { + api = &Router{} + }) + + It("returns the fetch result when it completes within the wait", func() { + res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) + }) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("fast")) + }) + + It("returns an empty result when the fetch exceeds the wait", func() { + shortenWait() + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + <-release // hung provider; would finish caching in the background + return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) + }) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + + It("dedupes requests into the in-flight fetch", func() { + shortenWait() + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + <-release + return result(nil, 0, 0) + } + // Both calls time out, but the flight can't complete before release closes, so the + // second call must join it rather than start a new fetch. + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + close(release) + Eventually(calls.Load).Should(Equal(int32(1))) + Consistently(calls.Load, "50ms").Should(Equal(int32(1))) + }) + + It("does not share fetches across users (items embed the user's annotations)", func() { + var calls atomic.Int32 + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + return result(nil, 0, 0) + } + api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) + api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + Expect(calls.Load()).To(Equal(int32(2))) + }) + + It("hands the fetch a deadline-bounded background context", func() { + var deadline time.Time + var hasDeadline bool + api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult { + deadline, hasDeadline = ctx.Deadline() + return result(nil, 0, 0) + }) + Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever") + Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout)) + }) +}) + +// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent. +type blockingProvider struct { + external.Provider + release chan struct{} +} + +func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + <-p.release + return nil, nil +} + +var _ = Describe("getInstantMix", func() { + It("returns the seed track even when the provider fetch exceeds the wait", func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Seed Song", LibraryID: 1}, + }) + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + api := &Router{ds: ds, provider: &blockingProvider{release: release}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("Seed Song")) + }) +}) diff --git a/server/jellyfin/socket.go b/server/jellyfin/socket.go new file mode 100644 index 000000000..80a49cd4c --- /dev/null +++ b/server/jellyfin/socket.go @@ -0,0 +1,55 @@ +package jellyfin + +import ( + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/log" +) + +// socketKeepAliveInterval (seconds) is sent in the initial ForceKeepAlive telling the client how +// often to send KeepAlive, and bounds the local read deadline. +const socketKeepAliveInterval = 60 + +// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed +// KeepAlive doesn't drop the connection. +const socketReadTimeout = 90 * time.Second + +var socketUpgrader = websocket.Upgrader{ + // Jellyfin clients aren't browsers, so there's no cross-origin risk; the connection is + // already authenticated via api_key. + CheckOrigin: func(*http.Request) bool { return true }, +} + +// handleSocket implements Jellyfin's /socket WebSocket endpoint. Finamp opens it right after login +// and 404-loop-reconnects without it. Minimal: keeps the connection alive and answers KeepAlive +// pings, with no session/playstate push. +func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) { + conn, err := socketUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err) + return + } + defer conn.Close() + + if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err) + return + } + + for { + _ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout)) + var msg struct { + MessageType string `json:"MessageType"` + } + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.MessageType == "KeepAlive" { + if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil { + return + } + } + } +} diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go new file mode 100644 index 000000000..91542c111 --- /dev/null +++ b/server/jellyfin/socket_test.go @@ -0,0 +1,125 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("handleSocket", func() { + var api *Router + + BeforeEach(func() { + api = &Router{} + }) + + // Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without + // a working handshake here they 404-loop-reconnect instead of settling into a session. + It("upgrades the connection and sends ForceKeepAlive", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + Expect(msg["Data"]).To(BeNumerically("==", 60)) + }) + + It("replies to a KeepAlive message with a KeepAlive of its own", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive")) + + Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var reply map[string]any + Expect(conn.ReadJSON(&reply)).To(Succeed()) + Expect(reply["MessageType"]).To(Equal("KeepAlive")) + }) + + It("closes the connection when the client disconnects, without leaving the handler hanging", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + + Expect(conn.Close()).To(Succeed()) + }) + + // End-to-end: proves /socket is reachable through the full router (case-insensitive + // wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp + // hits it in production with ?api_key=<jwt>. + Context("mounted behind the full router and auth middleware", func() { + var ds *tests.MockDataStore + var token string + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + token = t + + api = New(ds, nil, nil, nil, nil, nil, nil, nil) + }) + + It("upgrades when authenticated via the api_key query parameter", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + }) + + It("rejects the upgrade with no api_key", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go new file mode 100644 index 000000000..28411f9d1 --- /dev/null +++ b/server/jellyfin/stream.go @@ -0,0 +1,164 @@ +package jellyfin + +import ( + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its +// library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. +// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. +func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + u, _ := request.UserFrom(ctx) + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return mf, true +} + +// getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct +// playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). +func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + src := dto.MediaSourceFromMediaFile(*mf) + // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl + // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients + // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so + // a client resolving it as an absolute host path still hits the mounted router. + if token := tokenFromRequest(r); token != "" { + src.TranscodingSubProtocol = "http" + src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + } + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) +} + +// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, +// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + p := req.Params(r) + + format := p.StringOr("container", "") + if format == "" { + // The /stream.{container} route form carries the format as a path segment, not a query param. + format = chi.URLParam(r, "container") + } + if format == "" { + // Jellyfin's audioCodec param names the target codec when no container is given. + format = p.StringOr("audiocodec", "") + } + if p.BoolOr("static", false) { + format = "raw" + } + + // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. + bitRate := p.IntOr("audiobitrate", 0) / 1000 + if bitRate == 0 { + bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 + } + + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} + +// streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD +// playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. +// Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. +func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + + // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced + // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. + codec := strings.ToLower(p.StringOr("audiocodec", "")) + if codec != "mp3" { + codec = "aac" + } + if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { + codec = strings.ToLower(trc.TargetFormat) + } + + // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. + segment := "stream." + codec + q := url.Values{} + if token := tokenFromRequest(r); token != "" { + q.Set("api_key", token) + } + if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { + q.Set("audioBitRate", strconv.Itoa(bitRate)) + } + if len(q) > 0 { + segment += "?" + q.Encode() + } + + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + //nolint:gosec // not HTML; the only tainted value is query-escaped + fmt.Fprintf(w, "#EXTM3U\n"+ + "#EXT-X-VERSION:3\n"+ + "#EXT-X-PLAYLIST-TYPE:VOD\n"+ + "#EXT-X-TARGETDURATION:%d\n"+ + "#EXT-X-MEDIA-SEQUENCE:0\n"+ + "#EXTINF:%.3f,\n"+ + "%s\n"+ + "#EXT-X-ENDLIST\n", + int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) +} + +// streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some +// clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so +// it must always resolve to direct play ("raw"), never a forced transcode. +func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go new file mode 100644 index 000000000..7063d321a --- /dev/null +++ b/server/jellyfin/stream_test.go @@ -0,0 +1,316 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Stream", func() { + var api *Router + var ds *tests.MockDataStore + var streamer *fakeMediaStreamer + var decider *fakeTranscodeDecider + + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + streamer = &fakeMediaStreamer{} + decider = &fakeTranscodeDecider{} + api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + }) + + Describe("getPlaybackInfo", func() { + It("returns a media source for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.MediaSources).To(HaveLen(1)) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.MediaSources[0].Container).To(Equal("mp3")) + Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) + Expect(res.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamAudio", func() { + It("invokes the transcode decider and streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("converts the bps audioBitRate param to kbps", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.BitRate).To(Equal(320)) + }) + + It("uses the audioCodec param as target format when no container is given", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.Format).To(Equal("aac")) + }) + + It("returns 500 and logs when the streamer fails", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.err = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("streamHls", func() { + BeforeEach(func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, + }) + }) + + hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamHls, w, r) + return w + } + + It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() { + w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser()) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n")) + Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n")) + Expect(body).To(ContainSubstring("#EXTINF:100.500,\n")) + Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + }) + + It("omits the bitrate param when the client doesn't send one", func() { + w := hls("?audiocodec=aac&api_key=tok", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n")) + }) + + It("falls back to aac for codecs HLS packed-audio can't carry", func() { + w := hls("?audiocodec=opus", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n")) + }) + + It("honors mp3 as segment codec", func() { + w := hls("?audiocodec=mp3", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("prefers the server-forced transcoding format over the requested codec", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n")) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2}, + }) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamFile", func() { + It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(decider.req.Format).To(Equal("raw")) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + }) +}) + +// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether +// (and how) ResolveRequest was invoked, so tests can assert it's never called on the +// access-denied path, without needing a real transcode decision pipeline. +type fakeTranscodeDecider struct { + invoked bool + req stream.Request +} + +func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { + return &stream.TranscodeDecision{}, nil +} + +func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) { + return "", nil +} + +func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) { + return stream.Request{}, nil +} + +func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + f.invoked = true + f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset} + return f.req +} + +// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether +// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed +// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. +type fakeMediaStreamer struct { + invoked bool + content string + err error +} + +func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) { + f.invoked = true + if f.err != nil { + return nil, f.err + } + return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil +} diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go new file mode 100644 index 000000000..baa31fdeb --- /dev/null +++ b/server/jellyfin/system.go @@ -0,0 +1,96 @@ +package jellyfin + +import ( + "context" + "errors" + "fmt" + "net/http" + "path" + + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate +// on it, so it must stay a real Jellyfin release, not Navidrome's own version. +const jellyfinVersion = "10.8.13" + +func (api *Router) serverName() string { + if conf.Server.Jellyfin.ServerName != "" { + return conf.Server.Jellyfin.ServerName + } + return fmt.Sprintf("Navidrome %s", consts.Version) +} + +// serverID returns a stable Id that survives restarts, get-or-created in the Property table. +// Jellyfin clients cache ServerId across sessions, so a per-process value would break +// re-authentication. api.ds is nil only in unit tests; New() always sets it. +// +// The mutex serializes first-boot resolution so concurrent requests can't persist different +// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a +// temporary id and retries on the next request rather than pinning a value. +func (api *Router) serverID(ctx context.Context) string { + api.serverIDMu.Lock() + defer api.serverIDMu.Unlock() + if api.serverIDVal != "" { + return api.serverIDVal + } + if api.ds == nil { + api.serverIDVal = uuid.NewString() + return api.serverIDVal + } + id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + switch { + case errors.Is(err, model.ErrNotFound): + id = uuid.NewString() + if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + log.Error(ctx, "Jellyfin API: could not persist server id", err) + return id + } + case err != nil: + log.Error(ctx, "Jellyfin API: could not read server id", err) + return uuid.NewString() + } + api.serverIDVal = id + return api.serverIDVal +} + +func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { + return dto.PublicSystemInfo{ + LocalAddress: localAddress(r), + ServerName: api.serverName(), + Version: jellyfinVersion, + ProductName: "Jellyfin Server", + Id: api.serverID(r.Context()), + StartupWizardCompleted: true, + } +} + +// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring +// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as +// its server base URL; without it its SDK api instance is undefined and sign-in crashes. +func localAddress(r *http.Request) string { + scheme, host := server.ServerAddress(r) + return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI) +} + +func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, api.publicInfo(r)) +} + +// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's +// server does this and clients parse the raw body. +func (api *Router) ping(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(api.serverName())) +} + +func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, false) +} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go new file mode 100644 index 000000000..5ea525100 --- /dev/null +++ b/server/jellyfin/system_test.go @@ -0,0 +1,124 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + var api *Router + BeforeEach(func() { api = &Router{} }) + + It("returns public system info without auth", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.getPublicSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.Id).ToNot(BeEmpty()) + Expect(info.Version).To(Equal(jellyfinVersion)) + Expect(info.ProductName).To(Equal("Jellyfin Server")) + Expect(info.ServerName).To(HavePrefix("Navidrome")) + }) + + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { + DeferCleanup(configtest.SetupConfig()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + r.Host = "music.example.com:4599" + api.getPublicSystemInfo(w, r) + + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + // Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the + // SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path. + Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin")) + }) + + It("responds to ping with the server name as plain text", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Ping", nil) + api.ping(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain")) + // Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name. + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("reports quick connect as disabled", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) + api.quickConnectEnabled(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var enabled bool + Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed()) + Expect(enabled).To(BeFalse()) + }) + + Context("serverID with a real DataStore", func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() { + first := &Router{ds: ds} + id := first.serverID(ctx) + Expect(id).ToNot(BeEmpty()) + + second := &Router{ds: ds} + Expect(second.serverID(ctx)).To(Equal(id)) + }) + + It("memoizes the id across repeated calls on the same Router", func() { + r := &Router{ds: ds} + id := r.serverID(ctx) + Expect(r.serverID(ctx)).To(Equal(id)) + Expect(r.serverID(ctx)).To(Equal(id)) + }) + + It("does not overwrite or pin over a stored id when the property read fails transiently", func() { + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed()) + + r := &Router{ds: ds} + props := ds.Property(ctx).(*tests.MockedPropertyRepo) + props.Error = errors.New("database is locked") + degraded := r.serverID(ctx) + Expect(degraded).ToNot(BeEmpty()) + Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one + props.Error = nil + + // Once the DB recovers, the stored id is intact and served again. + Expect(r.serverID(ctx)).To(Equal("stable-id")) + stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal("stable-id")) + }) + }) +}) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go new file mode 100644 index 000000000..b58296391 --- /dev/null +++ b/server/jellyfin/truncated_ids.go @@ -0,0 +1,113 @@ +package jellyfin + +import ( + "context" + "slices" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming +// Jellyfin GUIDs). No Navidrome id family is 16 chars (nanoid=22, legacy MD5=32, playlist +// UUID=36), so the length alone identifies a truncated id. See README. +// +// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers +// and ParentId scoping don't (a restored queue never edits playlists or browses by container id). +const truncatedIDLen = 16 + +// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is +// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous. +func (api *Router) resolveItemID(ctx context.Context, id string) string { + if len(id) != truncatedIDLen { + return id + } + probes := []func() []string{ + func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) }, + func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) }, + func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) }, + func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) }, + } + for _, probe := range probes { + switch ids := probe(); len(ids) { + case 0: + continue + case 1: + log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0]) + return ids[0] + default: + log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id) + return id + } + } + return id +} + +// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of +// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the +// leftovers (containers, unknowns) fall back to the per-id probes. +func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string { + var truncated []string + for _, id := range ids { + if len(id) == truncatedIDLen { + truncated = append(truncated, id) + } + } + if len(truncated) == 0 { + return ids + } + + byPrefix := make(map[string][]string, len(truncated)) + for chunk := range slice.CollectChunks(slices.Values(truncated), 100) { + ranges := make(squirrel.Or, len(chunk)) + for i, p := range chunk { + ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}} + } + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges}) + if err != nil { + log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err) + break + } + for _, mf := range mfs { + p := mf.ID[:truncatedIDLen] + byPrefix[p] = append(byPrefix[p], mf.ID) + } + } + + out := make([]string, len(ids)) + for i, id := range ids { + switch full := byPrefix[id]; { + case len(full) == 1: + out[i] = full[0] + case len(id) == truncatedIDLen: + out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide + default: + out[i] = id + } + } + return out +} + +// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to +// detect ambiguity). '\x7f' is above every character the id alphabets use. +func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string { + rows, err := getAll(model.QueryOptions{ + Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}}, + Max: 2, + }) + if err != nil { + return nil + } + ids := make([]string, len(rows)) + for i, row := range rows { + ids[i] = id(row) + } + return ids +} + +func mediaFileID(mf model.MediaFile) string { return mf.ID } +func albumID(al model.Album) string { return al.ID } +func artistID(ar model.Artist) string { return ar.ID } +func playlistID(pl model.Playlist) string { return pl.ID } diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go new file mode 100644 index 000000000..5f231e0b5 --- /dev/null +++ b/server/jellyfin/users.go @@ -0,0 +1,61 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// getUserViews returns one CollectionFolder view per accessible library, so clients browse each +// library as its own top-level view rather than one aggregate. +func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + views := make([]dto.BaseItemDto, 0, len(u.Libraries)) + for _, lib := range u.Libraries { + views = append(views, libraryView(lib)) + } + api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) +} + +func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx))) +} + +// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login +// picker. The route is unauthenticated, so it lists only the configured allowlist (never the full +// user table) and returns a minimal DTO — no Policy/Configuration, which would leak admin status. +func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + serverID := api.serverID(ctx) + seen := make(map[string]bool) + users := []dto.UserDto{} + for name := range strings.SplitSeq(conf.Server.Jellyfin.ExposedPublicUsers, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + usr, err := api.ds.User(ctx).FindByUsername(name) + if err != nil { + log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) + continue + } + users = append(users, dto.UserDto{ + Name: usr.UserName, + Id: usr.ID, + ServerId: serverID, + HasPassword: true, + }) + } + api.ok(w, r, users) +} diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go new file mode 100644 index 000000000..2b7177044 --- /dev/null +++ b/server/jellyfin/users_test.go @@ -0,0 +1,130 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Users", func() { + var api *Router + authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + return r.WithContext(ctx) + } + BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) + + Describe("getUserViews", func() { + It("returns one view per accessible library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Podcasts"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(2)) + + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Name).To(Equal("Music")) + Expect(res.Items[0].Type).To(Equal("CollectionFolder")) + Expect(res.Items[0].CollectionType).To(Equal("music")) + Expect(res.Items[0].IsFolder).To(BeTrue()) + + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[1].Name).To(Equal("Podcasts")) + }) + + It("returns a single view for a user with one library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + }) + + It("returns no views for a user with no library access", func() { + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(0)) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + }) + + It("returns the current user", func() { + w := httptest.NewRecorder() + api.getCurrentUser(w, authedWithLibraries(httptest.NewRequest("GET", "/Users/Me", nil), nil)) + var u dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed()) + Expect(u.Name).To(Equal("alice")) + Expect(u.Policy).ToNot(BeNil()) + Expect(u.Policy.IsAdministrator).To(BeFalse()) + Expect(u.Configuration).ToNot(BeNil()) + }) + + Describe("getPublicUsers", func() { + var ur *tests.MockedUserRepo + publicUsers := func() []dto.UserDto { + w := httptest.NewRecorder() + api.getPublicUsers(w, httptest.NewRequest("GET", "/Users/Public", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &users)).To(Succeed()) + return users + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed()) + }) + + It("returns an empty list when the config is unset", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users in order, without leaking policy", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "bob, alice" + users := publicUsers() + Expect(users).To(HaveLen(2)) + Expect(users[0].Name).To(Equal("bob")) + Expect(users[0].Id).To(Equal("u2")) + Expect(users[1].Name).To(Equal("alice")) + // The public list must not expose Policy/Configuration to unauthenticated callers. + Expect(users[0].Policy).To(BeNil()) + Expect(users[0].Configuration).To(BeNil()) + }) + + It("skips a configured username that does not exist", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "alice,ghost" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + + It("matches usernames case-insensitively and de-duplicates", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "ALICE, alice" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + }) +}) diff --git a/server/middlewares.go b/server/middlewares.go index 5d6a1e59c..23e11eaa6 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -202,10 +202,10 @@ func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.H func serverAddressMiddleware(h http.Handler) http.Handler { // Define a new handler function that will be returned by this middleware function. fn := func(w http.ResponseWriter, r *http.Request) { - // Call the serverAddress function to get the scheme and host of the server + // Call the ServerAddress function to get the scheme and host of the server // handling the request. If a host is found, modify the request object to use // that host and scheme instead of the original ones. - if rScheme, rHost := serverAddress(r); rHost != "" { + if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme } @@ -225,10 +225,10 @@ var ( xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") ) -// serverAddress is a helper function that returns the scheme and host of the server +// ServerAddress is a helper function that returns the scheme and host of the server // handling the given request, as determined by the presence of X-Forwarded-* headers // or the scheme and host of the request URL. -func serverAddress(r *http.Request) (scheme, host string) { +func ServerAddress(r *http.Request) (scheme, host string) { // Save the original request host for later comparison. origHost := r.Host diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 5e2d29876..077eac35e 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,23 +13,14 @@ import ( "path/filepath" "strings" - "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -func maxImageUploadSize() int64 { - if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { - return int64(size) - } - size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) - return int64(size) -} - func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) if !conf.Server.EnableArtworkUpload && !user.IsAdmin { @@ -40,7 +31,7 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { - maxImageSize := maxImageUploadSize() + maxImageSize := core.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go deleted file mode 100644 index 291912e67..000000000 --- a/server/nativeapi/image_upload_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package nativeapi - -import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("maxImageUploadSize", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("returns the configured size when valid", func() { - conf.Server.MaxImageUploadSize = "20MB" - Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) - }) - - It("returns the default size when config is empty", func() { - conf.Server.MaxImageUploadSize = "" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("returns the default size when config is invalid", func() { - conf.Server.MaxImageUploadSize = "not-a-size" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("parses raw byte values", func() { - conf.Server.MaxImageUploadSize = "52428800" - Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) - }) -}) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 24bbca960..041a3b8f2 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/run" diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 817238aaf..f6a7047c4 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" diff --git a/server/e2e/doc.go b/server/subsonic/e2e/doc.go similarity index 98% rename from server/e2e/doc.go rename to server/subsonic/e2e/doc.go index 51ee6f047..9435d1f60 100644 --- a/server/e2e/doc.go +++ b/server/subsonic/e2e/doc.go @@ -103,7 +103,7 @@ // // The e2e tests are included in the standard test suite and can be run with: // -// make test PKG=./server/e2e # Run only e2e tests +// make test PKG=./server/subsonic/e2e # Run only e2e tests // make test # Run all tests including e2e // make test-race # Run with race detector // diff --git a/server/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go similarity index 75% rename from server/e2e/e2e_suite_test.go rename to server/subsonic/e2e/e2e_suite_test.go index ac4aaa5f2..6875b6370 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -4,14 +4,12 @@ import ( "bytes" "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" - "strings" "testing" "testing/fstest" "time" @@ -22,7 +20,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" @@ -40,6 +37,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,13 +87,10 @@ var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router - streamerSpy *spyStreamer + streamerSpy *harness.SpyStreamer + goldenDB *harness.DB lib model.Library - // Snapshot paths for fast DB restore - dbFilePath string - snapshotPath string - // Admin user used for most tests adminUser = model.User{ ID: "admin-1", @@ -113,13 +108,6 @@ var ( } ) -func createFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - // buildTestFS creates the full test filesystem matching the plan func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{ @@ -145,7 +133,7 @@ func buildTestFS() storagetest.FakeFS { // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) - return createFS(fstest.MapFS{ + return harness.CreateFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). @@ -331,61 +319,6 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// spyStreamer captures the Request passed to NewStream for test assertions, -// then returns a minimal fake Stream so the handler completes without error. -type spyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile - SimulateError error // When set, NewStream returns this error - SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) -} - -func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - s.LastRequest = req - s.LastMediaFile = mf - if s.SimulateError != nil { - return nil, s.SimulateError - } - format := req.Format - if format == "" || format == "raw" { - format = mf.Suffix - } - content := "fake audio data" - if s.SimulateEmptyStream { - content = "" - } - r := io.NopCloser(strings.NewReader(content)) - return stream.NewStream(mf, format, req.BitRate, r), nil -} - -// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. -type noopFFmpeg struct{} - -func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: transcode not supported") -} - -func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: extract image not supported") -} - -func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { - return "", nil -} - -func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - return nil, errors.New("noop ffmpeg: probe not supported") -} - -func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: convert animated image not supported") -} - -func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } -func (n noopFFmpeg) IsAvailable() bool { return false } -func (n noopFFmpeg) IsProbeAvailable() bool { return true } -func (n noopFFmpeg) Version() string { return "noop" } - // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -434,67 +367,22 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} ) var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) - tmpDir := GinkgoT().TempDir() - dbFilePath = filepath.Join(tmpDir, "test-e2e.db") - snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") - conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" - db.Db().SetMaxOpenConns(1) - // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false - db.Init(ctx) - - initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(initDS) - - adminUserWithPass := adminUser - adminUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) - - regularUserWithPass := regularUser - regularUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) - - lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - - Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) - Expect(err).ToNot(HaveOccurred()) - adminUser.Libraries = loadedUser.Libraries - - loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName) - Expect(err).ToNot(HaveOccurred()) - regularUser.Libraries = loadedRegular.Libraries - - ctx = request.WithUser(GinkgoT().Context(), adminUser) - buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err = s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Checkpoint WAL and snapshot the golden DB state - _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") - Expect(err).ToNot(HaveOccurred()) - data, err := os.ReadFile(dbFilePath) - Expect(err).ToNot(HaveOccurred()) - Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + lib = goldenDB.Library + ctx = request.WithUser(GinkgoT().Context(), adminUser) }) // Close the database before the suite's TempDir cleanup runs. Required on @@ -520,14 +408,14 @@ func setupTestDB() { conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) - restoreDB() + goldenDB.Restore() ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) // Create the Subsonic Router with real DS, streamer spy, and real Decider - streamerSpy = &spyStreamer{} - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + streamerSpy = &harness.SpyStreamer{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( @@ -549,39 +437,3 @@ func setupTestDB() { nil, ) } - -// restoreDB restores all table data from the snapshot using ATTACH DATABASE. -// This is much faster than re-running the scanner for each test. -func restoreDB() { - sqlDB := db.Db() - - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) - Expect(err).ToNot(HaveOccurred()) - - rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") - Expect(err).ToNot(HaveOccurred()) - var tables []string - for rows.Next() { - var name string - Expect(rows.Scan(&name)).To(Succeed()) - tables = append(tables, name) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - rows.Close() - - for _, table := range tables { - // Table names come from sqlite_master, not user input, so concatenation is safe here - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - _, err = sqlDB.Exec("DETACH DATABASE snapshot") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/subsonic/e2e/subsonic_album_lists_test.go similarity index 100% rename from server/e2e/subsonic_album_lists_test.go rename to server/subsonic/e2e/subsonic_album_lists_test.go diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/subsonic/e2e/subsonic_bookmarks_test.go similarity index 100% rename from server/e2e/subsonic_bookmarks_test.go rename to server/subsonic/e2e/subsonic_bookmarks_test.go diff --git a/server/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go similarity index 100% rename from server/e2e/subsonic_browsing_test.go rename to server/subsonic/e2e/subsonic_browsing_test.go diff --git a/server/e2e/subsonic_lyrics_test.go b/server/subsonic/e2e/subsonic_lyrics_test.go similarity index 100% rename from server/e2e/subsonic_lyrics_test.go rename to server/subsonic/e2e/subsonic_lyrics_test.go diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go similarity index 100% rename from server/e2e/subsonic_media_annotation_test.go rename to server/subsonic/e2e/subsonic_media_annotation_test.go diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go similarity index 100% rename from server/e2e/subsonic_media_retrieval_test.go rename to server/subsonic/e2e/subsonic_media_retrieval_test.go diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go similarity index 100% rename from server/e2e/subsonic_multilibrary_test.go rename to server/subsonic/e2e/subsonic_multilibrary_test.go diff --git a/server/e2e/subsonic_multiuser_test.go b/server/subsonic/e2e/subsonic_multiuser_test.go similarity index 100% rename from server/e2e/subsonic_multiuser_test.go rename to server/subsonic/e2e/subsonic_multiuser_test.go diff --git a/server/e2e/subsonic_playlists_test.go b/server/subsonic/e2e/subsonic_playlists_test.go similarity index 100% rename from server/e2e/subsonic_playlists_test.go rename to server/subsonic/e2e/subsonic_playlists_test.go diff --git a/server/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go similarity index 100% rename from server/e2e/subsonic_radio_test.go rename to server/subsonic/e2e/subsonic_radio_test.go diff --git a/server/e2e/subsonic_scan_test.go b/server/subsonic/e2e/subsonic_scan_test.go similarity index 100% rename from server/e2e/subsonic_scan_test.go rename to server/subsonic/e2e/subsonic_scan_test.go diff --git a/server/e2e/subsonic_searching_test.go b/server/subsonic/e2e/subsonic_searching_test.go similarity index 100% rename from server/e2e/subsonic_searching_test.go rename to server/subsonic/e2e/subsonic_searching_test.go diff --git a/server/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go similarity index 100% rename from server/e2e/subsonic_sharing_test.go rename to server/subsonic/e2e/subsonic_sharing_test.go diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go similarity index 98% rename from server/e2e/subsonic_sonic_similarity_test.go rename to server/subsonic/e2e/subsonic_sonic_similarity_test.go index 1b8d34eb1..775fefe89 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -32,11 +33,11 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { loader := &mockSonicPluginLoader{provider: provider} m := matcher.New(ds) sonicSvc := sonic.New(ds, loader, m) - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) return subsonic.New( ds, noopArtwork{}, - &spyStreamer{}, + &harness.SpyStreamer{}, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, diff --git a/server/e2e/subsonic_stream_test.go b/server/subsonic/e2e/subsonic_stream_test.go similarity index 100% rename from server/e2e/subsonic_stream_test.go rename to server/subsonic/e2e/subsonic_stream_test.go diff --git a/server/e2e/subsonic_system_test.go b/server/subsonic/e2e/subsonic_system_test.go similarity index 100% rename from server/e2e/subsonic_system_test.go rename to server/subsonic/e2e/subsonic_system_test.go diff --git a/server/e2e/subsonic_transcode_test.go b/server/subsonic/e2e/subsonic_transcode_test.go similarity index 100% rename from server/e2e/subsonic_transcode_test.go rename to server/subsonic/e2e/subsonic_transcode_test.go diff --git a/server/e2e/subsonic_users_test.go b/server/subsonic/e2e/subsonic_users_test.go similarity index 100% rename from server/e2e/subsonic_users_test.go rename to server/subsonic/e2e/subsonic_users_test.go diff --git a/tests/harness/harness.go b/tests/harness/harness.go new file mode 100644 index 000000000..ff5ce8919 --- /dev/null +++ b/tests/harness/harness.go @@ -0,0 +1,178 @@ +// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and +// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, +// and service doubles. Like core/storage/storagetest, it must only be imported from test code. +package harness + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" //nolint:staticcheck +) + +// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. +type DB struct { + FilePath string + SnapshotPath string + Library model.Library +} + +// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. +func CreateFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + +// SetupDB boots the golden database: a temp SQLite file, the given users (password "password", +// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a +// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS +// first; each user's Libraries field is populated in place. +func SetupDB(ctx context.Context, users ...*model.User) *DB { + tmpDir := ginkgo.GinkgoT().TempDir() + h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} + h.SnapshotPath = h.FilePath + ".snapshot" + conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + db.Init(ctx) + + ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&h.Library)).To(Succeed()) + + for _, u := range users { + seeded := *u + seeded.NewPassword = "password" + Expect(ds.User(ctx).Put(&seeded)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(u.ID, []int{h.Library.ID})).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername(u.UserName) + Expect(err).ToNot(HaveOccurred()) + u.Libraries = loaded.Libraries + } + + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(h.FilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir + return h +} + +// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a +// rescan. FTS shadow tables are skipped; they are kept in sync by their content tables' triggers. +func (h *DB) Restore() { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input. + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. +type SpyStreamer struct { + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // when set, NewStream returns this error + SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) +} + +func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { + s.LastRequest = req + s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } + format := req.Format + if format == "" || format == "raw" { + format = mf.Suffix + } + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil +} + +// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. +type NoopFFmpeg struct{} + +func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil } + +func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: convert animated image not supported") +} + +func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } +func (NoopFFmpeg) IsAvailable() bool { return false } +func (NoopFFmpeg) IsProbeAvailable() bool { return true } +func (NoopFFmpeg) Version() string { return "noop" } + +var ( + _ stream.MediaStreamer = &SpyStreamer{} + _ ffmpeg.FFmpeg = NoopFFmpeg{} +) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 3428813f6..85765abf8 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -174,6 +174,9 @@ func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { if m.Err { return errors.New("unexpected error") } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } return nil } @@ -182,6 +185,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { if m.Err { return errors.New("unexpected error") } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } return nil } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b7a6fb811..748002882 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -73,6 +73,28 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockArtistRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockArtistRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] @@ -145,6 +167,13 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } +func (m *MockArtistRepo) CountAll(...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f15ba1bc6..6ddd77f14 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -154,6 +154,28 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockMediaFileRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockMediaFileRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index b7df5361f..908d6aab5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -21,6 +21,7 @@ type MockPlaylistRepo struct { Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path All model.Playlists + Options model.QueryOptions Last *model.Playlist Deleted []string Starred map[string]bool // itemID -> starred @@ -33,14 +34,24 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } -func (m *MockPlaylistRepo) SetData(pls model.Playlists) { - m.Data = make(map[string]*model.Playlist, len(pls)) - m.All = pls +func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(playlists)) + m.All = playlists for i, p := range m.All { m.Data[p.ID] = &m.All[i] } } +func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -57,13 +68,6 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { - if m.Err { - return nil, errors.New("error") - } - return m.All, nil -} - func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") From 53d54baef04254c6243b5dd842db239fd0378cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 15 Jul 2026 14:07:00 -0400 Subject: [PATCH 364/596] fix(jellyfin): stream collection responses to prevent OOM on large libraries (#5783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): stream /Items responses to prevent OOM on large libraries Finamp's library sync issues an unbounded GET /Items?IncludeItemTypes=Audio with Fields=MediaSources and no Limit. On a large library this built the whole result set — every MediaFile and every BaseItemDto, each fat with MediaSources — in memory, and json.Encoder then buffered the entire ~200MB response before writing a byte. Measured on a 96k-track library, one such request peaked near 1.6GB RSS; in a memory-limited container the resulting slowness made clients retry, stacking concurrent full-library builds until the process was OOM-killed. Stream the song listing straight from a DB cursor (MediaFileRepository.GetCursor), mapping and encoding one row at a time, so peak memory is bounded to about one item regardless of library size. The cursor is opened lazily, when streaming begins, so it doesn't hold a DB connection across the CountAll and ServerId lookups that run first (ServerId can write on first use and would deadlock against an open reader). Pagination still works — the cursor query carries LIMIT/OFFSET/ORDER BY from StartIndex/Limit/SortBy — and TotalRecordCount stays the full count. Other materialized responses stream their JSON too, avoiding the encoder's buffer-the-whole-output cost. Also bound artwork image concurrency with the same server.ThrottleBacklog that Subsonic's getCoverArt uses, so a burst of image requests during sync can't exhaust memory through concurrent decode/resize. Verified against a copy of a 96k-track production database: byte-for-byte identical response, peak RSS 1593MB -> 53MB, time-to-first-byte 8.1s -> 0.2s. * fix(jellyfin): fail loudly on /Items streaming errors instead of a truncated 200 Addresses code review: a streamed /Items response commits HTTP 200 before an error can occur, so failures were surfacing as misleadingly successful bodies. - A cursor-open failure (e.g. a busy DB under connection pressure) is now surfaced before the first byte is written: the cursor open is deferred into the item source and run in writeItems after the ServerId lookup but before the envelope, returning a clean 500 the client can retry — not a 200 with an empty item list. - A mid-stream (row-scan) error now aborts without closing the JSON envelope, leaving the body malformed. A truncated-but-valid response would let a sync client (Finamp) treat the short list as the whole library and prune local tracks; malformed JSON forces the client's parser to fail and retry. * refactor(jellyfin): stream every collection endpoint from a DB cursor Streaming was applied only to the song listing, which left two write paths, two ServerId stamping mechanisms and a listXxx return-type split ("songs is special") that made the code hard to follow. Add GetCursor to the album, artist, genre and playlist repositories, mirroring MediaFileRepository.GetCursor: same select builder as GetAll, so each cursor yields identical, fully-hydrated rows (hydration happens per-row in PostScan, and toModels is only a deref loop). The playlist cursor keeps GetAll's owner/public visibility filter. Each repository test asserts the cursor yields exactly what GetAll returns, including Max/Offset. With cursors everywhere, every listXxx returns itemsResult and every collection streams through one writer: - listAlbums, listArtists and listPlaylists now stream from their cursors; search paths stay materialized (Search returns a slice). - listGenres stays materialized: its total is the length of the full list and it paginates in memory, so there is nothing for a cursor to page over. - api.ok's QueryResult case delegates to writeItems, so the ~9 inline callers funnel into the same writer; streamQueryResult and wrapResult are gone. - /Items/Latest streams as a bare JSON array (its Jellyfin wire shape) via writeItemsArray, which shares the item loop and stamping. That also closes the same unbounded hole /Items had: limit=0 disabled its LIMIT. - ServerId is now stamped in exactly one place, so api.ok only handles single, non-collection payloads. Verified against a copy of a 96k-track production database: every endpoint byte-for-byte identical to master. Unbounded /Items peak RSS 1691MB -> 54MB; time-to-first-byte 6.3s -> 0.19s, /Artists 1.14s -> 0.13s. * refactor(jellyfin): route every response through api.ok Handlers were split between api.ok and api.writeItems with no clear rule for which to call. api.ok now accepts itemsResult too, so it is the single entry point: callers hand it whatever they have and it routes collections (cursor -backed or materialized) to the streaming writer. writeItems is reached only through api.ok now. The one exception is /Items/Latest, which returns a bare JSON array rather than a QueryResult envelope and so writes directly — noted in both doc comments. Also drop GenreRepository.GetCursor: listGenres derives its total from the length of the full list and paginates in memory, so there is nothing for a cursor to page over, leaving the method unused. * fix(jellyfin): stream the unbounded multi-type /Items merge The multi-type merge capped each per-type query at offset+limit only when a Limit was given. Without one (Finamp's favorites screen sends multi-type), every type ran an unbounded query and collect() drained each cursor into a slice — so /Items?IncludeItemTypes=MusicAlbum,Audio with no Limit materialized every album and every song, the same OOM class this branch set out to fix. The comment claiming the set was "capped at offset+limit per type" was wrong for limit=0. Without a limit the merged page is just each type's rows in order minus the first offset, which is exactly what chaining the per-type cursors yields, so stream that instead of merging in memory. The bounded path is unchanged: with a Limit each type holds at most offset+limit rows, so merging and paginating across the combined list is safe. Cursors are opened one at a time (each pins a DB connection until drained), with the first opened eagerly so the usual failure is still a clean error before any byte is written. Measured on a 96k-track library, /Items?IncludeItemTypes=MusicAlbum,Audio with no Limit (103,393 items): peak RSS 612MB -> 53MB, time-to-first-byte 4.9s -> 0.6s, response byte-for-byte identical. * refactor(jellyfin): parse /Items params into a struct The /Items dispatcher was a single 90-line block that parsed a dozen params and then threaded them positionally through three layers — queryItemsOfType took 11 arguments, listSongs and listAlbums 9 each — so reading any one of them meant decoding a long argument list. Parse once into an itemsQuery and pass that instead. queryItems now reads as the four things it actually does: parse, the id/playlists-folder/playlist-parent special cases, single-type dispatch, multi-type merge — with the playlist-parent and merge bodies moved to playlistTracks and mergeTypes. Every listXxx takes (ctx, opts, q). No behavior change: parsing, ordering and the entityParent rule are unchanged, and /Artists still passes favOnly=false explicitly by building the subset of the query it uses. * docs(jellyfin): trim comments on the streaming path Cut the comments back to the non-obvious why: the deferred cursor open (the ServerId write would deadlock against an open reader), the deliberate malformed JSON on a mid-stream error, why chained opens one cursor at a time, why listGenres stays materialized, and why the cursor helpers take the underlying func type. Dropped the rest — restatements of the code, doc comments that repeated a test's own name, and the GetCursor interface comments that the existing MediaFileRepository.GetCursor does without. * refactor(persistence): fold the cursor wrappers into a generic wrapCursor wrapAlbumCursor, wrapArtistCursor, wrapMediaFileCursor and wrapFolderCursor were the same twelve lines four times over, differing only in the type name, and the playlist cursor had its own inline copy of the loop. Add one generic wrapCursor. It takes an extractor func rather than a method on an interface: a type parameter can't reach an embedded field, and methods that only satisfy a generic constraint are reported by the unused linter. The extractor also lets both type parameters be inferred, so call sites need no explicit instantiation. Each entity keeps its named wrapper as a one-liner, since the model cursor types are defined types and need the conversion — and the existing wrapper tests call them directly. The nil-row error now names the model type via %T ("unexpected nil model.Album") instead of a hand-written per-entity string; the three tests asserting that message are updated. * fix(jellyfin): bound concurrent collection streams A streamed collection holds a DB cursor — and its pooled connection — for the whole client-paced response, where the old materialize-then-write path released it as soon as the query finished. The pool is shared with the scanner, Subsonic, the native API and the UI, so enough slow clients take every connection and everything else blocks waiting for one. Measured against a copy of a 96k-track production DB, 20 concurrent unbounded streams against a pool of 16 stalled a write for 16.2s (the other 14 writes in the run took ~2ms — the signature of connection starvation, not lock contention). Cap concurrent streams at half the pool. Excess requests queue rather than fail, so no client is rejected: the same 20 streams now all complete and the worst write is 2.5ms. - conf.MaxOpenConns() now owns the pool sizing (db calls it). It belongs in conf: the pool is a tunable, db already imports conf, and putting it in db would force server/jellyfin to import db just to size the cap against it. Expressing the cap as MaxOpenConns()/2 also keeps the two from drifting apart. - Uses chi's ThrottleBacklog, not server.ThrottleBacklog: the latter buffers the whole response to release its token early, which is right for artwork but would undo the streaming. chi's panics on a non-positive limit, so throttleStreams guards it — setting MaxConcurrentStreams=0 disables the cap instead of crashing the server at startup. * refactor(jellyfin): move throttleStreams to middlewares.go It's a middleware, so it belongs beside normalizeQueryKeys, authenticate and withPlayer rather than in api.go. Its tests move to middlewares_test.go with it, keeping one test file per production file. * fix(jellyfin): abandon the scan when the client stops reading encodeItems discarded its write errors and relied on the final Flush to report them, so once bufio's buffer filled and the flush failed, the loop still pulled every remaining row through the cursor and serialized it for a client that was gone. A test with a failing writer confirms it: all 20k items were drained. That wastes CPU, and holds the cursor's pooled DB connection and a stream slot (now a capped resource) for the length of a full scan nobody is reading. Check the per-item writes so the first failure ends the scan; the fixed envelope writes stay unchecked, since bufio latches for them anyway. --- conf/configuration.go | 18 + db/db.go | 3 +- model/album.go | 1 + model/artist.go | 4 + model/playlist.go | 4 + persistence/album_repository.go | 21 +- persistence/album_repository_test.go | 18 +- persistence/artist_repository.go | 14 + persistence/artist_repository_test.go | 16 + persistence/folder_repository.go | 12 +- persistence/folder_repository_test.go | 2 +- persistence/mediafile_repository.go | 12 +- persistence/mediafile_repository_test.go | 18 +- persistence/persistence_suite_test.go | 13 + persistence/playlist_repository.go | 16 + persistence/playlist_repository_test.go | 9 + persistence/sql_base_repository.go | 18 + server/jellyfin/api.go | 52 ++- server/jellyfin/browsing.go | 9 +- server/jellyfin/e2e/browsing_test.go | 28 ++ server/jellyfin/items.go | 537 ++++++++++++++++------- server/jellyfin/items_test.go | 8 + server/jellyfin/middlewares.go | 16 + server/jellyfin/middlewares_test.go | 69 +++ server/jellyfin/response.go | 87 ++++ server/jellyfin/response_test.go | 116 +++++ tests/mock_album_repo.go | 14 + tests/mock_artist_repo.go | 14 + tests/mock_mediafile_repo.go | 14 + tests/mock_playlist_repo.go | 14 + 30 files changed, 957 insertions(+), 220 deletions(-) create mode 100644 server/jellyfin/response.go create mode 100644 server/jellyfin/response_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 562f52465..83793bd43 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -225,6 +225,10 @@ type jellyfinOptions struct { // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string + // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB + // cursor — and its pooled connection — for the whole client-paced response, so without a bound + // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. + MaxConcurrentStreams int } type httpHeaderOptions struct { @@ -889,6 +893,9 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) + // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, + // scrobbles and the UI. See MaxOpenConns. + viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2)) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) @@ -959,3 +966,14 @@ func getConfigFile(cfgFile string) string { } return "" } + +// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner, +// Subsonic, Jellyfin, native API, UI). +// +// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so +// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a +// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the +// count is only loosely related to core count, and the floor is what matters on small machines. +func MaxOpenConns() int { + return max(4, runtime.NumCPU()) +} diff --git a/db/db.go b/db/db.go index 3f3f61d71..4ca996fe5 100644 --- a/db/db.go +++ b/db/db.go @@ -5,7 +5,6 @@ import ( "database/sql" "embed" "fmt" - "runtime" "time" "github.com/mattn/go-sqlite3" @@ -44,7 +43,7 @@ func Db() *sql.DB { } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) - db.SetMaxOpenConns(max(4, runtime.NumCPU())) + db.SetMaxOpenConns(conf.MaxOpenConns()) if err != nil { log.Fatal("Error opening database", err) } diff --git a/model/album.go b/model/album.go index 667f4695b..ade7f6ee0 100644 --- a/model/album.go +++ b/model/album.go @@ -141,6 +141,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetCursor(...QueryOptions) (AlbumCursor, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/artist.go b/model/artist.go index 2085f0051..f9c4bffd5 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "maps" "slices" "time" @@ -79,6 +80,8 @@ type ArtistIndex struct { } type ArtistIndexes []ArtistIndex +type ArtistCursor iter.Seq2[Artist, error] + type ArtistRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) @@ -86,6 +89,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: diff --git a/model/playlist.go b/model/playlist.go index 262774aa7..f2586f52d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "slices" "strconv" "time" @@ -121,6 +122,8 @@ func (pls Playlist) UploadedImagePath() string { type Playlists []Playlist +type PlaylistCursor iter.Seq2[Playlist, error] + type PlaylistRepository interface { ResourceRepository AnnotatedRepository @@ -130,6 +133,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 34845be15..6ebbd9202 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -247,6 +247,15 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e return res.toModels(), nil } +func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { + sq := r.selectAlbum(options...) + cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) + if err != nil { + return nil, err + } + return wrapAlbumCursor(cursor), nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) @@ -319,17 +328,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) } func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { - return func(yield func(model.Album, error) bool) { - for a, err := range cursor { - if a.Album == nil { - yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) - return - } - if !yield(*a.Album, err) || err != nil { - return - } - } - } + return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album })) } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index f72f778db..64ff0095e 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -67,6 +67,22 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same albums as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) @@ -854,7 +870,7 @@ var _ = Describe("AlbumRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f84f410e9..b542dedb4 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "os" "slices" "strings" @@ -263,6 +264,19 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } +func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + sel := r.selectArtist(options...) + cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapArtistCursor(cursor), nil +} + +func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor { + return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist })) +} + func (r *artistRepository) getIndexKey(a model.Artist) string { source := a.OrderArtistName if conf.Server.PreferSortTags { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index d7b695ade..dc11ede36 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -268,6 +268,22 @@ var _ = Describe("ArtistRepository", func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same artists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 8fb7f0296..5da395a74 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -263,17 +263,7 @@ func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { } func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { - return func(yield func(model.Folder, error) bool) { - for f, err := range cursor { - if f.Folder == nil { - yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) - return - } - if !yield(*f.Folder, err) || err != nil { - return - } - } - } + return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) } func (r folderRepository) purgeEmpty(libraryIDs ...int) error { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index a8945dfee..8cd45f16b 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -297,7 +297,7 @@ var _ = Describe("FolderRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index b4979ca77..ace61610c 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -420,17 +420,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC } func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { - return func(yield func(model.MediaFile, error) bool) { - for m, err := range cursor { - if m.MediaFile == nil { - yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) - return - } - if !yield(*m.MediaFile, err) || err != nil { - return - } - } - } + return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile })) } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 80d440c41..f6a744d8d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -29,6 +29,22 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same media files as GetAll", func() { + opts := model.QueryOptions{Sort: "title"} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + }) + It("gets mediafile from the DB", func() { actual, err := mr.Get("1004") Expect(err).ToNot(HaveOccurred()) @@ -1012,7 +1028,7 @@ var _ = Describe("MediaRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 4f2fd7fe2..f146cb06b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -329,3 +329,16 @@ var _ = BeforeSuite(func() { func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } + +// collectCursor takes the cursor's underlying func type so the named cursor types +// (model.AlbumCursor, ...) infer T. +func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { + GinkgoHelper() + Expect(err).ToNot(HaveOccurred()) + var out []T + for item, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + out = append(out, item) + } + return out +} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 573f43c10..9626aad6a 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "slices" "time" @@ -186,6 +187,21 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. + sel := r.selectPlaylist(options...).Where(r.userFilter()) + cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapPlaylistCursor(cursor), nil +} + +// dbPlaylist embeds a value, not a pointer, so its model is never nil. +func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor { + return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist })) +} + func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 831e24453..c51ff6222 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -27,6 +27,15 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same playlists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want))) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ce5221d19..d0cbb2946 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -347,6 +347,24 @@ func (r sqlRepository) queryOne(sq Sqlizer, response any) error { return err } +// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's +// embedded model, which a type parameter can't reach on its own. +func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + for row, err := range cursor { + m := toModel(row) + if m == nil { + var zero T + yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + return + } + if !yield(*m, err) || err != nil { + return + } + } + } +} + // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index fd523d154..d169a4c83 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -1,7 +1,6 @@ package jellyfin import ( - "context" "encoding/json" "net/http" "sync" @@ -19,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" ) @@ -71,8 +71,14 @@ func (api *Router) routes() http.Handler { inner.Get("/Users/Public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. - inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage) - inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, + // and an unbounded burst (a client fetching artwork across a large library) can exhaust memory. + inner.Group(func(r chi.Router) { + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) + r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + }) inner.Group(func(r chi.Router) { r.Use(api.authenticate) @@ -85,12 +91,22 @@ func (api *Router) routes() http.Handler { r.Get("/Users/Me", api.getCurrentUser) r.Get("/Users/{userId}", api.getCurrentUser) - r.Get("/Items", api.getItems) - r.Get("/Users/{userId}/Items", api.getItems) + // Cursor-backed collections: each streams straight from the DB, holding a connection for the + // whole client-paced response, so enough slow clients would take the entire pool and stall the + // scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess + // requests queue rather than fail. + r.Group(func(r chi.Router) { + r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) + r.Get("/Items", api.getItems) + r.Get("/Users/{userId}/Items", api.getItems) + r.Get("/Users/{userId}/Items/Latest", api.getLatest) + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + }) + r.Get("/Items/{itemId}", api.getItem) r.Get("/Users/{userId}/Items/{itemId}", api.getItem) r.Delete("/Items/{itemId}", api.deleteItem) - r.Get("/Users/{userId}/Items/Latest", api.getLatest) // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. @@ -106,8 +122,6 @@ func (api *Router) routes() http.Handler { r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) - r.Get("/Artists", api.getArtists) - r.Get("/Artists/AlbumArtists", api.getAlbumArtists) r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) r.Get("/Items/{itemId}/Similar", api.getSimilarItems) r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) @@ -158,14 +172,19 @@ func (api *Router) routes() http.Handler { return caseInsensitivePaths(inner) } -// ok writes payload as JSON, stamping ServerId on any item(s) in it — real Jellyfin always sets it, -// and it's the same value for every item, so it's applied here rather than threaded through mappers. +// ok writes payload as JSON — the single entry point for every handler. Collections are routed to +// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped +// on any item(s): real Jellyfin always sets it, and it's constant per request. +// +// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray). func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { switch p := payload.(type) { + case itemsResult: + api.writeItems(w, r, p) + return case dto.QueryResult: - api.stampServerID(r.Context(), p.Items) - case []dto.BaseItemDto: - api.stampServerID(r.Context(), p) + api.writeItems(w, r, materialized(p)) + return case dto.BaseItemDto: p.ServerId = api.serverID(r.Context()) payload = p @@ -176,13 +195,6 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { } } -func (api *Router) stampServerID(ctx context.Context, items []dto.BaseItemDto) { - sid := api.serverID(ctx) - for i := range items { - items[i].ServerId = sid - } -} - // notFound handles unmatched routes and unsupported methods, logging them so unimplemented // endpoints surface instead of returning chi's default plain-text 404/405. func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index bf77c4594..d5a00e492 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -28,10 +28,15 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. - genreIds := decodedQueryIDs(r, "genreids") + q := itemsQuery{ + scopeIDs: scopeIDs, + genreIds: decodedQueryIDs(r, "genreids"), + search: p.StringOr("searchterm", ""), + } - res, err := api.listArtists(ctx, opts, genreIds, scopeIDs, p.StringOr("searchterm", ""), false, role) + res, err := api.listArtists(ctx, opts, q, role) if err != nil { api.internalError(w, r, err) return diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index af639ed29..646de4497 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -300,6 +300,34 @@ var _ = Describe("Browsing", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs }) + + // Chaining the per-type cursors must preserve the merged order. + It("streams an unbounded multi-type merge, honoring StartIndex", func() { + all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(all.Items).To(HaveLen(12)) + + skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2")) + Expect(skipped.Items).To(HaveLen(10)) + Expect(skipped.TotalRecordCount).To(Equal(12)) + Expect(skipped.StartIndex).To(Equal(2)) + Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:])) + }) + + // Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing. + It("pages songs via StartIndex/Limit while reporting the full total", func() { + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName")) + Expect(all.TotalRecordCount).To(Equal(7)) + Expect(all.Items).To(HaveLen(7)) + + p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3")) + Expect(p1.Items).To(HaveLen(3)) + Expect(p2.Items).To(HaveLen(3)) + Expect(p1.TotalRecordCount).To(Equal(7)) + // The two pages are distinct and match the head of the unpaged, identically-sorted list. + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6])) + }) }) Describe("GET /Items/{id}", func() { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index f8158bca4..0dca48938 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -2,6 +2,8 @@ package jellyfin import ( "context" + "io" + "iter" "net/http" "slices" "strconv" @@ -31,112 +33,328 @@ func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { api.ok(w, r, res) } -// queryItems is the /Items dispatcher: it parses entity types from IncludeItemTypes (defaulting to -// MusicAlbum), queries each via the matching listXxx, and merges multi-type results into one -// paginated list (as Finamp's favorites screen requests). -func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) { - p := req.Params(r) - // Query keys are read lowercase because normalizeQueryKeys folded them (Jellyfin binds - // case-insensitively). /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch below. - fields := dto.ParseFields(p.StringOr("fields", "")) - if ids := decodedQueryIDs(r, "ids"); len(ids) > 0 { - return api.itemsByIDs(ctx, ids, fields), nil - } - parentId := dto.DecodeID(p.StringOr("parentid", "")) - search := p.StringOr("searchterm", "") - // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone - // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly := strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false) - sortBy := p.StringOr("sortby", "") - sortOrder := p.StringOr("sortorder", "") - offset := p.IntOr("startindex", 0) - limit := p.IntOr("limit", 0) - rawTypes := p.StringOr("includeitemtypes", "") - // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - if strings.Contains(rawTypes, "ManualPlaylistsFolder") { - return result([]dto.BaseItemDto{playlistsFolder()}, 1, 0), nil - } - types := parseTypes(rawTypes) - // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping - // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistIds/artistIds - // select the artist's own discography; contributingArtistIds alone means albums they merely appear - // on (Jellyfin's "Featured On"), which must exclude that discography. - albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) - contributingScope := p.StringOr("contributingartistids", "") - artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) - contributingOnly := albumArtistScope == "" && contributingScope != "" - // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. - genreIds := decodedQueryIDs(r, "genreids") +// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a +// full-library response never builds every DTO at once. Exactly one of items/openCursor is set. +// +// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which +// writes to the DB on first use and would deadlock against an open reader, but before the first +// response byte, so a failed open is still a clean error rather than a truncated 200. +type itemsResult struct { + items []dto.BaseItemDto + openCursor func() (iter.Seq2[dto.BaseItemDto, error], error) + total int + start int +} - scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) - // A playlist parent always resolves to its tracks, whatever IncludeItemTypes says. Jellify opens - // a playlist with ParentId=<playlist>&IncludeItemTypes=Audio; routing that through listSongs would - // treat the playlist id as an album id and return nothing. - if parentId != "" && !isLibraryParent && parentId != playlistsFolderID { - if pls, err := api.playlists.GetWithTracks(ctx, parentId); err == nil { - // GetWithTracks enforces visibility (public or owned by the current user). - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - return result(paginate(items, offset, limit), len(items), offset), nil +func materialized(q dto.QueryResult) itemsResult { + return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex} +} + +func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult { + return itemsResult{openCursor: open, total: total, start: start} +} + +// chained streams several results back to back, skipping the first skip items — the unbounded +// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head. +func chained(results []itemsResult, total, skip int) itemsResult { + open := func() (iter.Seq2[dto.BaseItemDto, error], error) { + if len(results) == 0 { + return sliceItems(nil), nil + } + // Only the first opens eagerly (so the usual failure is still a clean error); the rest open as + // the stream reaches them, so only one cursor pins a DB connection at a time. + first, err := results[0].seq() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + n := 0 + emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return false + } + if n < skip { + n++ + continue + } + if !yield(it, nil) { + return false + } + } + return true + } + if !emit(first) { + return + } + for _, res := range results[1:] { + seq, err := res.seq() + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !emit(seq) { + return + } + } + }, nil + } + return streamed(open, total, skip) +} + +// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's +// underlying func type, so callers wrap repo.GetCursor for the named type to infer T. +func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) { + return func() (iter.Seq2[dto.BaseItemDto, error], error) { + cursor, err := openCursor() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + for row, err := range cursor { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !yield(toItem(row), nil) { + return + } + } + }, nil + } +} + +// seq returns the items as one sequence, opening the cursor if there is one. +func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) { + if ir.openCursor != nil { + return ir.openCursor() + } + return sliceItems(ir.items), nil +} + +// collect drains the result into a slice, for the merge that combines types before paginating. +func (ir itemsResult) collect() ([]dto.BaseItemDto, error) { + if ir.openCursor == nil { + return ir.items, nil + } + seq, err := ir.openCursor() + if err != nil { + return nil, err + } + var out []dto.BaseItemDto + for it, err := range seq { + if err != nil { + return nil, err + } + out = append(out, it) + } + return out, nil +} + +func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + return streamItemsEnvelope(w, items, res.total, res.start) + }) +} + +// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope. +func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, streamItemsArray) +} + +// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in +// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500. +func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, + write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { + sid := api.serverID(r.Context()) + seq, err := res.seq() + if err != nil { + api.internalError(w, r, err) + return + } + stamped := func(yield func(dto.BaseItemDto, error) bool) { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + it.ServerId = sid + if !yield(it, nil) { + return + } } } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := write(w, stamped); err != nil { + log.Error(r.Context(), "Jellyfin API: error streaming response", err) + } +} + +// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead +// of a long positional parameter list. +type itemsQuery struct { + fields dto.Fields + ids []string + rawTypes string + types []string + search string + sortBy string + sortOrder string + offset int + limit int + favOnly bool + // parentId scopes the query. entityParent is the same id only when it names an entity (an artist + // for MusicAlbum, an album for Audio) rather than a library. + parentId string + entityParent string + isLibraryParent bool + scopeIDs []int + // artistId selects that artist's own discography; contributingOnly means albums they merely + // appear on (Jellyfin's "Featured On"), which must exclude that discography. + artistId string + contributingOnly bool + genreIds []string +} + +// parseItemsQuery also resolves the entity types (inferring them from the parent when +// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { + p := req.Params(r) + q := itemsQuery{ + fields: dto.ParseFields(p.StringOr("fields", "")), + ids: decodedQueryIDs(r, "ids"), + rawTypes: p.StringOr("includeitemtypes", ""), + search: p.StringOr("searchterm", ""), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: dto.DecodeID(p.StringOr("parentid", "")), + // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. + genreIds: decodedQueryIDs(r, "genreids"), + } + // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + q.contributingOnly = albumArtistScope == "" && contributingScope != "" + + q.types = parseTypes(q.rawTypes) + q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). - if rawTypes == "" && parentId != "" && !isLibraryParent { - if parentId == playlistsFolderID { + if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { + if q.parentId == playlistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. - types = []string{"Playlist"} - } else if _, err := api.ds.Album(ctx).Get(parentId); err == nil { - types = []string{"Audio"} + q.types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { + q.types = []string{"Audio"} } } - entityParent := parentId - // ParentId-as-entity-id (artist for MusicAlbum, album for Audio) only makes sense for a single - // type; a multi-type query has no natural parent entity, so ParentId is only library scoping there. - if isLibraryParent || len(types) > 1 { - entityParent = "" + // ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural + // parent entity, so there ParentId is only library scoping. + q.entityParent = q.parentId + if q.isLibraryParent || len(q.types) > 1 { + q.entityParent = "" } - - if len(types) == 1 { - opts := model.QueryOptions{Offset: offset, Max: limit} - applySort(&opts, types[0], sortBy, sortOrder) - return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) - } - - var items []dto.BaseItemDto - total := 0 - for _, itemType := range types { - var opts model.QueryOptions - // Each per-type query needs at most offset+limit rows (the worst case where one type fills the - // whole [offset, offset+limit) window); without this cap each would fetch its whole table. - // Totals are unaffected — they come from CountAll. - if limit > 0 { - opts.Max = offset + limit - } - applySort(&opts, itemType, sortBy, sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) - if err != nil { - return dto.QueryResult{}, err - } - items = append(items, res.Items...) - total += res.TotalRecordCount - } - return result(paginate(items, offset, limit), total, offset), nil + return q } -func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, favOnly bool, fields dto.Fields) (dto.QueryResult, error) { +// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via +// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites +// screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { + q := api.parseItemsQuery(ctx, r) + switch { + // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. + case len(q.ids) > 0: + return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): + return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil + } + if res, ok := api.playlistTracks(ctx, q); ok { + return res, nil + } + if len(q.types) == 1 { + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, q.types[0], q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, q.types[0], opts, q) + } + return api.mergeTypes(ctx, q) +} + +// playlistTracks resolves a playlist parent to its tracks, whatever IncludeItemTypes says: Jellify +// opens a playlist with ParentId=<playlist>&IncludeItemTypes=Audio, and routing that through +// listSongs would treat the playlist id as an album id and return nothing. +func (api *Router) playlistTracks(ctx context.Context, q itemsQuery) (itemsResult, bool) { + if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { + return itemsResult{}, false + } + pls, err := api.playlists.GetWithTracks(ctx, q.parentId) + if err != nil { + return itemsResult{}, false + } + // GetWithTracks enforces visibility (public or owned by the current user). + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, q.fields) }) + return materialized(result(paginate(items, q.offset, q.limit), len(items), q.offset)), true +} + +func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + var results []itemsResult + total := 0 + for _, itemType := range q.types { + var opts model.QueryOptions + if q.limit > 0 { + opts.Max = q.offset + q.limit + } + applySort(&opts, itemType, q.sortBy, q.sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, q) + if err != nil { + return itemsResult{}, err + } + results = append(results, res) + total += res.total + } + if q.limit == 0 { + // No cap above, so merging in memory would pull every row of every type. The merged page is + // just their rows in order minus the first offset — what chaining the cursors yields. + return chained(results, total, q.offset), nil + } + var items []dto.BaseItemDto + for _, res := range results { + typeItems, err := res.collect() + if err != nil { + return itemsResult{}, err + } + items = append(items, typeItems...) + } + return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { switch itemType { case "Audio": - return api.listSongs(ctx, opts, entityParent, artistId, genreIds, scopeIDs, search, favOnly, fields) + return api.listSongs(ctx, opts, q) case "MusicArtist": // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. - return api.listArtists(ctx, opts, genreIds, scopeIDs, search, favOnly, model.RoleAlbumArtist) + return api.listArtists(ctx, opts, q, model.RoleAlbumArtist) case "MusicGenre": return api.listGenres(ctx, opts) case "Playlist": - return api.listPlaylists(ctx, opts, favOnly) + return api.listPlaylists(ctx, opts, q) default: // MusicAlbum - return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly) + return api.listAlbums(ctx, opts, q) } } @@ -213,145 +431,144 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO return rows, total, nil } -func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's // albums"; contributingArtistIds means "albums they only appear on" (Featured On). switch { - case contributingOnly && artistId != "": - filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) - case firstNonEmpty(artistId, parentId) != "": - filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + case q.contributingOnly && q.artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) + case firstNonEmpty(q.artistId, q.entityParent) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) default: filters = append(filters, notMissing) } - if len(genreIds) > 0 { - filters = append(filters, filter.ByGenreID(genreIds)) + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) } - if fav { + if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, scopeIDs) + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - if search != "" { + if q.search != "" { albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset), nil - } - albums, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err + return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + return streamed(open, int(total), opts.Offset), nil } -func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId, artistId string, genreIds []string, scopeIDs []int, search string, fav bool, fields dto.Fields) (dto.QueryResult, error) { - toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, fields) } +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } repo := api.ds.MediaFile(ctx) filters := squirrel.And{} // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. switch { - case artistId != "": - filters = append(filters, filter.SongsByArtistID(artistId).Filters) - case parentId != "": - filters = append(filters, filter.SongsByAlbum(parentId).Filters) + case q.artistId != "": + filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) + case q.entityParent != "": + filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) default: filters = append(filters, notMissing) } - if len(genreIds) > 0 { - filters = append(filters, filter.ByGenreID(genreIds)) + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) } - if fav { + if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, scopeIDs) + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - if search != "" { + if q.search != "" { mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(mfs, toItem), total, opts.Offset), nil + return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil } // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an // explicit client SortBy still wins, since applySort already set opts.Sort. - if artistId == "" && parentId != "" && opts.Sort == "" { - opts.Sort = filter.SongsByAlbum(parentId).Sort - } - mfs, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err + if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(q.entityParent).Sort } + // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(mfs, toItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { + return repo.GetCursor(opts) + }, toItem) + return streamed(open, int(total), opts.Offset), nil } // listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, // RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. // genreIds isn't applied to search — a name lookup, like role (see below). -func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, genreIds []string, scopeIDs []int, search string, fav bool, role model.Role) (dto.QueryResult, error) { +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { repo := api.ds.Artist(ctx) // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a // search scope (artists have no library_id column). A compound or join-based filter // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build // filters differently. Role isn't applied to search for the same reason — it's a name lookup. - if search != "" { - if len(scopeIDs) > 0 { - opts.Filters = squirrel.Eq{"library_id": scopeIDs} + if q.search != "" { + if len(q.scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} } artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset), nil + return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil } - if fav { + if q.favOnly { opts.Filters = filter.ArtistsByStarred().Filters } else { opts.Filters = notMissing } - if len(genreIds) > 0 { - opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(genreIds)} + if len(q.genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} } opts = filter.ArtistsByRole(opts, role) - opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) - artists, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err - } + opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Artist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.ArtistToBaseItem) + return streamed(open, int(total), opts.Offset), nil } -// listGenres is intentionally unscoped: genres are global tags, not per-library entities. Paging is -// in-memory (GenreRepository has no CountAll, lists are small) so TotalRecordCount is the real total. -func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) { +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also +// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the +// length of the full list and paging is in-memory — nothing for a cursor to page over. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } items := slice.Map(genres, dto.GenreToBaseItem) - return result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset), nil + return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil } // listPlaylists lists playlists visible to the current user. Visibility (public or owned) is // enforced by playlistRepository, not scopeIDs. -func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) { - if favOnly { +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + if q.favOnly { starred := squirrel.Eq{"starred": true} if opts.Filters == nil { opts.Filters = starred @@ -360,15 +577,14 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, f } } repo := api.ds.Playlist(ctx) - playlists, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err - } total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.PlaylistToBaseItem) + return streamed(open, int(total), opts.Offset), nil } // resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, @@ -482,17 +698,18 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for +// /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() opts := filter.AlbumsByNewest() opts.Max = req.Params(r).IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) - albums, err := api.ds.Album(ctx).GetAll(opts) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array + repo := api.ds.Album(ctx) + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + api.writeItemsArray(w, r, streamed(open, 0, 0)) } func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 8bd1bf052..801db9e3a 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -71,6 +71,14 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) }) + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) w := httptest.NewRecorder() diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 2941d3c32..d9e41d252 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -7,12 +7,28 @@ import ( "regexp" "strings" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) +// throttleStreams bounds how many collection responses stream concurrently, so they can't take every +// connection in the shared DB pool: each holds a cursor, and its connection, for the whole +// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it. +// +// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire +// response to release its token early, which is right for artwork but would undo the streaming here. +// chi's panics on a non-positive limit, hence the guard. +func throttleStreams(limit int) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) +} + // normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params // case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, // Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index 2c0761834..c766bff70 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -4,6 +4,9 @@ import ( "context" "net/http" "net/http/httptest" + "sync" + "sync/atomic" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -252,3 +255,69 @@ var _ = Describe("normalizeQueryKeys", func() { Expect(got).To(ConsistOf("aaa", "bbb")) }) }) + +var _ = Describe("throttleStreams", func() { + // serve fires n concurrent requests through the middleware and reports the highest number that + // were ever inside the handler at once. + serve := func(limit, n int) int32 { + var inFlight, peak int32 + release := make(chan struct{}) + h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cur := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&peak) + if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) { + break + } + } + <-release // hold the slot until every request has had a chance to enter + atomic.AddInt32(&inFlight, -1) + })) + + var wg sync.WaitGroup + for range n { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + // Give the admitted requests time to pile up before letting them finish. + time.Sleep(100 * time.Millisecond) + close(release) + wg.Wait() + return atomic.LoadInt32(&peak) + } + + It("admits no more than the limit at once", func() { + Expect(serve(2, 8)).To(Equal(int32(2))) + }) + + It("queues the excess rather than rejecting it", func() { + // All 8 still complete — they wait for a slot instead of getting a 429. + var served int32 + release := make(chan struct{}) + h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + atomic.AddInt32(&served, 1) + })) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + close(release) + wg.Wait() + Expect(served).To(Equal(int32(8))) + }) + + // chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not + // crash the server at startup. + It("is disabled, not panicking, when the limit is zero", func() { + Expect(func() { serve(0, 4) }).ToNot(Panic()) + Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) + }) +}) diff --git a/server/jellyfin/response.go b/server/jellyfin/response.go new file mode 100644 index 000000000..f4b96eda3 --- /dev/null +++ b/server/jellyfin/response.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "iter" + "strconv" + + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// streamItemsEnvelope writes a QueryResult, byte-identical to json.NewEncoder(w).Encode(q). +// +// A mid-stream error aborts without closing the envelope: the 200 is already committed, so a +// truncated-but-valid body would let a sync client treat the short list as the whole library and +// prune local tracks. Malformed JSON forces its parser to fail instead. Callers open the cursor +// before the first byte, so this only fires on a rare mid-iteration failure. +func streamItemsEnvelope(w io.Writer, items iter.Seq2[dto.BaseItemDto, error], total, start int) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString(`{"Items":[`) + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString(`],"TotalRecordCount":`) + _, _ = bw.WriteString(strconv.Itoa(total)) + _, _ = bw.WriteString(`,"StartIndex":`) + _, _ = bw.WriteString(strconv.Itoa(start)) + _, _ = bw.WriteString("}\n") + return bw.Flush() +} + +// streamItemsArray writes a bare JSON array — the shape /Items/Latest returns, with no envelope. +func streamItemsArray(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString("[") + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString("]\n") + return bw.Flush() +} + +// encodeItems writes items comma-separated. Unlike the fixed envelope writes, these are checked: +// bufio surfaces a latched write error here once a flush fails, and a client that has gone away must +// abandon the scan rather than pull the rest of the library through the cursor — which would hold its +// pooled DB connection and stream slot for a response nobody is reading. +func encodeItems(bw *bufio.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + // One reused buffer+encoder, so per-item JSON doesn't allocate. Encode HTML-escapes like + // json.Marshal, and appends a newline that's dropped below. + var itemBuf bytes.Buffer + enc := json.NewEncoder(&itemBuf) + first := true + for item, err := range items { + if err != nil { + return err + } + if !first { + if _, err := bw.WriteString(","); err != nil { + return err + } + } + first = false + itemBuf.Reset() + if err := enc.Encode(item); err != nil { + return err + } + b := itemBuf.Bytes() + if _, err := bw.Write(b[:len(b)-1]); err != nil { + return err + } + } + return nil +} + +func sliceItems(items []dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for i := range items { + if !yield(items[i], nil) { + return + } + } + } +} diff --git a/server/jellyfin/response_test.go b/server/jellyfin/response_test.go new file mode 100644 index 000000000..c32c566fc --- /dev/null +++ b/server/jellyfin/response_test.go @@ -0,0 +1,116 @@ +package jellyfin + +import ( + "bytes" + "encoding/json" + "errors" + "iter" + "strings" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// deadWriter stands in for a client that went away mid-response. +type deadWriter struct{} + +func (deadWriter) Write([]byte) (int, error) { return 0, errors.New("connection reset by peer") } + +var _ = Describe("streaming a materialized QueryResult", func() { + // The materialized path (api.ok -> writeItems -> sliceItems) must stay byte-for-byte identical to + // what json.Encoder.Encode produced before, so no client sees a different response. + assertIdenticalToEncoder := func(q dto.QueryResult) { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, sliceItems(q.Items), q.TotalRecordCount, q.StartIndex)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(q)).To(Succeed()) + + Expect(got.String()).To(Equal(want.String())) + } + + It("encodes an empty item list", func() { + assertIdenticalToEncoder(dto.QueryResult{Items: []dto.BaseItemDto{}}) + }) + + It("encodes a single item", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{{Id: "a", Name: "One"}}, + TotalRecordCount: 1, + }) + }) + + It("encodes multiple items, honoring HTML escaping and StartIndex", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{ + {Id: "a", Name: "One"}, + {Id: "b", Name: "Two & <Three>"}, + }, + TotalRecordCount: 500, + StartIndex: 100, + }) + }) +}) + +var _ = Describe("streamItemsEnvelope", func() { + seqOf := func(items ...dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } + } + + It("produces the same bytes as encoding an equivalent QueryResult", func() { + items := []dto.BaseItemDto{{Id: "a", Name: "One"}, {Id: "b", Name: "Two & <Three>"}} + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(items...), 500, 100)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(dto.QueryResult{Items: items, TotalRecordCount: 500, StartIndex: 100})).To(Succeed()) + Expect(got.String()).To(Equal(want.String())) + }) + + It("emits an empty array (not null) for a sequence that yields nothing", func() { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(), 0, 0)).To(Succeed()) + Expect(got.String()).To(Equal("{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}\n")) + }) + + // A client that goes away must not keep the source (a DB cursor, holding its pooled connection + // and a stream slot) running to the end of the library. + It("stops pulling from the source once writing fails", func() { + const total = 20000 + pulled := 0 + seq := func(yield func(dto.BaseItemDto, error) bool) { + for range total { + pulled++ + if !yield(dto.BaseItemDto{Id: "a", Name: strings.Repeat("x", 200)}, nil) { + return + } + } + } + err := streamItemsEnvelope(deadWriter{}, seq, total, 0) + Expect(err).To(HaveOccurred()) + Expect(pulled).To(BeNumerically("<", total), "should abandon the scan, not drain it") + }) + + It("aborts on a mid-stream error, leaving the envelope open (malformed) so the client fails loudly", func() { + boom := errors.New("scan failed") + first := dto.BaseItemDto{Id: "a", Name: "One"} + seq := func(yield func(dto.BaseItemDto, error) bool) { + if !yield(first, nil) { + return + } + yield(dto.BaseItemDto{}, boom) + } + var got bytes.Buffer + err := streamItemsEnvelope(&got, seq, 7, 0) + Expect(err).To(MatchError(boom)) + firstJSON, _ := json.Marshal(first) + Expect(got.String()).To(Equal("{\"Items\":[" + string(firstJSON))) + }) +}) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 85765abf8..6635881b7 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -75,6 +75,20 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.Album, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("unexpected error") diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 748002882..e6ea7aea4 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -113,6 +113,20 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Artist, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { if m.Err { return errors.New("mock repo error") diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 6ddd77f14..990b91d7c 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -109,6 +109,20 @@ func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFile return res, nil } +func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range res { + if !yield(mf, nil) { + return + } + } + }, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 908d6aab5..8f8842c8e 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -52,6 +52,20 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Playlist, error) bool) { + for _, p := range res { + if !yield(p, nil) { + return + } + } + }, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") From 3d438b08efc705928a187b51042ca0778feeb900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 15 Jul 2026 18:09:59 -0400 Subject: [PATCH 365/596] fix(jellyfin): close the unbounded playlist and search paths left by #5783 (#5784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): stream playlist tracks instead of loading every one PR #5783 left the playlist paths materializing: all three loaded every track of a playlist, whatever the client asked for. A playlist can be the whole library — a smart playlist matching everything — so this is the same OOM class that PR fixed for the other collections. Measured on a 96k-track smart playlist, /Items?ParentId=<playlist>&Limit=10 peaked at 1.3GB to return ten tracks. Playlist tracks now stream from a cursor, like every other collection: - PlaylistTrackRepository gains GetCursor and CountAll, sharing the select builder with loadTracks so cursor rows hydrate identically, plus GetMediaFileIDs for callers that need every id but no track data. - playlists.Tracks(ctx, id) exposes that repo to the HTTP layer with visibility enforced, returning ErrNotFound rather than the repo's nil-and-log-a-warning (which /Items would hit on every album browse, since ParentId is usually not a playlist). - /Playlists/{id}/Items now honors StartIndex/Limit, which it silently ignored before — it always returned the whole playlist. Real Jellyfin pages it. - getPlaylist runs an id-only query: PlaylistInfo carries every track id so it can't be paged, but it no longer hydrates rows it discards. - The route joins the throttled group, as it's now cursor-backed. Measured against a copy of a 96k-track production DB, peak RSS over idle, with byte-for-byte identical responses on every endpoint: /Items?ParentId=<pl>&Limit=10 1275MB -> 1MB 8.8s -> 3.6s (0.27s warm) /Items?ParentId=<pl> unbounded 1353MB -> 8MB 8.7s -> 3.4s /Playlists/<pl>/Items 1217MB -> 7MB 8.8s -> 3.4s /Playlists/<pl> 1178MB -> 33MB 9.1s -> 3.8s TotalRecordCount now costs a count query where the old path got it from len(tracks): 6ms on the largest real playlist in that library (2638 tracks), 288ms on the synthetic all-96k one. The old path paid 1.3GB and 4s+ instead. * fix(jellyfin): bound unbounded /Items searches The other collections stream, so an unbounded one costs about one item of memory. Search can't: the repositories' Search returns a slice, so it materializes every match. PR #5783 left two ways to reach that. A whitespace-only SearchTerm was the first. " " != "", so it took the search path, where doSearch trims it back to empty and hits its "empty query, return everything in natural order" branch — with no LIMIT, since executeTwoPhase only applies one when Max > 0. The whole library, materialized. Trimming at the two parse sites makes the `search != ""` checks mean what they look like they mean: a blank term is not a search, so it takes the unfiltered streaming path, which still returns everything, exactly as real Jellyfin does for an empty term. A real search with no Limit was the second, and needs an actual bound. Search gets a default of 100 when the client sends no Limit — matching the DefaultSearchLimit in Jellyfin's unreleased SqlSearchProvider — plus a ceiling, without which Limit=999999 would still materialize the library. The default alone wouldn't have closed the hole. An explicit Limit under the ceiling is honored unclamped, as upstream does; truncating a search is safe in a way truncating /Items is not, since nothing syncs a library through searchTerm. Note this is upstream's own bug: v10.11's /Search/Hints returns the entire library for a whitespace-only term, which master fixed by switching to ThrowIfNullOrWhiteSpace. * fix(jellyfin): cap the search Limit the client asked for, not the merge window searchPage sees two different things in opts.Max: the client's Limit for a single-type query, and mergeTypes' internal offset+limit window for a multi-type one. Clamping there hit both, so a multi-type search paging past the ceiling fetched only `ceiling` rows of the first type and the merged page skipped into the next one — IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=2000 &Limit=1 returned an album instead of the 2001st song. The ceiling now applies where the client's Limit is read: queryItems (after the playlist branch, so a playlist parent's page isn't capped by a stray SearchTerm) and getArtists, which reads its own. A limit of 0 stays 0, keeping searchPage's default. What a deep page materializes is then bounded by the client's StartIndex, as it already was for any multi-type query, search or not. Also from review: the playlist-track mock reused the previously stored Options when called without any, so a later no-args call inherited stale paging. * fix(jellyfin): apply the search default to the client's Limit, not per type The default lived in searchPage, which runs per type and after mergeTypes has already picked its branch. So an unbounded multi-type search left q.limit at 0, mergeTypes took its chained branch, and StartIndex was applied to a list each type had already truncated to the default — dropping matches rather than paging them. With 200 matching songs, IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song &StartIndex=150 returned nothing at all, and without StartIndex it returned the default per type instead of in total. clampSearchLimit now applies the default and the ceiling together, to the client's Limit, at the two places it's read (queryItems and getArtists). A search therefore always gives mergeTypes a real window, so it pages the merged result and cuts it once, at the end. * fix(jellyfin): bound the multi-type search window against StartIndex mergeTypes asks each type for offset+limit rows before paginating the merged list, so a search still materialized whatever StartIndex asked for: IncludeItemTypes=Audio,MusicAlbum&SearchTerm=x&StartIndex=500000&Limit=1 pulled ~500001 matches per type. Bounding the window alone isn't enough — below it the merged rows are the client's page, but at it a truncated type is followed by the next one's rows, which is what made a clamped window serve an album where the 2001st song belonged. So the window is capped at maxSearchLimit and the page is clipped to it: pages below the ceiling are served in full and unchanged, a page straddling it is cut at it, and past it the result is empty rather than another type's rows. The total reports what can actually be paged to, so a client stops instead of asking for pages that no longer exist. This is the merge's own limit, not the client's: a single-type search still pages as deep as it likes, since its offset goes to SQL. Non-search multi-type queries keep the unbounded offset+limit window, which predates this and wants the same treatment via CountAll (exact per-type totals let whole types be skipped) rather than a cap. * fix(jellyfin): advertise the pageable search total, not the page window Clipping the multi-type search total to `window` clipped it to StartIndex+Limit on an ordinary page, so a first page of Limit=10 reported TotalRecordCount 10 however many matches there were, and a client paging on the total stopped after one page. The cap belongs at the ceiling — what can be paged to overall — not at the current page. The tests missed it because the only one asserting a total used StartIndex=maxSearchLimit, where the window happens to equal the ceiling. * refactor(jellyfin): fold the search clamp into clampLimit and simplify mergeTypes Cleanup pass over the branch, no behaviour change: - clampSearchLimit was clampLimit (similar.go) with different constants, so the latter takes the default and ceiling as arguments and both call it. Similar's default moves out of three IntOr calls into defaultSimilarLimit. - mergeTypes derived a second `limit` and needed an early return for the empty page, only because paginate reads 0 as "unbounded". Clipping the merged slice to the window instead lets q.limit be passed straight through: window is min(offset+limit, ceiling), so clipping there is the same cut. - playlistTracks returned (result, handled, error) where handled=false always meant error=nil. Splitting the lookup out gives playlistTracksRepo returning (repo, ok), and the nilerr suppression goes with it. - The comment on playlists.Tracks blamed the log warning for its extra Get; the reason is that PlaylistRepository.Tracks discards the error behind a nil. Also drops a stale reference to a renamed variable. --- core/playlists/playlists.go | 16 ++ core/playlists/playlists_test.go | 22 ++ model/playlist.go | 5 + persistence/playlist_repository.go | 12 +- persistence/playlist_track_repository.go | 32 +++ persistence/playlist_track_repository_test.go | 61 +++++ server/jellyfin/api.go | 2 +- server/jellyfin/browsing.go | 5 +- server/jellyfin/browsing_test.go | 17 ++ server/jellyfin/items.go | 84 +++++-- server/jellyfin/items_test.go | 208 ++++++++++++++++++ server/jellyfin/playlists.go | 49 ++++- server/jellyfin/playlists_test.go | 46 +++- server/jellyfin/similar.go | 20 +- tests/mock_album_repo.go | 2 + tests/mock_playlist_track_repo.go | 64 +++++- 16 files changed, 591 insertions(+), 54 deletions(-) create mode 100644 persistence/playlist_track_repository_test.go diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 3da24706c..1ef083bbb 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -22,6 +22,7 @@ type Playlists interface { GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) Get(ctx context.Context, id string) (*model.Playlist, error) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) + Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) // Mutations @@ -98,6 +99,21 @@ func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) } +// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather +// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards +// its error behind a nil (and warns), and this is probed with ids that are usually not playlists. +func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { + repo := s.ds.Playlist(ctx) + if _, err := repo.Get(id); err != nil { + return nil, err + } + tracks := repo.Tracks(id, true) + if tracks == nil { + return nil, model.ErrNotFound + } + return tracks, nil +} + // --- Mutation operations --- // Create creates a new playlist (when name is provided) or replaces tracks on an existing diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index f849a0a21..0c9674bed 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -73,6 +73,28 @@ var _ = Describe("Playlists", func() { }) }) + Describe("Tracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + }) + + It("returns the playlist's track repository", func() { + Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks)) + }) + + It("returns ErrNotFound for an unknown or invisible playlist", func() { + _, err := ps.Tracks(ctx, "nonexistent") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Describe("Create", func() { BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ diff --git a/model/playlist.go b/model/playlist.go index f2586f52d..40adb8d0a 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -157,10 +157,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { return mfs } +type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] + type PlaylistTrackRepository interface { ResourceRepository + CountAll(options ...QueryOptions) (int64, error) GetAll(options ...QueryOptions) (PlaylistTracks, error) + GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error) GetAlbumIDs(options ...QueryOptions) ([]string, error) + GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9626aad6a..e39f0bbd3 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -298,10 +298,11 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { return nil } -func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { - sel = r.applyLibraryFilter(sel, "f") +// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. +func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder { + query = r.applyLibraryFilter(query, "f") userID := loggedUser(r.ctx).ID - tracksQuery := sel. + return query. Columns( "coalesce(starred, 0) as starred", "starred_at", @@ -321,8 +322,11 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). Where(Eq{"playlist_id": id}) +} + +func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) { tracks := dbPlaylistTracks{} - err := r.queryAll(tracksQuery, &tracks) + err := r.queryAll(r.tracksQuery(query, id), &tracks) if err != nil { return nil, err } diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a7062cc2..e51ff8ea6 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -77,6 +77,14 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool return p } +func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := Select(). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + return r.count(query, options...) +} + func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { query := Select(). LeftJoin("media_file f on f.id = media_file_id"). @@ -116,6 +124,30 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P return tracks, err } +func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId) + cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { + return t.PlaylistTrack + })), nil +} + +// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. +func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(options...).Columns("media_file_id"). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + var ids []string + if err := r.queryAllSlice(query, &ids); err != nil { + return nil, err + } + return ids, nil +} + func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go new file mode 100644 index 000000000..36f9ae4a9 --- /dev/null +++ b/persistence/playlist_track_repository_test.go @@ -0,0 +1,61 @@ +package persistence + +import ( + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PlaylistTrackRepository", func() { + var repo model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true) + }) + + Describe("GetCursor", func() { + It("yields the same tracks as GetAll", func() { + opts := model.QueryOptions{Sort: "id"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + + It("honors Max and Offset", func() { + opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(1)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + }) + + Describe("CountAll", func() { + It("returns the number of tracks in the playlist", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("ignores Max and Offset", func() { + Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) + }) + }) + + Describe("GetMediaFileIDs", func() { + It("returns the song ids in playlist order", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})). + To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) + }) + + It("honors Max and Offset", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). + To(Equal([]string{songRadioactivity.ID})) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d169a4c83..e94d64e85 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -102,6 +102,7 @@ func (api *Router) routes() http.Handler { r.Get("/Users/{userId}/Items/Latest", api.getLatest) r.Get("/Artists", api.getArtists) r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) }) r.Get("/Items/{itemId}", api.getItem) @@ -131,7 +132,6 @@ func (api *Router) routes() http.Handler { r.Post("/Playlists", api.createPlaylist) r.Get("/Playlists/{playlistId}", api.getPlaylist) r.Post("/Playlists/{playlistId}", api.updatePlaylist) - r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index d5a00e492..fae21fc0b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -33,7 +33,10 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol q := itemsQuery{ scopeIDs: scopeIDs, genreIds: decodedQueryIDs(r, "genreids"), - search: p.StringOr("searchterm", ""), + search: searchTerm(p), + } + if q.search != "" { + opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) } res, err := api.listArtists(ctx, opts, q, role) diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 7f50355e1..660e5d293 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -111,6 +111,23 @@ var _ = Describe("Browsing", func() { Expect(res.Items).To(HaveLen(1)) }) + It("bounds a search the client left unbounded, and clamps an oversized one", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + It("forwards StartIndex/Limit as Offset/Max", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 0dca48938..ff38b6491 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -24,6 +24,12 @@ import ( // album, artist and media_file). var notMissing = squirrel.Eq{"missing": false} +// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match +// everything" and materialize the library, where the unfiltered path streams. +func searchTerm(p *req.Values) string { + return strings.TrimSpace(p.StringOr("searchterm", "")) +} + func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { @@ -226,7 +232,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu fields: dto.ParseFields(p.StringOr("fields", "")), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), - search: p.StringOr("searchterm", ""), + search: searchTerm(p), sortBy: p.StringOr("sortby", ""), sortOrder: p.StringOr("sortorder", ""), offset: p.IntOr("startindex", 0), @@ -281,8 +287,11 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil } - if res, ok := api.playlistTracks(ctx, q); ok { - return res, nil + if repo, ok := api.playlistTracksRepo(ctx, q); ok { + return api.playlistTrackPage(repo, q.fields, q.offset, q.limit) + } + if q.search != "" { + q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) } if len(q.types) == 1 { opts := model.QueryOptions{Offset: q.offset, Max: q.limit} @@ -292,32 +301,39 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult return api.mergeTypes(ctx, q) } -// playlistTracks resolves a playlist parent to its tracks, whatever IncludeItemTypes says: Jellify -// opens a playlist with ParentId=<playlist>&IncludeItemTypes=Audio, and routing that through -// listSongs would treat the playlist id as an album id and return nothing. -func (api *Router) playlistTracks(ctx context.Context, q itemsQuery) (itemsResult, bool) { +// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a +// playlist with ParentId=<playlist>&IncludeItemTypes=Audio, and routing that through listSongs would +// treat the playlist id as an album id and return nothing. +// +// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type +// dispatch: ParentId is usually an album or artist. +func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { - return itemsResult{}, false + return nil, false } - pls, err := api.playlists.GetWithTracks(ctx, q.parentId) - if err != nil { - return itemsResult{}, false - } - // GetWithTracks enforces visibility (public or owned by the current user). - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, q.fields) }) - return materialized(result(paginate(items, q.offset, q.limit), len(items), q.offset)), true + // Tracks enforces visibility. + repo, err := api.playlists.Tracks(ctx, q.parentId) + return repo, err == nil } func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { // Each per-type query needs at most offset+limit rows (the worst case where one type fills the // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + window := 0 + if q.limit > 0 { + window = q.offset + q.limit + } + // A search can't stream, so the window is what each type materializes and StartIndex would drive + // it without bound. Only below the window are the merged rows the true order, hence the clip + // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. + if q.search != "" { + window = min(window, maxSearchLimit) + } var results []itemsResult total := 0 for _, itemType := range q.types { var opts model.QueryOptions - if q.limit > 0 { - opts.Max = q.offset + q.limit - } + opts.Max = window applySort(&opts, itemType, q.sortBy, q.sortOrder) res, err := api.queryItemsOfType(ctx, itemType, opts, q) if err != nil { @@ -339,6 +355,13 @@ func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, e } items = append(items, typeItems...) } + if q.search != "" { + // Past the window the merged order isn't the true one, so drop it rather than serve another + // type's rows. The total is what's pageable overall, not this page, or a client paging on it + // would stop after the first page. + items = items[:min(window, len(items))] + total = min(total, maxSearchLimit) + } return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil } @@ -412,20 +435,37 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without +// the ceiling, Limit=999999 still materializes every match. +const ( + defaultSearchLimit = 100 + maxSearchLimit = 2000 +) + +// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an +// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size). +// +// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger +// offset+limit window: bounding that would truncate each type before the merged page is cut. +func clampLimit(limit, def, ceiling int) int { + if limit <= 0 { + return def + } + return min(limit, ceiling) +} + // searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the // Search API returns no match count and CountAll can't see the search term. offset+len(rows) is // exact once matches end (and a growing lower bound before), so paging terminates at the last match. func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { fetch := opts - if fetch.Max > 0 { - fetch.Max++ - } + fetch.Max++ rows, err := search(fetch) if err != nil { return nil, 0, err } total := opts.Offset + len(rows) - if opts.Max > 0 && len(rows) > opts.Max { + if len(rows) > opts.Max { rows = rows[:opts.Max] } return rows, total, nil diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 801db9e3a..049151651 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -3,6 +3,7 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" @@ -71,6 +72,59 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) }) + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("pages a playlist parent's tracks in the query, not in memory", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + + It("falls through to the type dispatch when ParentId is not a playlist", func() { + fp.getErr = model.ErrNotFound + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) w := httptest.NewRecorder() @@ -220,6 +274,160 @@ var _ = Describe("Items", func() { Expect(res.Items).To(HaveLen(1)) }) + It("caps a search the client left unbounded", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + }) + + It("honors an explicit search Limit up to the ceiling", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(501)) + }) + + It("clamps a search Limit that would materialize the library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(albumRepo.SearchQuery).To(BeEmpty()) + }) + + It("reports a multi-type search total past the page, so clients keep paging", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(10)) + Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) + }) + + It("bounds the multi-type search window however large StartIndex is", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // Without the bound this asks each type for ~500001 rows. + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { + // Bounding the per-type window is what keeps StartIndex from driving it without limit, and + // past that window the merged order is no longer the true one. + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(maxSearchLimit)) + }) + + It("serves the last page below the ceiling in full", func() { + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + // Clipped to the window, and still the real row at that index — not the album behind it. + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + }) + + It("bounds an unbounded multi-type search to the default in total, not per type", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(defaultSearchLimit)) + }) + + It("pages an unbounded multi-type search past the default without dropping matches", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).ToNot(BeEmpty()) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+50].ID))) + }) + It("reports a search total beyond the fetched page instead of the page length", func() { ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index ffc2c6543..a157aae2e 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -125,6 +125,21 @@ func (api *Router) clearPlaylist(ctx context.Context, id string) error { return api.playlists.RemoveTracks(ctx, id, entryIDs) } +// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the +// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing +// tracks, and counts the same set, like GetWithTracks. +func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { + total, err := repo.CountAll(model.QueryOptions{Filters: notMissing}) + if err != nil { + return itemsResult{}, err + } + opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing} + open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) { + return repo.GetCursor(opts) + }, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + return streamed(open, int(total), offset), nil +} + // trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the // entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via // DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain @@ -136,17 +151,28 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { } // getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the -// edit screen). GetWithTracks enforces visibility; any error maps to 404 so private playlists can't +// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't // be probed. func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := dto.DecodeID(chi.URLParam(r, "playlistId")) - pls, err := api.playlists.GetWithTracks(ctx, id) + pls, err := api.playlists.Get(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return } - itemIds := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return dto.EncodeID(t.MediaFileID) }) + repo, err := api.playlists.Tracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // PlaylistInfo carries every track id, so this can't be paged — but it needs no track data. + trackIDs, err := repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Filters: notMissing}) + if err != nil { + api.internalError(w, r, err) + return + } + itemIds := slice.Map(trackIDs, dto.EncodeID) api.ok(w, r, dto.PlaylistInfo{ OpenAccess: pls.Public, Shares: []dto.PlaylistUserPermissions{}, @@ -154,19 +180,24 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { }) } -// getPlaylistItems relies on GetWithTracks to enforce visibility; any error maps to a generic 404 so -// a playlist id can't probe for private playlists. +// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a +// playlist id can't probe for private playlists. func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := dto.DecodeID(chi.URLParam(r, "playlistId")) - pls, err := api.playlists.GetWithTracks(ctx, id) + repo, err := api.playlists.Tracks(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return } - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)}) + p := req.Params(r) + fields := dto.ParseFields(p.StringOr("fields", "")) + res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) } // queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 804123003..27264b280 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -29,8 +29,9 @@ type fakePlaylists struct { createdIds []string createErr error - getPls *model.Playlist - getErr error + getPls *model.Playlist + getErr error + tracksRepo *tests.MockPlaylistTrackRepo getByIDPls *model.Playlist getByIDErr error @@ -92,6 +93,20 @@ func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playl return f.getPls, nil } +// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert +// what was pushed down to the query. +func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound + } + f.tracksRepo = &tests.MockPlaylistTrackRepo{} + f.tracksRepo.SetData(f.getPls.Tracks) + return f.tracksRepo, nil +} + func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { f.addPlaylistID = playlistID f.addIds = ids @@ -184,6 +199,30 @@ var _ = Describe("Playlists", func() { Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) }) + It("pages with StartIndex/Limit, pushing them down to the query", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylistItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + It("returns 404 for a non-owned or absent playlist", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() @@ -247,7 +286,7 @@ var _ = Describe("Playlists", func() { Describe("getPlaylist", func() { It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { - fp.getPls = &model.Playlist{ + pls := &model.Playlist{ ID: "pl1", Public: true, Tracks: model.PlaylistTracks{ @@ -255,6 +294,7 @@ var _ = Describe("Playlists", func() { {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, }, } + fp.getPls, fp.getByIDPls = pls, pls w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) r = withChiURLParam(r, "playlistId", "pl1") diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index db3671fe4..33cdc4c88 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -20,7 +20,10 @@ import ( // tests can shorten it. var similarWait = 10 * time.Second -const maxSimilarLimit = 100 +const ( + defaultSimilarLimit = 20 + maxSimilarLimit = 100 +) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine // indefinitely. @@ -51,7 +54,7 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch // returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { return api.similarArtists(ctx, id, limit) })) @@ -63,7 +66,7 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { @@ -88,7 +91,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { @@ -136,15 +139,6 @@ func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto return result(items, len(items), 0) } -// clampLimit bounds a client-supplied limit so it can't drive an oversized allocation or provider -// fetch (flagged by CodeQL as a user-controlled allocation size). -func clampLimit(limit int) int { - if limit <= 0 { - return 20 - } - return min(limit, maxSimilarLimit) -} - func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { songs, err := api.provider.SimilarSongs(ctx, id, limit) if err != nil { diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 6635881b7..03dfed879 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -20,6 +20,7 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions + SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID } @@ -134,6 +135,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { + m.SearchQuery = q if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index c11b077d2..2835baadd 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -1,9 +1,14 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) type MockPlaylistTrackRepo struct { model.PlaylistTrackRepository + Data model.PlaylistTracks + Options model.QueryOptions AddedIds []string DeletedIds []string Reordered bool @@ -11,6 +16,63 @@ type MockPlaylistTrackRepo struct { Err error } +func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { + m.Data = tracks +} + +// page applies Max/Offset as the real repository's SQL would. +func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.PlaylistTracks { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + m.Options = opts + } + tracks := m.Data + if opts.Offset >= len(tracks) { + return nil + } + tracks = tracks[opts.Offset:] + if opts.Max > 0 && opts.Max < len(tracks) { + tracks = tracks[:opts.Max] + } + return tracks +} + +func (m *MockPlaylistTrackRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockPlaylistTrackRepo) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { + if m.Err != nil { + return nil, m.Err + } + return m.page(options...), nil +} + +func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + if m.Err != nil { + return nil, m.Err + } + tracks := m.page(options...) + return func(yield func(model.PlaylistTrack, error) bool) { + for _, t := range tracks { + if !yield(t, nil) { + return + } + } + }, nil +} + +func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil +} + func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { m.AddedIds = append(m.AddedIds, ids...) if m.Err != nil { From adeaa93e7e03b6b98dc4098812a0c8a9c58adaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 15 Jul 2026 19:02:12 -0400 Subject: [PATCH 366/596] fix(jellyfin): honor Recursive=false for library parents (#5788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): honor Recursive=false for library parents /Items ignored the Recursive parameter entirely — it appeared only in tests, never in production code. Finamp's download sync sends exactly one Recursive=false request per library (ParentId=<library>&IncludeItemTypes=Audio) to pick up tracks outside any album, supplementing its recursive per-album fetch. We answered with every song in the library: 208MB and 21s per sync measured on a real library, against 20KB for the album queries. Finamp then required every track twice, once via its album and once under the library node. In Jellyfin 10.10 Recursive never reaches SQL. It selects between an in-memory walk of a folder's direct Children (false) and a DB ancestor query (true), with IncludeItemTypes applied as a post-filter over that direct-child list (ItemsController.cs:308, Folder.cs:949-994). The default is false, and neither IncludeItemTypes nor SearchTerm forces recursion, so Recursive=false with IncludeItemTypes=Audio on a music library returns an empty list — which is what Finamp expects and codes for. Filter the requested types to those nested directly under a library when the parent is a library and Recursive is not true; the hierarchy this API exposes is library -> album -> track, so no track is ever a library's direct child. Album and playlist parents are untouched, as their tracks are real direct children in Jellyfin (MusicAlbum.cs:86-89, Playlist.cs:140-167) and Jellify opens playlists with Recursive=false. A library parent is the only case handled: /Items with no ParentId still returns every song where Jellyfin returns the root's children, but no observed client sends that, and honoring it would surprise any client that simply omits Recursive. * refactor(jellyfin): narrow the Recursive=false filter to Audio Replace the libraryChildTypes allowlist with a direct Audio check. The two are behaviorally identical — parseTypes only ever yields Audio, MusicArtist, MusicAlbum, MusicGenre or Playlist, and the allowlist held the latter four, so it excluded exactly Audio and nothing else. The allowlist claimed those four are a library's direct children. That isn't true of MusicGenre or Playlist: listGenres is deliberately unscoped because genres are global tags, and listPlaylists ignores scopeIDs entirely, so neither is nested under a library at all. They were on the list only to leave their behavior untouched. The one verified invariant is that no track is a library's direct child, so state just that — it is also more conservative, as a type added later keeps its current behavior instead of being filtered by a stale list. Add a test pinning the omitted-Recursive default: ItemsController binds `bool? recursive` and reads it as `recursive ?? false`, so omitting the param is a non-recursive request and filters Audio for a library parent. --- server/jellyfin/e2e/browsing_test.go | 22 ++++++++ server/jellyfin/items.go | 6 +++ server/jellyfin/items_test.go | 75 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 646de4497..0b2769856 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -118,6 +118,28 @@ var _ = Describe("Browsing", func() { }) }) + // Finamp's download sync asks a library for the tracks outside any album this way; answering + // with every track would stream the whole library. + Describe("Recursive=false", func() { + lib1 := enc("1") + + It("returns no songs for a library parent", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(BeZero()) + }) + + It("still lists the library's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("still lists an album's tracks", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + }) + // Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=<artistId>. Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index ff38b6491..43c8de12c 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -254,6 +254,12 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu q.types = parseTypes(q.rawTypes) q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + // Recursive=false asks for direct children only, and no track is a library's direct child. + // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. + if q.isLibraryParent && !p.BoolOr("recursive", false) { + q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) + } + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 049151651..401145461 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -133,6 +133,81 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + // Recursive=false asks for direct children only. Finamp's sync probes a library this way + // looking for tracks outside any album; answering with every track streams the whole library. + Describe("Recursive=false", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + }) + + It("returns no songs for a library parent, as tracks are never its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(BeZero()) + }) + + It("drops only Audio from a multi-type library query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("still lists albums for a library parent, as they are its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("still lists an album's tracks, as they are its direct children", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("keeps returning every song when no parent scopes the query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + // Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as + // `recursive ?? false`, so an omitted Recursive is a non-recursive request. + It("treats an omitted Recursive as false, like Jellyfin", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + }) + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) w := httptest.NewRecorder() From 09022b4bd29a722ca624e45214a78a0b97f87143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 15 Jul 2026 20:44:56 -0400 Subject: [PATCH 367/596] feat(jellyfin): AudioMuse-AI compatible sonic endpoints (#5782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(jellyfin): inject core/sonic into the Jellyfin Router * feat(jellyfin): add AudioMuse /info endpoint * feat(jellyfin): add AudioMuse /similar_tracks endpoint * feat(jellyfin): gate AudioMuse endpoints on sonic provider * feat(jellyfin): add AudioMuse /find_path endpoint * fix(jellyfin): fix case-insensitive route collision across positions canonicalRouteSegments keyed canonical case by lower-cased segment name alone, globally. Two unrelated routes sharing a segment name with different casing at different tree depths (e.g. "Info" in /System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote each other, 404-ing the loser even for exact-case requests. Replace the flat map with a position-aware trie mirroring the routing tree. * test(jellyfin): e2e tests for AudioMuse endpoints * docs(jellyfin): document AudioMuse compatibility endpoints * test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups) - Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's AvailableEndpoints so it keeps serializing as [] rather than null, and add a raw-body assertion to the existing empty-list test to catch a regression a struct-only unmarshal can't detect. - Cover the previously-untested engine-error branch in similar_tracks and find_path, both of which degrade to an empty result. - Document that find_path's path/total_distance only reflect hops through libraries the caller can access in multi-library setups. * refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map * refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New The Jellyfin Router's sonic field was an interface but New() took the concrete *sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the only injected dependency that did. Move the interface (sonic.Engine) beside its implementation, take it in New() like every other service, and bind it in wire. * refactor(jellyfin): case-insensitive routing via lowercased paths Replace the position-aware route trie with a trivial middleware that lowercases the request path, and register every route in lowercase. Simpler, and no segment name can collide across positions. caseInsensitivePaths moves into middlewares.go alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment carries case-sensitive data (all ids are lowercase hex via dto.EncodeID). * feat(jellyfin): add AudioMuse /health endpoint A liveness probe matching the reference plugin: 200 with an empty body when a SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now advertises it (list alphabetized like the plugin's OrderBy). Also trims the AudioMuse and case-insensitive-routing comments to their essential rationale. * fix(jellyfin): hex-encode user IDs so lowercased paths stay valid Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62, uppercase-capable), so lowercasing request paths could alter a userId segment. Encode them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase hex' invariant true — no routing special-casing needed. Also caps user-controlled n / max_steps, fixes the songAgent test comment, and adds leading slashes to the README endpoint list. --- cmd/wire_gen.go | 5 +- cmd/wire_injectors.go | 1 + core/sonic/sonic.go | 9 + server/jellyfin/README.md | 23 ++ server/jellyfin/api.go | 127 +++++---- server/jellyfin/api_test.go | 10 +- server/jellyfin/audiomuse.go | 161 +++++++++++ server/jellyfin/audiomuse_test.go | 250 ++++++++++++++++++ server/jellyfin/auth.go | 2 +- server/jellyfin/case_insensitive_routes.go | 68 ----- .../jellyfin/case_insensitive_routes_test.go | 90 ------- server/jellyfin/e2e/audiomuse_test.go | 113 ++++++++ server/jellyfin/e2e/auth_test.go | 6 +- server/jellyfin/e2e/e2e_suite_test.go | 67 ++++- server/jellyfin/e2e/playlists_test.go | 2 +- server/jellyfin/middlewares.go | 17 ++ server/jellyfin/middlewares_test.go | 59 +++++ server/jellyfin/playlists.go | 2 +- server/jellyfin/playlists_test.go | 2 +- server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/users.go | 2 +- server/jellyfin/users_test.go | 2 +- 23 files changed, 780 insertions(+), 242 deletions(-) create mode 100644 server/jellyfin/audiomuse.go create mode 100644 server/jellyfin/audiomuse_test.go delete mode 100644 server/jellyfin/case_insensitive_routes.go delete mode 100644 server/jellyfin/case_insensitive_routes_test.go create mode 100644 server/jellyfin/e2e/audiomuse_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4a2b46289..bd211cbfc 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -136,7 +136,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) return router } @@ -245,7 +246,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 0f6b73891..94faa5af3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -51,6 +51,7 @@ var allProviders = wire.NewSet( wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go index 19eb69c65..67f5cc7da 100644 --- a/core/sonic/sonic.go +++ b/core/sonic/sonic.go @@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher } } +// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. +type Engine interface { + HasProvider() bool + GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) + FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) +} + +var _ Engine = (*Sonic)(nil) + func (s *Sonic) HasProvider() bool { return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 } diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index fb5c4a637..2e4c19950 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -215,6 +215,29 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are advertised and served but packed-audio players won't decode them. +## AudioMuse-AI compatible endpoints + +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no +external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` +plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. + +- `GET /AudioMuseAI/info` — returns `{"Version": <navidrome version>, "AvailableEndpoints": [...]}` (200). + `AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty. +- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404. +- `GET /AudioMuseAI/similar_tracks?item_id=<id>&n=10&eliminate_duplicates=true` — 404 when no provider is + loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match + or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist. +- `GET /AudioMuseAI/find_path?start_song_id=<id>&end_song_id=<id>&max_steps=25` — 404 when no provider is + loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400 + with `start_song_id and end_song_id are required.` when either id is missing. + +`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients. +`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features +(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library +setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries +the caller can access, since hops through inaccessible libraries are filtered out of the result. + ## curl walkthrough This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index e94d64e85..0740901aa 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -32,6 +33,7 @@ type Router struct { scrobbler scrobbler.PlayTracker playlists playlists.Playlists provider external.Provider + sonic sonic.Engine similarFlight singleflight.Group serverIDMu sync.Mutex serverIDVal string @@ -39,10 +41,12 @@ type Router struct { func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, - scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider) *Router { + scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, + sonicSvc sonic.Engine) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + sonic: sonicSvc, } r.Handler = r.routes() return r @@ -55,20 +59,22 @@ func (api *Router) routes() http.Handler { // handler and the api_key check see folded keys. inner.Use(normalizeQueryKeys) + // Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase. + // Public (no auth): handshake + login. - inner.Get("/System/Info/Public", api.getPublicSystemInfo) - inner.Get("/System/Ping", api.ping) - inner.Post("/System/Ping", api.ping) - inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + inner.Get("/system/info/public", api.getPublicSystemInfo) + inner.Get("/system/ping", api.ping) + inner.Post("/system/ping", api.ping) + inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated // brute-force surface, so it must share the same per-IP throttle when one is configured. if conf.Server.AuthRequestLimit > 0 { limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) - inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName) + inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) } else { - inner.Post("/Users/AuthenticateByName", api.authenticateByName) + inner.Post("/users/authenticatebyname", api.authenticateByName) } - inner.Get("/Users/Public", api.getPublicUsers) + inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, @@ -76,8 +82,8 @@ func (api *Router) routes() http.Handler { inner.Group(func(r chi.Router) { r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, conf.Server.DevArtworkThrottleBacklogTimeout)) - r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) - r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) }) inner.Group(func(r chi.Router) { @@ -86,10 +92,10 @@ func (api *Router) routes() http.Handler { // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a // player) even before the first playback report. r.Use(api.withPlayer) - r.Get("/UserViews", api.getUserViews) - r.Get("/Users/{userId}/Views", api.getUserViews) - r.Get("/Users/Me", api.getCurrentUser) - r.Get("/Users/{userId}", api.getCurrentUser) + r.Get("/userviews", api.getUserViews) + r.Get("/users/{userId}/views", api.getUserViews) + r.Get("/users/me", api.getCurrentUser) + r.Get("/users/{userId}", api.getCurrentUser) // Cursor-backed collections: each streams straight from the DB, holding a connection for the // whole client-paced response, so enough slow clients would take the entire pool and stall the @@ -97,70 +103,75 @@ func (api *Router) routes() http.Handler { // requests queue rather than fail. r.Group(func(r chi.Router) { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) - r.Get("/Items", api.getItems) - r.Get("/Users/{userId}/Items", api.getItems) - r.Get("/Users/{userId}/Items/Latest", api.getLatest) - r.Get("/Artists", api.getArtists) - r.Get("/Artists/AlbumArtists", api.getAlbumArtists) - r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) + r.Get("/items", api.getItems) + r.Get("/users/{userId}/items", api.getItems) + r.Get("/users/{userId}/items/latest", api.getLatest) + r.Get("/artists", api.getArtists) + r.Get("/artists/albumartists", api.getAlbumArtists) + r.Get("/playlists/{playlistId}/items", api.getPlaylistItems) }) - r.Get("/Items/{itemId}", api.getItem) - r.Get("/Users/{userId}/Items/{itemId}", api.getItem) - r.Delete("/Items/{itemId}", api.deleteItem) + r.Get("/items/{itemId}", api.getItem) + r.Get("/users/{userId}/items/{itemId}", api.getItem) + r.Delete("/items/{itemId}", api.deleteItem) // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. - r.Post("/UserFavoriteItems/{itemId}", api.markFavorite) - r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite) - r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite) - r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite) - r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating) - r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating) + r.Post("/userfavoriteitems/{itemId}", api.markFavorite) + r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) + r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) + r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) // Per-item play/favorite/rating state. Jellify uses the /UserItems form; // /Users/{userId}/Items is the legacy spelling. - r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) - r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) + r.Get("/useritems/{itemId}/userdata", api.getUserItemData) + r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) - r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) - r.Get("/Items/{itemId}/Similar", api.getSimilarItems) - r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) - r.Get("/Genres", api.getGenres) - r.Get("/MusicGenres", api.getGenres) + r.Get("/artists/{itemId}/similar", api.getSimilarArtists) + r.Get("/items/{itemId}/similar", api.getSimilarItems) + r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/genres", api.getGenres) + r.Get("/musicgenres", api.getGenres) - r.Post("/Playlists", api.createPlaylist) - r.Get("/Playlists/{playlistId}", api.getPlaylist) - r.Post("/Playlists/{playlistId}", api.updatePlaylist) - r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) - r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) - r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) - r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser) + r.Post("/playlists", api.createPlaylist) + r.Get("/playlists/{playlistId}", api.getPlaylist) + r.Post("/playlists/{playlistId}", api.updatePlaylist) + r.Post("/playlists/{playlistId}/items", api.addToPlaylist) + r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) + r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes // above stay public. - r.Post("/Items/{itemId}/Images/{type}", api.postItemImage) - r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage) + r.Post("/items/{itemId}/images/{type}", api.postItemImage) + r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage) - r.Get("/Audio/{itemId}/stream", api.streamAudio) - r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/Audio/{itemId}/universal", api.streamAudio) - r.Get("/Audio/{itemId}/main.m3u8", api.streamHls) - r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) - r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + r.Get("/audio/{itemId}/stream", api.streamAudio) + r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. - r.Get("/Items/{itemId}/File", api.streamFile) - r.Get("/Items/{itemId}/Download", api.streamFile) + r.Get("/items/{itemId}/file", api.streamFile) + r.Get("/items/{itemId}/download", api.streamFile) - r.Post("/Sessions/Playing", api.reportPlaybackStart) - r.Post("/Sessions/Playing/Progress", api.reportPlaybackProgress) - r.Post("/Sessions/Playing/Stopped", api.reportPlaybackStopped) - r.Post("/Sessions/Capabilities", api.postCapabilities) - r.Post("/Sessions/Capabilities/Full", api.postCapabilities) + r.Post("/sessions/playing", api.reportPlaybackStart) + r.Post("/sessions/playing/progress", api.reportPlaybackProgress) + r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) + r.Post("/sessions/capabilities", api.postCapabilities) + r.Post("/sessions/capabilities/full", api.postCapabilities) // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. r.Get("/socket", api.handleSocket) + + r.Get("/audiomuseai/info", api.audioMuseInfo) + r.Get("/audiomuseai/health", api.audioMuseHealth) + r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks) + r.Get("/audiomuseai/find_path", api.audioMuseFindPath) }) // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 23504c73e..1b390e75a 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go new file mode 100644 index 000000000..b01a4bf92 --- /dev/null +++ b/server/jellyfin/audiomuse.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin, +// and is sorted the same way (the plugin builds it with OrderBy). +var audioMuseEndpoints = []string{ + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", +} + +type audioMuseInfoResponse struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` +} + +func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) { + endpoints := []string{} // non-nil so an empty list serializes as [], not null + if api.sonic != nil && api.sonic.HasProvider() { + endpoints = audioMuseEndpoints + } + api.ok(w, r, audioMuseInfoResponse{ + Version: consts.Version, + AvailableEndpoints: endpoints, + }) +} + +// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else +// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable. +func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) { + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + w.WriteHeader(http.StatusOK) +} + +type audioMuseSimilarTrack struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` +} + +func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // 404 without a provider, like the Subsonic sonicSimilarity handlers. + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + tracks := []audioMuseSimilarTrack{} + + itemID := p.StringOr("item_id", "") + if itemID == "" { + api.ok(w, r, tracks) + return + } + + id := api.resolveItemID(ctx, dto.DecodeID(itemID)) + n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit + eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n) + if err != nil { + api.ok(w, r, tracks) + return + } + + u, _ := request.UserFrom(ctx) + seenArtists := make(map[string]bool, len(matches)) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + if eliminateDuplicates { + key := strings.ToLower(mf.Artist) + if seenArtists[key] { + continue + } + seenArtists[key] = true + } + tracks = append(tracks, audioMuseSimilarTrack{ + Author: mf.Artist, + Distance: m.Similarity, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + }) + } + api.ok(w, r, tracks) +} + +type audioMusePathTrack struct { + Author string `json:"author"` + ItemID string `json:"item_id"` + Title string `json:"title"` + Tempo *float64 `json:"tempo,omitempty"` +} + +type audioMusePathResponse struct { + Path []audioMusePathTrack `json:"path"` + TotalDistance float64 `json:"total_distance"` +} + +func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + + startID := p.StringOr("start_song_id", "") + endID := p.StringOr("end_song_id", "") + if startID == "" || endID == "" { + http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest) + return + } + + resp := audioMusePathResponse{Path: []audioMusePathTrack{}} + maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count + matches, err := api.sonic.FindSonicPath(ctx, + api.resolveItemID(ctx, dto.DecodeID(startID)), + api.resolveItemID(ctx, dto.DecodeID(endID)), + maxSteps) + if err != nil { + api.ok(w, r, resp) + return + } + + u, _ := request.UserFrom(ctx) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + track := audioMusePathTrack{ + Author: mf.Artist, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + } + if mf.BPM != nil { + tempo := float64(*mf.BPM) + track.Tempo = &tempo + } + resp.Path = append(resp.Path, track) + resp.TotalDistance += m.Similarity + } + api.ok(w, r, resp) +} diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go new file mode 100644 index 000000000..e9d6d4e85 --- /dev/null +++ b/server/jellyfin/audiomuse_test.go @@ -0,0 +1,250 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse info", func() { + It("lists the sonic endpoints (excluding info) when a provider is present", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("returns an empty endpoint list when no provider is loaded", func() { + api := &Router{} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.AvailableEndpoints).To(BeEmpty()) + Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`)) + }) +}) + +type fakeSonicEngine struct { + provider bool + similar []sonic.SimilarMatch + similarErr error + path []sonic.SimilarMatch + pathErr error + gotID string + gotStart string + gotEnd string + gotCount int +} + +func (f *fakeSonicEngine) HasProvider() bool { return f.provider } + +func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) { + f.gotID, f.gotCount = id, count + return f.similar, f.similarErr +} + +func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) { + f.gotStart, f.gotEnd, f.gotCount = startID, endID, count + return f.path, f.pathErr +} + +func mf(id, artist, title string, lib int) model.MediaFile { + return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib} +} + +var _ = Describe("AudioMuse health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("returns 404 when no provider is loaded", func() { + api := &Router{} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) +}) + +// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request. +func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", path+"?"+query, nil) + r = r.WithContext(request.WithUser(r.Context(), user)) + invoke(handler, w, r) + return w +} + +var _ = Describe("AudioMuse similar_tracks", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotID).To(Equal("seed")) + Expect(fake.gotCount).To(Equal(5)) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + Expect(body[0]).To(Equal(audioMuseSimilarTrack{ + Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1", + })) + }) + + It("collapses to one track per artist when eliminate_duplicates defaults on", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(1)) + }) + + It("keeps same-artist tracks when eliminate_duplicates=false", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + }) + + It("filters out tracks in libraries the user cannot access", func() { + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}}) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("returns an empty array without calling the engine when item_id is missing", func() { + w := call("n=5", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + Expect(fake.gotID).To(Equal("")) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty array when the engine errors", func() { + fake.similarErr = errors.New("boom") + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) +}) + +var _ = Describe("AudioMuse find_path", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("returns 400 with the exact message when start_song_id is missing", func() { + w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns 400 when end_song_id is missing", func() { + w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + }) + + It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { + bpm := 120 + withBPM := mf("mf1", "A", "T1", 1) + withBPM.BPM = &bpm + fake.path = []sonic.SimilarMatch{ + {MediaFile: withBPM, Similarity: 1.5}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0}, + } + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotStart).To(Equal("s")) + Expect(fake.gotEnd).To(Equal("e")) + Expect(fake.gotCount).To(Equal(10)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1"))) + Expect(*body.Path[0].Tempo).To(Equal(120.0)) + Expect(body.Path[1].Tempo).To(BeNil()) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty path object when the engine errors", func() { + fake.pathErr = errors.New("boom") + fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}} + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(BeEmpty()) + Expect(body.TotalDistance).To(Equal(0.0)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index ecd129222..062ac6458 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -56,7 +56,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { return &dto.UserDto{ Name: u.UserName, - Id: u.ID, + Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid ServerId: serverID, ServerName: serverName, HasPassword: true, diff --git a/server/jellyfin/case_insensitive_routes.go b/server/jellyfin/case_insensitive_routes.go deleted file mode 100644 index cc95b526f..000000000 --- a/server/jellyfin/case_insensitive_routes.go +++ /dev/null @@ -1,68 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/go-chi/chi/v5" -) - -// caseInsensitivePaths normalizes each request path's literal segments to the case they were -// registered with before delegating to r, since Jellyfin clients route case-insensitively but -// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id -// segments pass through untouched. -func caseInsensitivePaths(r chi.Router) http.Handler { - canon := canonicalRouteSegments(r) - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - normalizeRequestPath(req, canon) - r.ServeHTTP(w, req) - }) -} - -// canonicalRouteSegments walks every registered route and records, for each literal (non-param) -// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. -// "audio" -> "Audio"). -func canonicalRouteSegments(router chi.Router) map[string]string { - canon := map[string]string{} - _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { - for seg := range strings.SplitSeq(route, "/") { - if seg == "" || strings.Contains(seg, "{") { - continue - } - canon[strings.ToLower(seg)] = seg - } - return nil - }) - return canon -} - -// normalizeRequestPath rewrites literal path segments to the case routes were registered with. -// It must run before chi's matching. When the router is mounted under a parent, chi has already -// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so -// that's what must be normalized here. -func normalizeRequestPath(r *http.Request, canon map[string]string) { - if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { - rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) - return - } - r.URL.Path = normalizeCase(r.URL.Path, canon) -} - -// normalizeCase rewrites each "/"-separated literal segment of path to the case it was -// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. -// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"), -// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case -// the extension so chi's case-sensitive match still hits. -func normalizeCase(path string, canon map[string]string) string { - segs := strings.Split(path, "/") - for i, seg := range segs { - if canonical, ok := canon[strings.ToLower(seg)]; ok { - segs[i] = canonical - } else if prefix, suffix, found := strings.Cut(seg, "."); found { - if canonical, ok := canon[strings.ToLower(prefix)]; ok { - segs[i] = canonical + "." + strings.ToLower(suffix) - } - } - } - return strings.Join(segs, "/") -} diff --git a/server/jellyfin/case_insensitive_routes_test.go b/server/jellyfin/case_insensitive_routes_test.go deleted file mode 100644 index 4c9140f83..000000000 --- a/server/jellyfin/case_insensitive_routes_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/chi/v5" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("caseInsensitivePaths", func() { - var handler http.Handler - var gotID string - - var gotContainer string - - BeforeEach(func() { - gotID = "" - gotContainer = "" - r := chi.NewRouter() - r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { - gotID = chi.URLParam(req, "id") - w.WriteHeader(http.StatusOK) - }) - // A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream" - // literal prefix is registered separately via the bare /Foo/{id}/stream route below. - r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - }) - r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { - gotContainer = chi.URLParam(req, "container") - w.WriteHeader(http.StatusOK) - }) - handler = caseInsensitivePaths(r) - }) - - It("normalizes the literal prefix of a mixed literal.param segment", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotContainer).To(Equal("mp3")) - }) - - It("matches a lower-cased request path against mixed-case registered literals", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/bar", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("preserves the id segment's original casing", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/bar", nil) - handler.ServeHTTP(w, r) - Expect(gotID).To(Equal("ID")) - }) - - It("leaves a real mixed-case id untouched while still matching literals", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) - }) -}) - -var _ = Describe("normalizeCase", func() { - It("rewrites known literal segments to their canonical case", func() { - canon := map[string]string{ - "audio": "Audio", - "stream": "stream", - } - got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) - }) - - It("normalizes the literal prefix of a mixed literal.extension segment", func() { - canon := map[string]string{"audio": "Audio", "stream": "stream"} - got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3")) - }) - - It("leaves a dotted segment untouched when its prefix isn't a known literal", func() { - canon := map[string]string{"audio": "Audio"} - got := normalizeCase("/audio/some.file.id", canon) - Expect(got).To(Equal("/Audio/some.file.id")) - }) -}) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go new file mode 100644 index 000000000..383dbab83 --- /dev/null +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -0,0 +1,113 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse endpoints", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /AudioMuseAI/info", func() { + It("returns version and available endpoints", func() { + var body struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` + } + parseInto(get("/AudioMuseAI/info"), &body) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + w := get("/AudioMuseAI/health") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/similar_tracks", func() { + It("maps provider results to seeded tracks, encoding item ids", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("So What"), Similarity: 0.5}, + } + var body []struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` + } + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) + Expect(body).To(HaveLen(2)) + Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) + Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title))) + }) + + It("collapses to one track per artist by default", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("Come Together"), Similarity: 0.5}, + } + var body []map[string]any + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body) + Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles + }) + + It("returns an empty array (not null) when there are no results", func() { + sonicProviderFake.similar = nil + w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together"))) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/find_path", func() { + It("returns 400 with the exact message when a required id is missing", func() { + w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something"))) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns the path and summed total_distance", func() { + sonicProviderFake.path = []sonic.SimilarResult{ + {Song: songAgent("Come Together"), Similarity: 1.5}, + {Song: songAgent("So What"), Similarity: 2.0}, + } + var body struct { + Path []struct { + ItemID string `json:"item_id"` + Title string `json:"title"` + } `json:"path"` + TotalDistance float64 `json:"total_distance"` + } + parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index f66833af2..7128972ba 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -27,7 +27,7 @@ var _ = Describe("Authentication", func() { Expect(res.AccessToken).ToNot(BeEmpty()) Expect(res.User).ToNot(BeNil()) Expect(res.User.Name).To(Equal("admin")) - Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Id).To(Equal(enc("admin-1"))) Expect(res.User.Policy.IsAdministrator).To(BeTrue()) Expect(res.ServerId).ToNot(BeEmpty()) @@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() { users := publicUsers() Expect(users).To(HaveLen(1)) Expect(users[0].Name).To(Equal("regular")) - Expect(users[0].Id).To(Equal("regular-1")) + Expect(users[0].Id).To(Equal(enc("regular-1"))) Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login }) }) @@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() { var u dto.UserDto parseInto(getAs(regularUser, "/Users/Me"), &u) Expect(u.Name).To(Equal("regular")) - Expect(u.Id).To(Equal("regular-1")) + Expect(u.Id).To(Equal(enc("regular-1"))) }) It("returns the caller from GET /Users/{userId}", func() { diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 02c5183f8..ea47dcdaa 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -38,11 +38,14 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" @@ -77,14 +80,15 @@ var ( // Shared test state var ( - ctx context.Context - ds *tests.MockDataStore - router http.Handler - streamerSpy *harness.SpyStreamer - artworkSpy *spyArtwork - providerFake *fakeExternalProvider - goldenDB *harness.DB - dataFolder string + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + sonicProviderFake *fakeSonicProvider + goldenDB *harness.DB + dataFolder string adminUser = model.User{ ID: "admin-1", @@ -308,6 +312,8 @@ func setupTestDB() { streamerSpy = &harness.SpyStreamer{} artworkSpy = &spyArtwork{} providerFake = &fakeExternalProvider{} + sonicProviderFake = &fakeSonicProvider{} + sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds)) decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) router = jellyfin.New( ds, @@ -318,6 +324,7 @@ func setupTestDB() { scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), playlists.NewPlaylists(ds, core.NewImageUploadService()), providerFake, + sonicSvc, ) } @@ -338,6 +345,50 @@ func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model return f.similarSongs, nil } +// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are +// active in e2e; the provider it hands back returns test-configured results. +type fakeSonicLoader struct{ provider sonic.Provider } + +func (f *fakeSonicLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return []string{"fake"} + } + return nil +} + +func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) { + return f.provider, true +} + +// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the +// agents.Song results; the real matcher resolves them back to seeded library tracks. +type fakeSonicProvider struct { + similar []sonic.SimilarResult + path []sonic.SimilarResult +} + +func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.similar, nil +} + +func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.path, nil +} + +// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an +// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. +func songAgent(title string) agents.Song { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}} + } + } + Fail("song not found: " + title) + return agents.Song{} +} + // --- Spy/noop dependencies (shared ones live in tests/harness) --- // spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 73b76e3d8..d2ff49db9 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { var perms []dto.PlaylistUserPermissions parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) Expect(perms).To(HaveLen(1)) - Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].UserId).To(Equal(enc("admin-1"))) Expect(perms[0].CanEdit).To(BeTrue()) }) }) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index d9e41d252..840143ac8 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -29,6 +30,22 @@ func throttleStreams(limit int) func(http.Handler) http.Handler { return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) } +// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the +// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param +// segments too, which is safe because every id the API emits — user ids included — is lowercase hex +// (dto.EncodeID). +func caseInsensitivePaths(r chi.Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path. + if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = strings.ToLower(rctx.RoutePath) + } else { + req.URL.Path = strings.ToLower(req.URL.Path) + } + r.ServeHTTP(w, req) + }) +} + // normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params // case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, // Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index c766bff70..f3aa65d6f 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -321,3 +322,61 @@ var _ = Describe("throttleStreams", func() { Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) }) }) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID, gotContainer string + + BeforeEach(func() { + gotID, gotContainer = "", "" + r := chi.NewRouter() + // Routes are registered lowercase, mirroring the real router. + r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + // A second route reusing the "bar" segment name at a different position. + r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + serve := func(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + return w + } + + It("routes a mixed-case request to its lowercase-registered route", func() { + Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK)) + }) + + It("routes both routes that share a segment name, regardless of casing", func() { + Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK)) + Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK)) + }) + + It("lowercases the mixed literal.extension segment so the route and container match", func() { + w := serve("/Audio/abc/STREAM.MP3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() { + serve("/foo/DEADBEEF/bar") + Expect(gotID).To(Equal("deadbeef")) + }) + + It("normalizes the RoutePath branch when mounted under a parent", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", handler) + w := httptest.NewRecorder() + parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index a157aae2e..084ff2975 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -279,7 +279,7 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { // enforced by AddTracks/RemoveTracks. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { u, _ := request.UserFrom(r.Context()) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: u.ID, CanEdit: true}}) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) } func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 27264b280..7770cb6db 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -442,7 +442,7 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusOK)) var res []dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}})) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}})) }) }) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index e3c9903a8..e007dedf4 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 91542c111..d9d74bcb9 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index 5f231e0b5..bbc60c892 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -52,7 +52,7 @@ func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { } users = append(users, dto.UserDto{ Name: usr.UserName, - Id: usr.ID, + Id: dto.EncodeID(usr.ID), ServerId: serverID, HasPassword: true, }) diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 2b7177044..6a1597b70 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -106,7 +106,7 @@ var _ = Describe("Users", func() { users := publicUsers() Expect(users).To(HaveLen(2)) Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal("u2")) + Expect(users[0].Id).To(Equal(dto.EncodeID("u2"))) Expect(users[1].Name).To(Equal("alice")) // The public list must not expose Policy/Configuration to unauthenticated callers. Expect(users[0].Policy).To(BeNil()) From 1d5efdd5a0408cae8a31f80c11d78afd5d18a388 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 15 Jul 2026 21:21:26 -0400 Subject: [PATCH 368/596] fix(jellyfin): don't truncate InstantMix to the Similar ceiling Finamp's Radio Mix requests /Items/{id}/InstantMix?limit=250, but getInstantMix clamped the limit to maxSimilarLimit (100), so the queue was cut to 100 tracks. A mix is a playback queue, not a "related items" list, so it gets its own ceiling instead of sharing the Similar one. The Similar handlers keep 100. Verified against a live library: the sonic provider supplies all 250 tracks for a seed that previously returned 100. --- server/jellyfin/similar.go | 5 ++++- server/jellyfin/similar_test.go | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 33cdc4c88..085080850 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -23,6 +23,9 @@ var similarWait = 10 * time.Second const ( defaultSimilarLimit = 20 maxSimilarLimit = 100 + // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the + // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. + maxInstantMixLimit = 500 ) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine @@ -91,7 +94,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index cbb68e5cf..302566195 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -3,7 +3,9 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http/httptest" + "strconv" "sync/atomic" "time" @@ -103,6 +105,16 @@ func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.Med return nil, nil } +// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. +type fakeSimilarProvider struct { + external.Provider + songs model.MediaFiles +} + +func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { + return p.songs[:min(count, len(p.songs))], nil +} + var _ = Describe("getInstantMix", func() { It("returns the seed track even when the provider fetch exceeds the wait", func() { old := similarWait @@ -128,4 +140,28 @@ var _ = Describe("getInstantMix", func() { Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Name).To(Equal("Seed Song")) }) + + // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the + // queue, so InstantMix gets its own, higher ceiling. + It("honors a mix-sized limit above the Similar ceiling", func() { + const want = 250 + songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + for i := range want + 50 { // more than requested, so only the limit bounds the result + songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + } + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") + Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") + }) }) From caa0043030a02079e463b0827e3510c87a5771f5 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 15 Jul 2026 23:25:55 -0400 Subject: [PATCH 369/596] fix(jellyfin): honor AlbumIds filter on /Items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishin fetches an album's tracks with AlbumIds=<id>&IncludeItemTypes=Audio&Recursive=true, but the /Items handler never read the AlbumIds parameter, so the request degenerated into the entire library sorted by album: every track (with MediaSources, when requested) was counted and streamed for what should be one album's worth of songs — very slow on large libraries, and wrong results. Parse AlbumIds like GenreIds (comma-separated and repeated spellings, hex-decoded) and filter songs through a new filter.ByAlbumID helper, keeping the album_id column knowledge in the filter package. --- server/filter/filters.go | 5 +++++ server/jellyfin/e2e/browsing_test.go | 21 +++++++++++++++++++++ server/jellyfin/items.go | 6 ++++++ 3 files changed, 32 insertions(+) diff --git a/server/filter/filters.go b/server/filter/filters.go index e149dbced..5fa53daba 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -189,6 +189,11 @@ func ByGenreID(genreIds []string) Sqlizer { return genreTagFilter(Eq{"value": genreIds}) } +// ByAlbumID matches media files belonging to any of the given albums. +func ByAlbumID(albumIds []string) Sqlizer { + return Eq{"album_id": albumIds} +} + // ArtistsByGenreID matches artists credited as album artist on an album with any of the given // genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 0b2769856..18565c635 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -179,6 +179,27 @@ var _ = Describe("Browsing", func() { }) }) + // Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true. + Describe("album filtering (AlbumIds)", func() { + It("filters songs by AlbumIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + It("matches any of multiple comma-separated AlbumIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")) + "," + enc(albumID("IV")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + + It("returns nothing for an unknown album id", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + // Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 43c8de12c..b9372c397 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -221,6 +221,7 @@ type itemsQuery struct { artistId string contributingOnly bool genreIds []string + albumIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -243,6 +244,8 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu parentId: dto.DecodeID(p.StringOr("parentid", "")), // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), + // Feishin fetches an album's tracks with AlbumIds instead of ParentId. + albumIds: decodedQueryIDs(r, "albumids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -528,6 +531,9 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite default: filters = append(filters, notMissing) } + if len(q.albumIds) > 0 { + filters = append(filters, filter.ByAlbumID(q.albumIds)) + } if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } From c582ed31fa45beecf7064bab30c1c9fc7480a0d1 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 16 Jul 2026 08:49:00 -0400 Subject: [PATCH 370/596] feat(jellyfin): add authenticated System/Info endpoint Wire up GET /System/Info returning the previously unused dto.SystemInfo, available to any authenticated user, matching real Jellyfin's authorization (FirstTimeSetupOrIgnoreParentalControl, not admin-only). Feishin calls this endpoint on connect and reads Version to feature-gate; it previously got the unhandled-route 404. The advertised version stays 10.8.13: Feishin unlocks structured lyrics and public-playlist share permissions at >=10.9.0, and this API serves neither (no lyrics endpoint; playlist user permissions are stubs), so a higher version would falsely advertise capabilities. --- server/jellyfin/README.md | 2 +- server/jellyfin/api.go | 1 + server/jellyfin/e2e/system_test.go | 21 +++++++++++++++++++++ server/jellyfin/system.go | 7 +++++++ server/jellyfin/system_test.go | 21 +++++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 2e4c19950..8e0e08ce3 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -115,7 +115,7 @@ favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `Sort | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0740901aa..3aa870704 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -92,6 +92,7 @@ func (api *Router) routes() http.Handler { // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a // player) even before the first playback report. r.Use(api.withPlayer) + r.Get("/system/info", api.getSystemInfo) r.Get("/userviews", api.getUserViews) r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index d4d2f2777..c6f9145d5 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -30,6 +30,27 @@ var _ = Describe("System", func() { }) }) + Describe("GET /System/Info", func() { + It("returns system info to any authenticated user, matching the public Version", func() { + w := getAs(regularUser, "/System/Info") + var info map[string]any + parseInto(w, &info) + Expect(info["Version"]).ToNot(BeEmpty()) + Expect(info["SupportsLibraryMonitor"]).To(BeTrue()) + + pub := rawReq("GET", "/System/Info/Public", "") + var pubInfo map[string]any + parseInto(pub, &pubInfo) + Expect(info["Version"]).To(Equal(pubInfo["Version"])) + Expect(info["Id"]).To(Equal(pubInfo["Id"])) + }) + + It("rejects unauthenticated requests", func() { + w := rawReq("GET", "/System/Info", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + Describe("GET/POST /System/Ping", func() { It("answers GET with a plain-text server name", func() { w := rawReq("GET", "/System/Ping", "") diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index baa31fdeb..7e539be98 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -83,6 +83,13 @@ func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { api.ok(w, r, api.publicInfo(r)) } +func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, dto.SystemInfo{ + PublicSystemInfo: api.publicInfo(r), + SupportsLibraryMonitor: true, + }) +} + // ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's // server does this and clients parse the raw body. func (api *Router) ping(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 5ea525100..7846e4a20 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -38,6 +38,27 @@ var _ = Describe("System", func() { Expect(info.ServerName).To(HavePrefix("Navidrome")) }) + It("returns authenticated system info with the public fields plus library monitor support", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info", nil) + api.getSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.SystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.Id).ToNot(BeEmpty()) + Expect(info.Version).To(Equal(jellyfinVersion)) + Expect(info.ProductName).To(Equal("Jellyfin Server")) + Expect(info.ServerName).To(HavePrefix("Navidrome")) + Expect(info.SupportsLibraryMonitor).To(BeTrue()) + Expect(info.HasPendingRestart).To(BeFalse()) + Expect(info.IsShuttingDown).To(BeFalse()) + }) + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { DeferCleanup(configtest.SetupConfig()) From 29f481cd7b24336615ffe80410ce90e47a9348b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 16 Jul 2026 14:39:11 -0400 Subject: [PATCH 371/596] feat(jellyfin): lyrics endpoint and Lyric stream advertising (#5791) * feat(jellyfin): add LyricDto and lyrics mapper * feat(jellyfin): advertise Lyric media stream for embedded lyrics * feat(jellyfin): implement GET /Audio/{itemId}/Lyrics * feat(jellyfin): advertise pipeline-resolved lyrics in PlaybackInfo * feat(jellyfin): advertise server version 10.9.11 for client lyrics gates * test(jellyfin): e2e coverage for lyrics endpoint and advertising Seeds "Stairway To Heaven" with an embedded LRC lyric tag (lyrics:eng) and covers PlaybackInfo's Lyric MediaStream, GET /Audio/{id}/Lyrics, and the HasLyrics badge end to end. Fixes a bug the new seed exposed: HasLyrics and the Lyric MediaStream gate compared mf.Lyrics against "", but the persistence layer never stores an empty string post-scan (it normalizes to the JSON sentinel "[]"), so every track was reporting HasLyrics=true. Both call sites now parse the column via StructuredLyrics()/LyricList.Main() instead. * fix(jellyfin): cheap sentinel check for embedded lyrics advertising * chore(jellyfin): trim over-budget comments in lyrics code * test(jellyfin): cover lyrics pipeline error and nil-start cue skip * docs(jellyfin): document lyrics support and follow-ups in README * refactor(jellyfin): promote embedded-lyrics sentinel check to MediaFile The "[]" no-lyrics sentinel is persistence-layer knowledge; expose it as model.MediaFile.HasEmbeddedLyrics() instead of a dto-local helper. Also dedupe the test lyrics-cache construction and pre-size the media stream slice. * refactor(jellyfin): consolidate tick conversions around one constant ticksPerMillis is now the single source of the 100ns-tick unit; the scrobble handlers' three inline /10_000 divisions become dto.MillisFromTicks. * fix(jellyfin): align lyric advertising with the serving predicate PlaybackInfo advertised on any non-empty LyricList while the endpoint 404s when the main lyric has no lines; both now share servableLyric. Handler tests also send hex-encoded ids to match real traffic. * chore(jellyfin): drop unneeded lyrics package alias in e2e suite --- cmd/wire_gen.go | 3 +- model/mediafile.go | 6 ++ model/mediafile_test.go | 9 ++ server/jellyfin/README.md | 20 +++- server/jellyfin/api.go | 14 ++- server/jellyfin/api_test.go | 10 +- server/jellyfin/dto/dto.go | 29 ++++++ server/jellyfin/dto/mappers.go | 87 ++++++++++++++--- server/jellyfin/dto/mappers_test.go | 114 +++++++++++++++++++++- server/jellyfin/e2e/e2e_suite_test.go | 10 +- server/jellyfin/e2e/lyrics_test.go | 71 ++++++++++++++ server/jellyfin/lyrics.go | 47 +++++++++ server/jellyfin/lyrics_test.go | 132 ++++++++++++++++++++++++++ server/jellyfin/routing_test.go | 2 +- server/jellyfin/sessions.go | 6 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/stream.go | 10 ++ server/jellyfin/stream_test.go | 88 ++++++++++++++++- server/jellyfin/system.go | 5 +- 19 files changed, 627 insertions(+), 38 deletions(-) create mode 100644 server/jellyfin/e2e/lyrics_test.go create mode 100644 server/jellyfin/lyrics.go create mode 100644 server/jellyfin/lyrics_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index bd211cbfc..463ac7a44 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -137,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) + lyricsLyrics := lyrics.NewLyrics(dataStore, manager) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) return router } diff --git a/model/mediafile.go b/model/mediafile.go index 0fd172cee..90db80f71 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -147,6 +147,12 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) { return lyrics, nil } +// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan; +// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless. +func (mf MediaFile) HasEmbeddedLyrics() bool { + return mf.Lyrics != "" && mf.Lyrics != "[]" +} + // String is mainly used for debugging func (mf MediaFile) String() string { return mf.Path diff --git a/model/mediafile_test.go b/model/mediafile_test.go index f070f4649..c94653530 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -604,6 +604,15 @@ var _ = Describe("MediaFile", func() { }) +var _ = DescribeTable("MediaFile.HasEmbeddedLyrics", + func(lyrics string, expected bool) { + Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected)) + }, + Entry("empty string (never-scanned zero value)", "", false), + Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false), + Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true), +) + var _ = Describe("MediaFile.Works", func() { It("returns nil when there are no work tags", func() { mf := MediaFile{} diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 8e0e08ce3..7afc2b7a9 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -338,11 +338,21 @@ make test PKG=./server/jellyfin/... working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up would broadcast real session/playstate and library-change events over it (via `server/events`), mirroring Jellyfin's session messages. -- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp - and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve - Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both - clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level - timing, so word-sync is out of scope.) +- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in + 100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline + (embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also + caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less + tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade + gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is + just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel + check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per + track so sidecar/plugin lyrics also light up. Feishin additionally requires server version + ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. + Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same + track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via + ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only + lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources + can't be known at list time without per-row I/O). - **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed track followed by the provider's similar songs (with agents disabled it degrades to a seed-only diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 3aa870704..5e7cad0b8 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "sync" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/httprate" @@ -13,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" @@ -21,6 +23,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/cache" ) type Router struct { @@ -34,6 +37,8 @@ type Router struct { playlists playlists.Playlists provider external.Provider sonic sonic.Engine + lyrics lyrics.Lyrics + lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex serverIDVal string @@ -42,11 +47,15 @@ type Router struct { func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, + sonic: sonicSvc, lyrics: lyricsSvc, + lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ + SizeLimit: 1000, + DefaultTTL: 5 * time.Minute, + }), } r.Handler = r.routes() return r @@ -155,6 +164,7 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + r.Get("/audio/{itemId}/lyrics", api.getLyrics) // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. r.Get("/items/{itemId}/file", api.streamFile) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 1b390e75a..62af74794 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 7720640c5..9847a239c 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -256,3 +256,32 @@ type PlaybackInfoResponse struct { MediaSources []MediaSourceInfo `json:"MediaSources"` PlaySessionId string `json:"PlaySessionId"` } + +// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only +// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp. +type LyricDto struct { + Metadata LyricMetadata `json:"Metadata"` + Lyrics []LyricLine `json:"Lyrics"` +} + +type LyricMetadata struct { + Artist string `json:"Artist,omitempty"` + Album string `json:"Album,omitempty"` + Title string `json:"Title,omitempty"` + Length int64 `json:"Length,omitempty"` + Offset *int64 `json:"Offset,omitempty"` + IsSynced bool `json:"IsSynced"` +} + +type LyricLine struct { + Text string `json:"Text"` + Start *int64 `json:"Start,omitempty"` + Cues []LyricLineCue `json:"Cues,omitempty"` +} + +type LyricLineCue struct { + Position int `json:"Position"` + EndPosition int `json:"EndPosition"` + Start int64 `json:"Start"` + End *int64 `json:"End,omitempty"` +} diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bd817e432..c65b8933d 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -8,7 +8,16 @@ import ( "github.com/navidrome/navidrome/model" ) -func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } +// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. +const ticksPerMillis = 10_000 + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) } + +// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks. +func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis } + +// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds. +func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis } // premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 // PremiereDate clients parse, falling back to year; nil when neither exists. @@ -59,6 +68,20 @@ func channelLayout(n int) string { // Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo // responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + streams := make([]MediaStream, 1, 2) + streams[0] = MediaStream{ + Type: "Audio", + Index: 0, + Codec: mf.Codec, + BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. + Channels: mf.Channels, + SampleRate: mf.SampleRate, + ChannelLayout: channelLayout(mf.Channels), + } + // Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics. + if mf.HasEmbeddedLyrics() { + streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true}) + } return MediaSourceInfo{ Id: EncodeID(mf.ID), Protocol: "Http", @@ -73,17 +96,9 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { SupportsTranscoding: true, IsRemote: false, SupportsProbing: true, - MediaStreams: []MediaStream{{ - Type: "Audio", - Index: 0, - Codec: mf.Codec, - BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. - Channels: mf.Channels, - SampleRate: mf.SampleRate, - ChannelLayout: channelLayout(mf.Channels), - }}, - MediaAttachments: []any{}, - Formats: []string{}, + MediaStreams: streams, + MediaAttachments: []any{}, + Formats: []string{}, } } @@ -119,7 +134,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { MediaType: "Audio", IsFolder: false, LocationType: "FileSystem", - HasLyrics: mf.Lyrics != "", + HasLyrics: mf.HasEmbeddedLyrics(), ParentId: EncodeID(mf.AlbumID), Album: mf.Album, AlbumId: EncodeID(mf.AlbumID), @@ -254,3 +269,49 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto { UserData: UserData(p.Annotations, p.ID), } } + +// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain +// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start. +func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { + d := LyricDto{ + Metadata: LyricMetadata{ + Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist), + Album: mf.Album, + Title: cmp.Or(lyrics.DisplayTitle, mf.Title), + Length: TicksFromSeconds(mf.Duration), + IsSynced: lyrics.Synced, + }, + Lyrics: make([]LyricLine, 0, len(lyrics.Line)), + } + if lyrics.Offset != nil { + offset := TicksFromMillis(*lyrics.Offset) + d.Metadata.Offset = &offset + } + for _, line := range lyrics.Line { + out := LyricLine{Text: line.Value} + if lyrics.Synced { + if line.Start == nil { + continue + } + start := TicksFromMillis(*line.Start) + out.Start = &start + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + c := LyricLineCue{ + Position: cue.ByteStart, + EndPosition: cue.ByteEnd, + Start: TicksFromMillis(*cue.Start), + } + if cue.End != nil { + end := TicksFromMillis(*cue.End) + c.End = &end + } + out.Cues = append(out.Cues, c) + } + } + d.Lyrics = append(d.Lyrics, out) + } + return d +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index bd351bee9..e72efd617 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -39,7 +39,7 @@ var _ = Describe("mappers", func() { Describe("Fields gating (matches real Jellyfin)", func() { mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, - SortTitle: "sort song", Lyrics: `[{"line":"la"}]`} + SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} It("omits MediaSources and SortName when Fields does not ask for them", func() { item := SongToBaseItem(mf, nil) @@ -60,6 +60,8 @@ var _ = Describe("mappers", func() { It("sets HasLyrics from the media file's lyrics", func() { Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + // "[]" is the no-lyrics sentinel, not a truthy value. + Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) }) }) @@ -149,6 +151,29 @@ var _ = Describe("mappers", func() { Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) }) + Describe("Lyric media stream advertising", func() { + It("adds a Lyric media stream when the file has embedded lyrics", func() { + mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`} + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(2)) + Expect(src.MediaStreams[0].Type).To(Equal("Audio")) + Expect(src.MediaStreams[1].Type).To(Equal("Lyric")) + Expect(src.MediaStreams[1].Index).To(Equal(1)) + Expect(src.MediaStreams[1].IsExternal).To(BeTrue()) + }) + + It("emits only the Audio stream without lyrics", func() { + src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"}) + Expect(src.MediaStreams).To(HaveLen(1)) + Expect(src.MediaStreams[0].Type).To(Equal("Audio")) + }) + + It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { + src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"}) + Expect(src.MediaStreams).To(HaveLen(1)) + }) + }) + It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { mf := model.MediaFile{ ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", @@ -278,3 +303,90 @@ var _ = Describe("mappers", func() { Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) }) }) + +var _ = Describe("LyricDtoFromLyrics", func() { + ms := func(v int64) *int64 { return &v } + + mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} + + It("maps synced lyrics with tick conversion", func() { + l := model.Lyrics{ + DisplayArtist: "Display Artist", + DisplayTitle: "Display Title", + Synced: true, + Offset: ms(-150), + Line: []model.Line{ + {Start: ms(1000), Value: "line one"}, + {Start: ms(2500), Value: "line two"}, + }, + } + d := LyricDtoFromLyrics(mf, l) + Expect(d.Metadata.Artist).To(Equal("Display Artist")) + Expect(d.Metadata.Title).To(Equal("Display Title")) + Expect(d.Metadata.Album).To(Equal("Album")) + Expect(d.Metadata.IsSynced).To(BeTrue()) + Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000))) + Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100))) + Expect(d.Lyrics).To(HaveLen(2)) + Expect(d.Lyrics[0].Text).To(Equal("line one")) + Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000))) + Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000))) + }) + + It("falls back to the media file's artist and title", func() { + d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}}) + Expect(d.Metadata.Artist).To(Equal("Artist")) + Expect(d.Metadata.Title).To(Equal("Song")) + }) + + It("drops start-less lines from synced lyrics", func() { + l := model.Lyrics{Synced: true, Line: []model.Line{ + {Start: ms(0), Value: "kept"}, + {Value: "dropped"}, + }} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics).To(HaveLen(1)) + Expect(d.Lyrics[0].Text).To(Equal("kept")) + }) + + It("emits no Start on unsynced lyrics even when lines have one", func() { + l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics).To(HaveLen(1)) + Expect(d.Lyrics[0].Start).To(BeNil()) + Expect(d.Metadata.IsSynced).To(BeFalse()) + }) + + It("maps word cues", func() { + end := int64(1500) + l := model.Lyrics{Synced: true, Line: []model.Line{{ + Start: ms(1000), + Value: "word cue", + Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}}, + }}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics[0].Cues).To(HaveLen(1)) + c := d.Lyrics[0].Cues[0] + Expect(c.Position).To(Equal(0)) + Expect(c.EndPosition).To(Equal(4)) + Expect(c.Start).To(Equal(int64(10_000_000))) + Expect(*c.End).To(Equal(int64(15_000_000))) + }) + + It("skips a start-less cue while keeping its sibling", func() { + l := model.Lyrics{Synced: true, Line: []model.Line{{ + Start: ms(1000), + Value: "word cue", + Cue: []model.Cue{ + {Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7}, + {Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12}, + }, + }}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics[0].Cues).To(HaveLen(1)) + c := d.Lyrics[0].Cues[0] + Expect(c.Position).To(Equal(8)) + Expect(c.EndPosition).To(Equal(12)) + Expect(c.Start).To(Equal(int64(10_000_000))) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ea47dcdaa..e0f7b5bb5 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -42,6 +42,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" @@ -119,9 +120,11 @@ func buildTestFS() storagetest.FakeFS { "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), - "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), - "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + })), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), @@ -325,6 +328,7 @@ func setupTestDB() { playlists.NewPlaylists(ds, core.NewImageUploadService()), providerFake, sonicSvc, + lyrics.NewLyrics(ds, nil), ) } diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go new file mode 100644 index 000000000..279f288b8 --- /dev/null +++ b/server/jellyfin/e2e/lyrics_test.go @@ -0,0 +1,71 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("PlaybackInfo", func() { + It("advertises a Lyric stream for a track with embedded lyrics", func() { + id := songID("Stairway To Heaven") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + var found bool + for _, s := range info.MediaSources[0].MediaStreams { + if s.Type == "Lyric" { + found = true + } + } + Expect(found).To(BeTrue()) + }) + + It("does not advertise a Lyric stream for a track without lyrics", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + for _, s := range info.MediaSources[0].MediaStreams { + Expect(s.Type).ToNot(Equal("Lyric")) + } + }) + }) + + Describe("GET /Audio/{id}/Lyrics", func() { + It("returns the LyricDto for a track with embedded synced lyrics", func() { + id := songID("Stairway To Heaven") + var lyrics dto.LyricDto + parseInto(get("/Audio/"+enc(id)+"/Lyrics"), &lyrics) + Expect(lyrics.Lyrics).To(HaveLen(2)) + Expect(lyrics.Lyrics[0].Text).To(Equal("There's a lady who's sure")) + Expect(lyrics.Lyrics[0].Start).ToNot(BeNil()) + Expect(*lyrics.Lyrics[0].Start).To(Equal(int64(10000000))) + Expect(lyrics.Metadata.IsSynced).To(BeTrue()) + }) + + It("returns 404 for a track without lyrics", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a fabricated id", func() { + Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("HasLyrics badge", func() { + It("is true for a track with embedded lyrics and omitted/false otherwise", func() { + var stairway dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) + Expect(stairway.HasLyrics).To(BeTrue()) + + var soWhat dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) + Expect(soWhat.HasLyrics).To(BeFalse()) + }) + }) +}) diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go new file mode 100644 index 000000000..f6468ceeb --- /dev/null +++ b/server/jellyfin/lyrics.go @@ -0,0 +1,47 @@ +package jellyfin + +import ( + "context" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), +// caching results — including empty: clients poll per played track, so misses are the hot path. +func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { + list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { + l, err := api.lyrics.GetLyrics(ctx, mf) + return l, 0, err // 0 → cache DefaultTTL + }) + if err != nil { + log.Error(ctx, "Error getting lyrics", "id", mf.ID, "title", mf.Title, err) + return nil + } + return list +} + +// getLyrics serves GET /Audio/{itemId}/Lyrics. Jellyfin returns 404 when a track has no lyrics +// (never an empty 200); all surveyed clients treat that gracefully. +func (api *Router) getLyrics(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + main, found := servableLyric(api.cachedLyrics(r.Context(), mf)) + if !found { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + api.ok(w, r, dto.LyricDtoFromLyrics(*mf, main)) +} + +// servableLyric is the single predicate for both serving and advertising, so PlaybackInfo never +// advertises a Lyric stream that this endpoint would 404. +func servableLyric(list model.LyricList) (model.Lyrics, bool) { + main, found := list.Main() + return main, found && !main.IsEmpty() +} diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go new file mode 100644 index 000000000..f2c5993f6 --- /dev/null +++ b/server/jellyfin/lyrics_test.go @@ -0,0 +1,132 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeLyricsService returns canned lyrics per media-file ID and counts calls. +type fakeLyricsService struct { + lyrics map[string]model.LyricList + err error + calls int +} + +func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.lyrics[mf.ID], nil +} + +func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) { + return nil, nil +} + +func p(ms int64) *int64 { return &ms } + +func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] { + return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000}) +} + +var _ = Describe("getLyrics", func() { + var api *Router + var ds *tests.MockDataStore + var fake *fakeLyricsService + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", LibraryID: 1}, + {ID: "s2", Title: "Silent Song", LibraryID: 1}, + }) + fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} + api = &Router{ + ds: ds, + lyrics: fake, + lyricsCache: newTestLyricsCache(), + } + }) + + doRequest := func(id string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}) + // Clients send hex-encoded ids (matching real traffic and the other handler tests). + enc := dto.EncodeID(id) + r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", enc) + invoke(api.getLyrics, w, r) + return w + } + + It("returns 200 with a LyricDto for a track with synced lyrics", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + w := doRequest("s1") + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.LyricDto + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Lyrics).To(HaveLen(1)) + Expect(res.Lyrics[0].Text).To(Equal("hello")) + Expect(res.Lyrics[0].Start).ToNot(BeNil()) + Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000))) + }) + + It("serves the main-kind lyric when a translation is also present", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + w := doRequest("s1") + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.LyricDto + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Lyrics).To(HaveLen(1)) + Expect(res.Lyrics[0].Text).To(Equal("hello")) + }) + + It("returns 404 when the service returns no lyrics", func() { + w := doRequest("s2") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the main lyric has no lines", func() { + fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}} + w := doRequest("s1") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an unknown item id", func() { + w := doRequest("unknown") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("caches results so a second request doesn't re-invoke the service", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(fake.calls).To(Equal(1)) + }) + + It("caches empty results too", func() { + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(fake.calls).To(Equal(1)) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index e007dedf4..b0bc92caf 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f462f283a..f83e5182d 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -49,7 +49,7 @@ func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { clientId, clientName := clientIdentity(ctx) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: scrobbler.StatePlaying, PlaybackRate: 1.0, ClientId: clientId, @@ -73,7 +73,7 @@ func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request clientId, clientName := clientIdentity(ctx) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: state, PlaybackRate: 1.0, ClientId: clientId, @@ -97,7 +97,7 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: scrobbler.StateStopped, ClientId: clientId, ClientName: clientName, diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index d9d74bcb9..7d8874476 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 28411f9d1..022c00c96 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -5,6 +5,7 @@ import ( "math" "net/http" "net/url" + "slices" "strconv" "strings" @@ -44,6 +45,15 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { return } src := dto.MediaSourceFromMediaFile(*mf) + // The mapper only sees embedded lyrics; per-track we can afford the full pipeline + // (sidecars, plugins) so Finamp's Lyric-stream gate reflects every source. + if !slices.ContainsFunc(src.MediaStreams, func(s dto.MediaStream) bool { return s.Type == "Lyric" }) { + if _, found := servableLyric(api.cachedLyrics(r.Context(), mf)); found { + src.MediaStreams = append(src.MediaStreams, dto.MediaStream{ + Type: "Lyric", Index: len(src.MediaStreams), IsExternal: true, + }) + } + } // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 7063d321a..f77c10ec1 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -33,7 +33,11 @@ var _ = Describe("Stream", func() { ds = &tests.MockDataStore{} streamer = &fakeMediaStreamer{} decider = &fakeTranscodeDecider{} - api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + api = &Router{ + ds: ds, streamer: streamer, transcodeDecider: decider, + lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}}, + lyricsCache: newTestLyricsCache(), + } }) Describe("getPlaybackInfo", func() { @@ -76,6 +80,88 @@ var _ = Describe("Stream", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + + playbackInfo := func() dto.PlaybackInfoResponse { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getPlaybackInfo(w, r) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + return res + } + + lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream { + var out []dto.MediaStream + for _, s := range res.MediaSources[0].MediaStreams { + if s.Type == "Lyric" { + out = append(out, s) + } + } + return out + } + + It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + }} + + Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) + }) + + It("advertises no Lyric stream when the pipeline finds nothing", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + + Expect(lyricStreams(playbackInfo())).To(BeEmpty()) + }) + + It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Lang: "eng"}}, + }} + + Expect(lyricStreams(playbackInfo())).To(BeEmpty()) + }) + + It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + }} + + Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) + }) + + It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() { + // Own ID: an erroring loader isn't cached, but a shared ID could still pick up + // another test's cached (non-error) result and mask this assertion. + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{err: errors.New("boom")} + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s-err")) + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.MediaSources).To(HaveLen(1)) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s-err"))) + Expect(lyricStreams(res)).To(BeEmpty()) + }) }) Describe("streamAudio", func() { diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 7e539be98..6f0c359d4 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -17,8 +17,9 @@ import ( ) // jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate -// on it, so it must stay a real Jellyfin release, not Navidrome's own version. -const jellyfinVersion = "10.8.13" +// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required +// for Feishin to use the server lyrics endpoint. +const jellyfinVersion = "10.9.11" func (api *Router) serverName() string { if conf.Server.Jellyfin.ServerName != "" { From 756df9decf0cfe55a1eb528f892f31367e466c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 16 Jul 2026 20:10:35 -0400 Subject: [PATCH 372/596] fix: dedupe and cap concurrent lyrics plugin fetches (#5792) * fix: dedupe and cap concurrent lyrics plugin fetches Clients like Finamp prefetch lyrics for several queue tracks at once. The resulting burst of concurrent plugin calls can rate-limit the primary lyrics provider into a timeout, making the plugin fall back to a lower quality source and cache the bad result. SimpleCache.GetWithLoader now deduplicates concurrent loads of the same key via singleflight, with every waiter receiving the winner's result or error. The Jellyfin lyrics loader is detached from the request context so one cancelled request cannot fail the load for all waiters, and the lyrics adapter caps in-flight plugin calls at 2 per plugin, queueing the rest. As a side effect, the cached HTTP client used by the Last.fm, Deezer and ListenBrainz agents also collapses identical concurrent requests into a single upstream call. * fix: harden lyrics concurrency fixes per review Replace the stringified singleflight keys with a per-cache flight map keyed by the cache key type itself, eliminating potential key collisions for non-string keys, the nil-interface assertion panic, and the stringification overhead. Release the lyrics semaphore slot via defer so a panicking plugin call cannot leak it, and bound the detached lyrics load with a one-minute timeout so a hung plugin cannot pin its singleflight and semaphore slot indefinitely. --- plugins/lyrics_adapter.go | 10 +++ plugins/lyrics_adapter_test.go | 41 +++++++++++ plugins/manager_loader.go | 1 + plugins/manager_plugin.go | 1 + server/jellyfin/lyrics.go | 9 ++- server/jellyfin/lyrics_test.go | 31 +++++++-- utils/cache/simple_cache.go | 79 ++++++++++++++++------ utils/cache/simple_cache_test.go | 112 +++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 27 deletions(-) diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 281f022fb..9e02115e7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -14,6 +14,10 @@ const ( FuncLyricsGetLyrics = "nd_lyrics_get_lyrics" ) +// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch +// lyrics for whole queues, and the resulting burst can rate-limit upstream providers. +const maxConcurrentLyricsCalls = 2 + func init() { registerCapability( CapabilityLyrics, @@ -34,6 +38,12 @@ type LyricsPlugin struct { // GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response // via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { + select { + case l.plugin.lyricsSem <- struct{}{}: + defer func() { <-l.plugin.lyricsSem }() + case <-ctx.Done(): + return nil, ctx.Err() + } req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), } diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index 6e82dbfab..d110665f5 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -3,6 +3,8 @@ package plugins import ( + "context" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -71,6 +73,45 @@ var _ = Describe("LyricsPlugin", Ordered, func() { Expect(result[0].Lang).To(Equal("xxx")) }) + It("blocks new calls while the per-plugin concurrency cap is saturated", func() { + sem := provider.plugin.lyricsSem + for range cap(sem) { + sem <- struct{}{} + } + + ctx := GinkgoT().Context() + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + done := make(chan error, 1) + go func() { + _, err := provider.GetLyrics(ctx, track) + done <- err + }() + + Consistently(done, "500ms").ShouldNot(Receive()) + <-sem // free one slot; the pending call should now proceed + Eventually(done).Should(Receive(BeNil())) + for range cap(sem) - 1 { + <-sem + } + }) + + It("gives up waiting for a slot when the context is cancelled", func() { + sem := provider.plugin.lyricsSem + for range cap(sem) { + sem <- struct{}{} + } + defer func() { + for range cap(sem) { + <-sem + } + }() + + ctx, cancel := context.WithCancel(GinkgoT().Context()) + cancel() + _, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"}) + Expect(err).To(MatchError(context.Canceled)) + }) + It("returns error when plugin returns error", func() { manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ "test-lyrics": {"error": "service unavailable"}, diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 757ededb5..675c85e26 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -421,6 +421,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allowedUserIDs: allowedUsers, allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), + lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } m.mu.Unlock() loaded = true diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index f0c7c56d5..155663781 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -24,6 +24,7 @@ type plugin struct { allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users libraries libraryAccess + lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) } // instance creates a new plugin instance for the given context. diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go index f6468ceeb..a9d77ba19 100644 --- a/server/jellyfin/lyrics.go +++ b/server/jellyfin/lyrics.go @@ -10,11 +10,18 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" ) +const lyricsLoadTimeout = time.Minute + // cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), // caching results — including empty: clients poll per played track, so misses are the hot path. func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { + // The load is shared across requests (singleflight) and cached, so don't let one + // cancelled request abort it for everybody — detach it from the request's lifetime, + // keeping a bound so a hung plugin can't pin the fetch (and its plugin slot) forever. + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), lyricsLoadTimeout) + defer cancel() list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { - l, err := api.lyrics.GetLyrics(ctx, mf) + l, err := api.lyrics.GetLyrics(loadCtx, mf) return l, 0, err // 0 → cache DefaultTTL }) if err != nil { diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index f2c5993f6..402d14ed0 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -17,13 +17,18 @@ import ( // fakeLyricsService returns canned lyrics per media-file ID and counts calls. type fakeLyricsService struct { - lyrics map[string]model.LyricList - err error - calls int + lyrics map[string]model.LyricList + err error + calls int + hadDeadline bool } -func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) { +func (f *fakeLyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { f.calls++ + _, f.hadDeadline = ctx.Deadline() + if err := ctx.Err(); err != nil { + return nil, err + } if f.err != nil { return nil, f.err } @@ -129,4 +134,22 @@ var _ = Describe("getLyrics", func() { Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) Expect(fake.calls).To(Equal(1)) }) + + It("completes and caches the fetch even when the request context is cancelled", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"}) + Expect(list).ToNot(BeEmpty()) + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(fake.calls).To(Equal(1)) + }) + + It("bounds the detached fetch with a timeout", func() { + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(fake.hadDeadline).To(BeTrue()) + }) }) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index eb3c99995..494451c9e 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "runtime" + "sync" "sync/atomic" "time" @@ -44,7 +45,8 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { c := ttlcache.New[K, V](opts...) cache := &simpleCache[K, V]{ - data: c, + data: c, + loads: make(map[K]*flight[V]), } go cache.data.Start() @@ -61,6 +63,23 @@ const evictionTimeout = 1 * time.Hour type simpleCache[K comparable, V any] struct { data *ttlcache.Cache[K, V] evictionDeadline atomic.Pointer[time.Time] + loadsMu sync.Mutex + loads map[K]*flight[V] +} + +// flight tracks an in-progress load so concurrent misses of the same key share it. +type flight[V any] struct { + done chan struct{} + val V + err error +} + +func (f *flight[V]) result() (V, error) { + if f.err != nil { + var zero V + return zero, fmt.Errorf("cache error: loader returned %w", f.err) + } + return f.val, nil } func (c *simpleCache[K, V]) Add(key K, value V) error { @@ -90,31 +109,47 @@ func (c *simpleCache[K, V]) Get(key K) (V, error) { return item.Value(), nil } +// GetWithLoader loads misses via the loader, deduplicating concurrent loads of +// the same key: one loader call runs, and every waiter shares its result (or error). func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) { - var err error - loaderWrapper := ttlcache.LoaderFunc[K, V]( - func(t *ttlcache.Cache[K, V], key K) *ttlcache.Item[K, V] { - c.evictExpired() - var value V - var ttl time.Duration - value, ttl, err = loader(key) - if err != nil { - return nil - } - return t.Set(key, value, ttl) - }, - ) - item := c.data.Get(key, ttlcache.WithLoader[K, V](loaderWrapper)) - if item == nil { - var zero V - if err != nil { - return zero, fmt.Errorf("cache error: loader returned %w", err) - } - return zero, errors.New("item not found") + if item := c.data.Get(key); item != nil { + return item.Value(), nil } - return item.Value(), nil + + c.loadsMu.Lock() + if f, ok := c.loads[key]; ok { + c.loadsMu.Unlock() + <-f.done + return f.result() + } + f := &flight[V]{done: make(chan struct{}), err: errLoaderPanicked} + c.loads[key] = f + c.loadsMu.Unlock() + + // Deregister even if the loader panics, so waiters get an error instead of + // blocking forever on a flight that will never complete. + defer func() { + close(f.done) + c.loadsMu.Lock() + delete(c.loads, key) + c.loadsMu.Unlock() + }() + + if item := c.data.Get(key); item != nil { // a flight may have completed since the miss + f.val, f.err = item.Value(), nil + } else { + c.evictExpired() + var ttl time.Duration + f.val, ttl, f.err = loader(key) + if f.err == nil { + c.data.Set(key, f.val, ttl) + } + } + return f.result() } +var errLoaderPanicked = errors.New("loader panicked") + func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go index 45ba2c966..1c4f5c9bb 100644 --- a/utils/cache/simple_cache_test.go +++ b/utils/cache/simple_cache_test.go @@ -3,6 +3,8 @@ package cache import ( "errors" "fmt" + "sync" + "sync/atomic" "time" . "github.com/onsi/ginkgo/v2" @@ -69,6 +71,116 @@ var _ = Describe("SimpleCache", func() { _, err := cache.GetWithLoader("key", loader) Expect(err).To(HaveOccurred()) }) + + It("suppresses concurrent loads for the same key", func() { + var calls atomic.Int32 + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + calls.Add(1) + started <- struct{}{} + <-release + return "shared", time.Minute, nil + } + + const n = 5 + var wg sync.WaitGroup + results := make([]string, n) + errs := make([]error, n) + for i := range n { + wg.Go(func() { + results[i], errs[i] = cache.GetWithLoader("key", loader) + }) + } + + Eventually(started).Should(Receive()) + Consistently(started).ShouldNot(Receive()) + close(release) + wg.Wait() + + Expect(calls.Load()).To(Equal(int32(1))) + for i := range n { + Expect(errs[i]).ToNot(HaveOccurred()) + Expect(results[i]).To(Equal("shared")) + } + }) + + It("returns the loader error to all concurrent callers", func() { + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + started <- struct{}{} + <-release + return "", 0, errors.New("load failed") + } + + const n = 3 + var wg sync.WaitGroup + errs := make([]error, n) + for i := range n { + wg.Go(func() { + _, errs[i] = cache.GetWithLoader("key", loader) + }) + } + + Eventually(started).Should(Receive()) + Consistently(started).ShouldNot(Receive()) + close(release) + wg.Wait() + + for i := range n { + Expect(errs[i]).To(MatchError(ContainSubstring("load failed"))) + } + }) + + It("supports interface value types with nil results", func() { + c := NewSimpleCache[string, any]() + v, err := c.GetWithLoader("key", func(string) (any, time.Duration, error) { + return nil, time.Minute, nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(BeNil()) + }) + + It("cleans up the in-flight registration when the loader panics", func() { + Expect(func() { + _, _ = cache.GetWithLoader("key", func(string) (string, time.Duration, error) { + panic("boom") + }) + }).To(PanicWith("boom")) + + // Without cleanup this would deadlock on the never-completed flight + v, err := cache.GetWithLoader("key", func(string) (string, time.Duration, error) { + return "ok", 0, nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(Equal("ok")) + }) + + It("loads different keys independently", func() { + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + started <- struct{}{} + <-release + return key + "=value", time.Minute, nil + } + + var wg sync.WaitGroup + for _, key := range []string{"key1", "key2"} { + wg.Go(func() { + value, err := cache.GetWithLoader(key, loader) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(key + "=value")) + }) + } + + // Both loaders must be in flight at once: distinct keys are not suppressed + Eventually(started).Should(Receive()) + Eventually(started).Should(Receive()) + close(release) + wg.Wait() + }) }) Describe("Keys and Values", func() { From 27f02103923f0927a3cc20ba98ecbca4d73700bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 16 Jul 2026 20:47:47 -0400 Subject: [PATCH 373/596] fix(scrobbler): tolerate out-of-order playback reports (#5793) * fix(scrobbler): tolerate out-of-order playback reports Clients may fire reportPlayback requests concurrently (Feishin sends 'starting' and 'playing' in parallel, and the previous track's 'stopped' races the next track's start), so reports can be processed out of order. Two cases corrupted the now-playing session: a late 'starting' for the track already playing downgraded the session state, freezing position estimation at 0:00 until the next report; and a late 'stopped' for the previous track removed the new track's session and dispatched a playback report mislabeled with the new track's metadata, causing presence-style plugins (e.g. Discord Rich Presence) to clear or show stale state. ReportPlayback now ignores a 'starting' report when the session already has the same track in playing state, and ignores a 'stopped' report for a track other than the current session's - skipping both the session removal and the plugin dispatch, while still counting the play and dispatching external scrobbles for the stopped track. Reported in https://github.com/jeffvli/feishin/issues/2131 * fix(scrobbler): serialize session writes to close starting/playing race The out-of-order 'starting' guard checked the session cache before the media-file load, leaving a window where a concurrent 'playing' report on a fresh session could write between the check and the write, and still be overwritten back to 'starting'. Session check-then-write sections are now serialized by a mutex, with the guard re-checked after the load. Also tightens the guard comment to say 'playing session', matching the condition. Found by Codex review on #5793. * fix(scrobbler): fully exit ReportPlayback when ignoring out-of-order reports The out-of-order guards used 'break', which only exits the switch, so the post-switch NowPlaying block still ran for an ignored 'starting' report and enqueued a NowPlaying dispatch with the stale report's position - potentially overwriting a pending correct-position entry, since the queue is keyed by client. Return nil instead, so ignored reports have no side effects. Found by Gemini review on #5793. --- core/scrobbler/play_tracker.go | 37 +++++++- core/scrobbler/play_tracker_test.go | 134 +++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index a2d7e8639..e21db42d2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -89,6 +89,7 @@ type playTracker struct { ds model.DataStore broker events.Broker playMap cache.SimpleCache[string, PlaybackSession] + sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -249,6 +250,12 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } +// hasPlayingSession reports whether clientId's current session is already playing mediaId. +func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool { + cur, err := p.playMap.Get(clientId) + return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying +} + func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { if rate <= 0 { rate = 1.0 @@ -268,6 +275,12 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP switch params.State { case StateStarting: + // Clients may send starting/playing unordered; a late "starting" must not downgrade + // a playing session, or position estimation freezes until the next report. + if p.hasPlayingSession(clientId, params.MediaId) { + log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) + return nil + } mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err @@ -284,7 +297,15 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } + p.sessionsMu.Lock() + // re-check: a concurrent "playing" report may have created the session during the load above + if p.hasPlayingSession(clientId, params.MediaId) { + p.sessionsMu.Unlock() + log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) + return nil + } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } @@ -315,7 +336,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) + p.sessionsMu.Lock() err := p.playMap.AddWithTTL(clientId, info, ttl) + p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } @@ -339,6 +362,17 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.dispatchScrobble(ctx, mf, now) } } + p.sessionsMu.Lock() + info, getErr := p.playMap.Get(clientId) + // A late stop for a previous track must not end the current session nor reach + // playback reporters, or presence-style plugins would clear the active track. + if getErr == nil && info.MediaFile.ID != params.MediaId { + p.sessionsMu.Unlock() + log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID) + return nil + } + p.playMap.Remove(clientId) + p.sessionsMu.Unlock() stoppedInfo := PlaybackSession{ UserId: user.ID, Username: user.UserName, @@ -349,7 +383,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } - if info, getErr := p.playMap.Get(clientId); getErr == nil { + if getErr == nil { stoppedInfo.MediaFile = info.MediaFile stoppedInfo.Start = info.Start } else { @@ -364,7 +398,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP stoppedInfo.MediaFile = *mf } p.enqueuePlaybackReport(ctx, stoppedInfo) - p.playMap.Remove(clientId) } if conf.Server.EnableNowPlaying { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 831b0ce0d..f49d9a0bf 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,6 +3,7 @@ package scrobbler import ( "context" "errors" + "fmt" "net/http" "sync" "sync/atomic" @@ -45,6 +46,17 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { return s, ok } +// slowMediaFileRepo widens the window between a report's session check and its +// write, making check-then-write races reproducible. +type slowMediaFileRepo struct { + model.MediaFileRepository +} + +func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { + time.Sleep(5 * time.Millisecond) + return s.MediaFileRepository.GetWithParticipants(id) +} + var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore @@ -376,18 +388,23 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(BeEmpty()) }) - It("starting replaces existing entry for same player", func() { + It("starting replaces existing entry when switching tracks on same player", func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + MediaId: "456", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("456")) Expect(playing[0].State).To(Equal("starting")) Expect(playing[0].PositionMs).To(Equal(int64(0))) }) @@ -696,6 +713,119 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("resilience (out-of-order reports)", func() { + BeforeEach(func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + }) + + It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 1000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000))) + }) + + It("keeps the current session when a stopped report arrives for a different track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("456")) + Expect(playing[0].State).To(Equal("playing")) + }) + + It("still auto-scrobbles the stopped track when the current session is for a different track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + fake.nowPlayingCalled.Store(false) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("never lets a concurrent starting report downgrade the playing session", func() { + ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)} + for i := range 20 { + raceClientId := fmt.Sprintf("race-client-%d", i) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + defer GinkgoRecover() + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId, + }) + }() + go func() { + defer wg.Done() + defer GinkgoRecover() + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId, + }) + }() + wg.Wait() + info, err := tracker.playMap.Get(raceClientId) + Expect(err).ToNot(HaveOccurred()) + Expect(info.State).To(Equal("playing"), "iteration %d", i) + } + }) + + It("does NOT forward a stopped report for a different track to playback reporters", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + }) + Describe("external scrobbler dispatch", func() { It("dispatches NowPlaying on starting", func() { fake.nowPlayingCalled.Store(false) From ae7e81e33fcffee25c089cc93630dcccb2d74e64 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 16 Jul 2026 20:24:15 -0400 Subject: [PATCH 374/596] docs(jellyfin): sync README with current implementation Update the Jellyfin API README to cover changes that landed after it was written: add the Lyrics and AudioMuse-AI endpoints to the implemented-endpoints table, document the AlbumIds filter (Feishin), Recursive=false handling, and the Jellyfin.MaxConcurrentStreams config option. Mention Symfonium as a client of the AudioMuse-AI endpoints. Update the lyrics limitation for the singleflighted cache loader shipped in #5792. Drop two stale known-limitation entries: sonic similarity (plugin metadata agents already feed InstantMix/Similar) and MD5-hash ids (the codec round-trip is symmetric, and with the API unreleased no client can hold a raw un-encoded id). --- server/jellyfin/README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 7afc2b7a9..17d2797ab 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -22,6 +22,10 @@ Enabled = true ServerName = "My Music Server" # Optional: usernames to show in the client login user-picker (default: none). See "Public user list". ExposedPublicUsers = "alice, bob" +# Optional: max collection responses streaming at once (default: half the DB connection pool, +# min 2). Each streaming response holds a DB connection for its whole duration; excess requests +# queue rather than fail. +MaxConcurrentStreams = 4 ``` or via environment variables: @@ -104,12 +108,14 @@ authenticated user has access to; a library (or item within it) the user cannot `GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or -tracks — Finamp's artist screen sends these *alongside* `ParentId=<libraryId>`); `GenreIds` (a +tracks — Finamp's artist screen sends these *alongside* `ParentId=<libraryId>`); `AlbumIds` (an +album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); `SearchTerm`; favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; -`StartIndex`/`Limit`; and `Ids` (batch fetch by id). +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` +returns direct children only (no tracks — no track is a library's direct child). ## Implemented endpoints @@ -124,9 +130,11 @@ favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `Sort | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | | Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | | Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` | +| Lyrics | `GET Audio/{itemId}/Lyrics` | | Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | | Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | | Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | +| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint @@ -217,7 +225,9 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic ## AudioMuse-AI compatible endpoints -Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) +— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when +connected as a Jellyfin client. Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. @@ -320,9 +330,6 @@ make test PKG=./server/jellyfin/... Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client that already has an artist id from elsewhere is not re-checked against library membership. -- **MD5-hash ids from old migrated libraries.** The hex id codec assumes ids are opaque; a raw - 32-char MD5 id is itself valid hex and so must be encoded/decoded symmetrically like any other. - This is handled, but is the most fragile id case — see the note in `dto/ids.go`. - **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a @@ -348,15 +355,8 @@ make test PKG=./server/jellyfin/... check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. - Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same - track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via - ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only - lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources - can't be known at list time without per-row I/O). -- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are - backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed - track followed by the provider's similar songs (with agents disabled it degrades to a seed-only - mix). A follow-up would back them with Navidrome's `core/sonic` provider — the same one behind - the OpenSubsonic `sonicSimilarity` extension (`getSonicSimilarTracks`) that AudioMuse-AI feeds - via its Navidrome plugin, and the exact endpoint AudioMuse's own Jellyfin plugin overrides. - Needs the `core/sonic.Sonic` service injected into the `Router` (wire change). + Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` + is singleflighted), and the load runs detached from the request context with a one-minute bound, + so a cancelled request or hung plugin can't fail or pin the load for other waiters. + Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in + lists (request-time sources can't be known at list time without per-row I/O). From 85132240e0909dc460f604144cfd8578fa409e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 16 Jul 2026 22:20:35 -0400 Subject: [PATCH 375/596] feat(smartplaylist): per-playlist refreshDelay for stable daily/weekly playlists (#5790) * feat(utils): add ParseDuration/FormatDuration with day and week units * feat(criteria): add per-playlist refreshDelay to smart playlist rules * feat(smartplaylist): honor per-playlist refreshDelay in refresh gate * feat(subsonic): compute smart playlist validUntil from effective refresh delay * fix(playlists): reset smart playlist evaluation window when rules change via API * refactor(utils): flatten FormatDuration recursion, single-pass duration regex * fix(playlists): address PR review feedback - Reset EvaluatedAt to nil instead of zero-time on rules change and NSP re-import, so getPlaylist(s) never reports year-1 Changed/validUntil in the window between an edit and the next owner read - Parse negative d/w durations so they are rejected with the consistent "negative duration" error instead of "unknown unit" - Quote input in the negative-duration error, matching the parse error - Gate per-playlist RefreshDelay behind IsSmartPlaylist, matching its doc --- core/playlists/import.go | 3 +- core/playlists/rest_adapter.go | 1 + core/playlists/rest_adapter_test.go | 32 +++++++++ model/criteria/criteria.go | 17 +++++ model/criteria/criteria_test.go | 66 +++++++++++++++++ model/playlist.go | 10 +++ model/playlist_test.go | 30 ++++++++ persistence/smart_playlist_repository.go | 3 +- persistence/smart_playlist_repository_test.go | 44 ++++++++++++ server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 14 ++++ utils/time.go | 64 ++++++++++++++++- utils/time_test.go | 71 +++++++++++++++++++ 13 files changed, 351 insertions(+), 6 deletions(-) diff --git a/core/playlists/import.go b/core/playlists/import.go index 9d3ecabc5..bafb870cd 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -187,7 +186,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.OwnerID = pls.OwnerID newPls.Public = pls.Public newPls.UploadedImage = pls.UploadedImage // Preserve manual upload - newPls.EvaluatedAt = &time.Time{} + newPls.EvaluatedAt = nil // force re-evaluation on next read } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) newPls.OwnerID = owner.ID diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3f886aadd..f34524e27 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -135,6 +135,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod } if rulesChanged { current.Rules = entity.Rules + current.EvaluatedAt = nil // force re-evaluation on next read } if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 79d72d147..58a327bde 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -314,6 +314,38 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Public).To(BeTrue()) }) + It("resets EvaluatedAt when rules change", func() { + evaluatedAt := time.Now().Add(-1 * time.Hour) + mockPlsRepo.Data["smart-reset"] = &model.Playlist{ + ID: "smart-reset", + Name: "Smart", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + EvaluatedAt: &evaluatedAt, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}} + err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil()) + }) + + It("keeps EvaluatedAt when rules are not changed", func() { + evaluatedAt := time.Now().Add(-1 * time.Hour) + mockPlsRepo.Data["smart-keep"] = &model.Playlist{ + ID: "smart-keep", + Name: "Smart", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + EvaluatedAt: &evaluatedAt, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil()) + Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) + }) + It("updates name and rules together (smart-playlist Edit form)", func() { mockPlsRepo.Data["smart-edit"] = &model.Playlist{ ID: "smart-edit", diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 8c3d183a9..5d7dc3826 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,9 +4,12 @@ package criteria import ( "encoding/json" "errors" + "fmt" "slices" + "time" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils" ) type Expression interface { @@ -20,6 +23,7 @@ type Criteria struct { Limit int LimitPercent int Offset int + RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay } // EffectiveLimit resolves the effective limit for a query. If a fixed Limit is @@ -83,6 +87,7 @@ func (c Criteria) MarshalJSON() ([]byte, error) { Limit int `json:"limit,omitempty"` LimitPercent int `json:"limitPercent,omitempty"` Offset int `json:"offset,omitempty"` + RefreshDelay string `json:"refreshDelay,omitempty"` }{ Sort: c.Sort, Order: c.Order, @@ -90,6 +95,9 @@ func (c Criteria) MarshalJSON() ([]byte, error) { LimitPercent: c.LimitPercent, Offset: c.Offset, } + if c.RefreshDelay > 0 { + aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay) + } switch rules := c.Expression.(type) { case Any: aux.Any = rules @@ -110,6 +118,7 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { Limit int `json:"limit"` LimitPercent int `json:"limitPercent"` Offset int `json:"offset"` + RefreshDelay string `json:"refreshDelay"` } if err := json.Unmarshal(data, &aux); err != nil { return err @@ -131,6 +140,14 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { c.Limit = aux.Limit c.Offset = aux.Offset + if aux.RefreshDelay != "" { + d, err := utils.ParseDuration(aux.RefreshDelay) + if err != nil { + return fmt.Errorf("invalid refreshDelay: %w", err) + } + c.RefreshDelay = d + } + // Clamp LimitPercent to [0, 100] if aux.LimitPercent < 0 { log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent) diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 7f214e703..5e653150a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -3,6 +3,7 @@ package criteria import ( "bytes" "encoding/json" + "time" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" @@ -255,6 +256,71 @@ var _ = Describe("Criteria", func() { }) }) + Describe("refreshDelay", func() { + newCriteria := func(extra string) []byte { + return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`) + } + + It("unmarshals a valid refreshDelay", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour)) + }) + + It("supports week units", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour)) + }) + + It("leaves RefreshDelay zero when absent", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.BeZero()) + }) + + It("rejects an invalid refreshDelay", func() { + var c Criteria + err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) + }) + + It("rejects a negative refreshDelay", func() { + var c Criteria + err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) + }) + + It("marshals RefreshDelay back as a duration string", func() { + c := Criteria{ + Expression: All{Is{"loved": true}}, + RefreshDelay: 24 * time.Hour, + } + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`)) + }) + + It("omits refreshDelay from JSON when zero", func() { + c := Criteria{Expression: All{Is{"loved": true}}} + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay")) + }) + + It("round-trips through marshal and unmarshal", func() { + c := Criteria{ + Expression: All{Is{"loved": true}}, + RefreshDelay: 36 * time.Hour, + } + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + var c2 Criteria + gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed()) + gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour)) + }) + }) + Context("with child playlists", func() { var ( topLevelInPlaylistID string diff --git a/model/playlist.go b/model/playlist.go index 40adb8d0a..185f6f942 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" ) @@ -39,6 +40,15 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +// RefreshDelay returns the playlist's own refresh window when set, falling +// back to the global SmartPlaylistRefreshDelay. +func (pls Playlist) RefreshDelay() time.Duration { + if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 { + return pls.Rules.RefreshDelay + } + return conf.Server.SmartPlaylistRefreshDelay +} + func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil diff --git a/model/playlist_test.go b/model/playlist_test.go index 9ed24f00f..d936129ce 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,7 +1,12 @@ package model_test import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -43,4 +48,29 @@ var _ = Describe("Playlist", func() { Expect(pls.ToM3U8()).To(Equal(expected)) }) }) + + Describe("RefreshDelay", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second + }) + + It("returns the global config value when rules have no refreshDelay", func() { + pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}} + Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) + }) + + It("returns the per-playlist value when set", func() { + pls := model.Playlist{Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Is{"loved": true}}, + RefreshDelay: 24 * time.Hour, + }} + Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour)) + }) + + It("returns the global value for non-smart playlists", func() { + pls := model.Playlist{} + Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 54f316152..f7c41597d 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -4,7 +4,6 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -77,7 +76,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr if !pls.IsSmartPlaylist() { return false } - if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() { return false } if pls.OwnerID != usr.ID { diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 7bc705385..8e3ae488f 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -147,6 +147,50 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) }) }) + + Context("per-playlist refreshDelay", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + evaluatedAt := time.Now().Add(-1 * time.Hour) + + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + RefreshDelay: 24 * time.Hour, + } + pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + got, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + // Not re-evaluated: EvaluatedAt unchanged, no tracks materialized + Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) + Expect(got.Tracks).To(BeEmpty()) + }) + + It("refreshes when the per-playlist delay has elapsed, even if global has not", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + evaluatedAt := time.Now().Add(-10 * time.Minute) + + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + RefreshDelay: 5 * time.Minute, + } + pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + got, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + Expect(got.Tracks).To(HaveLen(1)) + Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + }) + }) }) }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 7101f9f15..c58fb9ab9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -168,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(p.RefreshDelay())) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 697dd5852..f0a2f8ac5 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -248,6 +248,20 @@ var _ = Describe("buildPlaylist", func() { Expect(result.OpenSubsonicPlaylist).To(BeNil()) }) }) + + Context("with a per-playlist refreshDelay", func() { + BeforeEach(func() { + playlist.Rules.RefreshDelay = 24 * time.Hour + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("computes validUntil from the playlist's own delay", func() { + result := router.buildPlaylist(ctx, playlist) + expected := evaluatedAt.Add(24 * time.Hour) + Expect(result.ValidUntil).To(Equal(&expected)) + }) + }) }) Describe("annotation leakage", func() { diff --git a/utils/time.go b/utils/time.go index c1e949589..b3f1a98fb 100644 --- a/utils/time.go +++ b/utils/time.go @@ -1,6 +1,12 @@ package utils -import "time" +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) func TimeNewest(times ...time.Time) time.Time { newest := time.Time{} @@ -11,3 +17,59 @@ func TimeNewest(times ...time.Time) time.Time { } return newest } + +var durationDayWeekRe = regexp.MustCompile(`-?\d+(?:\.\d+)?[dw]`) + +// ParseDuration is time.ParseDuration extended with d (24h) and w (168h) units. +// Negative durations are rejected. +func ParseDuration(s string) (time.Duration, error) { + expanded := durationDayWeekRe.ReplaceAllStringFunc(s, func(match string) string { + value, err := strconv.ParseFloat(match[:len(match)-1], 64) + if err != nil { + return match + } + hours := value * 24 + if match[len(match)-1] == 'w' { + hours = value * 24 * 7 + } + return strconv.FormatFloat(hours, 'f', -1, 64) + "h" + }) + d, err := time.ParseDuration(expanded) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + if d < 0 { + return 0, fmt.Errorf("negative duration not allowed: %q", s) + } + return d, nil +} + +// FormatDuration renders whole w/d multiples with those units, falling back to +// time.Duration.String for the sub-day remainder, so ParseDuration round-trips. +func FormatDuration(d time.Duration) string { + if d < 24*time.Hour { + return formatSubDay(d) + } + var b strings.Builder + weekDuration := 7 * 24 * time.Hour + if weeks := d / weekDuration; weeks > 0 { + b.WriteString(strconv.Itoa(int(weeks)) + "w") + d %= weekDuration + } + dayDuration := 24 * time.Hour + if days := d / dayDuration; days > 0 { + b.WriteString(strconv.Itoa(int(days)) + "d") + d %= dayDuration + } + if d > 0 { + b.WriteString(formatSubDay(d)) + } + return b.String() +} + +func formatSubDay(d time.Duration) string { + if d >= time.Hour && d%time.Hour == 0 { + return strconv.Itoa(int(d/time.Hour)) + "h" + } + return d.String() +} diff --git a/utils/time_test.go b/utils/time_test.go index f89f0d2be..8460b98f9 100644 --- a/utils/time_test.go +++ b/utils/time_test.go @@ -26,3 +26,74 @@ var _ = Describe("TimeNewest", func() { Expect(utils.TimeNewest(t1, t2, t3)).To(Equal(t2)) }) }) + +var _ = Describe("ParseDuration", func() { + DescribeTable("parses valid durations", + func(input string, expected time.Duration) { + d, err := utils.ParseDuration(input) + Expect(err).ToNot(HaveOccurred()) + Expect(d).To(Equal(expected)) + }, + Entry("standard Go units", "90m", 90*time.Minute), + Entry("hours", "12h", 12*time.Hour), + Entry("days", "1d", 24*time.Hour), + Entry("weeks", "1w", 7*24*time.Hour), + Entry("multiple days", "3d", 72*time.Hour), + Entry("mixed day and hours", "1d12h", 36*time.Hour), + Entry("mixed week, day and hours", "1w2d3h", (7*24+2*24+3)*time.Hour), + Entry("fractional days", "0.5d", 12*time.Hour), + ) + + DescribeTable("rejects invalid durations", + func(input string) { + _, err := utils.ParseDuration(input) + Expect(err).To(HaveOccurred()) + }, + Entry("empty string", ""), + Entry("not a duration", "tomorrow"), + Entry("bare number", "42"), + Entry("unit only", "d"), + Entry("unknown unit", "5y"), + ) + + DescribeTable("rejects negative durations", + func(input string) { + _, err := utils.ParseDuration(input) + Expect(err).To(MatchError(ContainSubstring("negative duration"))) + }, + Entry("negative days", "-1d"), + Entry("negative weeks", "-0.5w"), + Entry("negative Go units", "-30m"), + ) +}) + +var _ = Describe("FormatDuration", func() { + DescribeTable("formats durations using the largest whole units", + func(input time.Duration, expected string) { + Expect(utils.FormatDuration(input)).To(Equal(expected)) + }, + Entry("whole weeks", 7*24*time.Hour, "1w"), + Entry("whole days", 24*time.Hour, "1d"), + Entry("multiple days", 72*time.Hour, "3d"), + Entry("day and hours", 36*time.Hour, "1d12h"), + Entry("week, day and hours", (7*24+2*24+3)*time.Hour, "1w2d3h"), + Entry("hours only", 12*time.Hour, "12h"), + Entry("sub-hour", 90*time.Minute, "1h30m0s"), + Entry("zero", time.Duration(0), "0s"), + ) + + DescribeTable("round-trips through ParseDuration", + func(input string) { + d, err := utils.ParseDuration(input) + Expect(err).ToNot(HaveOccurred()) + formatted := utils.FormatDuration(d) + d2, err := utils.ParseDuration(formatted) + Expect(err).ToNot(HaveOccurred()) + Expect(d2).To(Equal(d)) + }, + Entry("1d", "1d"), + Entry("1w", "1w"), + Entry("1d12h", "1d12h"), + Entry("90m", "90m"), + ) +}) From 64430af9ce442c7266101204bcf53060f7e1443a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 17 Jul 2026 21:53:17 -0400 Subject: [PATCH 376/596] feat(ui): add Radio to Default View options (#5801) --- ui/src/personal/defaultViews.js | 2 +- ui/src/personal/defaultViews.test.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/personal/defaultViews.js b/ui/src/personal/defaultViews.js index 09e615419..a5a2c074d 100644 --- a/ui/src/personal/defaultViews.js +++ b/ui/src/personal/defaultViews.js @@ -1,6 +1,6 @@ import albumLists, { defaultAlbumList } from '../album/albumLists' -export const resourceDefaultViews = ['artist', 'song', 'playlist'] +export const resourceDefaultViews = ['artist', 'song', 'playlist', 'radio'] export const isResourceDefaultView = (defaultView) => resourceDefaultViews.includes(defaultView) diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js index 44057a736..4e80b2340 100644 --- a/ui/src/personal/defaultViews.test.js +++ b/ui/src/personal/defaultViews.test.js @@ -25,6 +25,7 @@ describe('defaultViews', () => { { id: 'artist', name: 'resources.artist.name:2' }, { id: 'song', name: 'resources.song.name:2' }, { id: 'playlist', name: 'resources.playlist.name:2' }, + { id: 'radio', name: 'resources.radio.name:2' }, ]), ) }) @@ -33,6 +34,7 @@ describe('defaultViews', () => { expect(isResourceDefaultView('artist')).toBe(true) expect(isResourceDefaultView('song')).toBe(true) expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('radio')).toBe(true) expect(isResourceDefaultView('recentlyAdded')).toBe(false) }) From deaa5e6c028abd70acb2427c50c478e79eab61dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 14:45:47 -0400 Subject: [PATCH 377/596] feat(ui): playlist favourites (heart, list filter, sidebar favourites-only toggle) (#5805) * feat(playlists): register starred REST filter on playlist repository * feat(ui): add persisted sidebarPlaylistsOnlyFavourites setting * feat(ui): playlist favourites heart column and list filter * feat(ui): favourite heart on playlist details header * feat(ui): sidebar favourites-only playlist toggle with live refresh * fix(playlists): qualify id in REST filter to avoid ambiguous column The annotation join in selectPlaylist made a bare id filter ambiguous, so GET /api/playlist?id=X (react-admin getMany, used by the sidebar refetch and useResourceRefresh) failed with 'ambiguous column name: id'. Register idFilter("playlist") like the album/artist/mediafile repos. * fix(ui): refresh favourites sidebar on local star toggle The SSE broker skips the client that originated a star, so the acting client never got the refreshResource echo and its favourites-only sidebar went stale. Key the sidebar query on a fingerprint of locally-known starred playlists so a star/unstar on this client refetches; SSE still covers other clients. * style(ui): prettier-format PlaylistsSubMenu test * feat(ui): refine playlist favourites layout - Move the favourite heart column to just before the edit button - Space the Playlists sidebar header text from its action icons - Use a list icon instead of a cog for the playlist-management action * feat(ui): make the playlist Favourite column toggleable Move the heart into the toggleable fields map so users can show/hide it from the column selector like the other optional columns, keeping it last so it stays just before the edit button. * refactor(ui): memoize sidebar star fingerprint and gate it on favourites-only - Derive starFingerprint via useMemo on the playlist data reference instead of recomputing sort/join inside useSelector on every app-wide dispatch. - Only include the fingerprint in the query payload when favourites-only is on, so a star toggle no longer refetches the sidebar when it shows all playlists. * fix(ui): don't refetch favourites sidebar on SSE events when showing all When favourites-only is off the sidebar shows every playlist, so a star event from another client changes nothing visible. Gate the SSE-driven refresh counter (and its payload key) on onlyFavourites so the sidebar no longer redraws on unrelated playlist star events. * fix(ui): address automated review feedback on playlist favourites - Ignore a persisted favourites-only preference when EnableFavourites is off, so disabling the feature later can't strand a filtered sidebar (Codex P2). - Make PlaylistLove's datagrid header props explicit (source/sortable via defaultProps, className forwarded) instead of relying on prop pass-through. - Add aria-label to the SubMenu secondary action button. - Cover the PlaylistLove list column with tests (Codex P1). --- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 29 ++++ ui/src/actions/settings.js | 7 + ui/src/i18n/en.json | 4 +- ui/src/layout/PlaylistsSubMenu.jsx | 65 ++++++++- ui/src/layout/PlaylistsSubMenu.test.jsx | 180 ++++++++++++++++++++++++ ui/src/layout/SubMenu.jsx | 31 +++- ui/src/playlist/PlaylistDetails.jsx | 36 +++-- ui/src/playlist/PlaylistList.jsx | 18 +++ ui/src/playlist/PlaylistList.test.jsx | 34 +++++ ui/src/reducers/settingsReducer.js | 7 + ui/src/reducers/settingsReducer.test.js | 36 +++++ 12 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 ui/src/layout/PlaylistsSubMenu.test.jsx create mode 100644 ui/src/playlist/PlaylistList.test.jsx create mode 100644 ui/src/reducers/settingsReducer.test.js diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..c78e0df1e 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -51,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe r.ctx = ctx r.db = db r.registerModel(&model.Playlist{}, map[string]filterFunc{ - "q": playlistFilter, - "smart": smartPlaylistFilter, + "id": idFilter("playlist"), + "q": playlistFilter, + "smart": smartPlaylistFilter, + "starred": annotationBoolFilter("starred"), }) r.setSortMappings(map[string]string{ "owner_name": "owner_name", diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c51ff6222..00d4ff9f2 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -4,6 +4,7 @@ import ( "slices" "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -155,6 +156,34 @@ var _ = Describe("PlaylistRepository", func() { Expect(count).To(Equal(int64(len(starred)))) }) + It("filters starred playlists through the registered REST filter", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"starred": "true"}, + }) + Expect(err).ToNot(HaveOccurred()) + starred := res.(model.Playlists) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + for _, p := range starred { + Expect(p.Starred).To(BeTrue()) + } + + res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"starred": "false"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID))) + }) + + It("reads a playlist by id through the REST id filter without ambiguity", func() { + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"id": plsID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID))) + }) + It("does not leak an annotation row of another item_type sharing the playlist id", func() { // Older builds (and the star fallthrough) can leave a media_file-typed row // under a playlist id; the item_type-scoped join must not surface or dupe it. diff --git a/ui/src/actions/settings.js b/ui/src/actions/settings.js index e62ecde8f..89c8f248f 100644 --- a/ui/src/actions/settings.js +++ b/ui/src/actions/settings.js @@ -1,6 +1,8 @@ export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE' export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS' export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS' +export const SET_SIDEBAR_PLAYLISTS_FAVOURITES = + 'SET_SIDEBAR_PLAYLISTS_FAVOURITES' export const setNotificationsState = (enabled) => ({ type: SET_NOTIFICATIONS_STATE, @@ -16,3 +18,8 @@ export const setOmittedFields = (obj) => ({ type: SET_OMITTED_FIELDS, data: obj, }) + +export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({ + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: enabled, +}) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..c0e226453 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -210,7 +210,8 @@ "songCount": "Songs", "comment": "Comment", "sync": "Auto-import", - "path": "Import from" + "path": "Import from", + "starred": "Favourite" }, "actions": { "selectPlaylist": "Select a playlist:", @@ -635,6 +636,7 @@ }, "albumList": "Albums", "playlists": "Playlists", + "onlyFavourites": "Only show favourites", "sharedPlaylists": "Shared Playlists", "about": "About" }, diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index b94bebf86..f332f6810 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -1,19 +1,24 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { MenuItemLink, useDataProvider, useNotify, useQueryWithStore, + useTranslate, } from 'react-admin' import { useHistory } from 'react-router-dom' import QueueMusicIcon from '@material-ui/icons/QueueMusic' import { Typography } from '@material-ui/core' import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' -import { BiCog } from 'react-icons/bi' +import FavoriteIcon from '@material-ui/icons/Favorite' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import { BiListUl } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks, OverflowTooltip } from '../common' +import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common' import { DraggableTypes } from '../consts' +import { setSidebarPlaylistsOnlyFavourites } from '../actions' import config from '../config' const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { @@ -53,6 +58,37 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const history = useHistory() + const dispatch = useDispatch() + const translate = useTranslate() + const onlyFavourites = useSelector( + (state) => state.settings.sidebarPlaylistsOnlyFavourites, + ) + // Ignore a persisted preference when the feature is off, so disabling it later + // (with the toggle now hidden) doesn't strand the user on a filtered sidebar + const showFavouritesOnly = config.enableFavourites && onlyFavourites + const playlistData = useSelector( + (state) => state.admin.resources.playlist?.data, + ) + // Fingerprint of local star state; changes only when a playlist is (un)starred, + // so a local toggle refetches the sidebar without the SSE echo the actor never gets + const starFingerprint = useMemo(() => { + const data = playlistData || {} + return Object.keys(data) + .filter((id) => data[id]?.starred) + .sort() + .join(',') + }, [playlistData]) + const [refreshCount, setRefreshCount] = useState(0) + + // Only the favourites-only view depends on star state changing elsewhere; + // when showing all playlists a star event from another client changes nothing + // async because useRefreshOnEvents calls .catch() on the returned value + const onRefresh = useCallback(async () => { + if (showFavouritesOnly) setRefreshCount((count) => count + 1) + }, [showFavouritesOnly]) + useRefreshOnEvents({ events: ['playlist'], onRefresh }) + + // A changed payload signature makes useQueryWithStore refetch const { data, loaded } = useQueryWithStore({ type: 'getList', resource: 'playlist', @@ -62,6 +98,11 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { perPage: config.maxSidebarPlaylists, }, sort: { field: 'name' }, + ...(showFavouritesOnly && { + filter: { starred: true }, + starFingerprint, + refresh: refreshCount, + }), }, }) @@ -98,6 +139,10 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { [history], ) + const handleToggleFavourites = useCallback(() => { + dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites)) + }, [dispatch, onlyFavourites]) + return ( <> <SubMenu @@ -107,8 +152,20 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { name={'menu.playlists'} icon={<QueueMusicIcon />} dense={dense} - actionIcon={<BiCog />} + actionIcon={<BiListUl />} onAction={onPlaylistConfig} + onSecondaryAction={ + config.enableFavourites ? handleToggleFavourites : undefined + } + secondaryActionIcon={ + onlyFavourites ? ( + <FavoriteIcon fontSize={'small'} /> + ) : ( + <FavoriteBorderIcon fontSize={'small'} /> + ) + } + secondaryActionTitle={translate('menu.onlyFavourites')} + secondaryActionActive={onlyFavourites} > {myPlaylists.map(renderPlaylistMenuItemLink)} </SubMenu> diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx new file mode 100644 index 000000000..617f60a4e --- /dev/null +++ b/ui/src/layout/PlaylistsSubMenu.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { settingsReducer, activityReducer } from '../reducers' +import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions' +import PlaylistsSubMenu from './PlaylistsSubMenu' + +const mockUseQueryWithStore = vi.fn() + +vi.mock('../config', () => ({ + // losslessFormats is read at module-load time by common/QualityInfo.jsx, + // pulled in transitively via the '../common' barrel file + default: { + enableFavourites: true, + maxSidebarPlaylists: 100, + losslessFormats: '', + }, +})) + +vi.mock('react-dnd', () => ({ + useDrop: () => [{}, () => {}], +})) + +vi.mock('react-router-dom', () => ({ + useHistory: () => ({ push: vi.fn() }), +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (x) => x, + useDataProvider: () => ({ addToPlaylist: vi.fn() }), + useNotify: () => vi.fn(), + useQueryWithStore: (query) => mockUseQueryWithStore(query), + MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>, + } +}) + +const playlists = { + 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' }, + 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' }, +} + +const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA' +const adminReducer = (state = { resources: {} }, action) => + action.type === SET_PLAYLIST_DATA + ? { resources: { playlist: { data: action.data } } } + : state + +const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => { + const store = createStore( + combineReducers({ + settings: settingsReducer, + activity: activityReducer, + admin: adminReducer, + }), + { + settings: preloadedSettings, + activity: {}, + admin: { + resources: preloadedPlaylistData + ? { playlist: { data: preloadedPlaylistData } } + : {}, + }, + }, + ) + const theme = createTheme() + render( + <Provider store={store}> + <ThemeProvider theme={theme}> + <PlaylistsSubMenu + state={{ menuPlaylists: true, menuSharedPlaylists: true }} + setState={vi.fn()} + sidebarIsOpen={true} + dense={false} + /> + </ThemeProvider> + </Provider>, + ) + return store +} + +const lastQuery = () => + mockUseQueryWithStore.mock.calls[ + mockUseQueryWithStore.mock.calls.length - 1 + ][0] + +describe('<PlaylistsSubMenu />', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.setItem('userId', 'user-1') + mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true }) + // SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom + window.matchMedia = (query) => ({ + matches: false, + media: query, + addListener: () => {}, + removeListener: () => {}, + }) + // OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + }) + + it('queries without a starred filter by default', () => { + renderMenu() + expect(lastQuery().payload.filter).toBeUndefined() + expect(screen.getByText('Mine')).not.toBeNull() + expect(screen.getByText('Theirs')).not.toBeNull() + }) + + it('adds the starred filter when favourites-only is enabled', () => { + renderMenu({ sidebarPlaylistsOnlyFavourites: true }) + expect(lastQuery().payload.filter).toEqual({ starred: true }) + }) + + it('toggles the setting when the heart action is clicked', () => { + const store = renderMenu() + fireEvent.click(screen.getByTitle('menu.onlyFavourites')) + expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true) + expect(lastQuery().payload.filter).toEqual({ starred: true }) + }) + + it('refetches on a playlist SSE event when favourites-only is on', async () => { + const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true }) + const before = lastQuery().payload.refresh + // useRefreshOnEvents compares Date.now() timestamps; make sure it advances + await act(() => new Promise((resolve) => setTimeout(resolve, 5))) + act(() => { + store.dispatch( + processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), + ) + }) + expect(lastQuery().payload.refresh).toBe(before + 1) + }) + + it('does not change the query signature on an SSE event when favourites-only is off', async () => { + const store = renderMenu() + const before = JSON.stringify(lastQuery().payload) + await act(() => new Promise((resolve) => setTimeout(resolve, 5))) + act(() => { + store.dispatch( + processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), + ) + }) + // Signature unchanged → useQueryWithStore dedupes, no wasted refetch + expect(lastQuery().payload.refresh).toBeUndefined() + expect(JSON.stringify(lastQuery().payload)).toBe(before) + }) + + it('refetches when a playlist is starred locally (no SSE echo)', () => { + const store = renderMenu( + { sidebarPlaylistsOnlyFavourites: true }, + { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } }, + ) + const before = lastQuery().payload.starFingerprint + act(() => { + store.dispatch({ + type: SET_PLAYLIST_DATA, + data: { + 'pl-1': { + id: 'pl-1', + name: 'Mine', + ownerId: 'user-1', + starred: true, + }, + }, + }) + }) + expect(lastQuery().payload.starFingerprint).not.toBe(before) + expect(lastQuery().payload.starFingerprint).toContain('pl-1') + }) +}) diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.jsx index 418f4c651..ee1bf343e 100644 --- a/ui/src/layout/SubMenu.jsx +++ b/ui/src/layout/SubMenu.jsx @@ -33,6 +33,9 @@ const useStyles = makeStyles( menuHeader: { width: '100%', }, + headerText: { + flexGrow: 1, + }, headerWrapper: { display: 'flex', '&:hover $actionIcon': { @@ -55,6 +58,10 @@ const SubMenu = ({ dense, onAction, actionIcon, + onSecondaryAction, + secondaryActionIcon, + secondaryActionTitle, + secondaryActionActive, }) => { const translate = useTranslate() const classes = useStyles() @@ -70,6 +77,11 @@ const SubMenu = ({ } } + const handleSecondaryClick = (e) => { + e.stopPropagation() + onSecondaryAction(e) + } + const header = ( <div className={classes.headerWrapper}> <MenuItem @@ -81,9 +93,26 @@ const SubMenu = ({ <ListItemIcon className={classes.icon}> {isOpen ? <ExpandMore /> : icon} </ListItemIcon> - <Typography variant="inherit" color="textSecondary"> + <Typography + variant="inherit" + color="textSecondary" + className={classes.headerText} + > {translate(name)} </Typography> + {onSecondaryAction && sidebarIsOpen && ( + <IconButton + size={'small'} + title={secondaryActionTitle} + aria-label={secondaryActionTitle} + className={ + isDesktop && !secondaryActionActive ? classes.actionIcon : null + } + onClick={handleSecondaryClick} + > + {secondaryActionIcon} + </IconButton> + )} {onAction && sidebarIsOpen && ( <IconButton size={'small'} diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index c396cbbeb..894809ce0 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -13,6 +13,7 @@ import { CollapsibleComment, DurationField, ImageUploadOverlay, + LoveButton, SizeField, isWritable, OverflowTooltip, @@ -81,6 +82,15 @@ const useStyles = makeStyles( overflow: 'hidden', textOverflow: 'ellipsis', wordBreak: 'break-word', + minWidth: 0, + }, + titleRow: { + display: 'flex', + alignItems: 'center', + }, + loveButton: { + marginLeft: theme.spacing(0.5), + flexShrink: 0, }, stats: { marginTop: '1em', @@ -139,14 +149,24 @@ const PlaylistDetails = (props) => { </div> <div className={classes.details}> <CardContent className={classes.content}> - <OverflowTooltip title={record.name || ''}> - <Typography - variant={isDesktop ? 'h5' : 'h6'} - className={classes.title} - > - {record.name || translate('ra.page.loading')} - </Typography> - </OverflowTooltip> + <div className={classes.titleRow}> + <OverflowTooltip title={record.name || ''}> + <Typography + variant={isDesktop ? 'h5' : 'h6'} + className={classes.title} + > + {record.name || translate('ra.page.loading')} + </Typography> + </OverflowTooltip> + <LoveButton + className={classes.loveButton} + record={record} + resource={'playlist'} + size={isDesktop ? 'default' : 'small'} + aria-label="love" + color="primary" + /> + </div> <Typography component="p" className={classes.stats}> {record.songCount ? ( <span> diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 8732725bc..642d90dd5 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -4,6 +4,7 @@ import { DateField, EditButton, Filter, + NullableBooleanInput, NumberField, ReferenceInput, SearchInput, @@ -22,11 +23,14 @@ import { CoverArtAvatar, DurationField, List, + LoveButton, Writable, isWritable, useSelectedFields, useResourceRefresh, } from '../common' +import FavoriteIcon from '@material-ui/icons/Favorite' +import config from '../config' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' @@ -53,6 +57,12 @@ const PlaylistFilter = (props) => { <SelectInput optionText="name" /> </ReferenceInput> )} + {config.enableFavourites && ( + <NullableBooleanInput + source="starred" + label={<FavoriteIcon fontSize={'small'} />} + /> + )} </Filter> ) } @@ -139,6 +149,13 @@ const PlaylistListBulkActions = (props) => { ) } +// Datagrid reads `source`/`sortable`/`label` off this element for the column +// header; only record/resource are forwarded so they never leak onto the button. +export const PlaylistLove = ({ record, className }) => ( + <LoveButton record={record} resource={'playlist'} className={className} /> +) +PlaylistLove.defaultProps = { source: 'starred', sortable: false } + const PlaylistList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -159,6 +176,7 @@ const PlaylistList = (props) => { sync: !isXsmall && ( <ToggleAutoImport source="sync" sortByOrder={'DESC'} /> ), + starred: config.enableFavourites && <PlaylistLove />, }), [isDesktop, isXsmall], ) diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx new file mode 100644 index 000000000..4fbc6d516 --- /dev/null +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { PlaylistLove } from './PlaylistList' + +vi.mock('../config', () => ({ + default: { enableFavourites: true }, +})) + +vi.mock('../common', () => ({ + LoveButton: ({ record, resource }) => ( + <button data-testid="love" data-resource={resource}> + {record?.starred ? 'starred' : 'not-starred'} + </button> + ), +})) + +describe('<PlaylistLove />', () => { + it('renders a LoveButton bound to the playlist resource', () => { + render(<PlaylistLove record={{ id: 'pl-1', starred: true }} />) + const btn = screen.getByTestId('love') + expect(btn.getAttribute('data-resource')).toBe('playlist') + expect(btn.textContent).toBe('starred') + }) + + it('exposes datagrid header props so the column renders unsorted', () => { + // The Datagrid reads these off the element; the wrapper body must not + // forward them to the button (which would leak onto the DOM). + expect(PlaylistLove.defaultProps).toEqual({ + source: 'starred', + sortable: false, + }) + }) +}) diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js index 0e598c22d..3f8278ea4 100644 --- a/ui/src/reducers/settingsReducer.js +++ b/ui/src/reducers/settingsReducer.js @@ -1,6 +1,7 @@ import { SET_NOTIFICATIONS_STATE, SET_OMITTED_FIELDS, + SET_SIDEBAR_PLAYLISTS_FAVOURITES, SET_TOGGLEABLE_FIELDS, } from '../actions' @@ -8,6 +9,7 @@ const initialState = { notifications: false, toggleableFields: {}, omittedFields: {}, + sidebarPlaylistsOnlyFavourites: false, } export const settingsReducer = (previousState = initialState, payload) => { @@ -34,6 +36,11 @@ export const settingsReducer = (previousState = initialState, payload) => { ...data, }, } + case SET_SIDEBAR_PLAYLISTS_FAVOURITES: + return { + ...previousState, + sidebarPlaylistsOnlyFavourites: data, + } default: return previousState } diff --git a/ui/src/reducers/settingsReducer.test.js b/ui/src/reducers/settingsReducer.test.js new file mode 100644 index 000000000..7ad68b291 --- /dev/null +++ b/ui/src/reducers/settingsReducer.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { settingsReducer } from './settingsReducer' +import { + SET_SIDEBAR_PLAYLISTS_FAVOURITES, + setSidebarPlaylistsOnlyFavourites, +} from '../actions' + +describe('settingsReducer', () => { + it('defaults sidebarPlaylistsOnlyFavourites to false', () => { + const state = settingsReducer(undefined, { type: 'UNKNOWN' }) + expect(state.sidebarPlaylistsOnlyFavourites).toBe(false) + }) + + it('enables the flag via the action creator', () => { + const state = settingsReducer( + undefined, + setSidebarPlaylistsOnlyFavourites(true), + ) + expect(state.sidebarPlaylistsOnlyFavourites).toBe(true) + }) + + it('disables the flag and preserves other settings', () => { + const initial = settingsReducer(undefined, { type: 'UNKNOWN' }) + const on = settingsReducer(initial, { + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: true, + }) + const off = settingsReducer(on, { + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: false, + }) + expect(off.sidebarPlaylistsOnlyFavourites).toBe(false) + expect(off.notifications).toEqual(initial.notifications) + expect(off.toggleableFields).toEqual(initial.toggleableFields) + }) +}) From f61b4eee210d692f50d37d3bad5b3714e28a094d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 16:40:10 -0400 Subject: [PATCH 378/596] fix(deezer): pick most-popular artist among same-name matches (#5808) * fix(deezer): pick most-popular artist among same-name matches The Deezer agent searched with order=RANKING and always took the top-ranked result (artists[0]) as long as its name matched. Deezer's RANKING order isn't reliable for homonyms, so for names shared by several artists (e.g. "Queen") it locked onto a low-popularity artist whose Top Tracks are empty, leaving getTopSongs empty. Among the exact-name matches, select the one with the highest fan count instead. This resolves "Queen" to the real band (Deezer ID 412) and preserves the existing ErrNotFound behavior when nothing matches the name exactly. Fixes #5802 * fix(deezer): improve artist disambiguation by ranking exact-case names Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/deezer/deezer.go | 31 +++++++++++++++--- adapters/deezer/deezer_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index ed3071766..d8e832cf1 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -1,10 +1,12 @@ package deezer import ( + "cmp" "context" "errors" "fmt" "net/http" + "slices" "strings" "github.com/navidrome/navidrome/conf" @@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e } } - // If the first one has the same name, that's the one - if !strings.EqualFold(artists[0].Name, name) { - log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name) + // Deezer's RANKING order isn't reliable for homonyms: rank name matches + // ahead of non-matches, prefer an exact-case match, then the most fans. + rank := func(a Artist) int { + switch { + case a.Name == name: + return 2 + case strings.EqualFold(a.Name, name): + return 1 + default: + return 0 + } + } + slices.SortFunc(artists, func(a, b Artist) int { + return cmp.Or( + cmp.Compare(rank(b), rank(a)), + cmp.Compare(b.NbFan, a.NbFan), + cmp.Compare(a.ID, b.ID), + ) + }) + best := artists[0] + if !strings.EqualFold(best.Name, name) { + log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name) return nil, agents.ErrNotFound } - log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link) - return &artists[0], err + log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan) + return new(best), nil } func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 4dd251585..f478af115 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("searchArtist", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("picks the exact-name match with the most fans when several share the name", func() { + // Deezer RANKING order returns a low-popularity homonym first (see issue #5802) + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":61045802,"name":"Queen","nb_fan":75}, + {"id":141954732,"name":"Queen","nb_fan":397}, + {"id":135041032,"name":"Queen(Ares)","nb_fan":133}, + {"id":183179807,"name":"Queen","nb_fan":53}, + {"id":412,"name":"Queen","nb_fan":12744378} + ],"total":5}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(412)) + }) + + It("matches the name case-insensitively", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"QUEEN","nb_fan":10}, + {"id":2,"name":"queen","nb_fan":20} + ],"total":2}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(2)) + }) + + It("returns ErrNotFound when no result matches the name exactly", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"Queens of the Stone Age","nb_fan":100} + ],"total":1}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient From e4a423db11bae287a2d41980e36536d8f1c00a9f Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 18 Jul 2026 16:49:31 -0400 Subject: [PATCH 379/596] feat(jellyfin): emit refreshResource events on favorite/rating changes Like Subsonic's setStar/setRating, the Jellyfin favorite and rating endpoints now broadcast a refreshResource event, so the web UI updates immediately when a Jellyfin client changes an annotation. Also fixes model.GetEntityByID to propagate unexpected repository errors instead of reporting them as not-found, preserving the 500-vs-404 distinction for all its callers. --- cmd/wire_gen.go | 2 +- model/get_entity.go | 35 ++++++------- model/get_entity_test.go | 40 +++++++++++++++ server/jellyfin/annotations.go | 72 ++++++++++++--------------- server/jellyfin/annotations_test.go | 64 +++++++++++++++++++++++- server/jellyfin/api.go | 6 ++- server/jellyfin/api_test.go | 10 ++-- server/jellyfin/e2e/e2e_suite_test.go | 1 + server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- 10 files changed, 163 insertions(+), 71 deletions(-) create mode 100644 model/get_entity_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 463ac7a44..c4a797ef4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -138,7 +138,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) return router } diff --git a/model/get_entity.go b/model/get_entity.go index 60972b2e9..3e1a78d1d 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -2,29 +2,26 @@ package model import ( "context" + "errors" ) // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - ar, err := ds.Artist(ctx).Get(id) - if err == nil { - return ar, nil + getters := []func() (any, error){ + func() (any, error) { return ds.Artist(ctx).Get(id) }, + func() (any, error) { return ds.Album(ctx).Get(id) }, + func() (any, error) { return ds.Playlist(ctx).Get(id) }, + func() (any, error) { return ds.MediaFile(ctx).Get(id) }, + func() (any, error) { return ds.Radio(ctx).Get(id) }, } - al, err := ds.Album(ctx).Get(id) - if err == nil { - return al, nil + for _, get := range getters { + entity, err := get() + if err == nil { + return entity, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } } - pls, err := ds.Playlist(ctx).Get(id) - if err == nil { - return pls, nil - } - mf, err := ds.MediaFile(ctx).Get(id) - if err == nil { - return mf, nil - } - r, err := ds.Radio(ctx).Get(id) - if err == nil { - return r, nil - } - return nil, err + return nil, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go new file mode 100644 index 000000000..f8a4c9e8e --- /dev/null +++ b/model/get_entity_test.go @@ -0,0 +1,40 @@ +package model_test + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetEntityByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the entity matching the id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + entity, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) + Expect(entity.(*model.Album).ID).To(Equal("a1")) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("propagates unexpected repository errors instead of reporting not-found", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true) + _, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(model.ErrNotFound)) + }) +}) diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index 8f900ee87..f2c2f546e 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -8,53 +8,41 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" ) -// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't -// access their library; artists span libraries (library_artist), so have no single LibraryID to -// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. -// When ok is false the response has already been written, so callers must return without writing -// the annotation. -func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { +// resolveAnnotated finds which annotated repo owns id, returning the resource name used in +// refreshResource events. Albums and songs 404 when the user can't access their library; artists +// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time +// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has +// already been written, so callers must return without writing the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) { ctx := r.Context() + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, "" + } u, _ := request.UserFrom(ctx) - if al, err := api.ds.Album(ctx).Get(id); err == nil { - if !u.HasLibraryAccess(al.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + switch e := entity.(type) { + case *model.Album: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.Album(ctx), "album" } - return api.ds.Album(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - if _, err := api.ds.Artist(ctx).Get(id); err == nil { - return api.ds.Artist(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { - if !u.HasLibraryAccess(mf.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + case *model.Artist: + return api.ds.Artist(ctx), "artist" + case *model.MediaFile: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.MediaFile(ctx), "song" } - return api.ds.MediaFile(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - playlistRepo := api.ds.Playlist(ctx) - if _, err := playlistRepo.Get(id); err == nil { - return playlistRepo, true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false + case *model.Playlist: + return api.ds.Playlist(ctx), "playlist" } + // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + return nil, "" } // getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify @@ -77,14 +65,15 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - repo, ok := api.resolveAnnotated(w, r, id) - if !ok { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetStar(starred, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) } @@ -96,14 +85,15 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - repo, ok := api.resolveAnnotated(w, r, id) - if !ok { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetRating(rating, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} if rating > 0 { diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 4a7efabd7..9e811a379 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -17,6 +18,7 @@ import ( var _ = Describe("Annotations", func() { var api *Router var ds *tests.MockDataStore + var broker *fakeEventBroker // alice has access to library 1 only. ctxUser := func() context.Context { return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) @@ -24,7 +26,8 @@ var _ = Describe("Annotations", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - api = &Router{ds: ds} + broker = &fakeEventBroker{} + api = &Router{ds: ds, broker: broker} }) Describe("markFavorite / unmarkFavorite", func() { @@ -134,6 +137,39 @@ var _ = Describe("Annotations", func() { invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + + It("emits a refreshResource event when starring a song", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) + + It("emits a refreshResource event when starring an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`)) + }) + + It("does not emit an event when the item is not accessible", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(broker.Events).To(BeEmpty()) + }) }) Describe("setRating / removeRating", func() { @@ -253,5 +289,31 @@ var _ = Describe("Annotations", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) }) + + It("emits a refreshResource event when rating a song", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) }) }) + +type fakeEventBroker struct { + http.Handler + Events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 5e7cad0b8..0f79011c7 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/cache" ) @@ -38,6 +39,7 @@ type Router struct { provider external.Provider sonic sonic.Engine lyrics lyrics.Lyrics + broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex @@ -47,11 +49,11 @@ type Router struct { func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 62af74794..e8e9cbd4b 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index e0f7b5bb5..445566387 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -329,6 +329,7 @@ func setupTestDB() { providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), + events.NoopBroker(), ) } diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index b0bc92caf..62b52cfff 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 7d8874476..8c509fe91 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { From 9c7cf7d734e0a34fd47bdbd66c3e87983f5b383e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:46:11 +0000 Subject: [PATCH 380/596] feat(jellyfin): expose genreitems for song/album (#5809) * feat(jellyfin): expose genreitems for song/album * use encode id instead --- server/jellyfin/dto/dto.go | 1 + server/jellyfin/dto/mappers.go | 2 ++ server/jellyfin/dto/mappers_test.go | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 9847a239c..2a817739b 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -70,6 +70,7 @@ type BaseItemDto struct { Artists []string `json:"Artists,omitempty"` ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index c65b8933d..2894edd6e 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -175,6 +175,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if len(mf.Genres) > 0 { for _, g := range mf.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } else if mf.Genre != "" { item.Genres = []string{mf.Genre} @@ -216,6 +217,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { if len(al.Genres) > 0 { for _, g := range al.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } return item diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index e72efd617..db0c56aa3 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -15,6 +15,7 @@ var _ = Describe("mappers", func() { ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, + Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}, } mf.PlayCount = 2 mf.Starred = true @@ -35,6 +36,8 @@ var _ = Describe("mappers", func() { Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) Describe("Fields gating (matches real Jellyfin)", func() { @@ -198,7 +201,7 @@ var _ = Describe("mappers", func() { }) It("maps an album to a MusicAlbum folder item", func() { - al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} item := AlbumToBaseItem(al) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) @@ -211,6 +214,8 @@ var _ = Describe("mappers", func() { Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) It("maps an artist to a MusicArtist folder item", func() { From 09ac342f5ae58ca69d60ca2d0cdf2302cf5bf76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 18:16:52 -0400 Subject: [PATCH 381/596] fix(jellyfin): split Artists/ArtistItems per track artist (#5810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): split Artists/ArtistItems per track artist The Jellyfin API returned a single ArtistItems entry built from the flattened display artist, so a multi-artist track (e.g. "De La Soul feat. Redman") lost its individual artists and dropped every artist id but the first — while the same track's OpenSubsonic response correctly split them. Real Jellyfin splits Artists/ArtistItems one entry per track artist. Build both from Participants[RoleArtist], which is already loaded on the search and browse paths, falling back to the flattened display fields when absent. AlbumArtists stays a single credit, matching real Jellyfin. * fix(jellyfin): omit empty Artists in the fallback path When a track has no participants and no display artist, the fallback set Artists to a single-element slice holding an empty string. Only populate it when the display artist is non-empty, so untagged tracks omit the field instead of sending [""]. --- server/jellyfin/dto/mappers.go | 21 +++++++++++++++------ server/jellyfin/dto/mappers_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 2894edd6e..dc9bc5fbd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) // Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. @@ -139,7 +140,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { Album: mf.Album, AlbumId: EncodeID(mf.AlbumID), AlbumArtist: mf.AlbumArtist, - Artists: []string{mf.Artist}, RunTimeTicks: TicksFromSeconds(mf.Duration), DateCreated: jellyfinDate(&mf.CreatedAt), Container: mf.Suffix, @@ -153,11 +153,20 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if fields.Has("SortName") { item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) } - // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown - // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; - // AlbumArtists the album artist. - if mf.ArtistID != "" { - item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). + // Participants holds the per-artist list; fall back to the flattened display fields when absent. + if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 { + item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name }) + item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair { + return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)} + }) + } else { + if mf.Artist != "" { + item.Artists = []string{mf.Artist} + } + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } } if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index db0c56aa3..6bfc9d95a 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -99,6 +99,32 @@ var _ = Describe("mappers", func() { Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) }) + It("omits Artists when the track has no artist name or participants", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil()) + }) + + It("splits Artists and ArtistItems per track artist from Participants", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Oooh", + Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul", + AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul", + } + mf.Participants = model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}}, + {Artist: model.Artist{ID: "ar-redman", Name: "Redman"}}, + }, + } + item := SongToBaseItem(mf, nil) + Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{ + {Name: "De La Soul", Id: EncodeID("ar-delasoul")}, + {Name: "Redman", Id: EncodeID("ar-redman")}, + })) + // AlbumArtists stays single, matching real Jellyfin. + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) + }) + It("builds a MediaSourceInfo from a media file", func() { mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) From 1e82f515c462bc08c1f0ffe058e286a20560626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 18:59:08 -0400 Subject: [PATCH 382/596] fix(jellyfin): honor repeated Fields query params (#5811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fields param was read with StringOr, which keeps only one value, so a request sending it as repeated params (Fields=Genres&Fields=MediaSources) — as Finamp and Feishin do — lost all but the first. Field-gated data like MediaSources was then omitted for Audio items even though the client asked for it; the comma-separated form happened to work because ParseFields splits on commas. Real Jellyfin accepts both forms. ParseFields is now variadic and a parseFields helper reads every repeated value via req.Values.Strings, applied to the item list, single-item, and playlist endpoints. --- server/jellyfin/dto/fields.go | 13 ++++++++----- server/jellyfin/dto/fields_test.go | 26 ++++++++++++++++++++++++++ server/jellyfin/e2e/browsing_test.go | 10 ++++++++++ server/jellyfin/items.go | 11 +++++++++-- server/jellyfin/playlists.go | 2 +- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 server/jellyfin/dto/fields_test.go diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go index faa31ec0d..fb49e43c7 100644 --- a/server/jellyfin/dto/fields.go +++ b/server/jellyfin/dto/fields.go @@ -7,12 +7,15 @@ import "strings" // omits those unless the client asks for them. type Fields map[string]struct{} -// ParseFields splits the comma-separated Fields param into a lowercased set. -func ParseFields(csv string) Fields { +// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated +// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. +func ParseFields(values ...string) Fields { f := Fields{} - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} + for _, csv := range values { + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } } } return f diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go new file mode 100644 index 000000000..59d06734d --- /dev/null +++ b/server/jellyfin/dto/fields_test.go @@ -0,0 +1,26 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseFields", func() { + It("parses a single comma-separated value", func() { + f := ParseFields("Genres,MediaSources") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + }) + + It("parses fields spread across repeated params", func() { + f := ParseFields("Genres", "MediaSources", "SortName") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + Expect(f.Has("SortName")).To(BeTrue()) + }) + + It("returns an empty set for no values", func() { + Expect(ParseFields()).To(BeEmpty()) + Expect(ParseFields("")).To(BeEmpty()) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 18565c635..382ec9406 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -62,6 +62,16 @@ var _ = Describe("Browsing", func() { } }) + // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated + // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. + It("honors MediaSources when Fields is sent as repeated params", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + It("lists all album artists", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(4)) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b9372c397..6ed24b056 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,13 +224,20 @@ type itemsQuery struct { albumIds []string } +// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a +// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. +func parseFields(p *req.Values) dto.Fields { + values, _ := p.Strings("fields") + return dto.ParseFields(values...) +} + // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: dto.ParseFields(p.StringOr("fields", "")), + fields: parseFields(p), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -730,7 +737,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + fields := parseFields(req.Params(r)) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 084ff2975..4d3959a79 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := dto.ParseFields(p.StringOr("fields", "")) + fields := parseFields(p) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) From 3158451b8d56cabf63e6b43b818bab99534d0396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 19:30:04 -0400 Subject: [PATCH 383/596] refactor(server): drop redundant error return from req.Strings parsing (#5812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(req): drop redundant error return from Strings The error from Strings carried no information beyond emptiness — it fired exactly when the param was absent — and nearly every caller discarded it with a blank identifier. Strings now just returns the values (empty when absent), making the common optional-list reads one clean expression. The few required-param callers (scrobble, createShare) check for emptiness and return the same Subsonic error code 10 as before; their e2e tests now pin that code. Ints and Times keep their contracts by synthesizing ErrMissingParam themselves, so selectedMusicFolderIds is untouched. The jellyfin parseFields helper is inlined away, since ParseFields(p.Strings("fields")...) now compiles directly. * docs(req): clarify Strings returns nil when param is absent --- server/jellyfin/items.go | 11 ++-------- server/jellyfin/playlists.go | 2 +- server/nativeapi/missing.go | 2 +- server/nativeapi/playlists.go | 2 +- server/subsonic/bookmarks.go | 4 ++-- .../e2e/subsonic_media_annotation_test.go | 1 + server/subsonic/e2e/subsonic_sharing_test.go | 1 + server/subsonic/jukebox.go | 4 ++-- server/subsonic/library_scanning.go | 3 ++- server/subsonic/media_annotation.go | 18 +++++++-------- server/subsonic/playlists.go | 4 ++-- server/subsonic/sharing.go | 6 ++--- utils/req/req.go | 22 +++++++++---------- utils/req/req_test.go | 4 +--- 14 files changed, 38 insertions(+), 46 deletions(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 6ed24b056..c3c217cfc 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,20 +224,13 @@ type itemsQuery struct { albumIds []string } -// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a -// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. -func parseFields(p *req.Values) dto.Fields { - values, _ := p.Strings("fields") - return dto.ParseFields(values...) -} - // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: parseFields(p), + fields: dto.ParseFields(p.Strings("fields")...), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -737,7 +730,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := parseFields(req.Params(r)) + fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 4d3959a79..9afee5ac3 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := parseFields(p) + fields := dto.ParseFields(p.Strings("fields")...) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 2b455e622..0ad9bb0cc 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -68,7 +68,7 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { ctx := r.Context() p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") var err error if len(ids) == 0 { diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index ea1cf579b..90b2f9e94 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -102,7 +102,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") - ids, _ := p.Strings("id") + ids := p.Strings("id") err := pls.RemoveTracks(r.Context(), playlistId, ids) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 337712750..4a7ebaa6c 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -103,7 +103,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") currentID, _ := p.String("current") position := p.Int64Or("position", 0) @@ -176,7 +176,7 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") position := p.Int64Or("position", 0) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go index ec9b070de..74b5238f2 100644 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ b/server/subsonic/e2e/subsonic_media_annotation_test.go @@ -155,6 +155,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) }) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 03bf1f80f..0421d96ea 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -109,6 +109,7 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) It("updateShare returns error when id parameter is missing", func() { diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index c4bc643ab..d8bf53360 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -68,7 +68,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionStatus: return createResponse(pb.Status(ctx)) case ActionSet: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Set(ctx, ids)) case ActionStart: return createResponse(pb.Start(ctx)) @@ -82,7 +82,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) offset := p.IntOr("offset", 0) return createResponse(pb.Skip(ctx, index, offset)) case ActionAdd: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Add(ctx, ids)) case ActionClear: return createResponse(pb.Clear(ctx)) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index e6f64456d..9630425d2 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -45,7 +45,8 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { // Parse optional target parameters for selective scanning var targets []model.ScanTarget - if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { + if targetParams := p.Strings("target"); len(targetParams) > 0 { + var err error targets, err = model.ParseTargets(targetParams) if err != nil { return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 27170c11b..cfbff3ecb 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -71,9 +71,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -90,9 +90,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -163,9 +163,9 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } times, _ := p.Times("time") if len(times) > 0 && len(times) != len(ids) { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index c58fb9ab9..17ba1b2c9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -62,7 +62,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) - songIds, _ := p.Strings("songId") + songIds := p.Strings("songId") playlistId, _ := p.String("playlistId") name, _ := p.String("name") if playlistId == "" && name == "" { @@ -99,7 +99,7 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - songsToAdd, _ := p.Strings("songIdToAdd") + songsToAdd := p.Strings("songIdToAdd") songIndexesToRemove, _ := p.Ints("songIndexToRemove") var plsName *string if s, err := p.String("name"); err == nil { diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index a9ccfdca4..540ae79d7 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -52,9 +52,9 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } description, _ := p.String("description") diff --git a/utils/req/req.go b/utils/req/req.go index 2757fc3f5..861cca9f7 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -60,12 +60,10 @@ func (r *Values) StringOr(param, def string) string { return v } -func (r *Values) Strings(param string) ([]string, error) { - values := r.URL.Query()[param] - if len(values) == 0 { - return nil, newError(ErrMissingParam, param) - } - return values, nil +// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that +// require the param should check for emptiness themselves. +func (r *Values) Strings(param string) []string { + return r.URL.Query()[param] } func (r *Values) TimeOr(param string, def time.Time) time.Time { @@ -85,9 +83,9 @@ func (r *Values) TimeOr(param string, def time.Time) time.Time { } func (r *Values) Times(param string) ([]time.Time, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } times := make([]time.Time, len(pStr)) for i, t := range pStr { @@ -139,9 +137,9 @@ func (r *Values) Int64Or(param string, def int64) int64 { } func (r *Values) Ints(param string) ([]int, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } ints := make([]int, 0, len(pStr)) for _, s := range pStr { diff --git a/utils/req/req_test.go b/utils/req/req_test.go index d76b3b934..5f9de8483 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -60,9 +60,7 @@ var _ = Describe("Request Helpers", func() { }) It("returns empty array if param does not exist", func() { - v, err := r.Strings("xx") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(v).To(BeEmpty()) + Expect(r.Strings("xx")).To(BeEmpty()) }) }) From 59f1b4206c9fd40b10b1bb32938b546c646c8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 19:36:12 -0400 Subject: [PATCH 384/596] fix(jellyfin): serve playlist covers regardless of visibility (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image endpoint only served a private playlist's cover when the request carried a token identifying its owner or an admin. But clients fetch cover URLs without credentials — real Jellyfin's image routes are anonymous — so every private playlist rendered the generic placeholder in Jellyfin clients (observed in production), while the same covers displayed fine through the always-authenticated Subsonic/native APIs. Drop the gate and serve playlist covers like album/artist/track artwork: playlist ids are unguessable without credentials, so anonymous access does not meaningfully expose private playlist contents. --- server/jellyfin/e2e/images_test.go | 34 ++++++++---------------------- server/jellyfin/images.go | 16 +++++--------- server/jellyfin/images_test.go | 17 ++------------- server/jellyfin/middlewares.go | 2 +- 4 files changed, 17 insertions(+), 52 deletions(-) diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 0c53d75f3..7fb85cba8 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -28,9 +28,9 @@ var _ = Describe("Item images", func() { Expect(artworkSpy.lastID).To(ContainSubstring(id)) }) - It("resolves a private playlist's cover for its owner under an elevated context", func() { - // The route carries no user in ctx (public); the owner is identified by the request token, - // and resolution then runs elevated so the visibility filter doesn't eat the cover. + It("resolves a private playlist's cover under an elevated context", func() { + // The route carries no user in ctx (public); resolution runs elevated so the visibility + // filter doesn't eat the cover. plID := createPlaylist("Private Mix", nil) w := get("/Items/" + enc(plID) + "/Images/Primary") Expect(w.Code).To(Equal(http.StatusOK)) @@ -48,27 +48,11 @@ var _ = Describe("Item images", func() { Expect(w.Body.String()).To(Equal("IMG")) }) - Describe("private playlist covers", func() { - It("does not resolve a private playlist's cover for an unauthenticated caller", func() { - plID := createPlaylist("Secret Mix", nil) // owned by admin, private - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("does not resolve a private playlist's cover for another user", func() { - plID := createPlaylist("Secret Mix", nil) - w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("resolves a public playlist's cover for anyone", func() { - plID := createPlaylist("Shared Mix", nil) - Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - }) + // Covers are served regardless of playlist visibility — see getItemImage for the rationale. + It("resolves a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 722e828c5..0ec34f491 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -25,14 +25,13 @@ import ( ) func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs - // under an elevated context to bypass the persistence visibility filter; playlist access is - // gated inside resolveArtworkID. + // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials + // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) - artID := api.resolveArtworkID(ctx, r, itemId) + artID := api.resolveArtworkID(ctx, itemId) reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): @@ -49,7 +48,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing // album -> artist -> media file -> playlist. -func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { +func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { if al, err := api.ds.Album(ctx).Get(itemId); err == nil { return al.CoverArtID().String() } @@ -60,12 +59,7 @@ func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId return mf.CoverArtID().String() } if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { - // Playlist covers are user-scoped: serve a private one only for a public playlist or a - // token identifying its owner/an admin, so this public route can't probe others' covers. - u, ok := api.userFromToken(r) - if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { - return pl.CoverArtID().String() - } + return pl.CoverArtID().String() } return (model.ArtworkID{}).String() } diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 8099fcf94..e435b99fd 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -85,20 +85,7 @@ var _ = Describe("Images", func() { Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) }) - It("resolves a public playlist id to its cover artwork", func() { - ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID("pl1")) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) - }) - - It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) fa := &fakeArtwork{} @@ -108,7 +95,7 @@ var _ = Describe("Images", func() { api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring("pl1")) }) // This endpoint is public (no user in the request), so artwork must be resolved under an diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 840143ac8..c90f9c088 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -152,7 +152,7 @@ func tokenFromRequest(r *http.Request) string { } // userFromToken resolves the user for the request's token; ok is false for a missing/invalid token -// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +// or unknown subject. func (api *Router) userFromToken(r *http.Request) (model.User, bool) { token := tokenFromRequest(r) if token == "" { From 7234ea23b76c228385922cc674f7c56a1d55b3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 20:26:14 -0400 Subject: [PATCH 385/596] feat(jellyfin): expose NormalizationGain from ReplayGain tags (#5815) * feat(jellyfin): expose NormalizationGain from ReplayGain tags Adds NormalizationGain and AlbumNormalizationGain to Audio BaseItemDtos, sourced from the scanner's ReplayGain values (REPLAYGAIN_* tags, with R128_* already converted to the same -18 LUFS reference). Same wire contract as real Jellyfin: PascalCase keys, omitted when absent, no Fields gating. Album items intentionally omit the field: model.Album has no gain column and Feishin reads gain from song DTOs. * test(jellyfin): e2e coverage for NormalizationGain fields * test(jellyfin): drop redundant NormalizationGain passthrough spec The JSON-casing spec already proves the mapped values (the substring "NormalizationGain":-3.5 can only appear if the passthrough worked), so the direct-struct spec added no coverage. * style(jellyfin): use ASCII punctuation in gain comment --- server/jellyfin/dto/dto.go | 30 ++++++++++++++------------- server/jellyfin/dto/mappers.go | 3 +++ server/jellyfin/dto/mappers_test.go | 16 ++++++++++++++ server/jellyfin/e2e/browsing_test.go | 16 ++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 4 +++- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 2a817739b..c8f7129cb 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -61,20 +61,22 @@ type BaseItemDto struct { PremiereDate *string `json:"PremiereDate,omitempty"` // DateCreated is the ISO 8601 date the item was added to the library; clients show it as // "Date Added" and sort "Recently Added" by it. - DateCreated string `json:"DateCreated,omitempty"` - Album string `json:"Album,omitempty"` - AlbumId string `json:"AlbumId,omitempty"` - AlbumArtist string `json:"AlbumArtist,omitempty"` - AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` - AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitempty"` - ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitempty"` - GenreItems []NameGuidPair `json:"GenreItems,omitempty"` - ChildCount *int `json:"ChildCount,omitempty"` - SongCount *int `json:"SongCount,omitempty"` - AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + NormalizationGain *float64 `json:"NormalizationGain,omitempty"` + AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't // calculating blurhashes. diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index dc9bc5fbd..bdb0a1e37 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -171,6 +171,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} } + // dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion. + item.NormalizationGain = mf.RGTrackGain + item.AlbumNormalizationGain = mf.RGAlbumGain if mf.Year > 0 { item.ProductionYear = new(mf.Year) } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 6bfc9d95a..8867a5e73 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -125,6 +125,22 @@ var _ = Describe("mappers", func() { Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) }) + It("serializes normalization gains with Jellyfin's exact key casing", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", + RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} + b, err := json.Marshal(SongToBaseItem(mf, nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`)) + Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`)) + }) + + It("omits normalization gains when the file has no ReplayGain tags", func() { + b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil)) + Expect(err).ToNot(HaveOccurred()) + // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("builds a MediaSourceInfo from a media file", func() { mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 382ec9406..a0632994e 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -415,6 +415,22 @@ var _ = Describe("Browsing", func() { Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) }) + It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item) + Expect(item.NormalizationGain).ToNot(BeNil()) + Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001)) + Expect(item.AlbumNormalizationGain).ToNot(BeNil()) + Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001)) + }) + + It("omits normalization gains for files without ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.NormalizationGain).To(BeNil()) + Expect(item.AlbumNormalizationGain).To(BeNil()) + }) + It("resolves an artist", func() { var item dto.BaseItemDto parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 445566387..5a4c8c031 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -121,7 +121,9 @@ func buildTestFS() storagetest.FakeFS { "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ - "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "replaygain_track_gain": "-3.50 dB", + "replaygain_album_gain": "-4.25 dB", })), "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), From 4efd92cf835bd7a8e3c971b5b87e834b0f32ee6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 18 Jul 2026 22:19:29 -0400 Subject: [PATCH 386/596] feat(server): expose album-level ReplayGain in albums (#5816) * feat(model): aggregate album ReplayGain in ToAlbum * feat(db): add nullable album ReplayGain columns with backfill * feat(persistence): persist album ReplayGain fields * feat(jellyfin): expose album NormalizationGain from ReplayGain * refactor(model): lazy-init mostFrequentPtr map; clean up RG test rows * fix(db): backfill album ReplayGain with most-frequent value, not max A plain max() picked a minority outlier that diverged from MediaFiles.ToAlbum (which uses the most-frequent value), and GetTouchedAlbums never re-derives an unchanged album, so the wrong value would persist. Reproduce the modal aggregation via grouped CTEs, which also groups media_file once instead of a per-album correlated scan. * refactor(model): return an owned pointer from mostFrequentPtr Avoid aliasing a MediaFile field so the resulting Album is independent of the source slice. --- .../20260719005427_add_album_replaygain.go | 44 +++++++++++++++++++ model/album.go | 2 + model/mediafile.go | 32 ++++++++++++++ model/mediafile_test.go | 29 ++++++++++++ persistence/album_repository.go | 6 +++ persistence/album_repository_test.go | 27 ++++++++++++ server/jellyfin/dto/mappers.go | 3 ++ server/jellyfin/dto/mappers_test.go | 15 +++++++ 8 files changed, 158 insertions(+) create mode 100644 db/migrations/20260719005427_add_album_replaygain.go diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go new file mode 100644 index 000000000..79c8b8b3e --- /dev/null +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) +} + +func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain + // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, + // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + _, err := tx.ExecContext(ctx, ` +ALTER TABLE album ADD COLUMN rg_album_gain real; +ALTER TABLE album ADD COLUMN rg_album_peak real; + +WITH gain_mode AS ( + SELECT album_id, rg_album_gain AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn + FROM media_file WHERE rg_album_gain IS NOT NULL + GROUP BY album_id, rg_album_gain +), +peak_mode AS ( + SELECT album_id, rg_album_peak AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn + FROM media_file WHERE rg_album_peak IS NOT NULL + GROUP BY album_id, rg_album_peak +) +UPDATE album SET + rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), + rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + `) + return err +} + +func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/album.go b/model/album.go index ade7f6ee0..16727ea39 100644 --- a/model/album.go +++ b/model/album.go @@ -49,6 +49,8 @@ type Album struct { MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..910cd998d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -314,6 +314,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears := make([]int, 0, len(mfs)) originalDates := make([]string, 0, len(mfs)) releaseDates := make([]string, 0, len(mfs)) + rgAlbumGains := make([]*float64, 0, len(mfs)) + rgAlbumPeaks := make([]*float64, 0, len(mfs)) tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true @@ -344,6 +346,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) + rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) + rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) @@ -378,6 +382,8 @@ func (mfs MediaFiles) ToAlbum() Album { a.Comment, _ = allOrNothing(comments) a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) + a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) + a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) fixAlbumArtist(&a) return a @@ -407,6 +413,32 @@ func minMax(items []int) (int, int) { return mn, mx } +// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if +// none. It counts by dereferenced value so a genuine 0.0 is a real candidate +// (slice.MostFrequent skips the zero value and compares pointers by identity). +func mostFrequentPtr(items []*float64) *float64 { + var counts map[float64]int + var best float64 + var bestCount int + for _, it := range items { + if it == nil { + continue + } + if counts == nil { + counts = map[float64]int{} + } + counts[*it]++ + if counts[*it] > bestCount { + bestCount = counts[*it] + best = *it + } + } + if bestCount == 0 { + return nil + } + return &best +} + func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..6085ccf25 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() { }) }) }) + Context("ReplayGain", func() { + It("picks the most frequent non-nil album gain and peak", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(-8.0)) + Expect(album.RGAlbumPeak).ToNot(BeNil()) + Expect(*album.RGAlbumPeak).To(Equal(0.9)) + }) + It("keeps a genuine 0.0 gain instead of dropping it", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(0.0)}, + {Path: "b", RGAlbumGain: new(0.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(0.0)) + }) + It("leaves gain and peak nil when no track has a value", func() { + mfs := MediaFiles{{Path: "a"}, {Path: "b"}} + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).To(BeNil()) + Expect(album.RGAlbumPeak).To(BeNil()) + }) + }) Context("Participants", func() { var album Album BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..53e8efe32 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -31,6 +31,10 @@ type dbAlbum struct { Participants string `structs:"-" json:"-"` Tags string `structs:"-" json:"-"` FolderIDs string `structs:"-" json:"-"` + // dbx maps columns to fields by name; RGAlbumGain doesn't convert to + // rg_album_gain, so shim fields carry the read and PostScan copies them over. + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` } func (a *dbAlbum) PostScan() error { @@ -58,6 +62,8 @@ func (a *dbAlbum) PostScan() error { } a.Album.FolderIDs = ids } + a.Album.RGAlbumGain = a.RgAlbumGain + a.Album.RGAlbumPeak = a.RgAlbumPeak return nil } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..761255b7d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -890,6 +890,33 @@ var _ = Describe("AlbumRepository", func() { Expect(albums[0].ID).To(Equal("a1")) }) }) + + Describe("ReplayGain", func() { + BeforeEach(func() { + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) + }) + }) + It("round-trips album ReplayGain gain and peak", func() { + Expect(albumRepo.Put(&model.Album{ + ID: "rg-1", Name: "rg", LibraryID: 1, + RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), + })).To(Succeed()) + got, err := albumRepo.Get("rg-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).ToNot(BeNil()) + Expect(*got.RGAlbumGain).To(Equal(-7.5)) + Expect(got.RGAlbumPeak).ToNot(BeNil()) + Expect(*got.RGAlbumPeak).To(Equal(0.98)) + }) + It("reads nil when ReplayGain is unset", func() { + Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get("rg-2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).To(BeNil()) + Expect(got.RGAlbumPeak).To(BeNil()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bdb0a1e37..036dfbd91 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -232,6 +232,9 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same + // convention as tracks; clients read it off the album item as NormalizationGain. + item.NormalizationGain = al.RGAlbumGain return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 8867a5e73..c986e7f81 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -260,6 +260,21 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("sets NormalizationGain on the album from its ReplayGain", func() { + al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + b, err := json.Marshal(AlbumToBaseItem(al)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) + // Real Jellyfin never sets AlbumNormalizationGain on an album item. + Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) + }) + + It("omits NormalizationGain when the album has no ReplayGain", func() { + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("maps an artist to a MusicArtist folder item", func() { ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} item := ArtistToBaseItem(ar) From b27d6f61ae7813d47eb4479637187bf26ab35adb Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 18 Jul 2026 22:59:53 -0400 Subject: [PATCH 387/596] fix(db): make album ReplayGain backfill scale to large libraries The windowed-CTE backfill was compiled as a correlated scalar subquery re-evaluated once per album (a full media_file group-by + window sort each time), which never finished on a large library. Stage the ReplayGain-bearing rows into an indexed temp table once and update only the affected albums: ~14s on a 727MB / 6945-album library, vs. effectively never. --- .../20260719005427_add_album_replaygain.go | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go index 79c8b8b3e..7f5665947 100644 --- a/db/migrations/20260719005427_add_album_replaygain.go +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -12,28 +12,25 @@ func init() { } func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain - // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, - // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + // Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows + // into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album. _, err := tx.ExecContext(ctx, ` ALTER TABLE album ADD COLUMN rg_album_gain real; ALTER TABLE album ADD COLUMN rg_album_peak real; -WITH gain_mode AS ( - SELECT album_id, rg_album_gain AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn - FROM media_file WHERE rg_album_gain IS NOT NULL - GROUP BY album_id, rg_album_gain -), -peak_mode AS ( - SELECT album_id, rg_album_peak AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn - FROM media_file WHERE rg_album_peak IS NOT NULL - GROUP BY album_id, rg_album_peak -) +CREATE TEMP TABLE _rg_backfill AS + SELECT album_id, rg_album_gain, rg_album_peak FROM media_file + WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL; +CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id); + UPDATE album SET - rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), - rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL + GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1), + rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL + GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1) +WHERE album.id IN (SELECT album_id FROM _rg_backfill); + +DROP TABLE _rg_backfill; `) return err } From 5927e693d128cb3c830f6d87b59bfd5128d35dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Jul 2026 12:39:31 -0400 Subject: [PATCH 388/596] feat(jellyfin): filter items by year and record label (#5817) * feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label. --- model/album.go | 1 + model/tag.go | 1 + persistence/album_repository.go | 15 +++++++ persistence/album_repository_test.go | 60 +++++++++++++++++++++++++ persistence/tag_repository.go | 11 ++++- server/filter/filters.go | 25 +++++++++-- server/jellyfin/api.go | 2 + server/jellyfin/browsing.go | 44 +++++++++++++++++- server/jellyfin/browsing_test.go | 48 ++++++++++++++++++++ server/jellyfin/dto/dto.go | 10 +++++ server/jellyfin/dto/filters_test.go | 22 +++++++++ server/jellyfin/dto/mappers.go | 19 +++++++- server/jellyfin/dto/mappers_test.go | 32 ++++++++++--- server/jellyfin/e2e/browsing_test.go | 65 +++++++++++++++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 2 +- server/jellyfin/items.go | 42 ++++++++++++++--- server/jellyfin/library.go | 17 +++++++ server/jellyfin/similar.go | 2 +- tests/mock_album_repo.go | 7 +++ tests/mock_data_store.go | 2 +- tests/mock_genre_repo.go | 10 +++-- tests/mock_tag_repo.go | 24 ++++++++++ 22 files changed, 435 insertions(+), 26 deletions(-) create mode 100644 server/jellyfin/dto/filters_test.go create mode 100644 tests/mock_tag_repo.go diff --git a/model/album.go b/model/album.go index 16727ea39..0afeb2760 100644 --- a/model/album.go +++ b/model/album.go @@ -144,6 +144,7 @@ type AlbumRepository interface { Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) + GetYears(libraryIDs ...int) ([]int, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/tag.go b/model/tag.go index 02ccac05d..1bc011495 100644 --- a/model/tag.go +++ b/model/tag.go @@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) { type TagRepository interface { Add(libraryID int, tags ...Tag) error UpdateCounts() error + GetAll(name TagName, options ...QueryOptions) (TagList, error) } type TagName string diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 53e8efe32..2bb541003 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" @@ -262,6 +263,20 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC return wrapAlbumCursor(cursor), nil } +func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { + cond := And{Gt{"max_year": 0}, Eq{"missing": false}} + if len(libraryIDs) > 0 { + cond = append(cond, Eq{"library_id": libraryIDs}) + } + sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) + years := []int{} + err := r.queryAllSlice(sq, &years) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + return years, nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 761255b7d..465617b10 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "errors" "fmt" + "sort" "time" "github.com/Masterminds/squirrel" @@ -851,6 +852,65 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetYears", func() { + It("returns distinct album years ascending, excluding zero", func() { + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // Sorted ascending, no duplicates, no zero-year entries. + Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) + Expect(years).ToNot(ContainElement(0)) + for i := 1; i < len(years); i++ { + Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct + } + }) + + It("deduplicates repeated years", func() { + // Regression test: verify that DISTINCT is applied in the SQL. + // Insert two albums with the same non-zero max_year (2005). + album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} + album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} + Expect(albumRepo.Put(album1)).To(Succeed()) + Expect(albumRepo.Put(album2)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + + // Count occurrences of 2005 in the result + count := 0 + for _, y := range years { + if y == 2005 { + count++ + } + } + Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") + }) + + It("scopes years to the given libraries", func() { + all, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // A library with no albums yields no years. + scoped, err := albumRepo.GetYears(99999) + Expect(err).ToNot(HaveOccurred()) + Expect(scoped).To(BeEmpty()) + Expect(all).ToNot(BeEmpty()) + }) + + It("excludes years that belong only to missing albums", func() { + gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} + Expect(albumRepo.Put(gone)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + Expect(years).ToNot(ContainElement(1911)) + }) + }) + Describe("wrapAlbumCursor", func() { It("does not panic when the cursor yields a dbAlbum with nil Album", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index 5bb8b3832..f2093c9d8 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } +func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) { + sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name}) + res := model.TagList{} + err := r.queryAll(sq, &res) + return res, err +} + func (r *tagRepository) purgeUnused() error { - del := Delete(r.tableName).Where(` + del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt where atom is not null and key = 'id' - UNION + UNION select jt.value from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null diff --git a/server/filter/filters.go b/server/filter/filters.go index 5fa53daba..067f7b1f9 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -194,6 +194,16 @@ func ByAlbumID(albumIds []string) Sqlizer { return Eq{"album_id": albumIds} } +// AlbumsByYears matches albums whose production year (max_year) is in years. +func AlbumsByYears(years []int) Sqlizer { + return Eq{"max_year": years} +} + +// SongsByYears matches media files whose year is in years. +func SongsByYears(years []int) Sqlizer { + return Eq{"year": years} +} + // ArtistsByGenreID matches artists credited as album artist on an album with any of the given // genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { @@ -204,10 +214,17 @@ func ArtistsByGenreID(genreIds []string) Sqlizer { ) } -// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry -// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. -func genreTagFilter(cond Sqlizer) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each +// entry against cond (its name via Like, or its tag id via Eq/IN). +func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) +} + +func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } + +// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. +func ByStudioID(ids []string) Sqlizer { + return tagIDFilter("recordlabel", Eq{"value": ids}) } func filterByGenre(genre string) Sqlizer { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0f79011c7..1f46c08b4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) + r.Get("/studios", api.getStudios) + r.Get("/items/filters", api.getQueryFiltersLegacy) r.Post("/playlists", api.createPlaylist) r.Get("/playlists/{playlistId}", api.getPlaylist) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index fae21fc0b..29fe48c2b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" ) // getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists @@ -27,7 +28,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + scopeIDs, _ := parentIDScope(ctx, r) // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. q := itemsQuery{ @@ -59,3 +60,44 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { } api.ok(w, r, res) } + +// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as +// Studio items, scoped to ParentId's library when accessible. +func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + scope, _ := parentIDScope(ctx, r) + opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} + labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) + if err != nil { + api.internalError(w, r, err) + return + } + items := slice.Map(labels, dto.StudioToBaseItem) + offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) + api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) +} + +// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's +// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. +func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + scope, _ := parentIDScope(ctx, r) + genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} + genres, err := api.ds.Genre(ctx).GetAll(genreOpts) + if err != nil { + api.internalError(w, r, err) + return + } + years, err := api.ds.Album(ctx).GetYears(scope...) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, dto.QueryFiltersLegacy{ + Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), + Tags: []string{}, + OfficialRatings: []string{}, + Years: years, + }) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 660e5d293..e346b56cb 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -175,4 +175,52 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) }) }) + + Describe("getStudios", func() { + It("scopes results to the user's accessible libraries", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := tagRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + // An empty scope (admin, or a non-admin with no explicit library grants) must be treated + // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". + It("does not restrict results for an admin user", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(tagRepo.Options.Filters).To(BeNil()) + }) + }) + + Describe("getQueryFiltersLegacy", func() { + It("scopes genres to the user's accessible libraries", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := genreRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("does not restrict genres for an admin user", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(genreRepo.Options.Filters).To(BeNil()) + }) + }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index c8f7129cb..9b2c35c69 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -71,6 +71,7 @@ type BaseItemDto struct { ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` @@ -288,3 +289,12 @@ type LyricLineCue struct { Start int64 `json:"Start"` End *int64 `json:"End,omitempty"` } + +// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; +// clients (jellyfin-web) render each unconditionally. +type QueryFiltersLegacy struct { + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + OfficialRatings []string `json:"OfficialRatings"` + Years []int `json:"Years"` +} diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go new file mode 100644 index 000000000..ad3367264 --- /dev/null +++ b/server/jellyfin/dto/filters_test.go @@ -0,0 +1,22 @@ +package dto + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QueryFiltersLegacy", func() { + It("marshals all four keys, empty ones as [] not null", func() { + b, err := json.Marshal(QueryFiltersLegacy{ + Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, + }) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) + Expect(j).To(ContainSubstring(`"Tags":[]`)) + Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) + Expect(j).To(ContainSubstring(`"Years":[1999]`)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 036dfbd91..444a25cac 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -200,7 +200,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { return item } -func AlbumToBaseItem(al model.Album) BaseItemDto { +func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { item := BaseItemDto{ Name: al.Name, Id: EncodeID(al.ID), @@ -232,6 +232,14 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios + // list and StudioIds= filter, so a client can display and click through to filter by label. + if fields.Has("Studios") { + for _, label := range al.Tags.Values(model.TagRecordLabel) { + id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) + item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) + } + } // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain @@ -264,6 +272,15 @@ func GenreToBaseItem(g model.Genre) BaseItemDto { } } +func StudioToBaseItem(t model.Tag) BaseItemDto { + return BaseItemDto{ + Name: t.TagValue, + Id: EncodeID(t.ID), + Type: "Studio", + BackdropImageTags: []string{}, + } +} + // PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. func PlaylistToBaseItem(p model.Playlist) BaseItemDto { // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c986e7f81..c11f38eaf 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -244,7 +244,7 @@ var _ = Describe("mappers", func() { It("maps an album to a MusicAlbum folder item", func() { al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} - item := AlbumToBaseItem(al) + item := AlbumToBaseItem(al, nil) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("alb-1"))) @@ -260,9 +260,22 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("populates album Studios from record-label tags only when Fields=Studios", func() { + al := model.Album{ID: "alb-2", Name: "Alb2"} + al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} + + Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) + + item := AlbumToBaseItem(al, ParseFields("Studios")) + Expect(item.Studios).To(Equal([]NameGuidPair{ + {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, + {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, + })) + }) + It("sets NormalizationGain on the album from its ReplayGain", func() { al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al)) + b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) // Real Jellyfin never sets AlbumNormalizationGain on an album item. @@ -270,7 +283,7 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) @@ -293,6 +306,13 @@ var _ = Describe("mappers", func() { Expect(item.Name).To(Equal("Rock")) }) + It("maps a tag to a Studio BaseItemDto", func() { + item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + Expect(item.Type).To(Equal("Studio")) + Expect(item.Name).To(Equal("Blue Note")) + Expect(item.Id).To(Equal(EncodeID("t1"))) + }) + Describe("premiereDate", func() { // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. It("serializes a full date", func() { @@ -321,9 +341,9 @@ var _ = Describe("mappers", func() { }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) - Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) }) }) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index a0632994e..be6d6bb5d 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "sort" "time" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -210,6 +211,44 @@ var _ = Describe("Browsing", func() { }) }) + Describe("year filtering (Years=)", func() { + It("filters items by Years=", func() { + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + + songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) + for _, it := range songs.Items { + Expect(it.ProductionYear).ToNot(BeNil()) + Expect(*it.ProductionYear).To(Equal(1959)) + } + Expect(songs.Items).ToNot(BeEmpty()) + }) + }) + + Describe("studio filtering (StudioIds=)", func() { + It("filters items by StudioIds=", func() { + studios := queryResult(get("/Studios")) + var columbiaID string + for _, it := range studios.Items { + if it.Name == "Columbia" { + columbiaID = it.Id + } + } + Expect(columbiaID).ToNot(BeEmpty()) + + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + }) + + It("returns filter lists scoped to a ParentId library", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Years).To(ContainElements(1959, 1965)) + studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + Expect(names(studios.Items)).To(ContainElement("Columbia")) + }) + }) + // Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") @@ -482,5 +521,31 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(HaveLen(1)) Expect(q.TotalRecordCount).To(Equal(3)) }) + + It("returns record labels as Studio items", func() { + q := queryResult(get("/Studios")) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Studio")) + names = append(names, it.Name) + } + Expect(names).To(ContainElement("Columbia")) + }) + }) + + Describe("GET /Items/Filters", func() { + It("returns legacy query filters with genres, years, and empty tags/ratings", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) + Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) + // Verify ascending sort by checking it equals itself sorted. + sorted := make([]int, len(filters.Years)) + copy(sorted, filters.Years) + sort.Ints(sorted) + Expect(filters.Years).To(Equal(sorted)) + Expect(filters.Tags).To(BeEmpty()) + Expect(filters.OfficialRatings).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 5a4c8c031..31d98d1d9 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -111,7 +111,7 @@ func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) return harness.CreateFS(fstest.MapFS{ diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index c3c217cfc..7cf2a17ba 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -222,6 +222,8 @@ type itemsQuery struct { contributingOnly bool genreIds []string albumIds []string + years []int + studioIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -245,7 +247,9 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + albumIds: decodedQueryIDs(r, "albumids"), + years: parseYears(r), + studioIds: decodedQueryIDs(r, "studioids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -414,6 +418,17 @@ func decodedQueryIDs(r *http.Request, key string) []string { return slice.Map(queryIDs(r, key), dto.DecodeID) } +// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. +func parseYears(r *http.Request) []int { + var years []int + for _, v := range queryIDs(r, "years") { + if y, err := strconv.Atoi(v); err == nil && y > 0 { + years = append(years, y) + } + } + return years +} + // parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to // {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums). func parseTypes(types string) []string { @@ -481,6 +496,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO } func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's @@ -496,6 +512,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.AlbumsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -509,12 +531,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -537,6 +559,12 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.SongsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -665,7 +693,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if !u.HasLibraryAccess(al.LibraryID) { return dto.BaseItemDto{}, false } - return dto.AlbumToBaseItem(*al), true + return dto.AlbumToBaseItem(*al, fields), true } if ar, err := api.ds.Artist(ctx).Get(id); err == nil { // TODO: an artist spans multiple libraries (library_artist), so there's no single @@ -754,13 +782,15 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { // /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + p := req.Params(r) + fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() - opts.Max = req.Params(r).IntOr("limit", 20) + opts.Max = p.IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) api.writeItemsArray(w, r, streamed(open, 0, 0)) } diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2e486c36f..2830f1105 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -2,11 +2,14 @@ package jellyfin import ( "context" + "net/http" "strconv" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" ) // accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty @@ -30,6 +33,20 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { + return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +} + +// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means +// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). +func libraryScopeFilter(scope []int) squirrel.Sqlizer { + if len(scope) == 0 { + return nil + } + return squirrel.Eq{"library_tag.library_id": scope} +} + // libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. // Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. func libraryView(lib model.Library) dto.BaseItemDto { diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 085080850..661fb95cc 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -177,7 +177,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. } seen[s.AlbumID] = true if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { - items = append(items, dto.AlbumToBaseItem(*al)) + items = append(items, dto.AlbumToBaseItem(*al, nil)) if len(items) >= limit { break } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..cc7d66b4d 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -209,4 +209,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { return nil } +func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) { + if m.Err { + return nil, errors.New("error") + } + return []int{}, nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..e016a28de 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -65,7 +65,7 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { if db.RealDS != nil { return db.RealDS.Tag(ctx) } - db.MockedTag = struct{ model.TagRepository }{} + db.MockedTag = &MockTagRepo{} return db.MockedTag } diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 122ccc278..50796efc0 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -5,8 +5,9 @@ import ( ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre + Error error + Data map[string]model.Genre + Options model.QueryOptions } func (r *MockedGenreRepo) init() { @@ -15,7 +16,10 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { +func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) { + if len(options) > 0 { + r.Options = options[0] + } if r.Error != nil { return nil, r.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go new file mode 100644 index 000000000..a59035ea6 --- /dev/null +++ b/tests/mock_tag_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" +) + +// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can +// assert on which filters a caller attached (e.g. a library scope). +type MockTagRepo struct { + model.TagRepository + Data model.TagList + Options model.QueryOptions + Err error +} + +func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) { + if len(options) > 0 { + r.Options = options[0] + } + if r.Err != nil { + return nil, r.Err + } + return r.Data, nil +} From e6597398c2f9834f3376ee041b135d26bfab27e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Jul 2026 13:36:05 -0400 Subject: [PATCH 389/596] feat(scrobbler): exponential backoff for scrobble retries during outages (#5818) * feat(scrobbler): add exponential backoff delay helper * feat(scrobbler): back off retries up to 4m during outages * test(scrobbler): verify backoff schedule with synctest; clarify backoffDelay doc Adds a testing/synctest-based test that drives the real run loop against a failing service and asserts the exact 5s/10s/20s/40s retry schedule and the drain-on-recovery reset, addressing the review note that the run loop's behavior was untested. Also clarifies the backoffDelay doc comment: the argument is a zero-based retry index. --- core/scrobbler/buffered_scrobbler.go | 42 +++++++++-- core/scrobbler/buffered_scrobbler_test.go | 91 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 408ab410d..38ea83228 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -10,6 +10,30 @@ import ( "github.com/navidrome/navidrome/model/request" ) +const ( + minRetryDelay = 5 * time.Second + maxRetryDelay = 4 * time.Minute + // maxRetryShift caps the exponent so the shift never overflows int64. + // minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling. + maxRetryShift = 6 +) + +// backoffDelay returns the delay for a zero-based retry index (0 = first retry): +// minRetryDelay doubled per prior failure, clamped to maxRetryDelay. +func backoffDelay(failures int) time.Duration { + if failures < 0 { + failures = 0 + } + if failures >= maxRetryShift { + return maxRetryDelay + } + d := minRetryDelay << failures + if d > maxRetryDelay { + return maxRetryDelay + } + return d +} + // Loader is a function that loads a scrobbler by name. // It returns the scrobbler and true if found, or nil and false if not available. // This allows the buffered scrobbler to always get the current plugin instance. @@ -98,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() { } func (b *bufferedScrobbler) run(ctx context.Context) { + timer := time.NewTimer(time.Hour) + timer.Stop() + defer timer.Stop() + failures := 0 for { - if !b.processQueue(ctx) { - time.AfterFunc(5*time.Second, func() { - b.sendWakeSignal() - }) + if b.processQueue(ctx) { + failures = 0 + timer.Stop() + } else { + timer.Reset(backoffDelay(failures)) + if failures < maxRetryShift { + failures++ + } } select { case <-b.wakeSignal: - continue + case <-timer.C: case <-ctx.Done(): return } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index d11f0b003..c250085ef 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,6 +2,9 @@ package scrobbler import ( "context" + "sync/atomic" + "testing" + "testing/synctest" "time" "github.com/navidrome/navidrome/model" @@ -100,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() { } }) }) + +var _ = Describe("backoffDelay", func() { + DescribeTable("computes the exponential backoff curve clamped to the ceiling", + func(failures int, expected time.Duration) { + Expect(backoffDelay(failures)).To(Equal(expected)) + }, + Entry("first failure", 0, 5*time.Second), + Entry("second failure", 1, 10*time.Second), + Entry("third failure", 2, 20*time.Second), + Entry("fourth failure", 3, 40*time.Second), + Entry("fifth failure", 4, 80*time.Second), + Entry("sixth failure", 5, 160*time.Second), + Entry("reaches the ceiling", 6, 4*time.Minute), + Entry("stays clamped past the ceiling", 7, 4*time.Minute), + Entry("stays clamped for large values", 1000, 4*time.Minute), + Entry("negative is treated as zero", -1, 5*time.Second), + ) +}) + +// Drives the real run loop and asserts the exact retry schedule + recovery. Plain +// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestBufferedScrobblerBackoffSchedule(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + + flaky := &recoveringScrobbler{} + flaky.fail(ErrRetryLater) + bs := newBufferedScrobbler(ds, flaky, "flaky") + defer func() { bs.Stop(); synctest.Wait() }() + + // Let the loop settle on the empty buffer, then enqueue a scrobble. + synctest.Wait() + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed()) + + // First attempt fires immediately on the enqueue wake and is left buffered. + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(int32(1))) + g.Expect(buffer.Length()).To(Equal(int64(1))) + + // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. + for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { + want := int32(i + 2) + time.Sleep(gap - time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap) + time.Sleep(time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) + } + + // Once the service recovers, waking the loop drains the buffered entry. + flaky.succeed() + bs.sendWakeSignal() + synctest.Wait() + g.Expect(buffer.Length()).To(Equal(int64(0))) + }) +} + +// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while +// the buffered scrobbler's goroutine is draining, to exercise retry then recovery. +type recoveringScrobbler struct { + err atomic.Pointer[error] + count atomic.Int32 +} + +func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } +func (f *recoveringScrobbler) succeed() { f.err.Store(nil) } + +func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true } + +func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error { + return nil +} + +func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error { + f.count.Add(1) + if e := f.err.Load(); e != nil { + return *e + } + return nil +} + +func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil } From fed966506012113ba7e4272ba422116c43d28b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Jul 2026 18:51:32 -0400 Subject: [PATCH 390/596] fix(streaming): surface why a transcode decision failed (#5820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subsonic): surface the reason a transcode decision failed getTranscodeDecision returned a bare "failed to make transcode decision" with no clue why, and the probe command ran ffprobe with -v quiet, so even the server log bottomed out at "exit status 1". A user whose files had been moved by an external tool only saw the opaque error. ProbeAudioStream now returns a typed ProbeError that separates the file path from the reason: ffprobe runs with -v error so its stderr diagnostic is captured, and a missing or unreadable file is reported as "file not found" rather than ffprobe's misleading "Invalid data found". The handler logs the full detail (including the path) and returns the reason to the client with the server path stripped out. Reported-by: Tolriq (Symfonium) * refactor(ffmpeg): use errors.AsType for ExitError match probeErrorReason used the older var+errors.As form while the rest of the codebase (and its sibling transcodeFailureReason) uses the generic errors.AsType. Switch to it for consistency; behavior is unchanged. * fix(subsonic): return error 70 when the source file is missing A getTranscodeDecision probe failure was always reported as generic error 0. When the source file is gone (moved or deleted out from under the DB), that is a not-found condition, so return the standard Subsonic error 70 ("data not found") instead — matching what the endpoint already returns for an unknown mediaId. Files that exist but are corrupt or unreadable stay error 0. ProbeError now wraps the underlying cause and implements Unwrap, so the handler detects the case with errors.Is(err, fs.ErrNotExist). * fix(ffmpeg): keep probe error paths out of client-facing reasons Addresses review feedback on the ProbeError type: the Reason field doubled as both the log detail and the client message, so an ffprobe launch failure (a *os.PathError from fork/exec) could leak the ffprobe binary path to clients, and an unexpected stat error was reduced to "file not accessible" in the log. Split the two concerns: Reason now holds only a path-free, client-safe string (built at construction), while Error() logs the full underlying cause. Launch failures return a generic "could not read file" instead of the raw exec error. SafeReason no longer does substring path-stripping (removing the empty-Path edge case); the stripping happens once, against ffprobe's stderr. * fix(subsonic): don't report a broken ffprobe as a missing media file Two issues from review of the previous commit: Code 70 was selected with errors.Is(err, fs.ErrNotExist), but a launch failure of a deleted ffprobe binary is an *os.PathError that also wraps fs.ErrNotExist. A server-side ffprobe problem was therefore reported to clients as a missing media file. ProbeError now carries an explicit NotFound flag, set only on the file-access branch, and the handler keys the code off that instead of the chain. ffprobe can also exit 0 while yielding no audio stream (an audio-suffixed container holding only video). That parse failure was returned unwrapped, so clients got "internal error"; it is now wrapped in a ProbeError too. --- core/ffmpeg/ffmpeg.go | 73 +++++++++++++++++++++++++++++-- core/ffmpeg/ffmpeg_test.go | 70 +++++++++++++++++++++++++++++ server/subsonic/transcode.go | 17 ++++++- server/subsonic/transcode_test.go | 49 +++++++++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3d4cd0e72..af2dab647 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" - probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" + probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} @@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP return nil, err } if err := fileExists(filePath); err != nil { - return nil, err + return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err), + NotFound: errors.Is(err, fs.ErrNotExist), err: err} } args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) log.Trace(ctx, "Executing ffprobe command", "args", args) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) + return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err} } - return parseProbeOutput(output) + result, err := parseProbeOutput(output) + if err != nil { + return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err} + } + return result, nil +} + +// ProbeError reports an ffprobe failure. Reason is a path-free message safe to +// expose to clients; the wrapped cause carries the full detail for logging. +// NotFound marks the media file itself as missing — a launch failure of a +// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer +// it from the error chain. +type ProbeError struct { + Path string + Reason string + NotFound bool + err error +} + +func (e *ProbeError) Error() string { + if e.err == nil { + return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason) + } + return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err)) +} + +// Unwrap exposes the underlying cause so callers can test it with errors.Is +// (e.g. fs.ErrNotExist to detect a missing file). +func (e *ProbeError) Unwrap() error { return e.err } + +// SafeReason returns the path-free reason, safe to send to clients. +func (e *ProbeError) SafeReason() string { return e.Reason } + +// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved +// or unreadable file reads as "file not found" rather than a raw ffprobe message. +func fileAccessReason(err error) string { + switch { + case errors.Is(err, fs.ErrNotExist): + return "file not found" + case errors.Is(err, fs.ErrPermission): + return "permission denied" + default: + return "file not accessible" + } +} + +// probeDetail returns the full diagnostic for logging (may contain paths): +// ffprobe's stderr when present, otherwise the raw error text. +func probeDetail(err error) string { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 { + return strings.TrimSpace(string(exitErr.Stderr)) + } + return err.Error() +} + +// probeClientReason returns a path-free reason for an ffprobe execution failure: +// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe +// couldn't run at all (its launch error may embed the binary path). +func probeClientReason(err error, path string) string { + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok || len(exitErr.Stderr) == 0 { + return "could not read file" + } + return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file")) } type probeOutput struct { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 9c20e6c05..0fa3de111 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,6 +2,7 @@ package ffmpeg import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("ProbeError", func() { + It("uses the underlying cause in Error() so logs keep the full detail", func() { + e := &ProbeError{Path: "/music/foo.flac", + err: errors.New("/music/foo.flac: Invalid data found when processing input")} + Expect(e.Error()).To(ContainSubstring("/music/foo.flac")) + Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input")) + }) + + It("returns the path-free reason from SafeReason()", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"} + Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input")) + Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac")) + }) + + It("unwraps to the underlying cause so errors.Is detects a missing file", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist} + Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue()) + }) + }) + + Describe("probeClientReason", func() { + It("strips the file path from ffprobe stderr", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found")) + }) + + It("returns a generic reason for launch failures, without leaking the binary path", func() { + err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory") + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file")) + }) + }) + + Describe("probeDetail", func() { + It("surfaces ffprobe stderr for logging", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeDetail(err)).To(Equal("boom detail")) + }) + }) + + Describe("fileAccessReason", func() { + It("reports a missing file as 'file not found', not a raw stat message", func() { + _, err := os.Stat("/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(fileAccessReason(err)).To(Equal("file not found")) + }) + + It("falls back to a generic reason for other access errors", func() { + Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible")) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() { } }) + It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() { + _, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) + var pe *ProbeError + Expect(errors.As(err, &pe)).To(BeTrue()) + Expect(pe.SafeReason()).To(Equal("file not found")) + Expect(pe.NotFound).To(BeTrue()) + }) + It("should interrupt transcoding when context is cancelled", func() { ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) defer cancel() diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 511db2b85..9eb2af160 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,6 +8,7 @@ import ( "slices" "strconv" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -315,7 +316,8 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) - return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") + code, reason := transcodeFailure(err) + return nil, newError(code, "failed to make transcode decision: %s", reason) } // Only create a token when there is a valid playback path @@ -346,6 +348,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return response, nil } +// transcodeFailure maps a decision error to a Subsonic error code and a reason +// safe to send to clients, omitting server file paths. +func transcodeFailure(err error) (int32, string) { + pe, ok := errors.AsType[*ffmpeg.ProbeError](err) + if !ok { + return responses.ErrorGeneric, "internal error" + } + if pe.NotFound { + return responses.ErrorDataNotFound, pe.SafeReason() + } + return responses.ErrorGeneric, pe.SafeReason() +} + // GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. // It streams media using the decision encoded in the transcodeParams JWT token. // All errors are returned as proper HTTP status codes (not Subsonic error responses). diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 7e36ab243..8d5cbb974 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -4,12 +4,16 @@ import ( "bytes" "context" "errors" + "fmt" + "io/fs" "net/http" "net/http/httptest" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -77,6 +81,47 @@ var _ = Describe("Transcode endpoints", func() { Expect(err.Error()).To(ContainSubstring("error retrieving media file")) }) + It("enriches the decision error with the reason, without leaking the file path", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to make transcode decision")) + Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input")) + Expect(err.Error()).ToNot(ContainSubstring("/music/secret")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns ErrorDataNotFound when the source file is missing on disk", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("file not found")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) + }) + + It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"} + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist) + Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue()) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + It("returns error when body is empty", func() { r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") _, err := router.GetTranscodeDecision(w, r) @@ -516,6 +561,7 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { decision *stream.TranscodeDecision + decisionErr error token string tokenErr error resolvedReq stream.Request @@ -525,6 +571,9 @@ type mockTranscodeDecision struct { func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { m.capturedClient = ci + if m.decisionErr != nil { + return nil, m.decisionErr + } if m.decision != nil { return m.decision, nil } From bf79d2f3a2a8c569d4d533a596f4575d39d5e1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Jul 2026 18:52:30 -0400 Subject: [PATCH 391/596] refactor(plugins): remove non-functional experimental manifest option (#5821) The `experimental.threads` manifest option never actually worked, so drop it from the schema, the generated types, the loader and the docs. --- plugins/README.md | 20 +---------- plugins/manager_loader.go | 8 ----- plugins/manifest-schema.json | 24 ------------- plugins/manifest.go | 5 --- plugins/manifest_gen.go | 15 -------- plugins/manifest_test.go | 70 ------------------------------------ 6 files changed, 1 insertion(+), 141 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index b9118d36f..08ab967f3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` -**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` +**Optional fields:** `description`, `website`, `config`, `permissions` #### Config Definition @@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema } ``` -#### Experimental Features - -Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: - -- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading) - -```json -{ - "experimental": { - "threads": { - "reason": "Required for concurrent audio processing" - } - } -} -``` - -> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary. - --- ## Capabilities diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 675c85e26..0ba5fcf76 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -13,8 +13,6 @@ import ( "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/scheduler" "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/experimental" "golang.org/x/sync/errgroup" ) @@ -377,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { WithCompilationCache(m.cache). WithCloseOnContextDone(true) - // Enable experimental threads if requested in manifest - if pkg.Manifest.HasExperimentalThreads() { - runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) - log.Debug(ctx, "Enabling experimental threads support") - } - extismConfig := extism.PluginConfig{ EnableWasi: true, RuntimeConfig: runtimeConfig, diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 29e5d1fc7..28adeed79 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -34,9 +34,6 @@ "permissions": { "$ref": "#/$defs/Permissions" }, - "experimental": { - "$ref": "#/$defs/Experimental" - }, "config": { "$ref": "#/$defs/ConfigDefinition" } @@ -58,27 +55,6 @@ } } }, - "Experimental": { - "type": "object", - "description": "Experimental features that may change or be removed in future versions", - "additionalProperties": false, - "properties": { - "threads": { - "$ref": "#/$defs/ThreadsFeature" - } - } - }, - "ThreadsFeature": { - "type": "object", - "description": "Enable experimental WebAssembly threads support", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why threads support is needed" - } - } - }, "Permissions": { "type": "object", "description": "Permissions required by the plugin", diff --git a/plugins/manifest.go b/plugins/manifest.go index 6bd0e8049..5e144b5c8 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -117,11 +117,6 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return nil } -// HasExperimentalThreads returns true if the manifest requests experimental threads support. -func (m *Manifest) HasExperimentalThreads() bool { - return m.Experimental != nil && m.Experimental.Threads != nil -} - // HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. func (m *Manifest) HasLibraryFilesystemPermission() bool { return m.Permissions != nil && diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 3599eafc4..c2aa5e298 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } -// Experimental features that may change or be removed in future versions -type Experimental struct { - // Threads corresponds to the JSON schema field "threads". - Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"` -} - // HTTP access permissions for a plugin type HTTPPermission struct { // Explanation for why HTTP access is needed @@ -109,9 +103,6 @@ type Manifest struct { // A brief description of what the plugin does Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` - // Experimental corresponds to the JSON schema field "experimental". - Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"` - // The display name of the plugin Name string `json:"name" yaml:"name" mapstructure:"name"` @@ -242,12 +233,6 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { return nil } -// Enable experimental WebAssembly threads support -type ThreadsFeature struct { - // Explanation for why threads support is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - // Users service permissions for accessing user information type UsersPermission struct { // Explanation for why users access is needed diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 2a8b0dcfa..32bcbba08 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -117,76 +117,6 @@ var _ = Describe("Manifest", func() { }) }) - Describe("HasExperimentalThreads", func() { - It("returns false when no experimental section", func() { - m := &Manifest{} - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns false when experimental section has no threads", func() { - m := &Manifest{ - Experimental: &Experimental{}, - } - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns true when threads feature is present", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{}, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("returns true when threads feature has a reason", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{ - Reason: new("Required for concurrent processing"), - }, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("parses experimental.threads from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": { - "reason": "To use multi-threaded WASM module" - } - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - Expect(m.Experimental.Threads.Reason).ToNot(BeNil()) - Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module")) - }) - - It("parses experimental.threads without reason from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": {} - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - }) - Describe("ParseManifest", func() { It("parses a valid manifest with users permission", func() { data := []byte(`{ From 6c2644d20860fa052fed1e150b2390727e37fa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 19 Jul 2026 18:59:37 -0400 Subject: [PATCH 392/596] feat(ui): remember 'items per page' selection across sessions (#5819) * feat(ui): add perPageStore helper for items-per-page persistence * feat(ui): persist items-per-page selection in localStorage * feat(ui): enable per-page persistence on album grid, missing files and playlist tracks * feat(ui): restore saved album grid page size on fresh load * feat(ui): restore saved items-per-page on list load * fix(ui): move defaultRowsPerPageOptions to perPageStore to satisfy react-refresh lint * fix(ui): correct defaultRowsPerPageOptions import in List and add render test * refactor(ui): default getStoredPerPage fallback to the first option The fallback equalled options[0] at three of four call sites; default it so those sites stop restating it. SongList and useAlbumsPerPage still pass an explicit fallback where it legitimately differs from the first option. * fix(ui): persist only user-selected page sizes, validate album size against width Persisting on every pagination context value let a URL-injected perPage (e.g. the perPage=15 album link in NowPlayingPanel) or a forced single-option mobile grid overwrite the saved preference. Persist only a value that is an actual option in a multi-option selector. Also validate the album grid's redux session value against the current width so a size chosen at a wider breakpoint can't leave an out-of-range selector. * fix(ui): restore saved items-per-page on the radio list RadioList passed a hard-coded perPage that overrode List's stored seed via the props spread, so a radio-list page size was persisted but never restored. Seed it from storage like the other list views. * fix(ui): persist page size only on an actual selector change Watching the pagination context value meant any valid value persisted itself: loading a list at a breakpoint where the saved size is invalid stored the responsive fallback, and opening a URL with a valid ?perPage= stored that too, either way discarding the user's real preference. Inject a wrapped setPerPage instead, so only the rows-per-page selector writes. This also drops the option-validation heuristics, which the new trigger makes unnecessary. --- ui/src/album/AlbumList.jsx | 2 +- ui/src/artist/ArtistShow.jsx | 1 + ui/src/common/List.jsx | 3 +- ui/src/common/List.test.jsx | 27 +++++++++++ ui/src/common/Pagination.jsx | 33 +++++++++++-- ui/src/common/Pagination.test.jsx | 62 +++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/common/perPageStore.js | 11 +++++ ui/src/common/perPageStore.test.js | 40 ++++++++++++++++ ui/src/common/useAlbumsPerPage.jsx | 16 +++++-- ui/src/common/useAlbumsPerPage.test.jsx | 61 ++++++++++++++++++++++++ ui/src/missing/MissingFilesList.jsx | 15 ++++-- ui/src/playlist/PlaylistShow.jsx | 20 ++++++-- ui/src/radio/RadioList.jsx | 8 +++- ui/src/song/SongList.jsx | 8 +++- 15 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 ui/src/common/List.test.jsx create mode 100644 ui/src/common/Pagination.test.jsx create mode 100644 ui/src/common/perPageStore.js create mode 100644 ui/src/common/perPageStore.test.js create mode 100644 ui/src/common/useAlbumsPerPage.test.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index a860c85bb..5108bfaa1 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -6,7 +6,6 @@ import { Filter, NullableBooleanInput, NumberInput, - Pagination, ReferenceArrayInput, ReferenceInput, SearchInput, @@ -20,6 +19,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, + Pagination, Title, useAlbumsPerPage, useResourceRefresh, diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 935b0bab7..955a565d6 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -100,6 +100,7 @@ const ArtistShowLayout = (props) => { const rowsPerPageOptions = [1, 2, 3].map((option) => Math.trunc(option * (perPage / 3)), ) + // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key pagination = <Pagination rowsPerPageOptions={rowsPerPageOptions} /> } diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index 72c2d9482..0ae089460 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -2,6 +2,7 @@ import React from 'react' import { List as RAList } from 'react-admin' import config from '../config' import { Pagination } from './Pagination' +import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore' import { Title } from './index' export const List = (props) => { @@ -15,7 +16,7 @@ export const List = (props) => { /> } debounce={config.uiSearchDebounceMs} - perPage={15} + perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)} pagination={<Pagination />} {...props} /> diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx new file mode 100644 index 000000000..5bc4b910f --- /dev/null +++ b/ui/src/common/List.test.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { List } from './List' + +// Only stub the heavy react-admin List controller (data fetching, router sync); +// everything else, including our own Pagination/perPageStore wiring, stays real +// so a bad import (the bug this test guards against) throws on render. +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + List: ({ children }) => <div data-testid="ra-list">{children}</div>, + } +}) + +describe('List', () => { + it('renders without throwing and shows its children', () => { + render( + <List resource="song"> + <div>list content</div> + </List>, + ) + expect(screen.getByTestId('ra-list')).toBeInTheDocument() + expect(screen.getByText('list content')).toBeInTheDocument() + }) +}) diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx index e17d9e63e..dd18961ce 100644 --- a/ui/src/common/Pagination.jsx +++ b/ui/src/common/Pagination.jsx @@ -1,6 +1,29 @@ -import React from 'react' -import { Pagination as RAPagination } from 'react-admin' +import React, { useCallback } from 'react' +import { + Pagination as RAPagination, + useListPaginationContext, +} from 'react-admin' +import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore' -export const Pagination = (props) => ( - <RAPagination rowsPerPageOptions={[15, 25, 50]} {...props} /> -) +export const Pagination = ({ + rowsPerPageOptions = defaultRowsPerPageOptions, + ...props +}) => { + const { resource, setPerPage } = useListPaginationContext() + // Persist only a selector-driven change: mount, URL params and responsive + // fallbacks never call setPerPage, so they can't overwrite the preference. + const handleSetPerPage = useCallback( + (value) => { + if (resource) setStoredPerPage(resource, value) + setPerPage(value) + }, + [resource, setPerPage], + ) + return ( + <RAPagination + rowsPerPageOptions={rowsPerPageOptions} + {...props} + setPerPage={handleSetPerPage} + /> + ) +} diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx new file mode 100644 index 000000000..a488aba91 --- /dev/null +++ b/ui/src/common/Pagination.test.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Pagination } from './Pagination' + +// stub RA's Pagination so a test can invoke the injected setPerPage, i.e. +// simulate an actual rows-per-page selection +vi.mock('react-admin', async () => { + const React = await vi.importActual('react') + return { + Pagination: ({ setPerPage }) => + React.createElement( + 'button', + { onClick: () => setPerPage(50) }, + 'select 50', + ), + useListPaginationContext: vi.fn(), + } +}) + +describe('Pagination', () => { + let mockContext + let setPerPage + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + setPerPage = vi.fn() + const { useListPaginationContext } = await import('react-admin') + mockContext = vi.mocked(useListPaginationContext) + }) + + const selectPerPage = () => fireEvent.click(screen.getByText('select 50')) + + it('persists the page size chosen in the selector', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render(<Pagination />) + selectPerPage() + expect(localStorage.getItem('perPage.song')).toEqual('50') + }) + + it('still applies the change to the list', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render(<Pagination />) + selectPerPage() + expect(setPerPage).toHaveBeenCalledWith(50) + }) + + it('does not persist a page size the user did not select', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render(<Pagination />) + expect(localStorage.getItem('perPage.song')).toBeNull() + }) + + it('does not persist without a resource in context', () => { + mockContext.mockReturnValue({ perPage: 15, setPerPage }) + render(<Pagination />) + selectPerPage() + expect(localStorage.getItem('perPage.undefined')).toBeNull() + expect(setPerPage).toHaveBeenCalledWith(50) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..ac8d7f62c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -10,6 +10,7 @@ export * from './DurationField' export * from './List' export * from './MultiLineTextField' export * from './Pagination' +export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js new file mode 100644 index 000000000..52a8a8f29 --- /dev/null +++ b/ui/src/common/perPageStore.js @@ -0,0 +1,11 @@ +export const defaultRowsPerPageOptions = [15, 25, 50] + +const key = (resource) => `perPage.${resource}` + +export const getStoredPerPage = (resource, options, fallback = options[0]) => { + const stored = parseInt(localStorage.getItem(key(resource)), 10) + return options.includes(stored) ? stored : fallback +} + +export const setStoredPerPage = (resource, perPage) => + localStorage.setItem(key(resource), String(perPage)) diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js new file mode 100644 index 000000000..3aeba3ad8 --- /dev/null +++ b/ui/src/common/perPageStore.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getStoredPerPage, setStoredPerPage } from './perPageStore' + +const options = [15, 25, 50] + +describe('perPageStore', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('round-trips a stored value', () => { + setStoredPerPage('song', 25) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + }) + + it('keys values per resource', () => { + setStoredPerPage('song', 25) + setStoredPerPage('playlist', 50) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + expect(getStoredPerPage('playlist', options, 15)).toEqual(50) + }) + + it('returns the fallback when nothing is stored', () => { + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback for garbage values', () => { + localStorage.setItem('perPage.song', 'bogus') + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback when the stored value is not a valid option', () => { + setStoredPerPage('album', 90) + expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18) + }) + + it('defaults the fallback to the first option', () => { + expect(getStoredPerPage('song', options)).toEqual(15) + }) +}) diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx index 6a02bdeb7..0fb5616c3 100644 --- a/ui/src/common/useAlbumsPerPage.jsx +++ b/ui/src/common/useAlbumsPerPage.jsx @@ -1,4 +1,5 @@ import { useSelector } from 'react-redux' +import { getStoredPerPage } from './perPageStore' const getPerPage = (width) => { if (width === 'xs') return 12 @@ -17,10 +18,15 @@ const getPerPageOptions = (width) => { } export const useAlbumsPerPage = (width) => { - const perPage = - useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage, - ) || getPerPage(width) + const options = getPerPageOptions(width) + const sessionPerPage = useSelector( + (state) => state?.admin.resources?.album?.list?.params?.perPage, + ) + // Use the session value only when it's valid for the current width, so a + // size picked at a wider breakpoint can't leave an out-of-range selector. + const perPage = options.includes(sessionPerPage) + ? sessionPerPage + : getStoredPerPage('album', options, getPerPage(width)) - return [perPage, getPerPageOptions(width)] + return [perPage, options] } diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx new file mode 100644 index 000000000..b194a6ef2 --- /dev/null +++ b/ui/src/common/useAlbumsPerPage.test.jsx @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAlbumsPerPage } from './useAlbumsPerPage' +import { setStoredPerPage } from './perPageStore' + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})) + +describe('useAlbumsPerPage', () => { + let mockUseSelector + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + const { useSelector } = await import('react-redux') + mockUseSelector = vi.mocked(useSelector) + }) + + const setReduxPerPage = (value) => + mockUseSelector.mockImplementation((selector) => + selector({ + admin: { + resources: { album: { list: { params: { perPage: value } } } }, + }, + }), + ) + + it('prefers the redux session value over the stored one', () => { + setReduxPerPage(36) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(36) + }) + + it('falls back to the stored value on fresh load', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(72) + }) + + it('ignores stored values invalid for the current width', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) + + it('returns the responsive default when nothing is stored', () => { + setReduxPerPage(undefined) + const { result } = renderHook(() => useAlbumsPerPage('xl')) + expect(result.current).toEqual([36, [18, 36, 72]]) + }) + + it('ignores a redux value invalid for the current width', () => { + setReduxPerPage(72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 87d9f629f..a09f552d9 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,10 +1,15 @@ -import { List, SizeField, useResourceRefresh } from '../common/index' +import { + List, + Pagination, + SizeField, + getStoredPerPage, + useResourceRefresh, +} from '../common/index' import { Datagrid, DateField, TextField, downloadCSV, - Pagination, Filter, ReferenceInput, useTranslate, @@ -49,8 +54,10 @@ const BulkActionButtons = (props) => ( </> ) +const missingPerPageOptions = [50, 100, 200] + const MissingPagination = (props) => ( - <Pagination rowsPerPageOptions={[50, 100, 200]} {...props} /> + <Pagination rowsPerPageOptions={missingPerPageOptions} {...props} /> ) const MissingFilesList = (props) => { @@ -63,7 +70,7 @@ const MissingFilesList = (props) => { actions={<MissingListActions />} filters={<MissingFilesFilter />} bulkActionButtons={<BulkActionButtons />} - perPage={50} + perPage={getStoredPerPage('missing', missingPerPageOptions)} pagination={<MissingPagination />} > <Datagrid> diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index f0cb472b1..4e269be18 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -4,14 +4,21 @@ import { ShowContextProvider, useShowContext, useShowController, - Pagination, Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { Title, canChangeTracks, useResourceRefresh } from '../common' +import { + Pagination, + Title, + canChangeTracks, + getStoredPerPage, + useResourceRefresh, +} from '../common' + +const playlistTrackPerPageOptions = [100, 250, 500] const useStyles = makeStyles( (theme) => ({ @@ -41,7 +48,10 @@ const PlaylistShowLayout = (props) => { reference="playlistTrack" target="playlist_id" sort={{ field: 'id', order: 'ASC' }} - perPage={100} + perPage={getStoredPerPage( + 'playlistTrack', + playlistTrackPerPageOptions, + )} filter={{ playlist_id: props.id }} > <PlaylistSongs @@ -56,7 +66,9 @@ const PlaylistShowLayout = (props) => { } resource={'playlistTrack'} exporter={false} - pagination={<Pagination rowsPerPageOptions={[100, 250, 500]} />} + pagination={ + <Pagination rowsPerPageOptions={playlistTrackPerPageOptions} /> + } /> </ReferenceManyField> )} diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 945bac519..ccdb9f1ef 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -16,6 +16,8 @@ import { } from 'react-admin' import { List, + defaultRowsPerPageOptions, + getStoredPerPage, useImageUrl, ToggleFieldsMenu, useSelectedFields, @@ -135,7 +137,11 @@ const RadioList = ({ permissions, ...props }) => { hasCreate={isAdmin} actions={<RadioListActions isAdmin={isAdmin} />} filters={<RadioFilter />} - perPage={isXsmall ? 25 : 10} + perPage={getStoredPerPage( + 'radio', + defaultRowsPerPageOptions, + isXsmall ? 25 : 10, + )} > {isXsmall ? ( <SimpleList diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index d44992d0c..6b7bfaf96 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -26,6 +26,8 @@ import { useResourceRefresh, ArtistLinkField, PathField, + defaultRowsPerPageOptions, + getStoredPerPage, } from '../common' import { useDispatch } from 'react-redux' import { makeStyles } from '@material-ui/core/styles' @@ -215,7 +217,11 @@ const SongList = (props) => { bulkActionButtons={<SongBulkActions />} actions={<SongListActions />} filters={<SongFilter />} - perPage={isXsmall ? 50 : 15} + perPage={getStoredPerPage( + 'song', + defaultRowsPerPageOptions, + isXsmall ? 50 : 15, + )} > {isXsmall ? ( <SongSimpleList /> From c35b14dd74d35660a4781d3bd72f7f9c8efa8bd9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 20 Jul 2026 09:49:48 -0400 Subject: [PATCH 393/596] chore(deps): update go-taglib to v2.3.1 Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 014a43a56..5488b41e4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index 064974edb..29983a27d 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= 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= From 62257527d7f24fdf90b717f12827e708f6223033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 20 Jul 2026 21:49:14 -0400 Subject: [PATCH 394/596] fix(subsonic): avoid double brackets when appending subtitle or version (#5832) When AppendSubtitle or AppendAlbumVersion is enabled, the subtitle/version tag was always wrapped in parentheses and appended to the title/album name. If the tag value already came wrapped in brackets (e.g. "(non-explicit version)"), the result was doubled: "Title ((non-explicit version))". Append the tag as-is when it is already wrapped in a matching bracket pair - (), [], {} or <> - and trim surrounding whitespace first. The shared appendSuffix helper is used by MediaFile.FullTitle, MediaFile.FullAlbumName and Album.FullName so all consumers behave consistently. --- model/album.go | 3 +-- model/album_test.go | 2 ++ model/mediafile.go | 14 ++++++++++++-- model/mediafile_test.go | 9 +++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/model/album.go b/model/album.go index 0afeb2760..55df8c63c 100644 --- a/model/album.go +++ b/model/album.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "iter" "math" "sync" @@ -77,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID { func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) + return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0]) } return a.Name } diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..ad2ca1cb6 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -24,6 +24,8 @@ var _ = Describe("Album", func() { Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), ) }) diff --git a/model/mediafile.go b/model/mediafile.go index 910cd998d..22ab7fbbe 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -100,18 +100,28 @@ type MediaFile struct { func (mf MediaFile) FullTitle() string { if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) + return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } func (mf MediaFile) FullAlbumName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) + return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0]) } return mf.Album } +var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} + +func appendSuffix(base, suffix string) string { + suffix = strings.TrimSpace(suffix) + if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { + return base + " " + suffix + } + return base + " (" + suffix + ")" +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 6085ccf25..3f306f1a7 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -533,6 +533,13 @@ var _ = Describe("MediaFile", func() { Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), Entry("returns just title when tag is absent", true, Tags{}, "Song"), Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), + Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), + Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), + Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), + Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{"<Live>"}}, "Song <Live>"), + Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), + Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), + Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), ) DescribeTable("FullAlbumName", func(enabled bool, tags Tags, expected string) { @@ -544,6 +551,8 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), ) Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { From 93f6afb684b44b29ebdbd35eb18d3f063d14caa6 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 22 Jul 2026 12:19:50 -0400 Subject: [PATCH 395/596] chore: add OpenCollective funding option Signed-off-by: Deluan <deluan@navidrome.org> --- .github/FUNDING.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 534cfbd11..e5ed36e70 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,10 +1,10 @@ # These are supported funding model platforms -github: deluan -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username ko_fi: deluan +github: deluan +open_collective: navidrome liberapay: deluan +patreon: # Replace with a single Patreon username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry issuehunt: # Replace with a single IssueHunt username From ecf606e52320b87d099991f4f3da87913523c303 Mon Sep 17 00:00:00 2001 From: Marco Ciotola <McCio@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:23:32 +0200 Subject: [PATCH 396/596] fix(ui): update Italian translation (#5848) --- resources/i18n/it.json | 108 +++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/resources/i18n/it.json b/resources/i18n/it.json index b91c04064..656043589 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -17,7 +17,7 @@ "genre": "Genere", "compilation": "Compilation", "year": "Anno", - "size": "Dimensioni", + "size": "Dimensione file", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", "bitDepth": "Profondità di bit", @@ -98,9 +98,9 @@ "lists": { "all": "Tutti", "random": "Casuali", - "recentlyAdded": "Aggiunti di Recente", - "recentlyPlayed": "Riprodotti di Recente", - "mostPlayed": "I Più Riprodotti", + "recentlyAdded": "Aggiunti di recente", + "recentlyPlayed": "Riprodotti di recente", + "mostPlayed": "I più riprodotti", "starred": "Preferiti", "topRated": "Più votati" } @@ -121,17 +121,17 @@ "roles": { "albumartist": "Artista Album |||| Artisti Album", "artist": "Artista |||| Artisti", - "composer": "Compositore |||| Compositori", - "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", - "lyricist": "Paroliere |||| Parolieri", - "arranger": "Arrangiatore |||| Arrangiatori", - "producer": "Produttore |||| Produttori", - "director": "Direttore |||| Direttori", - "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "composer": "Composizione |||| Composizione", + "conductor": "Direzione d'orchestra |||| Direzione d'orchestra", + "lyricist": "Testi |||| Testi", + "arranger": "Arrangiamento |||| Arrangiamento", + "producer": "Produzione |||| Produzione", + "director": "Direzione |||| Direzione", + "engineer": "Ingegneria del suono |||| Ingegneria del suono", "mixer": "Mixer |||| Mixer", "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "Esecutore |||| Esecutori", + "performer": "Esecuzione |||| Esecuzione", "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" }, "actions": { @@ -144,7 +144,7 @@ "name": "Utente |||| Utenti", "fields": { "userName": "Nome utente", - "isAdmin": "Amministratore", + "isAdmin": "Admin", "lastLoginAt": "Ultimo login", "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", @@ -152,13 +152,13 @@ "password": "Password", "createdAt": "Creato il", "changePassword": "Cambiare la password?", - "currentPassword": "Password Attuale", - "newPassword": "Nuova Password", + "currentPassword": "Password attuale", + "newPassword": "Nuova password", "token": "Token", "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "name": "Le modifiche al tuo nome verranno mostrate solo al prossimo accesso", "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { @@ -167,13 +167,13 @@ "deleted": "Utente eliminato" }, "validation": { - "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non admin" }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", "clickHereForToken": "Clicca qui per ottenere il tuo token", "selectAllLibraries": "Seleziona tutte le librerie", - "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" + "adminAutoLibraries": "Gli utenti admin hanno automaticamente accesso a tutte le librerie" } }, "player": { @@ -203,28 +203,29 @@ "fields": { "name": "Nome", "duration": "Durata", - "ownerName": "Creatore", + "ownerName": "Di", "public": "Pubblica", "updatedAt": "Ultimo aggiornamento", "createdAt": "Data creazione", "songCount": "Tracce", "comment": "Commento", "sync": "Importazione automatica", - "path": "Importa da" + "path": "Importa da", + "starred": "Preferita" }, "actions": { "selectPlaylist": "Seleziona una playlist:", "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", "saveQueue": "Salva la coda nella playlist", - "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata", + "makePublic": "Rendi pubblica", + "makePrivate": "Rendi privata", "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", "pressEnterToCreate": "Premi Invio per creare una nuova playlist", "removeFromSelection": "Rimuovi dalla selezione" }, "message": { - "duplicate_song": "Aggiungere i duplicati", + "duplicate_song": "Aggiungi tracce duplicate", "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", "noPlaylistsFound": "Nessuna playlist trovata", "noPlaylists": "Nessuna playlist disponibile" @@ -331,7 +332,7 @@ "pathInvalid": "Percorso della libreria non valido" }, "messages": { - "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "deleteConfirm": "Vuoi eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", "scanInProgress": "Scansione in corso...", "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" } @@ -367,7 +368,7 @@ "configuration": "Configurazione", "manifest": "Manifest", "usersPermission": "Permessi utenti", - "libraryPermission": "Permesso libreria" + "libraryPermission": "Permessi librerie" }, "status": { "enabled": "Abilitato", @@ -400,10 +401,10 @@ "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", "noUsers": "Nessun utente selezionato", "permissionReason": "Motivo", - "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona a quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", "noLibraries": "Nessuna libreria selezionata", - "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona a quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", "requiredHosts": "Host richiesti" }, @@ -416,9 +417,9 @@ "ra": { "auth": { "welcome1": "Grazie per aver installato Navidrome!", - "welcome2": "Per iniziare, crea un amministratore", + "welcome2": "Per iniziare, crea un account amministratore", "confirmPassword": "Conferma la password", - "buttonCreateAdmin": "Crea amministratore", + "buttonCreateAdmin": "Crea admin", "auth_check_error": "Per favore accedi per continuare", "user_menu": "Profilo", "username": "Nome utente", @@ -488,8 +489,8 @@ "loading": "Caricamento in corso", "not_found": "Non trovato", "show": "%{name} #%{id}", - "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi aggiungerne uno?" + "empty": "Ancora niente %{name}.", + "invite": "Vuoi aggiungerne?" }, "input": { "file": { @@ -512,10 +513,10 @@ }, "message": { "about": "Informazioni", - "are_you_sure": "Sei sicuro?", - "bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?", + "are_you_sure": "Vuoi procedere?", + "bulk_delete_content": "Vuoi rimuovere questo %{name}? |||| Vuoi rimuovere questi %{smart_count} elementi?", "bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}", - "delete_content": "Sei sicuro di voler eliminare questo elemento?", + "delete_content": "Vuoi eliminare questo elemento?", "delete_title": "Rimuovi %{name} #%{id}", "details": "Dettagli", "error": "Un errore dal lato client ha impedito il completamento della tua richiesta.", @@ -524,7 +525,7 @@ "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", "yes": "Sì", - "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" + "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ignorarle?" }, "navigation": { "no_results": "Nessun risultato trovato", @@ -574,11 +575,11 @@ "noTopSongsFound": "Nessun brano più ascoltato trovato", "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "delete_user_content": "Vuoi rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", "remove_missing_title": "Rimuovi i file mancanti", - "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_missing_content": "Vuoi rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "remove_all_missing_title": "Rimuovi tutti i file mancanti", - "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_content": "Vuoi rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", @@ -619,7 +620,7 @@ "options": { "theme": "Tema", "language": "Lingua", - "defaultView": "Vista Predefinita", + "defaultView": "Vista predefinita", "desktop_notifications": "Notifiche desktop", "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", @@ -635,21 +636,22 @@ }, "albumList": "Album", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise", - "about": "Info" + "sharedPlaylists": "Playlist condivise", + "about": "Info", + "onlyFavourites": "Mostra solo i preferiti" }, "player": { "playListsText": "Coda", "openText": "Apri", "closeText": "Chiudi", - "notContentText": "Nessuna traccia", + "notContentText": "Niente musica", "clickToPlayText": "Clicca per riprodurre", "clickToPauseText": "Clicca per mettere in pausa", "nextTrackText": "Traccia successiva", "previousTrackText": "Traccia precedente", "reloadText": "Ricarica", "volumeText": "Volume", - "toggleLyricText": "Mostra testo", + "toggleLyricText": "Mostra/nascondi testo", "toggleMiniModeText": "Minimizza", "destroyText": "Distruggi", "downloadText": "Scarica", @@ -659,7 +661,7 @@ "playModeText": { "order": "In ordine", "orderLoop": "Ripeti", - "singleLoop": "Ripeti una volta", + "singleLoop": "Ripeti traccia", "shufflePlay": "Casuale" } }, @@ -667,7 +669,7 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste", + "featureRequests": "Proponi idee", "lastInsightsCollection": "Ultima raccolta dati", "insights": { "disabled": "Disabilitato", @@ -693,7 +695,7 @@ }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate totali", + "totalScanned": "Totale cartelle scansionate", "quickScan": "Rapida", "fullScan": "Completa", "selectiveScan": "Selettiva", @@ -709,17 +711,17 @@ "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera di Navidrome", + "title": "Scorciatoie da tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", - "prev_song": "Traccia Precedente", - "next_song": "Traccia Successiva", + "prev_song": "Traccia precedente", + "next_song": "Traccia successiva", "current_song": "Vai alla traccia corrente", - "vol_up": "Alza il Volume", - "vol_down": "Abbassa il Volume", + "vol_up": "Alza il volume", + "vol_down": "Abbassa il volume", "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} \ No newline at end of file +} From 23548f40a09f5f8bf9cf1e1bc2d4c2b236777560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 28 Jul 2026 16:13:44 -0400 Subject: [PATCH 397/596] fix(share): give visual feedback when downloading from a share (#5865) * fix(share): give visual feedback when downloading a share The share page handed the download URL to navidrome-music-player, which fell through to downloadjs and buffered the whole ZIP into memory via XHR before saving it. Nothing was handed to the browser until the last byte arrived, so a large share produced a long silent window with no player feedback and no browser download UI, inviting repeat clicks that each spawn another server-side zip+transcode. Use the player's customDownloader prop to trigger a synthetic anchor instead, so the browser performs the download and reports its own progress. An anchor rather than assigning window.location.href: the share page's service worker registers a NavigationRoute over all navigations, which intercepts the streamed archive and fails it into the offline fallback (observed as HTTP 503 in Chrome). handleDownloads now loads the share before streaming so it can set Content-Disposition and Content-Type. This also fixes error reporting: ZipShare previously wrote to the ResponseWriter before checkShareError ran, locking the status at 200, so expired, missing and non-downloadable shares all returned 200. They now correctly return 410, 404 and 403. * feat(share): acknowledge the download click in the player The browser's download UI is the real progress indicator, but nothing in the page itself reacted to the click, so the moment before the browser catches up still read as unresponsive. Dim the download button and make it unclickable for two seconds after a download starts, reusing the JSS function-value pattern the existing single-track styling already uses. A repeat download restarts the window instead of extending the original, and the timer is cleared on unmount. This also blunts repeat clicking, where every extra click costs another server-side zip and transcode. Add SharePlayer tests covering the download mechanism and this state machine. The dimming itself is verified in a browser rather than jsdom: JSS function values are not evaluated there, so the rule is never emitted and a CSS assertion would pass or fail for the wrong reason. Signed-off-by: Deluan <deluan@navidrome.org> * test(share): assert render counts in SharePlayer feedback tests The two acknowledgement tests compared the props object across renders, which React may reuse, so they passed without proving anything and then failed once the surrounding assertions changed. Count renders instead, and let the pending timer run out rather than advancing exactly to its deadline, which does not cross it. The repeat-download test now also asserts that no render happens at the original deadline, proving the timer was replaced rather than merely that one eventually fired. * fix(share): count one visit per share download The preflight share load added in this branch made every download record two visits: handleDownloads called Share.Load, and ZipShare then loaded the share again internally. Share.Load increments and persists VisitCount, so the counter advanced twice per download and the repository work was duplicated. Pass the already-loaded share into ZipShare instead of its id. handleDownloads is its only production caller, and it now has the share in hand for the Content-Disposition header anyway. The archiver test asserts Load is not called, so the double-load cannot come back unnoticed. Verified against a running server: the counter now advances by one per download. * test(share): derive feedback-window timings from the constant The acknowledgement tests hardcoded clock advances tuned to a 2000ms window. Raising DOWNLOAD_FEEDBACK_MS to 5000 left them advancing 1500ms and 1001ms, which no longer reach the deadline they are meant to cross, so the repeat- download test passed without proving the timer had been replaced. Export the constant and derive the advances from it, and let the pending timer run out in the unmount test rather than advancing a fixed amount. Changing the duration can no longer silently strand a test short of its deadline. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/archiver.go | 12 +-- core/archiver_test.go | 7 +- server/public/handle_downloads.go | 27 ++++- server/public/handle_downloads_test.go | 134 ++++++++++++++++++++++++ server/subsonic/e2e/e2e_suite_test.go | 2 +- ui/src/share/SharePlayer.jsx | 42 ++++++-- ui/src/share/SharePlayer.test.jsx | 139 +++++++++++++++++++++++++ 7 files changed, 344 insertions(+), 19 deletions(-) create mode 100644 server/public/handle_downloads_test.go create mode 100644 ui/src/share/SharePlayer.test.jsx diff --git a/core/archiver.go b/core/archiver.go index 5d1c090cd..8c42f8f49 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -21,7 +21,7 @@ import ( type Archiver interface { ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error - ZipShare(ctx context.Context, id string, w io.Writer) error + ZipShare(ctx context.Context, s *model.Share, w io.Writer) error ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } @@ -100,16 +100,14 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } -func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { - s, err := a.shares.Load(ctx, id) - if err != nil { - return err - } +// ZipShare takes an already-loaded share: Share.Load records a visit, so +// loading it again here would count every download twice. +func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error { if !s.Downloadable { return model.ErrNotAuthorized } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) + return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { diff --git a/core/archiver_test.go b/core/archiver_test.go index f432139d8..2ba8f1fc0 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -130,13 +130,16 @@ var _ = Describe("Archiver", func() { Tracks: mfs, } - sh.On("Load", mock.Anything, "1").Return(share, nil) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) - err := arch.ZipShare(context.Background(), "1", out) + err := arch.ZipShare(context.Background(), share, out) Expect(err).To(BeNil()) + // Share.Load records a visit; re-loading here would double-count + // every download. + sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything) + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) Expect(err).To(BeNil()) diff --git a/server/public/handle_downloads.go b/server/public/handle_downloads.go index 6aa35c341..0012c4b35 100644 --- a/server/public/handle_downloads.go +++ b/server/public/handle_downloads.go @@ -1,18 +1,41 @@ package public import ( + "cmp" + "fmt" "net/http" + "strings" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/str" ) func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() id, err := req.Params(r).String(":id") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - err = pub.archiver.ZipShare(r.Context(), id, w) - checkShareError(r.Context(), w, err, id) + // Load the share before streaming: once ZipShare writes its first byte the + // status is locked at 200, so errors could no longer be reported. + s, err := pub.share.Load(ctx, id) + if err != nil { + checkShareError(ctx, w, err, id) + return + } + if !s.Downloadable { + checkShareError(ctx, w, model.ErrNotAuthorized, id) + return + } + + name := str.SanitizeFilename(cmp.Or(s.Description, s.ID)) + name = strings.ReplaceAll(name, ",", "_") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".zip")) + w.Header().Set("Content-Type", "application/zip") + + err = pub.archiver.ZipShare(ctx, s, w) + checkShareError(ctx, w, err, id) } diff --git a/server/public/handle_downloads_test.go b/server/public/handle_downloads_test.go new file mode 100644 index 000000000..1a97f4379 --- /dev/null +++ b/server/public/handle_downloads_test.go @@ -0,0 +1,134 @@ +package public + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockArchiver struct { + called bool + err error +} + +func (m *mockArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { + return nil +} + +func (m *mockArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { + return nil +} + +func (m *mockArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { + return nil +} + +func (m *mockArchiver) ZipShare(_ context.Context, _ *model.Share, w io.Writer) error { + m.called = true + if m.err != nil { + return m.err + } + _, _ = w.Write([]byte("zip-contents")) + return nil +} + +var _ = Describe("handleDownloads", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var archiver *mockArchiver + var pub *Router + + BeforeEach(func() { + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + archiver = &mockArchiver{} + pub = &Router{ds: ds, archiver: archiver, share: core.NewShare(ds)} + }) + + shareIs := func(s *model.Share) { + shareRepo.ID = s.ID + shareRepo.Entity = s + } + + makeRequest := func(id string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/d/"+id+"?%3Aid="+id, nil) + w := httptest.NewRecorder() + pub.handleDownloads(w, r) + return w + } + + It("sets a Content-Disposition filename from the share description", func() { + shareIs(&model.Share{ID: "abc123", Description: "My Mixtape", Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="My Mixtape.zip"`)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/zip")) + Expect(archiver.called).To(BeTrue()) + Expect(w.Body.String()).To(Equal("zip-contents")) + }) + + It("falls back to the share ID when there is no description", func() { + shareIs(&model.Share{ID: "abc123", Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="abc123.zip"`)) + }) + + It("sanitizes characters that are unsafe in a filename", func() { + shareIs(&model.Share{ID: "abc123", Description: `AC/DC: Live, 1979`, Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`)) + }) + + It("returns 403 without invoking the archiver when the share is not downloadable", func() { + shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false}) + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(archiver.called).To(BeFalse()) + Expect(w.Header().Get("Content-Disposition")).To(BeEmpty()) + }) + + It("returns 404 when the share does not exist", func() { + shareIs(&model.Share{ID: "other", Downloadable: true}) + + w := makeRequest("missing") + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(archiver.called).To(BeFalse()) + }) + + It("returns 410 when the share has expired", func() { + shareIs(&model.Share{ID: "abc123", Downloadable: true, ExpiresAt: new(time.Now().Add(-time.Hour))}) + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusGone)) + Expect(archiver.called).To(BeFalse()) + }) + + It("returns 500 when the share lookup fails", func() { + shareRepo.Error = errors.New("db error") + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(archiver.called).To(BeFalse()) + }) +}) diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 6875b6370..4e9039d68 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -330,7 +330,7 @@ func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) return model.ErrNotFound } -func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error { +func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error { return model.ErrNotFound } diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx index a3a15e50a..2384866ea 100644 --- a/ui/src/share/SharePlayer.jsx +++ b/ui/src/share/SharePlayer.jsx @@ -1,15 +1,24 @@ import ReactJkMusicPlayer from 'navidrome-music-player' +import { useCallback, useEffect, useRef, useState } from 'react' import config, { shareInfo } from '../config' import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils' import { makeStyles } from '@material-ui/core/styles' +// How long the download button stays inert after a click. The browser needs a +// moment to show its own download UI; until then the page looks unresponsive. +export const DOWNLOAD_FEEDBACK_MS = 5000 + const useStyle = makeStyles({ player: { '& .group .next-audio': { pointerEvents: (props) => props.single && 'none', opacity: (props) => props.single && 0.65, }, + '& .group.audio-download': { + pointerEvents: (props) => props.downloading && 'none', + opacity: (props) => props.downloading && 0.65, + }, '@media (min-width: 768px)': { '& .react-jinke-music-player-mobile > div': { width: 768, @@ -23,7 +32,14 @@ const useStyle = makeStyles({ }) const SharePlayer = () => { - const classes = useStyle({ single: shareInfo?.tracks.length === 1 }) + const [downloading, setDownloading] = useState(false) + const timer = useRef(null) + const classes = useStyle({ + single: shareInfo?.tracks.length === 1, + downloading, + }) + + useEffect(() => () => clearTimeout(timer.current), []) const list = shareInfo?.tracks.map((s) => { return { @@ -34,11 +50,23 @@ const SharePlayer = () => { duration: s.duration, } }) - const onBeforeAudioDownload = () => { - return Promise.resolve({ - src: shareDownloadUrl(shareInfo?.id), - }) - } + // An anchor, not a navigation: the service worker's NavigationRoute would + // intercept the streamed archive and fail it. + const customDownloader = useCallback(() => { + const link = document.createElement('a') + link.href = shareDownloadUrl(shareInfo?.id) + link.download = '' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + setDownloading(true) + clearTimeout(timer.current) + timer.current = setTimeout( + () => setDownloading(false), + DOWNLOAD_FEEDBACK_MS, + ) + }, []) const options = { audioLists: list, mode: 'full', @@ -59,7 +87,7 @@ const SharePlayer = () => { <ReactJkMusicPlayer {...options} className={classes.player} - onBeforeAudioDownload={onBeforeAudioDownload} + customDownloader={customDownloader} /> ) } diff --git a/ui/src/share/SharePlayer.test.jsx b/ui/src/share/SharePlayer.test.jsx new file mode 100644 index 000000000..68d1eff59 --- /dev/null +++ b/ui/src/share/SharePlayer.test.jsx @@ -0,0 +1,139 @@ +import { render, act } from '@testing-library/react' +import SharePlayer, { DOWNLOAD_FEEDBACK_MS } from './SharePlayer' + +let playerProps +let renderCount + +vi.mock('navidrome-music-player', () => ({ + default: (props) => { + playerProps = props + renderCount++ + return <div data-testid="player" /> + }, +})) + +vi.mock('../config', () => ({ + default: { enableDownloads: true }, + shareInfo: { + id: 'share-1', + downloadable: true, + tracks: [{ id: 't1', title: 'One', artist: 'A', duration: 100 }], + }, +})) + +vi.mock('../utils', () => ({ + shareDownloadUrl: (id) => `/share/d/${id}`, + shareStreamUrl: (id) => `/share/s/${id}`, + shareCoverUrl: (id) => `/share/img/${id}`, +})) + +describe('SharePlayer', () => { + let clickSpy + + beforeEach(() => { + vi.useFakeTimers() + playerProps = null + renderCount = 0 + // Downloading for real would navigate the jsdom window. + clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('downloads via an anchor so the service worker does not intercept it', () => { + render(<SharePlayer />) + + let anchor + clickSpy.mockImplementation(function () { + anchor = { href: this.href, download: this.download } + }) + + act(() => { + playerProps.customDownloader() + }) + + expect(clickSpy).toHaveBeenCalledTimes(1) + expect(anchor.href).toContain('/share/d/share-1') + // Empty, so the server's Content-Disposition filename wins. + expect(anchor.download).toBe('') + }) + + it('removes the anchor from the document after clicking', () => { + render(<SharePlayer />) + + act(() => { + playerProps.customDownloader() + }) + + expect(document.querySelectorAll('a[download]')).toHaveLength(0) + }) + + // The inert styling itself is driven by JSS function values, which jsdom does + // not evaluate; it is verified in a browser. What is checked here is the + // state machine feeding it -- that the component re-renders on download and + // again when the window closes. + it('re-renders when the feedback window opens and closes', () => { + render(<SharePlayer />) + + const beforeDownload = renderCount + act(() => { + playerProps.customDownloader() + }) + expect(renderCount).toBeGreaterThan(beforeDownload) + + const beforeExpiry = renderCount + act(() => { + vi.runAllTimers() + }) + expect(renderCount).toBeGreaterThan(beforeExpiry) + }) + + it('restarts the feedback window on a repeat download', () => { + render(<SharePlayer />) + + act(() => { + playerProps.customDownloader() + }) + const elapsedBeforeRepeat = Math.floor(DOWNLOAD_FEEDBACK_MS / 2) + act(() => { + vi.advanceTimersByTime(elapsedBeforeRepeat) + }) + act(() => { + playerProps.customDownloader() + }) + + // Past the first timer's deadline, which it would have fired at had the + // repeat download not replaced it. + const beforeOriginalDeadline = renderCount + act(() => { + vi.advanceTimersByTime(DOWNLOAD_FEEDBACK_MS - elapsedBeforeRepeat + 1) + }) + expect(renderCount).toBe(beforeOriginalDeadline) + + act(() => { + vi.runAllTimers() + }) + expect(renderCount).toBeGreaterThan(beforeOriginalDeadline) + }) + + it('does not update state after unmount', () => { + const { unmount } = render(<SharePlayer />) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + act(() => { + playerProps.customDownloader() + }) + unmount() + act(() => { + vi.runAllTimers() + }) + + expect(errorSpy).not.toHaveBeenCalled() + }) +}) From add0a6dc9b8360db480f76793fa928499bf51741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 29 Jul 2026 13:44:42 -0400 Subject: [PATCH 398/596] feat(config): warn about unrecognized options in the config file (#5870) * feat(config): warn about unrecognized options in the config file Options that don't match any known name were silently discarded, so a typo or an option written outside its section looked like it was applied. In #5869 the user set `ArtistSplitExceptions` at the root level instead of under `Scanner`, and got no feedback that the option was being ignored. The known names are derived by reflection over configOptions, so the check stays in sync with the struct. Free-form maps (Tags, DevLogLevels) accept any subkey. When an unknown key matches the last segment of a known one, the warning suggests it. Keys are reported as spelled in the config file, recovered by scanning it, since viper lowercases every key it loads. Also fixes two gaps this surfaced: - remapEnvVarKeysFromConfig accepted any ND_-prefixed key and advised a canonical name built by string substitution, so `ND_SCANNER_WATCHERENABLED` (not an option) suggested `Scanner.Watcherenabled` (not an option either). It now only advises names that exist, with their documented spelling, and leaves the rest to the unrecognized-option warning. - The deprecated option list drove only the warnings, while the value migration kept a second hardcoded list. They had drifted: SearchFullString warned about `Search.FullString` but never migrated to it. Both now come from deprecatedOptions. * fix(config): address Codex review on the unrecognized-option warning - Values computed during Load (ConfigFile, LastFM.Languages, Deezer.Languages) were accepted as valid keys, so setting them in the config file stayed silent even though Load overwrites them. They are now marked `conf:"-"` at the declaration, so the exclusion can't drift from the struct. - Removed options are in the known-key set only so they get their own warning, but suggestOptions drew from the same set, so an unknown `ID` advised `Spotify.ID`, a key Navidrome explicitly ignores. They are now filtered out of suggestions. - mapDeprecatedOption uses viper.Set, which outranks the config file, so a deprecated value overrode an explicitly configured replacement. It now skips the migration when the replacement was provided. viper.IsSet counts defaults as set, so the check is InConfig plus the env var. envVarName also returns "" for an empty option, so a deprecated option with no replacement no longer advises "Please use the new 'ND_'". * fix(config): cover the ND_ spelling of a replacement, and the warning output - explicitlySet missed the case where the replacement is given in the config file under its ND_ spelling: remapEnvVarKeysFromConfig moves it to the override layer, out of InConfig's reach, so the deprecated value still won. It now also checks the ND_-prefixed config key. - The tests asserted only the helpers' return values, so removing the logUnknownOptions call from Load left them green. Added a spec that captures the logger and checks the emitted warning and suggestion text; verified it fails when the call is removed. --- conf/configuration.go | 241 +++++++++++++++++++---- conf/configuration_test.go | 119 +++++++++++ conf/export_test.go | 4 + conf/testdata/cfg_deprecated_search.toml | 2 + conf/testdata/cfg_nd_bogus.toml | 3 + conf/testdata/cfg_runtime_fields.toml | 10 + conf/testdata/cfg_unknown_casing.ini | 3 + conf/testdata/cfg_unknown_casing.json | 4 + conf/testdata/cfg_unknown_casing.toml | 2 + conf/testdata/cfg_unknown_casing.yaml | 2 + conf/testdata/cfg_unknown_keys.toml | 18 ++ conf/testdata/cfg_warning_output.toml | 7 + 12 files changed, 380 insertions(+), 35 deletions(-) create mode 100644 conf/testdata/cfg_deprecated_search.toml create mode 100644 conf/testdata/cfg_nd_bogus.toml create mode 100644 conf/testdata/cfg_runtime_fields.toml create mode 100644 conf/testdata/cfg_unknown_casing.ini create mode 100644 conf/testdata/cfg_unknown_casing.json create mode 100644 conf/testdata/cfg_unknown_casing.toml create mode 100644 conf/testdata/cfg_unknown_casing.yaml create mode 100644 conf/testdata/cfg_unknown_keys.toml create mode 100644 conf/testdata/cfg_warning_output.toml diff --git a/conf/configuration.go b/conf/configuration.go index 83793bd43..9b49fb264 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,14 +2,18 @@ package conf import ( "cmp" + "encoding" "encoding/json" "fmt" "net/url" "os" "path/filepath" + "reflect" + "regexp" "runtime" "slices" "strings" + "sync" "time" "github.com/bmatcuk/doublestar/v4" @@ -21,11 +25,12 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/utils/run" + "github.com/navidrome/navidrome/utils/slice" "github.com/spf13/viper" ) type configOptions struct { - ConfigFile string + ConfigFile string `conf:"-"` Address string Port int UnixSocketPerm string @@ -201,7 +206,7 @@ type lastfmOptions struct { ScrobbleFirstArtistOnly bool // Computed values - Languages []string // Computed from Language, split by comma + Languages []string `conf:"-"` // Computed from Language, split by comma } type deezerOptions struct { @@ -209,7 +214,7 @@ type deezerOptions struct { Language string // Computed values - Languages []string // Computed from Language, split by comma + Languages []string `conf:"-"` // Computed from Language, split by comma } type listenBrainzOptions struct { @@ -340,12 +345,11 @@ func Load(noConfigDump bool) { remapEnvVarKeysFromConfig() // Map deprecated options to their new names for backwards compatibility - mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") - mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") - mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") - mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") - mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") + for _, o := range deprecatedOptions { + if o.replacement != "" { + mapDeprecatedOption(o.name, o.replacement) + } + } err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -403,6 +407,13 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) + // Log deprecated, removed and unknown options + for _, o := range deprecatedOptions { + logDeprecatedOptions(o.name, o.replacement) + } + logRemovedOptions(removedOptions...) + logUnknownOptions() + err = run.Sequentially( validateScanSchedule, validateBackupSchedule, @@ -461,21 +472,6 @@ func Load(noConfigDump bool) { // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) Server.Deezer.Languages = parseLanguages(Server.Deezer.Language) - // Deprecated options - logDeprecatedOptions("Scanner.GenreSeparators", "") - logDeprecatedOptions("Scanner.GroupAlbumReleases", "") - logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored - logDeprecatedOptions("SearchFullString", "Search.FullString") - logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") - logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") - logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") - logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") - logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation") - - // Removed options - logRemovedOptions("Spotify.ID", "Spotify.Secret") - // Validate other options if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 { newValue := max(200, min(1200, Server.UICoverArtSize)) @@ -489,9 +485,26 @@ func Load(noConfigDump bool) { } } +// deprecatedOptions still work, but will be removed in a future release. An empty +// replacement means the option is now ignored. +var deprecatedOptions = []struct{ name, replacement string }{ + {"Scanner.GenreSeparators", ""}, + {"Scanner.GroupAlbumReleases", ""}, + {"DevEnableBufferedScrobble", ""}, + {"SearchFullString", "Search.FullString"}, + {"ReverseProxyWhitelist", "ExtAuth.TrustedSources"}, + {"ReverseProxyUserHeader", "ExtAuth.UserHeader"}, + {"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"}, + {"CoverJpegQuality", "CoverArtQuality"}, + {"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"}, + {"EnableTranscodingCancellation", "Transcoding.EnableCancellation"}, +} + +var removedOptions = []string{"Spotify.ID", "Spotify.Secret"} + func logDeprecatedOptions(oldName, newName string) { - envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_")) - newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_")) + envVar := envVarName(oldName) + newEnvVar := envVarName(newName) logWarning := func(oldName, newName string) { if newName != "" { log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName)) @@ -511,7 +524,7 @@ func logDeprecatedOptions(oldName, newName string) { // not available anymore func logRemovedOptions(options ...string) { for _, option := range options { - envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) + envVar := envVarName(option) logWarning := func(option string) { log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option)) } @@ -532,35 +545,193 @@ func remapEnvVarKeysFromConfig() { continue } stripped := strings.TrimPrefix(key, "nd_") - canonicalKey := strings.ReplaceAll(stripped, "_", ".") + canonicalKey := ndKeyToCanonical(key) displayNDKey := "ND_" + strings.ToUpper(stripped) - displayCanonical := toPascalCase(canonicalKey) + canonicalName := canonicalOptionName(canonicalKey) if viper.InConfig(canonicalKey) { logFatal(fmt.Sprintf( "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ "The 'ND_' prefix is only needed for environment variables, not config file keys.", - displayNDKey, displayCanonical, + displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)), )) return } viper.Set(canonicalKey, viper.Get(key)) - _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ - "The 'ND_' prefix is only needed for environment variables.\n", - displayNDKey, displayCanonical, - ) + // Unknown keys get no advice here, logUnknownOptions reports them instead + if canonicalName != "" { + _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ + "The 'ND_' prefix is only needed for environment variables.\n", + displayNDKey, canonicalName, + ) + } } } // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { - if viper.IsSet(legacyName) { + // viper.Set outranks the config file, so an explicit replacement must win over the legacy value + if viper.IsSet(legacyName) && !explicitlySet(newName) { viper.Set(newName, viper.Get(legacyName)) } } +// explicitlySet reports whether the user provided the option, ignoring defaults, +// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config +// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach. +func explicitlySet(name string) bool { + envVar := envVarName(name) + return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar)) +} + +func envVarName(option string) string { + if option == "" { + return "" + } + return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) +} + +func logUnknownOptions() { + for _, key := range unknownConfigKeys() { + msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key) + if matches := suggestOptions(key); len(matches) > 0 { + msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '")) + } + log.Warn(msg) + } +} + +// suggestOptions returns the known options sharing the last segment with key, +// catching options written outside their section. +func suggestOptions(key string) []string { + key = strings.ToLower(key) + leaf := leafKey(key) + canonical, _ := configKeys() + var matches []string + for known, name := range canonical { + // Removed options are known only so they get their own warning, never suggest them + if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) { + matches = append(matches, name) + } + } + slices.Sort(matches) + return matches +} + +func leafKey(key string) string { + return key[strings.LastIndex(key, ".")+1:] +} + +// unknownConfigKeys returns config file keys that don't match any known option, so +// typos and options written outside their section don't fail silently. +func unknownConfigKeys() []string { + // INI files keep the original [default] section alongside the merged one + skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini") + + var unknown []string + for _, key := range viper.AllKeys() { + if !viper.InConfig(key) || canonicalOptionName(key) != "" { + continue + } + if skipDefault && strings.HasPrefix(key, "default.") { + continue + } + // Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid + if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" { + continue + } + unknown = append(unknown, key) + } + slices.Sort(unknown) + return asWrittenInConfigFile(unknown) +} + +func ndKeyToCanonical(key string) string { + return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".") +} + +// canonicalOptionName returns the documented spelling of a known option key, or "" +// if it matches no option. Subkeys of free-form maps have no fixed spelling. +func canonicalOptionName(key string) string { + keys, prefixes := configKeys() + if name, ok := keys[key]; ok { + return name + } + if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) { + return toPascalCase(key) + } + return "" +} + +// asWrittenInConfigFile restores the casing the keys have in the config file, as +// viper lowercases every key it loads. +func asWrittenInConfigFile(keys []string) []string { + if len(keys) == 0 { + return nil + } + data, err := os.ReadFile(viper.ConfigFileUsed()) + if err != nil { + return keys + } + casing := map[string]string{} + for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) { + for segment := range strings.SplitSeq(match[1], ".") { + lower := strings.ToLower(segment) + casing[lower] = cmp.Or(casing[lower], segment) + } + } + return slice.Map(keys, func(key string) string { + segments := strings.Split(key, ".") + for i, s := range segments { + segments[i] = cmp.Or(casing[s], s) + } + return strings.Join(segments, ".") + }) +} + +// Matches keys and section headers in all supported config formats. +var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`) + +// configKeys maps every accepted option name, lowercased, to its canonical spelling, +// plus the prefixes of free-form map options (Tags, DevLogLevels). +var configKeys = sync.OnceValues(func() (map[string]string, []string) { + keys := map[string]string{} + var prefixes []string + + var collect func(t reflect.Type, prefix string) + collect = func(t reflect.Type, prefix string) { + for field := range t.Fields() { + // `conf:"-"` marks values computed during Load, not settable in the config + if !field.IsExported() || field.Tag.Get("conf") == "-" { + continue + } + name := prefix + field.Name + if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) { + collect(field.Type, name+".") + continue + } + lower := strings.ToLower(name) + keys[lower] = name + if field.Type.Kind() == reflect.Map { + prefixes = append(prefixes, lower+".") + } + } + } + collect(reflect.TypeFor[configOptions](), "") + + for _, o := range deprecatedOptions { + keys[strings.ToLower(o.name)] = o.name + } + for _, o := range removedOptions { + keys[strings.ToLower(o)] = o + } + return keys, prefixes +}) + +var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + // parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it // would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] // section into the root level. diff --git a/conf/configuration_test.go b/conf/configuration_test.go index e43c91a4b..4eaa8e3d8 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -1,12 +1,14 @@ package conf_test import ( + "bytes" "fmt" "os" "path/filepath" "testing" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/viper" @@ -178,6 +180,123 @@ var _ = Describe("Configuration", func() { }) }) + Describe("unknownConfigKeys", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("reports misplaced and misspelled options, as spelled in the config file", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(ConsistOf( + "ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo", + )) + }) + + DescribeTable("recovers the original casing in all supported formats", + func(file string) { + conf.InitConfig(filepath.Join("testdata", file), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption")) + }, + Entry("TOML", "cfg_unknown_casing.toml"), + Entry("YAML", "cfg_unknown_casing.yaml"), + Entry("JSON", "cfg_unknown_casing.json"), + Entry("INI", "cfg_unknown_casing.ini"), + ) + + It("does not report valid, deprecated or free-form keys", func() { + conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + It("does not report the [default] section of INI files", func() { + conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + DescribeTable("SuggestOptions", + func(key string, expected []string) { + Expect(conf.SuggestOptions(key)).To(Equal(expected)) + }, + Entry("suggests the section of a misplaced option", "artistsplitexceptions", + []string{"Scanner.ArtistSplitExceptions"}), + Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold", + []string{"Matcher.FuzzyThreshold"}), + Entry("suggests every section defining the option", "schedule", + []string{"Backup.Schedule", "Scanner.Schedule"}), + Entry("suggests nothing for a typo", "enabledownlods", nil), + ) + + It("does not report ND_-prefixed keys, as they are remapped", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + It("reports ND_-prefixed keys that remap to no known option", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION")) + Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) + }) + + It("migrates every deprecated option that has a replacement", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false) + conf.Load(true) + + Expect(conf.Server.Search.FullString).To(BeTrue()) + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + It("warns about each unrecognized option at startup", func() { + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + DeferCleanup(func() { log.SetOutput(GinkgoWriter) }) + + conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false) + conf.Load(true) + + Expect(logBuf.String()).To(ContainSubstring( + "Option 'ArtistSplitExceptions' is not recognized and will be ignored. " + + "Did you mean 'Scanner.ArtistSplitExceptions'?")) + Expect(logBuf.String()).To(ContainSubstring( + "Option 'EnableDownlods' is not recognized and will be ignored")) + Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner")) + }) + + Context("with runtime-computed and removed options in the config", func() { + BeforeEach(func() { + conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false) + conf.Load(true) + }) + + It("reports values computed during Load, which the config cannot set", func() { + Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages")) + }) + + It("never suggests a removed option", func() { + Expect(conf.SuggestOptions("id")).To(BeEmpty()) + }) + + It("keeps an explicit replacement over the deprecated value", func() { + Expect(conf.Server.Search.FullString).To(BeFalse()) + }) + }) + }) + Describe("logFatal", func() { var invalidPath string BeforeEach(func() { diff --git a/conf/export_test.go b/conf/export_test.go index acebca551..cbb64b3d0 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -32,3 +32,7 @@ func SetLogFatal(f func(...any)) func() { logFatal = f return func() { logFatal = old } } + +var UnknownConfigKeys = unknownConfigKeys + +var SuggestOptions = suggestOptions diff --git a/conf/testdata/cfg_deprecated_search.toml b/conf/testdata/cfg_deprecated_search.toml new file mode 100644 index 000000000..cc6541e09 --- /dev/null +++ b/conf/testdata/cfg_deprecated_search.toml @@ -0,0 +1,2 @@ +MusicFolder = "/toml/music" +SearchFullString = true diff --git a/conf/testdata/cfg_nd_bogus.toml b/conf/testdata/cfg_nd_bogus.toml new file mode 100644 index 000000000..841b40998 --- /dev/null +++ b/conf/testdata/cfg_nd_bogus.toml @@ -0,0 +1,3 @@ +MusicFolder = "/toml/music" +ND_TOTALLY_BOGUS_OPTION = true +ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/conf/testdata/cfg_runtime_fields.toml b/conf/testdata/cfg_runtime_fields.toml new file mode 100644 index 000000000..a9c09f619 --- /dev/null +++ b/conf/testdata/cfg_runtime_fields.toml @@ -0,0 +1,10 @@ +MusicFolder = "/toml/music" +SearchFullString = true +ConfigFile = "/somewhere/else" +ID = "oops" + +[Search] +FullString = false + +[LastFM] +Languages = ["pt"] diff --git a/conf/testdata/cfg_unknown_casing.ini b/conf/testdata/cfg_unknown_casing.ini new file mode 100644 index 000000000..c88db9b1f --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.ini @@ -0,0 +1,3 @@ +[default] +MusicFolder = /ini/music +NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.json b/conf/testdata/cfg_unknown_casing.json new file mode 100644 index 000000000..cd3a226cd --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.json @@ -0,0 +1,4 @@ +{ + "MusicFolder": "/json/music", + "NotAnOption": true +} diff --git a/conf/testdata/cfg_unknown_casing.toml b/conf/testdata/cfg_unknown_casing.toml new file mode 100644 index 000000000..f015c9cb2 --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.toml @@ -0,0 +1,2 @@ +MusicFolder = "/toml/music" +NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.yaml b/conf/testdata/cfg_unknown_casing.yaml new file mode 100644 index 000000000..8987f6351 --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.yaml @@ -0,0 +1,2 @@ +MusicFolder: /yaml/music +NotAnOption: true diff --git a/conf/testdata/cfg_unknown_keys.toml b/conf/testdata/cfg_unknown_keys.toml new file mode 100644 index 000000000..a7ed55cb0 --- /dev/null +++ b/conf/testdata/cfg_unknown_keys.toml @@ -0,0 +1,18 @@ +MusicFolder = "/toml/music" + +# Valid option, but written at the root level instead of under Scanner +ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"] + +# Misspelled option +EnableDownlods = true + +# Unknown section +[Whatever] +Foo = "bar" + +# Valid options, must not be reported +[Scanner] +ArtistJoiner = " • " + +[Tags.custom] +aliases = ["toml", "test"] diff --git a/conf/testdata/cfg_warning_output.toml b/conf/testdata/cfg_warning_output.toml new file mode 100644 index 000000000..a52c5ea54 --- /dev/null +++ b/conf/testdata/cfg_warning_output.toml @@ -0,0 +1,7 @@ +MusicFolder = "/toml/music" +LogLevel = "warn" +ArtistSplitExceptions = ["AC/DC"] +EnableDownlods = true + +[Scanner] +ArtistJoiner = " • " From 4f29465d27d35444683bd7fae70097583ab4b01f Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:48:42 +0000 Subject: [PATCH 399/596] fix(artwork): do not promote artist folder to album folder (#5856) * fix(artwork): do not promote artist folder to album folder * use filesystem join for path instead * fix(artwork): resolve artist folder for albums with disc subfolders Dropping the promoted album root from the artist reader's paths fixed the flat-album case but broke albums whose tracks live in disc subfolders: the promoted root was what kept the byte-wise longest common prefix on a directory boundary. Without it, CD1/CD2 siblings share the fragment "Album/CD", so the artist folder resolved one level too deep and album/artist.* won over artist.*. loadAlbumFoldersPaths now collapses each album to its own root before the paths are compared across albums, so a disc-split album contributes its album folder rather than each disc folder. Folders no album claims (the promoted parent) are still returned unchanged, keeping the album, disc and mediafile readers unaffected. The prefix math moves into commonDir, which appends a trailing separator so the comparison lands on segment boundaries - this also fixes sibling folders sharing a name prefix (Album/Album2). Adds e2e coverage for the case the branch fixes (artist.* with no album/ fallback, which failed before this branch), the disc-subfolder regression, and a multi-album guard that pins the scope boundary. * test(artwork): cover artist whose albums are all disc-split The existing specs cover a single disc-split album and a disc-split album alongside a flat one, but not an artist where every album is split into disc subfolders. That layout resolves correctly because the albums diverge one level above the disc folders, which re-anchors the common prefix - a property worth pinning so a future change to the path math can't silently break it. * fix(artwork): resolve artist folder when albums share a folder loadArtistFolder decided whether to climb above the common directory by comparing len(paths) against 2. That count stands in for "how many distinct album roots are there", and the two diverge when an artist has two albums in the same folder: the slice holds two identical entries, the climb is skipped, and the artist folder resolves to the album folder. Deduplicate the roots so the branch tests the property it means to test. Replace the includeParent flag with two functions split by audience. The flag was a caller-identity switch - constant true at the album, disc and mediafile readers, all of which discard the returned paths, and constant false at the only caller that reads them. The artist path now has loadArtistAlbumRoots, which collapses each album to its own root and never consults albumRootParent; loadAlbumFoldersPaths goes back to taking a single album and always promoting the parent. Both share the folder load and image aggregation. This also makes albumRootParent's contract structural. Its guard only rejects an artist folder when it can find audio outside the album being resolved, so passing every album of an artist at once made the check meaningless; taking a single album means it can no longer be called that way. Drops the per-album grouping and unclaimed-folder reconciliation from the album path, where the result was discarded, and removes 15 mechanical true arguments from the tests. --------- Co-authored-by: Deluan <deluan@navidrome.org> --- core/artwork/artwork_internal_test.go | 1 + core/artwork/e2e/artist_test.go | 95 +++++++++++++++++++++++++++ core/artwork/reader_album.go | 32 ++++----- core/artwork/reader_album_test.go | 27 ++++++++ core/artwork/reader_artist.go | 58 ++++++++++++++-- core/artwork/reader_artist_test.go | 13 ++++ 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index c95371959..5bcd82f23 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -202,6 +202,7 @@ var _ = Describe("Artwork", func() { repoRoot, err := os.Getwd() Expect(err).ToNot(HaveOccurred()) folderRepo.result = []model.Folder{{ + ID: "f1", LibraryPath: testFileLibPath(repoRoot), Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"artist.png"}, diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index d959b1d60..2079d37ce 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -80,6 +80,101 @@ var _ = Describe("Artist artwork resolution", func() { }) }) + When("ArtistArtPriority has no album/ fallback", func() { + // Artist/ + // ├── artist.jpg ← must resolve via the artist folder itself + // └── Album/ + // └── 01 - Track.mp3 + It("still resolves the artist folder and returns artist.*", func() { + conf.Server.ArtistArtPriority = "artist.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("the artist's only album has its tracks in disc subfolders", func() { + // Artist/ + // ├── artist.jpg ← wins (artist.* before album/artist.*) + // └── Album/ + // ├── artist.jpg + // ├── CD1/01 - Track.mp3 + // └── CD2/02 - Track.mp3 + It("prefers the artist-folder image over the album-folder one", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("one album has disc subfolders and another sits at artist level", func() { + // Artist/ + // ├── artist.jpg ← wins + // ├── Album1/ + // │ ├── artist.jpg + // │ ├── CD1/01 - Track.mp3 + // │ └── CD2/02 - Track.mp3 + // └── Album2/03 - Track.mp3 + It("prefers the artist-folder image over the album-folder one", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album1/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("every album of the artist has its tracks in disc subfolders", func() { + // Artist/ + // ├── artist.jpg ← wins + // ├── Album1/ + // │ ├── artist.jpg + // │ ├── CD1/01 - Track.mp3 + // │ └── CD2/02 - Track.mp3 + // └── Album2/ + // ├── CD1/03 - Track.mp3 + // └── CD2/04 - Track.mp3 + It("prefers the artist-folder image over the album-folder one", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), + "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album1/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + When("an artist has an uploaded image and a matching artist.* file", func() { // <DataFolder>/ // └── artwork/ diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 8ad07773b..58a47c8ae 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" + "github.com/navidrome/navidrome/utils/slice" ) type albumArtworkReader struct { @@ -103,17 +104,13 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff return ff } -func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) { - var folderIDs []string - for _, album := range albums { - folderIDs = append(folderIDs, album.FolderIDs...) - } - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) +func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) { + folders, err := loadFolders(ctx, ds, album.FolderIDs) if err != nil { return nil, nil, nil, err } - parent, err := albumRootParent(ctx, ds, folders, folderIDs) + parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs) if err != nil { return nil, nil, nil, err } @@ -121,11 +118,21 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo folders = append(folders, *parent) } - var paths []string + paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() }) + imgFiles, updatedAt := folderImages(folders) + return paths, imgFiles, &updatedAt, nil +} + +func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) { + return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) +} + +// folderImages collects the folders' image files, sorted so files without +// numeric suffixes win (e.g. cover.jpg over cover.1.jpg). +func folderImages(folders []model.Folder) ([]string, time.Time) { var imgFiles []string var updatedAt time.Time for _, f := range folders { - paths = append(paths, f.AbsolutePath()) if f.ImagesUpdatedAt.After(updatedAt) { updatedAt = f.ImagesUpdatedAt } @@ -134,13 +141,8 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo imgFiles = append(imgFiles, path.Join(rel, img)) } } - - // Sort image files to ensure consistent selection of cover art - // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg) - // by comparing base filenames without extensions slices.SortFunc(imgFiles, compareImageFiles) - - return paths, imgFiles, &updatedAt, nil + return imgFiles, updatedAt } // albumRootParent returns the common parent of the album's folders when it diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index fe4a1a545..c95ddedaa 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,6 +3,7 @@ package artwork import ( "context" "errors" + "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -339,6 +340,32 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("promotes the album root parent into the returned paths", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ParentID: "libraryRoot", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"folder.jpg"}, + } + + paths, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(paths).To(Equal([]string{filepath.Join("Artist", "Album"), "Artist"})) + }) + It("does not include parent images when other albums' audio lives under the parent", func() { // Simulates: Artist/folder.jpg with Artist/Album (no images) and // another album's tracks elsewhere under the artist folder diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 37b7b6dee..fe5bcb196 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) @@ -54,7 +55,7 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A if err != nil { return nil, err } - albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...) + albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als) if err != nil { return nil, err } @@ -232,17 +233,64 @@ func escapeGlobLiteral(s string) string { return b.String() } +// loadArtistAlbumRoots returns one path per album — the deepest folder holding +// all of that album's tracks — so an album split into disc subfolders can't +// pull the artist folder's common prefix below the artist level. +func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { + var folderIDs []string + for _, album := range albums { + folderIDs = append(folderIDs, album.FolderIDs...) + } + folders, err := loadFolders(ctx, ds, folderIDs) + if err != nil { + return nil, nil, nil, err + } + + pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { + return f.ID, f.AbsolutePath() + }) + var roots []string + for _, album := range albums { + var albumPaths []string + for _, fid := range album.FolderIDs { + if p, ok := pathByID[fid]; ok { + albumPaths = append(albumPaths, p) + } + } + if len(albumPaths) > 0 { + roots = append(roots, commonDir(albumPaths)) + } + } + + imgFiles, updatedAt := folderImages(folders) + return roots, imgFiles, &updatedAt, nil +} + +// commonDir returns the deepest directory containing all paths. Trailing +// separators keep the comparison on segment boundaries, so a shared name +// fragment (".../Album" and ".../Album2") is never read as a shared directory. +func commonDir(paths []string) string { + sep := string(filepath.Separator) + common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) + if !strings.HasSuffix(common, sep) { + common, _ = filepath.Split(common) + } + return filepath.Clean(common) +} + func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { if len(albums) == 0 { return "", time.Time{}, nil } libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries - folderPath := str.LongestCommonPrefix(paths) - if !strings.HasSuffix(folderPath, string(filepath.Separator)) { - folderPath, _ = filepath.Split(folderPath) + // paths holds one root per album: two or more distinct roots already meet at + // the artist folder, while a single root is an album folder needing a climb. + roots := slices.Compact(slices.Sorted(slices.Values(paths))) + folderPath := commonDir(roots) + if len(roots) < 2 { + folderPath = filepath.Dir(folderPath) } - folderPath = filepath.Dir(folderPath) // Manipulate the path to get the folder ID // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 6d6d58fc5..c75f8447e 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -85,6 +85,19 @@ var _ = Describe("artistArtworkReader", func() { }) }) + When("two albums share the same folder", func() { + It("climbs above the shared album folder", func() { + paths = []string{ + filepath.FromSlash("/music/artist/split"), + filepath.FromSlash("/music/artist/split"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + When("the album paths contain same prefix", func() { It("returns the common prefix", func() { paths = []string{ From 600ea5482c36d3705fbca1d9ab749d9bdafd6f80 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:36:37 +0800 Subject: [PATCH 400/596] fix(ui): update Chinese (Simplified) translation (#5873) Signed-off-by: Deluan <deluan@navidrome.org> --- resources/i18n/zh-Hans.json | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 21778506a..24f55357a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -32,7 +32,7 @@ "rating": "评分", "quality": "品质", "bpm": "BPM", - "playDate": "最后一次播放", + "playDate": "上次播放", "createdAt": "加入日期", "grouping": "分组", "mood": "情绪", @@ -184,7 +184,7 @@ "maxBitRate": "最大比特率", "client": "客户端", "userName": "用户名", - "lastSeen": "上次浏览", + "lastSeen": "上次使用", "reportRealPath": "报告真实路径", "scrobbleEnabled": "发送个性化记录到外部服务" } @@ -210,7 +210,8 @@ "songCount": "歌曲数", "comment": "注释", "sync": "自动导入", - "path": "导入路径" + "path": "导入路径", + "starred": "收藏" }, "actions": { "selectPlaylist": "选择歌单", @@ -530,7 +531,7 @@ "no_results": "未找到结果", "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", "page_out_of_boundaries": "页码 %{page} 超出范围", - "page_out_from_end": "已经最后一页", + "page_out_from_end": "已经是最后一页", "page_out_from_begin": "已经是第一页", "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", "page_rows_per_page": "每页项目数:", @@ -636,6 +637,7 @@ "albumList": "专辑", "playlists": "歌单", "sharedPlaylists": "共享的歌单", + "onlyFavourites": "仅显示收藏项", "about": "关于" }, "player": { @@ -668,10 +670,10 @@ "homepage": "主页", "source": "源代码", "featureRequests": "功能需求", - "lastInsightsCollection": "最近的分析收集", + "lastInsightsCollection": "上次的匿名数据收集", "insights": { - "disabled": "禁用", - "waiting": "等待" + "disabled": "已禁用", + "waiting": "等待中" } }, "tabs": { From d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e Mon Sep 17 00:00:00 2001 From: Mateusz Piotrowski <mpp302@gmail.com> Date: Fri, 31 Jul 2026 14:54:29 +0200 Subject: [PATCH 401/596] fix: typo in description of --prometheus.enabled (#5878) Remove a stray back tick. --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 9e2b38cd8..d9e766c32 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -394,7 +394,7 @@ func init() { rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized") rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`") - rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`") + rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint") rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics") _ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address")) From 810b14ed571412e7a7ddb568ebee69ac1498faea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 2 Aug 2026 12:27:08 -0400 Subject: [PATCH 402/596] fix(plugins): confine plugin filesystem mounts to their root (#5881) * fix(plugins): confine plugin filesystem mounts to their root A plugin granted read-write filesystem access could escape its mount by creating a relative symlink inside it and then writing through that link, reaching any path the server process can write, including navidrome.db. wazero resolves guest paths by concatenating them onto the host root. Its WASI layer validates every path argument except the symlink target, which path_symlink forwards unvalidated by design, and fs.ValidPath splits on "/" only, so on Windows a "..\" path escapes the mount as well. Mounts now go through a jailedFS wrapper that denies symlink creation and rejects any path that is not filepath.IsLocal. That requires bypassing extism's AllowedPaths, which discards any FSConfig passed alongside it, so the mounts are built directly and applied per instance instead. Following symlinks that already exist in a mount is unchanged: music libraries rely on it, and read-only mounts already reject creating new ones. * test(plugins): guard against setting extism AllowedPaths Extracts the extism manifest construction so a test can assert AllowedPaths is never set. Setting it makes extism build its own FSConfig and discard the jailed mounts, silently restoring the symlink escape. Verified by simulating the regression: with AllowedPaths populated for plugins holding the filesystem permission, the new spec fails, as do two of the end-to-end sandbox specs. --- plugins/manager_loader.go | 59 +++------ plugins/manager_loader_test.go | 86 ++++-------- plugins/manager_plugin.go | 16 ++- plugins/plugins_suite_test.go | 62 +++++---- plugins/sandbox_fs.go | 157 ++++++++++++++++++++++ plugins/sandbox_fs_internal_test.go | 127 ++++++++++++++++++ plugins/sandbox_fs_test.go | 182 ++++++++++++++++++++++++++ plugins/testdata/test-library/main.go | 26 +++- 8 files changed, 572 insertions(+), 143 deletions(-) create mode 100644 plugins/sandbox_fs.go create mode 100644 plugins/sandbox_fs_internal_test.go create mode 100644 plugins/sandbox_fs_test.go diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 0ba5fcf76..fc9296427 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -307,22 +307,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("opening package: %w", err) } - // Build extism manifest - pluginManifest := extism.Manifest{ - Wasm: []extism.Wasm{ - extism.WasmData{Data: pkg.WasmBytes, Name: "main"}, - }, - Config: pluginConfig, - Timeout: uint64(defaultTimeout.Milliseconds()), - } + pluginManifest := buildExtismManifest(pkg, pluginConfig) - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { - if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { - pluginManifest.AllowedHosts = hosts - } - } - - // Configure filesystem access for library permission + // Configure filesystem access for library permission, applied per instance + var fsConfig wazero.FSConfig if pkg.Manifest.HasLibraryFilesystemPermission() { adminCtx := adminContext(ctx) libraries, err := m.ds.Library(adminCtx).GetAll() @@ -330,8 +318,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("failed to get libraries for filesystem access: %w", err) } - allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) - pluginManifest.AllowedPaths = allowedPaths + fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)) } // Build host functions based on permissions from manifest @@ -386,7 +373,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } // Create instance to detect capabilities - instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{}) + instance, err := compiled.Instance(ctx, instanceConfig(fsConfig)) if err != nil { compiled.Close(ctx) return fmt.Errorf("creating instance: %w", err) @@ -413,6 +400,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allowedUserIDs: allowedUsers, allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), + fsConfig: fsConfig, lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } m.mu.Unlock() @@ -459,31 +447,18 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { return pluginConfig, nil } -// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access. -// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only. -// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included. -func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string { - allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) - for _, id := range allowedLibraryIDs { - allowedLibrarySet[id] = struct{}{} +// buildExtismManifest describes the plugin to extism. It must never set +// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts. +func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { + manifest := extism.Manifest{ + Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, + Config: pluginConfig, + Timeout: uint64(defaultTimeout.Milliseconds()), } - allowedPaths := make(map[string]string) - for _, lib := range libraries { - _, allowed := allowedLibrarySet[lib.ID] - if allLibraries || allowed { - mountPoint := toPluginMountPoint(int32(lib.ID)) - hostPath := lib.Path - if !allowWriteAccess { - hostPath = "ro:" + hostPath - } - allowedPaths[hostPath] = mountPoint - log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath) + if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { + if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { + manifest.AllowedHosts = hosts } } - if allowWriteAccess { - log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) - } else { - log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) - } - return allowedPaths + return manifest } diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index cc07f0611..5e6851481 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,11 +1,32 @@ package plugins import ( - "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("buildExtismManifest", func() { + var pkg *ndpPackage + + BeforeEach(func() { + pkg = &ndpPackage{ + WasmBytes: []byte("wasm"), + Manifest: &Manifest{Permissions: &Permissions{ + Library: &LibraryPermission{Reason: new("test"), Filesystem: true}, + Http: &HTTPPermission{Reason: new("test"), RequiredHosts: []string{"example.com"}}, + }}, + } + }) + + It("never sets AllowedPaths, even with filesystem permission", func() { + Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) + }) + + It("carries the hosts the plugin is allowed to reach", func() { + Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"})) + }) +}) + var _ = Describe("parsePluginConfig", func() { It("returns nil for empty string", func() { result, err := parsePluginConfig("") @@ -57,66 +78,3 @@ var _ = Describe("parsePluginConfig", func() { Expect(result).ToNot(BeNil()) }) }) - -var _ = Describe("buildAllowedPaths", func() { - var libraries model.Libraries - - BeforeEach(func() { - libraries = model.Libraries{ - {ID: 1, Path: "/music/library1"}, - {ID: 2, Path: "/music/library2"}, - {ID: 3, Path: "/music/library3"}, - } - }) - - Context("read-only (default)", func() { - It("mounts all libraries with ro: prefix when allLibraries is true", func() { - result := buildAllowedPaths(nil, libraries, nil, true, false) - Expect(result).To(HaveLen(3)) - Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) - Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2")) - Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) - }) - - It("mounts only selected libraries with ro: prefix", func() { - result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false) - Expect(result).To(HaveLen(2)) - Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) - Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) - Expect(result).ToNot(HaveKey("ro:/music/library2")) - }) - }) - - Context("read-write (allowWriteAccess=true)", func() { - It("mounts all libraries without ro: prefix when allLibraries is true", func() { - result := buildAllowedPaths(nil, libraries, nil, true, true) - Expect(result).To(HaveLen(3)) - Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1")) - Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) - Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3")) - }) - - It("mounts only selected libraries without ro: prefix", func() { - result := buildAllowedPaths(nil, libraries, []int{2}, false, true) - Expect(result).To(HaveLen(1)) - Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) - }) - }) - - Context("edge cases", func() { - It("returns empty map when no libraries match", func() { - result := buildAllowedPaths(nil, libraries, []int{99}, false, false) - Expect(result).To(BeEmpty()) - }) - - It("returns empty map when libraries list is empty", func() { - result := buildAllowedPaths(nil, nil, []int{1}, false, false) - Expect(result).To(BeEmpty()) - }) - - It("returns empty map when allLibraries is false and no IDs provided", func() { - result := buildAllowedPaths(nil, libraries, nil, false, false) - Expect(result).To(BeEmpty()) - }) - }) -}) diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 155663781..13375a70f 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -24,16 +24,24 @@ type plugin struct { allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users libraries libraryAccess - lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) + lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) + fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission +} + +// instanceConfig is used by every call site, so all instances get the sandboxed mounts. +func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig { + moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader) + if fsConfig != nil { + moduleConfig = moduleConfig.WithFSConfig(fsConfig) + } + return extism.PluginInstanceConfig{ModuleConfig: moduleConfig} } // instance creates a new plugin instance for the given context. // The context is used for cancellation - if cancelled during a call, // the module will be terminated and the instance becomes unusable. func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) { - instance, err := p.compiled.Instance(ctx, extism.PluginInstanceConfig{ - ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader), - }) + instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig)) if err != nil { return nil, err } diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index bb081988e..77956e2f9 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "time" @@ -81,46 +82,43 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...) } +// installTestPlugins copies the given .ndp packages into dir and returns their +// enabled DB rows, so callers can grant whatever access the test needs. +func installTestPlugins(dir string, plugins ...string) model.Plugins { + var rows model.Plugins + for _, plugin := range plugins { + data, err := os.ReadFile(filepath.Join(testdataDir, plugin)) + Expect(err).ToNot(HaveOccurred()) + destPath := filepath.Join(dir, plugin) + Expect(os.WriteFile(destPath, data, 0600)).To(Succeed()) + + hash := sha256.Sum256(data) + rows = append(rows, model.Plugin{ + ID: strings.TrimSuffix(plugin, PackageExtension), + Path: destPath, + SHA256: hex.EncodeToString(hash[:]), + Enabled: true, + }) + } + return rows +} + // createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config, -// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager. -// Returns the manager and temp directory path. +// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, +// and starts the manager. Returns the manager and temp directory path. func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) { // Create temp directory tmpDir, err := os.MkdirTemp("", "plugins-test-*") Expect(err).ToNot(HaveOccurred()) - // Copy test plugins to temp dir and build plugin list with SHA256 - var enabledPlugins model.Plugins - for _, plugin := range plugins { - srcPath := filepath.Join(testdataDir, plugin) - destPath := filepath.Join(tmpDir, plugin) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension - - // Build config JSON if provided - configJSON := "" - if pluginConfig != nil && pluginConfig[pluginName] != nil { - // Encode config to JSON - configBytes, err := json.Marshal(pluginConfig[pluginName]) + enabledPlugins := installTestPlugins(tmpDir, plugins...) + for i, p := range enabledPlugins { + enabledPlugins[i].AllUsers = true // Allow all users by default in tests + if pluginConfig[p.ID] != nil { + configBytes, err := json.Marshal(pluginConfig[p.ID]) Expect(err).ToNot(HaveOccurred()) - configJSON = string(configBytes) + enabledPlugins[i].Config = string(configBytes) } - - enabledPlugins = append(enabledPlugins, model.Plugin{ - ID: pluginName, - Path: destPath, - SHA256: hashHex, - Enabled: true, - Config: configJSON, - AllUsers: true, // Allow all users by default in tests - }) } // Setup config diff --git a/plugins/sandbox_fs.go b/plugins/sandbox_fs.go new file mode 100644 index 000000000..57bba85af --- /dev/null +++ b/plugins/sandbox_fs.go @@ -0,0 +1,157 @@ +package plugins + +import ( + "context" + "io/fs" + "path/filepath" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/tetratelabs/wazero" + experimentalsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" + "github.com/tetratelabs/wazero/sys" +) + +// jailedFS confines a mount to its root: wazero resolves guest paths by bare +// concatenation. Named field, not embedding, so a new sys.FS method can't slip through. +type jailedFS struct { + fs experimentalsys.FS +} + +// escapes reports whether a guest path can resolve outside the mount root. +// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names. +func escapes(path string) bool { + switch path { + case "", ".", "/": // how wazero opens the mount root itself + return false + } + return !filepath.IsLocal(path) +} + +func (jailedFS) Symlink(string, string) experimentalsys.Errno { + return experimentalsys.EPERM +} + +func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) { + if escapes(path) { + return nil, experimentalsys.EPERM + } + return j.fs.OpenFile(path, flag, perm) +} + +func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) { + if escapes(path) { + return sys.Stat_t{}, experimentalsys.EPERM + } + return j.fs.Lstat(path) +} + +func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) { + if escapes(path) { + return sys.Stat_t{}, experimentalsys.EPERM + } + return j.fs.Stat(path) +} + +func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Mkdir(path, perm) +} + +func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Chmod(path, perm) +} + +func (j jailedFS) Rename(from, to string) experimentalsys.Errno { + if escapes(from) || escapes(to) { + return experimentalsys.EPERM + } + return j.fs.Rename(from, to) +} + +func (j jailedFS) Rmdir(path string) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Rmdir(path) +} + +func (j jailedFS) Unlink(path string) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Unlink(path) +} + +func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno { + if escapes(oldPath) || escapes(newPath) { + return experimentalsys.EPERM + } + return j.fs.Link(oldPath, newPath) +} + +func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) { + if escapes(path) { + return "", experimentalsys.EPERM + } + return j.fs.Readlink(path) +} + +func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Utimens(path, atim, mtim) +} + +// mount is a host directory exposed to a plugin at guestPath. +type mount struct { + hostPath string + guestPath string + readOnly bool +} + +// buildMounts lists the libraries the plugin may reach through the filesystem. +func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount { + allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) + for _, id := range allowedLibraryIDs { + allowedLibrarySet[id] = struct{}{} + } + var mounts []mount + for _, lib := range libraries { + _, allowed := allowedLibrarySet[lib.ID] + if allLibraries || allowed { + m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess} + mounts = append(mounts, m) + log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath) + } + } + if allowWriteAccess { + log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) + } else { + log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) + } + return mounts +} + +// buildFSConfig mounts each host directory jailed to its root. +func buildFSConfig(mounts []mount) wazero.FSConfig { + if len(mounts) == 0 { + return nil + } + cfg := wazero.NewFSConfig() + for _, m := range mounts { + var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)} + if m.readOnly { + mounted = &sysfs.ReadFS{FS: mounted} + } + cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath) + } + return cfg +} diff --git a/plugins/sandbox_fs_internal_test.go b/plugins/sandbox_fs_internal_test.go new file mode 100644 index 000000000..73011225d --- /dev/null +++ b/plugins/sandbox_fs_internal_test.go @@ -0,0 +1,127 @@ +package plugins + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + experimentalsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" +) + +var _ = Describe("buildMounts", func() { + var libraries model.Libraries + + BeforeEach(func() { + libraries = model.Libraries{ + {ID: 1, Path: "/music/library1"}, + {ID: 2, Path: "/music/library2"}, + {ID: 3, Path: "/music/library3"}, + } + }) + + It("mounts all libraries read-only by default", func() { + Expect(buildMounts(nil, libraries, nil, true, false)).To(Equal([]mount{ + {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, + {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: true}, + {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, + })) + }) + + It("mounts only the selected libraries", func() { + Expect(buildMounts(nil, libraries, []int{1, 3}, false, false)).To(Equal([]mount{ + {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, + {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, + })) + }) + + It("mounts writable when write access is granted", func() { + Expect(buildMounts(nil, libraries, []int{2}, false, true)).To(Equal([]mount{ + {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: false}, + })) + }) + + DescribeTable("mounts nothing", + func(libs model.Libraries, allowedIDs []int, allLibraries bool) { + Expect(buildMounts(nil, libs, allowedIDs, allLibraries, false)).To(BeEmpty()) + }, + Entry("when no library matches", libraries, []int{99}, false), + Entry("when there are no libraries", model.Libraries(nil), []int{1}, false), + Entry("when no library is selected", libraries, nil, false), + ) +}) + +var _ = Describe("jailedFS", func() { + var ( + fs jailedFS + root string + outsideDir string + ) + + BeforeEach(func() { + tmpDir := GinkgoT().TempDir() + root = filepath.Join(tmpDir, "root") + outsideDir = filepath.Join(tmpDir, "outside") + Expect(os.MkdirAll(root, 0755)).To(Succeed()) + Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0600)).To(Succeed()) + + fs = jailedFS{sysfs.DirFS(root)} + }) + + It("denies creating symlinks", func() { + Expect(fs.Symlink("../outside", "link")).To(Equal(experimentalsys.EPERM)) + }) + + It("denies reading through a path that escapes the root", func() { + _, errno := fs.OpenFile("../outside/secret.txt", experimentalsys.O_RDONLY, 0) + Expect(errno).To(Equal(experimentalsys.EPERM)) + }) + + It("denies traversal with the host separator", func() { + _, errno := fs.OpenFile(`..\outside\secret.txt`, experimentalsys.O_RDONLY, 0) + + if runtime.GOOS == "windows" { + Expect(errno).To(Equal(experimentalsys.EPERM)) + } else { + // A backslash is a legal filename character on POSIX, not a separator + Expect(errno).To(Equal(experimentalsys.ENOENT)) + } + }) + + It("denies absolute paths", func() { + _, errno := fs.OpenFile("/etc/passwd", experimentalsys.O_RDONLY, 0) + Expect(errno).To(Equal(experimentalsys.EPERM)) + }) + + It("denies creating a directory outside the root", func() { + Expect(fs.Mkdir("../outside/new-dir", 0755)).To(Equal(experimentalsys.EPERM)) + Expect(filepath.Join(outsideDir, "new-dir")).ToNot(BeADirectory()) + }) + + It("denies escaping through either side of a rename", func() { + Expect(fs.Rename("../outside/secret.txt", "stolen.txt")).To(Equal(experimentalsys.EPERM)) + Expect(fs.Rename("inside.txt", "../outside/leaked.txt")).To(Equal(experimentalsys.EPERM)) + }) + + // Music libraries symlink in folders from elsewhere; following them is intended + It("follows symlinks that already exist in the mount", func() { + if err := os.Symlink(outsideDir, filepath.Join(root, "linked")); err != nil { + Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges + } + + _, errno := fs.OpenFile("linked/secret.txt", experimentalsys.O_RDONLY, 0) + Expect(errno).To(BeZero()) + }) + + It("allows access within the root", func() { + Expect(fs.Mkdir("sub", 0755)).To(BeZero()) + f, errno := fs.OpenFile("sub/file.txt", experimentalsys.O_CREAT|experimentalsys.O_WRONLY, 0600) + Expect(errno).To(BeZero()) + Expect(f.Close()).To(BeZero()) + Expect(filepath.Join(root, "sub", "file.txt")).To(BeAnExistingFile()) + }) +}) diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go new file mode 100644 index 000000000..ea033283f --- /dev/null +++ b/plugins/sandbox_fs_test.go @@ -0,0 +1,182 @@ +//go:build !windows + +package plugins + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type sandboxInput struct { + Operation string `json:"operation"` + MountPoint string `json:"mount_point,omitempty"` + FilePath string `json:"file_path,omitempty"` + Content string `json:"content,omitempty"` + Target string `json:"target,omitempty"` +} + +type sandboxOutput struct { + FileContent string `json:"file_content,omitempty"` + Error *string `json:"error,omitempty"` +} + +// startSandboxManager loads test-library against libraryDir with the given grant. +func startSandboxManager(tmpDir, libraryDir string, grant func(*model.Plugin)) *Manager { + GinkgoHelper() + installed := installTestPlugins(tmpDir, "test-library"+PackageExtension) + grant(&installed[0]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(installed) + + mockLibraryRepo := &tests.MockLibraryRepo{} + mockLibraryRepo.SetData(model.Libraries{{ID: 1, Name: "Test Library", Path: libraryDir}}) + + manager := &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedLibrary: mockLibraryRepo}, + subsonicRouter: http.NotFoundHandler(), + } + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + DeferCleanup(func() { _ = manager.Stop() }) + return manager +} + +// callSandbox runs one filesystem operation inside the plugin sandbox. +func callSandbox(manager *Manager, input sandboxInput) sandboxOutput { + GinkgoHelper() + manager.mu.RLock() + p := manager.plugins["test-library"] + manager.mu.RUnlock() + Expect(p).ToNot(BeNil()) + + instance, err := p.instance(context.Background()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(context.Background()) + + inputBytes, err := json.Marshal(input) + Expect(err).ToNot(HaveOccurred()) + _, outputBytes, err := instance.Call("nd_test_library", inputBytes) + Expect(err).ToNot(HaveOccurred()) + + var output sandboxOutput + Expect(json.Unmarshal(outputBytes, &output)).To(Succeed()) + return output +} + +var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() { + var ( + manager *Manager + libraryDir string + outsideDir string + secretFile string + mountPoint string + ) + + call := func(input sandboxInput) sandboxOutput { + GinkgoHelper() + input.MountPoint = mountPoint + return callSandbox(manager, input) + } + + BeforeAll(func() { + tmpDir := GinkgoT().TempDir() + libraryDir = filepath.Join(tmpDir, "music-library") + Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) + + // Sibling of the mount root: anything reached here escaped the sandbox + outsideDir = filepath.Join(tmpDir, "outside") + Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) + secretFile = filepath.Join(outsideDir, "secret.txt") + Expect(os.WriteFile(secretFile, []byte("secret"), 0600)).To(Succeed()) + + mountPoint = toPluginMountPoint(1) + manager = startSandboxManager(tmpDir, libraryDir, func(p *model.Plugin) { + p.AllLibraries = true + p.AllowWriteAccess = true + }) + }) + + It("allows writing inside the mount", func() { + out := call(sandboxInput{Operation: "write_file", FilePath: "inside.txt", Content: "hello"}) + + Expect(out.Error).To(BeNil()) + Expect(os.ReadFile(filepath.Join(libraryDir, "inside.txt"))).To(BeEquivalentTo("hello")) + }) + + It("cannot escape the mount by creating a symlink", func() { + linked := call(sandboxInput{Operation: "symlink", FilePath: "escape", Target: "../outside"}) + Expect(linked.Error).ToNot(BeNil()) + _, err := os.Lstat(filepath.Join(libraryDir, "escape")) + Expect(err).To(MatchError(os.ErrNotExist), "the plugin created a symlink out of the mount") + + wrote := call(sandboxInput{Operation: "write_file", FilePath: "escape/via-created-symlink.txt", Content: "escaped"}) + + Expect(wrote.Error).ToNot(BeNil()) + Expect(filepath.Join(outsideDir, "via-created-symlink.txt")).ToNot(BeAnExistingFile()) + }) + + // Accepted residual, pinned so a future tightening can't happen silently + It("still follows a symlink planted in the mount by something else", func() { + Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed()) + + out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) + + Expect(out.Error).To(BeNil()) + Expect(filepath.Join(outsideDir, "via-symlink.txt")).To(BeAnExistingFile()) + }) + + It("cannot read outside the mount with ..", func() { + out := call(sandboxInput{Operation: "read_file", FilePath: "../outside/secret.txt"}) + + Expect(out.Error).ToNot(BeNil()) + Expect(out.FileContent).ToNot(Equal("secret")) + }) + + It("cannot write outside the mount with ..", func() { + out := call(sandboxInput{Operation: "write_file", FilePath: "../outside/via-dotdot.txt", Content: "escaped"}) + + Expect(out.Error).ToNot(BeNil()) + Expect(filepath.Join(outsideDir, "via-dotdot.txt")).ToNot(BeAnExistingFile()) + }) +}) + +var _ = Describe("Plugin filesystem sandbox without library access", Ordered, func() { + var manager *Manager + var libraryDir string + + BeforeAll(func() { + tmpDir := GinkgoT().TempDir() + libraryDir = filepath.Join(tmpDir, "music-library") + Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libraryDir, "track.txt"), []byte("audio"), 0600)).To(Succeed()) + + manager = startSandboxManager(tmpDir, libraryDir, func(*model.Plugin) {}) + }) + + It("mounts nothing when no library is granted", func() { + manager.mu.RLock() + p := manager.plugins["test-library"] + manager.mu.RUnlock() + Expect(p.fsConfig).To(BeNil()) + + out := callSandbox(manager, sandboxInput{Operation: "read_file", MountPoint: toPluginMountPoint(1), FilePath: "track.txt"}) + Expect(out.Error).ToNot(BeNil()) + }) +}) diff --git a/plugins/testdata/test-library/main.go b/plugins/testdata/test-library/main.go index 9c04c5f9b..e83c9aec6 100644 --- a/plugins/testdata/test-library/main.go +++ b/plugins/testdata/test-library/main.go @@ -14,10 +14,12 @@ import ( // TestLibraryInput is the input for nd_test_library callback. type TestLibraryInput struct { - Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir" + Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir", "write_file", "symlink" LibraryID int32 `json:"library_id,omitempty"` MountPoint string `json:"mount_point,omitempty"` // For filesystem operations FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point) + Content string `json:"content,omitempty"` // For write_file operation + Target string `json:"target,omitempty"` // For symlink operation } // TestLibraryOutput is the output from nd_test_library callback. @@ -88,6 +90,28 @@ func ndTestLibrary() int32 { pdk.OutputJSON(TestLibraryOutput{DirEntries: names}) return 0 + case "write_file": + // Write a file to the mounted library directory + fullPath := filepath.Join(input.MountPoint, input.FilePath) + if err := os.WriteFile(fullPath, []byte(input.Content), 0600); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestLibraryOutput{}) + return 0 + + case "symlink": + // Create a symlink inside the mounted library directory + fullPath := filepath.Join(input.MountPoint, input.FilePath) + if err := os.Symlink(input.Target, fullPath); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestLibraryOutput{}) + return 0 + default: errStr := "unknown operation: " + input.Operation pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) From b40b41584ad5e4e08430aabcd2a0d6484c998bc2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:39:51 -0700 Subject: [PATCH 403/596] feat(subsonic): Implement OpenSubsonic topSongsByArtistId extension (#5853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement topSongsByArtistId extension * do not look up by artist name if empty --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- core/artwork/e2e/helpers_test.go | 2 +- core/external/provider.go | 34 ++++++-- core/external/provider_topsongs_test.go | 84 +++++++++++++++---- server/subsonic/browsing.go | 5 +- server/subsonic/e2e/e2e_suite_test.go | 2 +- server/subsonic/e2e/subsonic_browsing_test.go | 68 ++++++++------- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 6 +- 8 files changed, 143 insertions(+), 59 deletions(-) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go index e3abca097..ddc926cec 100644 --- a/core/artwork/e2e/helpers_test.go +++ b/core/artwork/e2e/helpers_test.go @@ -171,7 +171,7 @@ func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*mo func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { return nil, nil } -func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { +func (n *noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { return nil, nil } func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { diff --git a/core/external/provider.go b/core/external/provider.go index 459e8a205..869841e06 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -34,7 +34,7 @@ type Provider interface { UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) - TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) + TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) ArtistImage(ctx context.Context, id string) (*url.URL, error) AlbumImage(ctx context.Context, id string) (*url.URL, error) } @@ -440,11 +440,16 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) return url.Parse(img.URL) } -func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { - artist, err := e.findArtistByName(ctx, artistName) +func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { + artist, err := e.findArtist(ctx, artistName, id) if err != nil { - log.Error(ctx, "Artist not found", "name", artistName, err) - return nil, nil + if errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Artist not found", "name", artistName, "id", id, err) + return nil, nil + } + + log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err) + return nil, err } songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) @@ -713,7 +718,24 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis return matches, nil } -func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) { +func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) { + if id != "" { + artist, err := e.ds.Artist(ctx).Get(id) + if err == nil { + return &auxArtist{Artist: *artist}, nil + } + + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Could not find artist by id", "id", id, err) + } else { + return nil, err + } + } + + if artistName == "" { + return nil, model.ErrNotFound + } + artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Like{"artist.name": artistName}, Max: 1, diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 86f9110e2..795a7ff74 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -65,7 +65,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -76,6 +76,59 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) + Context("artist id and/or name", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + + BeforeEach(func() { + agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}} + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() + + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() + }) + + It("returns top songs for a known artist by id only", func() { + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Once() + + songs, err := p.TopSongs(ctx, "", "artist-1", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + + It("returns top songs for a known artist by id, falling back to name", func() { + artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + + songs, err := p.TopSongs(ctx, "Artist One", "fake-id", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + }) + + It("bails if lookup by id returns a fatal error", func() { + artistRepo.On("Get", "artist-1").Return(nil, model.ErrInvalidAuth).Once() + songs, err := p.TopSongs(ctx, "Artist One", "artist-1", 2) + Expect(songs).To(BeEmpty()) + Expect(err).To(HaveOccurred()) + }) + + It("bails if lookup by name returns a fatal error", func() { + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(nil, model.ErrInvalidAuth).Once() + songs, err := p.TopSongs(ctx, "Artist One", "", 2) + Expect(songs).To(BeEmpty()) + Expect(err).To(HaveOccurred()) + }) + It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) @@ -96,7 +149,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -116,7 +169,8 @@ var _ = Describe("Provider - TopSongs", func() { ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() // Library track is credited to Artist One (the queried artist) under a same title. If the - // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID + // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() + // could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track. track := model.MediaFile{ ID: "one-track", Title: "Collab Song", ArtistID: "artist-1", @@ -126,7 +180,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) // "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track. @@ -137,7 +191,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock artist not found artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once() - songs, err := p.TopSongs(ctx, "Unknown Artist", 5) + songs, err := p.TopSongs(ctx, "Unknown Artist", "", 5) Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found Expect(songs).To(BeNil()) @@ -154,7 +208,7 @@ var _ = Describe("Provider - TopSongs", func() { agentErr := errors.New("agent error") ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once() - songs, err := p.TopSongs(ctx, "Artist One", 5) + songs, err := p.TopSongs(ctx, "Artist One", "", 5) Expect(err).To(MatchError(agentErr)) Expect(songs).To(BeNil()) @@ -170,7 +224,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock agent ErrNotFound ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once() - songs, err := p.TopSongs(ctx, "Artist One", 5) + songs, err := p.TopSongs(ctx, "Artist One", "", 5) Expect(err).To(MatchError(model.ErrNotFound)) Expect(songs).To(BeNil()) @@ -191,7 +245,7 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -220,7 +274,7 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -242,7 +296,7 @@ var _ = Describe("Provider - TopSongs", func() { ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once() cancel() // Cancel the context before calling - songs, err := p.TopSongs(canceledCtx, "Artist One", 5) + songs, err := p.TopSongs(canceledCtx, "Artist One", "", 5) Expect(err).To(MatchError(context.Canceled)) Expect(songs).To(BeNil()) @@ -276,7 +330,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -313,7 +367,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -341,7 +395,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -369,7 +423,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -397,7 +451,7 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index f6a7047c4..59d267321 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -381,13 +381,14 @@ func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) + id, idErr := p.String("id") artist, err := p.String("artist") - if err != nil { + if err != nil && idErr != nil { return nil, err } count := p.IntOr("count", 50) - songs, err := api.provider.TopSongs(ctx, artist, count) + songs, err := api.provider.TopSongs(ctx, artist, id, count) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 4e9039d68..97d7fb40a 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -353,7 +353,7 @@ func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFil return nil, nil } -func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { +func (n noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { return nil, nil } diff --git a/server/subsonic/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go index 55aeb8e9e..992f9e0fb 100644 --- a/server/subsonic/e2e/subsonic_browsing_test.go +++ b/server/subsonic/e2e/subsonic_browsing_test.go @@ -13,6 +13,15 @@ var _ = Describe("Browsing Endpoints", func() { setupTestDB() }) + getBeatlesId := func() string { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + return artists[0].ID + } + Describe("getMusicFolders", func() { It("returns the configured music library", func() { resp := doReq("getMusicFolders") @@ -85,12 +94,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getMusicDirectory", func() { It("returns an artist directory with its albums as children", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID + beatlesID := getBeatlesId() resp := doReq("getMusicDirectory", "id", beatlesID) @@ -125,12 +129,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtist", func() { It("returns artist with albums in ID3 format", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID + beatlesID := getBeatlesId() resp := doReq("getArtist", "id", beatlesID) @@ -141,12 +140,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns album names for the artist", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID + beatlesID := getBeatlesId() resp := doReq("getArtist", "id", beatlesID) @@ -381,13 +375,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtistInfo", func() { It("returns artist info for a valid artist", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID - + beatlesID := getBeatlesId() resp := doReq("getArtistInfo", "id", beatlesID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -397,13 +385,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtistInfo2", func() { It("returns artist info2 for a valid artist", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID - + beatlesID := getBeatlesId() resp := doReq("getArtistInfo2", "id", beatlesID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -426,6 +408,28 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.TopSongs).ToNot(BeNil()) Expect(resp.TopSongs.Song).To(BeEmpty()) }) + + It("returns an error if no arguments are specified", func() { + resp := doReq("getTopSongs") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.TopSongs).To(BeNil()) + }) + + It("returns a response only by id", func() { + beatlesID := getBeatlesId() + resp := doReq("getTopSongs", "id", beatlesID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TopSongs).ToNot(BeNil()) + }) + + It("returns a response when both id and name are included", func() { + beatlesID := getBeatlesId() + resp := doReq("getTopSongs", "id", beatlesID, "artist", "The Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TopSongs).ToNot(BeNil()) + }) }) Describe("getSimilarSongs", func() { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 97b3cafcc..2b2a31bf3 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -15,6 +15,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, + {Name: "topSongsByArtistId", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index e4217303f..2615a652d 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -55,13 +55,14 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(6), + HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), )) Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), @@ -85,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(7), + HaveLen(8), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), @@ -93,6 +94,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), )) }) }) From f853ca604a534e6916ea9a2e2bda5a27dc185bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 2 Aug 2026 12:58:53 -0400 Subject: [PATCH 404/596] refactor(db): migrate all ids to a uniform canonical 128-bit base62 encoding (#5824) * refactor(model): extract canonical 128-bit base62 id codec * feat(model): generate random ids as canonical 128-bit base62 values * feat(scanner): emit legacy PIDs in canonical base62 encoding * feat(db): add id canonicalization transform for the uniform-ids migration * feat(db): migrate all ids to canonical 128-bit base62 encoding * fix(db): canonicalize ids in junction tables and JSON columns * chore(jellyfin): update id-family notes for uniform canonical ids * test(ids): harden codec input contract and migration edge coverage * refactor(model): use log.Fatal for Encode128 contract guard per project convention * fix(db): force full rescan after id migration for legacy PID configs * test(db): guard id-column inventory against schema drift * refactor(ids): compile-time Encode128 contract and unified column rewrite helper * refactor(db): apply review feedback to id migration Filter empty strings in collectColumn's SQL, reuse a prepared statement for rewriteColumn updates, and clarify the legacy ID functions' comment now that they emit the canonical encoding. * feat(auth): split session and public-link JWT secrets, rotating sessions on id migration * test(subsonic): initialize public token secret in helpers suite The suite sets auth.TokenAuth directly instead of calling auth.Init, so the new PublicTokenAuth was nil whenever Ginkgo's spec order ran a helpers spec before any spec that calls auth.Init, panicking in publicurl.ImageURL. * refactor(db): inline canonicalID into its only consumer, the uniform-ids migration * refactor(model): rename Encode128/Decode128 to Encode/Decode With every id now exactly 128 bits, the width suffix is redundant; the package-qualified id.Encode/id.Decode carries the same information. * test(db): make the id-columns guard classify JSON columns too The guard only inspected columns named id/pid/*_id, so it could not see ids embedded in JSON. Widen it to *_ids and to every JSON column, and drive the "covered" set from a new embeddedIDColumns list instead of the inline calls in the migration. Every JSON column the schema has now carries a verdict. The four denormalized caches -- media_file/album.participants, media_file/album.tags, album.folder_ids and artist.similar_artists -- hold only artist, tag and folder ids. Those all come from id.NewHash, whose 22-char base62 encoding of a 128-bit MD5 is already in canonical range, so canonicalID is the identity on them and the migration correctly leaves them alone. A new codec test pins that invariant, since the exemptions depend on it. Verified on a copy of a 727MB/96k-track production database: canonicalizing those four columns changed zero rows, and artist, tag and folder ids were themselves unchanged by the migration (only media_file ids moved, 95108 of 96666). --- consts/consts.go | 1 + core/auth/auth.go | 53 +-- core/auth/auth_test.go | 45 +++ core/publicurl/publicurl_test.go | 2 +- .../20260720015443_uniform_canonical_ids.go | 310 ++++++++++++++++++ db/migrations/id_canonical_test.go | 47 +++ db/migrations/id_columns_guard_test.go | 119 +++++++ db/migrations/migrations_suite_test.go | 16 + db/migrations/uniform_canonical_ids_test.go | 233 +++++++++++++ model/id/id.go | 36 +- model/id/id_suite_test.go | 17 + model/id/id_test.go | 64 ++++ model/metadata/legacy_ids.go | 15 +- model/metadata/persistent_ids_test.go | 15 + plugins/host_artwork_test.go | 2 +- server/jellyfin/README.md | 16 +- server/jellyfin/dto/ids.go | 2 +- server/jellyfin/dto/ids_test.go | 2 +- server/jellyfin/truncated_ids.go | 4 +- server/public/handle_images.go | 2 +- server/public/handle_images_test.go | 2 +- server/public/handle_shares.go | 4 +- server/public/handle_streams.go | 2 +- server/public/handle_streams_test.go | 6 +- server/subsonic/helpers_test.go | 1 + 25 files changed, 957 insertions(+), 59 deletions(-) create mode 100644 db/migrations/20260720015443_uniform_canonical_ids.go create mode 100644 db/migrations/id_canonical_test.go create mode 100644 db/migrations/id_columns_guard_test.go create mode 100644 db/migrations/migrations_suite_test.go create mode 100644 db/migrations/uniform_canonical_ids_test.go create mode 100644 model/id/id_suite_test.go create mode 100644 model/id/id_test.go diff --git a/consts/consts.go b/consts/consts.go index f453ac125..e1a535c79 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -28,6 +28,7 @@ const ( UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" JWTSecretKey = "JWTSecret" + JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year diff --git a/core/auth/auth.go b/core/auth/auth.go index 7b3511bdf..b1e2667bd 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -19,35 +19,41 @@ import ( ) var ( - once sync.Once + once sync.Once + // TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die. TokenAuth *jwtauth.JWTAuth + // PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation. + PublicTokenAuth *jwtauth.JWTAuth ) -// Init creates a JWTAuth object from the secret stored in the DB. -// If the secret is not found, it will create a new one and store it in the DB. +// Init creates the JWTAuth objects from the secrets stored in the DB. +// Missing or undecryptable secrets are regenerated and stored. func Init(ds model.DataStore) { once.Do(func() { ctx := context.TODO() log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout) - secret, err := ds.Property(ctx).Get(consts.JWTSecretKey) - if err != nil || secret == "" { - log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions") - secret = createNewSecret(ctx, ds) - } else { - if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { - log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err) - secret = createNewSecret(ctx, ds) - } - } - - TokenAuth = jwtauth.New("HS256", []byte(secret), nil) + TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil) + PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil) }) } +func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string { + secret, err := ds.Property(ctx).Get(key) + if err != nil || secret == "" { + log.Info(ctx, "Creating new JWT secret", "key", key) + return createNewSecret(ctx, ds, key) + } + if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { + log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) + return createNewSecret(ctx, ds, key) + } + return secret +} + func CreatePublicToken(claims Claims) (string, error) { claims.Issuer = consts.JWTIssuer - _, token, err := TokenAuth.Encode(claims.ToMap()) + _, token, err := PublicTokenAuth.Encode(claims.ToMap()) return token, err } @@ -56,7 +62,7 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { if !exp.IsZero() { claims.ExpiresAt = exp } - _, token, err := TokenAuth.Encode(claims.ToMap()) + _, token, err := PublicTokenAuth.Encode(claims.ToMap()) return token, err } @@ -91,6 +97,15 @@ func Validate(tokenStr string) (Claims, error) { return ClaimsFromToken(token), nil } +// ValidatePublic verifies a public-link token against the public secret. +func ValidatePublic(tokenStr string) (Claims, error) { + token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr) + if err != nil { + return Claims{}, err + } + return ClaimsFromToken(token), nil +} + func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { u, err := ds.User(ctx).FindFirstAdmin() if err != nil { @@ -107,14 +122,14 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { return request.WithUser(ctx, *u) } -func createNewSecret(ctx context.Context, ds model.DataStore) string { +func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { secret := id.NewRandom() encSecret, err := utils.Encrypt(ctx, getEncKey(), secret) if err != nil { log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil { + if err := ds.Property(ctx).Put(key, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } return secret diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 3a3585e53..e5cbb2352 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -89,6 +89,51 @@ var _ = Describe("Auth", func() { }) }) + Describe("Session/Public secret split", func() { + claims := func() map[string]any { + return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()} + } + + It("verifies a session token via Validate but not ValidatePublic", func() { + _, tokenStr, err := auth.TokenAuth.Encode(claims()) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.Validate(tokenStr) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.ValidatePublic(tokenStr) + Expect(err).To(HaveOccurred()) + }) + + It("verifies a public token via ValidatePublic but not Validate", func() { + _, tokenStr, err := auth.PublicTokenAuth.Encode(claims()) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.ValidatePublic(tokenStr) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.Validate(tokenStr) + Expect(err).To(HaveOccurred()) + }) + + It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() { + tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"}) + Expect(err).NotTo(HaveOccurred()) + claims, err := auth.ValidatePublic(tokenStr) + Expect(err).NotTo(HaveOccurred()) + Expect(claims.ID).To(Equal("art-1")) + _, err = auth.Validate(tokenStr) + Expect(err).To(HaveOccurred()) + }) + + It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() { + exp := time.Now().Add(1 * time.Hour) + tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"}) + Expect(err).NotTo(HaveOccurred()) + claims, err := auth.ValidatePublic(tokenStr) + Expect(err).NotTo(HaveOccurred()) + Expect(claims.ID).To(Equal("art-2")) + _, err = auth.Validate(tokenStr) + Expect(err).To(HaveOccurred()) + }) + }) + Describe("TouchToken", func() { It("updates the expiration time", func() { yesterday := time.Now().Add(-oneDay) diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index a195fb9cd..7e9ee8b8e 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -175,7 +175,7 @@ var _ = Describe("Public URL Utilities", func() { BeforeEach(func() { conf.Server.ShareURL = "https://share.example.com" // Initialize JWT auth for token generation - auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) }) It("generates a URL with the artwork token", func() { diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go new file mode 100644 index 000000000..346f22050 --- /dev/null +++ b/db/migrations/20260720015443_uniform_canonical_ids.go @@ -0,0 +1,310 @@ +package migrations + +import ( + "context" + "crypto/md5" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/id" + "github.com/pressly/goose/v3" +) + +// canonicalID maps any historical Navidrome id shape to the canonical 22-char base62 encoding +// of a 128-bit value; unrecognized shapes (including empty and share ids) pass through unchanged. +func canonicalID(s string) string { + switch len(s) { + case 22: + v, ok := new(big.Int).SetString(s, 62) + if !ok || v.Sign() < 0 || v.BitLen() <= 128 { + return s + } + sum := md5.Sum([]byte(s)) + return id.Encode(sum) + case 32: + b, err := hex.DecodeString(s) + if err != nil { + return s + } + return id.Encode([16]byte(b)) + case 36: + if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { + return s + } + b, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:]) + if err != nil { + return s + } + return id.Encode([16]byte(b)) + } + return s +} + +func init() { + goose.AddMigrationContext(upUniformCanonicalIds, downUniformCanonicalIds) +} + +// idColumns is the exhaustive list of Navidrome-id-bearing columns; anything absent is deliberately exempt. +var idColumns = []struct{ table, col string }{ + {"media_file", "id"}, {"media_file", "pid"}, {"media_file", "artist_id"}, + {"media_file", "album_id"}, {"media_file", "album_artist_id"}, {"media_file", "folder_id"}, + {"album", "id"}, {"album", "album_artist_id"}, + {"artist", "id"}, + {"folder", "id"}, {"folder", "parent_id"}, + {"tag", "id"}, + {"library_artist", "artist_id"}, + {"user", "id"}, + {"user_props", "user_id"}, + {"playlist", "id"}, {"playlist", "owner_id"}, + {"playlist_tracks", "playlist_id"}, {"playlist_tracks", "media_file_id"}, + {"playlist_fields", "playlist_id"}, + {"annotation", "user_id"}, {"annotation", "item_id"}, + {"bookmark", "user_id"}, {"bookmark", "item_id"}, + {"player", "id"}, {"player", "user_id"}, {"player", "transcoding_id"}, + {"transcoding", "id"}, + {"radio", "id"}, + {"share", "user_id"}, + {"scrobble_buffer", "id"}, {"scrobble_buffer", "user_id"}, {"scrobble_buffer", "media_file_id"}, + {"playqueue", "id"}, {"playqueue", "user_id"}, + {"user_library", "user_id"}, + {"scrobbles", "user_id"}, {"scrobbles", "media_file_id"}, + {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, + {"album_artists", "album_id"}, {"album_artists", "artist_id"}, + {"library_tag", "tag_id"}, +} + +// embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this +// list against the schema, so every JSON column must appear here or be exempted there. +var embeddedIDColumns = []struct { + table, col string + transform func(string) (string, bool) +}{ + {"playqueue", "items", canonicalizeIDList}, + {"share", "resource_ids", canonicalizeIDList}, + {"plugin", "users", canonicalizePluginUsers}, + {"playlist", "rules", canonicalizePlaylistRules}, +} + +func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { + if err := buildIDMap(ctx, tx); err != nil { + return err + } + for _, tc := range idColumns { + if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil { + return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) + } + } + for _, tc := range embeddedIDColumns { + if err := rewriteColumn(ctx, tx, tc.table, tc.col, tc.transform); err != nil { + return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) + } + } + // Legacy PID specs embed old-shaped album/track ids into composite pids; a full rescan + // rewrites every pid with the new encoding so path-based move matching stays consistent. + if strings.Contains(conf.Server.PID.Track, "legacy") || strings.Contains(conf.Server.PID.Album, "legacy") { + if err := forceFullRescan(ctx, tx); err != nil { + return err + } + } + if err := rotateSessionSecret(ctx, tx); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "DROP TABLE _id_map") + return err +} + +// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens +// keep verifying while auth.Init mints a fresh session secret, killing every stale session. +func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, + `UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`, + consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey) + return err +} + +// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins. +func buildIDMap(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, + "CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID") + if err != nil { + return err + } + ins, err := tx.PrepareContext(ctx, "INSERT OR IGNORE INTO _id_map (old_id, new_id) VALUES (?, ?)") + if err != nil { + return err + } + defer ins.Close() + for _, tc := range idColumns { + if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil { + return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err) + } + } + return nil +} + +func collectColumn(ctx context.Context, tx *sql.Tx, ins *sql.Stmt, table, col string) error { + rows, err := tx.QueryContext(ctx, fmt.Sprintf( + "SELECT DISTINCT %[2]s FROM %[1]s WHERE %[2]s IS NOT NULL AND %[2]s <> ''", table, col)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var old string + if err := rows.Scan(&old); err != nil { + return err + } + if newID := canonicalID(old); newID != old { + if _, err := ins.ExecContext(ctx, old, newID); err != nil { + return err + } + } + } + return rows.Err() +} + +func applyIDMap(ctx context.Context, tx *sql.Tx, table, col string) error { + _, err := tx.ExecContext(ctx, fmt.Sprintf( + `UPDATE %[1]s SET %[2]s = (SELECT new_id FROM _id_map WHERE old_id = %[1]s.%[2]s) + WHERE %[2]s IN (SELECT old_id FROM _id_map)`, table, col)) + return err +} + +// canonicalizeIDList canonicalizes a comma-separated id list element-wise. +func canonicalizeIDList(s string) (string, bool) { + parts := strings.Split(s, ",") + changed := false + for i, p := range parts { + if n := canonicalID(p); n != p { + parts[i] = n + changed = true + } + } + if !changed { + return s, false + } + return strings.Join(parts, ","), true +} + +// rewriteColumn applies transform to each non-empty cell, updating only rows it changed. +func rewriteColumn(ctx context.Context, tx *sql.Tx, table, col string, transform func(string) (string, bool)) error { + rows, err := tx.QueryContext(ctx, fmt.Sprintf( + "SELECT rowid, %[2]s FROM %[1]s WHERE ifnull(%[2]s, '') <> ''", table, col)) + if err != nil { + return err + } + type change struct { + rowid int64 + val string + } + var changes []change + for rows.Next() { + var rowid int64 + var val string + if err := rows.Scan(&rowid, &val); err != nil { + _ = rows.Close() + return err + } + if newVal, ok := transform(val); ok { + changes = append(changes, change{rowid, newVal}) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + _ = rows.Close() + if len(changes) == 0 { + return nil + } + upd, err := tx.PrepareContext(ctx, fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, col)) + if err != nil { + return err + } + defer upd.Close() + for _, c := range changes { + if _, err := upd.ExecContext(ctx, c.val, c.rowid); err != nil { + return err + } + } + return nil +} + +// canonicalizePluginUsers maps a JSON array of user ids; malformed JSON passes through untouched. +func canonicalizePluginUsers(s string) (string, bool) { + var users []string + if err := json.Unmarshal([]byte(s), &users); err != nil { + return s, false + } + changed := false + for i, u := range users { + if n := canonicalID(u); n != u { + users[i] = n + changed = true + } + } + if !changed { + return s, false + } + out, err := json.Marshal(users) + if err != nil { + return s, false + } + return string(out), true +} + +// canonicalizePlaylistRules rewrites inPlaylist/notInPlaylist ids in smart-playlist criteria; malformed JSON passes through. +func canonicalizePlaylistRules(s string) (string, bool) { + var root map[string]any + if err := json.Unmarshal([]byte(s), &root); err != nil { + return s, false + } + if !canonicalizeRulesNode(root) { + return s, false + } + out, err := json.Marshal(root) + if err != nil { + return s, false + } + return string(out), true +} + +// canonicalizeRulesNode walks the criteria tree, canonicalizing the id of any inPlaylist/notInPlaylist object. +func canonicalizeRulesNode(node any) bool { + changed := false + switch v := node.(type) { + case map[string]any: + for k, val := range v { + if lk := strings.ToLower(k); lk == "inplaylist" || lk == "notinplaylist" { + if obj, ok := val.(map[string]any); ok { + if id, ok := obj["id"].(string); ok { + if n := canonicalID(id); n != id { + obj["id"] = n + changed = true + } + } + } + } + if canonicalizeRulesNode(val) { + changed = true + } + } + case []any: + for _, item := range v { + if canonicalizeRulesNode(item) { + changed = true + } + } + } + return changed +} + +func downUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { + return nil // irreversible data migration +} diff --git a/db/migrations/id_canonical_test.go b/db/migrations/id_canonical_test.go new file mode 100644 index 000000000..0befbe469 --- /dev/null +++ b/db/migrations/id_canonical_test.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "strings" + + "github.com/navidrome/navidrome/model/id" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("canonicalID", func() { + DescribeTable("transforms each historical id shape", + func(in, want string) { + Expect(canonicalID(in)).To(Equal(want)) + }, + Entry("hash-family id (fits 128 bits) is kept", "5cLJPkLA5DK2BADhoeotPk", "5cLJPkLA5DK2BADhoeotPk"), + Entry("overflowing random id is remapped via md5", "zzzzzzzzzzzzzzzzzzzzzz", "3LyqmwQBm5IRqlVjNYASwb"), + Entry("legacy 32-hex is re-encoded value-preserving", "e3b7fc2ae9447bbec37a13bf916e3cf6", "6VHl3uR4kss6sUPKA8Cwnk"), + Entry("playlist uuid is re-encoded value-preserving", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "7rke2SAWaicSeSYzkhww6R"), + Entry("empty string passes through", "", ""), + Entry("share id (10 chars) passes through", "aB3xY9kQz1", "aB3xY9kQz1"), + Entry("truncated Finamp id (16 chars) passes through", "0123456789abcdef", "0123456789abcdef"), + Entry("22 chars with non-base62 char passes through", "!!!!!!!!!!!!!!!!!!!!!!", "!!!!!!!!!!!!!!!!!!!!!!"), + Entry("32 chars non-hex passes through", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), + Entry("36 chars without uuid dashes passes through", "000000000000000000000000000000000000", "000000000000000000000000000000000000"), + ) + + // The exemptions for participants/tags/folder_ids/similar_artists rest on this invariant. + It("is the identity on every NewHash id", func() { + for _, parts := range [][]string{ + {""}, {"a"}, {"The Beatles"}, {"genre", "electronic"}, + {"/music/Artist/Album", "1"}, {strings.Repeat("x", 500)}, + } { + h := id.NewHash(parts...) + Expect(h).To(HaveLen(22)) + Expect(canonicalID(h)).To(Equal(h), "NewHash(%v) = %q was rewritten", parts, h) + } + }) + + It("is idempotent for every shape", func() { + for _, s := range []string{"5cLJPkLA5DK2BADhoeotPk", "zzzzzzzzzzzzzzzzzzzzzz", + "e3b7fc2ae9447bbec37a13bf916e3cf6", "f47ac10b-58cc-4372-a567-0e02b2c3d479"} { + once := canonicalID(s) + Expect(canonicalID(once)).To(Equal(once)) + } + }) +}) diff --git a/db/migrations/id_columns_guard_test.go b/db/migrations/id_columns_guard_test.go new file mode 100644 index 000000000..c4cb3b241 --- /dev/null +++ b/db/migrations/id_columns_guard_test.go @@ -0,0 +1,119 @@ +package migrations + +import ( + "context" + "database/sql" + "os" + "strings" + + _ "github.com/mattn/go-sqlite3" + "github.com/pressly/goose/v3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Version of the uniform-canonical-ids migration whose idColumns list this guard protects. +const idColumnsMigrationVersion = 20260720015443 + +var _ = Describe("idColumns inventory", func() { + It("covers every id-bearing column present at the migration version", func() { + ctx := context.Background() + db, err := sql.Open("sqlite3", "file::memory:") + Expect(err).ToNot(HaveOccurred()) + db.SetMaxOpenConns(1) // non-shared :memory: — a second conn would be an empty DB + DeferCleanup(func() { _ = db.Close() }) + _, err = db.ExecContext(ctx, "PRAGMA foreign_keys=off") + Expect(err).ToNot(HaveOccurred()) + + goose.SetBaseFS(os.DirFS(".")) + DeferCleanup(func() { goose.SetBaseFS(nil) }) + Expect(goose.SetDialect("sqlite3")).To(Succeed()) + Expect(goose.UpToContext(ctx, db, ".", idColumnsMigrationVersion)).To(Succeed()) + + covered := map[string]bool{} + for _, tc := range idColumns { + covered[tc.table+"."+tc.col] = true + } + for _, tc := range embeddedIDColumns { + covered[tc.table+"."+tc.col] = true + } + // Columns that are id-named or JSON but need no rewrite. + exempt := map[string]string{ + "share.id": "public share URLs, generated separately", + "property.id": "property key, not an entity id", + "plugin.id": "plugin name, not an entity id", + "media_file.participants": "hash-family artist ids, unchanged", + "album.participants": "hash-family artist ids, unchanged", + "media_file.tags": "hash-family tag ids, unchanged", + "album.tags": "hash-family tag ids, unchanged", + "album.folder_ids": "hash-family folder ids, unchanged", + "artist.similar_artists": "hash-family artist ids, unchanged", + "media_file.search_participants": "participant names for FTS, not ids", + "album.search_participants": "participant names for FTS, not ids", + "album.discs": "disc number -> title map", + "media_file.lyrics": "synced lyrics, no ids", + "folder.image_files": "image file names, no ids", + "plugin.manifest": "plugin-authored manifest, no Navidrome ids", + "plugin.config": "free-form plugin config, must not be rewritten", + "plugin.libraries": "integer library ids", + } + + tables, err := queryColumn(ctx, db, "SELECT name FROM sqlite_master WHERE type='table'") + Expect(err).ToNot(HaveOccurred()) + + var dangling []string + for _, table := range tables { + if strings.HasPrefix(table, "sqlite_") || table == "goose_db_version" || strings.Contains(table, "_fts") { + continue + } + rows, err := db.QueryContext(ctx, "SELECT name, type FROM pragma_table_info(?)", table) + Expect(err).ToNot(HaveOccurred()) + for rows.Next() { + var name, typ string + Expect(rows.Scan(&name, &typ)).To(Succeed()) + lname := strings.ToLower(name) + utyp := strings.ToUpper(typ) + // JSON can hide an id under any key, so every JSON column needs a verdict. + isJSON := strings.Contains(utyp, "JSON") + isIDName := lname == "id" || lname == "pid" || + strings.HasSuffix(lname, "_id") || strings.HasSuffix(lname, "_ids") + if !isJSON && !isIDName { + continue + } + if !isJSON && !strings.Contains(utyp, "TEXT") && !strings.Contains(utyp, "CHAR") { + continue // INTEGER ids (rowid PKs, library.id) are not canonical ids + } + if strings.HasPrefix(lname, "mbz_") { + continue // MusicBrainz UUIDs, not Navidrome ids + } + key := table + "." + name + if covered[key] || exempt[key] != "" { + continue + } + dangling = append(dangling, key) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + Expect(rows.Close()).To(Succeed()) + } + Expect(dangling).To(BeEmpty(), + "id-bearing columns missing from idColumns; add them to the migration or exempt with a reason: %v", dangling) + }) +}) + +func queryColumn(ctx context.Context, db *sql.DB, query string) ([]string, error) { + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/db/migrations/migrations_suite_test.go b/db/migrations/migrations_suite_test.go new file mode 100644 index 000000000..bb08ad8aa --- /dev/null +++ b/db/migrations/migrations_suite_test.go @@ -0,0 +1,16 @@ +package migrations + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// tests.Init is omitted: the tests package imports db, which imports this package. +func TestMigrations(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Migrations Suite") +} diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go new file mode 100644 index 000000000..ced75cb7e --- /dev/null +++ b/db/migrations/uniform_canonical_ids_test.go @@ -0,0 +1,233 @@ +package migrations + +import ( + "context" + "database/sql" + "encoding/json" + + _ "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("upUniformCanonicalIds", func() { + var db *sql.DB + var tx *sql.Tx + ctx := context.Background() + + const ( + hashID = "5cLJPkLA5DK2BADhoeotPk" // canonical, kept + randOld = "zzzzzzzzzzzzzzzzzzzzzz" // overflows -> remapped + randNew = "3LyqmwQBm5IRqlVjNYASwb" + legacyOld = "e3b7fc2ae9447bbec37a13bf916e3cf6" // 32-hex -> re-encoded + legacyNew = "6VHl3uR4kss6sUPKA8Cwnk" + uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded + uuidNew = "7rke2SAWaicSeSYzkhww6R" + shareID = "aB3xY9kQz1" // exempt family + + sessionSecret = "encrypted-session-secret-sentinel" + ) + + BeforeEach(func() { + var err error + db, err = sql.Open("sqlite3", "file::memory:") + Expect(err).ToNot(HaveOccurred()) + db.SetMaxOpenConns(1) // non-shared :memory: — every new conn is a fresh empty DB + DeferCleanup(func() { _ = db.Close() }) + + // Minimal fixture: every table/column idColumns and the list rewrites touch. + _, err = db.Exec(` + CREATE TABLE media_file (id text, pid text, artist_id text, album_id text, album_artist_id text, folder_id text, mbz_recording_id text); + CREATE TABLE album (id text, album_artist_id text); + CREATE TABLE artist (id text); + CREATE TABLE folder (id text, parent_id text); + CREATE TABLE tag (id text); + CREATE TABLE library_artist (artist_id text); + CREATE TABLE user (id text); + CREATE TABLE user_props (user_id text); + CREATE TABLE playlist (id text, owner_id text, rules text); + CREATE TABLE playlist_tracks (playlist_id text, media_file_id text); + CREATE TABLE playlist_fields (playlist_id text); + CREATE TABLE annotation (user_id text, item_id text, item_type text); + CREATE TABLE bookmark (user_id text, item_id text); + CREATE TABLE player (id text, user_id text, transcoding_id text); + CREATE TABLE transcoding (id text); + CREATE TABLE radio (id text); + CREATE TABLE share (id text, user_id text, resource_ids text, contents text); + CREATE TABLE scrobble_buffer (id text, user_id text, media_file_id text); + CREATE TABLE playqueue (id text, user_id text, items text); + CREATE TABLE user_library (user_id text, library_id integer); + CREATE TABLE scrobbles (id integer, media_file_id text, user_id text); + CREATE TABLE media_file_artists (media_file_id text, artist_id text); + CREATE TABLE album_artists (album_id text, artist_id text); + CREATE TABLE library_tag (tag_id text, library_id integer); + CREATE TABLE plugin (id text, users text); + CREATE TABLE property (id text primary key, value text); + `) + Expect(err).ToNot(HaveOccurred()) + + seed := func(query string, args ...any) { + _, err := db.Exec(query, args...) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + } + // media_file: legacy id/pid, hash artist, legacy album, mbz uuid must stay untouched + seed(`INSERT INTO media_file VALUES (?, ?, ?, ?, '', '', ?)`, legacyOld, legacyOld, hashID, legacyOld, uuidOld) + seed(`INSERT INTO album VALUES (?, ?)`, legacyOld, hashID) + seed(`INSERT INTO user VALUES (?)`, randOld) + // uuid id, random owner, smart-playlist rules with an embedded inPlaylist id and a sibling operator + seed(`INSERT INTO playlist VALUES (?, ?, ?)`, uuidOld, randOld, `{"all":[{"inPlaylist":{"id":"`+uuidOld+`"}},{"inTheLast":{"lastPlayed":30}}]}`) + seed(`INSERT INTO annotation VALUES (?, ?, 'media_file')`, randOld, legacyOld) + seed(`INSERT INTO playqueue VALUES (?, ?, ?)`, randOld, randOld, legacyOld+","+hashID) + seed(`INSERT INTO share VALUES (?, ?, ?, 'Album Foo...')`, shareID, randOld, legacyOld+","+uuidOld) + seed(`INSERT INTO user_library VALUES (?, 1)`, randOld) + seed(`INSERT INTO scrobbles VALUES (1, ?, ?)`, legacyOld, randOld) + seed(`INSERT INTO media_file_artists VALUES (?, ?)`, legacyOld, hashID) + seed(`INSERT INTO album_artists VALUES (?, ?)`, legacyOld, hashID) + seed(`INSERT INTO library_tag VALUES (?, 1)`, hashID) + seed(`INSERT INTO plugin VALUES ('lastfm', ?)`, `["`+randOld+`","`+hashID+`"]`) + seed(`INSERT INTO plugin VALUES ('empty', '[]')`) // exempt: empty user list untouched + // malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte + seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`) + seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID) + seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret) + }) + + JustBeforeEach(func() { + var err error + tx, err = db.Begin() + Expect(err).ToNot(HaveOccurred()) + Expect(upUniformCanonicalIds(ctx, tx)).To(Succeed()) + Expect(tx.Commit()).To(Succeed()) + }) + + get := func(query string) string { + var s string + ExpectWithOffset(1, db.QueryRow(query).Scan(&s)).To(Succeed()) + return s + } + + It("canonicalizes every id family and keeps references consistent", func() { + Expect(get(`SELECT id FROM media_file`)).To(Equal(legacyNew)) + Expect(get(`SELECT pid FROM media_file`)).To(Equal(legacyNew)) + Expect(get(`SELECT artist_id FROM media_file`)).To(Equal(hashID)) + Expect(get(`SELECT album_id FROM media_file`)).To(Equal(legacyNew)) + Expect(get(`SELECT id FROM album`)).To(Equal(legacyNew)) + Expect(get(`SELECT id FROM user`)).To(Equal(randNew)) + Expect(get(`SELECT id FROM playlist WHERE owner_id='` + randNew + `'`)).To(Equal(uuidNew)) + Expect(get(`SELECT owner_id FROM playlist WHERE id='` + uuidNew + `'`)).To(Equal(randNew)) + Expect(get(`SELECT item_id FROM annotation`)).To(Equal(legacyNew)) + Expect(get(`SELECT user_id FROM annotation`)).To(Equal(randNew)) + }) + + It("rewrites list columns element-wise", func() { + Expect(get(`SELECT items FROM playqueue`)).To(Equal(legacyNew + "," + hashID)) + Expect(get(`SELECT resource_ids FROM share`)).To(Equal(legacyNew + "," + uuidNew)) + }) + + It("leaves exempt columns alone", func() { + Expect(get(`SELECT id FROM share`)).To(Equal(shareID)) + Expect(get(`SELECT contents FROM share`)).To(Equal("Album Foo...")) + Expect(get(`SELECT mbz_recording_id FROM media_file`)).To(Equal(uuidOld)) + }) + + It("canonicalizes junction and membership tables", func() { + Expect(get(`SELECT user_id FROM user_library`)).To(Equal(randNew)) + Expect(get(`SELECT media_file_id FROM scrobbles`)).To(Equal(legacyNew)) + Expect(get(`SELECT user_id FROM scrobbles`)).To(Equal(randNew)) + Expect(get(`SELECT media_file_id FROM media_file_artists`)).To(Equal(legacyNew)) + Expect(get(`SELECT artist_id FROM media_file_artists`)).To(Equal(hashID)) // hash-family, unchanged + Expect(get(`SELECT album_id FROM album_artists`)).To(Equal(legacyNew)) + Expect(get(`SELECT artist_id FROM album_artists`)).To(Equal(hashID)) // hash-family, unchanged + Expect(get(`SELECT tag_id FROM library_tag`)).To(Equal(hashID)) // hash-family, unchanged + }) + + It("rewrites JSON columns element-wise", func() { + Expect(get(`SELECT users FROM plugin WHERE id='lastfm'`)).To(Equal(`["` + randNew + `","` + hashID + `"]`)) + Expect(get(`SELECT id FROM plugin WHERE id='lastfm'`)).To(Equal("lastfm")) // plugin name, untouched + + var rules map[string]any + Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) + all, ok := rules["all"].([]any) + Expect(ok).To(BeTrue()) + Expect(all).To(HaveLen(2)) + inPl := all[0].(map[string]any)["inPlaylist"].(map[string]any) + Expect(inPl["id"]).To(Equal(uuidNew)) + }) + + It("preserves sibling operators alongside a rewritten inPlaylist id", func() { + var rules map[string]any + Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) + all := rules["all"].([]any) + var sawInPlaylist, sawInTheLast bool + for _, e := range all { + op := e.(map[string]any) + if pl, ok := op["inPlaylist"].(map[string]any); ok { + Expect(pl["id"]).To(Equal(uuidNew)) + sawInPlaylist = true + } + if last, ok := op["inTheLast"].(map[string]any); ok { + Expect(last["lastPlayed"]).To(Equal(float64(30))) + sawInTheLast = true + } + } + Expect(sawInPlaylist).To(BeTrue()) + Expect(sawInTheLast).To(BeTrue()) + }) + + It("leaves exempt JSON rows untouched", func() { + Expect(get(`SELECT users FROM plugin WHERE id='empty'`)).To(Equal("[]")) + }) + + It("passes malformed JSON columns through byte-for-byte", func() { + Expect(get(`SELECT users FROM plugin WHERE id='broken'`)).To(Equal("not-json")) + Expect(get(`SELECT rules FROM playlist WHERE id='` + hashID + `'`)).To(Equal("{broken")) + }) + + rescanCount := func() int { + var count int + ExpectWithOffset(1, db.QueryRow( + `SELECT count(*) FROM property WHERE id = ?`, consts.FullScanAfterMigrationFlagKey).Scan(&count)).To(Succeed()) + return count + } + + It("does not force a full rescan for the default PID config", func() { + Expect(rescanCount()).To(Equal(0)) + }) + + Context("with a legacy PID configuration", func() { + BeforeEach(func() { + prev := conf.Server.PID.Album + conf.Server.PID.Album = "album_legacy" + DeferCleanup(func() { conf.Server.PID.Album = prev }) + }) + + It("forces a full rescan so composite pids are rewritten", func() { + Expect(rescanCount()).To(Equal(1)) + }) + }) + + propCount := func(key string) int { + var count int + ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed()) + return count + } + + It("rotates the session secret to the public key", func() { + Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) + Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret)) + }) + + Context("with no stored session secret", func() { + BeforeEach(func() { + _, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey) + Expect(err).ToNot(HaveOccurred()) + }) + + It("is a no-op", func() { + Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) + Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0)) + }) + }) +}) diff --git a/model/id/id.go b/model/id/id.go index b54542898..2885bc6ad 100644 --- a/model/id/id.go +++ b/model/id/id.go @@ -2,20 +2,36 @@ package id import ( "crypto/md5" + "crypto/rand" "fmt" "math/big" "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/nanoid" ) func NewRandom() string { - id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22) - if err != nil { - log.Error("Could not generate new ID", err) + var b [16]byte + _, _ = rand.Read(b[:]) + return Encode(b) +} + +// Encode renders a 16-byte value as the canonical 22-char zero-padded base62 id. +func Encode(b [16]byte) string { + return fmt.Sprintf("%022s", new(big.Int).SetBytes(b[:]).Text(62)) +} + +// Decode is the exact inverse of Encode. +func Decode(s string) ([]byte, error) { + if len(s) != 22 { + return nil, fmt.Errorf("invalid id length %d", len(s)) } - return id + v, ok := new(big.Int).SetString(s, 62) + if !ok || v.Sign() < 0 { + return nil, fmt.Errorf("invalid base62 id %q", s) + } + if v.BitLen() > 128 { + return nil, fmt.Errorf("id %q overflows 128 bits", s) + } + return v.FillBytes(make([]byte, 16)), nil } func NewHash(data ...string) string { @@ -24,11 +40,7 @@ func NewHash(data ...string) string { hash.Write([]byte(d)) hash.Write([]byte(string('\u200b'))) } - h := hash.Sum(nil) - bi := big.NewInt(0) - bi.SetBytes(h) - s := bi.Text(62) - return fmt.Sprintf("%022s", s) + return Encode([16]byte(hash.Sum(nil))) } func NewTagID(name, value string) string { diff --git a/model/id/id_suite_test.go b/model/id/id_suite_test.go new file mode 100644 index 000000000..e8a41f4e5 --- /dev/null +++ b/model/id/id_suite_test.go @@ -0,0 +1,17 @@ +package id_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestID(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ID Suite") +} diff --git a/model/id/id_test.go b/model/id/id_test.go new file mode 100644 index 000000000..407560a75 --- /dev/null +++ b/model/id/id_test.go @@ -0,0 +1,64 @@ +package id_test + +import ( + "github.com/navidrome/navidrome/model/id" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Encode/Decode", func() { + It("encodes 16 bytes as 22-char zero-padded base62", func() { + Expect(id.Encode([16]byte{})).To(Equal("0000000000000000000000")) + allFF := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + Expect(id.Encode(allFF)).To(Equal("7N42dgm5tFLK9N8MT7fHC7")) + }) + + It("round-trips arbitrary 16-byte values", func() { + b := [16]byte{0xe3, 0xb7, 0xfc, 0x2a, 0xe9, 0x44, 0x7b, 0xbe, + 0xc3, 0x7a, 0x13, 0xbf, 0x91, 0x6e, 0x3c, 0xf6} + s := id.Encode(b) + Expect(s).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) + Expect(id.Decode(s)).To(Equal(b[:])) + }) + + It("rejects invalid input", func() { + _, err := id.Decode("short") + Expect(err).To(HaveOccurred()) + _, err = id.Decode("!!!!!!!!!!!!!!!!!!!!!!") // 22 chars, not base62 + Expect(err).To(HaveOccurred()) + _, err = id.Decode("-000000000000000000001") // sign is not part of the alphabet + Expect(err).To(HaveOccurred()) + _, err = id.Decode("zzzzzzzzzzzzzzzzzzzzzz") // > 2^128 + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("NewRandom", func() { + It("emits 22-char canonical ids that always fit 128 bits", func() { + seen := make(map[string]struct{}) + for range 1000 { + s := id.NewRandom() + Expect(s).To(HaveLen(22)) + _, err := id.Decode(s) + Expect(err).ToNot(HaveOccurred(), "id %q must decode to 128 bits", s) + seen[s] = struct{}{} + } + Expect(seen).To(HaveLen(1000)) + }) +}) + +var _ = Describe("NewHash", func() { + It("keeps its historical output byte-for-byte (golden)", func() { + Expect(id.NewHash("test")).To(Equal("5cLJPkLA5DK2BADhoeotPk")) + Expect(id.NewHash("[unknown artist]")).To(Equal("7lsE5pS09fPS1VuFqwXbia")) + Expect(id.NewTagID("genre", "electronic")).To(Equal("7bLYq0Np81m1Wgy5N31nuG")) + }) + + It("always emits 22 decodable chars", func() { + h := id.NewHash("anything", "at", "all") + Expect(h).To(HaveLen(22)) + _, err := id.Decode(h) + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go index 18a273550..7156c4f50 100644 --- a/model/metadata/legacy_ids.go +++ b/model/metadata/legacy_ids.go @@ -9,17 +9,19 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" ) -// These are the legacy ID functions that were used in the original Navidrome ID generation. -// They are kept here for backwards compatibility with existing databases. +// These legacy ID functions hash the same inputs as the original Navidrome ID generation, +// now emitted in the canonical base62 encoding (matching what the uniform-ids migration stores). func legacyTrackID(mf model.MediaFile, prependLibId bool) string { - id := mf.Path + key := mf.Path if prependLibId && mf.LibraryID != model.DefaultLibraryID { - id = fmt.Sprintf("%d\\%s", mf.LibraryID, id) + key = fmt.Sprintf("%d\\%s", mf.LibraryID, key) } - return fmt.Sprintf("%x", md5.Sum([]byte(id))) + sum := md5.Sum([]byte(key)) + return id.Encode(sum) } func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { @@ -33,7 +35,8 @@ func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { if prependLibId && mf.LibraryID != model.DefaultLibraryID { albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath) } - return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) + sum := md5.Sum([]byte(albumPath)) + return id.Encode(sum) } func legacyMapAlbumArtistName(md Metadata) string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index eb66d11d1..8e38bbd42 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -218,6 +218,21 @@ var _ = Describe("getPID", func() { }) Context("legacy specs", func() { + It("emits canonical 22-char base62 ids", func() { + mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 1} + // md5("/music/a.mp3") = e3b7fc2ae9447bbec37a13bf916e3cf6 re-encoded as base62 + Expect(legacyTrackID(mf, false)).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) + }) + It("prepends the library id for a non-default library", func() { + mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 2} + // id.Encode(md5.Sum([]byte("2\\/music/a.mp3"))) + Expect(legacyTrackID(mf, true)).To(Equal("4EK5DHQBMeFuDHw6S3iooO")) + }) + It("emits a canonical album id (golden)", func() { + mf := model.MediaFile{LibraryID: 1} + // id.Encode(md5.Sum([]byte("[unknown artist]\\[unknown album]"))) + Expect(legacyAlbumID(mf, Metadata{}, false)).To(Equal("6xBmxSAUFJSQuW7UvwCq8X")) + }) Context("track_legacy", func() { When("library ID is default (1)", func() { It("should not prepend library ID even when prependLibId is true", func() { diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index ed8a0e810..218d3d892 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -225,7 +225,7 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID { } // Decode JWT token - token, err := auth.TokenAuth.Decode(tokenPart) + token, err := auth.PublicTokenAuth.Decode(tokenPart) Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") c := auth.ClaimsFromToken(token) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 17d2797ab..13ec79aa8 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -90,9 +90,9 @@ skipped, so it doesn't create a nameless player. Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`, -which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id -from an old migrated library is itself valid hex, correctness depends on every emit path encoding -and every receive path decoding — see `dto/ids.go`. +which chokes on Navidrome's base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can +itself be valid hex, correctness depends on every emit path encoding and every receive path +decoding — see `dto/ids.go`. ## Multi-library behavior @@ -184,13 +184,13 @@ their Navidrome `ArtworkID`. Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16 -bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into -GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for -those **truncated ids** back when restoring the queue — item lookups, then streaming, images, -favorites and playback reports for the restored tracks. +bytes. Navidrome ids are 22-character base62 strings, not 32-hex GUIDs, which means Finamp silently +stores only the first 16 characters of each id and asks for those **truncated ids** back when +restoring the queue — item lookups, then streaming, images, favorites and playback reports for the +restored tracks. This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome -id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; +id uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as requested**, because Finamp matches restored items back to its stored ids, and the other item endpoints accept truncated ids transparently. diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go index 3490ba260..2ac7e2a79 100644 --- a/server/jellyfin/dto/ids.go +++ b/server/jellyfin/dto/ids.go @@ -3,7 +3,7 @@ package dto import "encoding/hex" // EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g. -// Finamp's queue packing) and crash on Navidrome's base62 nanoids if emitted as-is. +// Finamp's queue packing) and crash on Navidrome's base62 ids if emitted as-is. func EncodeID(id string) string { if id == "" { return "" diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go index 26a957604..d85786d99 100644 --- a/server/jellyfin/dto/ids_test.go +++ b/server/jellyfin/dto/ids_test.go @@ -6,7 +6,7 @@ import ( ) var _ = Describe("id codec", func() { - It("round-trips a base62 nanoid through Encode/Decode", func() { + It("round-trips a base62 id", func() { id := "5QFKvMsJrd57QE2Le2dKKo" Expect(DecodeID(EncodeID(id))).To(Equal(id)) }) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go index b58296391..bb3e4ce9b 100644 --- a/server/jellyfin/truncated_ids.go +++ b/server/jellyfin/truncated_ids.go @@ -11,8 +11,8 @@ import ( ) // truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming -// Jellyfin GUIDs). No Navidrome id family is 16 chars (nanoid=22, legacy MD5=32, playlist -// UUID=36), so the length alone identifies a truncated id. See README. +// Jellyfin GUIDs). All Navidrome ids are 22 chars (share ids 10), so length alone flags a +// truncated id. See README. // // Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers // and ParentId scoping don't (a restored queue never edits playlists or browses by container id). diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 50f9238e5..633665e0f 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -68,7 +68,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { } func decodeArtworkID(tokenString string) (model.ArtworkID, error) { - token, err := auth.TokenAuth.Decode(tokenString) + token, err := auth.PublicTokenAuth.Decode(tokenString) if err != nil { return model.ArtworkID{}, err } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 6895241f6..669f879a6 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -9,7 +9,7 @@ import ( var _ = Describe("decodeArtworkID", func() { BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) }) It("fails to decode an invalid token", func() { diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 76f674483..13a7e4c32 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -111,8 +111,8 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { // admin flag) and grants access to nothing beyond the share it belongs to; the // stream handler still verifies the share exists, is unexpired, and that the // track is actually a member of it. An attacker who can forge these tokens -// necessarily already holds the signing secret, which also signs real user -// sessions, so that scenario is out of scope for the share boundary specifically. +// necessarily already holds the public-link signing secret, a full-server +// compromise that is out of scope for the share boundary specifically. func encodeMediafileShare(s model.Share, id string) string { claims := auth.Claims{ ID: id, diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 15abab693..3d624f661 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -107,7 +107,7 @@ func shareContainsTrack(share *model.Share, mediaFileID string) bool { // public-share capability, not an auth credential; see encodeMediafileShare for // why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - c, err := auth.Validate(tokenString) + c, err := auth.ValidatePublic(tokenString) if err != nil { return shareTrackInfo{}, err } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 2f32ea6f2..870dfa8ef 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -29,7 +29,7 @@ func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream var _ = Describe("decodeStreamInfo", func() { BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) }) It("decodes a valid token with all fields", func() { @@ -81,7 +81,7 @@ var _ = Describe("decodeStreamInfo", func() { var _ = Describe("encodeMediafileShare", func() { BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) }) It("includes the share ID in the token", func() { @@ -113,7 +113,7 @@ var _ = Describe("handleStream", func() { var pub *Router BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) ds = &tests.MockDataStore{} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 3741462a2..8db624649 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -22,6 +22,7 @@ var _ = Describe("helpers", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test public secret"), nil) }) Describe("fakePath", func() { From 279ff98e0d5f0639077f730083b0c3fd498c800e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 2 Aug 2026 19:46:05 -0400 Subject: [PATCH 405/596] fix(ui): stop precaching index.html so logins can't show the wrong user (#5882) * fix(ui): stop precaching index.html so logins can't show the wrong user index.html is rendered per-user by the server: it carries __APP_CONFIG__, including the auth payload (user id, name, role, Subsonic token) when authentication comes from a reverse-proxy header. Workbox precached it, and because precacheAndRoute registers its route before the NetworkOnly NavigationRoute, every navigation to /app/ was answered from Cache Storage with a frozen copy of whoever installed the service worker. With ExtAuth, signing out and back in as a different user therefore kept showing the previous user, along with their Subsonic token. Cache Storage ignores the no-store header serve_index.go already sets, and ignores the browser's "disable cache", so only clearing site data recovered. Excluding index.html from the precache manifest lets the NetworkOnly navigation strategy do the job it was written for. Existing poisoned caches heal themselves: the entry is dropped when the new manifest activates. The same stale document caused the create-admin dialog to reappear (#3613), patched then by calling removeHomeCache() after login. That helper only ran on password login and token refresh, so it never covered the ExtAuth path, and a refetch re-poisoned the cache anyway. Fixing the cause makes it dead code, so it is removed. Also serve the offline page on 5xx: it arrives as a normal response, so the existing catch never saw it, and without a precached shell a restarting server would surface a raw gateway error. The offline copy is reworded to fit both causes. * test(ui): cover the service worker navigation fallback rules The handler lived inside sw.js, which only loads in a service worker where workbox arrives via importScripts, so none of it was reachable from vitest. Moving the decision into its own module pins the rules that matter: a 5xx falls back to the offline page like a thrown network error does, while 4xx and 304 still pass through to the app. --- .gitignore | 1 + ui/public/offline.html | 2 +- ui/src/authProvider.js | 2 - ui/src/dataProvider/httpClient.js | 2 - ui/src/sw.js | 20 +++++----- ui/src/swNavigation.js | 14 +++++++ ui/src/swNavigation.test.js | 62 +++++++++++++++++++++++++++++++ ui/src/utils/removeHomeCache.js | 20 ---------- ui/vite.config.js | 3 ++ 9 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 ui/src/swNavigation.js create mode 100644 ui/src/swNavigation.test.js delete mode 100644 ui/src/utils/removeHomeCache.js diff --git a/.gitignore b/.gitignore index fc8eaac69..3567a7d90 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ openspec/ .agents go.work* .worktrees/ +.playwright-mcp/ diff --git a/ui/public/offline.html b/ui/public/offline.html index 72f2d04ae..e3c44e8d3 100644 --- a/ui/public/offline.html +++ b/ui/public/offline.html @@ -5,6 +5,6 @@ <p id="errorMessageDescription" style="text-align:center;font-size:21px;font-family:arial;margin-top:28px"> It looks like we are having trouble connecting. <br/> -Please check your internet connection and try again.</p> +Please check your internet connection, or try again in a moment.</p> </body> </html> \ No newline at end of file diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 813a4f5b4..18badbc9c 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,7 +1,6 @@ import { jwtDecode } from 'jwt-decode' import { baseUrl } from './utils' import config from './config' -import { removeHomeCache } from './utils/removeHomeCache' // config sent from server may contain authentication info, for example when the user is authenticated // by a reverse proxy request header @@ -49,7 +48,6 @@ const authProvider = { storeAuthenticationInfo(response) // Avoid "going to create admin" dialog after logout/login without a refresh config.firstTime = false - removeHomeCache() return response }) .catch((error) => { diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 3820f658d..706486fa6 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -3,7 +3,6 @@ import { v4 as uuidv4 } from 'uuid' import { baseUrl } from '../utils' import config from '../config' import { jwtDecode } from 'jwt-decode' -import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' @@ -27,7 +26,6 @@ const httpClient = (url, options = {}) => { localStorage.setItem('userId', decoded.uid) // Avoid going to create admin dialog after logout/login without a refresh config.firstTime = false - removeHomeCache() } return response }) diff --git a/ui/src/sw.js b/ui/src/sw.js index f4a5664b8..edc272850 100644 --- a/ui/src/sw.js +++ b/ui/src/sw.js @@ -1,5 +1,7 @@ /* eslint-disable */ +import { createNavigationHandler } from './swNavigation' + // documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw importScripts('3rdparty/workbox/workbox-sw.js') @@ -36,17 +38,13 @@ self.addEventListener('install', async (event) => { }) const networkOnly = new workbox.strategies.NetworkOnly() -const navigationHandler = async (params) => { - try { - // Attempt a network request. - return await networkOnly.handle(params) - } catch (error) { - // If it fails, return the cached HTML. - return caches.match(FALLBACK_HTML_URL, { - cacheName: CACHE_NAME, - }) - } -} +const fallbackToCachedHtml = () => + caches.match(FALLBACK_HTML_URL, { cacheName: CACHE_NAME }) + +const navigationHandler = createNavigationHandler( + networkOnly, + fallbackToCachedHtml, +) // self.__WB_MANIFEST is default injection point workbox.precaching.precacheAndRoute(self.__WB_MANIFEST) diff --git a/ui/src/swNavigation.js b/ui/src/swNavigation.js new file mode 100644 index 000000000..28d01eaef --- /dev/null +++ b/ui/src/swNavigation.js @@ -0,0 +1,14 @@ +// Lives outside sw.js so it can be unit tested: sw.js only loads inside a +// service worker, where workbox arrives via importScripts. +export const createNavigationHandler = + (networkOnly, offlineFallback) => async (params) => { + try { + // Attempt a network request. + const response = await networkOnly.handle(params) + // A 5xx reaches us as a normal response, but carries no usable app + return response.status >= 500 ? offlineFallback() : response + } catch (error) { + // If it fails, return the cached HTML. + return offlineFallback() + } + } diff --git a/ui/src/swNavigation.test.js b/ui/src/swNavigation.test.js new file mode 100644 index 000000000..8ab749757 --- /dev/null +++ b/ui/src/swNavigation.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest' +import { createNavigationHandler } from './swNavigation' + +const OFFLINE = { offlinePage: true } + +const setup = (networkResult) => { + const networkOnly = { + handle: vi.fn(() => + networkResult instanceof Error + ? Promise.reject(networkResult) + : Promise.resolve(networkResult), + ), + } + const offlineFallback = vi.fn(() => Promise.resolve(OFFLINE)) + return { + networkOnly, + offlineFallback, + handler: createNavigationHandler(networkOnly, offlineFallback), + } +} + +describe('createNavigationHandler', () => { + it('serves the response from the network', async () => { + const response = { status: 200 } + const { handler, offlineFallback } = setup(response) + + await expect(handler({})).resolves.toBe(response) + expect(offlineFallback).not.toHaveBeenCalled() + }) + + it('falls back to the offline page when the network fails', async () => { + const { handler } = setup(new Error('Failed to fetch')) + + await expect(handler({})).resolves.toBe(OFFLINE) + }) + + it.each([500, 502, 503])( + 'falls back to the offline page on %i', + async (status) => { + const { handler } = setup({ status }) + + await expect(handler({})).resolves.toBe(OFFLINE) + }, + ) + + it.each([304, 401, 404])('passes %i through untouched', async (status) => { + const response = { status } + const { handler, offlineFallback } = setup(response) + + await expect(handler({})).resolves.toBe(response) + expect(offlineFallback).not.toHaveBeenCalled() + }) + + it('passes the navigation params to the network strategy', async () => { + const params = { request: { url: '/app/' } } + const { handler, networkOnly } = setup({ status: 200 }) + + await handler(params) + + expect(networkOnly.handle).toHaveBeenCalledWith(params) + }) +}) diff --git a/ui/src/utils/removeHomeCache.js b/ui/src/utils/removeHomeCache.js deleted file mode 100644 index 08ed720e0..000000000 --- a/ui/src/utils/removeHomeCache.js +++ /dev/null @@ -1,20 +0,0 @@ -export const removeHomeCache = async () => { - try { - const workboxKey = (await caches.keys()).find((key) => - key.startsWith('workbox-precache'), - ) - if (!workboxKey) return - - const workboxCache = await caches.open(workboxKey) - const indexKey = (await workboxCache.keys()).find((key) => - key.url.includes('app/index.html'), - ) - - if (indexKey) { - await workboxCache.delete(indexKey) - } - } catch (e) { - // eslint-disable-next-line no-console - console.error('error reading cache', e) - } -} diff --git a/ui/vite.config.js b/ui/vite.config.js index 9d9c845f1..182e825b0 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -16,6 +16,9 @@ export default defineConfig({ filename: 'sw.js', injectManifest: { maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB + // index.html is rendered per-user by the server, so a precached copy + // would pin one user's config (and auth payload) across logins + globIgnores: ['index.html'], }, devOptions: { enabled: true, From 77726af59ca5808dae08681233b7df98ec6cc7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 3 Aug 2026 13:09:53 -0400 Subject: [PATCH 406/596] fix(plugins): reject plugin IDs that are unusable as directory names (#5886) The plugin ID is derived from the package filename and used verbatim as a directory name under DataFolder/plugins by the kvstore and taskqueue host services. A package installed as '..ndp' yields the ID '.', whose data directory resolves to the parent of every other plugin's directory, so it overlaps their private data. On Windows, trailing dots and spaces are dropped during path normalization, so 'foo..ndp' and 'foo.ndp' yield distinct IDs that resolve to the same directory and would share the same SQLite files. Discovery and the file watcher now derive the ID through pluginIDFromPath, which rejects '.', '..', empty names, separators, trailing dots or spaces, and anything filepath.IsLocal refuses (Windows reserved names, drive-relative paths). The loader repeats the check, since a sync failure is non-fatal and could otherwise leave a stale row reaching the host services. --- plugins/manager_loader.go | 4 ++++ plugins/manager_loader_test.go | 13 +++++++++++ plugins/manager_sync.go | 6 ++++- plugins/manager_sync_test.go | 40 ++++++++++++++++++++++++++++++++++ plugins/manager_watcher.go | 6 ++++- plugins/package.go | 22 +++++++++++++++++++ plugins/package_test.go | 30 +++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index fc9296427..5238a4755 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -271,6 +271,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manager is stopped") } + if !validPluginID(p.ID) { + return fmt.Errorf("invalid plugin ID %q", p.ID) + } + // Track this operation m.loadWg.Add(1) defer m.loadWg.Done() diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 5e6851481..6326b2218 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,6 +1,7 @@ package plugins import ( + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,6 +28,18 @@ var _ = Describe("buildExtismManifest", func() { }) }) +var _ = Describe("loadPluginWithConfig", func() { + // Discovery already rejects these, but a row predating that check, or one + // left behind by a failed sync, must not reach the mount setup + It("refuses a plugin whose ID is not usable as a directory name", func() { + m := &Manager{plugins: make(map[string]*plugin)} + + err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"}) + + Expect(err).To(MatchError(ContainSubstring("invalid plugin ID"))) + }) +}) + var _ = Describe("parsePluginConfig", func() { It("returns nil for empty string", func() { result, err := parsePluginConfig("") diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 2119f1a5a..480fa1bb9 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -152,7 +152,11 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } - name := strings.TrimSuffix(entry.Name(), PackageExtension) + name, ok := pluginIDFromPath(entry.Name()) + if !ok { + log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name()) + continue + } filesOnDisk[name] = filepath.Join(folder, entry.Name()) } log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index 26da2079b..dd64dcd3f 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -12,6 +12,46 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("syncPlugins", func() { + var m *Manager + var repo *tests.MockPluginRepo + var folder string + + BeforeEach(func() { + folder = GinkgoT().TempDir() + repo = tests.CreateMockPluginRepo() + repo.SetData(model.Plugins{}) + m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}} + }) + + writePackage := func(name string) { + GinkgoHelper() + manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"} + wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header + Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed()) + } + + It("registers a plugin with a usable ID", func() { + writePackage("my-plugin" + PackageExtension) + + Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips packages whose name yields a path-like ID", func() { + writePackage("." + PackageExtension) + writePackage(".." + PackageExtension) + + Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(all).To(BeEmpty()) + }) +}) + var _ = Describe("removePluginFromDB", func() { It("discards buffered scrobbles for the removed plugin", func() { ctx := context.Background() diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index d666d8620..f7f658be9 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -89,7 +89,11 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) { return } - pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension) + pluginName, ok := pluginIDFromPath(path) + if !ok { + log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path) + return + } log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path) diff --git a/plugins/package.go b/plugins/package.go index e0405d4d8..77cb6e5ac 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "path/filepath" + "strings" ) const ( @@ -18,6 +20,26 @@ const ( wasmFileName = "plugin.wasm" ) +// validPluginID reports whether id is usable. It names a directory under +// DataFolder/plugins, so a path-like one would point at another plugin's data. +func validPluginID(id string) bool { + if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) { + return false + } + // Windows drops trailing dots and spaces, so "foo." and "foo" would end up + // sharing a directory + return strings.TrimRight(id, ". ") == id +} + +// pluginIDFromPath derives the plugin ID from a package path. +func pluginIDFromPath(path string) (string, bool) { + id := strings.TrimSuffix(filepath.Base(path), PackageExtension) + if !validPluginID(id) { + return "", false + } + return id, true +} + // ndpPackage represents a loaded .ndp plugin package. // It contains the manifest and wasm bytes read from the archive. type ndpPackage struct { diff --git a/plugins/package_test.go b/plugins/package_test.go index 4a37f4352..953797750 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -11,6 +11,36 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("pluginIDFromPath", func() { + DescribeTable("derives the ID from the package filename", + func(path, expected string) { + id, ok := pluginIDFromPath(path) + Expect(ok).To(BeTrue()) + Expect(id).To(Equal(expected)) + }, + Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"), + Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"), + Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"), + Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"), + ) + + // The ID becomes a directory name under DataFolder/plugins, so a path-like + // one would let a plugin reach another plugin's data + DescribeTable("rejects IDs that are unsafe as a directory name", + func(path string) { + _, ok := pluginIDFromPath(path) + Expect(ok).To(BeFalse()) + }, + Entry("empty", "/plugins/.ndp"), + Entry("current directory", "/plugins/..ndp"), + Entry("parent directory", "/plugins/...ndp"), + // Windows drops trailing dots and spaces, so these would share a + // directory with "foo" + Entry("trailing dot", "/plugins/foo..ndp"), + Entry("trailing space", "/plugins/foo .ndp"), + ) +}) + var _ = Describe("ndpPackage", func() { var tmpDir string From 54fe6c254e5b9eedf105660fe1ca03ee271c24ca Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:23:42 -0700 Subject: [PATCH 407/596] feat(plugins): implement plugin specific storage (#5839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement storage api/hooks * fine. if the error messages are different, just match error * rename storageMount * add independent plugin test * round 2 * .-. * one more copypasta fail * docs(plugins): document the Storage host service The README is the plugin author reference and every other host service has a section there, but Storage had none, so the /storage guest path contract only existed in code. Documents the mount point and its backing directory, the manifest permission, the host function, and the two behaviours an author would otherwise discover the hard way: there is no size limit, and the directory outlives an uninstall. * docs(plugins): correct the library filesystem security notes The README stated in three places that library filesystem access is read-only, which stopped being true when AllowWriteAccess was added: an administrator can grant a plugin write access to libraries. Corrects those and gives the library and storage mounts the same wording for what the sandbox guarantees, since both now go through the same jail. * docs(plugins): describe symlink handling in mounts accurately The security notes claimed paths resolving outside a mount are rejected, which overstates the jail. Only lexical escapes are: '..' and absolute paths. Creating symlinks is denied, but symlinks already present are followed and do reach outside the mount, which is what lets music libraries link folders in from elsewhere. Both behaviours are pinned by tests in the plugins package. --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- plugins/README.md | 53 +++- plugins/host/storage.go | 13 + plugins/host/storage_gen.go | 67 +++++ plugins/host_storage.go | 34 +++ plugins/host_storage_test.go | 270 ++++++++++++++++++ plugins/manager_loader.go | 38 ++- plugins/manifest-schema.json | 14 + plugins/manifest.go | 4 + plugins/manifest_gen.go | 10 + plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_storage.go | 46 +++ plugins/pdk/go/host/nd_host_storage_stub.go | 34 +++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../rust/nd-pdk-host/src/nd_host_storage.rs | 34 +++ plugins/testdata/test-storage-plugin-2 | 1 + plugins/testdata/test-storage-plugin/go.mod | 16 ++ plugins/testdata/test-storage-plugin/go.sum | 14 + plugins/testdata/test-storage-plugin/main.go | 50 ++++ .../test-storage-plugin/manifest.json | 11 + 19 files changed, 709 insertions(+), 9 deletions(-) create mode 100644 plugins/host/storage.go create mode 100644 plugins/host/storage_gen.go create mode 100644 plugins/host_storage.go create mode 100644 plugins/host_storage_test.go create mode 100644 plugins/pdk/go/host/nd_host_storage.go create mode 100644 plugins/pdk/go/host/nd_host_storage_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs create mode 120000 plugins/testdata/test-storage-plugin-2 create mode 100644 plugins/testdata/test-storage-plugin/go.mod create mode 100644 plugins/testdata/test-storage-plugin/go.sum create mode 100644 plugins/testdata/test-storage-plugin/main.go create mode 100644 plugins/testdata/test-storage-plugin/manifest.json diff --git a/plugins/README.md b/plugins/README.md index 08ab967f3..a2f9532ca 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -30,6 +30,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) + - [Storage](#storage) - [Task](#task) - [WebSocket](#websocket) - [Library](#library) @@ -548,6 +549,52 @@ usage, err := host.KVStoreGetStorageUsed() fmt.Printf("Using %d bytes\n", usage) ``` +### Storage + +A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes. + +**Manifest permission:** + +```json +{ + "permissions": { + "storage": { + "reason": "Cache generated playlists between restarts" + } + } +} +``` + +**Host functions:** + +| Function | Parameters | Description | +|--------------------------|------------|------------------------------------| +| `storage_getstoragepath` | – | Get the guest path of the mount | + +**Usage:** + +Normal WASI filesystem calls work inside the mount, so use the `os` package directly: + +```go +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" +) + +// The path never changes, so read it once instead of per operation +var storageDir = host.StorageGetStoragePath() // "/storage" + +err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600) +content, err := os.ReadFile(filepath.Join(storageDir, "cache.json")) +entries, err := os.ReadDir(storageDir) +``` + +> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable. + +> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled. + ### Task Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. @@ -644,7 +691,7 @@ Access music library metadata and optionally read files from library directories } ``` -- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`) +- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`) **Host functions:** @@ -683,7 +730,7 @@ content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Filesystem access is read-only and restricted to configured library paths only. +> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit <name> --write-access`). **Usage:** @@ -1058,7 +1105,7 @@ See [examples/](examples/) for complete working plugins: Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission +2. **Limited File System** – Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime diff --git a/plugins/host/storage.go b/plugins/host/storage.go new file mode 100644 index 000000000..ed24d7508 --- /dev/null +++ b/plugins/host/storage.go @@ -0,0 +1,13 @@ +package host + +import "context" + +// StorageService provides access to a plugin-specific directory with read/write permissions +// +//nd:hostservice name=Storage permission=storage +type StorageService interface { + // GetStoragePath retrieves the persistent storage path, if allowed + // + //nd:hostfunc + GetStoragePath(ctx context.Context) string +} diff --git a/plugins/host/storage_gen.go b/plugins/host/storage_gen.go new file mode 100644 index 000000000..d705f0831 --- /dev/null +++ b/plugins/host/storage_gen.go @@ -0,0 +1,67 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath. +type StorageGetStoragePathResponse struct { + Result string `json:"result,omitempty"` +} + +// RegisterStorageHostFunctions registers Storage service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction { + return []extism.HostFunction{ + newStorageGetStoragePathHostFunction(service), + } +} + +func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "storage_getstoragepath", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + + // Call the service method + result := service.GetStoragePath(ctx) + + // Write JSON response to plugin memory + resp := StorageGetStoragePathResponse{ + Result: result, + } + storageWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// storageWriteResponse writes a JSON response to plugin memory. +func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + storageWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// storageWriteError writes an error response to plugin memory. +func storageWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_storage.go b/plugins/host_storage.go new file mode 100644 index 000000000..e2ead7aae --- /dev/null +++ b/plugins/host_storage.go @@ -0,0 +1,34 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/plugins/host" +) + +const storageMount = "/storage" + +type storageServiceImpl struct{} + +func getHostStoragePath(pluginName string) string { + return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage") +} + +func newStorageService(pluginName string) (host.StorageService, error) { + dataDir := getHostStoragePath(pluginName) + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("creating plugin data directory: %w", err) + } + + return &storageServiceImpl{}, nil +} + +func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string { + return storageMount +} + +var _ host.StorageService = (*storageServiceImpl)(nil) diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go new file mode 100644 index 000000000..d6f895bff --- /dev/null +++ b/plugins/host_storage_test.go @@ -0,0 +1,270 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("utility functions", Ordered, func() { + var tmpDir string + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "storage-test-*") + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(tmpDir) + + DeferCleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("GetHostStoragePath", func() { + It("should join data folder, plugins, plugin name, and storage", func() { + actual := getHostStoragePath("plugin-name") + expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") + Expect(actual).To(Equal(expected)) + }) + }) + + Describe("GetStoragePath", func() { + It("should return the fixed path", func() { + impl := storageServiceImpl{} + Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage")) + }) + }) + + Describe("netStorageService", func() { + It("should create the directory on init", func() { + svc, err := newStorageService("plugin-name") + Expect(err).ToNot(HaveOccurred()) + Expect(svc).ToNot(BeNil()) + + dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") + Expect(dataDir).To(BeADirectory()) + }) + }) +}) + +var _ = Describe("Storage Host Function", Ordered, func() { + const ID = "test-storage-plugin" + + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "storage-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Setup mock router and data store + router := &fakeSubsonicRouter{} + userRepo := tests.CreateMockUserRepo() + dataStore := &tests.MockDataStore{MockedUser: userRepo} + + // Create and configure manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + } + manager.SetSubsonicRouter(router) + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + pluginPaths := []string{ID, ID + "-2"} + plugins := []model.Plugin{} + + for idx := range pluginPaths { + path := pluginPaths[idx] + PackageExtension + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, path) + destPath := filepath.Join(tmpDir, path) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Pre-enable the plugin in the mock repo so it loads on startup + // Compute SHA256 of the plugin file to match what syncPlugins will compute + pluginPath := filepath.Join(tmpDir, path) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + plugins = append(plugins, model.Plugin{ + ID: pluginPaths[idx], + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, // Allow all users for test plugin + }) + } + + mockPluginRepo.SetData(plugins) + + // Start the manager + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + var instance *extism.Plugin + BeforeEach(func() { + var err error + manager.mu.RLock() + plugin := manager.plugins[ID] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + + ctx := GinkgoT().Context() + instance, err = plugin.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + instance.Close(ctx) + }) + }) + + Describe("Read", func() { + BeforeAll(func() { + path := filepath.Join(getHostStoragePath(ID), "real") + err := os.WriteFile(path, []byte("1234"), 0600) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = os.Remove(path) + }) + }) + + It("should fail to read missing file", func() { + exit, _, err := instance.Call("call_read", []byte("missing")) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + + It("should read an existing file", func() { + exit, output, err := instance.Call("call_read", []byte("real")) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal([]byte("1234"))) + }) + + It("should not escape read", func() { + path := filepath.Join(getHostStoragePath(ID), "..", "outside") + err := os.WriteFile(path, []byte("outside"), 0600) + Expect(err).ToNot(HaveOccurred()) + + exit, _, err := instance.Call("call_read", []byte("../outside")) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Write", func() { + BeforeAll(func() { + path := filepath.Join(getHostStoragePath(ID), "real") + err := os.WriteFile(path, []byte("1234"), 0600) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = os.Remove(path) + }) + }) + + It("should fail to write to nested file", func() { + exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`)) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + + It("should write to a file", func() { + exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`)) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + + data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new")) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]byte("contents"))) + + exit, output, err := instance.Call("call_read", []byte("new")) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal([]byte("contents"))) + }) + + It("should not escape writing a file", func() { + path := filepath.Join(getHostStoragePath(ID), "..", "outside") + err := os.WriteFile(path, []byte("outside"), 0600) + Expect(err).ToNot(HaveOccurred()) + + exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`)) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + + It("should have independent storage for multiple plugins", func() { + manager.mu.RLock() + plugin2 := manager.plugins[ID+"-2"] + manager.mu.RUnlock() + + Expect(plugin2).ToNot(BeNil()) + + secondInstance, err := plugin2.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer secondInstance.Close(GinkgoT().Context()) + + instances := []*extism.Plugin{instance, secondInstance} + names := []string{ID, ID + "-2"} + + for idx := range instances { + exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx])) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + } + + for idx := range names { + data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new")) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]byte(names[idx]))) + } + + for idx := range instances { + exit, output, err := instances[idx].Call("call_read", []byte("new")) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal([]byte(names[idx]))) + } + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 5238a4755..0e9e7bb62 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -166,6 +166,17 @@ var hostServices = []hostServiceEntry{ return host.RegisterTaskHostFunctions(service), service, nil }, }, + { + name: "Storage", + hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + service, err := newStorageService(ctx.pluginName) + if err != nil { + return nil, nil, err + } + return host.RegisterStorageHostFunctions(service), nil, nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. @@ -315,14 +326,29 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { // Configure filesystem access for library permission, applied per instance var fsConfig wazero.FSConfig - if pkg.Manifest.HasLibraryFilesystemPermission() { - adminCtx := adminContext(ctx) - libraries, err := m.ds.Library(adminCtx).GetAll() - if err != nil { - return fmt.Errorf("failed to get libraries for filesystem access: %w", err) + if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() { + mounts := []mount{} + + if pkg.Manifest.HasLibraryFilesystemPermission() { + adminCtx := adminContext(ctx) + libraries, err := m.ds.Library(adminCtx).GetAll() + if err != nil { + return fmt.Errorf("failed to get libraries for filesystem access: %w", err) + } + mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) } - fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)) + if pkg.Manifest.HasStoragePermission() { + pluginStore := getHostStoragePath(p.ID) + log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID) + + mounts = append(mounts, mount{ + hostPath: pluginStore, + guestPath: storageMount, + }) + } + + fsConfig = buildFSConfig(mounts) } // Build host functions based on permissions from manifest diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 28adeed79..736677313 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -92,6 +92,9 @@ }, "matcher": { "$ref": "#/$defs/MatcherPermission" + }, + "storage": { + "$ref": "#/$defs/StoragePermission" } } }, @@ -244,6 +247,17 @@ "description": "Explanation for why matcher access is needed" } } + }, + "StoragePermission": { + "type": "object", + "description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why storage access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 5e144b5c8..ef70e8d9d 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -123,3 +123,7 @@ func (m *Manifest) HasLibraryFilesystemPermission() bool { m.Permissions.Library != nil && m.Permissions.Library.Filesystem } + +func (m *Manifest) HasStoragePermission() bool { + return m.Permissions != nil && m.Permissions.Storage != nil +} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index c2aa5e298..5e0035bd5 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -178,6 +178,9 @@ type Permissions struct { // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + // Storage corresponds to the JSON schema field "storage". + Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` + // Subsonicapi corresponds to the JSON schema field "subsonicapi". Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` @@ -197,6 +200,13 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Storage permissions for enabling persistent read-write storage exclusively for +// the plugin +type StoragePermission struct { + // Explanation for why storage access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // SubsonicAPI service permissions. Requires 'users' permission to be declared. type SubsonicAPIPermission struct { // Explanation for why SubsonicAPI access is needed diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index ff2c2a07f..e4157e29c 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. + - Storage: provides access to a plugin-specific directory with read/write permissions - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. - Users: provides access to user information for plugins. diff --git a/plugins/pdk/go/host/nd_host_storage.go b/plugins/pdk/go/host/nd_host_storage.go new file mode 100644 index 000000000..4f37bc8e2 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage.go @@ -0,0 +1,46 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Storage host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// storage_getstoragepath is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user storage_getstoragepath +func storage_getstoragepath(uint64) uint64 + +type storageGetStoragePathResponse struct { + Result string `json:"result,omitempty"` +} + +// StorageGetStoragePath calls the storage_getstoragepath host function. +// GetStoragePath retrieves the persistent storage path, if allowed +func StorageGetStoragePath() string { + // No parameters - allocate empty JSON object + reqMem := pdk.AllocateBytes([]byte("{}")) + defer reqMem.Free() + + // Call the host function + responsePtr := storage_getstoragepath(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response storageGetStoragePathResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return "" + } + + return response.Result +} diff --git a/plugins/pdk/go/host/nd_host_storage_stub.go b/plugins/pdk/go/host/nd_host_storage_stub.go new file mode 100644 index 000000000..c2213ece9 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage_stub.go @@ -0,0 +1,34 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/stretchr/testify/mock" +) + +// mockStorageService is the mock implementation for testing. +type mockStorageService struct { + mock.Mock +} + +// StorageMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...) +var StorageMock = &mockStorageService{} + +// GetStoragePath is the mock method for StorageGetStoragePath. +func (m *mockStorageService) GetStoragePath() string { + args := m.Called() + return args.String(0) +} + +// StorageGetStoragePath delegates to the mock instance. +// GetStoragePath retrieves the persistent storage path, if allowed +func StorageGetStoragePath() string { + return StorageMock.GetStoragePath() +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index cc1fdc190..3da023f5c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. //! - [`users`] - provides access to user information for plugins. @@ -101,6 +102,13 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } +#[doc(hidden)] +mod nd_host_storage; +/// provides access to a plugin-specific directory with read/write permissions +pub mod storage { + pub use super::nd_host_storage::*; +} + #[doc(hidden)] mod nd_host_subsonicapi; /// provides access to Navidrome's Subsonic API from plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs new file mode 100644 index 000000000..336ae565f --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs @@ -0,0 +1,34 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Storage host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StorageGetStoragePathResponse { + #[serde(default)] + result: String, +} + +#[host_fn] +extern "ExtismHost" { + fn storage_getstoragepath(input: Json<serde_json::Value>) -> Json<StorageGetStoragePathResponse>; +} + +/// GetStoragePath retrieves the persistent storage path, if allowed +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_storage_path() -> Result<String, Error> { + let response = unsafe { + storage_getstoragepath(Json(serde_json::json!({})))? + }; + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-storage-plugin-2 b/plugins/testdata/test-storage-plugin-2 new file mode 120000 index 000000000..444bafd87 --- /dev/null +++ b/plugins/testdata/test-storage-plugin-2 @@ -0,0 +1 @@ +test-storage-plugin \ No newline at end of file diff --git a/plugins/testdata/test-storage-plugin/go.mod b/plugins/testdata/test-storage-plugin/go.mod new file mode 100644 index 000000000..7b9a2b567 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.mod @@ -0,0 +1,16 @@ +module test-storage-plugin + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-storage-plugin/go.sum b/plugins/testdata/test-storage-plugin/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-storage-plugin/main.go b/plugins/testdata/test-storage-plugin/main.go new file mode 100644 index 000000000..17ffc8e6b --- /dev/null +++ b/plugins/testdata/test-storage-plugin/main.go @@ -0,0 +1,50 @@ +// Test plugin for Storage host function integration tests. +// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go +package main + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +//go:wasmexport call_read +func callRead() int32 { + path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString()) + content, err := os.ReadFile(path) + if err != nil { + pdk.SetErrorString("failed to read file: " + err.Error()) + return 1 + } + pdk.Output(content) + return 0 +} + +type WriteInput struct { + Path string `json:"path"` + Contents string `json:"contents"` +} + +//go:wasmexport call_write +func callWrite() int32 { + var config WriteInput + err := pdk.InputJSON(&config) + + if err != nil { + pdk.SetErrorString("failed to parse json: " + err.Error()) + return 1 + } + + path := filepath.Join(host.StorageGetStoragePath(), config.Path) + err = os.WriteFile(path, []byte(config.Contents), 0600) + if err != nil { + pdk.SetErrorString("failed to write file: " + err.Error()) + return 1 + } + + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-storage-plugin/manifest.json b/plugins/testdata/test-storage-plugin/manifest.json new file mode 100644 index 000000000..98ac24a47 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Test Storage Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin for storage host function", + "permissions": { + "storage": { + "reason": "persistent storage for testing" + } + } +} From e1b89050df7cb228f04d5870be03af3c985fc3bc Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 3 Aug 2026 14:34:53 -0400 Subject: [PATCH 408/596] chore: exclude .worktrees from golangci-lint checks --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 200fe122f..0804138dc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -69,6 +69,7 @@ linters: - examples$ - node_modules - _gen\.go$ + - .worktrees formatters: exclusions: generated: lax From 50633f839dabe49571099977753036d166734982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 4 Aug 2026 22:07:16 -0400 Subject: [PATCH 409/596] fix(ui): restore each page's scroll position when navigating back (#5892) * fix(ui): anchor detail pages to the top when opened from a list React Router keeps the previous page's scroll offset, so opening an album from a scrolled list started the detail page mid-song-list. Artist pages had the same bug; it just shows less because the artist list is rarely long enough to scroll far. Keyed on the record id rather than mount, so detail-to-detail navigation (an album's artist link) resets too, and so the scroll waits for the record instead of firing against an empty page. * feat(ui): remember each route's scroll offset A hook that saves window.scrollY per history entry and, on navigation, tops a pushed route or returns a popped one to where it was. Keyed on location.key so back and forward each restore their own offset, and bounded because history entries are not. Callers pass their own readiness: restoring before the rows render leaves the document too short, so the browser clamps to the top and the offset is lost with no error to show for it. * refactor(ui): fold scroll-to-top into route scroll restoration useScrollToTop only ever forced the top, so it fixed opening a detail page from a scrolled list and did nothing for the return trip: coming back to the list landed wherever it happened to be. The new hook covers both, and its readiness argument carries over the reason the old one keyed on the record id - waiting for the record rather than firing against an empty page. * feat(ui): restore the album list's position when returning to it The list is the page worth remembering: scrolling a long way to find an album and landing back at the top is the annoyance the detail-page scroll fix could not reach. Mounted as a pass-through around the List's child rather than beside it, because React Admin calls Children.only on List children and clones that one child with the list props - which is also how this reads loaded/total to wait for rows. * fix(ui): key scroll restoration on the route, not location.key Hash history never assigns location.key - history.js calls createLocation with an undefined key and warns that it cannot carry state - so every route collapsed into one slot. The detail page's own scroll listener then overwrote the list's saved offset with 0, and going back restored that 0. Only the scroll-to-top half worked, which is what made it look fine. Keyed on pathname+search instead. The spec that would have caught this asserts two routes keep separate offsets; the previous ones mocked the router and so only ever confirmed the assumption. * refactor(ui): tidy up scroll restoration after review Four cleanups, none changing behaviour: ScrollRestorer read loaded/total off whatever props React Admin happened to clone in. useListContext is the contract for those values and is already used three times elsewhere, including further down this same file; the clone payload is an implementation detail that would fail silently if it ever changed. The restore runs in a layout effect now. A passive effect is flushed after paint, so an image-heavy grid was painted at the old offset and then jumped. MAX_ENTRIES matches the naming every other module-level cap in common/ uses. The per-instance guard against re-restoring was deletable with a green suite, so it now has a spec: readiness flickering on one route must not scroll twice. * fix(ui): resolve media queries on the first render MUI defaults useMediaQuery and withWidth to SSR-safe two-pass rendering: the first render reports the wrong breakpoint and a layout effect corrects it. Navidrome is client-only, so every mount paints a layout it immediately reflows. Album tiles render without their artist line and grow 14px when it arrives, and react-admin's own buttons paint labelled before collapsing to icons, which together grew the album list 79px right after a scroll restore and dragged the position with it. Setting noSsr on the theme reaches react-admin's internals too. withWidth needs its own option because it gates on a mount flag rather than on the media query. * fix(ui): keep restoring the scroll offset until it sticks A single scrollTo assumes the page is already at its final height. It often is not: the artist page reports ready as soon as its cached header record renders, while the albums below are still loading, so the browser clamps the offset to the top of a viewport-tall document and it is lost with no error. The restore now retries until the offset lands, yielding as soon as the position moves somewhere we did not put it. Keying that on input events instead breaks the trackpad back-swipe, whose wheel momentum keeps firing through the restore window. Offsets are also committed when leaving the page rather than on every scroll. Tearing the page down collapses the document and snaps to the top, and a live listener recorded that clamp over the offset being left behind. * refactor(ui): tidy up scroll restoration after review Folds the offset commit into the scroll tracker's own cleanup now that the tracker ignores the collapse snap, so one effect does what two did. Skips scheduling the retry loop when the first scrollTo already landed, and only pays for the scrollHeight read on scroll events that reach the top, since that is the only case the guard can fire. The theme hook's own prefers-color-scheme query runs above the ThemeProvider carrying the new prop, so it needs noSsr passed directly or the auto theme still renders dark first and flips. Also lifts the repeated fake-timer fixture in the specs into one helper and restores real timers from afterEach, so a failing assertion no longer leaves them installed for every later spec. * fix(ui): bank the scroll offset before the next page tops itself Committing the offset from the scroll listener's passive cleanup ran too late. The incoming page scrolls to the top in its own layout effect, and the outgoing page's listener is still attached at that moment, so it recorded that 0 over the offset being left behind. Whether it did depended on when the browser dispatched the scroll event relative to React's passive flush, which made returning to a detail page lose its position intermittently. Moving the commit back into a layout cleanup makes it deterministic: React runs those in the mutation phase, before the incoming page's layout effects. The scrollHeight guard in the listener went with it, since it only covered the case where the incoming page was shorter than the viewport and the ordering covers all of them. --- ui/src/album/AlbumList.jsx | 21 +- ui/src/album/AlbumShow.jsx | 3 +- ui/src/artist/ArtistShow.jsx | 8 +- ui/src/common/ArtistLinkField.jsx | 3 +- ui/src/common/index.js | 1 + ui/src/common/useScrollRestoration.jsx | 64 ++++++ ui/src/common/useScrollRestoration.test.jsx | 222 ++++++++++++++++++++ ui/src/themes/useCurrentTheme.js | 18 +- 8 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 ui/src/common/useScrollRestoration.jsx create mode 100644 ui/src/common/useScrollRestoration.test.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 5108bfaa1..cd73fa0c3 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -1,3 +1,4 @@ +import { cloneElement } from 'react' import { useSelector } from 'react-redux' import { Redirect, useLocation } from 'react-router-dom' import { @@ -23,6 +24,7 @@ import { Title, useAlbumsPerPage, useResourceRefresh, + useScrollRestoration, useSetToggleableFields, } from '../common' import AlbumListActions from './AlbumListActions' @@ -39,6 +41,13 @@ import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import { humanize } from 'inflection' import { makeStyles } from '@material-ui/core/styles' +// Waits for rows: restoring into an unrendered list leaves the page too short to hold the offset. +const ScrollRestorer = ({ children, ...rest }) => { + const { loaded, total } = useListContext() + useScrollRestoration(loaded && total > 0) + return cloneElement(children, rest) +} + const useStyles = makeStyles({ chip: { margin: 0, @@ -255,11 +264,13 @@ const AlbumList = (props) => { } title={<AlbumListTitle albumListType={albumListType} />} > - {albumView.grid ? ( - <AlbumGridView albumListType={albumListType} {...props} /> - ) : ( - <AlbumTableView {...props} /> - )} + <ScrollRestorer> + {albumView.grid ? ( + <AlbumGridView albumListType={albumListType} {...props} /> + ) : ( + <AlbumTableView {...props} /> + )} + </ScrollRestorer> </List> <ExpandInfoDialog content={<AlbumInfo />} /> </> diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.jsx index c9e944999..86b5ab1bc 100644 --- a/ui/src/album/AlbumShow.jsx +++ b/ui/src/album/AlbumShow.jsx @@ -10,7 +10,7 @@ import { makeStyles } from '@material-ui/core/styles' import AlbumSongs from './AlbumSongs' import AlbumDetails from './AlbumDetails' import AlbumActions from './AlbumActions' -import { useResourceRefresh, Title } from '../common' +import { useResourceRefresh, useScrollRestoration, Title } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -28,6 +28,7 @@ const AlbumShowLayout = (props) => { const { record } = context const classes = useStyles() useResourceRefresh('album', 'song') + useScrollRestoration(!!record?.id) return ( <> diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 955a565d6..eef2989d5 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -13,7 +13,12 @@ import subsonic from '../subsonic' import AlbumGridView from '../album/AlbumGridView' import MobileArtistDetails from './MobileArtistDetails' import DesktopArtistDetails from './DesktopArtistDetails' -import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js' +import { + useAlbumsPerPage, + useResourceRefresh, + useScrollRestoration, + Title, +} from '../common/index.js' import ArtistActions from './ArtistActions' import { makeStyles } from '@material-ui/core' @@ -85,6 +90,7 @@ const ArtistShowLayout = (props) => { const [, perPageOptions] = useAlbumsPerPage(width) const classes = useStyles() useResourceRefresh('artist', 'album') + useScrollRestoration(!!record?.id) const maxPerPage = 90 let perPage = 0 diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx index d41b47b06..2a70ded7c 100644 --- a/ui/src/common/ArtistLinkField.jsx +++ b/ui/src/common/ArtistLinkField.jsx @@ -7,7 +7,8 @@ import { intersperse } from '../utils/index.js' import { useDispatch } from 'react-redux' import { closeExtendedInfoDialog } from '../actions/dialogs.js' -const ALink = withWidth()((props) => { +// noSSR: withWidth otherwise renders null until mounted, so the artist line pops in and grows the row. +const ALink = withWidth({ noSSR: true })((props) => { const { artist, width, ...rest } = props const artistLink = useGetHandleArtistClick(width) const dispatch = useDispatch() diff --git a/ui/src/common/index.js b/ui/src/common/index.js index ac8d7f62c..c88ea3e9c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -28,6 +28,7 @@ export * from './useAlbumsPerPage' export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' +export * from './useScrollRestoration' export * from './useRefreshOnEvents' export * from './useToggleLove' export * from './useTraceUpdate' diff --git a/ui/src/common/useScrollRestoration.jsx b/ui/src/common/useScrollRestoration.jsx new file mode 100644 index 000000000..94e5cb2fd --- /dev/null +++ b/ui/src/common/useScrollRestoration.jsx @@ -0,0 +1,64 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useHistory, useLocation } from 'react-router-dom' + +// Keyed on the route because hash history assigns no location.key, so every page would +// otherwise share one slot and overwrite the offset we came back for. +const positions = new Map() +const MAX_ENTRIES = 50 +const MAX_FRAMES = 60 + +export const useScrollRestoration = (ready = true) => { + const { pathname, search } = useLocation() + const history = useHistory() + const key = pathname + search + const handled = useRef(null) + const latest = useRef(0) + + useEffect(() => { + latest.current = window.scrollY + const track = () => { + latest.current = window.scrollY + } + window.addEventListener('scroll', track, { passive: true }) + return () => window.removeEventListener('scroll', track) + }, [key]) + + // Layout cleanup, so the offset is banked while this page is torn down. A passive one runs + // after the incoming page tops itself, and this page's listener records that 0 first. + useLayoutEffect( + () => () => { + positions.delete(key) + positions.set(key, latest.current) + if (positions.size > MAX_ENTRIES) { + positions.delete(positions.keys().next().value) + } + }, + [key], + ) + + // Layout effect: a passive one runs after paint, so the list would be painted at the old + // offset first and visibly jump. + useLayoutEffect(() => { + if (!ready || handled.current === key) return + handled.current = key + const saved = positions.get(key) + const top = history.action === 'POP' && saved !== undefined ? saved : 0 + window.scrollTo({ top }) + if (!top || Math.round(window.scrollY) === top) return + + // The page can still be filling in, and until it is tall enough the browser clamps us to + // the top. Keep asking until the offset sticks, yielding if anything else moves us. + let frame + let frames = 0 + let landed = Math.round(window.scrollY) + const retry = () => { + const y = Math.round(window.scrollY) + if (y === top || y !== landed || ++frames > MAX_FRAMES) return + window.scrollTo({ top }) + landed = Math.round(window.scrollY) + frame = requestAnimationFrame(retry) + } + frame = requestAnimationFrame(retry) + return () => cancelAnimationFrame(frame) + }, [ready, key, history.action]) +} diff --git a/ui/src/common/useScrollRestoration.test.jsx b/ui/src/common/useScrollRestoration.test.jsx new file mode 100644 index 000000000..7ca67a724 --- /dev/null +++ b/ui/src/common/useScrollRestoration.test.jsx @@ -0,0 +1,222 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { render } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockLocation = { pathname: '/album', search: '' } +const mockHistory = { action: 'PUSH' } +vi.mock('react-router-dom', () => ({ + useLocation: () => mockLocation, + useHistory: () => mockHistory, +})) + +import { useScrollRestoration } from './useScrollRestoration' + +describe('useScrollRestoration', () => { + beforeEach(() => { + window.scrollTo = vi.fn() + window.scrollY = 0 + mockLocation.pathname = '/album' + mockLocation.search = '' + mockHistory.action = 'PUSH' + }) + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + const scrollTo = (y) => { + window.scrollY = y + act(() => { + window.dispatchEvent(new Event('scroll')) + }) + } + + // Returns to a page whose rows have not arrived, so scrollTo still clamps. The returned setter + // grows the page to the height it reaches once they do. + const returnToUnfilledPage = (saved) => { + vi.useFakeTimers() + let maxScroll = saved + window.scrollTo = vi.fn(({ top }) => { + window.scrollY = Math.min(top, maxScroll) + }) + + const first = renderHook(() => useScrollRestoration()) + scrollTo(saved) + first.unmount() + + maxScroll = 0 + window.scrollY = 0 + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + return (height) => (maxScroll = height) + } + + const advanceFrames = () => act(() => vi.advanceTimersByTime(100)) + + it('starts a pushed route at the top', () => { + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) + }) + + it('restores the saved offset when returning to an entry', () => { + const first = renderHook(() => useScrollRestoration()) + scrollTo(640) + first.unmount() + + mockLocation.pathname = '/album/al-1/show' + const second = renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) + second.unmount() + + mockLocation.pathname = '/album' + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 640 }) + }) + + it('tops a pushed route even if that key was seen before', () => { + const first = renderHook(() => useScrollRestoration()) + scrollTo(500) + first.unmount() + + mockHistory.action = 'PUSH' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) + }) + + // Restoring before the rows exist leaves the document too short: the browser clamps to the + // top and the offset is lost with no error. + it('waits for readiness before touching the scroll position', () => { + const { rerender } = renderHook( + ({ ready }) => useScrollRestoration(ready), + { + initialProps: { ready: false }, + }, + ) + expect(window.scrollTo).not.toHaveBeenCalled() + + rerender({ ready: true }) + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) + }) + + // Hash history assigns no location.key, so an implementation keyed on it would collapse every + // route into one slot and the detail page would erase the list's offset before we returned. + it('keeps a separate offset per route', () => { + const list = renderHook(() => useScrollRestoration()) + scrollTo(900) + list.unmount() + + mockLocation.pathname = '/album/al-1/show' + const detail = renderHook(() => useScrollRestoration()) + scrollTo(0) + detail.unmount() + + mockLocation.pathname = '/album' + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) + }) + + // Without the per-instance guard this fires again when readiness flickers, fighting a user who + // has already started scrolling. Nothing else in the suite pins it. + it('does not scroll again when readiness flickers on the same route', () => { + const { rerender } = renderHook( + ({ ready }) => useScrollRestoration(ready), + { + initialProps: { ready: true }, + }, + ) + expect(window.scrollTo).toHaveBeenCalledTimes(1) + + rerender({ ready: false }) + rerender({ ready: true }) + expect(window.scrollTo).toHaveBeenCalledTimes(1) + }) + + it('scrolls once per entry, not on every re-render', () => { + const { rerender } = renderHook(() => useScrollRestoration()) + rerender() + rerender() + expect(window.scrollTo).toHaveBeenCalledTimes(1) + }) + + // The artist page reports ready as soon as its header record is cached, while the albums below + // are still loading. A single scrollTo lands on a viewport-tall document and is clamped to 0. + it('keeps trying until the page is tall enough to hold the offset', () => { + const fillPageTo = returnToUnfilledPage(800) + expect(window.scrollY).toBe(0) + + fillPageTo(1200) + advanceFrames() + expect(window.scrollY).toBe(800) + }) + + it('stops retrying once the user scrolls somewhere else', () => { + const fillPageTo = returnToUnfilledPage(800) + + fillPageTo(1200) + window.scrollY = 300 + advanceFrames() + expect(window.scrollY).toBe(300) + }) + + // A trackpad back-swipe keeps firing wheel momentum through the restore, so treating input as + // the yield signal cancels the gesture's own restore. + it('keeps restoring while wheel momentum fires, as a back-swipe does', () => { + const fillPageTo = returnToUnfilledPage(800) + + act(() => { + for (let i = 0; i < 10; i++) { + window.dispatchEvent( + new WheelEvent('wheel', { deltaX: -30, deltaY: 0 }), + ) + } + }) + fillPageTo(1200) + advanceFrames() + expect(window.scrollY).toBe(800) + }) + + // The incoming page tops itself in its own layout effect, while this page's scroll listener is + // still attached. Banking the offset any later than the teardown records that 0 instead. + it('records the offset before the next page scrolls to the top', () => { + const Outgoing = () => { + useScrollRestoration() + return null + } + const Incoming = () => { + useLayoutEffect(() => { + window.scrollY = 0 + window.dispatchEvent(new Event('scroll')) + }, []) + return null + } + + const { rerender } = render(<Outgoing />) + scrollTo(1400) + + mockLocation.pathname = '/album/al-1/show' + rerender(<Incoming />) + + mockLocation.pathname = '/album' + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 1400 }) + }) + + it('keeps saving after the restore, so leaving again remembers the new offset', () => { + const first = renderHook(() => useScrollRestoration()) + scrollTo(200) + first.unmount() + + mockHistory.action = 'POP' + const second = renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 200 }) + scrollTo(900) + second.unmount() + + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) + }) +}) diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 0d986033d..2b5d13d13 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -3,10 +3,14 @@ import useMediaQuery from '@material-ui/core/useMediaQuery' import themes from './index' import { AUTO_THEME_ID } from '../consts' import config from '../config' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' const useCurrentTheme = () => { - const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') + // Runs above the ThemeProvider carrying the prop below, so it needs its own noSsr or the + // auto theme renders dark first and flips. + const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)', { + noSsr: true, + }) const theme = useSelector((state) => { if (state.theme === AUTO_THEME_ID) { return prefersLightMode ? themes.LightTheme : themes.DarkTheme @@ -50,7 +54,15 @@ const useCurrentTheme = () => { document.body.style.backgroundColor = bgColor }, [theme]) - return theme + // We never server-render, so let media queries resolve on the first render: the default + // defers them to an effect, which makes every mount paint the wrong breakpoint and reflow. + return useMemo( + () => ({ + ...theme, + props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } }, + }), + [theme], + ) } export default useCurrentTheme From d764a1e9d77d255a79a13eac1ef039957051252c Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 6 Aug 2026 00:40:58 -0400 Subject: [PATCH 410/596] fix(log): change debug log level to trace to reduce log noise from cron Signed-off-by: Deluan <deluan@navidrome.org> --- scheduler/log_adapter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/log_adapter.go b/scheduler/log_adapter.go index 057818785..f7089f97c 100644 --- a/scheduler/log_adapter.go +++ b/scheduler/log_adapter.go @@ -11,7 +11,7 @@ func (l *logger) Info(msg string, keysAndValues ...any) { "Scheduler: " + msg, } args = append(args, keysAndValues...) - log.Debug(args...) + log.Trace(args...) } func (l *logger) Error(err error, msg string, keysAndValues ...any) { From dce48ff650f0f7ba30d7f4113fc443751d4c93ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 6 Aug 2026 10:59:50 -0400 Subject: [PATCH 411/596] feat(smartplaylist): add album-level fields for sorting and filtering (#5899) Smart playlists could only sort by the track-level `dateadded`, which scatters an album's tracks because every track has its own timestamp. There was no way to express "newest albums first, tracks in album order". Adds five fields backed by the album table: albumdateadded, albumdatemodified, albumduration, albumsongcount and albumsize, so `"sort": "-albumdateadded, tracknumber"` now works. They filter as well as sort, which also enables rules like "everything from albums added this month" or "skip singles and EPs". These need the album table rather than the album_annotation table the existing album* fields join, so they get their own bit in the join mask. The bitmask already unions joins from both the expression and the sort fields, so a sort-only reference pulls in the join for the main query while correctly staying out of the percentage-limit count query. No COALESCE default is used. That mechanism exists because an album_annotation row is genuinely often absent; the album row always exists and song_count, duration and size are NOT NULL, so leaving the columns bare keeps filters index-friendly. albumdatemodified follows the existing track-level `datemodified` naming rather than the `albumdateupdated` spelling used in the request. albumreleasedate was considered and rejected: album.release_date is allOrNothing() over the tracks, so it equals the track-level releasedate when they agree and collapses to empty when they disagree. Closes https://github.com/navidrome/navidrome/discussions/5347 --- model/criteria/fields.go | 5 ++ persistence/criteria_sql.go | 6 +++ persistence/criteria_sql_test.go | 48 +++++++++++++++++++ persistence/e2e/smartplaylist_test.go | 37 ++++++++++++++ persistence/smart_playlist_repository.go | 14 +++--- persistence/smart_playlist_repository_test.go | 35 ++++++++++++++ 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 5c9ec898d..a9a634c01 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -71,6 +71,11 @@ var fieldMap = map[string]FieldInfo{ "albumlastplayed": {}, "albumdateloved": {}, "albumdaterated": {}, + "albumdateadded": {}, + "albumdatemodified": {}, + "albumduration": {}, + "albumsongcount": {}, + "albumsize": {}, "artistrating": {}, "artistloved": {Boolean: true}, "artistplaycount": {}, diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 43cab4fdd..70f9a1fb5 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -21,6 +21,7 @@ const ( smartPlaylistJoinNone smartPlaylistJoinType = 0 smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota smartPlaylistJoinArtistAnnotation + smartPlaylistJoinAlbum ) func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { @@ -103,6 +104,11 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdateadded": {expr: "album.created_at", joinType: smartPlaylistJoinAlbum}, + "albumdatemodified": {expr: "album.updated_at", joinType: smartPlaylistJoinAlbum}, + "albumduration": {expr: "album.duration", joinType: smartPlaylistJoinAlbum}, + "albumsongcount": {expr: "album.song_count", joinType: smartPlaylistJoinAlbum}, + "albumsize": {expr: "album.size", joinType: smartPlaylistJoinAlbum}, "artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, "artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation}, "artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 59bdc4452..0cd7187c5 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -51,6 +51,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), + Entry("album column", criteria.Gt{"albumSongCount": 5}, "album.song_count > ?", 5), + Entry("album duration column", criteria.Lt{"albumDuration": 600}, "album.duration < ?", 600), + Entry("album size column", criteria.Gt{"albumSize": 1000}, "album.size > ?", 1000), + Entry("album date column", criteria.After{"albumDateAdded": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.created_at > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("album modified column", criteria.Before{"albumDateModified": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.updated_at < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), // Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks // with no annotation row behave as that default. To keep the annotation index usable, the // COALESCE is dropped when the compared value cannot match the default (the missing-row @@ -298,6 +303,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) }) + It("sorts by album columns bare, with no COALESCE default", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()). + To(Equal("album.created_at desc, media_file.track_number asc")) + }) + It("sorts by multiple fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) }) @@ -323,6 +333,28 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + It("declares a joinType matching the table each field selects from", func() { + // Omitting the joinType still compiles, so without this the field would only fail at + // refresh time with "no such column". + joinByTable := map[string]smartPlaylistJoinType{ + "media_file": smartPlaylistJoinNone, + "annotation": smartPlaylistJoinNone, + "album": smartPlaylistJoinAlbum, + "album_annotation": smartPlaylistJoinAlbumAnnotation, + "artist_annotation": smartPlaylistJoinArtistAnnotation, + } + for name, field := range smartPlaylistFields { + if field.expr == "" { + continue + } + table, _, ok := strings.Cut(field.expr, ".") + Expect(ok).To(BeTrue(), "field %q has expr %q with no table prefix", name, field.expr) + want, known := joinByTable[table] + Expect(known).To(BeTrue(), "field %q selects from unknown table %q", name, table) + Expect(field.joinType).To(Equal(want), "field %q selects from %q but declares the wrong joinType", name, table) + } + }) + Describe("JSON condition merging", func() { It("merges multiple role conditions in an OR group into a single EXISTS", func() { expr := criteria.Any{ @@ -585,5 +617,21 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) + + It("keeps a sort-only album join out of the expression joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"} + cSQL := newSmartPlaylistCriteria(c) + + Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) + }) + + It("distinguishes the album join from the album annotation join", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} + joins := newSmartPlaylistCriteria(c).RequiredJoins() + + Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse()) + }) }) }) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 4966d3b8a..b96605d09 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -204,6 +204,43 @@ var _ = Describe("Smart Playlists", func() { }) }) + Describe("Album aggregate fields", func() { + // Abbey Road and IV have two tracks each; the other four albums have one. + It("matches albums with more than one track", func() { + results := evaluateRule(`{"all":[{"gt":{"albumsongcount":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog")) + }) + + It("matches single-track albums", func() { + results := evaluateRule(`{"all":[{"is":{"albumsongcount":1}}]}`) + Expect(results).To(ConsistOf("So What", "Bohemian Rhapsody", "All Along the Watchtower", + "We Are the Champions")) + }) + + It("matches albumDateAdded inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"albumdateadded":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches albumDateModified before a far-future date", func() { + results := evaluateRule(`{"all":[{"before":{"albumdatemodified":"2099-01-01"}}]}`) + Expect(results).To(HaveLen(8)) + }) + + // Fixture durations are randomized, so only the aggregate being populated can be asserted. + It("resolves albumDuration and albumSize to non-zero aggregates", func() { + Expect(evaluateRule(`{"all":[{"gt":{"albumduration":0}}]}`)).To(HaveLen(8)) + Expect(evaluateRule(`{"all":[{"gt":{"albumsize":0}}]}`)).To(HaveLen(8)) + }) + + It("groups by album date and orders within the album (issue #5347)", func() { + results := evaluateRuleOrdered( + `{"all":[{"is":{"album":"Abbey Road"}}],"sort":"-albumdateadded,tracknumber"}`) + Expect(results).To(Equal([]string{"Come Together", "Something"})) + }) + }) + Describe("Logic operators", func() { It("matches with ALL (AND)", func() { results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index f7c41597d..5f84981b3 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -122,7 +122,7 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file") countSq = r.addMediaFileAnnotationJoin(countSq, userID) - countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID) countSq = r.applyLibraryFilter(countSq, "media_file") cond, err := rulesSQL.Where() @@ -144,7 +144,7 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ } // buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, -// including necessary joins for annotations and library filtering. +// including the joins its fields require and library filtering. func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { orderBy := rulesSQL.OrderBy() sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). @@ -152,7 +152,7 @@ func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesS sq = r.addMediaFileAnnotationJoin(sq, userID) requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID) + sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID) sq = r.applyLibraryFilter(sq, "media_file") return sq } @@ -166,9 +166,8 @@ func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID " AND annotation.user_id = ?)", userID) } -// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on -// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { +// addSmartPlaylistJoins adds the left joins required by the criteria's fields. +func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { if joins.has(smartPlaylistJoinAlbumAnnotation) { sq = sq.LeftJoin("annotation AS album_annotation ON ("+ "album_annotation.item_id = media_file.album_id"+ @@ -181,6 +180,9 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j " AND artist_annotation.item_type = 'artist'"+ " AND artist_annotation.user_id = ?)", userID) } + if joins.has(smartPlaylistJoinAlbum) { + sq = sq.LeftJoin("album ON album.id = media_file.album_id") + } return sq } diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 8e3ae488f..e62436890 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -392,6 +393,40 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Describe("Smart Playlists with Album Aggregate Criteria", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + }) + + trackIDsOf := func(rules *criteria.Criteria) []string { + newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID }) + } + + It("filters on albumSongCount", func() { + // albumMultiDisc (ID "104") is the only fixture album with SongCount > 3 + rules := &criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumSongCount": 3}}} + + Expect(trackIDsOf(rules)).To(ConsistOf("2001", "2002", "2003", "2004")) + }) + + It("sorts by an album field not referenced in the expression (issue #5347)", func() { + // All four tracks share album 104, so the album date ties and disc/track number decide. + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Is{"album": "Multi Disc Album"}}, + Sort: "-albumDateAdded,discNumber,trackNumber", + } + + Expect(trackIDsOf(rules)).To(HaveExactElements("2002", "2004", "2003", "2001")) + }) + }) + Describe("Smart Playlists with Tag Criteria", func() { var mfRepo model.MediaFileRepository var testPlaylistID string From 0f4c9b82121f870e5c925657458a9b87912f59ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 7 Aug 2026 09:36:52 -0400 Subject: [PATCH 412/596] fix(ui): don't start playback when closing the disc cover lightbox (#5901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lightbox renders through a React portal (react-modal), and React propagates synthetic events up the React tree rather than the DOM tree, so clicking its close button or backdrop bubbled into the ancestor TableRow and triggered playSubset. The existing timestamp guard could not catch this: react-image-lightbox defers onCloseRequest by animationDuration via setTimeout, so lightboxClosedAt was only stamped 200ms after the click had already propagated. The guard is kept because it still covers the separate mobile ghost-click path, where the synthesized click lands on the row after the overlay unmounts. Stopping propagation in onCloseRequest — the approach used by ContextMenus, whose MUI onClose fires synchronously — does not work for the same reason. Instead, wrap the Lightbox in an element that stops propagation at the React tree boundary the portal bubbles through, which also covers the lightbox controls we don't own (zoom buttons, caption, image drag). --- ui/src/common/SongDatagrid.jsx | 20 ++++---- ui/src/common/SongDatagrid.test.jsx | 74 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 ui/src/common/SongDatagrid.test.jsx diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index d2c98bbe7..4a813f84d 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -78,7 +78,7 @@ const useStyles = makeStyles({ }, }) -const DiscSubtitleRow = forwardRef( +export const DiscSubtitleRow = forwardRef( ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { const translate = useTranslate() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -153,13 +153,17 @@ const DiscSubtitleRow = forwardRef( {subtitle} </Typography> {isLightboxOpen && !imageError && ( - <Lightbox - imagePadding={50} - animationDuration={200} - imageTitle={record.album + ' - ' + subtitle} - mainSrc={fullImageUrl} - onCloseRequest={handleCloseLightbox} - /> + // Lightbox portals out of the row, but React still bubbles its + // events up this tree, where the row's onClick would play the disc. + <span onClick={(e) => e.stopPropagation()}> + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.album + ' - ' + subtitle} + mainSrc={fullImageUrl} + onCloseRequest={handleCloseLightbox} + /> + </span> )} </TableCell> <TableCell> diff --git a/ui/src/common/SongDatagrid.test.jsx b/ui/src/common/SongDatagrid.test.jsx new file mode 100644 index 000000000..f8459d8eb --- /dev/null +++ b/ui/src/common/SongDatagrid.test.jsx @@ -0,0 +1,74 @@ +import React from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTheme, ThemeProvider } from '@material-ui/core/styles' +import { DiscSubtitleRow } from './SongDatagrid' + +vi.mock('../subsonic', () => ({ + default: { getDiscCoverArtUrl: () => 'http://localhost/cover.jpg' }, +})) + +vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) + +vi.mock('../common', () => ({ AlbumContextMenu: () => null })) + +vi.mock('react-dnd', () => ({ useDrag: () => [{}, vi.fn()] })) + +const record = { + id: 'song-1', + albumId: 'album-1', + album: 'The Album', + discNumber: 2, + discSubtitle: 'Bonus Disc', + updatedAt: '2024-01-01', +} + +const renderRow = (onClick) => + render( + <ThemeProvider theme={createTheme()}> + <table> + <tbody> + <DiscSubtitleRow record={record} onClick={onClick} colSpan={3} /> + </tbody> + </table> + </ThemeProvider>, + ) + +const openLightbox = () => { + fireEvent.click(document.querySelector('img')) + expect(document.querySelector('.ril__closeButton')).toBeTruthy() +} + +describe('DiscSubtitleRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('plays the disc when the row is clicked', () => { + const onClick = vi.fn() + renderRow(onClick) + fireEvent.click(screen.getByText('Bonus Disc')) + expect(onClick).toHaveBeenCalledWith(2) + }) + + it('does not play the disc when opening the lightbox', () => { + const onClick = vi.fn() + renderRow(onClick) + openLightbox() + expect(onClick).not.toHaveBeenCalled() + }) + + it('does not play the disc when closing the lightbox', () => { + const onClick = vi.fn() + renderRow(onClick) + openLightbox() + fireEvent.click(document.querySelector('.ril__closeButton')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('does not play the disc when clicking the lightbox backdrop', () => { + const onClick = vi.fn() + renderRow(onClick) + openLightbox() + fireEvent.click(document.querySelector('.ril__inner')) + expect(onClick).not.toHaveBeenCalled() + }) +}) From a0bf78cdeab4d0f316edcd37094f03d09f0ede8e Mon Sep 17 00:00:00 2001 From: ant <marxguey@proton.me> Date: Sat, 8 Aug 2026 12:40:05 -0700 Subject: [PATCH 413/596] fix(contrib): added missing hyphen in OpenRC script that caused crashes on startup (#5906) Signed-off-by: Ant <marxguey@proton.me> --- contrib/navidrome | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/navidrome b/contrib/navidrome index bb6908797..8ab5afe2e 100644 --- a/contrib/navidrome +++ b/contrib/navidrome @@ -2,7 +2,7 @@ name=$RC_SVCNAME command="/opt/navidrome/${RC_SVCNAME}" -command_args="-datafolder /opt/navidrome" +command_args="--datafolder /opt/navidrome" command_user="${RC_SVCNAME}" pidfile="/var/run/${RC_SVCNAME}.pid" output_log="/opt/navidrome/${RC_SVCNAME}.log" From 5a5311a9c0ac0147fb49ed990f4cbb4f6c7ac0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 8 Aug 2026 21:57:44 -0400 Subject: [PATCH 414/596] fix(plugins): make generated mock stubs nil-safe for nilable returns (#5909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ndpgen mock accessors asserted return types unconditionally, so a plugin test using Return(nil, ...) — the natural way to model an empty result or a pagination terminal page — panicked on the untyped-nil type assertion instead of returning the zero value. Guard pointer, slice, map and any returns in both the host-service client stubs and the PDK stub, and regenerate. --- plugins/cmd/ndpgen/internal/generator.go | 62 ++++++++---- plugins/cmd/ndpgen/internal/generator_test.go | 96 ++++++++++++++++++- .../internal/templates/client_stub.go.tmpl | 2 +- .../internal/templates/pdk_stub.go.tmpl | 2 +- plugins/pdk/go/host/mock_nil_returns_test.go | 27 ++++++ plugins/pdk/go/host/nd_host_cache_stub.go | 6 +- plugins/pdk/go/host/nd_host_config_stub.go | 6 +- plugins/pdk/go/host/nd_host_http_stub.go | 6 +- plugins/pdk/go/host/nd_host_kvstore_stub.go | 18 +++- plugins/pdk/go/host/nd_host_library_stub.go | 12 ++- plugins/pdk/go/host/nd_host_matcher_stub.go | 6 +- .../pdk/go/host/nd_host_subsonicapi_stub.go | 6 +- plugins/pdk/go/host/nd_host_task_stub.go | 6 +- plugins/pdk/go/host/nd_host_users_stub.go | 12 ++- plugins/pdk/go/pdk/pdk_stub.go | 24 ++++- 15 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 plugins/pdk/go/host/mock_nil_returns_test.go diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 50f53cd37..bfc1b7c4e 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -27,32 +27,46 @@ func hostFuncMap(svc Service) template.FuncMap { // Uses private (lowercase) type names for request/response structs. func clientFuncMap(svc Service) template.FuncMap { return template.FuncMap{ - "lower": strings.ToLower, - "title": strings.Title, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, - "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, - "formatDoc": formatDoc, - "mockReturnValues": mockReturnValues, + "lower": strings.ToLower, + "title": strings.Title, + "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, + "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, + "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, + "formatDoc": formatDoc, + "mockReturnBody": mockReturnBody, } } -// mockReturnValues generates the testify mock return value accessors for a method. -// For example: args.String(0), args.Bool(1), args.Error(2) -func mockReturnValues(m Method) string { +// mockReturnBody generates the testify mock body lines that extract return values. +// Nil-able returns are guarded so tests can use Return(nil, ...) without the +// type-assertion panic testify produces on an untyped nil. +func mockReturnBody(m Method) string { + var b strings.Builder var parts []string - idx := 0 - for _, r := range m.Returns { - parts = append(parts, mockAccessor(r.Type, idx)) - idx++ + for idx, r := range m.Returns { + if isNilableType(r.Type) { + name := fmt.Sprintf("r%d", idx) + fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, idx, name, r.Type) + parts = append(parts, name) + } else { + parts = append(parts, mockAccessor(r.Type, idx)) + } } if m.HasError { - parts = append(parts, fmt.Sprintf("args.Error(%d)", idx)) + parts = append(parts, fmt.Sprintf("args.Error(%d)", len(m.Returns))) } - return strings.Join(parts, ", ") + b.WriteString("\treturn " + strings.Join(parts, ", ")) + return b.String() +} + +// isNilableType reports whether a nil mock return value is a valid intent for the +// type, rather than a malformed test expectation. +func isNilableType(typ string) bool { + return strings.HasPrefix(typ, "*") || strings.HasPrefix(typ, "[]") || + strings.HasPrefix(typ, "map[") || typ == "any" || typ == "interface{}" } // mockAccessor returns the testify mock accessor call for a given type and index. @@ -725,7 +739,7 @@ func pdkFuncMap() template.FuncMap { "returnList": pdkReturnList, "argList": pdkArgList, "argListWithReceiver": pdkArgListWithReceiver, - "mockReturns": pdkMockReturns, + "mockReturnBody": pdkMockReturnBody, "constValue": pdkConstValue, "stubTypeUnderlying": stubTypeUnderlying, "methodReceiver": pdkMethodReceiver, @@ -834,12 +848,20 @@ func pdkMethodReceiver(receiver, typeName string) string { } // pdkMockReturns generates the mock return accessors for a function. -func pdkMockReturns(returns []PDKReturn) string { +func pdkMockReturnBody(returns []PDKReturn) string { + var b strings.Builder var parts []string for i, r := range returns { - parts = append(parts, mockAccessorForType(r.Type, i)) + if isNilableType(r.Type) { + name := fmt.Sprintf("r%d", i) + fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, i, name, r.Type) + parts = append(parts, name) + } else { + parts = append(parts, mockAccessorForType(r.Type, i)) + } } - return strings.Join(parts, ", ") + b.WriteString("\treturn " + strings.Join(parts, ", ")) + return b.String() } // mockAccessorForType returns the testify mock accessor for a type. diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 15b97e1ae..ea94c7fc6 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -748,8 +748,9 @@ var _ = Describe("Generator", func() { // Check bool return uses args.Bool(1) Expect(codeStr).To(ContainSubstring("args.Bool(1)")) - // Check []byte return uses args.Get(0).([]byte) - Expect(codeStr).To(ContainSubstring("args.Get(0).([]byte)")) + // []byte is nil-able, so it gets a guarded extraction + Expect(codeStr).To(ContainSubstring("var r0 []byte")) + Expect(codeStr).To(ContainSubstring("r0 = v.([]byte)")) // Check error returns use args.Error(N) Expect(codeStr).To(ContainSubstring("args.Error(")) @@ -1699,6 +1700,97 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("use base64")) }) }) + + Describe("nil-safe mock accessors", func() { + Describe("GenerateClientGoStub", func() { + It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() { + svc := Service{ + Name: "Paged", + Interface: "PagedService", + Methods: []Method{ + { + Name: "GetPage", + HasError: true, + Params: []Param{NewParam("query", "string")}, + Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")}, + }, + { + Name: "GetLabels", + HasError: true, + Params: []Param{NewParam("id", "string")}, + Returns: []Param{NewParam("labels", "map[string]string")}, + }, + }, + } + + code, err := GenerateClientGoStub(svc, "host") + Expect(err).NotTo(HaveOccurred()) + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring("var r0 []Item")) + Expect(codeStr).To(ContainSubstring("var r1 *PageOptions")) + Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {")) + Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)")) + Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)")) + Expect(codeStr).To(ContainSubstring("var r0 map[string]string")) + Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)")) + Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)")) + }) + + It("keeps non-nilable returns as inline accessors", func() { + svc := Service{ + Name: "Counter", + Interface: "CounterService", + Methods: []Method{ + { + Name: "Count", + HasError: true, + Params: []Param{NewParam("id", "string")}, + Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")}, + }, + }, + } + + code, err := GenerateClientGoStub(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)")) + Expect(codeStr).NotTo(ContainSubstring("var r0")) + }) + }) + + Describe("GeneratePDKGoStub", func() { + It("guards pointer and slice returns", func() { + symbols := &PDKSymbols{ + Functions: []PDKFunc{ + { + Name: "NewHTTPRequest", + Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, + Returns: []PDKReturn{{Type: "*HTTPRequest"}}, + }, + { + Name: "Input", + Returns: []PDKReturn{{Type: "[]byte"}}, + }, + }, + } + + code, err := GeneratePDKGoStub(symbols) + Expect(err).NotTo(HaveOccurred()) + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest")) + Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)")) + Expect(codeStr).To(ContainSubstring("var r0 []byte")) + Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)")) + }) + }) + }) }) func writeFile(path, content string) error { diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index d2a8f3f27..eb5e9f844 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -51,7 +51,7 @@ var {{.Service.Name}}Mock = &mock{{.Service.Name}}Service{} // {{.Name}} is the mock method for {{$.Service.Name}}{{.Name}}. func (m *mock{{$.Service.Name}}Service) {{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} { args := m.Called({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}}{{end}}) - return {{mockReturnValues .}} +{{mockReturnBody .}} } // {{$.Service.Name}}{{.Name}} delegates to the mock instance. diff --git a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl index 6a57a6469..2bd73f107 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl @@ -34,7 +34,7 @@ func ResetMock() { func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} args := PDKMock.Called({{argList .Params}}) - return {{mockReturns .Returns}} +{{mockReturnBody .Returns}} {{- else}} PDKMock.Called({{argList .Params}}) {{- end}} diff --git a/plugins/pdk/go/host/mock_nil_returns_test.go b/plugins/pdk/go/host/mock_nil_returns_test.go new file mode 100644 index 000000000..ea5f334f1 --- /dev/null +++ b/plugins/pdk/go/host/mock_nil_returns_test.go @@ -0,0 +1,27 @@ +//go:build !wasip1 + +package host + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Return(nil, ...) models "no result"; the generated accessors must yield the +// zero value instead of panicking on the type assertion. +func TestMockNilPointerReturn(t *testing.T) { + HTTPMock.On("Send", HTTPRequest{URL: "http://nil.example"}).Return(nil, nil) + + resp, err := HTTPSend(HTTPRequest{URL: "http://nil.example"}) + require.NoError(t, err) + require.Nil(t, resp) +} + +func TestMockNilSliceReturn(t *testing.T) { + LibraryMock.On("GetAllLibraries").Return(nil, nil) + + libs, err := LibraryGetAllLibraries() + require.NoError(t, err) + require.Nil(t, libs) +} diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go index 46bb44bcf..faf4e65d2 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -154,7 +154,11 @@ func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { // GetBytes is the mock method for CacheGetBytes. func (m *mockCacheService) GetBytes(key string) ([]byte, bool, error) { args := m.Called(key) - return args.Get(0).([]byte), args.Bool(1), args.Error(2) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0, args.Bool(1), args.Error(2) } // CacheGetBytes delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 463c29b76..0327346a3 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -59,7 +59,11 @@ func ConfigGetInt(key string) (int64, bool) { // Keys is the mock method for ConfigKeys. func (m *mockConfigService) Keys(prefix string) []string { args := m.Called(prefix) - return args.Get(0).([]string) + var r0 []string + if v := args.Get(0); v != nil { + r0 = v.([]string) + } + return r0 } // ConfigKeys delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 09c4d0fcf..8706ac9e5 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -43,7 +43,11 @@ var HTTPMock = &mockHTTPService{} // Send is the mock method for HTTPSend. func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) { args := m.Called(request) - return args.Get(0).(*HTTPResponse), args.Error(1) + var r0 *HTTPResponse + if v := args.Get(0); v != nil { + r0 = v.(*HTTPResponse) + } + return r0, args.Error(1) } // HTTPSend delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index fce038aa1..e5243860a 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -64,7 +64,11 @@ func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { // Get is the mock method for KVStoreGet. func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) { args := m.Called(key) - return args.Get(0).([]byte), args.Bool(1), args.Error(2) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0, args.Bool(1), args.Error(2) } // KVStoreGet delegates to the mock instance. @@ -81,7 +85,11 @@ func KVStoreGet(key string) ([]byte, bool, error) { // GetMany is the mock method for KVStoreGetMany. func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) { args := m.Called(keys) - return args.Get(0).(map[string][]byte), args.Error(1) + var r0 map[string][]byte + if v := args.Get(0); v != nil { + r0 = v.(map[string][]byte) + } + return r0, args.Error(1) } // KVStoreGetMany delegates to the mock instance. @@ -116,7 +124,11 @@ func KVStoreHas(key string) (bool, error) { // List is the mock method for KVStoreList. func (m *mockKVStoreService) List(prefix string) ([]string, error) { args := m.Called(prefix) - return args.Get(0).([]string), args.Error(1) + var r0 []string + if v := args.Get(0); v != nil { + r0 = v.([]string) + } + return r0, args.Error(1) } // KVStoreList delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 1e4c230c3..0232c49d9 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -39,7 +39,11 @@ var LibraryMock = &mockLibraryService{} // GetLibrary is the mock method for LibraryGetLibrary. func (m *mockLibraryService) GetLibrary(id int32) (*Library, error) { args := m.Called(id) - return args.Get(0).(*Library), args.Error(1) + var r0 *Library + if v := args.Get(0); v != nil { + r0 = v.(*Library) + } + return r0, args.Error(1) } // LibraryGetLibrary delegates to the mock instance. @@ -56,7 +60,11 @@ func LibraryGetLibrary(id int32) (*Library, error) { // GetAllLibraries is the mock method for LibraryGetAllLibraries. func (m *mockLibraryService) GetAllLibraries() ([]Library, error) { args := m.Called() - return args.Get(0).([]Library), args.Error(1) + var r0 []Library + if v := args.Get(0); v != nil { + r0 = v.([]Library) + } + return r0, args.Error(1) } // LibraryGetAllLibraries delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go index 07b34bb82..ade368c06 100644 --- a/plugins/pdk/go/host/nd_host_matcher_stub.go +++ b/plugins/pdk/go/host/nd_host_matcher_stub.go @@ -31,7 +31,11 @@ var MatcherMock = &mockMatcherService{} // MatchSongs is the mock method for MatcherMatchSongs. func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { args := m.Called(songs, opts) - return args.Get(0).([]*types.Track), args.Error(1) + var r0 []*types.Track + if v := args.Get(0); v != nil { + r0 = v.([]*types.Track) + } + return r0, args.Error(1) } // MatcherMatchSongs delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 6d3a56b35..ef3d1b735 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -39,7 +39,11 @@ func SubsonicAPICall(uri string) (string, error) { // CallRaw is the mock method for SubsonicAPICallRaw. func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { args := m.Called(uri) - return args.String(0), args.Get(1).([]byte), args.Error(2) + var r1 []byte + if v := args.Get(1); v != nil { + r1 = v.([]byte) + } + return args.String(0), r1, args.Error(2) } // SubsonicAPICallRaw delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index 286d14035..f2c45a2b5 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -70,7 +70,11 @@ func TaskEnqueue(queueName string, payload []byte) (string, error) { // Get is the mock method for TaskGet. func (m *mockTaskService) Get(taskID string) (*TaskInfo, error) { args := m.Called(taskID) - return args.Get(0).(*TaskInfo), args.Error(1) + var r0 *TaskInfo + if v := args.Get(0); v != nil { + r0 = v.(*TaskInfo) + } + return r0, args.Error(1) } // TaskGet delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index 8858b2109..e0f8c0939 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -33,7 +33,11 @@ var UsersMock = &mockUsersService{} // GetUsers is the mock method for UsersGetUsers. func (m *mockUsersService) GetUsers() ([]User, error) { args := m.Called() - return args.Get(0).([]User), args.Error(1) + var r0 []User + if v := args.Get(0); v != nil { + r0 = v.([]User) + } + return r0, args.Error(1) } // UsersGetUsers delegates to the mock instance. @@ -49,7 +53,11 @@ func UsersGetUsers() ([]User, error) { // GetAdmins is the mock method for UsersGetAdmins. func (m *mockUsersService) GetAdmins() ([]User, error) { args := m.Called() - return args.Get(0).([]User), args.Error(1) + var r0 []User + if v := args.Get(0); v != nil { + r0 = v.([]User) + } + return r0, args.Error(1) } // UsersGetAdmins delegates to the mock instance. diff --git a/plugins/pdk/go/pdk/pdk_stub.go b/plugins/pdk/go/pdk/pdk_stub.go index 3bdbb1cb7..8f2707d56 100644 --- a/plugins/pdk/go/pdk/pdk_stub.go +++ b/plugins/pdk/go/pdk/pdk_stub.go @@ -62,7 +62,11 @@ func GetConfig(key string) (string, bool) { // GetVar GetVar returns the byte slice (if any) associated with `key`. func GetVar(key string) []byte { args := PDKMock.Called(key) - return args.Get(0).([]byte) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0 } // GetVarInt GetVarInt returns the int associated with `key` (or 0 if none). @@ -74,7 +78,11 @@ func GetVarInt(key string) int { // Input Input returns a slice of bytes from the host. func Input() []byte { args := PDKMock.Called() - return args.Get(0).([]byte) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0 } // InputJSON InputJSON returns unmartialed JSON data from the host "input". @@ -108,7 +116,11 @@ func LogMemory(level LogLevel, m Memory) { // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { args := PDKMock.Called(method, url) - return args.Get(0).(*HTTPRequest) + var r0 *HTTPRequest + if v := args.Get(0); v != nil { + r0 = v.(*HTTPRequest) + } + return r0 } func NewMemory(offset uint64, length uint64) Memory { args := PDKMock.Called(offset, length) @@ -139,7 +151,11 @@ func OutputString(s string) { // ParamBytes ParamBytes returns bytes from Extism host memory given an offset. func ParamBytes(offset uint64) []byte { args := PDKMock.Called(offset) - return args.Get(0).([]byte) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0 } // ParamString ParamString returns UTF-8 string data from Extism host memory given an offset. From b0c6d2e444607e1bb71cafee9361b2dfe4139927 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:13:29 -0700 Subject: [PATCH 415/596] feat(plugins): add scrobbles access to PDK (#5795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial scrobble api * feat: add scrobble retrieval api * address feedback (1) * fix spelling * be explicit about get * add primary key field, update index, remove rowid references * use unix timestamp for input and output * initial api, some testing * add tests, add count retrieval * add docs, test for rejected user * add permission validation for scrobble retriever * chore(plugins): fix typos in scrobble retriever Rename newScrobbleRetreverService, and fix FromTImestamp/nonero in the ScrobbleRetriever doc comments, which generate into the Go and Rust PDKs. Also corrects two mislabelled test entries. * fix(plugins): make scrobble pagination order deterministic Sorting only by submission_time left the order of equal timestamps up to the query planner, but the cursor skips ties by offset, so an unstable order can repeat or drop scrobbles between pages. Break ties on scrobbles.id, which the existing scrobbles_user_time index already yields for free. Descending is now honoured for every combination of From/To rather than only when both or neither is set. This changes the default for a lone ToTimestamp from newest-first to oldest-first. * refactor(plugins): return the next page's options from GetScrobbles Paging previously meant reading NextTimestamp and Cursor off the response and deciding where each belonged: NextTimestamp into FromTimestamp when ascending or ToTimestamp when descending, and Cursor copied every time, including when 0. Both are silent data-loss bugs when a plugin gets them wrong. GetScrobbles now returns the options for the following page, or nil when the range is exhausted, so a plugin passes the value straight back and repeats. ScrobbleCursor and ScrobbleList are gone; the query itself is unchanged. * docs(plugins): warn against setting ScrobbleOptions.Offset manually The all-ties carry rule assumes Offset counts already-returned rows at the boundary timestamp, which only holds for the options GetScrobbles returns. A hand-built From+Offset combination can silently skip scrobbles, so document the field as managed pagination state instead of a generic skip. * docs(plugins): document the ScrobbleRetriever host service in the README Covers the manifest permissions (including the users requirement), the four host functions, the options/ref field tables, and the pagination loop with its two gotchas: the host-managed offset and the adjusted range on the returned next options. * chore(plugins): regenerate scrobble retriever stub with nil-safe accessors --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- plugins/README.md | 77 ++++ plugins/host/scrobble_retriever.go | 89 ++++ plugins/host/scrobbleretriever_gen.go | 230 +++++++++++ plugins/host_scrobbleretriever.go | 161 ++++++++ plugins/host_scrobbleretriever_test.go | 390 ++++++++++++++++++ plugins/manager_loader.go | 8 + plugins/manifest-schema.json | 14 + plugins/manifest.go | 8 +- plugins/manifest_gen.go | 9 + plugins/manifest_test.go | 30 ++ plugins/pdk/go/host/doc.go | 1 + .../pdk/go/host/nd_host_scrobbleretriever.go | 266 ++++++++++++ .../go/host/nd_host_scrobbleretriever_stub.go | 136 ++++++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../src/nd_host_scrobbleretriever.rs | 237 +++++++++++ .../testdata/test-scrobble-retriever/go.mod | 16 + .../testdata/test-scrobble-retriever/go.sum | 14 + .../testdata/test-scrobble-retriever/main.go | 120 ++++++ .../test-scrobble-retriever/manifest.json | 14 + 19 files changed, 1826 insertions(+), 2 deletions(-) create mode 100644 plugins/host/scrobble_retriever.go create mode 100644 plugins/host/scrobbleretriever_gen.go create mode 100644 plugins/host_scrobbleretriever.go create mode 100644 plugins/host_scrobbleretriever_test.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs create mode 100644 plugins/testdata/test-scrobble-retriever/go.mod create mode 100644 plugins/testdata/test-scrobble-retriever/go.sum create mode 100644 plugins/testdata/test-scrobble-retriever/main.go create mode 100644 plugins/testdata/test-scrobble-retriever/manifest.json diff --git a/plugins/README.md b/plugins/README.md index a2f9532ca..adc177324 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -38,6 +38,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) + - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) - [Building Plugins](#building-plugins) - [Examples](#examples) @@ -896,6 +897,82 @@ for _, user := range users { admins, err := host.UsersGetAdmins() ``` +### ScrobbleRetriever + +Retrieve the scrobble history of users the plugin has been granted access to. Each scrobble carries only the media file ID and the submission time; use the Matcher host service to resolve them to track metadata. + +**Manifest permission:** + +```json +{ + "permissions": { + "scrobbleRetriever": { + "reason": "Sync scrobble history to an external service" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +} +``` + +> **Important:** The `scrobbleRetriever` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. + +**Host functions:** + +| Function | Parameters | Returns | +|---------------------------------------|-----------------------|----------------------------------| +| `scrobbleretriever_getfirsttimestamp` | `username` | Unix timestamp of oldest scrobble, or null | +| `scrobbleretriever_getlasttimestamp` | `username` | Unix timestamp of newest scrobble, or null | +| `scrobbleretriever_getscrobbles` | `username`, `options` | One page of scrobbles + options for the next page | +| `scrobbleretriever_getscrobblecount` | `username`, `options` | Number of scrobbles in the range | + +**ScrobbleOptions fields** (all optional): + +| Field | Type | Description | +|-----------------|---------|----------------------------------------------------------| +| `fromTimestamp` | int64 | Start of the range (inclusive). Default: first scrobble | +| `toTimestamp` | int64 | End of the range (inclusive). Default: last scrobble | +| `descending` | boolean | Newest first. Default: oldest first | +| `maxItems` | int | Page size, capped at 5000 (the default) | +| `offset` | int | Managed by the host for pagination. Never set it manually | + +**ScrobbleRef fields:** + +| Field | Type | Description | +|------------------|--------|------------------------------------------------| +| `id` | int64 | Scrobble ID, unique even for duplicate submissions | +| `mediaFileId` | string | The media file that was scrobbled | +| `submissionTime` | int64 | Unix timestamp of the submission | + +**Usage:** + +`GetScrobbles` returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil: + +```go +opts := host.ScrobbleOptions{MaxItems: 500} +var all []host.ScrobbleRef +for { + page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts) + if err != nil { + return err + } + all = append(all, page...) + if next == nil { + break // no more scrobbles + } + opts = *next +} + +// Range boundaries and counts +first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles +count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{ + FromTimestamp: first, +}) +``` + +> **Note:** The returned `next` options carry an adjusted `fromTimestamp`/`toTimestamp`, so keep a copy of your original options if you still need the range. + --- ## Configuration diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go new file mode 100644 index 000000000..2582dd650 --- /dev/null +++ b/plugins/host/scrobble_retriever.go @@ -0,0 +1,89 @@ +package host + +import "context" + +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + // The ID of the scrobble. Useful if duplicate scrobbles happen for the same time + ID int64 `json:"id"` + // The ID of the MediaFile submitted at this time + MediaFileID string `json:"mediaFileId"` + // The UNIX timestamp this scrobble was submitted + SubmissionTime int64 `json:"submissionTime"` +} + +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + // If true, return scrobbles from newest to oldest. Defaults to oldest first + Descending bool `json:"descending"` + // The maximum number of items to retrieve. This is capped at 5000, the + // default if not specified + MaxItems int `json:"maxItems"` + // Pagination state managed by GetScrobbles; only meaningful on the options it returns. + // Never set it or combine it with your own timestamps — scrobbles may be silently skipped + Offset int `json:"offset,omitempty"` +} + +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +// ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. +// It will only provide the media_file ID and submission time, which can be combined with the MatcherService +// to fetch deduped tracks +// +//nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever +type ScrobbleRetrieverService interface { + // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. + // If the user has no scrobbles, returns nil + //nd:hostfunc + GetFirstTimestamp(ctx context.Context, username string) (*int64, error) + + // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user + // If the user has no scrobbles, return nil + //nd:hostfunc + GetLastTimestamp(ctx context.Context, username string) (*int64, error) + + // GetScrobbles returns one page of scrobbles for a user. + // + // Parameters: + // - username: the user to query for scrobbles + // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble + // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble + // - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest + // - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 + // - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually + // + // Returns: + // - scrobbles: The scrobbles in the requested range, ordered by submission time + // (ties broken by scrobble ID) in the direction given by options.Descending + // - next: The options for the following page, or nil once no scrobbles remain. + // Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an + // adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range + //nd:hostfunc + GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (scrobbles []ScrobbleRef, next *ScrobbleOptions, err error) + + // GetScrobbleCount returns the number of scrobbles for a user in a given range + // + // Parameters: + // - username: the user to query for scrobbles + // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble + // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble + // + // Returns: + // - the number of scrobbles in the given range, or 0 + //nd:hostfunc + GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) +} diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go new file mode 100644 index 000000000..7a00c9c1f --- /dev/null +++ b/plugins/host/scrobbleretriever_gen.go @@ -0,0 +1,230 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetFirstTimestampResponse is the response type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesResponse struct { + Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` + Next *ScrobbleOptions `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobbleCountResponse is the response type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { + return []extism.HostFunction{ + newScrobbleRetrieverGetFirstTimestampHostFunction(service), + newScrobbleRetrieverGetLastTimestampHostFunction(service), + newScrobbleRetrieverGetScrobblesHostFunction(service), + newScrobbleRetrieverGetScrobbleCountHostFunction(service), + } +} + +func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getfirsttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetFirstTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetFirstTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetFirstTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getlasttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetLastTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetLastTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetLastTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobbles", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobblesRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + scrobbles, next, svcErr := service.GetScrobbles(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobblesResponse{ + Scrobbles: scrobbles, + Next: next, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobbleCountHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobblecount", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobbleCountRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetScrobbleCount(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobbleCountResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// scrobbleretrieverWriteResponse writes a JSON response to plugin memory. +func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// scrobbleretrieverWriteError writes an error response to plugin memory. +func scrobbleretrieverWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go new file mode 100644 index 000000000..7417d7c50 --- /dev/null +++ b/plugins/host_scrobbleretriever.go @@ -0,0 +1,161 @@ +package plugins + +import ( + "context" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" +) + +// maxScrobbleItems caps how many scrobbles a single GetScrobbles call can return. +const maxScrobbleItems = 5000 + +type scrobbleRetrieverServiceImpl struct { + ds model.DataStore + users userAccess +} + +func newScrobbleRetrieverService(ds model.DataStore, users userAccess) host.ScrobbleRetrieverService { + return &scrobbleRetrieverServiceImpl{ + ds: ds, + users: users, + } +} + +func (s *scrobbleRetrieverServiceImpl) getUserContext(ctx context.Context, username string) (context.Context, error) { + usr, err := s.users.resolve(ctx, s.ds, username) + if err != nil { + return nil, fmt.Errorf("scrobbleRetriever: %w", err) + } + + ctx = request.WithUser(ctx, *usr) + return ctx, nil +} + +func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, username string, order string) (*int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, err + } + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) + if err != nil { + return nil, err + } + + if len(scrobbles) == 0 { + return nil, nil + } + + return &scrobbles[0].SubmissionTime, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetFirstTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "ASC") +} + +func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "DESC") +} + +func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) ([]host.ScrobbleRef, *host.ScrobbleOptions, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, nil, err + } + + if options.MaxItems < 1 || options.MaxItems > maxScrobbleItems { + options.MaxItems = maxScrobbleItems + } + options.Offset = max(options.Offset, 0) + + order := "ASC" + if options.Descending { + order = "DESC" + } + + // Fetch one more item than requested. The last item is the next timestamp to fetch + lookahead := options.MaxItems + 1 + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{ + Max: lookahead, + Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), + // The id tiebreak makes the order of equal timestamps stable, which is what + // lets Offset skip exactly the ties already returned + Sort: "scrobbles.submission_time, scrobbles.id", + Order: order, + Offset: options.Offset, + }) + if err != nil { + return nil, nil, err + } + + var next *host.ScrobbleOptions + targetLen := len(scrobbles) + + if len(scrobbles) == lookahead { + nextTimestamp := scrobbles[lookahead-1].SubmissionTime + targetLen = lookahead - 1 + + ties := 0 + for i := targetLen - 1; i >= 0; i-- { + if scrobbles[i].SubmissionTime != nextTimestamp { + break + } + ties++ + } + + // Every scrobble in this page shares the timestamp, so the ties skipped by the + // incoming offset are still ahead of us and must be carried over + if ties == targetLen { + ties += options.Offset + } + + advanced := options + advanced.Offset = ties + if options.Descending { + advanced.ToTimestamp = &nextTimestamp + } else { + advanced.FromTimestamp = &nextTimestamp + } + next = &advanced + } + + scrobbleRefs := make([]host.ScrobbleRef, targetLen) + for idx, scrobble := range scrobbles[:targetLen] { + scrobbleRefs[idx] = host.ScrobbleRef{ + ID: scrobble.ID, + MediaFileID: scrobble.MediaFileID, + SubmissionTime: scrobble.SubmissionTime, + } + } + + return scrobbleRefs, next, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return 0, err + } + + return s.ds.Scrobble(ctx).CountAll(model.QueryOptions{ + Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), + }) +} + +func scrobbleRangeFilters(from, to *int64) squirrel.And { + var filters squirrel.And + if from != nil { + filters = append(filters, squirrel.GtOrEq{"scrobbles.submission_time": *from}) + } + if to != nil { + filters = append(filters, squirrel.LtOrEq{"scrobbles.submission_time": *to}) + } + return filters +} + +var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go new file mode 100644 index 000000000..ab817c282 --- /dev/null +++ b/plugins/host_scrobbleretriever_test.go @@ -0,0 +1,390 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "time" + + extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// scrobblePage mirrors what the test plugin emits for the multi-return GetScrobbles +type scrobblePage struct { + Scrobbles []host.ScrobbleRef `json:"scrobbles"` + Next *host.ScrobbleOptions `json:"next"` +} + +var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { + var ( + manager *Manager + tmpDir string + dataStore *tests.MockDataStore + ) + + p := func(val int64) *int64 { + return &val + } + + opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions { + return &host.ScrobbleOptions{ + FromTimestamp: from, ToTimestamp: to, + Descending: descending, Offset: offset, MaxItems: maxItems, + } + } + + BeforeAll(func() { + ctx := GinkgoT().Context() + + var err error + tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*") + Expect(err).ToNot(HaveOccurred()) + + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + userRepo := dataStore.User(ctx) + // Add test users + _ = userRepo.Put(&model.User{ + ID: "user1", + UserName: "testuser", + IsAdmin: false, + }) + _ = userRepo.Put(&model.User{ + ID: "admin1", + UserName: "adminuser", + IsAdmin: true, + }) + + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "1", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "2", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "3", LibraryID: 1}) + Expect(err).To(BeNil()) + + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + err = scrobbleRepo.RecordScrobble("1", time.Unix(0, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("2", time.Unix(1, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("3", time.Unix(2, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("1", time.Unix(2, 0)) + Expect(err).To(BeNil()) + + // Create and configure manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + } + router := &fakeSubsonicRouter{} + manager.SetSubsonicRouter(router) + + // Pre-enable the plugin in the mock repo so it loads on startup + // Compute SHA256 of the plugin file to match what syncPlugins will compute + pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + dataStore.MockedPlugin = tests.CreateMockPluginRepo() + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + enabledPlugin := model.Plugin{ + ID: "test-scrobble-retriever", + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + Users: `["user1","admin1"]`, + } + mockPluginRepo.SetData(model.Plugins{enabledPlugin}) + + // Start the manager + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + var instance *extism.Plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin := manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + + var err error + ctx := GinkgoT().Context() + instance, err = plugin.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + instance.Close(ctx) + }) + }) + + Describe("not authorized", func() { + It("rejects first timestamp", func() { + exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects last timestamp", func() { + exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + }) + + Describe("no items", func() { + It("calls get first timestamp", func() { + exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls get last timestamp", func() { + exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls scrobbles", func() { + exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`))) + }) + + It("calls get scrobble count", func() { + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + Expect(output).To(Equal([]byte("0"))) + }) + }) + + Describe("with items", func() { + scrobbles := []host.ScrobbleRef{ + {ID: 1, MediaFileID: "1", SubmissionTime: 0}, + {ID: 2, MediaFileID: "2", SubmissionTime: 1}, + {ID: 3, MediaFileID: "3", SubmissionTime: 2}, + {ID: 4, MediaFileID: "1", SubmissionTime: 2}, + } + + scrobblesReversed := make([]host.ScrobbleRef, 4) + + BeforeAll(func() { + for idx := range scrobbles { + scrobblesReversed[3-idx] = scrobbles[idx] + } + }) + + It("calls get first timestamp", func() { + exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":0}"))) + }) + + It("calls get last timestamp", func() { + exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":2}"))) + }) + + DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) { + exit, output, err := instance.Call("call_get_scrobbles", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + var page scrobblePage + Expect(json.Unmarshal(output, &page)).To(Succeed()) + Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) + }, + Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil), + Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil), + Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil), + Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil), + Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)), + Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)), + Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil), + Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil), + Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil), + Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)), + Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)), + ) + + DescribeTable("GetScrobblesCount", func(params string, count int) { + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + value, err := strconv.ParseInt(string(output), 10, 64) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(int64(count))) + }, + Entry("gets all scrobbles", `{"username":"adminuser"}`, 4), + Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2), + Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1), + Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1), + Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4), + ) + }) + + Context("Complex edge cases - multiple scrobbles at the same timestamp", func() { + duplicates := make([]host.ScrobbleRef, 5) + duplicatesReversed := make([]host.ScrobbleRef, 5) + + BeforeAll(func() { + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + + for i := range 5 { + err := scrobbleRepo.RecordScrobble("3", time.Unix(100, 0)) + Expect(err).To(BeNil()) + + scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100} + duplicates[i] = scrobble + duplicatesReversed[4-i] = scrobble + } + }) + + getPage := func(o host.ScrobbleOptions) scrobblePage { + GinkgoHelper() + payload, err := json.Marshal(struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + Descending bool `json:"descending"` + Offset int `json:"offset,omitempty"` + MaxItems int `json:"maxItems"` + }{ + Username: "adminuser", + FromTimestamp: o.FromTimestamp, + ToTimestamp: o.ToTimestamp, + Descending: o.Descending, + Offset: o.Offset, + MaxItems: o.MaxItems, + }) + Expect(err).ToNot(HaveOccurred()) + + exit, output, err := instance.Call("call_get_scrobbles", payload) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + var page scrobblePage + Expect(json.Unmarshal(output, &page)).To(Succeed()) + return page + } + + // every scrobble here shares timestamp 100, so only Offset advances between pages + tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions { + return *opts(p(100), p(100), descending, offset, maxItems) + } + + DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) { + var next *host.ScrobbleOptions + if nextOffset != nil { + next = opts(p(100), p(100), descending, *nextOffset, count) + } + Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) + }, + Entry("All tracks, in order", 0, 100, false, duplicates, nil), + Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil), + Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)), + Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)), + Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil), + Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)), + Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)), + Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)), + Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil), + ) + + It("walks every page exactly once when all timestamps collide", func() { + for _, descending := range []bool{false, true} { + var seen []host.ScrobbleRef + o := tied(0, 2, descending) + var page scrobblePage + for range 10 { + page = getPage(o) + seen = append(seen, page.Scrobbles...) + if page.Next == nil { + break + } + o = *page.Next + } + Expect(page.Next).To(BeNil(), "pagination did not terminate") + if descending { + Expect(seen).To(Equal(duplicatesReversed)) + } else { + Expect(seen).To(Equal(duplicates)) + } + } + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 0e9e7bb62..439c944f5 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -177,6 +177,14 @@ var hostServices = []hostServiceEntry{ return host.RegisterStorageHostFunctions(service), nil, nil }, }, + { + name: "ScrobbleRetriever", + hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + service := newScrobbleRetrieverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) + return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 736677313..dae0fd937 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -95,6 +95,9 @@ }, "storage": { "$ref": "#/$defs/StoragePermission" + }, + "scrobbleRetriever": { + "$ref": "#/$defs/ScrobbleRetrieverPermission" } } }, @@ -258,6 +261,17 @@ "description": "Explanation for why storage access is needed" } } + }, + "ScrobbleRetrieverPermission": { + "type": "object", + "description": "Scrobble retriever permissions for retrieving scrobbles from users", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why scrobble retriever access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index ef70e8d9d..5c0d91f51 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -53,10 +53,14 @@ func ParseManifest(data []byte) (*Manifest, error) { // This validates rules like "SubsonicAPI permission requires users permission". func (m *Manifest) Validate() error { // SubsonicAPI permission requires users permission - if m.Permissions != nil && m.Permissions.Subsonicapi != nil { - if m.Permissions.Users == nil { + if m.Permissions != nil && m.Permissions.Users == nil { + if m.Permissions.Subsonicapi != nil { return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") } + + if m.Permissions.ScrobbleRetriever != nil { + return fmt.Errorf("'scrobbleRetriever' permission requires 'users' permission to be declared") + } } // Matcher returns library content, so it requires the library permission (which diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 5e0035bd5..6f5c01596 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -178,6 +178,9 @@ type Permissions struct { // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + // ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever". + ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"` + // Storage corresponds to the JSON schema field "storage". Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` @@ -200,6 +203,12 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Scrobble retriever permissions for retrieving scrobbles from users +type ScrobbleRetrieverPermission struct { + // Explanation for why scrobble retriever access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Storage permissions for enabling persistent read-write storage exclusively for // the plugin type StoragePermission struct { diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 32bcbba08..bfd8bcac1 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -222,6 +222,36 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("library")) }) + It("validates manifest with scrobbleRetriever and users permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + Users: &UsersPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when scrobbleRetriever without users permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("scrobbleRetriever")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index e4157e29c..9063f2bd0 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. + - ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users. - Storage: provides access to a plugin-specific directory with read/write permissions - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go new file mode 100644 index 000000000..596d7ec1d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -0,0 +1,266 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + +// ScrobbleOptions represents the ScrobbleOptions data structure. +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` + Descending bool `json:"descending"` + MaxItems int `json:"maxItems"` + Offset int `json:"offset"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp +func scrobbleretriever_getfirsttimestamp(uint64) uint64 + +// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp +func scrobbleretriever_getlasttimestamp(uint64) uint64 + +// scrobbleretriever_getscrobbles is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobbles +func scrobbleretriever_getscrobbles(uint64) uint64 + +// scrobbleretriever_getscrobblecount is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobblecount +func scrobbleretriever_getscrobblecount(uint64) uint64 + +type scrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobblesResponse struct { + Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` + Next *ScrobbleOptions `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetFirstTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getfirsttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetFirstTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetLastTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetLastTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. +// GetScrobbles returns one page of scrobbles for a user. +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest +// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +// +// Returns: +// - scrobbles: The scrobbles in the requested range, ordered by submission time +// (ties broken by scrobble ID) in the direction given by options.Descending +// - next: The options for the following page, or nil once no scrobbles remain. +// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an +// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range +func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobblesRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobbles(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobblesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, nil, errors.New(response.Error) + } + + return response.Scrobbles, response.Next, nil +} + +// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// +// Returns: +// - the number of scrobbles in the given range, or 0 +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobbleCountRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobbleCountResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go new file mode 100644 index 000000000..a75fcebb5 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/stretchr/testify/mock" +) + +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + +// ScrobbleOptions represents the ScrobbleOptions data structure. +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` + Descending bool `json:"descending"` + MaxItems int `json:"maxItems"` + Offset int `json:"offset"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// mockScrobbleRetrieverService is the mock implementation for testing. +type mockScrobbleRetrieverService struct { + mock.Mock +} + +// ScrobbleRetrieverMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) +var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} + +// GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. +func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { + args := m.Called(username) + var r0 *int64 + if v := args.Get(0); v != nil { + r0 = v.(*int64) + } + return r0, args.Error(1) +} + +// ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetFirstTimestamp(username) +} + +// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. +func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { + args := m.Called(username) + var r0 *int64 + if v := args.Get(0); v != nil { + r0 = v.(*int64) + } + return r0, args.Error(1) +} + +// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetLastTimestamp(username) +} + +// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. +func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + args := m.Called(username, options) + var r0 []ScrobbleRef + if v := args.Get(0); v != nil { + r0 = v.([]ScrobbleRef) + } + var r1 *ScrobbleOptions + if v := args.Get(1); v != nil { + r1 = v.(*ScrobbleOptions) + } + return r0, r1, args.Error(2) +} + +// ScrobbleRetrieverGetScrobbles delegates to the mock instance. +// GetScrobbles returns one page of scrobbles for a user. +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest +// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +// +// Returns: +// - scrobbles: The scrobbles in the requested range, ordered by submission time +// (ties broken by scrobble ID) in the direction given by options.Descending +// - next: The options for the following page, or nil once no scrobbles remain. +// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an +// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range +func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + return ScrobbleRetrieverMock.GetScrobbles(username, options) +} + +// GetScrobbleCount is the mock method for ScrobbleRetrieverGetScrobbleCount. +func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + args := m.Called(username, options) + return args.Get(0).(int64), args.Error(1) +} + +// ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// +// Returns: +// - the number of scrobbles in the given range, or 0 +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + return ScrobbleRetrieverMock.GetScrobbleCount(username, options) +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3da023f5c..33cbe7725 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users. //! - [`storage`] - provides access to a plugin-specific directory with read/write permissions //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -102,6 +103,13 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } +#[doc(hidden)] +mod nd_host_scrobbleretriever; +/// allows a plugin to retrieve scrobbles for one or more authorized users. +pub mod scrobbleretriever { + pub use super::nd_host_scrobbleretriever::*; +} + #[doc(hidden)] mod nd_host_storage; /// provides access to a plugin-specific directory with read/write permissions diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs new file mode 100644 index 000000000..fe80d98f6 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -0,0 +1,237 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// ScrobbleCountOptions carries optional parameters for counting user scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleCountOptions { + #[serde(default)] + pub from_timestamp: Option<i64>, + #[serde(default)] + pub to_timestamp: Option<i64>, +} + +/// ScrobbleOptions carries optional parameters for retrieving user scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleOptions { + #[serde(default)] + pub from_timestamp: Option<i64>, + #[serde(default)] + pub to_timestamp: Option<i64>, + pub descending: bool, + pub max_items: i32, + #[serde(default)] + pub offset: i32, +} + +/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleRef { + pub id: i64, + pub media_file_id: String, + pub submission_time: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampResponse { + #[serde(default)] + result: Option<i64>, + #[serde(default)] + error: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampResponse { + #[serde(default)] + result: Option<i64>, + #[serde(default)] + error: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesRequest { + username: String, + options: ScrobbleOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesResponse { + #[serde(default)] + scrobbles: Vec<ScrobbleRef>, + #[serde(default)] + next: Option<ScrobbleOptions>, + #[serde(default)] + error: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountRequest { + username: String, + options: ScrobbleCountOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountResponse { + #[serde(default)] + result: i64, + #[serde(default)] + error: Option<String>, +} + +#[host_fn] +extern "ExtismHost" { + fn scrobbleretriever_getfirsttimestamp(input: Json<ScrobbleRetrieverGetFirstTimestampRequest>) -> Json<ScrobbleRetrieverGetFirstTimestampResponse>; + fn scrobbleretriever_getlasttimestamp(input: Json<ScrobbleRetrieverGetLastTimestampRequest>) -> Json<ScrobbleRetrieverGetLastTimestampResponse>; + fn scrobbleretriever_getscrobbles(input: Json<ScrobbleRetrieverGetScrobblesRequest>) -> Json<ScrobbleRetrieverGetScrobblesResponse>; + fn scrobbleretriever_getscrobblecount(input: Json<ScrobbleRetrieverGetScrobbleCountRequest>) -> Json<ScrobbleRetrieverGetScrobbleCountResponse>; +} + +/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +/// If the user has no scrobbles, returns nil +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_first_timestamp(username: &str) -> Result<Option<i64>, Error> { + let response = unsafe { + scrobbleretriever_getfirsttimestamp(Json(ScrobbleRetrieverGetFirstTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// If the user has no scrobbles, return nil +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_last_timestamp(username: &str) -> Result<Option<i64>, Error> { + let response = unsafe { + scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// GetScrobbles returns one page of scrobbles for a user. +/// +/// Parameters: +/// - username: the user to query for scrobbles +/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +/// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest +/// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +/// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +/// +/// Returns: +/// - scrobbles: The scrobbles in the requested range, ordered by submission time +/// (ties broken by scrobble ID) in the direction given by options.Descending +/// - next: The options for the following page, or nil once no scrobbles remain. +/// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an +/// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range +/// +/// # Arguments +/// * `username` - String parameter. +/// * `options` - ScrobbleOptions parameter. +/// +/// # Returns +/// A tuple of (scrobbles, next). +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec<ScrobbleRef>, Option<ScrobbleOptions>), Error> { + let response = unsafe { + scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok((response.0.scrobbles, response.0.next)) +} + +/// GetScrobbleCount returns the number of scrobbles for a user in a given range +/// +/// Parameters: +/// - username: the user to query for scrobbles +/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +/// +/// Returns: +/// - the number of scrobbles in the given range, or 0 +/// +/// # Arguments +/// * `username` - String parameter. +/// * `options` - ScrobbleCountOptions parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobble_count(username: &str, options: ScrobbleCountOptions) -> Result<i64, Error> { + let response = unsafe { + scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod new file mode 100644 index 000000000..59486d796 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/go.mod @@ -0,0 +1,16 @@ +module test-scrobble-retriever + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-scrobble-retriever/go.sum b/plugins/testdata/test-scrobble-retriever/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go new file mode 100644 index 000000000..cc824acb9 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +func main() { + +} + +type TestScrobbleTimestampOutput struct { + Timestamp *int64 `json:"timestamp"` +} + +//go:wasmexport call_get_first_timestamp +func callGetFirstTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetFirstTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +//go:wasmexport call_get_last_timestamp +func callGetLastTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetLastTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +type TestScrobbleOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + Descending bool `json:"descending"` + Offset int `json:"offset,omitempty"` + MaxItems int `json:"maxItems"` +} + +// TestScrobblePage mirrors the multi-return shape so tests can assert on both values +type TestScrobblePage struct { + Scrobbles []host.ScrobbleRef `json:"scrobbles"` + Next *host.ScrobbleOptions `json:"next"` +} + +//go:wasmexport call_get_scrobbles +func callGetScrobbles() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + scrobbles, next, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + MaxItems: options.MaxItems, + Descending: options.Descending, + Offset: options.Offset, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + if scrobbles == nil { + scrobbles = []host.ScrobbleRef{} + } + + pdk.OutputJSON(TestScrobblePage{Scrobbles: scrobbles, Next: next}) + return 0 +} + +type TestScrobbleCountOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +//go:wasmexport call_get_scrobbles_count +func callGetScrobblesCount() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + count, err := host.ScrobbleRetrieverGetScrobbleCount(options.Username, host.ScrobbleCountOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputString(strconv.FormatInt(count, 10)) + return 0 +} diff --git a/plugins/testdata/test-scrobble-retriever/manifest.json b/plugins/testdata/test-scrobble-retriever/manifest.json new file mode 100644 index 000000000..a60203704 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Scrobble Retriever", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for scrobble retriever integration settings", + "permissions": { + "scrobbleRetriever": { + "reason": "For testing scrobble retriever operations" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +} From 5c5b849a4e6133eb7fc7286007ee2260f0aa3638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 8 Aug 2026 22:25:48 -0400 Subject: [PATCH 416/596] docs(plugins): sync plugins README with the implementation (#5912) * docs(plugins): document the Matcher host service The Matcher host service (matcher_matchsongs, permission 'matcher') was missing from the README entirely. Add its section with permission example, the matcher-requires-library rule, result ordering/scoping semantics, and a Go usage example. * docs(plugins): add missing nd_scrobbler_playback_report to Scrobbler The Scrobbler capability has four required methods, but the README said three and omitted nd_scrobbler_playback_report from the function table. Add the table row and document the PlaybackReport input payload and its possible playback states. * docs(plugins): remove stale coverartarchive-as example row The AssemblyScript coverartarchive-as example no longer exists in plugins/examples/ (and no AssemblyScript code remains in the repo), so drop its row from the examples table. * docs(plugins): add Command Line Interface section The 'navidrome plugin' CLI (list, info, validate, enable, disable, edit, rescan) was undocumented; the README's only mention was a single inline 'plugin edit --write-access' reference, and it claimed plugins could only be enabled/disabled via the UI. Add a CLI section with all subcommands and the 'plugin edit' flags, and link it from Runtime Management. * docs(plugins): add pdk and types packages to Go PDK table The Go PDK package table was missing two of the packages that ship under plugins/pdk/go/: 'types' (shared DTOs used across capabilities and host services) and 'pdk' (the extism/go-pdk wrapper the README's own examples already import). * docs(plugins): correct capabilities and host services in examples table Several example rows understated what the plugins implement: the scheduler/websocket callback capabilities were omitted for nowplaying-py, library-inspector-rs, crypto-ticker, and discord-rich-presence-rs, and the Discord example also uses the Config host service. * docs(plugins): state that scheduler/taskqueue permissions require callbacks Manifest validation rejects a plugin that declares the scheduler or taskqueue permission without exporting nd_scheduler_callback or nd_task_execute, but the README worded both callbacks as soft suggestions (TaskWorker was even labeled optional). Make the load-time failure explicit in both capability sections. * docs(plugins): document HTTP, WebSocket, and Task limits and defaults Add the runtime limits plugins actually hit: HTTP default 10s timeout, 5-redirect cap with per-hop host re-validation, and 10MB response cap; the 30s timeout on WebSocket callbacks; and the full task_createqueue parameter list (delayMs, retentionMs) with queue defaults, retention bounds, the 1MB payload cap, and task persistence across restarts. * docs(plugins): include scrobbleRetriever in user-scoped authorization note The ScrobbleRetriever host service added in #5795 is user-scoped like subsonicapi and scrobbler, but the Security section's user-scoped authorization item didn't list it. * docs(plugins): clarify matcher user scoping requires the users permission The Matcher usage example set opts.Username while the section's manifest example declares only matcher+library; without the users permission no users can be assigned to the plugin, so userAccess.resolve rejects the call at runtime. Make the example unscoped and state that user scoping requires the users permission with users assigned. Raised by Codex review on PR #5912. --- plugins/README.md | 143 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 23 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index adc177324..b04e12bd9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -34,12 +34,14 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Task](#task) - [WebSocket](#websocket) - [Library](#library) + - [Matcher](#matcher) - [Artwork](#artwork) - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) +- [Command Line Interface](#command-line-interface) - [Building Plugins](#building-plugins) - [Examples](#examples) - [Security](#security) @@ -226,13 +228,14 @@ func ndGetArtistBiography() int32 { ### Scrobbler -Integrates with external scrobbling services. All three methods are **required**. +Integrates with external scrobbling services. All four methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|--------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|---------------------------------|--------------|--------|------------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| `nd_scrobbler_playback_report` | See below | (none) | Send playback state report | > **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. @@ -270,6 +273,25 @@ Integrates with external scrobbling services. All three methods are **required** } ``` +**PlaybackReport Input:** + +Same `username` and `track` fields, plus playback state details: + +```json +{ + "username": "john", + "track": { ... }, + "state": "playing", + "positionMs": 45000, + "playbackRate": 1.0, + "playerId": "player-id", + "playerName": "My Client", + "timestamp": 1703270400 +} +``` + +`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`. + **Error Handling:** On success, return `0`. On failure, use `pdk.SetError()` with one of these error types: @@ -309,7 +331,7 @@ Each match contains a `song` reference and a `similarity` score (float64, 0.0– ### TaskWorker -Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. +Processes tasks from a queue. **Required** if your plugin uses the [Task](#task) host service: declaring the `taskqueue` permission without exporting this function fails the plugin load. | Function | Input | Output | Description | |---------------------|---------------------------------------------|---------|----------------------| @@ -329,7 +351,7 @@ Useful for initializing connections, scheduling recurring tasks, etc. Errors are ### SchedulerCallback -Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service: declaring the `scheduler` permission without exporting this function fails the plugin load. | Function | Input | Output | Description | |---------------------------|----------------------------------------------|--------|-----------------------------| @@ -346,6 +368,8 @@ Receives WebSocket events. Export any subset of these to handle events from the | `nd_websocket_on_error` | `{connectionId, error}` | Connection error | | `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +Each callback invocation is subject to a 30-second timeout. + --- ## Host Services @@ -390,6 +414,8 @@ Make HTTP requests to external services. This is a dedicated host service (separ |-------------|----------------------------------------------------------|----------------------------------| | `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | +**Limits:** Requests time out after 10 seconds by default (override per request with `timeoutMs`). Redirects are followed up to 5 times, re-checking the allowed hosts on every hop. Response bodies are capped at 10MB. + **Usage:** ```go @@ -615,13 +641,21 @@ Background task queue with retry support. Plugins enqueue tasks and process them **Host functions:** -| Function | Parameters | Description | -|---------------------|---------------------------------------------------|----------------------------| -| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | -| `task_enqueue` | `queueName, payload` | Add a task to the queue | -| `task_get` | `taskID` | Get task status and result | -| `task_cancel` | `taskID` | Cancel a pending task | -| `task_clearqueue` | `queueName` | Remove all tasks from queue| +| Function | Parameters | Description | +|---------------------|---------------------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, delayMs, retentionMs` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +Tasks are persisted to SQLite, so pending tasks survive server restarts. Queue behavior: + +- `concurrency` – Parallel workers (default 1), capped by the manifest's `maxConcurrency` +- `maxRetries` – Retries for a failed task (default 0); `backoffMs` (default 1000) doubles on each retry +- `delayMs` – Minimum delay between consecutive task starts, useful for rate limiting (default 0) +- `retentionMs` – How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week) +- Payloads are capped at 1MB **Usage:** @@ -747,6 +781,45 @@ for _, lib := range libraries { } ``` +### Matcher + +Match externally-obtained songs (e.g. results from a recommendation or similarity API) to tracks in the local library, reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). + +**Manifest permission:** + +```json +{ + "permissions": { + "matcher": { + "reason": "Resolve external recommendations to library tracks" + }, + "library": { + "reason": "Required by the matcher permission" + } + } +} +``` + +> **Important:** The `matcher` permission requires the `library` permission. + +**Host functions:** + +| Function | Parameters | Returns | +|----------------------|---------------|-------------------------| +| `matcher_matchsongs` | `songs, opts` | Array of matched tracks | + +The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set `opts.username` to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the [`users`](#users) permission, with users assigned to the plugin. + +**Usage:** + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +matches, err := host.MatcherMatchSongs([]types.SongRef{ + {Name: "Song Title", Artists: []types.ArtistRef{{Name: "Artist Name"}}}, +}, host.MatchOptions{}) +``` + ### Artwork Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). @@ -1008,6 +1081,29 @@ For more advanced access (listing keys, integer values), use the [Config](#confi --- +## Command Line Interface + +Manage plugins from the command line with `navidrome plugin`: + +| Command | Description | +|--------------------------------------------------------|------------------------------------------------------------| +| `navidrome plugin list [-f table\|csv\|json]` | List installed plugins | +| `navidrome plugin info <id\|file.ndp> [-f text\|json]` | Show details for an installed plugin or a `.ndp` package | +| `navidrome plugin validate <id\|file.ndp>` | Validate an installed plugin or a `.ndp` package manifest | +| `navidrome plugin enable <id>` | Enable a plugin | +| `navidrome plugin disable <id>` | Disable a plugin | +| `navidrome plugin edit <id>` | Update a plugin's config and/or permissions | +| `navidrome plugin rescan` | Re-discover plugins in the plugins folder | + +**`plugin edit` flags:** + +- `--config <json>` / `--config-file <path>` – Set the plugin configuration (`-` reads from stdin) +- `--users <list>` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users +- `--libraries <list>` / `--all-libraries` – Library IDs the plugin may access (comma-separated or JSON array), or all libraries +- `--write-access` / `--no-write-access` – Allow or deny the plugin write access to libraries + +--- + ## Building Plugins ### Supported Languages @@ -1070,6 +1166,8 @@ replace github.com/navidrome/navidrome => ../../.. | `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | | `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | | `host` | `plugins/pdk/go/host` | Host service SDK (all services) | +| `types` | `plugins/pdk/go/types` | Shared data types (tracks, artists, song refs) | +| `pdk` | `plugins/pdk/go/pdk` | Low-level helpers (wraps extism/go-pdk: config, logging, memory) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -1168,12 +1266,11 @@ See [examples/](examples/) for complete working plugins: | [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | | [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | | [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler, SchedulerCallback, WebSocketCallback | HTTP, WebSocket, Cache, Scheduler, Artwork, Config | Discord integration | --- @@ -1186,7 +1283,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration +6. **User-Scoped Authorization** – Plugins with `subsonicapi`, `scrobbleRetriever`, or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed --- @@ -1201,7 +1298,7 @@ If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin c ### Enabling/Disabling Plugins -Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persisted in the database. +Plugins can be enabled/disabled via the Navidrome UI or the [`navidrome plugin` CLI](#command-line-interface). The plugin state is persisted in the database. ### Important Notes From 944ca3100fce669e4bd63be09bdd158ab59faa3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 9 Aug 2026 15:03:27 -0400 Subject: [PATCH 417/596] feat(artwork): new artwork pipeline with background resolution and Low Quality Image Placeholders (#5847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): add artwork, item_artwork and artwork_queue tables * feat(artwork): add artwork models, repository interfaces and mocks * feat(artwork): implement artwork repository * feat(artwork): implement item_artwork repository with batched hydration * feat(artwork): implement artwork_queue repository * feat(artwork): add content-addressed originals store * feat(artwork): add artwork prune (orphan cleanup) * fix(artwork): never sweep files on transient DB errors during prune * refactor(artwork): fold originals package into core/artwork as ImageStore * refactor(artwork): merge item artwork state into ArtworkRepository * fix(artwork): chunk unbounded IN clauses and restore interface docs * refactor(artwork): apply simplify-pass cleanups Internal item_artwork sqlRepository helper, toSQLArgs upserts, batched queue enqueue, EnqueueStaleAbsent moved to queue repo, snapshot-based prune sweep, mock/real semantics aligned. * fix(artwork): address review findings on prune/sweep races and mock fidelity Sweep now honors an mtime grace window (in-flight acquisitions and temp files), reacquired orphans reset the prune grace window, and the queue mock implements real stale-absent semantics. * fix(artwork): atomic orphan deletion and timestamp semantics from review DeleteOrphans re-checks age+references at delete time, PutItemArtwork defaults attempted_at, queue mock timestamps mirror SQL. * fix(artwork): guard orphan file removal with the prune grace window Duplicate ImageStore writes refresh the file mtime and Remove skips files newer than the cutoff, so overlapping acquisitions cannot lose their store files to a concurrent prune. * fix(artwork): rewrite vanished duplicates and sweep stale mime variants Write falls through to a real write when the liveness touch fails, and sweep retention now matches the recorded mime's extension so obsolete variants are reclaimed. * fix(artwork): index artwork_queue in dequeue order The previous leading retry_at range column forced a temp B-tree sort of the whole eligible set on every DequeueBatch; ordering the index by (priority DESC, enqueued_at) lets scans stop after the batch size. * fix(artwork): honor the orphan cutoff in the repository mock The mock's DeleteOrphans now applies createdBefore like the SQL implementation, and a new spec covers a freshly reacquired row surviving prune. * fix(artwork): reject malformed hashes in ImageStore operations Known-absent states carry an empty hash and malformed persisted hashes could panic path sharding or inject separators; Write/Open/Remove now return an error for anything but 16 lowercase hex chars. * fix(artwork): mock PutImage refreshes created_at like the SQL repository Prune specs now age fixtures directly instead of seeding stale timestamps through the upsert. * fix(artwork): store backing-file provenance per item, not per hash * feat(artwork): import blurhash encoder from #5797 * feat(artwork): add worker-side artwork resolvers * fix(artwork): propagate playlist tile failures and dedupe external step * feat(artwork): add acquisition processor Resolves one queue item end to end: hash/dedup, decode + 128px thumbnail blurhash, place bytes (store vs source file), and persist found/absent/ failed state for the worker (Task 4) to act on. * style(artwork): tighten processor comments to budget * feat(artwork): add acquisition worker service * feat(artwork): enqueue artwork resolution from scan and CRUD paths * feat(artwork): artwork backfill, fingerprint re-resolution and scheduled jobs * test(artwork): leak/soak coverage and deferred assertions * fix(artwork): propagate transient artist image errors to the worker callGetImage swallowed all agent errors, so an agent outage surfaced as ErrNotFound and the worker settled artist artwork as a definitive absent (and reset the breaker). Add an additive ArtistImageResult path that returns the underlying agent error on transient failure while keeping ArtistImage byte-identical for existing callers; the worker's artist external step uses it via fromArtistExternalResult. * fix(artwork): resolve full playlist source chain resolvePlaylist only built the generated grid, dropping the uploaded-image, sidecar and ExternalImageURL sources the old reader_playlist.go chain serves. Port the full chain before the grid fallback: uploaded (upload), sidecar (folder), and ExternalImageURL routed through extGate with the same extError semantics as the other external steps. Also rewires the artist external step onto ArtistImageResult. * fix(artwork): purge dangling queue rows and guard concurrent re-enqueues Queue rows for deleted entities failed forever (Get -> ErrNotFound -> failed -> capped retries, unbounded). Add ArtworkQueueRepository.PurgeDangling, called from Prune next to the item_artwork purge. Separately, the found/absent path unconditionally deleted the dequeued row, erasing a concurrent scan re-enqueue; switch to DeleteIfUnchanged, which deletes only while retry_at still matches the dequeued value (verified retry_at is the column an Enqueue upsert resets). * style(artwork): fix comment accuracy and budget; fingerprint ArtistImageFolder Correct the inverted workerDeps.extGate comment, trim over-budget doc comments, and add conf.Server.ArtistImageFolder to the resolution fingerprint so an image-folder change re-resolves artist artwork. * fix(artwork): treat missing local playlist cover as definitive, not transient A playlist ExternalImageURL pointing at a local file that fails to open was routed through extError, causing failed/48h-retry loops that burn a rate limiter token forever instead of falling through to the generated grid. * refactor(artwork): deduplicate purge loop, backfill table, and extGate alias * fix(artwork): cap resolved image reads A user-editable ExternalImageURL can point at an arbitrarily large endpoint; a fast server could make the worker buffer hundreds of MB inside the 5s HTTP timeout. Bound the read to a fixed 20MB cap (no config knob) via io.LimitReader and fail the item if it is exceeded. * fix(artwork): retry higher-priority external art after fallback hit With CoverArtPriority="external,cover.jpg", a transient external failure followed by a folder hit dropped the external error: the worker recorded found and deleted the queue row, so the configured higher-priority external art was never retried. Carry extError onto the fallback resolution and add an outcomeFoundStale that persists+serves the art but reschedules via MarkFailed, giving the external source another chance. When external later answers definitively-not-found, the hit is not stale and the row is deleted. * fix(artwork): treat playlist cover URL 404 as definitive miss The playlist ExternalImageURL step used sources.go's fromURL, which maps any non-200 to a generic error, so a stale URL returning 404/410 was classified transient: infinite backoff plus it counted toward the circuit breaker, blocking valid external work. Add a local fetch in resolve.go that maps 404/410 to model.ErrNotFound (definitive) while keeping other non-200s transient. sources.go is left untouched. * test(artwork): move soak test into the Ginkgo suite * test(artwork): make leak and permission tests pass on linux goleak now ignores notify's nonrecursive-tree goroutines (linux uses inotify, which spawns dispatch+internal instead of darwin's recursive dispatch), and the read-only-dir prune spec skips under root, where permission bits cannot make Remove fail. * fix(artwork): reject decompression-bomb dimensions before decoding * fix(artwork): keep fresh re-enqueues ahead of stale failure backoff * fix(artwork): include M3U external art flag in the config fingerprint * fix(artwork): resolve private playlists with an admin context * test(artwork): convert non-synctest timing tests to Ginkgo specs TestArtworkBackoffSchedule and TestArtworkWorkerRunNoLeak needed no real *testing.T (no synctest), so move them into worker_test.go as Ginkgo specs. TestArtworkBreakerHalfOpen stays plain since testing/synctest requires a real *testing.T, matching core/scrobbler's precedent. * fix(artwork): store backing-file provenance per item, not per hash * fix(artwork): apply image limits to playlist tile decoding decodeTile ran image.Decode on every sampled album's resolved bytes before processItem's maxImageBytes/maxImagePixels guards applied, letting an oversized or decompression-bomb tile fully decode unbounded. Enforce both caps inside decodeTile itself. * refactor(artwork): reuse auth.WithAdminUser and dedupe image cap guards * perf(artwork): fetch only IDs for backfill enumeration Backfill enumerated every album, artist, playlist and radio via GetAll and mapped out just the ID. GetAll materializes full entities (library joins, participant/stats/tags JSON, annotation, artwork hydration), so on a large library it loaded tens of thousands of heavy structs only to read one field each — spiking transient RSS to ~1GB during the one-time upgrade backfill, a memory risk on small NAS/Pi hardware. Add GetAllIDs to the album, artist, playlist and radio repositories: it reuses each repo's base row-set filter (library visibility, artist content join, playlist userFilter) but projects only id, skipping the heavy columns and post-processing. A per-repo parity test asserts GetAllIDs returns exactly the same id set as GetAll. Verified on a 727MB / 29k-artist production DB copy: peak RSS during backfill dropped from ~1012MB to ~89MB, file descriptors flat, same 36,138 items enqueued. * feat(artwork): promote worker concurrency and external rate to real configs The artwork worker's drain speed was governed by two hidden Dev flags, DevArtworkWorkerConcurrency and DevArtworkExternalRPS, both defaulting to 2. On a large library's one-time backfill the external rate limiter is the real ceiling: every art-less item waits on it before the (rate-limited) external lookup, so the drain crawls at ~RPS items/sec while local-art items are unaffected. Promote both to documented, supported options: ArtworkWorkerConcurrency (default 4) sets local-resolution parallelism, ArtworkExternalMaxRPS (default 2, 0 = unlimited) caps external-agent lookups to stay polite to Last.fm/Deezer/etc. Operators can now trade first-backfill speed against external-API rate limits. The old Dev names still map for backward compat. * fix(deezer): never return empty-image-id placeholder pictures * feat(agents): enumerate enabled image-retriever agents per capability * feat(artwork): worker fetches agent images directly with per-agent rate limits and breakers * fix(artwork): treat agent not-found as breaker success * feat(model): content-hash artwork id suffix and hydratable per-entity image state * feat(persistence): hydrate artwork hash and absence onto entity pages * feat(artwork): resolve media_file embedded art in the worker, invalidate on rescan * feat(artwork): broadcast refresh events when artwork lands * fix(artwork): broadcast refresh for stale-found artwork too * feat(artwork): state-backed serving path with provisional read-through * feat(server): serve artwork from persisted state with content-hash caching * feat(subsonic): content-hash coverArt ids, omit artwork on known-absent * refactor(artwork): delete the legacy reader chain, cache warmer, and provider image methods * feat(artwork): precache on acquisition, bump on upload/radio changes, manual re-resolve API * test(artwork): end-to-end coverage for the serving cutover * chore(artwork): generic 500 bodies on refresh endpoint, trim stale test comments * fix(artwork): request read-through must not reset the failure backoff The provisional read-through and dangling re-enqueue used Enqueue, whose upsert resets retry_at, so any browse of an unresolved entity that was backing off after an external failure made it immediately eligible again — defeating the exponential backoff during a provider outage. Add EnqueueBump, which raises priority but leaves an existing row's retry_at intact, and route the serving path through it. Scan and manual re-resolve keep Enqueue's reset (a detected change wants immediate retry). * fix(artwork): keep an eligible track's cover requestable when its album is absent An embedded-eligible track with no resolved item_artwork row inherited the album's ImageAbsent, so when the album resolved absent (e.g. CoverArtPriority without 'embedded') the track's coverArt was omitted permanently — the client never requested it, so the lazy mediafile path never resolved it — even though the serving path would extract and serve the track's own embedded art. Hydration now never copies the album's absence onto an eligible-but-unresolved track. * fix(artwork): validate each agent image URL before picking the largest bestImageURL selected the largest by size and only then parsed it, so a malformed largest URL (e.g. a bad percent-escape) returned nil and shadowed a valid smaller candidate, contradicting the documented skip-unparseable behavior. Parse per candidate and compare sizes only among URLs that parse. * fix(artwork): fall back to disc art, not the album, for multi-disc tracks serveMediaFile delegated an absent/ineligible track straight to AlbumCoverArtID, skipping the disc-specific lookup that MediaFile.CoverArtID (and the deleted legacy reader) use. On multi-disc albums with per-disc images that served the album cover instead of the configured disc artwork. Delegate through DiscCoverArtID. * fix(artwork): enqueue uploaded artwork only after the filename is persisted SetImage cleared state and enqueued the bump before the caller stored the new filename, so a worker drain in that window could resolve against the old (already deleted) file and settle absent, leaving the upload unused until a later scan. Move the invalidate+enqueue into EnqueueArtwork, which each caller now invokes after the entity Put. * fix(artwork): keep multi-disc tracks requestable when the album is absent Round-1's hydration fix still copied the album's known-absent onto a non-eligible (or own-absent) track, but MediaFile.CoverArtID routes a multi-disc track to disc art, which resolves provisionally and is never known-absent. Marking it absent made Subsonic omit coverArt so clients never requested a valid disc image. Only mark a single-disc track absent, and only when its own art won't resolve. * fix(artwork): serve a local playlist ExternalImageURL as a file-backed reference A local ExternalImageURL was resolved through the external step and labelled external, so placeBytes copied it into the content-addressed store and dropped its path/mtime — replacing the file never tripped the staleness check. Classify local references as file-backed (resolved in place, even on the request path) and keep store-backed behaviour only for http(s) URLs. * fix(artwork): requeue playlist cover when its track set changes A generated-grid cover went stale after track mutations: nothing re-resolved the playlist's artwork, and the request path deliberately never rebuilds the grid, so serveEntity kept returning the old grid hash indefinitely. Enqueue pl artwork from refreshCounters (the choke point for every track-set change); no clear, so the old cover keeps serving until the worker rebuilds. * fix(artwork): open library-backed artwork through its on-disk root A library configured with a file:// path stored absRoot as the raw URI, so Abs produced strings like file:/music/cover.jpg that os.Open/os.Stat reject — folder, upload and embedded art were treated as dangling on every request, looping forever. Normalize a file:// path to its parsed OS path (the same root os.DirFS uses); non-local schemes are left unchanged (out of scope, per the artwork-musicfs TODO). * fix(artwork): only use disc resolution for multi-disc albums DiscCoverArtID returns a dc- id for any track with DiscNumber>0, so serveDisc ran the full DiscArtPriority chain even for single-disc albums, where a stray disc*/ embedded image could shadow higher-priority album art. Gate disc resolution on the album having more than one disc, matching the legacy reader; single-disc tracks serve album art directly. * fix(artwork): invalidate artwork when an uploaded image is deleted Deleting an artist/radio/playlist upload cleared the filename but left the found item_artwork row and its hash, so lists kept advertising the deleted cover's hash-suffixed immutable URL and clients could display it indefinitely. Call EnqueueArtwork after the delete-side Put, symmetric with upload, so the state is cleared and re-resolved to the next source (or absent). * fix(artwork): restore synthetic-artist guard and unicode normalization in agent lookups Moving agent calls into the worker bypassed two behaviors of the aggregate provider: Agents.GetArtistImages' guard for Unknown/Various Artists (a direct retriever call could assign an unrelated image to a synthetic artist), and auxAlbum/auxArtist.Name's DevPreserveUnicodeInExternalCalls normalization (records with typographic quotes/dashes missed exact-name searches). Re-apply both before enumerating retrievers. * fix(artwork): enforce entity visibility on the Subsonic getCoverArt path serveEntity reads persisted item_artwork by id, bypassing the library and private- playlist filters that the legacy entity-load applied. On the authenticated Subsonic path a user could fetch artwork for an inaccessible album or someone else's private playlist by guessing an id. getCoverArt now resolves the underlying entity through the request-scoped (filtered) repositories and serves the placeholder when it is not visible, so existence isn't leaked and the always-an-image invariant holds. The public share (JWT-authorized) and Jellyfin (admin) paths are intentionally untouched. * fix(artwork): version the artwork ETag with the served representation The ETag was the pixel hash of the original image, so a CoverArtQuality or EnableWebPEncoding change altered the resized bytes without changing the ETag — revalidating clients got a spurious 304 and kept the old encoding. Resized responses now carry a representation ETag (hash + size + square + encode settings) used for the ETag header and If-None-Match, while the immutable decision stays on the pixel hash (URLs remain pixel-identity per the spec, so hash-suffixed clients keep zero-request caching). Full-size originals fall back to the pixel hash as before. * fix(artwork): don't stamp the album hash onto multi-disc tracks The hydration fallback assigned a found album hash to every fallback track, but a multi-disc track's CoverArtID emits a dc- id served from disc-specific art whose hash is unknown at hydration time. Advertising dc-..._<albumHash> gave clients a content- version that never changes when the disc image does, breaking id-based refresh. Only stamp the album hash for single-disc tracks (DiscNumber == 0); multi-disc tracks stay unhashed and rely on the correct ETag returned by the served response. * fix(artwork): enqueue new empty playlists by id, and refresh on absent outcomes Two worker/enqueue fixes from review: - playlistRepository.Put assigned the generated id to the caller's Playlist but passed the stale copy (empty id) to refreshCounters, enqueueing a pl|"" row the worker failed until the daily dangling purge while the real playlist went unresolved. Set the id on the copy before enqueueing. - The drain refresh batch only included found/foundStale, so a cover removed by a scan (found -> absent) never notified clients, leaving the old immutable image displayed. Broadcast absent outcomes too; precache still only warms found/foundStale. * fix(artwork): honor disabled per-track art at serve time; use nanosecond mtime provenance Two serving-correctness fixes from review: - serveMediaFile served a persisted mf embedded image even after EnableMediaFileCoverArt was turned off (the setting isn't in the config fingerprint, so found rows aren't reprocessed). Direct mf- URLs now honor the setting at serve time and fall back to disc/album art. - The file-backed staleness check compared whole-second mtimes, so a same-second content replacement (two writes in one second, or timestamp-preserving tools) could serve different bytes under the old hash + immutable policy. RefMtime is now unix-nanoseconds (no schema change; int64 column), detecting sub-second changes where the filesystem records them. * fix(artwork): preserve the drive when normalizing Windows file:// library paths url.Parse puts the volume of file://C:/Music in Host, not Path, so localOSRoot dropped it and returned /Music — os.Open/os.Stat then failed and folder/embedded art on Windows looped as dangling. Rejoin the host volume, matching core/storage/local's newLocalStorage. * fix(artwork): clamp negative sizes to full-size; convert imghttp test to Ginkgo - A negative size (Subsonic size / Jellyfin maxwidth accept signed ints) reached resizeStaticImage, where the square path builds image.NewNRGBA(Rect(0,0,size,size)) — a giant rectangle that panics/OOMs. Clamp size<0 to 0 (full-size) at the Service entry. Positive sizes were already clamped to the original. - imghttp used a plain func Test with a table; convert to a Ginkgo DescribeTable with the suite entry point in imghttp_suite_test.go (AGENTS.md test-framework requirement). * test(artwork): use renamed ArtworkWorkerConcurrency in e2e tests * feat(artwork): carry blurhash through item image hydration * feat(nativeapi): expose artwork hash, absence and blurhash * feat(artwork): hydrate the parent album's artwork state onto tracks * test(artwork): add hydrateArtwork regression guard for AlbumImage wiring Drives hydrateArtwork itself (not applyItemImage directly) over tracks that take each of the loop's continue branches, so a future edit moving the AlbumImage fill below a continue would fail loudly instead of passing silently. * feat(jellyfin): version album and artist image tags by content hash * fix(jellyfin): trim primaryImageTag comment to why-only, within budget * feat(jellyfin): emit real blurhashes and drop the synthesized fallback * feat(ui): version cover art urls by content hash and skip absent art * feat(ui): add BlurHashCanvas placeholder component * fix(ui): clear stale blurhash pixels and assert the draw path in tests Clear the canvas before each decode attempt so a hash change that fails to decode doesn't leave the previous frame's pixels on screen once this wires into a list that recycles items. Also strengthen the specs to assert createImageData/putImageData were actually invoked (and with what), instead of only checking that a <canvas> element exists. * feat(ui): show the blurhash while an album cover loads * fix(artwork): hydrate cursor streams via an id pre-pass The album, artist and playlist GetCursor built their own select and never called hydrateArtwork, so every Jellyfin list endpoint (all six stream via GetCursor) emitted entity-id image tags and no blurhash. Only GetAll hydrated, which is why Subsonic and the native API were unaffected. Each cursor now resolves its ordered/filtered/paginated id set with the cheap id-only GetAllIDs query, then streams those ids in chunks through the repo's existing GetAll, which already hydrates and applies the full select. Max/Offset are consumed by the pre-pass alone; the chunk query carries only the caller's filters, Sort and Order. This also removes a pre-existing deep-pagination cost: keeping OFFSET out of the joined query makes the pre-pass a covering index scan instead of paying the library and annotation joins for every skipped row. Benchmarked on a synthetic 100k-album DB with the real schema, page=500 at offset 90,000: 3.9ms via the id pre-pass, 52.5ms for the current shape, 192.7ms for a naive join. An unpaginated full stream costs ~24% more, which is the trade. GetAllIDs gains the annotation join whenever the caller's filters or sort reference an annotation column (same gate CountAll uses), otherwise Filters=IsFavorite and SortBy=PlayCount would fail in the pre-pass. The playlist pre-pass repeats GetAll's columns so ORDER BY keeps resolving to playlist.name rather than the joined user.name. * fix(jellyfin): hydrate artwork on the song cursor Jellyfin's listSongs streamed media files via GetCursor, which never hydrates artwork, so songs emitted entity-id image tags and no blurhash. media_file now uses the same id pre-pass as the other three cursors (album/artist/playlist), for consistency, but on a separate method, GetCursorWithArtwork: GetCursor itself must stay untouched, since it's also the scanner's hot path and the scanner never reads artwork. Measured on 1,000,000 tracks, the pre-pass over all ids costs +41.8 MB heap and +298 ms versus GetCursor's bounded +0.0 MB. The Jellyfin path is paginated, though, so in practice it only ever pre-passes a page's worth of ids, not the full library, and doesn't pay that cost. * feat(jellyfin): emit a song's own cover art when it differs from the album's Real Jellyfin fills ImageTags from each item's own images before falling back to the parent album, and Finamp checks imageTags.Primary before AlbumId. Our mapper read only the album's image, so a track with distinct embedded art silently showed the album cover. Emit exactly one entry under ImageBlurHashes.Primary: Go marshals map[string]string in sorted key order rather than insertion order, so a second entry could pair the wrong blurhash with the image imageId resolves to, and Finamp pins that pairing in its cache for 365 days. * test(persistence): scope the GetCursorWithArtwork full-stream spec to tie-free ids The fixture has title ties (e.g. three "Antenna" tracks), so the unscoped positional comparison against GetAll only passed because SQLite's tie order happened to coincide between the full scan and the pre-pass's id IN (...) fetch. Scope it to onlySongs like the sibling ordering specs already do. * refactor(artwork): route song own-art through primaryImageTag; align chunk size Cleanups surfaced by /simplify: the song mapper's own-art branch reimplemented primaryImageTag's tag+blurhash-map construction (and its one-entry invariant) — route it through the helper so that invariant lives in one place. Tie artworkChunkSize to a whole multiple of artworkBatchSize so a cursor page re-chunks into even hydration batches. Hoist a duplicated imageLoading && blurHash boolean in the album grid. * fix(ui): serve the placeholder for known-absent art instead of a broken icon getCoverArtUrl returned '' for an imageAbsent record, so <img src={undefined}> rendered as the browser's broken-image icon on every absent cover. The server already serves a proper placeholder for absent art, so build the url and let it render. * feat(ui): show the blurhash as the loading placeholder across cover surfaces Add a shared CoverImage component (useImageUrl blob cache + blurhash + fade) and render the blurhash while a cover loads on the list thumbnails (CoverArtAvatar, radio) and the artist/album/playlist detail pages. The detail pages now go through CoverImage instead of a plain CardMedia, so their images come from the in-memory blob cache and survive React remounts without re-fetching. BlurHashCanvas gains an optional style prop. * refactor(ui): unify list cover surfaces onto the shared CoverImage component Route the album grid, CoverArtAvatar (artist/playlist lists) and the radio list's cover field through CoverImage instead of each carrying its own useImageUrl + blurhash-overlay wiring. CoverImage gains a default object-fit: cover. Radio keeps its uploaded-image gate and the generic radio placeholder for stations with no art. * fix(ui): address CoverImage review findings Restructure CoverImage so the size/shape lives on the root and the blurhash + image are absolute fills: the <img> mounts only once its blob is ready, so an unresolved cover never flashes a broken <img>. Add a fit prop (default cover) so album/playlist detail keep their letterbox instead of being cropped by a hardcoded object-fit. Remove the orphaned coverLoading styles and an unused subsonic import; add a CoverImage unit test. * perf(ui): only refetch already-loaded records on SSE refresh The artwork worker broadcasts a RefreshResource event per resolved chunk, carrying every id in the chunk. useResourceRefresh was doing a getMany for all of them, so any open list/detail page fetched hundreds of artists it was not displaying. Filter the event ids to records already in the store; the rest load fresh (with their new artwork) when navigated to. * refactor(artwork): scale worker concurrency with CPU count ArtworkWorkerConcurrency now defaults to max(2, NumCPU()/2) instead of a fixed 4, mirroring MaxOpenConns: local resolution scales with the host but stays at half the SQLite pool so it never starves the scanner/UI. External RPS stays a fixed 2 — it gates third-party API calls and is bounded by their tolerance, not the host, so it must not scale with CPUs. Also drop the DevArtworkWorkerConcurrency/DevArtworkExternalRPS deprecated aliases: those names were never released, so there is nothing to migrate. * feat(artwork): re-queue an absent cover when its page is viewed serveEntity now schedules a Bump recheck for an entity whose art was recorded absent, so viewing a missing cover re-triggers resolution (e.g. after an external source that was down during the scan comes back), matching the request-time bump that already covers never-resolved entities. Throttled by attempted_at against requestRecheckAge (1h) so repeatedly opening a genuinely-absent page can't hammer external services. EnqueueBump preserves an existing failed-state backoff via MAX(priority,...) and inserts a fresh, immediately-eligible recheck for a settled-absent row (whose queue row was already deleted). * refactor(artwork): move ImageUploadService to artwork.Uploader Relocate the image-upload service from core to core/artwork as artwork.Uploader, co-locating it with the resolver/worker/serving that own the artwork state it invalidates. MaxImageUploadSize moves too — its only callers are the two image-upload handlers — which lets core/image_upload.go be deleted entirely. Extract the shared "clear resolved state + re-queue at Bump" invalidation into artwork.Refresh and fold nativeapi's refreshArtwork handler onto it, removing the duplicated DeleteForItem+Enqueue block that had drifted into three places. The wire provider moves from core's set to artwork's; the playlists.ImageUploadService binding moves to the top-level injector so core/artwork stays unaware of playlists. Behavior is unchanged. * refactor(artwork): thread model.Kind through the artwork API Entity-level artwork queries now take a typed model.Kind instead of a bare prefix string. GetItemArtwork, DeleteForItem(s), GetInfoForItems, EnqueueStaleAbsent, hydrateItemImages, enqueueBackfillKind and artwork.Refresh convert to the prefix string only at the two real boundaries: the SQL item_kind column (kind.Prefix() inside each repo) and external string inputs (a new model.ParseKind for the nativeapi URL param, which also validates it). The Backfill/stale-absent kind slices, the resolve.go dispatch switch, and the kind→resource / kind→table lookup maps now use the Kind vars directly. The queue lifecycle methods (MarkFailed/Delete*) keep string kinds — they operate on a dequeued item's raw ItemKind column, which stays a string field, always populated via kind.Prefix(). Removes every bare "al"/"ar"/… prefix literal from non-test code (27 -> 0); behavior is unchanged. * tune(artwork): drop backoff base from 5m to 15s The exponential retry (base × 4^attempts, cap 48h) started at 5 minutes, so a single transient failure — a timeout under load, an external blip — parked a cover for 5 minutes even though a retry seconds later would have resolved it. Start at 15s instead: transient failures recover almost immediately (15s → 1m → 4m → 16m …), while persistent failures still escalate to the 48h cap (now at the 8th attempt instead of the 5th). * tune(artwork): 5s backoff base + 12h give-up, drop the cap Retry backoff now starts at 5s (was 15s) so a transient failure recovers on essentially the next drain, and jitter widens to ±40% so a wave of correlated failures doesn't re-clump into one poll. Add a 12h give-up budget measured from enqueued_at: once the next backoff would land past it, the worker stops retrying instead of grinding at a cap forever. A bare failure settles absent (handed to the 24h stale-absent sweep, and still recoverable on a page view); a found-stale keeps its already-served art. The budget bounds the tail, so the separate 48h backoffCap is removed. * fix(lastfm): match album.getInfo on name+artist only, not MBID Last.fm's album.getInfo by MBID is unreliable: a correct MBID can return a different album, or none. Observed with black midi's "7-eleven" (whose correct MBID returned a FLEETWOOD release) and both missing The Chats albums (one MBID 404s, the other resolves to a different self-titled release). The worker then recorded covers absent — or would fetch the wrong art — even though the correct cover is on Last.fm by name+artist. Stop passing the MBID to album.getInfo; query by name+artist only, which also drops the now-dead error-6 MBID-retry fallback. The low-level client keeps its MBID support for other callers; only the album lookup changes. * fix(lastfm): return agents.ErrNotFound on error 6 (not found) Last.fm returns error 6 for a missing artist/album — a definitive negative — but the agent returned the raw *lastFMError, so the artwork worker treated every not-found as a real fault: it counted toward the per-source circuit breaker (5 in a row opens it, fast-failing all Last.fm calls including valid ones) and was retried as a transient error instead of settling absent. On a first scan of a library with many artists Last.fm lacks, this stalled valid cover lookups and left entities churning in backoff. Translate error 6 to the shared agents.ErrNotFound at the agent boundary (callAlbumGetInfo / callArtistGetInfo), matching how the Deezer agent maps its client's not-found, and log it at Debug instead of Error — which also removes the not-found log spam. * feat(artwork): log external image-lookup failures at debug The worker's res.reader==nil && extError branch returned outcomeFailed with no log, so a failing external cover lookup (agent error, dead image URL, download timeout) was undiagnosable. Log the agent, entity, and underlying error at the fetch site where it's in hand — this surfaced a Last.fm album.getInfo returning an image URL that itself 404s. * fix(artwork): treat a 404/410 image URL as not-found, not a transient fault An agent (notably Last.fm's album.getInfo) can advertise a cover URL that is itself dead — a 404. sources.go's fromURL returned a generic error for any non-200, so a dead URL was treated as a transient failure: it churned in backoff and counted toward the circuit breaker, stalling valid lookups. Map 404/410 to model.ErrNotFound in fromURL so a dead URL settles absent, and collapse the near-identical fetchPlaylistImageURL (which already did this for M3U covers) into it. * feat(artwork): make artwork re-resolution targeted, not blunt Two gaps in when the pipeline re-resolves artwork: The recheck job only requeued absent-state rows (hash=''), so an entity that was never processed — added between scans, or on a server with the scanner disabled — had no periodic safety net and stayed without artwork indefinitely. Add EnqueueMissing(kind): a SQL set-difference enqueueing entities with no item_artwork row at Recheck priority (ON CONFLICT DO NOTHING, so it never disturbs a queued row). Run it once at startup and hourly alongside the stale-absent recheck. Rename staleAbsentKinds -> recheckKinds accordingly. Conversely, the config fingerprint included consts.Version, which embeds the git SHA and so changed on every build, re-enqueueing every entity in the library (~34k here) and re-querying external agents at the configured RPS for anything without local art. Replace it with an explicit artworkEpoch constant, bumped deliberately when resolution semantics change. The cases that motivated the version input — absent art becoming available — are already covered by the stale-absent and missing-row rechecks; only a corrected wrong-pick needs the epoch. A test guards against reintroducing the version. * test(artwork): restore resolution edge-case e2e coverage The serving cutover removed the album/disc/artist/mediafile/playlist/radio e2e specs that documented the folder-selection rules and guarded the #5376/#5456/ #5451/#5457 regressions; nothing replaced them, so compareImageFiles and the parent-fallback logic were left untested. Restore them driving the real pipeline: a real scanner populates the folder graph from an in-memory library, the real Worker drains the queue, and the real Service serves. Folder-backed art is file-backed (served via os.Open, which the in-memory FS can't satisfy) so its selection is asserted on the persisted state row; store-backed and real-disk sources are asserted byte-for-byte. Single-disc disc resolution now serves album art directly, so only multi-disc disc scenarios are ported. * fix(artwork): run disc resolution for single-disc albums too ed4178a6 gated serveDisc on len(album.Discs) > 1, claiming parity with the legacy reader. The legacy reader has no such gate: artwork.go dispatches every dc- id to newDiscArtworkReader, whose Reader() walks DiscArtPriority unconditionally. The gate also lost art. For a single-disc album whose only image is disc1.jpg, the disc request skipped the chain and fell through to album art, which does not match CoverArtPriority — so tracks tagged disc 1 (whose CoverArtID is a dc- id) served nothing at all, where before they served disc1.jpg. A single disc can legitimately have its own cover, distinct from the album's, and DiscArtPriority is what expresses that preference. Drop the gate and restore the single-disc e2e scenarios that covered it. * fix(artwork): register the GIF decoder in core/artwork The deleted artwork.go carried blank imports for image/gif and x/image/webp. WebP came back via resize.go's gen2brain/webp, which self-registers, but GIF did not: core/artwork claims GIF support in mimeForFormat and extForMime while relying on an unrelated server package to have imported the decoder. The guard lives in the e2e suite because that test binary has no other image/gif importer; a spec in core/artwork would pass regardless, since animation_test.go imports the package non-blank. * fix(artwork): never record absent after a local I/O failure Local sources swallowed their open errors, so a stale NFS/SMB mount was indistinguishable from "this entity has no artwork": the chain returned no reader, processItem took the absent branch, and the upsert replaced a good content hash with the empty string. Clients then saw a placeholder until the 1h request recheck or the 24h stale-absent sweep, and the orphaned bytes became eligible for the next prune. A candidate the resolver knows about — a file in the folder listing, a track's own audio file — failing to open is not evidence of absence, so it now forces a retry the same way an external agent error does. * fix(artwork): keep served art when the retry budget runs out Exhausting the 12h budget called writeAbsent unconditionally, so an entity whose art was already resolved and serving lost it to a long upstream outage: the hash went empty, clients fell back to the placeholder, and the now-unreferenced bytes were freed by the next prune even though nothing about the image had changed. Exhaustion means the source stayed unreachable, not that the cover disappeared, so absent is now recorded only when there is nothing to keep. * fix(artwork): restart the retry budget on re-enqueue The conflict clause updated only priority and retry_at, so a row that already existed kept its original attempts and enqueued_at. The worker measures the 12h give-up budget from enqueued_at, so any row that had been pending across a longer gap — a server left off, an upgrade, a laptop asleep — gave up on its very first attempt and settled absent. The manual re-resolve endpoint is the sharpest case: it clears artwork state and re-queues, but inherited the old row's spent window, so a deliberate retry got one shot. A fresh request now gets a fresh budget, with the mock updated to match. * fix(artwork): keep not-found distinct from artwork-absent GetOrPlaceholder folded model.ErrNotFound in with ErrUnavailable, so an id matching no entity returned 200 and the placeholder PNG. That made the ErrorDataNotFound branch in getCoverArt unreachable: Subsonic went from error 70 to a successful placeholder, and Jellyfin's Primary image endpoint from 404 to 200. Neither is ours to change. An entity with no art and an id with no entity are different answers; only the first is a placeholder. * fix(artwork): make the prune sweep cancellable Sweep walked the whole store with no context, and RunPrune holds the prune write lock for its full duration. In-flight acquisitions park on the read lock, drain's WaitGroup never returns, and Run never reaches its ctx.Err() check — so a SIGTERM during a daily prune over a large store on slow storage waits out the container's grace period and dies mid-remove. * fix(artwork): hydrate the tracks reached through a playlist loadTracks and the playlist-track cursor were the only entity-page paths that never hydrated artwork state, so a song reached through a playlist behaved differently from the same song in the songs list: Subsonic emitted a hashless coverArt id, which imghttp downgrades to no-cache, and advertised art even for known-absent albums; Jellyfin emitted AlbumPrimaryImageTag as the bare album id — a tag that never changes when the cover does — and no blurhash at all. The media-file hydration moves next to the other hydration helpers so both paths share one implementation rather than growing a third. * feat(ui): cross-fade the cover over its blurhash The blurhash unmounted the moment the blob arrived, so the placeholder vanished a frame before the image painted. The image now mounts transparent and fades in over the blurhash, which stays behind it until the fade completes. A blob already cached when the instance mounts skips the fade, so a remount does not re-animate. * fix(ui): retire the blurhash on a timer, not transitionend Under prefers-reduced-motion the img rule sets transition:none, so toggling opacity fires no transitionend and the handler that unmounts the blurhash never ran. The placeholder stayed mounted for the life of the component — visible in the letterbox bars wherever the cover is rendered with fit="contain", and a live canvas per tile everywhere else. One duration constant now drives both the CSS transition and the timer, so they cannot drift. * fix(artwork): stop advertising a hash for bytes that won't be served Hydration stamped the album's hash and blurhash onto an embedded-eligible track that had no state row yet. Serving takes provisionalEmbedded for exactly that case and returns the track's own embedded image, so the id carried a content-version belonging to a different picture: every such request fell back to no-cache instead of immutable, and a client keying its cover cache on the blurhash paired the album's with the track's art. AlbumCoverArtID had the mirror-image problem, building the album id from the track's own ItemImage. It happened to work only because hydration overwrote ImageHash in precisely the fallback cases; a track with its own resolved art would have stamped that hash onto the album's id. * perf(artwork): precache from the bytes just acquired Warming the resize cache re-read the two rows and the file the acquisition had just written, so every acquired image cost two extra queries and a second full read of a file whose bytes were still in memory. processItem now hands back what it persisted and precache warms from that, under the same cache key the serving path computes. Resolving the admin user also moves behind the empty-queue check: it is needed only to resolve private playlists, so an idle server no longer runs a user lookup on every poll. * perf(artwork): keep the worker pool fed across a drain The pool was fed from a batch sized to the pool itself (2x concurrency) with a WaitGroup barrier before the next dequeue, so one item burning its external timeout idled every other slot until it finished. The legacy cache warmer had no such barrier: it streamed through a pipeline of 4. Dequeuing well past the pool keeps the slots fed for the whole pass at no extra cost, since DequeueBatch does not mark rows taken and was already one query per pass. Acquiring a slot now also observes cancellation, so a larger batch cannot delay shutdown. * perf(artwork): drain local and external artwork in separate pools A first backfill enqueues artists before albums at a single priority, so the drain took them in that order. Artists resolve through a rate-limited agent, and gate() waits for its permit while holding a worker slot, so the whole pool sat asleep in the limiter with every album queued behind it. Measured on a 96k-track library (29,115 artists to 6,949 albums, 4:1): zero albums resolved in seven minutes, and roughly 3.3 hours before the first album cover would have appeared. Splitting the drain gives each class its own slots: albums now finish in under eight minutes while artists trickle at the same 2/s they were always limited to. The two budgets are carved out of MaxOpenConns so a second pool cannot take connections the scanner and the UI need. Dequeue filters by kind, and the drain index leads with item_kind so each pool seeks to its own work instead of scanning past the other's backlog. * fix(artwork): treat an unreadable upload as a failure, not a miss resolveLocalFile swallowed every os.Open error, so uploads, playlist sidecars, a local M3U image and the artist image folder still had the bug that was fixed for folder and embedded sources: a permission or transient I/O error on a file that exists read as "no image here". The worker then settled the item absent and dropped its queue row. Uploads outrank every other source, so an unreadable one now stops the chain rather than letting a lower-priority image be persisted in its place. A genuinely missing file stays a clean miss. Reported by Codex on #5847. * fix(artwork): make the pool-split worker test race-clean CI runs the suite under -race, which the local `make test` does not, so this only showed up there: 230 specs passed and the detector still failed the run. The drain-pools spec started Run and never waited for it, so pool goroutines outlived the spec and raced the config snapshot Ginkgo restores on cleanup. It now cancels, unparks the blocked lookups and waits for Run to return. Two test doubles also had to become concurrency-safe, since the spec is the first to resolve several artists at once: fakeImageAgent's call counters, and MockAlbumRepo.GetAll, which records the last query options on a read path. MockDataStore's lazy accessors get the same treatment — only MediaFile was guarded before, and two pools now reach them concurrently. ArtworkQueue takes an unlocked helper for its internal Artwork call, since repoMu is not reentrant. * perf(artwork): stop reading and hashing disc art on every request The resize-cache key was the content hash, which cannot be computed without reading the file, so a warm cache never prevented the I/O: every sized disc request read up to 20MB and hashed it before the lookup. The legacy reader keyed on the id and the album's mtime and touched the file only on a miss. Disc art has no state row and therefore no stored hash, so the key is that same identity — id, album mtime, DiscArtPriority — and the selection chain now runs only when the cache misses. Full-size requests stream the source instead of buffering and hashing it. This matters more now that single-disc albums keep running the disc chain, which puts every disc-tagged track without embedded art on this path. * fix(artwork): key disc art on folder image changes, not just the album The identity cache key used album.UpdatedAt alone, which a replaced disc image does not necessarily move — the sized response would then serve the old image indefinitely. The legacy reader folded ImportedAt and the folder's ImagesUpdatedAt into its key for exactly this reason, and loadAlbumFoldersPaths already returns that timestamp; the disc reader was discarding it. * fix(artwork): return undispatched items when a drain is cancelled claim() reserves the whole batch before dispatch, but the cancellation path returned without releasing what it had not yet started, leaving those items in the in-flight set permanently — no later drain could claim them again. Harmless until the batch grew past the pool size; now a cancel strands up to a full batch. The e2e harness cancels mid-drain after every acquire, so it surfaced there first: one spec timed out waiting for an item that had been claimed and abandoned, and the suite went from 59s to 87s on CI. * fix(jellyfin): make a track's own cover reachable for Jellyfin clients Media files are never enqueued — the scanner drops their state without queueing them and the recheck kinds exclude them — so an unresolved track keeps an empty ImageHash and SongToBaseItem always fell through to AlbumPrimaryImageTag. Finamp then asks for the album image, nothing ever requests mf-, and the read-through that resolves the track never fires: the own-cover branch was unreachable for Jellyfin-only users. An eligible, unresolved, not-known-absent track now advertises its id as the Primary tag, which is what makes the client ask. The request serves the embedded art and queues the track so the worker persists a real hash. No blurhash is sent, since none exists yet and a fake would be cached against that tag forever. Serving gains the album fallback that made this safe to advertise: an eligible track whose frame will not extract now falls back the way CoverArtID does instead of answering with a placeholder. Reported by Codex on #5847. * fix(artwork): treat an unreadable artist-folder image as a failure Third and last site in this class: findImageInFolder logged and skipped an image the glob had already matched, so a permissions or mount failure during the artist-folder traversal read as "no image here" and let processItem settle the artist absent, discarding any artwork already resolved. A matched-but-unreadable file now propagates through fromArtistFolder and lands as localError, the same as album folder art, embedded art and uploads. A folder with no match stays a definitive miss. Also normalizes the e2e path assertions with filepath.ToSlash: the stored SourcePath is OS-native, so the forward-slash suffixes failed all 23 folder specs on Windows. Reported by Codex on #5847. * fix(artwork): give full-size disc art a real ETag Regression from 04d5a556. Keying the resize cache on identity meant the disc response no longer carried a content hash, and the full-size branch set no ETag either — so WriteImageHeaders fell back to the empty hash and emitted `ETag: ""` for every full-size disc image. Since ifNoneMatch compares the unquoted value, a client echoing that back matched, and got a 304 even after the image was replaced. The full-size branch now carries the same identity validator the sized branch uses, so it moves when the folder's images change. WriteImageHeaders is hardened against the class as well: an empty validator is no validator, so it is neither emitted nor matched. Reported by Codex on #5847. * test(artwork): inject the unreadable source instead of chmod os.Chmod cannot revoke read access on Windows — it only toggles the read-only attribute — so the findImageInFolder spec opened the file happily and failed there, and the upload spec passed for the wrong reason: outcomeFailed came from the 1-byte payload failing to decode, not from the source being unreadable. findImageInFolder takes an fs.FS, so the failure is now injected and the spec is filesystem-independent. The upload path goes through os.Open directly and has nothing to inject, so it skips on Windows rather than pretend to cover it. * fix(artwork): compare the pixel cap without multiplying Defence in depth rather than a live hole: the reported crafted PNG (0xffffffff square) never reaches the multiplication, because image/png rejects it at DecodeConfig, and the largest dimensions any supported format can declare — 2^30-1 for PNG, 16-bit for JPEG and GIF, 14-bit for WebP — cannot overflow the int64 product. decodeCapped is format-agnostic though, so the guard should not depend on a decoder's own limits staying where they are. Comparing by division holds for any dimensions a decoder might report, and non-positive ones are now rejected outright. * refactor(ui): rename cover artwork components * fix(ui): remove Artwork rendering gate Signed-off-by: Deluan <deluan@navidrome.org> * fix(cache): re-fetch when a cache entry outlives its data file fscache's Remove drops the in-memory entry, releases the lock, and only then unlinks - blocking until every outstanding reader closes. A Get landing in that window re-creates the file at the same path under a fresh entry, and the deferred unlink deletes those new bytes. The entry survives pointing at nothing, and since a present entry is treated as a hit, every later Get for that key returns ENOENT for the rest of the process's life. Only a restart, which rebuilds the map from disk, cleared it. Get now drops such an entry and retries once, so a vanished data file costs one re-fetch instead of poisoning the key permanently. This also covers a file disappearing for reasons unrelated to that race, such as external deletion or a restored backup. Specs cover an in-process entry, one adopted at startup, and the deferred-removal race itself. * fix(artwork): log why a sized cover fell back to the placeholder serveHash routed every non-cancel cache error into dangling(), which returns ErrUnavailable and is then rendered as a placeholder at 200 OK. A cache-layer fault was therefore indistinguishable from an album genuinely having no artwork, and left no trace: a broken resize cache silently served placeholders for a quarter of the library while the logs stayed clean. Log the error before falling back, so the cause is recoverable from the logs. * fix(artwork): precache the cover variant the UI actually requests precache built its resizedItem without setting square, so it warmed h-<hash>.<size>.false.<quality>. The list surfaces - album grid, artwork avatars, playlist and radio details - all request square covers, so the warmed entry was never read and every grid cover stayed a cold miss on first view. Set square on the precache item so the key matches the request path. The existing specs asserted the '.300.false.' key and were updated accordingly. * fix(cache): give a re-created cache entry its own file Create re-opened the path with O_TRUNC, which shrinks the file out from under an older stream that may still be serving readers. stream.Reader then hits EOF from the OS at the new, shorter length while the broadcaster still reports the original size, so Wait() reports 'more data exists' and the reader retries forever - a tight pread loop that burns a core and never releases its handle, which in turn blocks Stream.Remove() indefinitely. Unlink first and create with O_EXCL so the new entry gets a fresh inode. Existing readers keep their descriptor on the old inode, see its full contents, and reach a clean EOF. Note this does not address the deferred unlink deleting the re-created file, which is handled separately by the re-fetch in fileCache.Get. * fix(cache): keep the in-place truncate on windows Unlinking before re-creating fixes the premature-EOF spin on unix, but Windows refuses to remove a file another handle still has open and returns a sharing violation. Because Create surfaces that error, every cache miss on a path with a live reader would have failed outright - worse than the spin it was meant to fix. Split the create behind a build tag: unix unlinks for a fresh inode, Windows keeps truncating in place and stays exposed to the spin, which is the behaviour it already had. The unix-only spec is skipped there. * fix(artwork): shape the blurhash placeholder to the artwork's aspect ratio The UI decoded every blurhash into a 32x32 bitmap and stretched it to fill its container, so a non-square cover showed a full-box blur that collapsed into a letterboxed image the moment it loaded. On the album detail page the placeholder overhung the image by a third of the box height. A blurhash string carries no aspect ratio of its own, so the dimensions have to come from the server. artwork.width/height were already stored and read by nobody; they now surface on ItemImage as imageWidth/imageHeight, hydrated through the join that was already in place. Existing rows already carry them, so no migration or rescan is needed. A square request is padded rather than cropped, which aspect-fits the content inside the square the server returns. That made the grid a second instance of the same bug, so `square` now implies contain for the image as well as the placeholder, instead of the two renderers reading it differently. * feat(jellyfin): expose PrimaryImageAspectRatio Real Jellyfin carries width/height of the Primary image on BaseItemDto (MediaBrowser.Model/Dto/BaseItemDto.cs), attaching it only when the request's Fields asks for it (DtoService.cs ContainsField). The dimensions are now on model.ItemImage for the web UI's blurhash placeholder, so the adapter can report the same thing for free. Gated behind Fields to match, and omitted rather than defaulted when the item has no image or unknown dimensions: real Jellyfin falls back to a per-type default of 1 for music, but a wrong ratio mis-shapes a client's placeholder, and we only lack dimensions when the artwork is genuinely unresolved. primaryImageTag becomes primaryImage, returning the tag, blurhashes and ratio together, so the choice of which image is Primary is made once per mapper rather than the same ItemImage being threaded through two calls. ArtistToBaseItem and PlaylistToBaseItem take Fields now, like the album and song mappers already did. * refactor(model): give ItemImage an AspectRatio method The zero/absent guards around width/height are ItemImage's own invariant, not the Jellyfin adapter's. Moving them onto the model keeps one definition for every consumer, so a second one cannot quietly disagree about what an unknown ratio means. * fix(ui): anchor detail pages to the top when opened from a list React Router keeps the previous page's scroll offset, so opening an album from a scrolled list started the detail page mid-song-list. Artist pages had the same bug; it just shows less because the artist list is rarely long enough to scroll far. Keyed on the record id rather than mount, so detail-to-detail navigation (an album's artist link) resets too, and so the scroll waits for the record instead of firing against an empty page. * fix(ui): stop the Random album grid collapsing on every keystroke The grid was replaced by a spinner whenever the random list was loading, so each search keystroke collapsed it to spinner height and back. That blanket blanking is the flicker commit 9e559311a removed for every other list; random kept the exception so a re-roll would not flash the roll it is replacing. Blank on a seed change instead of on any load: a re-roll gets a new seed and still blanks, while a search keeps the seed and leaves the grid in place. The seed is tracked from empty rather than from the current value because a re-roll redirects and remounts the grid, which would otherwise look already-settled with the previous roll still on screen. Same rule for the pagination, which was hidden on the same condition. * refactor(artwork): move fingerprint property key const to `consts` package Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): enhance database handling with resettable tables and truncation Signed-off-by: Deluan <deluan@navidrome.org> * refactor(artwork): narrow the prune lock and drop redundant in-flight tracking The prune read-lock wrapped all of processItem, including external fetches under their own timeout. Since a pending RWMutex writer blocks new readers, one prune arriving behind a slow provider stalled every subsequent item in both drain pools. Extract persist() so the lock covers only the window it protects: store placement plus the two row writes. The in-flight set guarded against a queue row appearing twice in one batch, but artwork_queue's primary key makes that impossible, drains are serial per pool, and the pools' kind lists are disjoint. Removing it also retires the cancellation unwind loop that existed only to release those claims. * fix(artwork): never settle absent for a kind no recheck job revisits The 12h retry budget hands a bare failure to the periodic stale-absent sweep, which is what makes the resulting absent row recoverable. Media files are deliberately excluded from that sweep -- they resolve embedded only, at scan or on view -- so exhausting the budget on a transient read error recorded a "this track has no cover" verdict that nothing would ever revisit. Settle absent only for kinds a recheck job covers. Without a row the track stays unresolved, so the next view re-enqueues it. * refactor(artwork): remove dead plumbing from the serving path artworkReader.LastUpdated had no callers: invalidation rides entirely on the cache key, so the interface member, the resizedItem field and its four assignments were vestigial. Reader's second return value was likewise discarded at all three call sites. resizedItem.Key duplicated representationTag's format string over identical inputs, where drift would serve a wrong-keyed entry under a right-looking validator; it now derives from it. newResizedItem had one caller and a doc comment claiming a sharing with worker.precache that never existed -- precache builds its own literal. Also unexport Prune, which no caller outside the package used while RunPrune documented itself as the only sanctioned path, drop a single-call placeholder wrapper, and delete five fakeFolderRepo fields no spec ever set. * refactor(artwork): drop queue repository methods only tests called MarkFailed and Delete had no production caller: the worker only ever uses MarkFailedIfUnchanged and DeleteIfUnchanged, which refuse to act on a row a concurrent scan re-enqueued. Keeping the unconditional pair meant the interface offered the racy variant under the more obvious name. MarkFailedIfUnchanged does not build on MarkFailed, so nothing in the implementation needed them either. The repository tests used them to put a row into a backed-off state; they now do that directly. * test(artwork): pin that a request never fetches or samples album art resolveItemLocal's guard against the remote ExternalImageURL fetch and the 2x2 grid had no coverage: deleting it left the whole suite green while putting synchronous network calls on the request path. The worker resolving the same playlist is asserted alongside, so the spec cannot pass by simply resolving nothing. * refactor(artwork): give the resolver a receiver and one capability field The resolve* chain walkers each took seven parameters -- ds, agents, ffmpeg, gate, localOnly -- while the package already had workerDeps bundling the same collaborators for processItem. They are now methods on a resolver. The external capability is one nilable field instead of three values that had to agree. Previously a local-only resolution passed agents=nil, gate=denyGate and localOnly=true, and only the localOnly check actually protected anything: the external branch dereferences agents in the loop header, before the gate closure runs, so denyGate could never fire. It is deleted. A nil ext now both marks the resolution local-only and removes the agents there were to dereference, and newLocalResolver takes no parameter that could supply one. The playlist tile loop hardcoded localOnly=false, safe only because an early return 22 lines above it made that unreachable; it now inherits the resolver's capability. * refactor(artwork): funnel every served representation through one path serveHash, serveBytes and serveDisc each hand-copied the same five steps -- test for full size, stream or build a resizedItem, call the cache, wrap with a validator -- with a different error policy bolted on. The ETag rule was restated at four sites and applied inconsistently. serveSource now states it once: full size streams open() directly, and an ETag is attached only when the bytes are resized or there is no hash to validate against. Each caller keeps just its own error policy, and serveBytes folds into its single caller. Two behavior changes fall out, both narrowing an aborted request's blast radius: serveDisc propagates context.Canceled instead of falling back to a full album resolution, and serveHash's full-size path propagates it instead of going dangling, which would have enqueued a re-resolution for a request nobody is waiting on. * style(artwork): use one log prefix, spelled the way the codebase does The package logged under three spellings of its own name -- "artwork: " lowercase, "Prune: " and one "Artwork: " -- and the lowercase ones carried lowercase message text, against 568 capitalized to 48 lowercase elsewhere. Prefixing itself is the convention here (Scanner:, API:, Watcher:) and it earns its place: DevLogSourceLine is off by default, so without it a line does not say which subsystem emitted it. So this normalizes the spelling rather than dropping the prefix. Error strings stay lowercase and unprefixed per Go convention. * refactor(artwork): give workerDeps only what the processor uses The bag carried cache, which processItem never reads and only the worker's precache uses, and carried agents/ffmpeg/gate solely to reconstruct a resolver on every queue item. cache and ffmpeg move to Worker, where precache actually uses them, and the resolver is built once in NewWorker. persist's hash parameter was redundant: decodeArtwork sets Hash and GetImage selects it, so art.Hash already holds it on both paths. The type itself now lives beside Worker, which owns it, rather than in the file of the function it is passed to. * refactor(artwork): make acquisition a processor with its own receiver workerDeps was a parameter bag threaded into two free functions that nothing outside the worker calls. It becomes the processor type, with processItem and persist as acquire and persist methods on it, and Worker holds one collaborator instead of reaching through a bag. Kept as a separate type rather than folding onto Worker: acquisition takes a queue item and returns bytes, while Worker.process settles the queue row around it. That boundary is what keeps retry policy out of the image pipeline, and what lets the acquisition specs build a three-field value instead of a Worker with drain pools, gates, a broker and a real on-disk cache. * refactor(artwork): collect the external gate contract in one file gateFunc, passthroughGate and isTransientExternal sat in agent_images.go while every implementation lived in worker.go: extGate, breaker, Worker.gate, gateFor. isTransientExternal even carries a comment saying it must stay consistent with breaker.record, which was in the other file -- a rule spanning two files with only a comment holding it together. Pure move into gate.go: no symbol added or removed. * refactor(artwork): put the whole playlist grid in playlist_cover.go decodeTile and assembleTiles were in resolve.go while the geometry they depend on -- rect, fillCenter, tileSize -- was in playlist_cover.go and used nowhere else, so one file held the grid's helpers and another its assembly. Pure move. * refactor(artwork): move resizedItem next to the interface it implements resizedItem is the only implementation of artworkReader, which is declared in image_cache.go, and it is used by the worker's precache as well as the serving path -- so worker.go was reaching into serving.go for a cache type. representationTag stays in serving.go, where the HTTP validator belongs. Pure move. * refactor(artwork): fold Refresh into housekeeping refresh.go was a 21-line file for one function that clears artwork state and enqueues -- the same thing Backfill, EnqueueStaleAbsentAll and EnqueueMissingAll already do next door. Pure move. * refactor(artwork): accumulate priority-chain state in one place Every source in the album and artist chains repeated the same five lines: stamp the accumulated external failure onto a hit, or OR the local fault into the running total on a miss. Each new source was a chance to forget the OR, which is how the local-I/O-settles-absent bug happened. chainState.try does both, so each case drops to three lines and the omission is no longer expressible. Semantics are unchanged: a hit still carries extErr only. Also drops localErr from resolvePlaylist, which declared it but never assigned it. * refactor(artwork): expose housekeeping through the Worker scheduleArtworkHousekeeping received a *artwork.Worker and then called CreateDataStore() for a second handle onto the state that Worker already owns, because Backfill, EnqueueStaleAbsentAll and EnqueueMissingAll were free functions taking a DataStore. They are now Worker methods over unexported implementations, the same shape prune/RunPrune already uses: one public path, and the specs keep calling the plain function with a mock store instead of standing up a Worker. Fingerprint is unexported too -- nothing outside the package used it. * refactor(artwork): name the service for its domain, not its role Every other core interface is named for what it is -- Playlists, Library, Scrobbler, MediaStreamer -- while this one was artwork.Service, the only X.Service in the tree. It becomes artwork.Artwork/NewArtwork, and serving.go follows the type to artwork.go. ProvideImageStore was likewise the only "func Provide" in the repo; it is GetImageStore now, matching GetImageCache beside it. cmd/wire_gen.go regenerated via make wire. * docs(artwork): give each Worker housekeeping method its own godoc The three methods shared one comment attached to Backfill, so godoc rendered the other two undocumented and the one it did show described the group rather than the call. Each now opens with its own name and says what that call does, including Backfill's bool return. * refactor(artwork): move fakeFolderRepo to artwork_suite_test.go Signed-off-by: Deluan <deluan@navidrome.org> * refactor(artwork): unexport artwork.HashImage function * fix(log): stop ShortDur eating significant trailing zeros TrimSuffix(s, "0s") was meant to turn "4h0m0s" into "4h", but it strips any trailing "0s"/"0m" -- so 10s logged as "1", 20s as "2", 1m30s as "1m3", 2h30m as "2h3", and a zero duration as the empty string. Every elapsed/duration field in the app was affected. The suffix now has to include the preceding unit, so only a whole zero-valued component is dropped. The existing table only covered values that dodge the bug (4m, 4h, 4m3s); added the ones that don't. * feat(artwork): prefix every log message and time the slow steps Prefix: 22 messages still logged unprefixed, so a line from this package was indistinguishable from any other subsystem's. All 40 now carry "Artwork: ", matching Scanner:/API:/Watcher: -- which earns its place because DevLogSourceLine is off by default. Timing on what can actually be slow: total per acquisition (on every exit, failures included), the read that also covers the provider download, hashing, decode+blurhash, resize, drain batch, precache, prune, backfill, and the external agent call -- with the rate-limiter wait counted separately, since a throttled agent and a slow one look identical from the drain. Debug coverage for states that were previously silent: dedup hit vs decode, settling absent, serving a lower-priority source after an external failure, retry scheduling with attempts and budget left, giving up when the budget runs out, breaker open/close per agent, provisional read-through, dangling state rows, and the mtime mismatch that makes art appear to vanish. outcome gained a String() so it reads as a name. * refactor(artwork): log decoded dimensions as fields, not a formatted string fmt.Sprintf ran on every newly-decoded image even with Debug off, since Go evaluates log arguments regardless of level. Separate width/height fields also query better than a "300x300" string. Correction to e0f1acd1a's message: it said "all 40" messages carry the prefix. The package has 68 log call sites and 62 distinct messages; 40 was only what the test suite happened to exercise. The sweep itself was complete -- zero unprefixed messages remain. * fix(subsonic): stop serving artwork for a deleted radio artworkAccessible checked every kind except radio, which fell through to the default "no per-user access control" branch and returned true without a lookup. Radio deletion removes only the entity row -- item_artwork and the uploaded file survive until the next prune -- so the old ra- id kept serving the removed radio's image for up to a day. This is a regression against master, where newRadioArtworkReader loaded the radio first and returned its error, so deletion took effect at once. Radios stay globally visible; only existence is checked. CreateMockedRadioRepo left Data nil, so its own Put panicked on first use; initialized it. Reported by Codex on #5847. * fix(artwork): stop serving artwork for entities that no longer exist Artwork state and its bytes outlive a deleted entity until the next prune (@daily), and the serving path consulted only item_artwork, so a Subsonic id or a signed public token kept serving a removed entity's image in the meantime. Master's readers loaded the entity first, so this was a regression. The check goes in serveHash, the one path that can hand back a found row's bytes: absent rows are already unavailable, and the provisional and disc paths load their entity to resolve at all. Doing it there instead of per-handler also settles who owns the invariant. Subsonic had worked around it with artworkAccessible, whose comment described the service "bypassing the library and private-playlist filters"; that workaround is now deleted, since the service resolves through the request-scoped repositories and enforces the filters itself. Because those repositories are ctx-scoped, each caller says what it wants by what it passes: Subsonic hands over the request context and so gets visibility as well as existence, while the public image route elevates like the Jellyfin one already did -- a token is the authorization there, and a visibility check would hide a shared private playlist, the very case shares exist for. Two supporting fixes. albumRepository.Exists and mediaFileRepository .Exists used the plain exists() helper, which applies no library filter, so they reported rows in libraries the caller cannot see; they now count through applyLibraryFilter as CountAll and artistRepository.Exists already do. Neither had a production caller. RadioRepository gained the Exists it lacked. Tests follow the layers: the service refuses a found row whose entity is gone, the repositories hide rows the caller may not see, and the handlers only assert the context they hand over. Jellyfin needed no change -- resolveArtworkID probes the entity tables, so a deleted item yields an empty artwork id -- but that protection was incidental and untested, so it is pinned now. * fix(playlist): rebuild the generated cover only when the tracks change The enqueue sat in refreshCounters, which Put also reaches for an ordinary metadata update, so renaming a playlist or editing its comment re-resolved the cover. The 2x2 grid samples albums with random(), so that silently handed the playlist a different cover for an edit that touched no tracks -- and refetched remote artwork to do it. It now happens where the track set actually changes: addTracks (which Put-with-tracks and updatePlaylist both funnel through) and renumber (reached from removeOrphans). Creation still enqueues even with no tracks, since an imported m3u can carry an ExternalImageURL. Reported by Codex on #5847. * style(artwork): trim verbose comments to the 1-2 line budget Comments only; no executable code changed. Verified by comparing the Go token stream of every touched file before and after: identical. Removes 375 of the 1104 comment lines this branch added, targeting content that belongs in a commit message or PR body rather than in the code: rejected alternatives ("DeleteIfUnchanged, not Delete", "Waking all beats routing by kind"), refactor history ("as the legacy reader did"), issue references (#5798, #5597, #5376), benchmark numbers (~400ms, ~16k allocs), and four persistence doc comments that duplicated the interface godoc in model/artwork.go verbatim. Comments predating this branch are left untouched. The ASCII fixture trees in the e2e suites are deliberately kept above the line budget: they diagram the fixture layout with its expected outcomes, and every pre-existing block in those files carries one. * test(artwork): make the artists-first backfill assertion non-vacuous The follow-up loop could never fail: once the first "ar" index is asserted to be 0, every non-"ar" element is necessarily at an index greater than 0. A sequence like ["ar", "al", "ar"] passed both assertions, which is exactly the interleaving the check exists to forbid. Assert the partition directly instead: nothing after the first non-artist call may be an artist. Verified by mutation — enqueueing artists a second time after albums now fails the spec. * refactor: use stdlib slices/maps and utils helpers in artwork code Mechanical cleanups, no behavior change: - 15 copies of the same id-extraction loop collapse to slice.Map (5 repo mocks, the scanner's track sweep, 4 wantIDs assertions) and slice.ToMap (6 index-by-id loops in the hydration specs). - disc.go built a map[string]bool purely to dedup folder ids and then walked it back into a slice; slice.Unique says that directly. - folders_artist.go's image filter is slice.Filter over model.IsImageFile. - mock_artwork_repo deleted from a map while ranging it; maps.DeleteFunc states the intent. - sort.Slice -> slices.SortFunc + cmp.Or; math.Min/Max -> builtin min/max; make+copy -> bytes.Clone; strings.Split -> SplitSeq on a per-request path; three-clause pixel loops -> for range. - Reuse utils.BaseName where a stem was recomputed by hand. Not at playlist_cover.go:27: that path is a full OS path and utils.BaseName uses path.Base, which does not split backslashes. - Drop a dead nil-guard in agents.go: getAgent returns a bare nil interface, and a type assertion on nil already yields ok == false. cmp.Or was rejected for the gate fallback (func types are not comparable, does not compile) and for ItemArtwork.AttemptedAt (cmp.Or compares time.Time with ==, which includes loc; IsZero does not). * refactor(persistence): collapse the four hydrateArtwork copies into one generic album/artist/playlist/radio each carried the same eight lines, differing only in element type and model.Kind. hydrateItems takes a ref callback yielding an item's id and the ItemImage to fill, which is all that varied. The len()==0 guards drop out: hydrateItemImages already short-circuits an empty id list, and both loops are no-ops on an empty slice. applyItemImage stays as-is; hydrateMediaFileArtwork and its own specs still use it. * perf(scanner): stop reprocessing album and artist artwork on every full scan A full scan re-imports every track, so the per-entity artwork enqueue in persistChanges fired for every album and artist in the library — measured as 100% of both on a live instance, and ~8.5k artists / ~16.7k Deezer fetches on a 96k-file library. Re-importing a track is no evidence the art changed: artist art has no track-content source at all, and the albums re-derived to identical hashes across three consecutive scans. Enqueue through EnqueueIfMissing on a full scan, which anti-joins item_artwork so only entities that never resolved are queued. Incremental scans keep the unconditional Enqueue, where a re-import does mean the file changed. Doing this at the enqueue site rather than skipping it wholesale keeps a first scan filling in covers as it walks, instead of stalling every cover until the scan ends. EnqueueMissing grows a priority argument and runs once at end of scan, so an entity phase 1 never saw still resolves, at Scan priority rather than dropping to Recheck behind the backfill. * refactor(artwork): derive blurhash components inside Encode Components had a single caller, which only ever fed it the bounds of the image it then passed to Encode. Exporting it gave callers two ways to get it wrong — components mismatched with the image, or out of the 1..9 range — in exchange for a knob nobody turned. Derive them from img.Bounds() at the top of Encode and unexport the helper. The counts must come from the pre-downscale bounds: downscale's integer rounding can shift the ratio across a component boundary, and the hash is a client-side cache key. Verified byte-identical over 18 hashes spanning 9 aspect ratios. The out-of-range validation goes with it, being unreachable once the counts are always derived. The aspect-ratio table now asserts through Encode's size flag, which encodes (x-1)+(y-1)*9. * refactor(ui): replace the blurhash package with a local decoder The UI pulled in the `blurhash` dependency for one function, `decode`, called from a single component. The decoder is ~80 lines of well-specified arithmetic, so carrying a dependency for it costs more in supply chain and bundle than it saves. Equivalence was proven against the package before removing it: 84 hashes — three real ones plus every component count from 1x1 to 9x9 — decoded at six sizes, compared byte for byte, plus parity on which malformed inputs throw. Those pixel values are now pinned in the spec, so drift from the reference algorithm fails. The punch parameter is dropped rather than reproduced: no caller passes one, and the package applies `punch | 1`, which silently turns a punch of 2 into 3. * refactor: simplify the artwork enqueue and blurhash paths Cleanup pass over the three preceding commits. Tabulate the cosine terms in the UI blurhash decoder instead of calling Math.cos per pixel per component: 248us -> 75us for a 32x32 decode, and an album grid mounts one decoder per tile. Output is unchanged, which the pinned pixel specs enforce. The Go encoder already tabulated the same terms. Drop the dead paths that deriving components inside Encode left behind: the zero-size guard in components, the post-downscale empty check, and the no-AC-factor branch, which cannot be reached now that the counts are always at least 1x9. The empty-image check moves ahead of the derivation, where it belongs. In the queue mock, look up item_artwork by its existing iaKey rather than scanning the map, and hold the lock across EnqueueIfMissing through a shared unlocked helper instead of releasing it mid-operation. Extract the duplicated drain-and-resolve block in the scanner specs into one helper. * fix(artwork): refresh songs when their album's artwork changes A track with no art of its own is served its album's, so hydration copies the album's hash onto the track record. When an album resolution changed, the worker broadcast only an `album` refresh, leaving song and now-playing surfaces holding the previous hash-suffixed URL until something else refetched them. Pair the album refresh with a song one. The dependent id list is unbounded — an album has arbitrarily many tracks and a drained batch arbitrarily many albums — so this refreshes the resource as a whole via the protocol's existing wildcard rather than enumerating ids. Reported by Codex on #5847. * feat(persistence): log SQLite result codes on failed statements SQLite reuses one message for errors that need different responses: "database is locked" is both SQLITE_BUSY, which busy_timeout retries, and SQLITE_BUSY_SNAPSHOT, which it can never retry because the transaction's read snapshot is already stale. Reading only the message, the two are indistinguishable, and a lock error seen in the wild could not be diagnosed without guessing which one it was. Add db.ErrorCodes to unwrap a sqlite3.Error and report its result and extended result codes, and include them in the SQL error log. The helper lives in db because that package already owns the driver, so persistence does not need to import it. Constraint, readonly and disk-full errors share messages the same way, so this applies to every failed statement, not just locks. * fix(ui): keep the Random grid blank while a refresh re-rolls Refresh bumps the list version, which changes the random seed and remounts the grid. useRollChanged tracked the seed on screen in a ref inside the grid, so the remount started it empty and adopted the new seed on the first render, while the refetch had not begun and the store still held the previous roll. The grid painted the old albums for the length of the request and swapped when the new roll arrived. Move the ref up to AlbumList, which a refresh does not remount, and pass it to the grid and the pagination. The seed on screen then survives the remount, so a refresh reads as a re-roll and blanks until the new roll lands. A search keystroke keeps the seed and still leaves the grid in place. * fix(ui): keep the album grid working outside the Random list Hoisting the shown-seed ref into AlbumList made the prop mandatory in practice: ArtistShow renders the same grid through ReferenceManyField and passes no seed tracking, so useRollChanged dereferenced undefined and the artist page died with "Cannot read properties of undefined (reading 'current')". Own a ref in the grid when none is passed. A caller with no roll to track then behaves as it did before, while the Random list keeps the ref that has to outlive the refresh remount. * refactor(config): make the artwork tuning options dev flags ArtworkWorkerConcurrency and ArtworkExternalMaxRPS become DevArtworkWorkerConcurrency and DevArtworkExternalMaxRPS, joining the other DevArtwork* flags. Their defaults should not need tuning, so they do not belong in the documented, user-facing option set. * refactor(artwork): name the hashed image store folder for how it is addressed The content-addressed store sat in artwork/store/, which did not distinguish it from the artist/, playlist/ and radio/ upload folders beside it — those hold artwork too. It is now artwork/hashed/, naming the one property that sets it apart, and the path comes from a consts entry rather than a bare literal, matching how the sibling folders are built. Deliberately not under cache/: that folder holds resizes that rebuild offline from a local source, while this one holds the only local copy of externally fetched images, whose re-fetch depends on a third party still serving them and whose bytes back the stored blurhash and dimensions. No migration: anything left in the old artwork/store/ is orphaned and re-resolved into the new location. * refactor(artwork): call the album e2e helpers by their real names album_test.go aliased expectAlbumFolderCover and expectAlbumAbsent to shorter local names, which cost a lookup to resolve and hid the prefix that distinguishes them from the artist and playlist helpers. * test(artwork): cover the album-root and artist-folder path arithmetic The unit specs for these helpers lived in the readers #5856 patched, both deleted here, leaving the album-root promotion and the artist-folder climb covered only end-to-end. A layout can show which image won but not why, so these pin the parts behind it: that the parent is fetched only when it could qualify as an album root, that a failed fetch propagates or degrades, and that commonDir keeps a shared name fragment from reading as a shared folder. Each spec was checked against a mutant: removing the library-root guard, the other-album audio check, the single-folder short circuit, the single-root climb, or commonDir's separator all turn one red. Master's remaining specs were dropped as redundant — they cover sources this branch already exercises under different names. * fix(ui): stop artwork refreshes reloading the whole page Resolving an album broadcast song:["*"], because a track with no art of its own is served its album's and the dependent ids are unbounded server-side. useResourceRefresh checked for that wildcard across every resource in the payload, not just the ones a component shows, so the album grid called refresh() — a full page reload — for every drained batch. Upgrading a large library reloaded the grid thousands of times. The client knows what the server cannot: which tracks are loaded, and their albumId. So the fan-out moves there, the wildcard check is scoped to watched resources, and the backend now names only what it resolved. The playlist views watch playlistTrack too: their rows carry albumId but are keyed by playlist entry, so a song refresh never reached them — previously masked by the wildcard's page reload. Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): stop the album grid blanking its covers on refresh Cover takes its height from react-measure, which reports nothing until it re-measures. That was harmless while the cover class sat on the img itself, which has intrinsic size; it now sits on the Artwork root, whose img fills it absolutely and so lends no height. A refresh remounts the tiles, and for the frame before measurement lands the box collapsed to zero and every cover vanished. Give the box its own aspect ratio as a floor. The measured height still wins once it arrives. * refactor(artwork): drop the unused Bump/wake path from the worker Worker.Bump had no production callers. Every real bump writes the queue row through the repository instead: artwork.Refresh (the refresh endpoint and the uploader), service.enqueue (read-through and stale-absent views), and radio_repository. None of them wake a pool. Bump was also the only sender to drainPool.wake, so the select case it fed was unreachable outside tests, and runPool read as if a bump were picked up promptly when it always waited for the 5s poll. The e2e and unit suites used Bump only as a driver, never as the subject, so they now enqueue with EnqueueBump and exercise the path production takes. EnqueueBump rather than Refresh because Refresh also clears the state row, which would change semantics for the re-bump-after-state and dedup specs. Both harnesses start Run after enqueueing, so a fresh pool drains on its first iteration and the wake never affected them: core/artwork/e2e averaged 3.26s before and 3.30s after over 5 runs, within noise. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(artwork): unexport what nothing outside the package calls PlaceholderFor had no callers at all. Its comment offered it to callers that must not consult persisted state, but no such caller was ever written, so it is removed rather than unexported. entityExists and encode83 are only reached from inside their own packages; their tests are package-internal (artwork) or never touch them (blurhash_test). ImageStore stays exported: server/subsonic/e2e constructs one to wire up the worker, and that suite cannot move into package artwork. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(persistence): name the backoff-preserving enqueue for what it does EnqueueBump was named for a priority, but its behaviour is preserving an existing row's retry_at; the priority comes from the item. Its one caller passes ArtworkPriorityScan, which read like a bug at the call site and is not. The three DO NOTHING inserts each repeated the INSERT prefix, the column list and the conflict clause. They now share insertIfNotQueued, with the CTE that EnqueueIfMissing needs passed as a prefix, and the column list lives in one slice used by both squirrel and the raw SQL. EnqueueIfMissing had no test against real SQL, only a mock mirroring the anti-join by hand, so the rewritten statement had nothing verifying it. Two specs now cover it: items with a state row are skipped, and an already-queued row keeps its priority. Signed-off-by: Deluan <deluan@navidrome.org> * test(thumbhash): vendor the reference and generate golden vectors * test(thumbhash): add a faithful reference port as the differential oracle * fix(thumbhash): re-condition alpha fixture and assert solid.png header alpha.png varied only alpha over a constant color, so compositing atop the average canceled L/P/Q exactly like solid.png, leaving it just as float-noise unstable and silently dropping alpha-path coverage. Vary R/G/B with position too so alpha.png carries real signal and reproduces byte-exactly, then replace the blanket solid.png skip with a precise assertion on its well-conditioned header bytes and quantized-zero scales. Also apply a range-over-int modernizer hint in reference_test.go. * test(thumbhash): dither the fixtures so no DCT coefficient is degenerate The gradient fixtures are smooth analytic ramps, and alpha.png's alpha channel varied along x only. Both make whole families of DCT coefficients mathematically zero: 13 of alpha.png's 38 AC nibbles, and at least one in every other ramp fixture. A zero coefficient normalizes to exactly the 0.5 midpoint, i.e. 15*f = 7.5, so which of nibble 7 or 8 it quantizes to is decided by ~1e-16 of float rounding noise. Those nibbles are therefore unstable across any two summation orders -- the vendored JS and the Go port already disagreed on solid.png for this reason -- and they carry no signal, so a bug that transposed two of them would be invisible. A deterministic +/-4 dither, plus an alpha ramp that varies in x and y, leaves every coefficient at least 4.8e-5 from the tie boundary: ~1e9 times the observed inter- implementation noise. solid.png and tiny.png regenerate byte-identical and keep their existing carve-outs. * feat(thumbhash): add a two-pass separable ThumbHash encoder Encode replaces the reference's four w*h scratch arrays (~320 KB at 100x100) and its 40 re-reads of every pixel with a single pixel pass that folds each row into per-frequency sums, then a second fold over rows. The transform drops from O(terms * pixels) to O(nx * pixels + terms * h), nx <= 7. Verified against the vendored JS goldens and, differentially, against the literal Go port on 500 randomized images. On the fixtures the two implementations' AC coefficients agree to 8e-15; the separable inner loop reassociates the additions, so exact agreement holds only where a coefficient is not sitting on a quantization tie, which is why the fixtures are now dithered. * test(thumbhash): fuzz the opaque path and drop an ill-conditioned golden The 500-image randomized differential filled every byte randomly, so hasAlpha (avgA < w*h) was true for all 500 images: the 7x7 no-alpha layout, terms(7,7), nx=7 and the `if hasAlpha` false branch were never fuzzed. Force full opacity on alternating iterations, which splits the run 250/250 with the seed and iteration count unchanged. All 500 still match the reference port. tiny.png is 1x1, so it has no non-zero AC content: 12 of its 37 AC coefficients sit exactly on the round(15*f) = .5 tie and 24 are within 1e-12. It passed only because a single pixel admits no summation reassociation. Give it the same header-only carve-out solid.png already had, in both test files. The four well-conditioned fixtures keep strict full byte equality. Also document the divergence class on Encode itself rather than only in test comments, add a sub-image regression spec, and use the max builtin over math.Max. The toNRGBA Rect.Min gate turns out to protect nothing — SubImage re-slices Pix so Pix[0] is the Rect.Min pixel and the loops read it correctly either way — so its comment, which claimed the opposite, is corrected. The gate is kept for now; removing it is a separate call. * test(thumbhash): benchmark against blurhash at the pipeline input size * feat(artwork): persist a thumbhash alongside the blurhash The artwork table is content-addressed, so this costs one row per unique image rather than per entity. Measured on a 25k-image library: +0.6MB on a 727MB DB. The column is added to the existing add_artwork_tables migration rather than a new one, since #5847 has not been released and the table it extends ships in that same PR. * feat(artwork): hydrate and expose thumbHash on every entity ItemArtworkInfo.Image() is the single projection every hydration branch goes through, so adding the field there covers albums, artists, playlists and both the own-art and inherited media-file branches. * feat(artwork): encode blurhash and thumbhash from one 100px thumbnail thumbnailSize drops 128 -> 100 so a single CatmullRom scale feeds both encoders; thumbhash hard-rejects anything larger and a second downscale would cost more than shrinking the shared one. decodeArtwork on a 1000x1000 JPEG, before -> after: 15.53ms -> 15.45ms/op, 5878122 -> 5014796 B/op, 37 -> 75 allocs/op Time is a wash because the JPEG decode dominates; the 863KB drop is the smaller thumbnail more than paying for thumbhash's added work. This rewrites every blurhash value, so it must land before #5847 reaches a release: Finamp keys its cover cache and download dedup on that value. * feat(ui): use thumbhash for the cover loading placeholder Adds a local decoder rather than the thumbhash npm package, matching the local blurhash decoder it replaces. Pixels are pinned against evanw/thumbhash's reference decoder via the vendored copy already in the encoder's testdata. Unlike a blurhash, a thumbhash carries its own approximate aspect, so a record with no dimensions now falls back to that instead of to a square. The blurHash API field stays: Jellyfin clients and third-party native clients still consume it. * perf(artwork): hand both hash encoders one NRGBA thumbnail makeThumbnail emitted a premultiplied *image.RGBA, so thumbhash allocated and un-premultiplied a full copy on every image while blurhash paid nothing. It now emits *image.NRGBA, which thumbhash wants as-is, and blurhash reads that type directly, premultiplying per pixel to keep its output identical. thumbhash.Encode at the pipeline's 100x100 input: 134200 -> 95300 ns/op, 56188 -> 15163 B/op, 37 -> 35 allocs/op decodeArtwork on a 1000x1000 JPEG: 5014796 -> 4973800 B/op, exactly the conversion that is gone The scaler costs the same into either destination (7.33ms vs 7.34ms measured), so no time is traded for this. * test(artwork): drop the decodeArtwork benchmark It existed to capture the before/after baseline for the 128->100 thumbnail change, which it has served. As an ongoing guard it is misleading: a 1000x1000 JPEG decode is ~97% of the 15ms it measures, so the two encoders it would be reached for are ~2.6% of the signal. It reported a phantom 2.8% regression for the NRGBA thumbnail change that isolating the scaler disproved. The per-encoder benchmarks in blurhash/ and thumbhash/ cover the part that actually changes. * test(artwork): benchmark both hash encoders from one file benchImage and BenchmarkEncodeAtInputSize existed in both encoder packages, and blurhash's copy had drifted into a third inline duplicate once both benches moved to NRGBA input. The head-to-head now lives in core/artwork, which already imports both encoders and can pin the input to the real thumbnailSize constant. The gradient builder moves to tests, which both packages already import via their suite bootstrap. thumbhash keeps a shipped-vs-reference benchmark, since referenceEncode is test-only and cannot be reached from core/artwork. * test(thumbhash): drop the shipped-vs-reference benchmark The two-pass rewrite's advantage over the naive port is already recorded in its commit message; carrying the benchmark to re-derive it has no ongoing use. reference_test.go stays: besides the benchmark baseline it holds the differential oracle the golden-vector and randomised specs assert against. * test(thumbhash): assert against reference goldens, drop the Go port The reference port existed to be a differential oracle, but its packing half was a verbatim copy of production pack(), so it was not as independent as it looked. Only the 500-image randomised spec needed it; the six PNG fixtures cannot reach random sizes, aspects, or both coefficient layouts. gen_generated.mjs now emits 300 vectors from evanw's actual JS. Their pixels are a pure function of their index, so Go rebuilds them byte-for-byte and only the hashes are committed (16KB). The oracle is now the reference itself rather than a hand transcription of it. Also from the cleanup pass: - maxCX/maxCY scanned every term to recover a bound that is just the widest coefficient region - tests.GradientImage duplicated generateGradientImage three files away - BenchmarkHashEncodersAtInputSize was also BenchmarkHashEncoders/*/100x100 - processor had two internal test files with no rule for which gets a new spec - three blurhash specs differing only by alpha became a DescribeTable - the shared mock's GetInfoForItems projection had drifted from the real query * refactor(model): keep the blurhash off native JSON and fold its specs Nothing on the native API consumes blurHash: the web UI reads thumbHash, and Jellyfin's mappers take the Go field directly rather than through this serialization. It was shipping ~40 unused bytes on every row of every list response, on a surface that accretes clients once published. The JSON specs were five marshal-and-check-keys blocks over the same struct; they collapse to one populated case, one bare case, and a guard that the blurhash stays off the wire. * refactor(persistence): simplify the artwork dangling-row purge helper purgeDangling took a bound executeSQL method value and a table name only because its two callers live on unrelated types. Neither parameter was load-bearing: executeSQL is a value receiver on sqlRepository that never reads tableName, and both callers already hold an sqlRepository whose tableName is the table being purged. Taking that struct collapses both parameters into the receiver. itemArtworkSQL wrapped sqlRepository without adding a single method, so the items field becomes a plain sqlRepository. danglingItemArtworkKinds is also read by EnqueueAllMissing, which enqueues entities that have no artwork row yet - neither a purge nor anything specific to item_artwork. Renamed to artworkOwnerTables to describe both of its uses. * fix(artwork): keep sweeping past a store file that cannot be removed Sweep returned the os.Remove error straight out of WalkDir, so a single unremovable file abandoned the rest of the walk. Every later orphan, stale mime variant and abandoned temp file then survived until the next prune, which would abort at the same place. Warn and carry on instead. The orphan-file loop in prune already had this resilience; Sweep is where it belongs, since it is the only step that reaches stray files and superseded variants. * refactor(artwork): let the sweep reclaim orphan files instead of prune Deleting unreferenced artwork rows took five round trips: snapshot the orphan hashes, fetch their mimes, delete, re-fetch to see which rows actually went, then remove each file. The last four exist only to work out which files to delete - but the sweep that runs moments later already derives exactly that from the database, since GetAllMimes is read after the delete and Sweep removes any file whose hash has no row, under the same mtime guard. The sweep is also the more thorough of the two: it reclaims stale mime variants of an orphan hash, which the per-hash removal never looked at. Delete the rows in one predicate-only statement and let the sweep follow. The orphan predicate now runs once per prune instead of once for the snapshot plus once per 200-hash delete chunk, and with no hash list to bind there is nothing left to chunk. GetOrphanHashes and GetImages had no other caller and are gone. Dropping them also removes the mock's OrphanHashes lever, which let a spec declare a hash orphaned while item_artwork still referenced it - a state the SQL repository could never produce, since both predicates were always identical. * refactor(model): name the artwork mime lookup for what it is keyed by GetAllMimes reads as a collection of mime types; it returns a hash -> mime index over every stored artwork. GetMimeByHash names the key, matching the convention the other map-returning repository methods already follow (CountBySuffix, CountByClient). * refactor(artwork): split the repository's deletes from its purges by name Six removal methods across the two artwork repositories used Delete and Purge interchangeably, so neither prefix told you anything. There is a real distinction underneath: Delete methods are caller-directed and take the rows to remove, while Purge methods are garbage collection - the repository finds rows whose referent is gone by joining against the owning table, and returns a count because the caller cannot know one. The signatures already followed that split; only the names did not. DeleteOrphans was the odd one out, taking a grace cutoff rather than a target and returning a count, so it becomes PurgeOrphans. PurgeDanglingItemArtwork loses the Artwork it repeats from its own interface and keeps the Items that says which of that interface's two tables it means. DeleteForItem was DeleteForItems with one id - squirrel emits = for a scalar and IN for a slice against the same predicate - so its one caller passes a slice instead. Dangling and Orphan stay distinct: dangling is a row whose entity is gone, orphan is an image no state row references. * refactor(artwork): drop the orphaned store remover, aggregate sweep failures Review follow-ups to the prune rework. ImageStore.Remove lost its only production caller when the sweep took over orphan-file reclamation. It carried its own copy of the mtime guard that now lives solely in Sweep, so the two could drift, and it stood as an invitation to grow a second reclaim path. Its specs either duplicated Sweep's own coverage or tested nothing else, so they go with it; the one unrelated test that used it to delete a file calls os.Remove directly. Sweep warned once per file it could not remove, which is fine for a stray permission bit and useless for a store mounted read-only - a large library would emit one line per aged file on every prune, and prune would still report success. Count the failures instead and warn once, with the count and the last error, so a systemic failure is legible without drowning the log. Also: the orphan log line counts rows, not files, and now says so; and the blocked-removal spec asserts its two fixtures really do land in different shards, since the assertion is vacuous if they collide. * fix(ui): show the placeholder when a refresh swaps in an uncached cover A cover whose hash changed under a mounted grid went blank for the whole fetch, instead of falling back to its thumbhash. Artwork decided 'this blob was already cached, skip the placeholder' once at mount and never revisited it, so every later url for that record inherited the answer. The fix has to live in useImageUrl. imgUrl is state cleared in an effect, so on the render where url changes it still holds the previous url's blob - any caller inspecting it reads the new url as already cached. Only the hook can see cache.get(url) for the url actually being rendered, so it now reports fromCache and resyncs its state during render rather than one render later. Two shapes were tried and rejected against a live server before this one: a ref recomputed per url, which a render-phase resync discards without rolling back the ref, and the same tracking in Artwork state, which still samples imgUrl before React re-runs the component. Verified on a running instance with cover-art responses held open: the thumbhash now covers the whole window and stays under the image until the fade ends. * fix(ui): keep the placeholder for a cover whose fetch failed useImageUrl caches a failed fetch as an entry with no blob, so treating every cached entry as instantly painted suppressed the placeholder on remount: the cover became an empty box instead of falling back to its thumbhash. Only a cached blob counts as instant. Caught by Codex on the previous commit, which introduced fromCache with the looser predicate. * feat(artwork): capture each image's dominant colour A flat colour is the one placeholder a client can paint with no decoding at all, so it can cover the frames before a thumbhash canvas even renders. Extract it from the same 100px thumbnail the two hash encoders already share, and expose it as dominantColor on the native API. Dominant means presence, not salience: the largest cluster wins, so a white sleeve reports white. That is what a placeholder needs. An accent colour is the opposite question and is deliberately not answered here - it depends on the client's theme, and by the time a client needs one it has the cover pixels and can compute whatever suits it. Bins at 4 bits per channel, then merges perceptually-near bins in Oklab before picking the winner: a gradient splits across adjacent bins and would otherwise lose to a smaller flat region. Measured at 246us per image on the weakest target hardware we care about (Celeron N5105), against ~15.8 images/s for the pipeline around it. The column goes into the feature's original migration rather than a new one: computing it later would mean re-decoding every image in the library. * fix(artwork): ignore image candidates that cannot be fetched bestImageURL only rejected URLs that url.Parse itself refuses, which is almost nothing: relative paths and any scheme parse cleanly. A candidate like images/big.jpg or ftp://host/big.jpg therefore won on size, and since the function returns a single URL whose failure ends that agent's turn, the agent's valid smaller images were never tried. Two details made it easy to hit rather than theoretical. Size is frequently 0 for every candidate, and only a strictly larger one replaces the first, so an unfetchable entry in first position sticks. And these strings come straight from plugins, where a relative path is an ordinary mistake. Accept only absolute http/https URLs with a host. * revert(ui): move detail-page scroll handling to its own branch Reverts 40153bd43. Scroll position carrying over between pages is stock React Router behaviour rather than something this branch introduced, so the fix does not belong in an artwork PR that is otherwise ready for review. The work continues on fix/scroll-restoration, off master, where it grew into per-route restoration: a new page starts at the top and going back returns to where you were, which the scroll-to-top could not do. * fix(ui): fade cover art consistently, and shorten the fade to 150ms The <img> mounts with its blob URL already set, so onLoad often fires in the same frame the element is inserted. The opacity:0 start state never gets painted, the transition has no value to animate from, and the cover pops in instead of fading. Only tiles whose decode happened to straddle a paint boundary actually faded, which made a grid load look ragged: 4 to 6 of 18 covers faded, varying between runs. Deferring the decoded flag by two animation frames guarantees the hidden state is painted first. Measured on a live grid afterwards, 17 of 18 covers fade, the 18th having a cached blob and taking the intentional instant path, and the tail after the last byte arrives drops from ~440ms to ~163ms. The duration also drops from 500ms to 150ms. That 500ms mirrored master's fade, but master paints no placeholder behind the image and needs a slow blend to cover a blank tile. Here a thumbhash already fills the gap, so a long crossfade only delays the grid settling, measured at 145-190ms slower to full opacity than master. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/deezer/deezer.go | 13 +- adapters/deezer/deezer_test.go | 48 ++ adapters/lastfm/agent.go | 34 +- adapters/lastfm/agent_test.go | 32 +- cmd/pls.go | 4 +- cmd/root.go | 66 ++ cmd/scan.go | 4 +- cmd/wire_gen.go | 109 ++- cmd/wire_injectors.go | 8 + conf/configuration.go | 8 + consts/consts.go | 10 + core/agents/agents.go | 36 + core/agents/agents_test.go | 72 ++ core/artwork/agent_images.go | 100 +++ core/artwork/agent_images_test.go | 262 ++++++ core/artwork/artwork.go | 422 +++++++-- core/artwork/artwork_internal_test.go | 629 -------------- core/artwork/artwork_suite_test.go | 48 +- core/artwork/artwork_test.go | 522 ++++++++++- core/artwork/benchmark_e2e_test.go | 189 ---- core/artwork/benchmark_helpers_test.go | 9 + core/artwork/blurhash/blurhash.go | 191 ++++ core/artwork/blurhash/blurhash_suite_test.go | 17 + core/artwork/blurhash/blurhash_test.go | 137 +++ core/artwork/cache_warmer.go | 162 ---- core/artwork/cache_warmer_test.go | 245 ------ core/artwork/{reader_disc.go => disc.go} | 128 ++- .../{reader_disc_test.go => disc_test.go} | 0 core/artwork/dominant/dominant.go | 127 +++ core/artwork/dominant/dominant_suite_test.go | 17 + core/artwork/dominant/dominant_test.go | 91 ++ core/artwork/e2e/acquire_serve_test.go | 326 +++++++ core/artwork/e2e/album_test.go | 217 ++--- core/artwork/e2e/artist_test.go | 113 +-- core/artwork/e2e/disc_test.go | 128 +-- core/artwork/e2e/e2e_suite_test.go | 83 ++ core/artwork/e2e/helpers_test.go | 184 ---- core/artwork/e2e/mediafile_test.go | 71 +- core/artwork/e2e/playlist_test.go | 133 ++- core/artwork/e2e/radio_test.go | 29 +- core/artwork/e2e/resolution_harness_test.go | 365 ++++++++ core/artwork/e2e/suite_test.go | 120 --- core/artwork/e2e/testdata/embedded_art.mp3 | Bin 64223 -> 0 bytes .../{reader_album.go => folders_album.go} | 90 +- core/artwork/folders_album_test.go | 214 +++++ core/artwork/folders_artist.go | 210 +++++ core/artwork/folders_artist_paths_test.go | 117 +++ core/artwork/folders_artist_test.go | 47 + core/artwork/gate.go | 120 +++ core/artwork/hash_encoder_bench_test.go | 51 ++ core/artwork/housekeeping.go | 129 +++ core/artwork/housekeeping_test.go | 261 ++++++ core/artwork/image_cache.go | 62 +- core/artwork/image_store.go | 155 ++++ core/artwork/image_store_test.go | 228 +++++ core/artwork/library_fs.go | 22 +- core/artwork/library_fs_test.go | 7 + core/artwork/playlist_cover.go | 116 +++ core/artwork/processor.go | 311 +++++++ core/artwork/processor_test.go | 495 +++++++++++ core/artwork/prune.go | 63 ++ core/artwork/prune_test.go | 239 +++++ core/artwork/reader_album_test.go | 481 ----------- core/artwork/reader_artist.go | 359 -------- core/artwork/reader_artist_test.go | 761 ---------------- core/artwork/reader_mediafile.go | 82 -- core/artwork/reader_playlist.go | 269 ------ core/artwork/reader_radio.go | 40 - core/artwork/reader_radio_test.go | 84 -- core/artwork/reader_resized.go | 209 ----- core/artwork/reader_resized_test.go | 176 ---- core/artwork/resize.go | 144 ++++ core/artwork/resolve.go | 452 ++++++++++ core/artwork/resolve_test.go | 632 ++++++++++++++ core/artwork/sources.go | 72 +- core/artwork/thumbhash/testdata/alpha.png | Bin 0 -> 24912 bytes .../thumbhash/testdata/gen_fixtures.mjs | 90 ++ .../thumbhash/testdata/gen_generated.mjs | 28 + .../artwork/thumbhash/testdata/gen_golden.mjs | 37 + .../artwork/thumbhash/testdata/generated.json | 1 + core/artwork/thumbhash/testdata/golden.json | 8 + core/artwork/thumbhash/testdata/landscape.png | Bin 0 -> 20198 bytes core/artwork/thumbhash/testdata/portrait.png | Bin 0 -> 20102 bytes core/artwork/thumbhash/testdata/solid.png | Bin 0 -> 209 bytes core/artwork/thumbhash/testdata/square.png | Bin 0 -> 33071 bytes core/artwork/thumbhash/testdata/thumbhash.js | 288 +++++++ core/artwork/thumbhash/testdata/tiny.png | Bin 0 -> 70 bytes core/artwork/thumbhash/thumbhash.go | 257 ++++++ .../artwork/thumbhash/thumbhash_suite_test.go | 17 + core/artwork/thumbhash/thumbhash_test.go | 194 +++++ core/{image_upload.go => artwork/uploader.go} | 54 +- .../uploader_test.go} | 67 +- core/artwork/wire_providers.go | 4 +- core/artwork/worker.go | 318 +++++++ core/artwork/worker_soak_test.go | 146 ++++ core/artwork/worker_test.go | 813 ++++++++++++++++++ core/artwork/worker_timing_test.go | 76 ++ core/external/provider.go | 85 +- core/external/provider_albumimage_test.go | 365 -------- core/external/provider_artistimage_test.go | 426 --------- core/playlists/import_test.go | 44 +- core/playlists/playlists.go | 17 +- core/playlists/playlists_test.go | 38 +- core/playlists/rest_adapter_test.go | 4 +- core/wire_providers.go | 2 - db/db.go | 12 + .../20260722023032_add_artwork_tables.sql | 46 + go.mod | 2 +- log/formatters.go | 11 +- log/formatters_test.go | 6 + model/album.go | 2 + model/artist.go | 2 + model/artwork.go | 144 ++++ model/artwork_id.go | 82 +- model/artwork_id_test.go | 63 +- model/artwork_test.go | 73 ++ model/datastore.go | 2 + model/mediafile.go | 16 +- model/playlist.go | 2 + model/radio.go | 4 + model/radio_test.go | 5 +- persistence/album_repository.go | 36 +- persistence/album_repository_test.go | 28 + persistence/artist_repository.go | 37 +- persistence/artist_repository_test.go | 12 + persistence/artwork_hydration.go | 181 ++++ persistence/artwork_hydration_test.go | 773 +++++++++++++++++ persistence/artwork_queue_repository.go | 149 ++++ persistence/artwork_queue_repository_test.go | 284 ++++++ persistence/artwork_repository.go | 176 ++++ persistence/artwork_repository_test.go | 248 ++++++ persistence/e2e/e2e_suite_test.go | 5 +- persistence/mediafile_repository.go | 43 +- persistence/mediafile_repository_test.go | 36 + persistence/persistence.go | 8 + persistence/playlist_repository.go | 66 +- persistence/playlist_repository_test.go | 88 ++ persistence/playlist_track_repository.go | 5 +- persistence/radio_repository.go | 45 +- persistence/radio_repository_test.go | 33 + persistence/sql_base_repository.go | 13 +- scanner/controller.go | 9 +- scanner/controller_test.go | 3 +- scanner/phase_1_folders.go | 51 +- scanner/phase_4_playlists.go | 11 +- scanner/phase_4_playlists_test.go | 28 +- scanner/scanner.go | 32 +- scanner/scanner_benchmark_test.go | 5 +- scanner/scanner_multilibrary_test.go | 5 +- scanner/scanner_selective_test.go | 5 +- scanner/scanner_test.go | 94 +- server/imghttp/headers.go | 67 ++ server/imghttp/headers_test.go | 117 +++ server/imghttp/imghttp_suite_test.go | 17 + server/jellyfin/dto/blurhash.go | 36 - server/jellyfin/dto/blurhash_test.go | 27 - server/jellyfin/dto/dto.go | 15 +- server/jellyfin/dto/mappers.go | 137 ++- server/jellyfin/dto/mappers_test.go | 283 +++++- server/jellyfin/e2e/e2e_suite_test.go | 11 +- server/jellyfin/e2e/playlists_test.go | 22 +- server/jellyfin/images.go | 29 +- server/jellyfin/images_test.go | 54 +- server/jellyfin/items.go | 13 +- server/jellyfin/similar.go | 2 +- server/nativeapi/artists.go | 12 +- server/nativeapi/artwork.go | 42 + server/nativeapi/artwork_test.go | 95 ++ server/nativeapi/image_upload.go | 4 +- server/nativeapi/native_api.go | 6 +- server/nativeapi/radios.go | 12 +- server/public/handle_images.go | 16 +- server/public/handle_images_test.go | 63 ++ server/subsonic/browsing.go | 18 +- server/subsonic/browsing_test.go | 54 ++ server/subsonic/e2e/e2e_suite_test.go | 22 +- server/subsonic/e2e/subsonic_artwork_test.go | 328 +++++++ .../e2e/subsonic_multilibrary_test.go | 5 +- .../e2e/subsonic_sonic_similarity_test.go | 3 +- server/subsonic/helpers.go | 41 +- server/subsonic/helpers_test.go | 76 ++ server/subsonic/media_retrieval.go | 13 +- server/subsonic/media_retrieval_test.go | 88 +- server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 13 + server/subsonic/radio.go | 4 +- server/subsonic/radio_test.go | 19 +- server/subsonic/searching.go | 12 +- server/subsonic/searching_test.go | 30 + tests/harness/harness.go | 47 +- tests/mock_album_repo.go | 14 + tests/mock_artist_repo.go | 9 + tests/mock_artwork_queue_repo.go | 258 ++++++ tests/mock_artwork_repo.go | 175 ++++ tests/mock_data_store.go | 78 +- tests/mock_mediafile_repo.go | 12 + tests/mock_playlist_repo.go | 9 + tests/mock_playlist_track_repo.go | 8 + tests/mock_radio_repository.go | 11 +- ui/src/album/AlbumDetails.jsx | 39 +- ui/src/album/AlbumGridView.jsx | 43 +- ui/src/album/AlbumGridView.test.jsx | 18 + ui/src/album/AlbumList.jsx | 18 +- ui/src/album/AlbumTableView.jsx | 6 +- ui/src/album/useRollChanged.jsx | 13 + ui/src/album/useRollChanged.test.jsx | 73 ++ ui/src/artist/ArtistList.jsx | 4 +- ui/src/artist/ArtistSimpleList.jsx | 4 +- ui/src/artist/DesktopArtistDetails.jsx | 47 +- ui/src/artist/MobileArtistDetails.jsx | 49 +- ui/src/common/Artwork.jsx | 127 +++ ui/src/common/Artwork.test.jsx | 232 +++++ ui/src/common/ArtworkAvatar.jsx | 40 + ui/src/common/CoverArtAvatar.jsx | 52 -- ui/src/common/ThumbHashCanvas.jsx | 75 ++ ui/src/common/ThumbHashCanvas.test.jsx | 95 ++ ui/src/common/index.js | 2 +- ui/src/common/useImageUrl.js | 14 +- ui/src/common/useImageUrl.test.js | 3 + ui/src/common/useResourceRefresh.jsx | 48 +- ui/src/common/useResourceRefresh.test.js | 185 +++- ui/src/playlist/PlaylistDetails.jsx | 49 +- ui/src/playlist/PlaylistList.jsx | 4 +- ui/src/playlist/PlaylistShow.jsx | 2 +- ui/src/playlist/PlaylistSongs.jsx | 3 +- ui/src/radio/RadioList.jsx | 32 +- ui/src/subsonic/index.js | 14 +- ui/src/subsonic/index.test.js | 41 + ui/src/utils/thumbhash.js | 138 +++ ui/src/utils/thumbhash.test.js | 66 ++ utils/cache/file_caches.go | 9 + utils/cache/file_caches_test.go | 87 ++ utils/cache/spread_fs.go | 2 +- utils/cache/spread_fs_test.go | 36 + utils/cache/spread_fs_unix.go | 18 + utils/cache/spread_fs_windows.go | 15 + 236 files changed, 16349 insertions(+), 6350 deletions(-) create mode 100644 core/artwork/agent_images.go create mode 100644 core/artwork/agent_images_test.go delete mode 100644 core/artwork/artwork_internal_test.go delete mode 100644 core/artwork/benchmark_e2e_test.go create mode 100644 core/artwork/blurhash/blurhash.go create mode 100644 core/artwork/blurhash/blurhash_suite_test.go create mode 100644 core/artwork/blurhash/blurhash_test.go delete mode 100644 core/artwork/cache_warmer.go delete mode 100644 core/artwork/cache_warmer_test.go rename core/artwork/{reader_disc.go => disc.go} (60%) rename core/artwork/{reader_disc_test.go => disc_test.go} (100%) create mode 100644 core/artwork/dominant/dominant.go create mode 100644 core/artwork/dominant/dominant_suite_test.go create mode 100644 core/artwork/dominant/dominant_test.go create mode 100644 core/artwork/e2e/acquire_serve_test.go create mode 100644 core/artwork/e2e/e2e_suite_test.go delete mode 100644 core/artwork/e2e/helpers_test.go create mode 100644 core/artwork/e2e/resolution_harness_test.go delete mode 100644 core/artwork/e2e/suite_test.go delete mode 100644 core/artwork/e2e/testdata/embedded_art.mp3 rename core/artwork/{reader_album.go => folders_album.go} (63%) create mode 100644 core/artwork/folders_album_test.go create mode 100644 core/artwork/folders_artist.go create mode 100644 core/artwork/folders_artist_paths_test.go create mode 100644 core/artwork/folders_artist_test.go create mode 100644 core/artwork/gate.go create mode 100644 core/artwork/hash_encoder_bench_test.go create mode 100644 core/artwork/housekeeping.go create mode 100644 core/artwork/housekeeping_test.go create mode 100644 core/artwork/image_store.go create mode 100644 core/artwork/image_store_test.go create mode 100644 core/artwork/playlist_cover.go create mode 100644 core/artwork/processor.go create mode 100644 core/artwork/processor_test.go create mode 100644 core/artwork/prune.go create mode 100644 core/artwork/prune_test.go delete mode 100644 core/artwork/reader_album_test.go delete mode 100644 core/artwork/reader_artist.go delete mode 100644 core/artwork/reader_artist_test.go delete mode 100644 core/artwork/reader_mediafile.go delete mode 100644 core/artwork/reader_playlist.go delete mode 100644 core/artwork/reader_radio.go delete mode 100644 core/artwork/reader_radio_test.go delete mode 100644 core/artwork/reader_resized.go delete mode 100644 core/artwork/reader_resized_test.go create mode 100644 core/artwork/resize.go create mode 100644 core/artwork/resolve.go create mode 100644 core/artwork/resolve_test.go create mode 100644 core/artwork/thumbhash/testdata/alpha.png create mode 100644 core/artwork/thumbhash/testdata/gen_fixtures.mjs create mode 100644 core/artwork/thumbhash/testdata/gen_generated.mjs create mode 100644 core/artwork/thumbhash/testdata/gen_golden.mjs create mode 100644 core/artwork/thumbhash/testdata/generated.json create mode 100644 core/artwork/thumbhash/testdata/golden.json create mode 100644 core/artwork/thumbhash/testdata/landscape.png create mode 100644 core/artwork/thumbhash/testdata/portrait.png create mode 100644 core/artwork/thumbhash/testdata/solid.png create mode 100644 core/artwork/thumbhash/testdata/square.png create mode 100644 core/artwork/thumbhash/testdata/thumbhash.js create mode 100644 core/artwork/thumbhash/testdata/tiny.png create mode 100644 core/artwork/thumbhash/thumbhash.go create mode 100644 core/artwork/thumbhash/thumbhash_suite_test.go create mode 100644 core/artwork/thumbhash/thumbhash_test.go rename core/{image_upload.go => artwork/uploader.go} (55%) rename core/{image_upload_test.go => artwork/uploader_test.go} (60%) create mode 100644 core/artwork/worker.go create mode 100644 core/artwork/worker_soak_test.go create mode 100644 core/artwork/worker_test.go create mode 100644 core/artwork/worker_timing_test.go delete mode 100644 core/external/provider_albumimage_test.go delete mode 100644 core/external/provider_artistimage_test.go create mode 100644 db/migrations/20260722023032_add_artwork_tables.sql create mode 100644 model/artwork.go create mode 100644 model/artwork_test.go create mode 100644 persistence/artwork_hydration.go create mode 100644 persistence/artwork_hydration_test.go create mode 100644 persistence/artwork_queue_repository.go create mode 100644 persistence/artwork_queue_repository_test.go create mode 100644 persistence/artwork_repository.go create mode 100644 persistence/artwork_repository_test.go create mode 100644 server/imghttp/headers.go create mode 100644 server/imghttp/headers_test.go create mode 100644 server/imghttp/imghttp_suite_test.go delete mode 100644 server/jellyfin/dto/blurhash.go delete mode 100644 server/jellyfin/dto/blurhash_test.go create mode 100644 server/nativeapi/artwork.go create mode 100644 server/nativeapi/artwork_test.go create mode 100644 server/subsonic/e2e/subsonic_artwork_test.go create mode 100644 tests/mock_artwork_queue_repo.go create mode 100644 tests/mock_artwork_repo.go create mode 100644 ui/src/album/AlbumGridView.test.jsx create mode 100644 ui/src/album/useRollChanged.jsx create mode 100644 ui/src/album/useRollChanged.test.jsx create mode 100644 ui/src/common/Artwork.jsx create mode 100644 ui/src/common/Artwork.test.jsx create mode 100644 ui/src/common/ArtworkAvatar.jsx delete mode 100644 ui/src/common/CoverArtAvatar.jsx create mode 100644 ui/src/common/ThumbHashCanvas.jsx create mode 100644 ui/src/common/ThumbHashCanvas.test.jsx create mode 100644 ui/src/utils/thumbhash.js create mode 100644 ui/src/utils/thumbhash.test.js create mode 100644 utils/cache/spread_fs_unix.go create mode 100644 utils/cache/spread_fs_windows.go diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index d8e832cf1..742b8b1a5 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -70,16 +70,27 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([ {artist.PictureSmall, deezerApiPictureSmallSize}, } for _, imgData := range possibleImages { - if imgData.URL != "" { + if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) { res = append(res, agents.ExternalImage{ URL: imgData.URL, Size: imgData.Size, }) } } + if len(res) == 0 { + return nil, agents.ErrNotFound + } return res, nil } +// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture +// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host. +const deezerEmptyPicturePath = "/images/artist//" + +func isPlaceholderPicture(url string) bool { + return strings.Contains(url, deezerEmptyPicturePath) +} + func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) if errors.Is(err, ErrNotFound) || len(artists) == 0 { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index f478af115..360db1f13 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -94,6 +94,54 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("GetArtistImages", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("returns the real images when the artist has a picture", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":412,"name":"Queen","nb_fan":12744378, + "picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg", + "picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"} + ],"total":1}`)), + }) + + images, err := agent.GetArtistImages(ctx, "", "Queen", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(HaveLen(2)) + Expect(images[0].URL).To(ContainSubstring("1000x1000")) + }) + + It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":412,"name":"Queen","nb_fan":12744378, + "picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg", + "picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg", + "picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg", + "picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"} + ],"total":1}`)), + }) + + images, err := agent.GetArtistImages(ctx, "", "Queen", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + Expect(images).To(BeEmpty()) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index eb8f3d36e..f967595e3 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -93,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin var resp agents.AlbumInfo for _, lang := range l.languages { var err error - a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang) + a, err = l.callAlbumGetInfo(ctx, name, artist, lang) if err != nil { return nil, err } @@ -114,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin } func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0]) + a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0]) if err != nil { return nil, err } @@ -286,22 +286,18 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) return res, nil } -func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) { - a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang) - var lfErr *lastFMError - isLastFMError := errors.As(err, &lfErr) - - if mbid != "" && (isLastFMError && lfErr.Code == 6) { - log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) - return l.callAlbumGetInfo(ctx, name, artist, "", lang) - } - +// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable — +// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed. +func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) { + a, err := l.client.albumGetInfo(ctx, name, artist, "", lang) if err != nil { - if isLastFMError && lfErr.Code == 6 { - log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err) - } else { - log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err) + if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { + // A not-found is a definitive absence, not a fault: return the shared sentinel so the + // artwork worker's breaker/transient checks don't retry it, and log it at Debug. + log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist) + return nil, agents.ErrNotFound } + log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err) return nil, err } return a, nil @@ -313,6 +309,12 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s a, err := l.client.artistGetInfo(ctx, name, lang) if err != nil { + if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { + // A not-found is a definitive absence, not a fault: return the shared sentinel so it + // doesn't trip the artwork worker's breaker, and log at Debug instead of Error. + log.Debug(ctx, "Artist not found in Last.fm", "artist", name) + return nil, agents.ErrNotFound + } log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) return nil, err } diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 7e4e29294..94024b9ab 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -539,7 +539,10 @@ var _ = Describe("lastfmAgent", func() { URL: "https://www.last.fm/music/Cher/Believe", })) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) + // MBID is deliberately not sent — album.getInfo matches on name+artist only. + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe")) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher")) }) It("returns empty images if no images are available", func() { @@ -558,7 +561,7 @@ var _ = Describe("lastfmAgent", func() { _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) }) It("returns an error if Last.fm call returns an error", func() { @@ -566,23 +569,17 @@ var _ = Describe("lastfmAgent", func() { _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) }) - It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + It("returns an error when Last.fm returns an error 6 (album not found)", func() { httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, err := agent.GetAlbumInfo(ctx, "123", "U2", "") + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) + // A definitive not-found must satisfy the sentinel, or the artwork worker retries it. + Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) Expect(httpClient.RequestCount).To(Equal(1)) - }) - - Context("MBID non existent in Last.fm", func() { - It("calls again when last.fm returns an error 6", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") - Expect(httpClient.RequestCount).To(Equal(2)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - }) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) }) }) @@ -613,6 +610,13 @@ var _ = Describe("lastfmAgent", func() { Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png")) }) + It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() { + apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "") + // Not a fault: runs of missing artists must not trip the worker's circuit breaker. + Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) + }) + It("returns empty list if image is the ignored default image", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} diff --git a/cmd/pls.go b/cmd/pls.go index 95cbe4eec..184ca6fe7 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -12,7 +12,7 @@ import ( "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -260,7 +260,7 @@ func runImport(ctx context.Context, files []string) { ctx = request.WithUser(ctx, *user) } - pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) for _, file := range files { absPath, err := filepath.Abs(file) diff --git a/cmd/root.go b/cmd/root.go index d9e766c32..ff1641bd3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -88,6 +89,9 @@ func runNavidrome(ctx context.Context) { g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) + artworkWorker := CreateArtworkWorker() + g.Go(startArtworkWorker(ctx, artworkWorker)) + g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { g.Go(startScanWatcher(ctx)) @@ -344,6 +348,68 @@ func startPlaybackServer(ctx context.Context) func() error { } } +// startArtworkWorker starts the background artwork acquisition worker. It always +// runs; the queue is simply empty until something enqueues work into it. +func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error { + return func() error { + log.Info(ctx, "Starting artwork worker") + return worker.Run(ctx) + } +} + +// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the +// recurring stale-absent recheck and prune jobs. +func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { + return func() error { + schedulerInstance := scheduler.GetInstance() + + if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { + if err := worker.EnqueueStaleAbsentAll(ctx); err != nil { + log.Error(ctx, "Error enqueueing stale artwork rechecks", err) + } + if err := worker.EnqueueMissingAll(ctx); err != nil { + log.Error(ctx, "Error enqueueing missing artwork rechecks", err) + } + }); err != nil { + log.Error(ctx, "Error scheduling artwork stale-absent recheck", err) + } + + if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { + if err := worker.RunPrune(ctx); err != nil { + log.Error(ctx, "Error running artwork prune", err) + } + }); err != nil { + log.Error(ctx, "Error scheduling artwork prune", err) + } + + // Also run the missing-row recheck once at startup so a never-scanned entity is picked up + // immediately, not only on the next hourly tick (e.g. after enabling the feature). + if err := worker.EnqueueMissingAll(ctx); err != nil { + log.Error(ctx, "Error enqueueing missing artwork rechecks", err) + } + + backfilled, err := worker.Backfill(ctx) + if err != nil { + log.Error(ctx, "Error running artwork backfill", err) + return nil + } + if !backfilled { + return nil + } + log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune") + timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay) + defer timer.Stop() + select { + case <-timer.C: + if err := worker.RunPrune(ctx); err != nil { + log.Error(ctx, "Error running post-backfill artwork prune", err) + } + case <-ctx.Done(): + } + return nil + } +} + // startPluginManager starts the plugin manager, if configured. func startPluginManager(ctx context.Context) func() error { return func() error { diff --git a/cmd/scan.go b/cmd/scan.go index 320b401d4..08eccbf2a 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -9,7 +9,7 @@ import ( "os" "strings" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -82,7 +82,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c4a797ef4..4bdd48422 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -65,25 +65,18 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) insights := metrics.GetInstance(dataStore) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader) return router } @@ -91,23 +84,23 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + share := core.NewShare(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) - players := core.NewPlayers(dataStore) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) @@ -121,21 +114,22 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) players := core.NewPlayers(dataStore) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) @@ -146,14 +140,9 @@ func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) @@ -193,38 +182,22 @@ func CreatePrometheus() metrics.Metrics { func CreateScanner(ctx context.Context) model.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) return modelScanner } func CreateScanWatcher(ctx context.Context) scanner.Watcher { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } @@ -236,6 +209,20 @@ func GetPlaybackServer() playback.PlaybackServer { return playbackServer } +func CreateArtworkWorker() *artwork.Worker { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + imageStore := artwork.GetImageStore() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + fFmpeg := ffmpeg.New() + fileCache := artwork.GetImageCache() + worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache) + return worker +} + func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -247,7 +234,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 94faa5af3..ae24b61fa 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" @@ -56,6 +57,7 @@ var allProviders = wire.NewSet( wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), + wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)), ) func CreateDataStore() model.DataStore { @@ -136,6 +138,12 @@ func GetPlaybackServer() playback.PlaybackServer { )) } +func CreateArtworkWorker() *artwork.Worker { + panic(wire.Build( + allProviders, + )) +} + func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index 9b49fb264..5122b15eb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -144,6 +144,8 @@ type configOptions struct { DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration DevArtworkThrottleBuffered bool + DevArtworkWorkerConcurrency int + DevArtworkExternalMaxRPS int DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -1071,6 +1073,12 @@ func setViperDefaults() { viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebuffered", true) + // Half the CPU count (min 2), so local resolution scales with the host but stays under the + // SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI. + viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2)) + // External RPS gates outbound calls to third-party services (per service); it is bounded by + // their tolerance, not the host, so it stays a small constant regardless of CPU count. + viper.SetDefault("devartworkexternalmaxrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/consts/consts.go b/consts/consts.go index e1a535c79..46906b8b5 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -24,6 +24,9 @@ const ( LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" DBAnalyzePendingKey = "DBAnalyzePending" DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" + // ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against + // to detect artwork-affecting config changes across restarts. + ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -36,6 +39,10 @@ const ( DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour + ArtworkStaleAbsentRecheckSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" + ArtworkPostBackfillPruneDelay = 10 * time.Minute + // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option DefaultEncryptionKey = "just for obfuscation" @@ -80,6 +87,9 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" + // HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed + // upload folders beside it so Prune's sweep never reaches them. + HashedArtworkFolder = "hashed" PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" diff --git a/core/agents/agents.go b/core/agents/agents.go index ead6dacd0..348f7d4e7 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -124,6 +124,42 @@ func (a *Agents) AgentName() string { return "agents" } +// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability. +type ArtistImageAgent struct { + Name string + Retriever ArtistImageRetriever +} + +// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability. +type AlbumImageAgent struct { + Name string + Retriever AlbumImageRetriever +} + +// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever, +// in conf.Server.Agents order (same order the aggregate dispatch uses). +func (a *Agents) ArtistImageAgents() []ArtistImageAgent { + var result []ArtistImageAgent + for _, ea := range a.getEnabledAgentNames() { + if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok { + result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever}) + } + } + return result +} + +// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever, +// in conf.Server.Agents order (same order the aggregate dispatch uses). +func (a *Agents) AlbumImageAgents() []AlbumImageAgent { + var result []AlbumImageAgent + for _, ea := range a.getEnabledAgentNames() { + if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok { + result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever}) + } + } + return result +} + func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { switch id { case consts.UnknownArtistID: diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 50285a084..e3087f995 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -362,6 +362,64 @@ var _ = Describe("Agents", func() { }) }) }) + + Describe("Image retriever enumeration", func() { + var ag *Agents + var artistImg, artistImg2 *testImageAgent + var albumImg, albumImg2 *testAlbumImageAgent + + BeforeEach(func() { + artistImg = &testImageAgent{Name: "artistImg"} + artistImg2 = &testImageAgent{Name: "artistImg2"} + albumImg = &testAlbumImageAgent{name: "albumImg"} + albumImg2 = &testAlbumImageAgent{name: "albumImg2"} + Register("artistImg", func(model.DataStore) Interface { return artistImg }) + Register("artistImg2", func(model.DataStore) Interface { return artistImg2 }) + Register("albumImg", func(model.DataStore) Interface { return albumImg }) + Register("albumImg2", func(model.DataStore) Interface { return albumImg2 }) + Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} }) + }) + + Describe("ArtistImageAgents", func() { + It("returns only ArtistImageRetriever agents, named, in configured order", func() { + conf.Server.Agents = "artistImg,noImages,artistImg2" + ag = createAgents(ds, nil) + + result := ag.ArtistImageAgents() + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("artistImg")) + Expect(result[0].Retriever).To(BeIdenticalTo(artistImg)) + Expect(result[1].Name).To(Equal("artistImg2")) + Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2)) + }) + + It("is empty when external services are disabled", func() { + conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false + ag = createAgents(ds, nil) + Expect(ag.ArtistImageAgents()).To(BeEmpty()) + }) + }) + + Describe("AlbumImageAgents", func() { + It("returns only AlbumImageRetriever agents, named, in configured order", func() { + conf.Server.Agents = "albumImg,noImages,albumImg2" + ag = createAgents(ds, nil) + + result := ag.AlbumImageAgents() + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("albumImg")) + Expect(result[0].Retriever).To(BeIdenticalTo(albumImg)) + Expect(result[1].Name).To(Equal("albumImg2")) + Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2)) + }) + + It("is empty when external services are disabled", func() { + conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false + ag = createAgents(ds, nil) + Expect(ag.AlbumImageAgents()).To(BeEmpty()) + }) + }) + }) }) type mockAgent struct { @@ -497,3 +555,17 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin t.Args = []any{id, name, mbid} return t.Images, t.Err } + +type testAlbumImageAgent struct { + name string + Images []ExternalImage + Err error + Args []any +} + +func (t *testAlbumImageAgent) AgentName() string { return t.name } + +func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) { + t.Args = []any{name, artist, mbid} + return t.Images, t.Err +} diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go new file mode 100644 index 000000000..c7032b62b --- /dev/null +++ b/core/artwork/agent_images.go @@ -0,0 +1,100 @@ +package artwork + +import ( + "context" + "io" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +// externalName mirrors the normalization the aggregate provider applies, so agent searches match. +func externalName(name string) string { + if conf.Server.DevPreserveUnicodeInExternalCalls { + return name + } + return str.Clear(name) +} + +// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends +// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything. +func bestImageURL(imgs []agents.ExternalImage) *url.URL { + var best *url.URL + var bestSize int + for i := range imgs { + if imgs[i].URL == "" { + continue + } + u, err := url.Parse(imgs[i].URL) + if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + continue + } + if best == nil || imgs[i].Size > bestSize { + best, bestSize = u, imgs[i].Size + } + } + return best +} + +// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no +// agent succeeded and at least one failed transiently. +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { + // Synthetic artists would otherwise get an unrelated agent result assigned to them. + switch ar.ID { + case consts.UnknownArtistID, consts.VariousArtistsID: + return nil, "", false + } + name := externalName(ar.Name) + for _, a := range ag.ArtistImageAgents() { + reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) + if err != nil { + return nil, "", err + } + u := bestImageURL(imgs) + if u == nil { + return nil, "", agents.ErrNotFound + } + return fromURL(ctx, u) + }) + if reader != nil { + return reader, a.Name, false + } + if isTransientExternal(err) { + extErr = true + log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) + } + } + return nil, "", extErr +} + +// fetchAlbumImage is the album counterpart of fetchArtistImage. +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { + name, artist := externalName(al.Name), externalName(al.AlbumArtist) + for _, a := range ag.AlbumImageAgents() { + reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) + if err != nil { + return nil, "", err + } + u := bestImageURL(imgs) + if u == nil { + return nil, "", agents.ErrNotFound + } + return fromURL(ctx, u) + }) + if reader != nil { + return reader, a.Name, false + } + if isTransientExternal(err) { + extErr = true + log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) + } + } + return nil, "", extErr +} diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go new file mode 100644 index 000000000..716245c93 --- /dev/null +++ b/core/artwork/agent_images_test.go @@ -0,0 +1,262 @@ +package artwork + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/str" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeImageAgent is a built-in agent stub implementing both image retrievers; it +// records call counts so per-agent ordering and short-circuiting can be asserted. +type fakeImageAgent struct { + name string + imgs []agents.ExternalImage + err error + artistCalls int + albumCalls int + gotArtistName string + gotAlbumName string + // block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent. + block chan struct{} + // mu guards the call counters: the worker resolves several items concurrently. + mu sync.Mutex +} + +func (f *fakeImageAgent) AgentName() string { return f.name } + +func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) { + if f.block != nil { + <-f.block + } + f.mu.Lock() + f.artistCalls++ + f.gotArtistName = name + f.mu.Unlock() + return f.imgs, f.err +} + +func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) { + f.albumCalls++ + f.gotAlbumName = name + return f.imgs, f.err +} + +// imageAgents registers the fakes as built-in agents and enables them in order. The fakes +// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe. +func imageAgents(fakes ...*fakeImageAgent) *agents.Agents { + names := make([]string, 0, len(fakes)) + for _, f := range fakes { + fake := f + agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake }) + names = append(names, fake.name) + } + conf.Server.Agents = strings.Join(names, ",") + return agents.GetAgents(&tests.MockDataStore{}, nil) +} + +var _ = Describe("agent images", func() { + var ( + ctx context.Context + srv *httptest.Server + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("image-bytes")) + })) + DeferCleanup(srv.Close) + }) + + img := func(path string, size int) agents.ExternalImage { + return agents.ExternalImage{URL: srv.URL + path, Size: size} + } + + Describe("bestImageURL", func() { + It("picks the largest-Size URL and skips empty ones", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "http://x/small", Size: 10}, + {URL: "", Size: 9999}, + {URL: "http://x/big", Size: 100}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("http://x/big")) + }) + + It("skips a malformed largest URL and falls back to a valid smaller one", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "http://x/valid", Size: 10}, + {URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("http://x/valid")) + }) + + It("returns nil when there is no non-empty URL", func() { + Expect(bestImageURL(nil)).To(BeNil()) + Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil()) + }) + + // Plugins hand these over as free-form strings, and url.Parse accepts them all. An + // unfetchable candidate that wins here ends the agent's turn before its valid images run. + DescribeTable("skips a candidate that cannot be fetched", + func(badURL string) { + u := bestImageURL([]agents.ExternalImage{ + {URL: badURL, Size: 100}, // largest, and first + {URL: "https://cdn.example.com/ok.jpg", Size: 10}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) + }, + Entry("a relative path", "images/big.jpg"), + Entry("a root-relative path", "/images/big.jpg"), + Entry("a scheme we cannot fetch", "ftp://host/big.jpg"), + Entry("a scheme-relative URL", "//host/big.jpg"), + Entry("a URL with no host", "http:///big.jpg"), + ) + + // Size is often 0 for every candidate, and only a strictly larger one replaces the first, + // so an unfetchable entry in first position would otherwise stick. + It("skips an unfetchable first candidate when every Size is zero", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "images/rel.jpg"}, + {URL: "https://cdn.example.com/ok.jpg"}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) + }) + + It("returns nil when no candidate is fetchable", func() { + Expect(bestImageURL([]agents.ExternalImage{ + {URL: "images/a.jpg", Size: 10}, + {URL: "ftp://host/b.jpg", Size: 20}, + })).To(BeNil()) + }) + }) + + Describe("fetchArtistImage", func() { + It("returns the first agent's image and its name", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentA")) + Expect(extErr).To(BeFalse()) + }) + + It("skips the external lookup for synthetic artists", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) + Expect(r).To(BeNil()) + Expect(name).To(BeEmpty()) + Expect(extErr).To(BeFalse()) + } + Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") + }) + + It("clears typographic characters from the query name unless preserving unicode", func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + a := &fakeImageAgent{name: "agentA"} + ag := imageAgents(a) + + _, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"}) + Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC"))) + }) + + It("falls through to a later agent, and its success beats the earlier error", func() { + a := &fakeImageAgent{name: "agentA", err: errBreakerOpen} + b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} + ag := imageAgents(a, b) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentB")) + Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error") + Expect(a.artistCalls).To(Equal(1)) + Expect(b.artistCalls).To(Equal(1)) + }) + + It("reports a clean miss when every agent finds nothing", func() { + a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found + b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} + ag := imageAgents(a, b) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(name).To(BeEmpty()) + Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure") + }) + + It("reports extErr when one agent fails transiently and the rest find nothing", func() { + a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} + b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} + ag := imageAgents(a, b) + + r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeTrue()) + }) + }) + + Describe("fetchAlbumImage", func() { + It("returns the winning agent's image and name", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentA")) + Expect(extErr).To(BeFalse()) + Expect(a.albumCalls).To(Equal(1)) + }) + + It("reports extErr when the only agent fails transiently", func() { + a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} + ag := imageAgents(a) + + r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeTrue()) + }) + }) + + Describe("gate naming", func() { + It("invokes the gate once per agent, keyed by agent name", func() { + a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} + b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}} + ag := imageAgents(a, b) + + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(gatedNames).To(Equal([]string{"agentA", "agentB"})) + }) + }) +}) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index b8c395c12..8ac8d9c9d 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -1,134 +1,394 @@ package artwork import ( + "bytes" "context" "errors" - _ "image/gif" + "fmt" "io" + "os" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/utils/cache" - _ "golang.org/x/image/webp" ) var ErrUnavailable = errors.New("artwork unavailable") +// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale. +var errStaleSource = errors.New("artwork: source file changed since resolution") + +// Image is one servable artwork response. +type Image struct { + io.ReadCloser + Hash string // pixel identity; "" for placeholders + ETag string // representation validator; "" means Hash applies (full-size original) + LastUpdated time.Time + Placeholder bool +} + +// representationTag varies with dimensions and encode settings, so a config change invalidates +// a revalidating client's cache even though the pixel hash is unchanged. +func representationTag(hash string, size int, square bool) string { + return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag()) +} + type Artwork interface { - Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) - GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) + // Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when + // the id resolves to nothing, so the caller can pick placeholder vs 404. + Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) + // GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the + // kind's placeholder image (never resized, Placeholder=true). + GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) } -func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { - return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} +func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork { + return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm} } -type artwork struct { - ds model.DataStore - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - provider external.Provider +// entityExists reports whether the entity an artwork id points at is still there: state rows +// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner. +func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool { + var found bool + var err error + switch artID.Kind { + case model.KindArtistArtwork: + found, err = ds.Artist(ctx).Exists(artID.ID) + case model.KindAlbumArtwork: + found, err = ds.Album(ctx).Exists(artID.ID) + case model.KindMediaFileArtwork: + found, err = ds.MediaFile(ctx).Exists(artID.ID) + case model.KindPlaylistArtwork: + found, err = ds.Playlist(ctx).Exists(artID.ID) + case model.KindRadioArtwork: + found, err = ds.Radio(ctx).Exists(artID.ID) + case model.KindDiscArtwork: + albumID, _, perr := model.ParseDiscArtworkID(artID.ID) + if perr != nil { + return false + } + found, err = ds.Album(ctx).Exists(albumID) + default: + return false + } + return err == nil && found } -type artworkReader interface { - cache.Item - LastUpdated() time.Time - Reader(ctx context.Context) (io.ReadCloser, string, error) +type service struct { + ds model.DataStore + cache cache.FileCache + store *ImageStore + ffmpeg ffmpeg.FFmpeg } -func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { - artID, err := a.getArtworkId(ctx, id) +func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) { + artID, err := s.parseArtworkID(ctx, id) + var img *Image if err == nil { - reader, lastUpdate, err = a.Get(ctx, artID, size, square) + img, err = s.Get(ctx, artID, size, square) } + // Only a resolvable entity with no art gets a placeholder; an unknown id must stay + // ErrNotFound so callers can still answer 404 / Subsonic error 70. if errors.Is(err, ErrUnavailable) { - if artID.Kind == model.KindArtistArtwork { - reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) - } else { - reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt) - } - return reader, consts.ServerStart, nil + return placeholderImage(artID.Kind), nil } - return reader, lastUpdate, err + return img, err } -func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { - artReader, err := a.getArtworkReader(ctx, artID, size, square) - if err != nil { - return nil, time.Time{}, err +func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + if artID.ID == "" { + return nil, ErrUnavailable } - - r, err := a.cache.Get(ctx, artReader) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { - log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) - } - return nil, time.Time{}, err + if size < 0 { + size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle + } + switch artID.Kind { + case model.KindDiscArtwork: + return s.serveDisc(ctx, artID, size, square) + case model.KindMediaFileArtwork: + return s.serveMediaFile(ctx, artID, size, square) + default: + return s.serveEntity(ctx, artID, size, square) } - return r, artReader.LastUpdated(), nil } -type coverArtGetter interface { +// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't +// hammer external services; below staleAbsentAge to catch younger absences. +const requestRecheckAge = time.Hour + +func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary) + switch { + case errors.Is(err, model.ErrNotFound): + return s.provisional(ctx, artID, size, square) + case err != nil: + return nil, err + case ia.Hash == "": + // Inserts an immediately-eligible recheck for a settled absent row. + if time.Since(ia.AttemptedAt) > requestRecheckAge { + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + } + return nil, ErrUnavailable + default: + return s.serveHash(ctx, artID, ia, size, square) + } +} + +// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art) +// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "". +func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time, + size int, square bool, open func() (io.ReadCloser, error), +) (*Image, error) { + if size == 0 && !square { + rc, err := open() + if err != nil { + return nil, err + } + if rc == nil { + return nil, ErrUnavailable + } + img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate} + if hash == "" { + img.ETag = representationTag(key, size, square) + } + return img, nil + } + stream, err := s.cache.Get(ctx, &resizedItem{ + hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open, + }) + if err != nil { + return nil, err + } + return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil +} + +// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a +// cancelled request is not: it must not enqueue a re-resolution. +func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) { + // Only this path can hand back a deleted entity's bytes; the others load their entity anyway. + if !entityExists(ctx, s.ds, artID) { + return nil, ErrUnavailable + } + art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return s.dangling(ctx, artID) + } + return nil, err + } + img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square, + func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) }) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err) + return s.dangling(ctx, artID) + } + return img, nil +} + +// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match. +func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) { + if isFileBacked(ia.Source) { + f, err := os.Open(ia.SourcePath) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime { + f.Close() + log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath, + "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) + return nil, errStaleSource + } + return f, nil + } + // Store-backed bytes still carry the source's mtime, to detect edits to embedded art. + if ia.SourcePath != "" && ia.RefMtime != 0 { + info, err := os.Stat(ia.SourcePath) + if err != nil { + return nil, err + } + if info.ModTime().UnixNano() != ia.RefMtime { + log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath, + "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) + return nil, errStaleSource + } + } + return store.Open(ia.Hash, mime) +} + +// provisional serves local bytes for an entity with no state row, enqueuing the worker but +// never writing a state row itself. +func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary} + res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item) + if err != nil { + return nil, err + } + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID, + "source", res.source, "hit", res.reader != nil) + return s.serveResolution(ctx, res, size, square) +} + +// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode). +func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) { + if res.reader == nil { + return nil, ErrUnavailable + } + defer res.reader.Close() + data, err := readCapped(res.reader) + if err != nil { + return nil, ErrUnavailable + } + hash, err := hashImage(bytes.NewReader(data)) + if err != nil { + return nil, ErrUnavailable + } + // Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry. + return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square, + func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }) +} + +func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + // The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL + // must fall back to disc/album instead of serving stale persisted embedded art. + if !conf.Server.EnableMediaFileCoverArt { + mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + return s.Get(ctx, mf.DiscCoverArtID(), size, square) + } + ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary) + switch { + case err == nil && ia.Hash != "": + return s.serveHash(ctx, artID, ia, size, square) + case err == nil: + // absent row: fall through + case errors.Is(err, model.ErrNotFound): + // no row: fall through + default: + return nil, err + } + noRow := errors.Is(err, model.ErrNotFound) + + mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt { + return s.provisionalEmbedded(ctx, artID, *mf, size, square) + } + // Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album. + return s.Get(ctx, mf.DiscCoverArtID(), size, square) +} + +// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker. +func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) { + lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID) + if err != nil { + return nil, err + } + res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path) + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + if !ok { + // Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder. + return s.Get(ctx, mf.DiscCoverArtID(), size, square) + } + return s.serveResolution(ctx, res, size, square) +} + +// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover. +func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + dr, err := newDiscArtworkReader(ctx, s.ds, artID) + if err != nil { + return nil, err + } + // Single-disc albums run the chain too: a disc can carry art distinct from the album cover. + selectImage := func() (io.ReadCloser, string, error) { + funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) + return selectImageReader(ctx, artID, funcs...) + } + albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} + // Disc art has no state row, hence no content hash: keying on id, album mtime and + // DiscArtPriority lets a warm cache answer without running the chain or touching the disk. + key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority) + img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, + func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err }) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + return s.Get(ctx, albumArtID, size, square) + } + return img, nil +} + +// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched. +func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) { + log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID) + s.enqueue(ctx, artID, model.ArtworkPriorityScan) + return nil, ErrUnavailable +} + +func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { + err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: artID.Kind.Prefix(), + ItemID: artID.ID, + ImageType: model.ImageTypePrimary, + Priority: priority, + }) + if err != nil { + log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err) + } +} + +func placeholderImage(kind model.Kind) *Image { + path := consts.PlaceholderAlbumArt + if kind == model.KindArtistArtwork { + path = consts.PlaceholderArtistArt + } + r, _ := resources.FS().Open(path) + return &Image{ReadCloser: r, Placeholder: true} +} + +type coverArtIDGetter interface { CoverArtID() model.ArtworkID } -func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { +// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID. +func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) { if id == "" { return model.ArtworkID{}, ErrUnavailable } - artID, err := model.ParseArtworkID(id) - if err == nil { + if artID, err := model.ParseArtworkID(id); err == nil { return artID, nil } - - log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id) - entity, err := model.GetEntityByID(ctx, a.ds, id) + entity, err := model.GetEntityByID(ctx, s.ds, id) if err != nil { return model.ArtworkID{}, err } - if e, ok := entity.(coverArtGetter); ok { - artID = e.CoverArtID() + if e, ok := entity.(coverArtIDGetter); ok { + return e.CoverArtID(), nil } - switch e := entity.(type) { - case *model.Artist: - log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name) - case *model.Album: - log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist) - case *model.MediaFile: - log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album) - case *model.Playlist: - log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name) - } - return artID, nil + return model.ArtworkID{}, model.ErrNotFound } -func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) { - var artReader artworkReader - var err error - if size > 0 || square { - artReader, err = resizedFromOriginal(ctx, a, artID, size, square) - } else { - switch artID.Kind { - case model.KindArtistArtwork: - artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider) - case model.KindAlbumArtwork: - artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider) - case model.KindMediaFileArtwork: - artReader, err = newMediafileArtworkReader(ctx, a, artID) - case model.KindPlaylistArtwork: - artReader, err = newPlaylistArtworkReader(ctx, a, artID) - case model.KindDiscArtwork: - artReader, err = newDiscArtworkReader(ctx, a, artID) - case model.KindRadioArtwork: - artReader, err = newRadioArtworkReader(ctx, a, artID) - default: - return nil, ErrUnavailable - } +func unixMtime(mtime int64) time.Time { + if mtime <= 0 { + return time.Time{} } - return artReader, err + return time.Unix(0, mtime) // RefMtime is unix-nanoseconds } diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go deleted file mode 100644 index 5bcd82f23..000000000 --- a/core/artwork/artwork_internal_test.go +++ /dev/null @@ -1,629 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "image" - "image/jpeg" - "image/png" - "io" - "os" - "path/filepath" - "time" - - _ "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Artwork", func() { - var aw *artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg - var folderRepo *fakeFolderRepo - ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album - var arMultipleCovers model.Artist - var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ImageCacheSize = "0" // Disable cache - conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" - - folderRepo = &fakeFolderRepo{} - libRepo := &tests.MockLibraryRepo{} - repoRoot, _ := os.Getwd() - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ds = &tests.MockDataStore{ - MockedTranscoding: &tests.MockTranscodingRepo{}, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. - alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} - alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} - alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} - alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} - alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} - arMultipleCovers = model.Artist{ID: "777", Name: "All options"} - alMultipleCovers = model.Album{ - ID: "666", - Name: "All options", - EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", - FolderIDs: []string{"f1"}, - AlbumArtistID: "777", - } - mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"} - mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"} - mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"} - mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"} - - cache := GetImageCache() - ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") - aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) - }) - - Describe("albumArtworkReader", func() { - Context("ID not found", func() { - It("returns ErrNotFound if album is not in the DB", func() { - _, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Context("Embed images", func() { - BeforeEach(func() { - folderRepo.result = nil - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alEmbedNotFound, - }) - }) - It("returns embed cover", func() { - aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) - }) - It("returns ErrUnavailable if embed path is not available", func() { - ffmpeg.Error = errors.New("not available") - aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, _, err = aw.Reader(ctx) - Expect(err).To(MatchError(ErrUnavailable)) - }) - }) - Context("External images", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyExternal, - alExternalNotFound, - }) - }) - It("returns external cover", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"front.png"}, - }} - aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) - }) - It("returns ErrUnavailable if external file is not available", func() { - folderRepo.result = []model.Folder{} - aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, _, err = aw.Reader(ctx) - Expect(err).To(MatchError(ErrUnavailable)) - }) - }) - Context("Multiple covers", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg", "front.png", "artist.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - }) - DescribeTable("CoverArtPriority", - func(priority string, expected string) { - conf.Server.CoverArtPriority = priority - aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) - }, - Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), - Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), - Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), - ) - }) - Context("LastUpdated", func() { - // Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header. - // It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate - // cached cover art when only the image file changes. - now := time.Now().Truncate(time.Second) - DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", - func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { - album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} - folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - - ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - Expect(ar.LastUpdated()).To(Equal(expected)) - }, - Entry("album newer than images", now, now.Add(-1*time.Hour), now), - Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), - Entry("equal timestamps", now, now, now), - ) - }) - }) - Describe("discArtworkReader", func() { - Context("LastUpdated", func() { - // Regression test for #5377: same bug as albumArtworkReader — disc covers - // must also revalidate when the image file changes, not only when media files do. - now := time.Now().Truncate(time.Second) - DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", - func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { - album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} - folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"}, - }) - - artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil) - dr, err := newDiscArtworkReader(ctx, aw, artID) - Expect(err).ToNot(HaveOccurred()) - Expect(dr.LastUpdated()).To(Equal(expected)) - }, - Entry("album newer than images", now, now.Add(-1*time.Hour), now), - Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), - Entry("equal timestamps", now, now, now), - ) - }) - }) - Describe("artistArtworkReader", func() { - Context("Multiple covers", func() { - BeforeEach(func() { - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - folderRepo.result = []model.Folder{{ - ID: "f1", - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ - arMultipleCovers, - }) - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - mfAnotherWithEmbed, - }) - }) - DescribeTable("ArtistArtPriority", - func(priority string, expected string) { - conf.Server.ArtistArtPriority = priority - aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) - }, - Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), - Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), - ) - }) - }) - Describe("mediafileArtworkReader", func() { - Context("ID not found", func() { - It("returns ErrNotFound if mediafile is not in the DB", func() { - _, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND")) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Context("Embed images", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"front.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alOnlyExternal, - alSingleDisc, - }) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - mfWithEmbed, - mfWithoutEmbed, - mfCorruptedCover, - }) - }) - It("returns embed cover", func() { - aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) - }) - It("returns embed cover if successfully extracted by ffmpeg", func() { - aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - r, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - data, _ := io.ReadAll(r) - Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal("tests/fixtures/test.ogg")) - }) - It("returns album cover if cannot read embed artwork", func() { - // Force fromTag to fail - mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg" - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed()) - // Simulate ffmpeg error - ffmpeg.Error = errors.New("not available") - - aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444_0")) - }) - It("returns album cover if media file has no cover art", func() { - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444_0")) - }) - It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { - mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) - - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - // Should fall back to disc art, which itself falls back to album art - Expect(path).To(Equal("dc-444:2_0")) - }) - It("falls back to album cover art for single-disc albums even with a disc number", func() { - mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) - - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - // Single-disc album should skip disc art and go straight to album art - Expect(path).To(Equal("al-888_0")) - }) - }) - }) - Describe("playlistArtworkReader", func() { - Describe("findPlaylistSidecarPath", func() { - It("discovers sidecar image next to playlist file", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") - imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(Equal(imgPath)) - }) - - It("returns empty string when no sidecar image exists", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(BeEmpty()) - }) - - It("returns empty string when playlist has no path", func() { - result := findPlaylistSidecarPath(GinkgoT().Context(), "") - Expect(result).To(BeEmpty()) - }) - - It("finds sidecar with different case base name", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "myplaylist.m3u") - imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(Equal(imgPath)) - }) - }) - - Describe("fromPlaylistExternalImage", func() { - It("opens local path from ExternalImageURL", func() { - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed()) - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: imgPath}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - data, _ := io.ReadAll(r) - Expect(string(data)).To(Equal("external image data")) - r.Close() - }) - - It("returns nil when ExternalImageURL is empty", func() { - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: ""}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - - It("returns error when local file does not exist", func() { - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"}, - } - r, _, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).To(HaveOccurred()) - Expect(r).To(BeNil()) - }) - - It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - - It("still opens local path when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed()) - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: imgPath}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - }) - - Describe("resizedArtworkReader", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg", "front.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - }) - When("Square is false", func() { - It("returns PNG if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns JPEG if original image is not a PNG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(format).To(Equal("jpeg")) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("When square is true", func() { - var alCover model.Album - - DescribeTable("resize", - func(srcFormat string, expectedFormat string, landscape bool, size int) { - coverFileName := "cover." + srcFormat - dirName := createImage(srcFormat, landscape, size) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} - rootLibRepo := &tests.MockLibraryRepo{} - rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) - ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alCover, - }) - - conf.Server.CoverArtPriority = coverFileName - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal(expectedFormat)) - Expect(img.Bounds().Size().X).To(Equal(size)) - Expect(img.Bounds().Size().Y).To(Equal(size)) - }, - Entry("portrait png image", "png", "png", false, 200), - Entry("landscape png image", "png", "png", true, 200), - Entry("portrait jpg image", "jpg", "png", false, 200), - Entry("landscape jpg image", "jpg", "png", true, 200), - ) - }) - When("EnableWebPEncoding is true and square is false", func() { - BeforeEach(func() { - conf.Server.EnableWebPEncoding = true - }) - It("returns WebP even if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("webp")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns WebP if original image is not a PNG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(format).To(Equal("webp")) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("EnableWebPEncoding is false and square is false", func() { - BeforeEach(func() { - conf.Server.EnableWebPEncoding = false - }) - It("returns PNG if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns JPEG if original image is a JPG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("jpeg")) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("EnableWebPEncoding is false and square is true", func() { - var alCover model.Album - - BeforeEach(func() { - conf.Server.EnableWebPEncoding = false - }) - It("returns PNG for square mode", func() { - dirName := createImage("png", false, 200) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} - rootLibRepo := &tests.MockLibraryRepo{} - rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) - ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) - - conf.Server.CoverArtPriority = "cover.png" - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("Requested size is larger than original", func() { - It("clamps size to original dimensions", func() { - conf.Server.CoverArtPriority = "front.png" - // front.png is 16x16, requesting 99999 should return at original size - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false) - Expect(err).ToNot(HaveOccurred()) - - img, _, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - // Should be clamped to original size (16), not 99999 - Expect(img.Bounds().Size().X).To(Equal(16)) - Expect(img.Bounds().Size().Y).To(Equal(16)) - }) - - It("clamps square size to original dimensions", func() { - conf.Server.CoverArtPriority = "front.png" - // front.png is 16x16, requesting 99999 with square should return 16x16 square - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true) - Expect(err).ToNot(HaveOccurred()) - - img, _, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - // Should be clamped to original size (16), not 99999 - Expect(img.Bounds().Size().X).To(Equal(16)) - Expect(img.Bounds().Size().Y).To(Equal(16)) - }) - }) - }) -}) - -func createImage(format string, landscape bool, size int) string { - var img image.Image - - if landscape { - img = image.NewRGBA(image.Rect(0, 0, size, size/2)) - } else { - img = image.NewRGBA(image.Rect(0, 0, size/2, size)) - } - - tmpDir := GinkgoT().TempDir() - f, _ := os.Create(filepath.Join(tmpDir, "cover."+format)) - defer f.Close() - switch format { - case "png": - _ = png.Encode(f, img) - case "jpg": - _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75}) - } - - return tmpDir -} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index d42d7f3e4..1ea82b7fa 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -11,13 +11,26 @@ import ( "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "go.uber.org/goleak" ) func TestArtwork(t *testing.T) { + // Runs unconditionally: the two leaks below are pre-existing and out of this + // package's control, so they're ignored by exact top-function instead. + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // notify's own init() starts a singleton tree the moment it's imported (via + // core/storage/local or plugins); recursive on darwin, nonrecursive on linux. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), + ) + tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) @@ -25,7 +38,6 @@ func TestArtwork(t *testing.T) { } // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. -// ReadTags is not used by albumArtworkReader, so it is left as a stub. type osDirFS struct{ fs.FS } func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } @@ -69,3 +81,37 @@ func (s *osDirStorage) FS() (storage.MusicFS, error) { } return osDirFS{os.DirFS(s.root)}, nil } + +// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value +// answers as an unremarkable library does; the fields drive the album-root lookup and its failures. +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder + err error + parentResult *model.Folder + getErr error + getCallCount int + // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root check). + // False means the parent qualifies as an album root. + hasOtherAudio bool + otherAudioErr error +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { + return f.result, f.err +} + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return f.hasOtherAudio, f.otherAudioErr +} + +func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { + f.getCallCount++ + if f.getErr != nil { + return nil, f.getErr + } + if f.parentResult != nil { + return f.parentResult, nil + } + return nil, model.ErrNotFound +} diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index adddd0dc3..c6b848101 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -1,57 +1,523 @@ -package artwork_test +package artwork import ( + "bytes" "context" + "image" "io" + "os" + "path/filepath" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Artwork", func() { - var aw artwork.Artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + mfRepo *tests.MockMediaFileRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + store *ImageStore + imgCache cache.FileCache + svc Artwork + repoRoot string + coverBytes []byte + seedEntity func(kind, id string) + ) + + primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary } + + seedFoundStore := func(kind, id string, imgBytes []byte) string { + hash, err := hashImage(bytes.NewReader(imgBytes)) + Expect(err).ToNot(HaveOccurred()) + Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) + seedEntity(kind, id) + return hash + } + + // Without its owning entity, a state row is not served at all. + seedEntity = func(kind, id string) { + GinkgoHelper() + switch kind { + case "al": + Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed()) + case "mf": + Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed()) + } + } + + readAll := func(img *Image) []byte { + GinkgoHelper() + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data + } BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.ImageCacheSize = "0" // Disable cache - cache := artwork.GetImageCache() - ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") - aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + + conf.Server.EnableWebPEncoding = false + conf.Server.CoverArtQuality = 75 + conf.Server.CoverArtPriority = "cover.*" + conf.Server.DiscArtPriority = "cover.*" + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + mfRepo = tests.CreateMockMediaFileRepo() + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedMediaFile: mfRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + store = NewImageStore(GinkgoT().TempDir()) + imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + return arg.(artworkReader).Reader(ctx) + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + svc = NewArtwork(ds, imgCache, store, ffm) }) - Context("GetOrPlaceholder", func() { - Context("Empty ID", func() { - It("returns placeholder if album is not in the DB", func() { - r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false) - Expect(err).ToNot(HaveOccurred()) + Describe("found state", func() { + It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() { + seedFoundStore("al", "al1", coverBytes) - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, err := io.ReadAll(ph) - Expect(err).ToNot(HaveOccurred()) + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + Expect(err).ToNot(HaveOccurred()) + // A resized response versions its ETag with the encode settings, not the pixel hash. + Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false))) + Expect(img.ETag).ToNot(Equal(img.Hash)) + resized := readAll(img) + cfg, _, err := image.DecodeConfig(bytes.NewReader(resized)) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Width).To(Equal(100)) - result, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) + // Deleting the store file proves the warm entry serves without touching the original. + hash, _ := hashImage(bytes.NewReader(coverBytes)) + Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed()) + Eventually(func(g Gomega) { + img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(readAll(img2)).To(Equal(resized)) + }).Should(Succeed()) + }) - Expect(result).To(Equal(phBytes)) - }) + It("treats a negative size as a full-size request, not a giant resize", func() { + seedFoundStore("al", "alneg", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)") + }) + + It("streams a file-backed found image at full size", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + mtime := fileMtime(imgPath) + Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "al2") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa", + Source: "folder", SourcePath: imgPath, RefMtime: mtime, + })).To(Succeed()) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "al3") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb", + Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan)) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb")) + }) + + It("enforces the mtime rule on the sized (loader) path too", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "al3b") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc", + Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + + // State rows outlive a deleted entity until the next prune. + It("refuses to serve a found row whose entity is gone", func() { + hash := seedFoundStore("al", "alzz", coverBytes) + Expect(hash).ToNot(BeEmpty()) + albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("does not re-enqueue a recently-attempted absent state", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data).To(BeEmpty()) + }) + + It("promotes a stale absent state at Bump priority on view", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump)) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) }) }) - Context("Get", func() { - Context("Empty ID", func() { - It("returns an ErrUnavailable error", func() { - _, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - }) + + Describe("provisional read-through", func() { + It("serves local folder art, enqueues a Bump, and writes no state row", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + + Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() { + folderRepo.result = nil + albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}}) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("media file", func() { + It("serves a track's own found art", func() { + seedFoundStore("mf", "mf1", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + seedFoundStore("mf", "mf7", []byte("stale embedded track art")) + seedFoundStore("al", "albz", coverBytes) + mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art") + }) + + It("delegates to the album when the track's state is absent", func() { + seedFoundStore("al", "albm", coverBytes) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) + mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + _, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")] + Expect(mfEnq).To(BeFalse()) + }) + + It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() { + conf.Server.EnableMediaFileCoverArt = true + seedFoundStore("al", "albn", coverBytes) + mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + _, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")] + Expect(mfEnq).To(BeFalse()) + }) + + It("extracts embedded art provisionally and enqueues the track when eligible", func() { + conf.Server.EnableMediaFileCoverArt = true + mfRepo.SetData(model.MediaFiles{{ + ID: "mf4", AlbumID: "albo", HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0, + }}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("delegates a multi-disc track to its disc art, not straight to the album", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}}) + seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs + mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false) + Expect(err).ToNot(HaveOccurred()) + // The disc-folder image, not the album's own art: proof it routed through serveDisc. + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("falls back to the album when an eligible track's embedded art will not extract", func() { + conf.Server.EnableMediaFileCoverArt = true + // HasCoverArt is set, but the file is not audio, so nothing extracts. + mfRepo.SetData(model.MediaFiles{{ + ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/front.png", + }}) + albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}}) + seedFoundStore("al", "albad", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover") + }) + + It("routes a single-disc track through disc resolution too", func() { + // DiscArtPriority applies to single-disc albums too, over the album's own found art. + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}}) + seedFoundStore("al", "alsd", []byte("album-art-distinct")) + mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + }) + + Describe("disc", func() { + It("serves a local disc-folder image", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}}) + + img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + // The resize cache keys on id + album mtime, not on the bytes, so dropping the source + // between the two requests is what shows a warm hit never touches the filesystem. + It("serves a sized disc image from cache without re-reading the source", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}}) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil) + + first, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + warmed := readAll(first) + Expect(warmed).ToNot(BeEmpty()) + + folderRepo.result = nil + + second, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source") + }) + + // A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt. + It("invalidates the cached image when the folder's images change", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, + ImagesUpdatedAt: time.Now().Add(-time.Hour), + }} + albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}}) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil) + + first, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + firstKey := first.ETag + readAll(first) + + folderRepo.result[0].ImagesUpdatedAt = time.Now() + second, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + readAll(second) + Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry") + }) + + // Disc art has no content hash, so without an explicit validator every ETag would be empty. + It("gives a full-size disc image a validator that tracks the source", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, + ImagesUpdatedAt: time.Now().Add(-time.Hour), + }} + albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}}) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil) + + first, err := svc.Get(ctx, discID, 0, false) + Expect(err).ToNot(HaveOccurred()) + readAll(first) + Expect(first.ETag).ToNot(BeEmpty()) + + folderRepo.result[0].ImagesUpdatedAt = time.Now() + second, err := svc.Get(ctx, discID, 0, false) + Expect(err).ToNot(HaveOccurred()) + readAll(second) + Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged") + }) + + It("falls back to album art when no disc image matches", func() { + folderRepo.result = nil + albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}}) + seedFoundStore("al", "aldc2", coverBytes) + + img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + }) + + Describe("GetOrPlaceholder", func() { + It("accepts a raw entity id and serves its cover art", func() { + albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}}) + seedFoundStore("al", "rawal", coverBytes) + + img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("falls back to the album placeholder ignoring size and square", func() { + img, err := svc.GetOrPlaceholder(ctx, "", 300, true) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + Expect(img.Hash).To(BeEmpty()) + Expect(img.LastUpdated).To(BeZero()) + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, _ := io.ReadAll(ph) + Expect(readAll(img)).To(Equal(phBytes)) + }) + + It("falls back to the artist placeholder for an absent artist", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) + + img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + + ph, err := resources.FS().Open(consts.PlaceholderArtistArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, _ := io.ReadAll(ph) + Expect(readAll(img)).To(Equal(phBytes)) + }) + + // "No art" and "no such entity" are different answers: clients 404 only on the latter. + It("reports not-found rather than a placeholder for an id with no entity", func() { + _, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false) + Expect(err).To(MatchError(model.ErrNotFound)) + + _, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false) + Expect(err).To(MatchError(model.ErrNotFound)) }) }) }) + +func fileMtime(path string) int64 { + GinkgoHelper() + info, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + return info.ModTime().UnixNano() +} + +var _ = Describe("EntityExists", func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeEach(func() { + ctx = context.Background() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1"}}) + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1"}}) + radioRepo := tests.CreateMockedRadioRepo() + Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed()) + ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo} + }) + + DescribeTable("reports whether the owning entity is still there", + func(id string, expected bool) { + Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected)) + }, + Entry("existing album", "al-al1", true), + Entry("deleted album", "al-gone", false), + Entry("existing artist", "ar-ar1", true), + Entry("deleted artist", "ar-gone", false), + Entry("existing radio", "ra-ra1", true), + Entry("deleted radio", "ra-gone", false), + // Disc art has no entity of its own; it stands or falls with its album. + Entry("disc of an existing album", "dc-al1:1", true), + Entry("disc of a deleted album", "dc-gone:1", false), + Entry("malformed disc id", "dc-nodiscnum", false), + ) +}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go deleted file mode 100644 index bf3d435a8..000000000 --- a/core/artwork/benchmark_e2e_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "image/jpeg" - "io" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" -) - -// setupE2EBenchmark creates an artwork instance with a real album cover image on disk, -// backed by either a real file cache or disabled cache depending on cacheSize. -// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers -// the critical path (source selection, decode, resize, encode, cache). This is a deliberate -// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure -// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant. -// -// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together). -func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) { - b.Helper() - cleanup := configtest.SetupConfig() - b.Cleanup(cleanup) - - tmpDir, err := os.MkdirTemp("", "artwork-bench-*") - if err != nil { - b.Fatal(err) - } - - // Create a realistic cover image on disk - coverPath := filepath.Join(tmpDir, "cover.jpg") - coverImg := generateGradientImage(1000, 1000) - f, err := os.Create(coverPath) - if err != nil { - b.Fatal(err) - } - if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil { - f.Close() - b.Fatal(err) - } - f.Close() - - // Configure cache - conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = conf.NewDir(tmpDir) - conf.Server.CoverArtQuality = 75 - conf.Server.CoverArtPriority = "cover.*" - - // Set up mock data store with album pointing to our cover. - // Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls. - album := model.Album{ - ID: "bench-album-1", - Name: "Benchmark Album", - FolderIDs: []string{"f1"}, - UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - } - folderRepo := &fakeFolderRepo{ - result: []model.Folder{{ - Path: tmpDir, - ImageFiles: []string{"cover.jpg"}, - }}, - } - ds := &tests.MockDataStore{ - MockedTranscoding: &tests.MockTranscodingRepo{}, - MockedFolder: folderRepo, - } - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - - artID := album.CoverArtID() - - imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - r, _, err := arg.(artworkReader).Reader(ctx) - return r, err - }) - - // Wait for cache init if enabled - if cacheSize != "0" { - for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) { - runtime.Gosched() // Yield to allow background init goroutine to run - } - } - - ffmpeg := tests.NewMockFFmpeg("fallback content") - aw := NewArtwork(ds, imgCache, ffmpeg, nil) - - cleanupAll := func() { - os.RemoveAll(tmpDir) - } - return aw, artID, cleanupAll -} - -func BenchmarkArtworkGetE2E(b *testing.B) { - cacheConfigs := []struct { - name string - cacheSize string - }{ - {"no_cache", "0"}, - {"with_cache", "100MB"}, - } - sizes := []int{0, 300} - - for _, cc := range cacheConfigs { - for _, size := range sizes { - b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) { - aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) - defer cleanup() - - // Warm the cache on first call if cache is enabled - if cc.cacheSize != "0" { - r, _, err := aw.Get(context.Background(), artID, size, size > 0) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(r) - r.Close() - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - r, _, err := aw.Get(context.Background(), artID, size, size > 0) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(r) - r.Close() - } - }) - } - } -} - -func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { - cacheConfigs := []struct { - name string - cacheSize string - }{ - {"no_cache", "0"}, - {"with_cache", "100MB"}, - } - concurrencyLevels := []int{10, 50} - - for _, cc := range cacheConfigs { - for _, n := range concurrencyLevels { - b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) { - aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) - defer cleanup() - - // Warm cache - if cc.cacheSize != "0" { - r, _, _ := aw.Get(context.Background(), artID, 300, true) - if r != nil { - _, _ = io.ReadAll(r) - r.Close() - } - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - var wg sync.WaitGroup - wg.Add(n) - for range n { - go func() { - defer wg.Done() - r, _, err := aw.Get(context.Background(), artID, 300, true) - if err != nil { - b.Error(err) - return - } - _, _ = io.ReadAll(r) - r.Close() - }() - } - wg.Wait() - } - }) - } - } -} diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 0076506f3..f3201d412 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "image" "image/color" + "image/draw" "image/jpeg" "image/png" "testing" @@ -45,3 +46,11 @@ func generateGradientImage(width, height int) *image.RGBA { } return img } + +// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders. +func gradientNRGBA(size int) *image.NRGBA { + src := generateGradientImage(size, size) + dst := image.NewNRGBA(src.Bounds()) + draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) + return dst +} diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..ca5bf7026 --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -0,0 +1,191 @@ +// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash), +// parameterized to match Jellyfin so clients see equivalent hashes. +package blurhash + +import ( + "errors" + "image" + "image/draw" + "math" + "strings" + "sync" + + xdraw "golang.org/x/image/draw" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// maxInputSize: larger inputs are slower with no visible difference in the result. +const maxInputSize = 128 + +// components picks x/y component counts targeting ~16 near-square tiles. +func components(width, height int) (int, int) { + xf := math.Sqrt(16.0 * float64(width) / float64(height)) + yf := xf * float64(height) / float64(width) + return min(int(xf)+1, 9), min(int(yf)+1, 9) +} + +// Encode returns the blurhash of img, deriving the component counts from its aspect ratio. +func Encode(img image.Image) (string, error) { + if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { + return "", errors.New("blurhash: empty image") + } + // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. + xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) + src := pixelsOf(downscale(img)) + w, h := src.w, src.h + + cosX := make([][]float64, xComp) + for i := range cosX { + cosX[i] = make([]float64, w) + for x := range cosX[i] { + cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) + } + } + cosY := make([][]float64, yComp) + for j := range cosY { + cosY[j] = make([]float64, h) + for y := range cosY[j] { + cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) + } + } + + lin := srgbToLinearTable() + factors := make([][3]float64, xComp*yComp) + for y := range h { + row := src.pix[y*src.stride:] + for x := range w { + p := x * 4 + r, g, b := row[p], row[p+1], row[p+2] + if src.straight { + r, g, b = premultiply(r, g, b, row[p+3]) + } + lr, lg, lb := lin[r], lin[g], lin[b] + for j := range yComp { + for i := range xComp { + basis := cosX[i][x] * cosY[j][y] + f := &factors[j*xComp+i] + f[0] += basis * lr + f[1] += basis * lg + f[2] += basis * lb + } + } + } + } + for idx := range factors { + norm := 2.0 + if idx == 0 { + norm = 1.0 + } + scale := norm / float64(w*h) + factors[idx][0] *= scale + factors[idx][1] *= scale + factors[idx][2] *= scale + } + + var sb strings.Builder + sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) + + // Derived counts are at least 1x9, so there is always at least one AC factor. + ac := factors[1:] + actualMax := 0.0 + for _, f := range ac { + actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) + } + quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5)))) + maxVal := float64(quantMax+1) / 166 + sb.WriteString(encode83(quantMax, 1)) + + dc := factors[0] + sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) + for _, f := range ac { + sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) + } + return sb.String(), nil +} + +// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. +type pixels struct { + pix []uint8 + stride int + w, h int + // straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash + // identical to the one an equivalent *image.RGBA produces. + straight bool +} + +// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts +// anything else. +func pixelsOf(img image.Image) pixels { + b := img.Bounds() + switch src := img.(type) { + case *image.RGBA: + return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()} + case *image.NRGBA: + return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true} + } + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()} +} + +func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) { + if a == 255 { + return r, g, b + } + return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255) +} + +var srgbToLinearTable = sync.OnceValue(func() *[256]float64 { + var t [256]float64 + for i := range t { + t[i] = srgbToLinear(i) + } + return &t +}) + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} + +func quantAC(v, maxVal float64) int { + return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5)))) +} + +func signPow(v, exp float64) float64 { + return math.Copysign(math.Pow(math.Abs(v), exp), v) +} + +func srgbToLinear(v int) float64 { + f := float64(v) / 255 + if f <= 0.04045 { + return f / 12.92 + } + return math.Pow((f+0.055)/1.055, 2.4) +} + +func linearToSRGB(v float64) int { + v = min(max(0, v), 1) + if v <= 0.0031308 { + return int(v*12.92*255 + 0.5) + } + return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) +} + +// encode83 encodes value as a fixed-width, big-endian base83 string of the given length. +func encode83(value, length int) string { + b := make([]byte, length) + for i := length - 1; i >= 0; i-- { + b[i] = alphabet[value%83] + value /= 83 + } + return string(b) +} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go new file mode 100644 index 000000000..b0e96d7a7 --- /dev/null +++ b/core/artwork/blurhash/blurhash_suite_test.go @@ -0,0 +1,17 @@ +package blurhash_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBlurHash(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "BlurHash Suite") +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go new file mode 100644 index 000000000..91187165e --- /dev/null +++ b/core/artwork/blurhash/blurhash_test.go @@ -0,0 +1,137 @@ +package blurhash_test + +import ( + "image" + "image/color" + "strings" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +func decode83(s string) int { + v := 0 + for _, c := range s { + v = v*83 + strings.IndexRune(alphabet, c) + } + return v +} + +func solidImage(w, h int, c color.NRGBA) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.SetNRGBA(x, y, c) + } + } + return img +} + +func gradientImage(w, h int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) + } + } + return img +} + +var _ = Describe("Encode input types", func() { + // The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the + // premultiplied *image.RGBA it used to receive, or every hash silently shifts. + buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) { + const size = 40 + nrgba := image.NewNRGBA(image.Rect(0, 0, size, size)) + rgba := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := range size { + for x := range size { + c := color.NRGBA{ + R: uint8(255 * x / size), G: uint8(255 * y / size), + B: uint8((x + y) * 255 / (2 * size)), A: alpha, + } + nrgba.SetNRGBA(x, y, c) + rgba.Set(x, y, c) // image.RGBA.Set premultiplies + } + } + return nrgba, rgba + } + + DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces", + func(alpha uint8) { + nrgba, rgba := buildPair(alpha) + fromNRGBA, err := blurhash.Encode(nrgba) + Expect(err).ToNot(HaveOccurred()) + fromRGBA, err := blurhash.Encode(rgba) + Expect(err).ToNot(HaveOccurred()) + Expect(fromNRGBA).To(Equal(fromRGBA)) + }, + Entry("opaque", uint8(255)), + Entry("partly transparent", uint8(128)), + Entry("fully transparent, which premultiplication crushes to black", uint8(0)), + ) +}) + +var _ = Describe("Encode", func() { + // The size flag encodes (xComp-1) + (yComp-1)*9. + DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", + func(w, h, expectedX, expectedY int) { + hash, err := blurhash.Encode(gradientImage(w, h)) + Expect(err).ToNot(HaveOccurred()) + Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9)) + }, + Entry("square album art", 60, 60, 5, 5), + Entry("smallest square", 1, 1, 5, 5), + Entry("landscape 16:9", 192, 108, 6, 4), + Entry("portrait 9:16", 108, 192, 4, 6), + Entry("extreme landscape capped at 9", 1000, 10, 9, 1), + Entry("extreme portrait capped at 9", 10, 1000, 1, 9), + ) + + It("rejects an empty image", func() { + _, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0))) + Expect(err).To(HaveOccurred()) + }) + + It("produces the spec-mandated length", func() { + // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5 + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255})) + Expect(err).ToNot(HaveOccurred()) + Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1))) + }) + + It("stores the average color in the DC component", func() { + h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255})) + Expect(err).ToNot(HaveOccurred()) + dc := decode83(h[2:6]) + Expect(dc >> 16).To(BeNumerically("~", 200, 1)) + Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1)) + Expect(dc & 0xFF).To(BeNumerically("~", 50, 1)) + }) + + It("is deterministic", func() { + img := gradientImage(64, 64) + h1, err1 := blurhash.Encode(img) + h2, err2 := blurhash.Encode(img) + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(h1).To(Equal(h2)) + }) + + It("produces different hashes for different images", func() { + h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255})) + h2, _ := blurhash.Encode(gradientImage(16, 16)) + Expect(h1).ToNot(Equal(h2)) + }) + + It("downscales large images internally without changing the result materially", func() { + big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) + Expect(err).ToNot(HaveOccurred()) + small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) + Expect(err).ToNot(HaveOccurred()) + Expect(big[2:6]).To(Equal(small[2:6])) + }) +}) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go deleted file mode 100644 index 5090d638e..000000000 --- a/core/artwork/cache_warmer.go +++ /dev/null @@ -1,162 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "maps" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/pl" -) - -type CacheWarmer interface { - PreCache(artID model.ArtworkID) -} - -// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background -// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original -// image size, as well as the size defined by the UICoverArtSize config option. -func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { - // If image cache is disabled, return a NOOP implementation - if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { - return &noopCacheWarmer{} - } - - // If the file cache is disabled, return a NOOP implementation - if cache.Disabled(context.Background()) { - log.Debug("Image cache disabled. Cache warmer will not run") - return &noopCacheWarmer{} - } - - a := &cacheWarmer{ - artwork: artwork, - cache: cache, - buffer: make(map[model.ArtworkID]struct{}), - wakeSignal: make(chan struct{}, 1), - coverArtSize: conf.Server.UICoverArtSize, - } - - // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts - ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true}) - go a.run(ctx) - return a -} - -type cacheWarmer struct { - artwork Artwork - buffer map[model.ArtworkID]struct{} - mutex sync.Mutex - cache cache.FileCache - wakeSignal chan struct{} - coverArtSize int -} - -func (a *cacheWarmer) PreCache(artID model.ArtworkID) { - if a.cache.Disabled(context.Background()) { - return - } - a.mutex.Lock() - defer a.mutex.Unlock() - a.buffer[artID] = struct{}{} - a.sendWakeSignal() -} - -func (a *cacheWarmer) sendWakeSignal() { - // Don't block if the previous signal was not read yet - select { - case a.wakeSignal <- struct{}{}: - default: - } -} - -func (a *cacheWarmer) run(ctx context.Context) { - for { - a.waitSignal(ctx, 10*time.Second) - if ctx.Err() != nil { - break - } - - if a.cache.Disabled(ctx) { - a.mutex.Lock() - pending := len(a.buffer) - a.buffer = make(map[model.ArtworkID]struct{}) - a.mutex.Unlock() - if pending > 0 { - log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending) - } - return - } - - // If cache not available, keep waiting - if !a.cache.Available(ctx) { - a.mutex.Lock() - bufferLen := len(a.buffer) - a.mutex.Unlock() - if bufferLen > 0 { - log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen) - } - continue - } - - a.mutex.Lock() - - // If there's nothing to send, keep waiting - if len(a.buffer) == 0 { - a.mutex.Unlock() - continue - } - - batch := slices.Collect(maps.Keys(a.buffer)) - a.buffer = make(map[model.ArtworkID]struct{}) - a.mutex.Unlock() - - a.processBatch(ctx, batch) - } -} - -func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { - select { - case <-time.After(timeout): - case <-a.wakeSignal: - case <-ctx.Done(): - } -} - -func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { - log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) - input := pl.FromSlice(ctx, batch) - errs := pl.Sink(ctx, 4, input, a.doCacheImage) - for err := range errs { - log.Debug(ctx, "Error warming cache", err) - } -} - -func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - size := a.coverArtSize - r, _, err := a.artwork.Get(ctx, id, size, true) - if err != nil { - return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) - } - _, err = io.Copy(io.Discard, r) - r.Close() - return err -} - -func NoopCacheWarmer() CacheWarmer { - return &noopCacheWarmer{} -} - -type noopCacheWarmer struct{} - -func (a *noopCacheWarmer) PreCache(model.ArtworkID) {} diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go deleted file mode 100644 index a5da2004c..000000000 --- a/core/artwork/cache_warmer_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("CacheWarmer", func() { - var ( - fc *mockFileCache - aw *mockArtwork - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - fc = &mockFileCache{} - aw = &mockArtwork{} - }) - - Context("initialization", func() { - It("returns noop when cache is disabled", func() { - fc.SetDisabled(true) - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns noop when ImageCacheSize is 0", func() { - conf.Server.ImageCacheSize = "0" - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns noop when EnableArtworkPrecache is false", func() { - conf.Server.EnableArtworkPrecache = false - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns real implementation when properly configured", func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*cacheWarmer) - Expect(ok).To(BeTrue()) - }) - }) - - Context("buffer management", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("drops buffered items when cache becomes disabled", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-test")) - fc.SetDisabled(true) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("adds multiple items to buffer", func() { - fc.SetReady(false) // Make cache unavailable so items stay in buffer - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.PreCache(model.MustParseArtworkID("al-2")) - cw.mutex.Lock() - defer cw.mutex.Unlock() - Expect(len(cw.buffer)).To(Equal(2)) - }) - - It("deduplicates items in buffer", func() { - fc.SetReady(false) // Make cache unavailable so items stay in buffer - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.mutex.Lock() - defer cw.mutex.Unlock() - Expect(len(cw.buffer)).To(Equal(1)) - }) - }) - - Context("error handling", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("continues processing after artwork retrieval error", func() { - aw.err = errors.New("artwork error") - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-error")) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("continues processing after cache error", func() { - fc.err = errors.New("cache error") - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-error")) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - }) - - Context("background processing", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("processes items in batches", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - for i := range 5 { - cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) - } - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("wakes up on new items", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - - // Add first batch - cw.PreCache(model.MustParseArtworkID("al-1")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - - // Add second batch - cw.PreCache(model.MustParseArtworkID("al-2")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("pre-caches UICoverArtSize", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() []int { - return aw.getCachedSizes() - }).Should(ContainElements(conf.Server.UICoverArtSize)) - }) - }) -}) - -type mockArtwork struct { - err error - mu sync.Mutex - cachedSizes []int -} - -func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { - if m.err != nil { - return nil, time.Time{}, m.err - } - m.mu.Lock() - m.cachedSizes = append(m.cachedSizes, size) - m.mu.Unlock() - return io.NopCloser(strings.NewReader("test")), time.Now(), nil -} - -func (m *mockArtwork) getCachedSizes() []int { - m.mu.Lock() - defer m.mu.Unlock() - result := make([]int, len(m.cachedSizes)) - copy(result, m.cachedSizes) - return result -} - -func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { - return m.Get(ctx, model.ArtworkID{}, size, square) -} - -type mockFileCache struct { - disabled atomic.Bool - ready atomic.Bool - err error -} - -func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) { - if f.err != nil { - return nil, f.err - } - return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil -} - -func (f *mockFileCache) Available(ctx context.Context) bool { - return f.ready.Load() && !f.disabled.Load() -} - -func (f *mockFileCache) Disabled(ctx context.Context) bool { - return f.disabled.Load() -} - -func (f *mockFileCache) SetDisabled(v bool) { - f.disabled.Store(v) - f.ready.Store(true) -} - -func (f *mockFileCache) SetReady(v bool) { - f.ready.Store(v) -} diff --git a/core/artwork/reader_disc.go b/core/artwork/disc.go similarity index 60% rename from core/artwork/reader_disc.go rename to core/artwork/disc.go index 0f648c987..21f596b60 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/disc.go @@ -2,7 +2,6 @@ package artwork import ( "context" - "crypto/md5" "fmt" "io" "path" @@ -12,16 +11,16 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" ) +// discArtworkReader resolves disc-level artwork from a library's folder images +// and embedded tags. It is used by the serving path's provisional disc read-through. type discArtworkReader struct { - cacheKey - a *artwork album model.Album discNumber int imgFiles []string // library-relative, forward-slash, no leading slash @@ -29,27 +28,40 @@ type discArtworkReader struct { isMultiFolder bool firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs lib libraryView - updatedAt *time.Time + // Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be + // replaced without the album row changing, so this is what makes a cache key notice it. + imagesUpdatedAt time.Time } -func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) { +// cacheTime is the disc image's validity stamp: any of these moving means the selection may +// have changed. +func (d *discArtworkReader) cacheTime() time.Time { + return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt) +} + +func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) { albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) if err != nil { return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) } - al, err := a.ds.Album(ctx).Get(albumID) + al, err := ds.Album(ctx).Get(albumID) if err != nil { return nil, err } - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al) + _, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al) if err != nil { return nil, err } + var imagesUpdatedAt time.Time + if albumImagesAt != nil { + imagesUpdatedAt = *albumImagesAt + } + // Query mediafiles for this album + disc to find folder associations and first track - mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Sort: "track_number", Order: "ASC", Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, @@ -58,29 +70,25 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + lib, err := loadLibraryView(ctx, ds, al.LibraryID) if err != nil { return nil, err } // Build disc folder set and find first track. mf.Path is already library-relative. var firstTrackRel string - allFolderIDs := make(map[string]bool) for _, mf := range mfs { - allFolderIDs[mf.FolderID] = true - if firstTrackRel == "" { + if mf.Path != "" { firstTrackRel = filepath.ToSlash(mf.Path) + break } } + folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID })) // Resolve folder IDs to library-relative paths discFoldersRel := make(map[string]bool) - if len(allFolderIDs) > 0 { - folderIDs := make([]string, 0, len(allFolderIDs)) - for id := range allFolderIDs { - folderIDs = append(folderIDs, id) - } - folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{ + if len(folderIDs) > 0 { + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"folder.id": folderIDs}, }) if err != nil { @@ -89,49 +97,20 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID for _, f := range folders { rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") discFoldersRel[rel] = true + imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt) } } - isMultiFolder := len(al.FolderIDs) > 1 - - r := &discArtworkReader{ - a: a, - album: *al, - discNumber: discNumber, - imgFiles: imgFiles, - discFoldersRel: discFoldersRel, - isMultiFolder: isMultiFolder, - firstTrackRel: firstTrackRel, - lib: lib, - updatedAt: imagesUpdatedAt, - } - r.cacheKey.artID = artID - r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) - if imagesUpdatedAt != nil { - r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) - } - return r, nil -} - -func (d *discArtworkReader) Key() string { - hash := md5.Sum([]byte(conf.Server.DiscArtPriority)) - return fmt.Sprintf( - "%s.%x", - d.cacheKey.Key(), - hash, - ) -} - -func (d *discArtworkReader) LastUpdated() time.Time { - return d.lastUpdate -} - -func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority) - // Fallback to album cover art - albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt) - ff = append(ff, fromAlbum(ctx, d.a, albumArtID)) - return selectImageReader(ctx, d.cacheKey.artID, ff...) + return &discArtworkReader{ + album: *al, + discNumber: discNumber, + imgFiles: imgFiles, + discFoldersRel: discFoldersRel, + isMultiFolder: len(al.FolderIDs) > 1, + firstTrackRel: firstTrackRel, + lib: lib, + imagesUpdatedAt: imagesUpdatedAt, + }, nil } func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { @@ -162,14 +141,13 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range d.imgFiles { - name := path.Base(file) - stem := strings.TrimSuffix(name, path.Ext(name)) + stem := utils.BaseName(file) if !strings.EqualFold(stem, subtitle) { continue } f, err := d.lib.FS.Open(file) if err != nil { - log.Warn(ctx, "Could not open disc art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) continue } return f, file, nil @@ -178,19 +156,12 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin } } -// globMetaChars holds the substitution metacharacters understood by -// filepath.Match. The '\' escape character is intentionally excluded: -// disc art patterns come from user config and never include escaped -// metachars in practice, and treating '\' as a metachar would misalign -// the literal-prefix extraction in extractDiscNumber. +// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar +// would misalign the literal-prefix extraction in extractDiscNumber. const globMetaChars = "*?[" -// extractDiscNumber parses the disc number from a filename matched by a -// filepath.Match-style glob pattern. -// -// Both pattern and filename must already be lowercased by the caller, which -// is also expected to have verified that filepath.Match(pattern, filename) -// is true before calling this function. +// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style +// glob. Caller must lowercase both args and have already verified the match. func extractDiscNumber(pattern, filename string) (int, bool) { metaIdx := strings.IndexAny(pattern, globMetaChars) if metaIdx < 0 { @@ -216,9 +187,8 @@ func extractDiscNumber(pattern, filename string) (int, bool) { return num, true } -// fromExternalFile returns a sourceFunc that matches image files against a glob -// pattern. A numbered filename whose number equals the target disc wins over -// any unnumbered candidate; callers must pass a lowercase pattern. +// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered +// filename whose number equals the target disc wins over any unnumbered candidate. func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { isLiteral := !strings.ContainsAny(pattern, globMetaChars) return func() (io.ReadCloser, string, error) { @@ -227,7 +197,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string name := strings.ToLower(path.Base(file)) match, err := filepath.Match(pattern, name) if err != nil { - log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) + log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file) continue } if !match { @@ -241,7 +211,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string } f, err := d.lib.FS.Open(file) if err != nil { - log.Warn(ctx, "Could not open disc art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) continue } return f, file, nil @@ -257,7 +227,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string for _, file := range fallbacks { f, err := d.lib.FS.Open(file) if err != nil { - log.Warn(ctx, "Could not open disc art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) continue } return f, file, nil diff --git a/core/artwork/reader_disc_test.go b/core/artwork/disc_test.go similarity index 100% rename from core/artwork/reader_disc_test.go rename to core/artwork/disc_test.go diff --git a/core/artwork/dominant/dominant.go b/core/artwork/dominant/dominant.go new file mode 100644 index 000000000..2bbda8ae9 --- /dev/null +++ b/core/artwork/dominant/dominant.go @@ -0,0 +1,127 @@ +// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the +// real artwork loads. +package dominant + +import ( + "fmt" + "image" + "math" + "sort" +) + +const ( + // 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that + // distinct colours stay apart. + bits = 4 + nBins = 1 << (3 * bits) + // Only the heaviest bins can win, and merging is O(n^2) over whatever survives. + maxBins = 64 + // Oklab distance below which two bins are the same colour to the eye. Merging matters because a + // gradient splits across adjacent bins and would otherwise lose to a smaller flat region. + mergeDist = 0.10 +) + +type bin struct { + r, g, b float64 + n float64 +} + +// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports +// presence, not salience: a mostly white sleeve returns white. +func Color(img image.Image) string { + var bins [nBins]bin + total := 0 + eachPixel(img, func(r, g, b uint8) { + i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<<bits | int(b>>(8-bits)) + bins[i].r += float64(r) + bins[i].g += float64(g) + bins[i].b += float64(b) + bins[i].n++ + total++ + }) + if total == 0 { + return "" + } + + used := make([]bin, 0, 32) + for i := range bins { + if bins[i].n > 0 { + used = append(used, bins[i]) + } + } + sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n }) + if len(used) > maxBins { + used = used[:maxBins] + } + + merged := make([]bin, 0, len(used)) + for _, b := range used { + if i := nearest(merged, b); i >= 0 { + merged[i].r += b.r + merged[i].g += b.g + merged[i].b += b.b + merged[i].n += b.n + continue + } + merged = append(merged, b) + } + + best := merged[0] + for _, m := range merged[1:] { + if m.n > best.n { + best = m + } + } + return fmt.Sprintf("#%02x%02x%02x", + uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5)) +} + +func nearest(merged []bin, b bin) int { + bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n) + for i, m := range merged { + ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n) + if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist { + return i + } + } + return -1 +} + +// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash +// encoders already read the shared thumbnail in that form. +func eachPixel(img image.Image, fn func(r, g, b uint8)) { + if p, ok := img.(*image.NRGBA); ok { + for y := range p.Rect.Dy() { + row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4] + for x := 0; x < len(row); x += 4 { + fn(row[x], row[x+1], row[x+2]) + } + } + return + } + b := img.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl, _ := img.At(x, y).RGBA() + fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8)) + } + } +} + +func srgbToLinear(v float64) float64 { + v /= 255 + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) +} + +func oklab(r, g, b float64) (float64, float64, float64) { + lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b) + l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb) + m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb) + s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb) + return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, + 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, + 0.0259040371*l + 0.7827717662*m - 0.8086757660*s +} diff --git a/core/artwork/dominant/dominant_suite_test.go b/core/artwork/dominant/dominant_suite_test.go new file mode 100644 index 000000000..56dbd6209 --- /dev/null +++ b/core/artwork/dominant/dominant_suite_test.go @@ -0,0 +1,17 @@ +package dominant_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDominant(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Dominant Suite") +} diff --git a/core/artwork/dominant/dominant_test.go b/core/artwork/dominant/dominant_test.go new file mode 100644 index 000000000..da7fed09c --- /dev/null +++ b/core/artwork/dominant/dominant_test.go @@ -0,0 +1,91 @@ +package dominant_test + +import ( + "image" + "image/color" + + "github.com/navidrome/navidrome/core/artwork/dominant" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fill paints rect with c onto img. +func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) { + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + img.SetNRGBA(x, y, c) + } + } +} + +func newImg(w, h int, c color.NRGBA) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + fill(img, img.Bounds(), c) + return img +} + +var _ = Describe("Color", func() { + It("returns a solid image's own colour", func() { + Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699")) + }) + + It("returns empty for an image with no pixels", func() { + Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal("")) + }) + + // Presence, not salience: this is a placeholder, so the large field wins even though the small + // patch is the more interesting colour. + It("picks the largest area, not the most vivid one", func() { + img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255}) + fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255}) + Expect(dominant.Color(img)).To(Equal("#fafafa")) + }) + + It("reports a near-black cover as near-black", func() { + img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255}) + fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255}) + Expect(dominant.Color(img)).To(Equal("#050505")) + }) + + // A gradient splits across many quantisation bins. Without merging, each slice is smaller than + // the flat block and the block would win despite covering far less of the image. + It("merges a gradient's bins so it beats a smaller flat block", func() { + img := image.NewNRGBA(image.Rect(0, 0, 40, 40)) + for y := range 40 { + for x := range 40 { + // 30 columns of blue gradient == 75% of the image + if x < 30 { + img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255}) + } else { + img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255}) + } + } + } + got := dominant.Color(img) + Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got) + }) + + It("is deterministic", func() { + img := image.NewNRGBA(image.Rect(0, 0, 30, 30)) + for y := range 30 { + for x := range 30 { + img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255}) + } + } + first := dominant.Color(img) + for range 5 { + Expect(dominant.Color(img)).To(Equal(first)) + } + }) + + It("handles images that are not NRGBA", func() { + src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255}) + rgba := image.NewRGBA(src.Bounds()) + for y := range 10 { + for x := range 10 { + rgba.Set(x, y, src.At(x, y)) + } + } + Expect(dominant.Color(rgba)).To(Equal("#204060")) + }) +}) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go new file mode 100644 index 000000000..d6c374e39 --- /dev/null +++ b/core/artwork/e2e/acquire_serve_test.go @@ -0,0 +1,326 @@ +package e2e + +import ( + "context" + "encoding/base64" + "errors" + "io" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites. +var _ = Describe("Acquisition → serve loop", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + artistRepo *tests.MockArtistRepo + mfRepo *tests.MockMediaFileRepo + plRepo *tests.MockPlaylistRepo + radioRepo *tests.MockedRadioRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + store *artwork.ImageStore + svc artwork.Artwork + worker *artwork.Worker + coverBytes []byte + ) + + itemFound := func(kind model.Kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash != "" + } + } + itemAbsent := func(kind model.Kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash == "" + } + } + // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. + bump := func(kind, id string) { + GinkgoHelper() + Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes = readFixture(coverFixture) + + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline + conf.Server.EnableMediaFileCoverArt = true + conf.Server.DevArtworkWorkerConcurrency = 1 + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}}) + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo = tests.CreateMockArtistRepo() + mfRepo = tests.CreateMockMediaFileRepo() + plRepo = tests.CreateMockPlaylistRepo() + radioRepo = tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedMediaFile: mfRepo, + MockedPlaylist: plRepo, + MockedRadio: radioRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm := tests.NewMockFFmpeg("") + store = artwork.NewImageStore(GinkgoT().TempDir()) + // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). + imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in e2e") + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + + svc = artwork.NewArtwork(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + }) + + seedFolderAlbum := func(albumID string) { + folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) + } + + It("acquires album folder art and serves the exact bytes under its hash", func() { + seedFolderAlbum("al1") + bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("acquires an artist's uploaded image and serves it", func() { + name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}}) + bump("ar", "ar1") + runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1")) + + ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) + }) + + It("generates a playlist grid from its tracks' album art and serves it from the store", func() { + seedFolderAlbum("al1") + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}} + bump("pl", "pl1") + runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1")) + + ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("generated")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/png")) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + }) + + It("acquires a radio station's uploaded image and serves it", func() { + name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} + bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) + + ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() { + mfRepo.SetData(model.MediaFiles{{ + ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture, + }}) + + provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(provisional.Placeholder).To(BeFalse()) + Expect(provisional.Hash).ToNot(BeEmpty()) + provisionalBytes := readAll(provisional) + Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) + + _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") + + // The provisional read enqueued a Bump; drain it. + runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1")) + ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).To(Equal(provisional.Hash)) + + resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved.Hash).To(Equal(ia.Hash)) + Expect(readAll(resolved)).To(Equal(provisionalBytes)) + }) + + It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() { + seedFolderAlbum("al1") + bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/jpeg")) + Expect(art.Width).To(BeNumerically(">", 0)) + Expect(art.Height).To(BeNumerically(">", 0)) + Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes))) + // Never a synthesized value: both hashes are encoded from the real pixels. + Expect(art.BlurHash).ToNot(BeEmpty()) + Expect(art.ThumbHash).ToNot(BeEmpty()) + raw, err := base64.StdEncoding.DecodeString(art.ThumbHash) + Expect(err).ToNot(HaveOccurred()) + Expect(len(raw)).To(BeNumerically(">=", 5)) + }) + + It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() { + writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"} + bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) + + ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/gif")) + Expect(art.Width).To(BeNumerically("==", 4)) + }) + + It("deduplicates byte-identical art across entities onto one image row", func() { + folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}, + {ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0}, + }) + bump("al", "al1") + bump("al", "al2") + runWorkerUntil(ctx, worker, func() bool { + return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")() + }) + + ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash") + Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes)) + }) + + It("stops serving a file-backed image once its source file changes underneath", func() { + name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} + bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) + + ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + staleHash := ia.Hash + + path := model.UploadedImagePath(consts.EntityRadio, name) + Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed()) + newer := time.Now().Add(2 * time.Second) + Expect(os.Chtimes(path, newer, newer)).To(Succeed()) + + // The mtime no longer matches the state row, so the stale bytes are not served. + _, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + + // That failed read enqueued a re-resolution. + runWorkerUntil(ctx, worker, func() bool { + cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + return gerr == nil && cur.Hash != "" && cur.Hash != staleHash + }) + img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) + }) + + It("records an absent state for an entity with no art and reports it unavailable", func() { + albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}}) + bump("al", "alx") + runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx")) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + + img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + }) +}) + +func mustGet(img *artwork.Image, err error) *artwork.Image { + GinkgoHelper() + Expect(err).ToNot(HaveOccurred()) + return img +} + +// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking +// the production import the spec above guards. +var gifFixture = []byte{ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00, + 0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05, + 0x00, 0x3b, +} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 5e61684cc..7b5e7b4a3 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -1,4 +1,4 @@ -package artworke2e_test +package e2e import ( "testing/fstest" @@ -9,14 +9,11 @@ import ( . "github.com/onsi/gomega" ) -const ( - defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" - defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" -) - +// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so +// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes. var _ = Describe("Album artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("an album has a single folder with cover.jpg at the album root", func() { @@ -28,12 +25,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -55,16 +50,16 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/cover.jpg": imageFile("album-root"), - "Artist/Album/CD1/cover.jpg": imageFile("disc1"), - "Artist/Album/CD2/cover.jpg": imageFile("disc2"), + "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/CD1/cover.jpg": smallPNG("disc1"), + "Artist/Album/CD2/cover.jpg": smallPNG("disc2"), }) scan() al := firstAlbum() Expect(al.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two disc subfolders as one multi-disc album") - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + "sanity check: the two disc subfolders should form one multi-disc album") + expectAlbumFolderCover(al, "Artist/Album/cover.jpg") }) }) @@ -86,14 +81,12 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/folder.jpg": imageFile("album-root"), - "Artist/Album/CD1/folder.jpg": imageFile("disc1"), - "Artist/Album/CD2/folder.jpg": imageFile("disc2"), + "Artist/Album/folder.jpg": smallPNG("album-root"), + "Artist/Album/CD1/folder.jpg": smallPNG("disc1"), + "Artist/Album/CD2/folder.jpg": smallPNG("disc2"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) @@ -109,12 +102,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -133,14 +124,12 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Album/cover.jpg": imageFile("album-root"), - "Album/CD1/folder.jpg": imageFile("disc1"), - "Album/CD2/folder.jpg": imageFile("disc2"), + "Album/cover.jpg": smallPNG("album-root"), + "Album/CD1/folder.jpg": smallPNG("disc1"), + "Album/CD2/folder.jpg": smallPNG("disc2"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg") }) }) @@ -153,14 +142,14 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - "Artist/Album/cover.jpg": imageFile("external"), + "Artist/Album/cover.jpg": smallPNG("external"), }) scan() - // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. replaceWithRealMP3("Artist/Album/01 - Track.mp3") - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) + Expect(ia.Source).To(Equal("embedded")) + Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) }) }) @@ -176,8 +165,9 @@ var _ = Describe("Album artwork resolution", func() { scan() replaceWithRealMP3("Artist/Album/01 - Track.mp3") - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) + Expect(ia.Source).To(Equal("embedded")) + Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) }) }) @@ -190,12 +180,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/Cover.JPG": imageFile("case-insensitive"), + "Artist/Album/Cover.JPG": smallPNG("case-insensitive"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG") }) }) @@ -209,30 +197,25 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("primary"), - "Artist/Album/cover.1.jpg": imageFile("secondary"), + "Artist/Album/cover.jpg": smallPNG("primary"), + "Artist/Album/cover.1.jpg": smallPNG("secondary"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) When("the album has no cover and CoverArtPriority lists only file patterns", func() { // Artist/ // └── Album/ - // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) - It("returns ErrUnavailable", func() { + // └── 01 - Track.mp3 (no image files — settles absent) + It("settles absent", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() - - al := firstAlbum() - _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) - Expect(err).To(HaveOccurred()) + expectAlbumAbsent(firstAlbum()) }) }) @@ -248,12 +231,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/folder.jpg": smallPNG("folder"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) @@ -266,12 +247,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/front.jpg": imageFile("front"), + "Artist/Album/front.jpg": smallPNG("front"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg") }) }) @@ -286,14 +265,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - "Artist/Album/folder.jpg": imageFile("folder"), - "Artist/Album/front.jpg": imageFile("front"), + "Artist/Album/cover.jpg": smallPNG("cover"), + "Artist/Album/folder.jpg": smallPNG("folder"), + "Artist/Album/front.jpg": smallPNG("front"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -307,13 +284,11 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), - "Artist/Album/front.jpg": imageFile("front"), + "Artist/Album/folder.jpg": smallPNG("folder"), + "Artist/Album/front.jpg": smallPNG("front"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) @@ -328,14 +303,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.2.jpg": imageFile("second"), - "Artist/Album/cover.jpg": imageFile("primary"), - "Artist/Album/cover.1.jpg": imageFile("first"), + "Artist/Album/cover.2.jpg": smallPNG("second"), + "Artist/Album/cover.jpg": smallPNG("primary"), + "Artist/Album/cover.1.jpg": smallPNG("first"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -348,12 +321,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "bogus.*, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/cover.jpg": smallPNG("cover"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -371,21 +342,16 @@ var _ = Describe("Album artwork resolution", func() { It("does not use the artist image as album art", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ - "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/folder.jpg": smallPNG("artist-thumbnail"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": imageFile("album-b"), + "Artist/Album B/cover.jpg": smallPNG("album-b"), }) scan() - alA := albumByName("Album A") - _, err := readArtworkOrErr(alA.CoverArtID()) - Expect(err).To(HaveOccurred(), - "Album A has no images of its own, so it must fall through to the placeholder "+ - "instead of inheriting the artist folder's folder.jpg") - - alB := albumByName("Album B") - Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b"))) + // Album B first: the acquire in expectAlbumAbsent would settle Album B too. + expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg") + expectAlbumAbsent(albumByName("Album A")) }) }) @@ -402,21 +368,58 @@ var _ = Describe("Album artwork resolution", func() { It("does not use the artist image as album art for the spread album", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ - "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/folder.jpg": smallPNG("artist-thumbnail"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": imageFile("album-b"), + "Artist/Album B/cover.jpg": smallPNG("album-b"), }) scan() alA := albumByName("Album A") Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two sibling folders as one spread album") - _, err := readArtworkOrErr(alA.CoverArtID()) - Expect(err).To(HaveOccurred(), - "the spread album has no images of its own, so it must fall through to the "+ - "placeholder instead of inheriting the artist folder's folder.jpg") + "sanity check: the two sibling folders should form one spread album") + expectAlbumAbsent(alA) + }) + }) + + // albumRootParent refuses the library root as an album root (parent.ParentID == ""). + When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() { + // (library root) + // ├── cover.jpg ← must NOT be adopted + // ├── CD1/ + // │ └── 01 - Track.mp3 + // └── CD2/ + // └── 01 - Track.mp3 + It("does not adopt the library-root image as album art", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "cover.jpg": smallPNG("library-root"), + "CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}), + "CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}), + }) + scan() + expectAlbumAbsent(firstAlbum()) + }) + }) + + // The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips + // the parent folder for a single-folder album that has images of its own. + When("a single-folder album has its own cover.jpg and the artist folder has one too", func() { + // Artist/ + // ├── cover.jpg ← shallower, but must NOT win + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← should win + It("prefers the album's own cover over the shallower artist-folder cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/cover.jpg": smallPNG("artist-image"), + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/cover.jpg": smallPNG("album-own"), + }) + scan() + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -430,13 +433,13 @@ var _ = Describe("Album artwork resolution", func() { // ├── Album A bonus/ // │ └── 02 - Track.mp3 (album: "Album A") // └── Album B/ - // └── 01 - Track.mp3 + // └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root) It("prefers the album's own art over the artist image", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ - "Artist/cover.jpg": imageFile("artist-image"), + "Artist/cover.jpg": smallPNG("artist-image"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album A/front.jpg": imageFile("album-a-front"), + "Artist/Album A/front.jpg": smallPNG("album-a-front"), "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), }) @@ -444,8 +447,8 @@ var _ = Describe("Album artwork resolution", func() { alA := albumByName("Album A") Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two sibling folders as one spread album") - Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front"))) + "sanity check: the two sibling folders should form one spread album") + expectAlbumFolderCover(alA, "Artist/Album A/front.jpg") }) }) @@ -458,12 +461,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "embedded, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/cover.jpg": smallPNG("cover"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) }) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index 2079d37ce..5a9396cc3 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -1,4 +1,4 @@ -package artworke2e_test +package e2e import ( "os" @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,9 +17,11 @@ import ( // Doc reference: // https://www.navidrome.org/docs/usage/library/artwork/#artists // Default ArtistArtPriority is "artist.*, album/artist.*, external". +// Library-folder images are file-backed (asserted on the worker state row); uploaded and +// image-folder images are real files on disk (asserted byte-for-byte). var _ = Describe("Artist artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("the artist folder contains an artist.jpg", func() { @@ -30,13 +33,10 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -49,13 +49,10 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") }) }) @@ -69,14 +66,11 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -89,13 +83,10 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -111,14 +102,11 @@ var _ = Describe("Artist artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -136,14 +124,11 @@ var _ = Describe("Artist artwork resolution", func() { "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album1/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album1/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -164,14 +149,11 @@ var _ = Describe("Artist artwork resolution", func() { "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album1/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album1/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -189,18 +171,19 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() ar := soleArtist() uploaded := ar.ID + "_upload.jpg" - writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) + writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded")) ar.UploadedImage = uploaded - Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) + Expect(rds.Artist(rctx).Put(&ar)).To(Succeed()) - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Source).To(Equal("upload")) + Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded"))) }) }) @@ -213,13 +196,36 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() + expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") + }) + }) + + // resolveArtist only samples albums where this artist is the SOLE album artist, so a + // collaboration or compilation never donates its images as the artist's own. + When("the artist's only album is credited to two album artists", func() { + // Artist/ + // └── Collab Album/ (album artists: "Artist" + a collaborator) + // ├── 01 - Track.mp3 + // └── artist.jpg ← must NOT become the artist image + It("ignores the album's images and settles absent", func() { + conf.Server.ArtistArtPriority = "album/artist.*" + // " / " is a default artists split separator, so this single tag yields two album artists. + setLayout(fstest.MapFS{ + "Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}), + "Artist/Collab Album/artist.jpg": smallPNG("collab-artist"), + }) + scan() + Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2), + "sanity check: the album must be credited to two album artists") ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))). + To(MatchError(artwork.ErrUnavailable)) }) }) @@ -232,7 +238,7 @@ var _ = Describe("Artist artwork resolution", func() { // └── 01 - Track.mp3 (no artist.* present in library) It("returns the image from the configured artist image folder", func() { imgFolder := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed()) conf.Server.ArtistImageFolder = imgFolder conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" @@ -242,15 +248,16 @@ var _ = Describe("Artist artwork resolution", func() { scan() ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder"))) }) }) }) func soleArtist() model.Artist { GinkgoHelper() - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"artist.name": "Artist"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go index 667079458..5fdd5f27b 100644 --- a/core/artwork/e2e/disc_test.go +++ b/core/artwork/e2e/disc_test.go @@ -1,18 +1,20 @@ -package artworke2e_test +package e2e import ( "fmt" "testing/fstest" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/core/artwork" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images +// are asserted byte-for-byte, while album-root covers are asserted on the state row. var _ = Describe("Disc artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("the album is single-disc with a disc1.jpg in the only folder", func() { @@ -24,32 +26,25 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": imageFile("disc1-image"), + "Artist/Album/disc1.jpg": smallPNG("disc1-image"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) + expectDiscImage(firstAlbum(), 1, "disc1-image") }) }) When("the album has no per-disc image and no album cover", func() { // Artist/ // └── Album/ - // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) - It("returns ErrUnavailable for the disc lookup", func() { + // └── 01 - Track.mp3 (no disc or album art — nothing to serve) + It("reports the disc lookup as unavailable", func() { conf.Server.DiscArtPriority = "disc*.*, cd*.*" conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - _, err := readArtworkOrErr(discID) - Expect(err).To(HaveOccurred()) + Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable)) }) }) @@ -63,13 +58,10 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), + "Artist/Album/cover.jpg": smallPNG("album-cover"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) + expectDiscImage(firstAlbum(), 1, "album-cover") }) }) @@ -83,14 +75,11 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "disc*.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": imageFile("disc-one"), - "Artist/Album/disc10.jpg": imageFile("disc-ten"), + "Artist/Album/disc1.jpg": smallPNG("disc-one"), + "Artist/Album/disc10.jpg": smallPNG("disc-ten"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) + expectDiscImage(firstAlbum(), 1, "disc-one") }) }) @@ -108,14 +97,11 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) + expectDiscImage(firstAlbum(), 2, "disc-2") }) }) @@ -136,14 +122,11 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) + expectDiscImage(firstAlbum(), 2, "cd-2") }) }) @@ -161,14 +144,11 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), - "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), + "Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"), + "Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) + expectDiscImage(firstAlbum(), 1, "disc1-cover") }) }) @@ -188,17 +168,15 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/cd2.png": imageFile("cd-2"), - "Artist/Album/cover.jpg": imageFile("album-cover"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), + "Artist/Album/cover.jpg": smallPNG("album-cover"), }) scan() al := firstAlbum() for _, n := range []int{1, 2} { - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), - "disc %d should use the album cover when DiscArtPriority is empty", n) + expectDiscImage(al, n, "album-cover") } }) }) @@ -223,17 +201,15 @@ var _ = Describe("Disc artwork resolution", func() { "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), - "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/disc2/cd2.png": imageFile("cd-2"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/disc1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/disc2/cd2.png": smallPNG("cd-2"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() al := firstAlbum() - disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) - Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) + expectDiscImage(al, 1, "disc-1") + expectDiscImage(al, 2, "cd-2") }) }) @@ -246,13 +222,10 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "discsubtitle" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), + "Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) + expectDiscImage(firstAlbum(), 1, "bonus-tracks") }) }) @@ -286,26 +259,22 @@ var _ = Describe("Disc artwork resolution", func() { "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", } layout := fstest.MapFS{ - "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": smallPNG("album-root-cover"), } for i, name := range discNames { discNum := i + 1 prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) - layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) + layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum)) } setLayout(layout) scan() al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - + expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg") for i := range discNames { discNum := i + 1 - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), - "disc %d should use its own folder.jpg", discNum) + expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum)) } }) }) @@ -328,24 +297,20 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = defaultDiscPriority conf.Server.CoverArtPriority = defaultCoverPriority layout := fstest.MapFS{ - "Album/cover.jpg": imageFile("album-root-cover"), + "Album/cover.jpg": smallPNG("album-root-cover"), } for i := 1; i <= 3; i++ { prefix := fmt.Sprintf("Album/Disc %02d/", i) layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) - layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) + layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i)) } setLayout(layout) scan() al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - + expectAlbumFolderCover(al, "Album/cover.jpg") for i := 1; i <= 3; i++ { - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), - "disc %d should use its own folder.jpg", i) + expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i)) } }) }) @@ -359,13 +324,10 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "discsubtitle, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/cover.jpg": smallPNG("cover"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) + expectDiscImage(firstAlbum(), 1, "cover") }) }) }) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go new file mode 100644 index 000000000..8881da3a6 --- /dev/null +++ b/core/artwork/e2e/e2e_suite_test.go @@ -0,0 +1,83 @@ +// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the +// real Service serves the result, over a real ImageStore and real library files. +package e2e + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/core/artwork" + _ "github.com/navidrome/navidrome/core/storage/local" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork Pipeline E2E Suite") +} + +// Fixtures relative to the project root (tests.Init chdirs there). +const ( + coverFixture = "tests/fixtures/artist/an-album/cover.jpg" + mp3Fixture = "tests/fixtures/artist/an-album/test.mp3" + artistPngFixture = "tests/fixtures/artist/an-album/artist.png" + albumFolderPath = "tests/fixtures/artist/an-album" +) + +func readFixture(rel string) []byte { + GinkgoHelper() + data, err := os.ReadFile(rel) + Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) + return data +} + +func readAll(img *artwork.Image) []byte { + GinkgoHelper() + Expect(img).ToNot(BeNil()) + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data +} + +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} + +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil } + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return false, nil +} + +func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound } + +func writeUpload(entityType, name, srcFixture string) string { + GinkgoHelper() + dst := model.UploadedImagePath(entityType, name) + Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) + Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed()) + return name +} diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go deleted file mode 100644 index ddc926cec..000000000 --- a/core/artwork/e2e/helpers_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package artworke2e_test - -import ( - "bytes" - "context" - _ "embed" - "errors" - "hash/fnv" - "image" - "image/color" - "image/png" - "io" - "maps" - "net/url" - "os" - "path/filepath" - "testing/fstest" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that -// contains a valid MP3 stream with an embedded picture. Used in the -// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't -// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the -// scanner still populates EmbedArtPath via the JSON-tagged track, and the -// artwork reader gets real bytes when it calls libFS.Open. -// -//go:embed testdata/embedded_art.mp3 -var realMP3WithEmbeddedArt []byte - -// embeddedArtBytes is the exact image payload that the artwork reader will -// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can -// assert byte-for-byte equality — if this ever differs it means the reader -// pulled from a different source. -var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) - -func extractEmbeddedArt(mp3 []byte) []byte { - tf, err := taglib.OpenStream(bytes.NewReader(mp3)) - if err != nil { - panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) - } - defer tf.Close() - images := tf.Properties().Images - if len(images) == 0 { - panic("embedded-art fixture has no embedded images") - } - data, err := tf.Image(0) - if err != nil || len(data) == 0 { - panic("embedded-art fixture: could not read image 0") - } - return data -} - -// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative -// path so libFS.Open returns an MP3 stream taglib can parse. -func replaceWithRealMP3(relPath string) { - GinkgoHelper() - fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} -} - -// placeholderBytes returns the bundled album-placeholder image bytes — the -// same stream the artwork reader emits when every source falls through. -func placeholderBytes() []byte { - GinkgoHelper() - r, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with -// the given bytes, matching the on-disk layout expected by -// model.UploadedImagePath. -func writeUploadedImage(entity, filename string, data []byte) { - GinkgoHelper() - dir := filepath.Dir(model.UploadedImagePath(entity, filename)) - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) -} - -func newNoopFFmpeg() *tests.MockFFmpeg { - ff := tests.NewMockFFmpeg("") - ff.Error = errors.New("noop") - return ff -} - -// trackFile builds a FakeFS MP3 entry with optional tag overrides. -func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { - tags := storagetest.Track(num, title) - for _, e := range extra { - maps.Copy(tags, e) - } - return storagetest.MP3(tags) -} - -// imageFile builds a label-keyed image entry. The bytes are deterministic -// per-label so tests can assert which file won. -func imageFile(label string) *fstest.MapFile { - return &fstest.MapFile{Data: []byte("image:" + label)} -} - -// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by -// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). -func realPNG(label string) *fstest.MapFile { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - // Derive a deterministic color per label. - h := fnv.New32a() - _, _ = h.Write([]byte(label)) - sum := h.Sum32() - c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} - for y := range 2 { - for x := range 2 { - img.Set(x, y, c) - } - } - var buf bytes.Buffer - Expect(png.Encode(&buf, img)).To(Succeed()) - return &fstest.MapFile{Data: buf.Bytes()} -} - -// imageBytes returns the bytes that imageFile(label) writes. -func imageBytes(label string) []byte { return imageFile(label).Data } - -// setLayout populates fakeFS with the given map. Call after setupHarness. -// All paths must be forward-slash and relative (no leading "/"). -func setLayout(files fstest.MapFS) { - GinkgoHelper() - fakeFS.SetFiles(files) -} - -func readArtwork(artID model.ArtworkID) []byte { - GinkgoHelper() - r, _, err := aw.Get(ctx, artID, 0, false) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - b, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return b -} - -func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { - r, _, err := aw.Get(ctx, artID, 0, false) - if err != nil { - return nil, err - } - defer r.Close() - return io.ReadAll(r) -} - -// noopProvider implements external.Provider with not-found returns so the -// "external" priority entry never produces a result. -type noopProvider struct{} - -func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - return nil, nil -} -func (n *noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { - return nil, nil -} -func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go index 1f43a3827..d26756dcd 100644 --- a/core/artwork/e2e/mediafile_test.go +++ b/core/artwork/e2e/mediafile_test.go @@ -1,4 +1,4 @@ -package artworke2e_test +package e2e import ( "testing/fstest" @@ -17,13 +17,11 @@ import ( // 2. For multi-disc albums, disc-level artwork // 3. Album cover art // -// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) -// is covered by the existing embedded-art album tests (which currently -// Skip under FakeFS). The tests below cover (2) and (3): the fallback -// chain for tracks without embedded art. -var _ = Describe("MediaFile artwork fallback", func() { +// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a +// serve-time read through the library FS. +var _ = Describe("MediaFile artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("a multi-disc album track has no embedded art", func() { @@ -42,14 +40,14 @@ var _ = Describe("MediaFile artwork fallback", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) + Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2"))) }) }) @@ -63,12 +61,12 @@ var _ = Describe("MediaFile artwork fallback", func() { conf.Server.DiscArtPriority = defaultDiscPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), + "Artist/Album/cover.jpg": smallPNG("album-cover"), }) scan() mf := mediafileOn("Artist/Album/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) + Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover"))) }) }) @@ -86,19 +84,60 @@ var _ = Describe("MediaFile artwork fallback", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) + Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root"))) + }) + }) + + When("a track has its own embedded art", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 ← has embedded picture (wins over every fallback) + It("resolves the track's embedded image into the store", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + mf := mediafileOn("Artist/Album/01 - Track.mp3") + ia := acquire(model.KindMediaFileArtwork, mf.ID) + Expect(ia.Source).To(Equal("embedded")) + Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) + }) + }) + + When("EnableMediaFileCoverArt is turned off after the track was scanned", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← has embedded picture (must NOT be served) + // └── cover.jpg ← wins (per-track art disabled at serve time) + It("serves the album cover instead of the track's embedded art", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + "Artist/Album/cover.jpg": smallPNG("album-cover"), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + // The setting is not part of the artwork fingerprint, so it must be honored at serve time. + conf.Server.EnableMediaFileCoverArt = false + mf := mediafileOn("Artist/Album/01 - Track.mp3") + trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil) + Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover"))) }) }) }) func mediafileOn(relPath string) model.MediaFile { GinkgoHelper() - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{ Filters: squirrel.Like{"media_file.path": relPath}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go index d28efca8e..f5ac8a7e3 100644 --- a/core/artwork/e2e/playlist_test.go +++ b/core/artwork/e2e/playlist_test.go @@ -1,13 +1,16 @@ -package artworke2e_test +package e2e import ( + "image/color" "os" "path/filepath" "testing/fstest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -17,14 +20,13 @@ import ( // 2. Sidecar image next to the .m3u file (same basename, any image ext) // 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) // 4. Generated 2x2 tiled cover from the playlist's albums -// 5. Album placeholder image +// 5. Absent // -// The library FS is FakeFS, but uploaded/sidecar/local-external images are -// real files on disk — the reader reads them via os.Open, so the tests -// place them in a real tempdir under DataFolder. +// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on +// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir. var _ = Describe("Playlist artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("a playlist has an uploaded image", func() { @@ -33,11 +35,12 @@ var _ = Describe("Playlist artwork resolution", func() { // └── playlist/ // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) - + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload")) pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("upload")) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload"))) }) }) @@ -48,12 +51,14 @@ var _ = Describe("Playlist artwork resolution", func() { It("returns the sidecar image", func() { dir := GinkgoT().TempDir() m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar"))) }) }) @@ -64,12 +69,12 @@ var _ = Describe("Playlist artwork resolution", func() { It("matches case-insensitively", func() { dir := GinkgoT().TempDir() m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png"))) }) }) @@ -80,31 +85,39 @@ var _ = Describe("Playlist artwork resolution", func() { conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) + Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local"))) }) }) When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { - // (no local files — http source is gated off, reader falls through to placeholder) - It("skips the URL and falls through to the bundled placeholder", func() { + // (no local files — the http source is gated off, so resolution settles absent) + It("skips the URL and settles absent", func() { conf.Server.EnableM3UExternalAlbumArt = false - pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) + + img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false) + Expect(err).ToNot(HaveOccurred()) + defer img.Close() + Expect(img.Placeholder).To(BeTrue()) }) }) When("a playlist has no images and no tracks", func() { - // (reader falls all the way through to the bundled album placeholder) - It("returns the album placeholder", func() { + // (no uploaded/sidecar/external image and no album art to sample) + It("settles absent", func() { pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) }) }) @@ -113,37 +126,79 @@ var _ = Describe("Playlist artwork resolution", func() { // Artist/ // ├── AlbumA/ // │ ├── 01 - Track.mp3 - // │ └── cover.png (real PNG — wins as tile 1 source) + // │ └── cover.png ← tile 1 source // └── AlbumB/ // ├── 01 - Track.mp3 - // └── cover.png (real PNG — wins as tile 2 source) - // Playlist "pl-7" references tracks from both albums, so the reader - // generates a 2x2 tiled cover from 2 distinct album art tiles (the - // tiled generator mirrors when it has fewer than 4 unique tiles). + // └── cover.png ← tile 2 source + // Playlist "pl-7" references tracks from both albums, so the worker generates a tiled + // cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid). It("generates a tiled cover from album art", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), - "Artist/AlbumA/cover.png": realPNG("albumA"), + "Artist/AlbumA/cover.png": smallPNG("albumA"), "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), - "Artist/AlbumB/cover.png": realPNG("albumB"), + "Artist/AlbumB/cover.png": smallPNG("albumB"), }) scan() - // Pull the scanned mediafile IDs so we can attach them to the playlist. - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) + mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) - data := readArtwork(pl.CoverArtID()) - // The tiled cover is a PNG-encoded 600x600 image (tileSize const). - // Exact bytes vary (random album order), so assert format + non-trivial size. + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("generated")) + data := storedBytes(ia) + // The tiled cover is a PNG-encoded image; exact bytes vary (random album order). Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) - Expect(len(data)).To(BeNumerically(">", 1000)) + // Two tiles are mirrored into the 2x2 grid as [A B B A], so opposite corners match. + q := gridQuadrants(data) + Expect(q[0]).To(Equal(q[3])) + Expect(q[1]).To(Equal(q[2])) + Expect(q[0]).ToNot(Equal(q[1])) + }) + }) + + When("a playlist has tracks from four albums, each with its own cover", func() { + // Library: + // Artist/ + // ├── AlbumA/{01 - Track.mp3, cover.png} ← tile 1 + // ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2 + // ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3 + // └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4 + It("fills all four grid quadrants with distinct album art", func() { + conf.Server.CoverArtPriority = "cover.*" + layout := fstest.MapFS{} + for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} { + layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name}) + layout["Artist/"+name+"/cover.png"] = smallPNG(name) + } + setLayout(layout) + scan() + + mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(4)) + ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) + + pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"} + pl.AddMediaFilesByID(ids) + Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) + + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("generated")) + q := gridQuadrants(storedBytes(ia)) + Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4)) + Expect(q[0]).ToNot(Equal(q[1])) + Expect(q[0]).ToNot(Equal(q[2])) + Expect(q[0]).ToNot(Equal(q[3])) + Expect(q[1]).ToNot(Equal(q[2])) + Expect(q[1]).ToNot(Equal(q[3])) + Expect(q[2]).ToNot(Equal(q[3])) }) }) }) @@ -153,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist { if pl.OwnerID == "" { pl.OwnerID = "admin-1" } - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) return pl } diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go index 73ee5f377..bba85224a 100644 --- a/core/artwork/e2e/radio_test.go +++ b/core/artwork/e2e/radio_test.go @@ -1,15 +1,18 @@ -package artworke2e_test +package e2e import ( "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they +// serve back byte-for-byte; a radio with no upload settles absent. var _ = Describe("Radio artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("a radio has an uploaded image", func() { @@ -18,25 +21,25 @@ var _ = Describe("Radio artwork resolution", func() { // └── radio/ // └── rd-1_logo.jpg ← matched by UploadedImagePath() It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) - + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo")) rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + Expect(rds.Radio(rctx).Put(&rd)).To(Succeed()) - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) + ia := acquire(model.KindRadioArtwork, rd.ID) + Expect(ia.Source).To(Equal("upload")) + Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("radio-logo"))) }) }) When("a radio has no uploaded image", func() { - // (no files on disk — reader has no sources to fall back to) - It("returns ErrUnavailable", func() { + // (no files on disk — the resolver has no sources to fall back to) + It("settles absent", func() { rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + Expect(rds.Radio(rctx).Put(&rd)).To(Succeed()) - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - _, err := readArtworkOrErr(artID) - Expect(err).To(HaveOccurred()) + ia := acquire(model.KindRadioArtwork, rd.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable)) }) }) }) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go new file mode 100644 index 000000000..1168393df --- /dev/null +++ b/core/artwork/e2e/resolution_harness_test.go @@ -0,0 +1,365 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "testing/fstest" + "time" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + +var ( + rctx context.Context + rds *tests.MockDataStore + rstore *artwork.ImageStore + rsvc artwork.Artwork + rworker *artwork.Worker + fakeFS *storagetest.FakeFS +) + +// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a +// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir. +var suiteDBTempDir string + +// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating. +var userTables []string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() + + DeferCleanup(configtest.SetupConfig()) + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + db.Db().SetMaxOpenConns(1) + db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true})) + + userTables = harness.ResettableTables() +}) + +var _ = AfterSuite(func() { + db.Close(context.Background()) +}) + +func setupResolutionHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = conf.NewDir(tempDir) + conf.Server.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" + conf.Server.EnableExternalServices = false + conf.Server.EnableMediaFileCoverArt = true + conf.Server.DevArtworkWorkerConcurrency = 1 + + rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + harness.TruncateDB(userTables) + + rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(rds.User(rctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(rds.Library(rctx).Put(&lib)).To(Succeed()) + Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + loadEmbeddedFixture() + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + ffm := tests.NewMockFFmpeg("") + rstore = artwork.NewImageStore(filepath.Join(tempDir, consts.HashedArtworkFolder)) + // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). + imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, fmt.Errorf("resize not exercised in e2e") + }) + Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue()) + + rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) + rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) +} + +// setLayout paths must be relative and forward-slash. +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func scan() { + GinkgoHelper() + s := scanner.New(rctx, rds, events.NoopBroker(), + playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(rctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func acquire(kind model.Kind, id string) model.ItemArtwork { + GinkgoHelper() + // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. + Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + var ia *model.ItemArtwork + runResolutionWorkerUntil(func() bool { + got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + if err != nil { + return false + } + ia = got + return true + }) + return *ia +} + +func runResolutionWorkerUntil(until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(rctx) + done := make(chan error, 1) + go func() { done <- rworker.Run(runCtx) }() + Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} + +func serveBytes(artID model.ArtworkID) []byte { + GinkgoHelper() + img, err := rsvc.Get(rctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data +} + +func serveErr(artID model.ArtworkID) error { + img, err := rsvc.Get(rctx, artID, 0, false) + if img != nil { + img.Close() + } + return err +} + +func libFileBytes(suffix string) []byte { + GinkgoHelper() + var match string + for name := range fakeFS.MapFS { + if strings.HasSuffix(name, suffix) { + Expect(match).To(BeEmpty(), "suffix %q is ambiguous: %q and %q", suffix, match, name) + match = name + } + } + Expect(match).ToNot(BeEmpty(), "no library file ends with %q", suffix) + return fakeFS.MapFS[match].Data +} + +// Serving before acquiring is deliberate: with no state row the request resolves through the +// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve. +func expectAlbumFolderCover(al model.Album, suffix string) { + GinkgoHelper() + requireNoStateRow(model.KindAlbumArtwork, al.ID) + Expect(serveBytes(al.CoverArtID())).To(Equal(libFileBytes(suffix))) + ia := acquire(model.KindAlbumArtwork, al.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) +} + +// A drain settles every ready item, so byte-level folder assertions must precede any acquire. +func requireNoStateRow(kind model.Kind, id string) { + GinkgoHelper() + _, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), + "assert %s %q before acquiring any other entity in this spec", kind, id) +} + +func expectAlbumAbsent(al model.Album) { + GinkgoHelper() + ia := acquire(model.KindAlbumArtwork, al.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) +} + +func expectArtistFolder(ar model.Artist, suffix string) { + GinkgoHelper() + requireNoStateRow(model.KindArtistArtwork, ar.ID) + Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(libFileBytes(suffix))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) +} + +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dst := model.UploadedImagePath(entity, filename) + Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) + Expect(os.WriteFile(dst, data, 0o600)).To(Succeed()) +} + +func discArtID(al model.Album, disc int) model.ArtworkID { + return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt) +} + +// Disc art is a pure serve-time read through the library FS: no worker, no state row. +func expectDiscImage(al model.Album, disc int, label string) { + GinkgoHelper() + Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label))) +} + +// Samples in rect() order: top-left, top-right, bottom-left, bottom-right. +func gridQuadrants(data []byte) [4]color.RGBA { + GinkgoHelper() + img, _, err := image.Decode(bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + b := img.Bounds() + qw, qh := b.Dx()/4, b.Dy()/4 + at := func(x, y int) color.RGBA { + c := color.RGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y)) + return c.(color.RGBA) + } + return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)} +} + +// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath. +func storedBytes(ia model.ItemArtwork) []byte { + GinkgoHelper() + art, err := rds.Artwork(rctx).GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + r, err := rstore.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// The pixel color derives from label, so each label yields distinct, still-decodable bytes. +func smallPNG(label string) *fstest.MapFile { + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +func pngBytes(label string) []byte { + GinkgoHelper() + return smallPNG(label).Data +} + +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3 +// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first. +var ( + embeddedFixtureOnce sync.Once + embeddedArtFixture []byte + embeddedArtBytes []byte +) + +func loadEmbeddedFixture() { + embeddedFixtureOnce.Do(func() { + embeddedArtFixture = readFixture(mp3Fixture) + embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture) + }) +} + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture} +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := rds.Album(rctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} + +func albumByName(name string) model.Album { + GinkgoHelper() + albums, err := rds.Album(rctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + for _, al := range albums { + if al.Name == name { + return al + } + } + Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) + return model.Album{} +} diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go deleted file mode 100644 index 06cc05b6f..000000000 --- a/core/artwork/e2e/suite_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package artworke2e_test - -import ( - "context" - "fmt" - "path/filepath" - "testing" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestArtworkE2E(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Artwork E2E Suite") -} - -const fakeLibScheme = "artworkfake" -const fakeLibPath = fakeLibScheme + ":///music" - -var ( - ctx context.Context - ds *tests.MockDataStore - aw artwork.Artwork - fakeFS *storagetest.FakeFS -) - -// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps -// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup -// can't unlink a file with a live handle on Windows. A suite-level tempdir -// combined with an AfterSuite close avoids the lock conflict. -var suiteDBTempDir string - -var _ = BeforeSuite(func() { - suiteDBTempDir = GinkgoT().TempDir() -}) - -var _ = AfterSuite(func() { - db.Close(GinkgoT().Context()) -}) - -func setupHarness() { - DeferCleanup(configtest.SetupConfig()) - - tempDir := GinkgoT().TempDir() - // Reuse the suite-level DB path so the singleton connection keeps working - // across specs (see suiteDBTempDir comment). - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(tempDir) - conf.Server.MusicFolder = fakeLibPath - conf.Server.DevExternalScanner = false - conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call - conf.Server.EnableExternalServices = false - - db.Db().SetMaxOpenConns(1) - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) - db.Init(ctx) - DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} - Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - - lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} - Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - - fakeFS = &storagetest.FakeFS{} - storagetest.Register(fakeLibScheme, fakeFS) - - aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) -} - -func scan() { - GinkgoHelper() - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) -} - -func firstAlbum() model.Album { - GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) - return albums[0] -} - -func albumByName(name string) model.Album { - GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - for _, al := range albums { - if al.Name == name { - return al - } - } - Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) - return model.Album{} -} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 deleted file mode 100644 index 18cb906749e047afda734b62c1ffcdd89ca72ead..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 64223 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 diff --git a/core/artwork/reader_album.go b/core/artwork/folders_album.go similarity index 63% rename from core/artwork/reader_album.go rename to core/artwork/folders_album.go index 58a47c8ae..88f0181a3 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/folders_album.go @@ -3,107 +3,19 @@ package artwork import ( "cmp" "context" - "crypto/md5" "errors" - "fmt" - "io" "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/slice" ) -type albumArtworkReader struct { - cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string // library-relative, forward-slash, no leading slash - lib libraryView -} - -func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { - al, err := artwork.ds.Album(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - _, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) - if err != nil { - return nil, err - } - lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) - if err != nil { - return nil, err - } - a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - lib: lib, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) - if imagesUpdateAt != nil { - a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) - } - return a, nil -} - -func (a *albumArtworkReader) Key() string { - hashInput := conf.Server.CoverArtPriority - if conf.Server.EnableExternalServices { - hashInput = conf.Server.Agents + hashInput - } - hash := md5.Sum([]byte(hashInput)) - return fmt.Sprintf( - "%s.%x.%t", - a.cacheKey.Key(), - hash, - conf.Server.EnableExternalServices, - ) -} -func (a *albumArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority) - return selectImageReader(ctx, a.artID, ff...) -} - -func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { - var ff []sourceFunc - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - switch { - case pattern == "embedded": - embedRel := a.album.EmbedArtPath - ff = append(ff, - fromTag(ctx, a.lib.FS, embedRel), - fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), - ) - case pattern == "external": - ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) - case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) - } - } - return ff -} - func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) { folders, err := loadFolders(ctx, ds, album.FolderIDs) if err != nil { @@ -169,7 +81,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo } parent, err := ds.Folder(ctx).Get(commonParentID) if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID) return nil, nil } if err != nil { diff --git a/core/artwork/folders_album_test.go b/core/artwork/folders_album_test.go new file mode 100644 index 000000000..a263dbaaf --- /dev/null +++ b/core/artwork/folders_album_test.go @@ -0,0 +1,214 @@ +package artwork + +import ( + "context" + "errors" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The e2e specs cover which image wins for a layout; these pin what a layout cannot reach: that +// the album-root parent is only fetched when it could qualify, and what happens when that fails. +var _ = Describe("loadAlbumFoldersPaths", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + repo *fakeFolderRepo + album model.Album + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Now().Truncate(time.Second) + repo = &fakeFolderRepo{} + ds = &tests.MockDataStore{MockedFolder: repo} + album = model.Album{ + ID: "album1", + Name: "Album", + FolderIDs: []string{"folder1", "folder2", "folder3"}, + } + }) + + It("does not query the parent when it is already one of the album's folders", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "folder2", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "", Name: "Artist", ImagesUpdatedAt: now}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(BeZero()) + }) + + It("does not query the parent when the album's folders have different parents", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist1/Album", Name: "part1", ParentID: "parentA", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist2/Album", Name: "part2", ParentID: "parentB", + ImagesUpdatedAt: now}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist1/Album/part1/cover.jpg")) + Expect(repo.getCallCount).To(BeZero()) + }) + + It("does not query the parent for a single-folder album that has images of its own", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "artistFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(BeZero()) + }) + + It("does not promote the library root, so its images never become album art", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: ".", Name: "AlbumPart1", ParentID: "rootFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: ".", Name: "AlbumPart2", ParentID: "rootFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "rootFolder", Name: ".", ImageFiles: []string{"unrelated.jpg"}} + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not promote a parent that holds another album's audio", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImageFiles: []string{"artist.jpg"}} + repo.hasOtherAudio = true + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) + }) + + It("promotes the album root parent into the returned paths", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} + + paths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) + Expect(paths).To(HaveLen(3)) + }) + + It("propagates errors from the album-root check", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "disc1", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} + repo.otherAudioErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + }) + + It("propagates non-ErrNotFound errors from the parent folder lookup", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "parentFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "parentFolder", + ImagesUpdatedAt: now}, + } + repo.getErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("continues when the parent folder has been deleted", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "missingParent", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "missingParent", + ImagesUpdatedAt: now}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) +}) + +// folderImages is the sort that decides which of several same-named images wins, so it is pinned +// directly rather than through a layout that can only show the winner. +var _ = Describe("folderImages", func() { + It("prefers base filenames over numeric-suffixed ones", func() { + imgFiles, _ := folderImages([]model.Folder{ + {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}}, + }) + + Expect(imgFiles).To(HaveExactElements( + "Artist/Album/cover.jpg", "Artist/Album/cover.1.jpg", "Artist/Album/cover.2.jpg")) + }) + + It("prefers shallower paths when the base filenames tie", func() { + imgFiles, _ := folderImages([]model.Folder{ + {Path: "Artist/Album", Name: "CD1", ImageFiles: []string{"cover.jpg"}}, + {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.jpg"}}, + }) + + Expect(imgFiles).To(HaveExactElements("Artist/Album/cover.jpg", "Artist/Album/CD1/cover.jpg")) + }) + + It("sorts case-insensitively", func() { + imgFiles, _ := folderImages([]model.Folder{ + {Path: "Artist", Name: "Album", ImageFiles: []string{"Cover.jpg", "back.JPG"}}, + }) + + Expect(imgFiles).To(HaveExactElements("Artist/Album/back.JPG", "Artist/Album/Cover.jpg")) + }) + + It("reports the newest ImagesUpdatedAt across the folders", func() { + now := time.Now().Truncate(time.Second) + newest := now.Add(5 * time.Minute) + + _, updatedAt := folderImages([]model.Folder{ + {Path: "Artist", Name: "Album", ImagesUpdatedAt: now}, + {Path: "Artist/Album", Name: "CD1", ImagesUpdatedAt: newest}, + }) + + Expect(updatedAt).To(Equal(newest)) + }) +}) diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go new file mode 100644 index 000000000..1ca1ce034 --- /dev/null +++ b/core/artwork/folders_artist.go @@ -0,0 +1,210 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + maxArtistFolderTraversalDepth = 3 +) + +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob needs forward slashes; filepath.Rel returns backslashes on Windows. + rel = filepath.ToSlash(rel) + current := artistFolder + var unreadable error + for range maxArtistFolderTraversalDepth { + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil + } + if errors.Is(err, errSourceUnreadable) { + unreadable = err + } + if rel == "." { + break // reached library root + } + rel = path.Dir(rel) + current = filepath.Dir(current) + } + if unreadable != nil { + return nil, "", unreadable + } + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) + } +} + +// findImageInFolder returns the first image matching pattern; absFolder is only used for +// the returned display path and log messages. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "Artwork: Looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) + if err != nil { + log.Warn(ctx, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) + return nil, "", err + } + + imagePaths := slice.Filter(matches, model.IsImageFile) + + // Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg) + slices.SortFunc(imagePaths, compareImageFiles) + + var openErr error + for _, p := range imagePaths { + f, err := libFS.Open(p) + if err != nil { + log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err) + openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err) + continue + } + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil + } + if openErr != nil { + return nil, "", openErr + } + + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +// loadArtistAlbumRoots returns one path per album — the deepest folder holding +// all of that album's tracks — so an album split into disc subfolders can't +// pull the artist folder's common prefix below the artist level. +func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { + var folderIDs []string + for _, album := range albums { + folderIDs = append(folderIDs, album.FolderIDs...) + } + folders, err := loadFolders(ctx, ds, folderIDs) + if err != nil { + return nil, nil, nil, err + } + + pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { + return f.ID, f.AbsolutePath() + }) + var roots []string + for _, album := range albums { + var albumPaths []string + for _, fid := range album.FolderIDs { + if p, ok := pathByID[fid]; ok { + albumPaths = append(albumPaths, p) + } + } + if len(albumPaths) > 0 { + roots = append(roots, commonDir(albumPaths)) + } + } + + imgFiles, updatedAt := folderImages(folders) + return roots, imgFiles, &updatedAt, nil +} + +// commonDir returns the deepest directory containing all paths. Trailing +// separators keep the comparison on segment boundaries, so a shared name +// fragment (".../Album" and ".../Album2") is never read as a shared directory. +func commonDir(paths []string) string { + sep := string(filepath.Separator) + common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) + if !strings.HasSuffix(common, sep) { + common, _ = filepath.Split(common) + } + return filepath.Clean(common) +} + +func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { + if len(albums) == 0 { + return "", time.Time{}, nil + } + libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries + + // paths holds one root per album: two or more distinct roots already meet at + // the artist folder, while a single root is an album folder needing a climb. + roots := slices.Compact(slices.Sorted(slices.Values(paths))) + folderPath := commonDir(roots) + if len(roots) < 2 { + folderPath = filepath.Dir(folderPath) + } + + // TODO: Hacky, but the easiest way to get the folder ID ATM + libPath := core.AbsolutePath(ctx, ds, libID, "") + folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) + + log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, + "libPath", libPath, "libID", libID, "albumPaths", paths) + + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) + if err != nil || len(folders) == 0 { + log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID, + "libPath", libPath, "libID", libID, err) + return "", time.Time{}, err + } + return folderPath, folders[0].ImagesUpdatedAt, nil +} + +// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none. +func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { + entries, err := os.ReadDir(folder) + if err != nil { + return "" + } + for _, candidate := range []string{mbzArtistID, artistName} { + if candidate == "" { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + base := utils.BaseName(name) + if strings.EqualFold(base, candidate) && model.IsImageFile(name) { + return filepath.Join(folder, name) + } + } + } + return "" +} diff --git a/core/artwork/folders_artist_paths_test.go b/core/artwork/folders_artist_paths_test.go new file mode 100644 index 000000000..cc8af3e63 --- /dev/null +++ b/core/artwork/folders_artist_paths_test.go @@ -0,0 +1,117 @@ +package artwork + +import ( + "context" + "errors" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// commonDir and loadArtistFolder decide how far above the albums the artist folder sits. A layout +// can only show the image that won, so the arithmetic is pinned here. +var _ = Describe("commonDir", func() { + It("returns the folder itself for a single path", func() { + Expect(commonDir([]string{filepath.FromSlash("/music/artist/album")})). + To(Equal(filepath.FromSlash("/music/artist/album"))) + }) + + It("returns the deepest shared folder", func() { + Expect(commonDir([]string{ + filepath.FromSlash("/music/artist/album/cd1"), + filepath.FromSlash("/music/artist/album/cd2"), + })).To(Equal(filepath.FromSlash("/music/artist/album"))) + }) + + It("does not read a shared name fragment as a shared folder", func() { + Expect(commonDir([]string{ + filepath.FromSlash("/music/artist/Album"), + filepath.FromSlash("/music/artist/Album2"), + })).To(Equal(filepath.FromSlash("/music/artist"))) + }) +}) + +var _ = Describe("loadArtistFolder", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + repo *fakeFolderRepo + albums model.Albums + updatedAt time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(stubCoreAbsolutePath()) + + updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute) + repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}} + ds = &tests.MockDataStore{MockedFolder: repo} + albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}} + }) + + It("returns empty when the artist has no albums", func() { + folder, upd, err := loadArtistFolder(ctx, ds, model.Albums{}, []string{"/dummy/path"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) + + It("climbs above the album folder when the artist has a single album root", func() { + folder, upd, err := loadArtistFolder(ctx, ds, albums, + []string{filepath.FromSlash("/music/artist/album1")}) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(updatedAt)) + }) + + It("climbs above the shared folder when two albums live in the same one", func() { + folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ + filepath.FromSlash("/music/artist/split"), + filepath.FromSlash("/music/artist/split"), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(updatedAt)) + }) + + It("stops at the folder where distinct album roots already meet", func() { + folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(updatedAt)) + }) + + It("returns the error when the folder lookup fails", func() { + repo.err = errors.New("fake error") + + folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + }) + + Expect(err).To(MatchError(ContainSubstring("fake error"))) + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) +}) + +func stubCoreAbsolutePath() func() { + original := core.AbsolutePath + core.AbsolutePath = func(context.Context, model.DataStore, int, string) string { + return filepath.FromSlash("/music") + } + return func() { core.AbsolutePath = original } +} diff --git a/core/artwork/folders_artist_test.go b/core/artwork/folders_artist_test.go new file mode 100644 index 000000000..a35e40232 --- /dev/null +++ b/core/artwork/folders_artist_test.go @@ -0,0 +1,47 @@ +package artwork + +import ( + "context" + "errors" + "io/fs" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error +// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows. +type unreadableFS struct{ fstest.MapFS } + +func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission } + +var _ = Describe("findImageInFolder", func() { + var ctx context.Context + var files fstest.MapFS + + BeforeEach(func() { + ctx = context.Background() + files = fstest.MapFS{"artist.jpg": &fstest.MapFile{Data: []byte("img")}} + }) + + It("returns the first matching image", func() { + r, hit, err := findImageInFolder(ctx, files, ".", "/lib", "artist.*") + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(hit).To(HaveSuffix("artist.jpg")) + }) + + // The glob matched, so the image exists; failing to open it says nothing about whether the + // artist has one, and must not let the resolver settle on absent. + It("reports a matched but unreadable image as unreadable, not as a miss", func() { + _, _, err := findImageInFolder(ctx, unreadableFS{files}, ".", "/lib", "artist.*") + Expect(err).To(MatchError(errSourceUnreadable)) + }) + + It("reports a plain miss when nothing matches", func() { + _, _, err := findImageInFolder(ctx, files, ".", "/lib", "nothing.*") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, errSourceUnreadable)).To(BeFalse(), "no match is definitive, not transient") + }) +}) diff --git a/core/artwork/gate.go b/core/artwork/gate.go new file mode 100644 index 000000000..a64c11a20 --- /dev/null +++ b/core/artwork/gate.go @@ -0,0 +1,120 @@ +package artwork + +import ( + "errors" + "io" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "golang.org/x/time/rate" +) + +const ( + breakerThreshold = 5 + breakerProbeAfter = time.Minute +) + +var errBreakerOpen = errors.New("artwork: external circuit breaker open") + +// gateFunc gates one named external fetch (rate limit + circuit breaker per name). +type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) + +func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return f() +} + +// isTransientExternal reports whether an external failure is worth retrying; a not-found +// (from either package) is a definitive answer, not a fault. +func isTransientExternal(err error) bool { + return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound) +} + +// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off +// in isolation from the others. +type extGate struct { + limiter *rate.Limiter + breaker *breaker +} + +// gate runs a named external step through that agent's rate limiter and circuit breaker. +func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + g := w.gateFor(name) + if !g.breaker.allow() { + log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name) + return nil, "", errBreakerOpen + } + // Timed separately so a throttled agent isn't mistaken for a slow provider. + waitStart := time.Now() + if err := g.limiter.Wait(w.runCtx); err != nil { + return nil, "", err + } + callStart := time.Now() + r, path, err := f() + g.breaker.record(name, err) + log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil, + "limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err) + return r, path, err +} + +// gateFor lazily creates the per-name gate on first use. +func (w *Worker) gateFor(name string) *extGate { + w.gatesMu.Lock() + defer w.gatesMu.Unlock() + if g, ok := w.gates[name]; ok { + return g + } + rps := conf.Server.DevArtworkExternalMaxRPS + limit := rate.Inf + if rps > 0 { + limit = rate.Limit(rps) + } + g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()} + w.gates[name] = g + return g +} + +// breaker opens after breakerThreshold consecutive errors and admits a single probe once +// breakerProbeAfter has elapsed; a success re-closes it. +type breaker struct { + mu sync.Mutex + failures int + openedAt time.Time +} + +func newBreaker() *breaker { return &breaker{} } + +func (b *breaker) allow() bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.failures < breakerThreshold { + return true + } + if time.Since(b.openedAt) >= breakerProbeAfter { + b.openedAt = time.Now() // start a fresh probe window so only one caller passes + return true + } + return false +} + +func (b *breaker) record(name string, err error) { + b.mu.Lock() + defer b.mu.Unlock() + // A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal. + if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) { + if b.failures >= breakerThreshold { + log.Info("Artwork: Circuit breaker closed for agent", "agent", name) + } + b.failures = 0 + return + } + b.failures++ + if b.failures == breakerThreshold { + b.openedAt = time.Now() + log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, + "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) + } +} diff --git a/core/artwork/hash_encoder_bench_test.go b/core/artwork/hash_encoder_bench_test.go new file mode 100644 index 000000000..2bc4b90ba --- /dev/null +++ b/core/artwork/hash_encoder_bench_test.go @@ -0,0 +1,51 @@ +package artwork + +import ( + "fmt" + "image" + "testing" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/artwork/thumbhash" +) + +// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail. +var hashEncoders = []struct { + name string + encode func(image.Image) error +}{ + {"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }}, + {"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }}, +} + +func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) { + b.Helper() + b.ReportAllocs() + for b.Loop() { + if err := encode(img); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image +// makeThumbnail produces, so neither is measured with a conversion the other avoids. +func BenchmarkHashEncodersAtInputSize(b *testing.B) { + img := gradientNRGBA(thumbnailSize) + for _, e := range hashEncoders { + b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) }) + } +} + +// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive +// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above. +func BenchmarkHashEncoders(b *testing.B) { + for _, size := range []int{300, 600, 900, 1200, 1500} { + img := gradientNRGBA(size) + for _, e := range hashEncoders { + b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) { + benchEncoder(b, e.encode, img) + }) + } + } +} diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go new file mode 100644 index 000000000..ce72a6c8e --- /dev/null +++ b/core/artwork/housekeeping.go @@ -0,0 +1,129 @@ +package artwork + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "slices" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +const staleAbsentAge = 24 * time.Hour + +// recheckKinds omits media files: they resolve embedded-only, at scan or on view. +var recheckKinds = []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, +} + +// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. +func hasRecheckPath(prefix string) bool { + kind, ok := model.ParseKind(prefix) + return ok && slices.Contains(recheckKinds, kind) +} + +// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. +const artworkEpoch = 1 + +// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. +func fingerprint() string { + raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d", + conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, + conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch) + sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive + return hex.EncodeToString(sum[:]) +} + +// backfill enqueues artwork resolution for every entity when the config fingerprint changed. +func backfill(ctx context.Context, ds model.DataStore) (bool, error) { + start := time.Now() + ctx = auth.WithAdminUser(ctx, ds) + current := fingerprint() + props := ds.Property(ctx) + stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") + if err != nil { + return false, err + } + if stored == current { + return false, nil + } + + // Artists first: few entities, most external-dependent, so they get a queue headstart. + kinds := []struct { + kind model.Kind + fetch func() ([]string, error) + }{ + {model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, + {model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, + {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, + {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, + } + for _, k := range kinds { + ids, err := k.fetch() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { + return false, err + } + } + + if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { + return false, err + } + log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start)) + return true, nil +} + +func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { + if len(ids) == 0 { + return nil + } + items := slice.Map(ids, func(id string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, + } + }) + return ds.ArtworkQueue(ctx).Enqueue(items...) +} + +func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { + cutoff := time.Now().Add(-staleAbsentAge) + queue := ds.ArtworkQueue(ctx) + for _, kind := range recheckKinds { + if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { + return err + } + } + return nil +} + +// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). +func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { + queue := ds.ArtworkQueue(ctx) + for _, kind := range recheckKinds { + if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { + return err + } + } + return nil +} + +// Refresh drops an item's resolved artwork state and re-queues it at Bump priority. +func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { + if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil { + return fmt.Errorf("clearing artwork state: %w", err) + } + item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} + if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + return fmt.Errorf("enqueuing artwork refresh: %w", err) + } + return nil +} diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go new file mode 100644 index 000000000..3b75c5186 --- /dev/null +++ b/core/artwork/housekeeping_test.go @@ -0,0 +1,261 @@ +package artwork + +import ( + "context" + "slices" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only +// visible when the ctx carries an admin, so headless work must wrap ctx with one first. +type visibilityPlaylistDS struct { + *tests.MockDataStore + private model.Playlist + tracks model.PlaylistTrackRepository +} + +func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { + repo := tests.CreateMockPlaylistRepo() + repo.TracksRepo = v.tracks + if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { + repo.SetData(model.Playlists{v.private}) + } + return repo +} + +func adminUserRepo() *tests.MockedUserRepo { + repo := tests.CreateMockUserRepo() + Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + return repo +} + +// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can +// assert phase ordering (artists-first) that same-priority timestamps can't guarantee. +type orderTrackingQueueRepo struct { + *tests.MockArtworkQueueRepo + callKinds []string +} + +func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + if len(items) > 0 { + o.callKinds = append(o.callKinds, items[0].ItemKind) + } + return o.MockArtworkQueueRepo.Enqueue(items...) +} + +var _ = Describe("Housekeeping", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + queueRepo *orderTrackingQueueRepo + propRepo *tests.MockedPropertyRepo + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + conf.Server.CoverArtPriority = "embedded, folder" + conf.Server.ArtistArtPriority = "artist.jpg" + conf.Server.Agents = "spotify" + conf.Server.EnableExternalServices = true + + queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + propRepo = &tests.MockedPropertyRepo{} + ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} + }) + + seedEntities := func() { + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}}) + ds.MockedArtist = artistRepo + + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1"}}) + ds.MockedAlbum = albumRepo + + playlistRepo := tests.CreateMockPlaylistRepo() + playlistRepo.SetData(model.Playlists{{ID: "pl1"}}) + ds.MockedPlaylist = playlistRepo + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.All = model.Radios{{ID: "ra1"}} + ds.MockedRadio = radioRepo + } + + Describe("Fingerprint", func() { + It("changes when a fingerprint-affecting config value changes", func() { + f1 := fingerprint() + conf.Server.CoverArtPriority = "folder, embedded" + f2 := fingerprint() + Expect(f1).NotTo(Equal(f2)) + }) + + It("changes when ArtistImageFolder changes", func() { + conf.Server.ArtistImageFolder = "/before" + f1 := fingerprint() + conf.Server.ArtistImageFolder = "/after" + Expect(fingerprint()).NotTo(Equal(f1)) + }) + + It("changes when EnableM3UExternalAlbumArt is toggled", func() { + conf.Server.EnableM3UExternalAlbumArt = false + f1 := fingerprint() + conf.Server.EnableM3UExternalAlbumArt = true + Expect(fingerprint()).NotTo(Equal(f1)) + }) + + It("does not change when the server version changes", func() { + original := consts.Version + DeferCleanup(func() { consts.Version = original }) + f1 := fingerprint() + consts.Version = original + "-next" + Expect(fingerprint()).To(Equal(f1), + "the version must not invalidate artwork state: it would re-resolve every entity on every build") + }) + }) + + Describe("Backfill", func() { + It("enqueues nothing and returns false when the stored fingerprint matches", func() { + seedEntities() + Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed()) + + did, err := backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeFalse()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + It("runs the backfill when no fingerprint was ever stored", func() { + seedEntities() + + did, err := backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio + + stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal(fingerprint())) + }) + + It("enqueues a private playlist by resolving it under an admin context", func() { + ds.MockedUser = adminUserRepo() + vds := &visibilityPlaylistDS{ + MockDataStore: ds, + private: model.Playlist{ID: "plPrivate", OwnerID: "admin"}, + tracks: &tests.MockPlaylistTrackRepo{}, + } + + did, err := backfill(ctx, vds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) + }) + + It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { + seedEntities() + Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + did, err := backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + Expect(queueRepo.callKinds).ToNot(BeEmpty()) + firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) + Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call") + if firstOther >= 0 { + Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"), + "no artist Enqueue may follow another kind") + } + + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill)) + Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) + } + }) + }) + + Describe("EnqueueStaleAbsentAll", func() { + var artRepo *tests.MockArtworkRepo + + BeforeEach(func() { + artRepo = tests.CreateMockArtworkRepo() + ds.MockedArtwork = artRepo + queueRepo.ItemArtworkSource = artRepo + }) + + It("enqueues only absent entries older than the recheck window, across all kinds", func() { + old := time.Now().Add(-48 * time.Hour) + recent := time.Now().Add(-time.Hour) + + artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old} + + err := enqueueStaleAbsentAll(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + Expect(queueRepo.Data).To(HaveLen(4)) + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + } + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) + }) + }) + + Describe("EnqueueMissingAll", func() { + var artRepo *tests.MockArtworkRepo + + BeforeEach(func() { + artRepo = tests.CreateMockArtworkRepo() + ds.MockedArtwork = artRepo + queueRepo.ItemArtworkSource = artRepo + queueRepo.ExistingIDs = map[string]map[string]bool{ + "al": {"al1": true, "al2": true}, + "ar": {"ar1": true}, + "pl": {"pl1": true}, + "ra": {"ra1": true}, + } + }) + + It("enqueues only entities that have no item_artwork row, across all kinds", func() { + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()} + artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()} + + err := enqueueMissingAll(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + } + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil()) + }) + }) +}) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index ac0f63794..39938a755 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -1,30 +1,22 @@ package artwork import ( + "bytes" "context" - "fmt" "io" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" ) -type cacheKey struct { - artID model.ArtworkID - lastUpdate time.Time -} - -func (k *cacheKey) Key() string { - return fmt.Sprintf( - "%s-%s.%d", - k.artID.Kind, - k.artID.ID, - k.lastUpdate.UnixMilli(), - ) +// artworkReader is the cache.Item the image cache loader dispatches on: Reader +// produces the (possibly resized) bytes to store under Key. +type artworkReader interface { + cache.Item + Reader(ctx context.Context) (io.ReadCloser, error) } type imageCache struct { @@ -36,9 +28,45 @@ func GetImageCache() cache.FileCache { return &imageCache{ FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { - r, _, err := arg.(artworkReader).Reader(ctx) - return r, err + return arg.(artworkReader).Reader(ctx) }), } }) } + +// resizedItem is an artworkReader that resizes bytes opened by open() and caches the +// result under a hash-derived key. +type resizedItem struct { + hash string + size int + square bool + ffmpeg ffmpeg.FFmpeg + open func() (io.ReadCloser, error) +} + +// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it +// validates can never drift apart. +func (r *resizedItem) Key() string { + return "h-" + representationTag(r.hash, r.size, r.square) +} + +func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { + orig, err := r.open() + if err != nil { + return nil, err + } + defer orig.Close() + data, err := readCapped(orig) + if err != nil { + return nil, err + } + resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square) + if err != nil || resized == nil { + // Resize failed or image already within bounds: serve the original bytes. + return io.NopCloser(bytes.NewReader(data)), nil + } + if rc, ok := resized.(io.ReadCloser); ok { + return rc, nil + } + return io.NopCloser(resized), nil +} diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go new file mode 100644 index 000000000..5dbe727e4 --- /dev/null +++ b/core/artwork/image_store.go @@ -0,0 +1,155 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/zeebo/xxh3" +) + +// ImageStore is the content-addressed store for artwork images with no library file backing them. +type ImageStore struct { + root string +} + +func NewImageStore(rootDir string) *ImageStore { + return &ImageStore{root: rootDir} +} + +func GetImageStore() *ImageStore { + return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder)) +} + +// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open. +func extForMime(m string) string { + switch m { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + } + return ".img" +} + +func hashImage(r io.Reader) (string, error) { + d := xxh3.New() + if _, err := io.Copy(d, r); err != nil { + return "", err + } + return fmt.Sprintf("%016x", d.Sum64()), nil +} + +// validHash guards path sharding: a malformed hash would slice-panic or inject path separators. +func validHash(hash string) bool { + if len(hash) != 16 { + return false + } + for _, c := range []byte(hash) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func (s *ImageStore) path(hash, mimeType string) string { + return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) +} + +func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } + dst := s.path(hash, mimeType) + if _, err := os.Stat(dst); err == nil { + // A touched mtime marks the file live so a concurrent prune spares it. + now := time.Now() + if err := os.Chtimes(dst, now, now); err == nil { + return nil + } + // touch failed (likely pruned concurrently) — fall through and rewrite it + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), dst) +} + +func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { + if !validHash(hash) { + return nil, fmt.Errorf("imagestore: invalid hash %q", hash) + } + return os.Open(s.path(hash, mimeType)) +} + +// Sweep removes store files not accepted by keep. Files modified after cutoff are always +// kept: their acquisition row may not be committed yet. +func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) { + removed, failed := 0, 0 + var lastErr error + err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if err := ctx.Err(); err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if info.ModTime().After(cutoff) { + return nil + } + name := d.Name() + remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window + if !remove { + ext := filepath.Ext(name) + remove = !keep(strings.TrimSuffix(name, ext), ext) + } + if remove { + // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks + if err := os.Remove(path); err != nil { + // One unremovable file must not strand the rest of the store until the next prune. + failed, lastErr = failed+1, err + return nil //nolint:nilerr // counted and reported in aggregate below + } + removed++ + } + return nil + }) + // Aggregated: a store that has gone read-only would otherwise warn once per file, every prune. + if failed > 0 { + log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr) + } + if errors.Is(err, fs.ErrNotExist) { + return removed, nil + } + return removed, err +} diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go new file mode 100644 index 000000000..f7b2467ca --- /dev/null +++ b/core/artwork/image_store_test.go @@ -0,0 +1,228 @@ +package artwork + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageStore", func() { + var store *ImageStore + var root string + var ctx context.Context + + BeforeEach(func() { + root = GinkgoT().TempDir() + store = NewImageStore(root) + ctx = context.Background() + }) + + It("hashes deterministically", func() { + h1, err := hashImage(bytes.NewReader([]byte("some image bytes"))) + Expect(err).ToNot(HaveOccurred()) + h2, _ := hashImage(bytes.NewReader([]byte("some image bytes"))) + Expect(h1).To(Equal(h2)) + Expect(h1).To(HaveLen(16)) + h3, _ := hashImage(bytes.NewReader([]byte("other bytes"))) + Expect(h3).ToNot(Equal(h1)) + }) + + It("writes sharded and reads back", func() { + data := []byte("jpeg-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + got, _ := io.ReadAll(rc) + Expect(got).To(Equal(data)) + }) + + It("is idempotent on duplicate writes and preserves the original content", func() { + data := []byte("dup") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + // A duplicate write only touches mtime; passing different bytes under the same + // hash proves the second reader is never consumed to overwrite the file. + Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed()) + + rc, err := store.Open(h, "image/png") + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + got, err := io.ReadAll(rc) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(data)) + }) + + It("refreshes the mtime on a duplicate write", func() { + data := []byte("touch-me") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + + info, err := os.Stat(store.path(h, "image/png")) + Expect(err).ToNot(HaveOccurred()) + Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute))) + }) + + It("rewrites the bytes when the existing file vanished before the liveness touch", func() { + data := []byte("vanishing") + h, _ := hashImage(bytes.NewReader(data)) + for range 10 { + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(os.Remove(store.path(h, "image/png"))).To(Succeed()) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + + rc, err := store.Open(h, "image/png") + Expect(err).ToNot(HaveOccurred()) + got, _ := io.ReadAll(rc) + rc.Close() + Expect(got).To(Equal(data)) + } + }) + + It("returns fs.ErrNotExist for missing images", func() { + _, err := store.Open("beefbeefbeefbeef", "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("rejects invalid hashes instead of panicking", func() { + for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} { + Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash"))) + _, err := store.Open(h, "image/jpeg") + Expect(err).To(MatchError(ContainSubstring("invalid hash"))) + } + }) + + It("sweeps unknown files, keeps known ones", func() { + d1 := []byte("keep-me") + h1, _ := hashImage(bytes.NewReader(d1)) + Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed()) + d2 := []byte("orphan") + h2, _ := hashImage(bytes.NewReader(d2)) + Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) + + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed()) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + _, err = store.Open(h2, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h1, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("sweeps a stale mime variant of a known hash, keeps the current one", func() { + data := []byte("same-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + + // The recorded mime is image/jpeg, so the .png variant is obsolete. + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(hash, ext string) bool { + return hash == h && ext == ".jpg" + }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + _, err = store.Open(h, "image/png") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("keeps young unknown files inside the grace window", func() { + d := []byte("fresh-orphan") + h, _ := hashImage(bytes.NewReader(d)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed()) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(0)) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("removes abandoned temp files past the grace window, keeps fresh ones", func() { + oldTmp := filepath.Join(root, ".old.tmp") + Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(oldTmp, old, old)).To(Succeed()) + + freshTmp := filepath.Join(root, ".fresh.tmp") + Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed()) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return true }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + Expect(oldTmp).ToNot(BeAnExistingFile()) + Expect(freshTmp).To(BeAnExistingFile()) + }) + + It("keeps sweeping past a file it cannot remove", func() { + tests.SkipOnWindows("uses Unix file permission bits") + if os.Geteuid() == 0 { + Skip("read-only dir cannot block root (e.g. tests in a container)") + } + old := time.Now().Add(-2 * time.Hour) + // "blocked" sorts before "ok", so the walk hits the unremovable file first. + blockedDir := filepath.Join(root, "blocked") + Expect(os.MkdirAll(blockedDir, 0755)).To(Succeed()) + blocked := filepath.Join(blockedDir, "a.jpg") + Expect(os.WriteFile(blocked, []byte("x"), 0600)).To(Succeed()) + Expect(os.Chtimes(blocked, old, old)).To(Succeed()) + + okDir := filepath.Join(root, "ok") + Expect(os.MkdirAll(okDir, 0755)).To(Succeed()) + reachable := filepath.Join(okDir, "b.jpg") + Expect(os.WriteFile(reachable, []byte("y"), 0600)).To(Succeed()) + Expect(os.Chtimes(reachable, old, old)).To(Succeed()) + + Expect(os.Chmod(blockedDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(blockedDir, 0755) }) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + Expect(blocked).To(BeAnExistingFile()) + Expect(reachable).ToNot(BeAnExistingFile()) + }) + + // Prune holds the worker's write lock for the whole sweep, and shutdown waits on the + // worker, so an uncancellable walk over a large store stalls it until SIGKILL. + It("abandons the walk when the context is cancelled", func() { + old := time.Now().Add(-2 * time.Hour) + for _, name := range []string{"a", "b", "c", "d"} { + p := filepath.Join(root, name+".jpg") + Expect(os.WriteFile(p, []byte("x"), 0600)).To(Succeed()) + Expect(os.Chtimes(p, old, old)).To(Succeed()) + } + cancelCtx, cancel := context.WithCancel(ctx) + cancel() + + _, err := store.Sweep(cancelCtx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) + Expect(err).To(MatchError(context.Canceled)) + matches, _ := filepath.Glob(filepath.Join(root, "*.jpg")) + Expect(matches).To(HaveLen(4), "a cancelled sweep must not keep deleting") + }) +}) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index ff557294e..6e6099325 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -2,7 +2,9 @@ package artwork import ( "context" + "net/url" "path/filepath" + "strings" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model" @@ -40,5 +42,23 @@ func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (librar if err != nil { return libraryView{}, err } - return libraryView{FS: fs, absRoot: lib.Path}, nil + return libraryView{FS: fs, absRoot: localOSRoot(lib.Path)}, nil +} + +// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept: +// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged). +func localOSRoot(libPath string) string { + if !strings.Contains(libPath, "://") { + return libPath + } + u, err := url.Parse(libPath) + if err != nil || u.Scheme != storage.LocalSchemaID { + return libPath + } + // Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching + // core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path. + if filepath.VolumeName(u.Host) != "" { + return filepath.Join(u.Host, u.Path) + } + return u.Path } diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go index acf08fda3..22498e7a1 100644 --- a/core/artwork/library_fs_test.go +++ b/core/artwork/library_fs_test.go @@ -32,6 +32,13 @@ var _ = Describe("loadLibraryView", Ordered, func() { Expect(lib.absRoot).To(Equal("fake:///music")) }) + It("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() { + // file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged. + Expect(localOSRoot("file:///music/library")).To(Equal("/music/library")) + Expect(localOSRoot("/music/library")).To(Equal("/music/library")) + Expect(localOSRoot("fake:///music")).To(Equal("fake:///music")) + }) + It("returns an error when the library does not exist", func() { _, err := loadLibraryView(ctx, ds, 999) Expect(err).To(HaveOccurred()) diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go new file mode 100644 index 000000000..e74b64808 --- /dev/null +++ b/core/artwork/playlist_cover.go @@ -0,0 +1,116 @@ +package artwork + +import ( + "bytes" + "context" + "image" + "image/draw" + "image/png" + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + xdraw "golang.org/x/image/draw" +) + +const tileSize = 600 + +// findPlaylistSidecarPath finds an image beside plsPath with the same base name (case-insensitive). +func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { + if plsPath == "" { + return "" + } + dir := filepath.Dir(plsPath) + base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) + + entries, err := os.ReadDir(dir) + if err != nil { + log.Warn(ctx, "Artwork: Could not read directory for playlist sidecar", "dir", dir, err) + return "" + } + for _, entry := range entries { + name := entry.Name() + nameBase := utils.BaseName(name) + if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { + return filepath.Join(dir, name) + } + } + return "" +} + +func rect(pos int) image.Rectangle { + r := image.Rectangle{} + switch pos { + case 1: + r.Min.X = tileSize / 2 + case 2: + r.Min.Y = tileSize / 2 + case 3: + r.Min.X = tileSize / 2 + r.Min.Y = tileSize / 2 + } + r.Max.X = r.Min.X + tileSize/2 + r.Max.Y = r.Min.Y + tileSize/2 + return r +} + +// fillCenter center-crops src and scales it to fill dstW x dstH exactly. +func fillCenter(src image.Image, dstW, dstH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + srcAspect := float64(srcW) / float64(srcH) + dstAspect := float64(dstW) / float64(dstH) + + var cropRect image.Rectangle + if srcAspect > dstAspect { + cropW := int(float64(srcH) * dstAspect) + cropX := (srcW - cropW) / 2 + cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) + } else { + cropH := int(float64(srcW) / dstAspect) + cropY := (srcH - cropH) / 2 + cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) + } + + dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + return dst +} + +// decodeTile runs before the processor's size guards apply, so it enforces the caps itself. +func decodeTile(r io.ReadCloser) (image.Image, error) { + data, err := readCapped(r) + if err != nil { + return nil, err + } + img, _, err := decodeCapped(data) + if err != nil { + return nil, err + } + return fillCenter(img, tileSize/2, tileSize/2), nil +} + +func assembleTiles(tiles []image.Image) (io.ReadCloser, error) { + buf := new(bytes.Buffer) + var err error + if len(tiles) == 4 { + rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) + draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) + draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) + draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) + draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) + err = png.Encode(buf, rgba) + } else { + err = png.Encode(buf, tiles[0]) + } + if err != nil { + return nil, err + } + return io.NopCloser(buf), nil +} diff --git a/core/artwork/processor.go b/core/artwork/processor.go new file mode 100644 index 000000000..c3cf15b71 --- /dev/null +++ b/core/artwork/processor.go @@ -0,0 +1,311 @@ +package artwork + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "image" + "image/draw" + _ "image/gif" // the only artwork format with no other importer in this package + "io" + "sync" + "time" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/artwork/dominant" + "github.com/navidrome/navidrome/core/artwork/thumbhash" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" +) + +// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it. +type outcome int + +const ( + outcomeFound outcome = iota + // outcomeFoundStale: state was written and is served, but a higher-priority external + // source failed, so the row must retry to give it another chance. + outcomeFoundStale + outcomeAbsent + outcomeFailed +) + +func (o outcome) String() string { + switch o { + case outcomeFound: + return "found" + case outcomeFoundStale: + return "foundStale" + case outcomeAbsent: + return "absent" + default: + return "failed" + } +} + +// thumbnailSize is the max dimension fed to both hash encoders; thumbhash rejects anything larger. +const thumbnailSize = 100 + +// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at +// an arbitrarily large endpoint. +const maxImageBytes = 20 << 20 + +// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that +// image.Decode would expand into gigabytes. +const maxImagePixels = 64 << 20 + +// acquired is what a successful acquire persisted, handed back so the caller can warm the resize +// cache without re-reading the rows and the file it just wrote. +type acquired struct { + ia *model.ItemArtwork + mime string + data []byte +} + +// processor turns one queue item into stored artwork; settling the queue row is the Worker's job. +type processor struct { + ds model.DataStore + store *ImageStore + resolver *resolver + pruneLock sync.Locker +} + +// acquire resolves one queue item end to end: find an image, hash/decode/ +// blurhash it, place its bytes, and persist the resulting state. +func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) { + repo := p.ds.Artwork(ctx) + start := time.Now() + defer func() { + log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID, + "outcome", out, "elapsed", time.Since(start)) + }() + + res, err := p.resolver.resolve(ctx, item) + if err != nil { + log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + if res.reader == nil { + if res.extError || res.localError { + // A fault is not a definitive "no image": never settle absent, keep serving old state. + log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", + "kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError) + return outcomeFailed, nil + } + return writeAbsent(ctx, repo, item), nil + } + defer res.reader.Close() + + readStart := time.Now() + data, err := readCapped(res.reader) + if err != nil { + log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) + return outcomeFailed, nil + } + log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, + "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) + + hashStart := time.Now() + hash, err := hashImage(bytes.NewReader(data)) + if err != nil { + log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, + "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) + + art, err := repo.GetImage(hash) + switch { + case err == nil: + log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, + "id", item.ItemID, "hash", hash) + case errors.Is(err, model.ErrNotFound): + decodeStart := time.Now() + art, err = decodeArtwork(ctx, hash, data) + if err != nil { + log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, + "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) + default: + log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + art.SizeBytes = int64(len(data)) + + ia, err := p.persist(repo, item, art, res, data) + if err != nil { + log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + got = &acquired{ia: ia, mime: art.Mime, data: data} + if res.extError { + log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", + "kind", item.ItemKind, "id", item.ItemID, "source", res.source) + return outcomeFoundStale, got + } + return outcomeFound, got +} + +// persist places the bytes and commits the rows referencing them, excluding Prune for that +// window only so a slow resolution can never hold it off. +func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem, + art *model.Artwork, res resolution, data []byte, +) (*model.ItemArtwork, error) { + if p.pruneLock != nil { + p.pruneLock.Lock() + defer p.pruneLock.Unlock() + } + sourcePath, refMtime, err := placeBytes(p.store, art, res, data) + if err != nil { + return nil, fmt.Errorf("writing image store: %w", err) + } + if err := repo.PutImage(art); err != nil { + return nil, fmt.Errorf("persisting artwork image: %w", err) + } + ia := &model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + Hash: art.Hash, + Source: res.source, + SourcePath: sourcePath, + RefMtime: refMtime, + AttemptedAt: time.Now(), + } + // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. + if err := repo.PutItemArtwork(ia); err != nil { + return nil, fmt.Errorf("persisting item artwork state: %w", err) + } + return ia, nil +} + +// writeAbsent records a known-absent state: every source answered definitively "no". +func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome { + err := repo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + AttemptedAt: time.Now(), + }) + if err != nil { + log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + log.Debug(ctx, "Artwork: Settled absent, every source answered definitively", + "kind", item.ItemKind, "id", item.ItemID) + return outcomeAbsent +} + +func readCapped(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes) + } + return data, nil +} + +// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation. +func decodeCapped(data []byte) (image.Image, string, error) { + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, "", fmt.Errorf("decode image config: %w", err) + } + // Compared by division so the cap cannot be defeated by an int64 overflow. + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height { + return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, "", fmt.Errorf("decode image: %w", err) + } + return img, format, nil +} + +// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two +// placeholder hashes, both encoded from one shared downscaled thumbnail. +func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { + img, format, err := decodeCapped(data) + if err != nil { + return nil, err + } + + thumb := makeThumbnail(img, thumbnailSize) + bh, err := blurhash.Encode(thumb) + if err != nil { + log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err) + bh = "" + } + + var th string + if raw, err := thumbhash.Encode(thumb); err != nil { + log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err) + } else { + th = base64.StdEncoding.EncodeToString(raw) + } + + return &model.Artwork{ + Hash: hash, + Mime: mimeForFormat(format), + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), + BlurHash: bh, + ThumbHash: th, + DominantColor: dominant.Color(thumb), + }, nil +} + +// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales. +func makeThumbnail(img image.Image, maxSize int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxSize && h <= maxSize { + return toFastScaleType(img) + } + scale := float64(maxSize) / float64(max(w, h)) + // NRGBA, not RGBA: thumbhash requires straight alpha, and blurhash reads this type without + // converting, so neither encoder allocates a second copy of the thumbnail. + dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil) + return dst +} + +// isFileBacked reports whether the bytes already live in a library/upload file, so the +// content-addressed store must not duplicate them. +func isFileBacked(source string) bool { + return source == "folder" || source == "upload" +} + +// placeBytes reports the item's backing-file provenance and writes the bytes into the store +// for the sources that have none. +func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { + if isFileBacked(res.source) { + return res.sourcePath, res.refMtime, nil + } + if res.source == "embedded" { + sourcePath, refMtime = res.sourcePath, res.refMtime + } + return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data)) +} + +// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse. +func mimeForFormat(format string) string { + switch format { + case "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "webp": + return "image/webp" + } + return "application/octet-stream" +} diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go new file mode 100644 index 000000000..d326dde09 --- /dev/null +++ b/core/artwork/processor_test.go @@ -0,0 +1,495 @@ +package artwork + +import ( + "context" + "encoding/binary" + "errors" + "hash/crc32" + "image" + "image/color" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// DecodeConfig reads only the header, so the pixel data can be omitted entirely. +func pngHeaderWithDims(w, h uint32) []byte { + ihdr := make([]byte, 13) + binary.BigEndian.PutUint32(ihdr[0:], w) + binary.BigEndian.PutUint32(ihdr[4:], h) + ihdr[8] = 8 // bit depth + ihdr[9] = 2 // color type: truecolor + chunk := append([]byte("IHDR"), ihdr...) + out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} + out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr))) + out = append(out, chunk...) + return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk)) +} + +var _ = Describe("processor.acquire", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + store *ImageStore + artRepo *tests.MockArtworkRepo + repoRoot string + proc *processor + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + artRepo = tests.CreateMockArtworkRepo() + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + } + ds.MockedAlbum = tests.CreateMockAlbumRepo() + store = NewImageStore(GinkgoT().TempDir()) + proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} + + conf.Server.CoverArtPriority = "cover.jpg, embedded" + }) + + It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).ToNot(BeEmpty()) + Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(ia.RefMtime).To(BeNumerically(">", 0)) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + // Every placeholder is derived from the one shared thumbnail, so all three land together. + Expect(art.BlurHash).ToNot(BeEmpty()) + Expect(art.ThumbHash).ToNot(BeEmpty()) + Expect(art.DominantColor).To(MatchRegexp(`^#[0-9a-f]{6}$`)) + _, err = store.Open(ia.Hash, art.Mime) + Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") + }) + + Describe("prune lock scope", func() { + var lock *countingLocker + + BeforeEach(func() { + lock = &countingLocker{} + proc.pruneLock = lock + }) + + It("holds it once across the write window", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) + Expect(out).To(Equal(outcomeFound)) + Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") + Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") + }) + + // Resolution can reach the network, so holding the lock across it would let one slow + // provider block prune, and every drain queued behind prune's writer. + It("never takes it while only resolving", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) + Expect(out).To(Equal(outcomeAbsent)) + Expect(lock.locks).To(BeZero()) + }) + }) + + It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + folderRepo.result = nil + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.BlurHash).ToNot(BeEmpty()) + + rc, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("absent: no local source and no external error persists a known-absent state", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al3", Name: "Album"}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(out).To(Equal(outcomeAbsent)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + Expect(ia.Source).To(BeEmpty()) + Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second)) + }) + + It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() { + conf.Server.CoverArtPriority = "cover.jpg" + // A folder listing that names a cover the FS will not hand over: a stale mount. + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jpg"}}} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent") + }) + + // An upload outranks every source, so an unreadable one must not let a lower one take over. + It("failed-on-unreadable-upload: an upload that will not open never records absent", func() { + if runtime.GOOS == "windows" { + // The file would still open, so the spec would pass on the decode error instead. + Skip("chmod does not restrict read access on Windows") + } + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds.MockedRadio = radioRepo + dir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(dir) + upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg") + Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed()) + Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed()) + Expect(os.Chmod(upload, 0o000)).To(Succeed()) // present, but unreadable + DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) + radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") + }) + + It("failed-on-extError: leaves the item's state untouched", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + Expect(out).To(Equal(outcomeFoundStale)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).ToNot(BeEmpty()) + Expect(ia.Source).To(Equal("folder")) + }) + + It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() { + conf.Server.CoverArtPriority = "external" + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(imgBytes) + })) + DeferCleanup(srv.Close) + + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("external:deezerFake")) + Expect(ia.Hash).ToNot(BeEmpty()) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}}, + {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, + }) + + out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + Expect(out1).To(Equal(outcomeFound)) + ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + + // A re-decode instead of a hash dedup would overwrite this sentinel. + poisoned := artRepo.Data[ia1.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[ia1.Hash] = poisoned + + out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + Expect(out2).To(Equal(outcomeFound)) + ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia2.Hash).To(Equal(ia1.Hash)) + + reused, err := artRepo.GetImage(ia1.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(reused.BlurHash).To(Equal("SENTINEL")) + }) + + It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() { + // Byte-identical files share one hash, but provenance is per item. + libRoot := GinkgoT().TempDir() + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} { + dir := filepath.Join(libRoot, sub) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + img := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed()) + Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed()) + } + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}}, + {ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}}, + }) + + folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} + outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) + Expect(outN).To(Equal(outcomeFound)) + iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(iaA.Source).To(Equal("folder")) + Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) + Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) + + // A re-decode instead of a hash dedup would overwrite this sentinel. + poisoned := artRepo.Data[iaA.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[iaA.Hash] = poisoned + + folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} + outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) + Expect(outN).To(Equal(outcomeFound)) + iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(iaB.Hash).To(Equal(iaA.Hash)) + Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) + Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) + + iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) + Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) + + Expect(artRepo.Data).To(HaveLen(1)) + reused, err := artRepo.GetImage(iaA.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(reused.BlurHash).To(Equal("SENTINEL")) + }) + + It("decode failure on found bytes: fails without writing state", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} + ds.MockedRadio = radioRepo + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("oversized read: a resolved image larger than the cap fails without writing state", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") + f, err := os.Create(imgPath) + Expect(err).ToNot(HaveOccurred()) + Expect(f.Truncate(maxImageBytes + 1)).To(Succeed()) + Expect(f.Close()).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} + ds.MockedRadio = radioRepo + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + // The cap is compared by division so an overflowing product cannot slip past it; no supported + // format can declare these dimensions, so this pins the arithmetic, not a live hole. + DescribeTable("rejects out-of-range declared dimensions", + func(w, h uint32) { + _, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h)) + Expect(err).To(HaveOccurred()) + }, + Entry("both dimensions at the 32-bit maximum", uint32(0xffffffff), uint32(0xffffffff)), + Entry("both dimensions at the signed 32-bit maximum", uint32(0x7fffffff), uint32(0x7fffffff)), + Entry("zero width", uint32(0), uint32(100)), + Entry("zero height", uint32(100), uint32(0)), + ) + + It("decompression bomb: rejects huge declared dimensions before the full decode", func() { + data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap + _, err := decodeArtwork(ctx, "bomb", data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("dimensions")) + }) + + It("store write failure: fails without writing state", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + folderRepo.result = nil + + // A store root that is a plain file makes every MkdirAll under it fail. + blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir") + Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) + proc.store = NewImageStore(blockedRoot) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) + +type countingLocker struct { + locks int + unlocks int +} + +func (l *countingLocker) Lock() { l.locks++ } +func (l *countingLocker) Unlock() { l.unlocks++ } +func (l *countingLocker) held() bool { return l.locks != l.unlocks } + +var _ = Describe("makeThumbnail", func() { + // Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is + // what keeps either of them from allocating a converted copy of the thumbnail. + It("emits NRGBA when it downscales", func() { + src := image.NewRGBA(image.Rect(0, 0, 400, 300)) + Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{})) + }) + + It("fits the longest side to maxSize and keeps the aspect ratio", func() { + src := image.NewRGBA(image.Rect(0, 0, 400, 300)) + b := makeThumbnail(src, 100).Bounds() + Expect(b.Dx()).To(Equal(100)) + Expect(b.Dy()).To(Equal(75)) + }) + + It("never upscales an image already within bounds", func() { + src := image.NewRGBA(image.Rect(0, 0, 40, 30)) + Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds())) + }) + + // A fully transparent pixel's colour cannot survive any resample, since the scaler works in + // premultiplied space and multiplying by zero is not invertible. Partial alpha is the case + // that distinguishes straight storage from premultiplied. + It("keeps partly transparent colour straight rather than premultiplied", func() { + src := image.NewNRGBA(image.Rect(0, 0, 400, 400)) + for y := range 400 { + for x := range 400 { + src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128}) + } + } + thumb, ok := makeThumbnail(src, 100).(*image.NRGBA) + Expect(ok).To(BeTrue()) + // Premultiplied storage would have halved this to ~100. + Expect(thumb.Pix[0]).To(BeNumerically("==", 200)) + Expect(thumb.Pix[3]).To(BeNumerically("==", 128)) + }) +}) diff --git a/core/artwork/prune.go b/core/artwork/prune.go new file mode 100644 index 000000000..c79458b8f --- /dev/null +++ b/core/artwork/prune.go @@ -0,0 +1,63 @@ +package artwork + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// pruneMinAge guards the window between artwork insert and item_artwork upsert. +const pruneMinAge = time.Hour + +func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { + start := time.Now() + defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }() + repo := ds.Artwork(ctx) + + purged, err := repo.PurgeDanglingItems() + if err != nil { + return err + } + if purged > 0 { + log.Info(ctx, "Artwork: Purged dangling item state", "count", purged) + } + + // Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed). + queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling() + if err != nil { + return err + } + if queuePurged > 0 { + log.Info(ctx, "Artwork: Purged dangling queue rows", "count", queuePurged) + } + + // Files younger than the grace window may belong to acquisitions whose rows aren't committed yet. + cutoff := time.Now().Add(-pruneMinAge) + orphans, err := repo.PurgeOrphans(cutoff) + if err != nil { + return err + } + if orphans > 0 { + log.Info(ctx, "Artwork: Removed orphan image rows", "count", orphans) + } + + // Read after the delete, so the sweep below reclaims the files of the rows just removed. + mimes, err := repo.GetMimeByHash() + if err != nil { + return err + } + removed, err := store.Sweep(ctx, cutoff, func(hash, ext string) bool { + // A known hash under a stale extension is a superseded mime variant — reclaim it. + m, ok := mimes[hash] + return ok && ext == extForMime(m) + }) + if err != nil { + return err + } + if removed > 0 { + log.Info(ctx, "Artwork: Swept stray files", "count", removed) + } + return nil +} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go new file mode 100644 index 000000000..8b8504f89 --- /dev/null +++ b/core/artwork/prune_test.go @@ -0,0 +1,239 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type flakyGetArtworkRepo struct { + *tests.MockArtworkRepo +} + +func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) { + return nil, errors.New("db locked") +} + +var _ = Describe("Prune", func() { + var ds *tests.MockDataStore + var store *ImageStore + var awRepo *tests.MockArtworkRepo + + BeforeEach(func() { + ds = &tests.MockDataStore{} + awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo) + store = NewImageStore(GinkgoT().TempDir()) + }) + + // PutImage refreshes created_at like the SQL repo, so fixtures are aged directly. + ageArtwork := func(h string, t time.Time) { + a := awRepo.Data[h] + a.CreatedAt = t + awRepo.Data[h] = a + } + + It("purges dangling item_artwork state for gone entities, summed across kinds", func() { + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + awRepo.ExistingIDs = map[string]map[string]bool{ + "al": {}, + "ar": {"live-artist": true}, + } + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + }) + + It("purges dangling artwork_queue rows for gone entities", func() { + queueRepo := tests.CreateMockArtworkQueueRepo() + Expect(queueRepo.Enqueue( + model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary}, + model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary}, + )).To(Succeed()) + queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}} + ds.MockedArtworkQueue = queueRepo + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil()) + Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil()) + }) + + It("deletes orphan rows and their store files, keeps referenced ones", func() { + data := []byte("orphan-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, old) + + kept := []byte("kept-bytes") + hk, _ := hashImage(bytes.NewReader(kept)) + Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(h, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(hk, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("spares an aged row that item_artwork state still references", func() { + data := []byte("reacquired-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, time.Now().Add(-2*time.Hour)) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("spares an unreferenced row recreated inside the grace window", func() { + data := []byte("fresh-reacquired-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + // Reacquisition refreshed created_at, so the row is unreferenced but too young to drop. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("spares an orphan file freshly touched by an overlapping acquisition", func() { + data := []byte("racing-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, time.Now().Add(-2*time.Hour)) + // The row is orphaned, but a concurrent acquisition just touched the file's mtime. + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("sweeps store files that have no artwork row", func() { + stray := []byte("no-row-bytes") + h, _ := hashImage(bytes.NewReader(stray)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := store.Open(h, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("sweeps an obsolete mime variant of a reacquired hash", func() { + data := []byte("variant-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + // The row records the current mime; the .png file is a superseded variant. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := store.Open(h, "image/png") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("reclaims the other orphan files when one of them cannot be removed", func() { + tests.SkipOnWindows("uses Unix file permission bits") + if os.Geteuid() == 0 { + Skip("read-only dir cannot block root (e.g. tests in a container)") + } + old := time.Now().Add(-2 * time.Hour) + + blocked := []byte("blocked-bytes") + hb, _ := hashImage(bytes.NewReader(blocked)) + Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed()) + Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(hb, old) + + good := []byte("good-bytes") + hg, _ := hashImage(bytes.NewReader(good)) + Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed()) + Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(hg, old) + + // A read-only shard directory makes os.Remove fail (EACCES) for hb's file only. + shardDir := filepath.Dir(store.path(hb, "image/jpeg")) + Expect(shardDir).ToNot(Equal(filepath.Dir(store.path(hg, "image/jpeg"))), "fixtures must land in different shards") + Expect(os.Chmod(shardDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) }) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(hg) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(hg, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + + // The row purge does not depend on file removal, so only the file survives. + _, err = awRepo.GetImage(hb) + Expect(err).To(MatchError(model.ErrNotFound)) + rc, err := store.Open(hb, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("never sweeps files on a transient DB error", func() { + ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} + + data := []byte("live-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).ToNot(Succeed()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) +}) diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go deleted file mode 100644 index c95ddedaa..000000000 --- a/core/artwork/reader_album_test.go +++ /dev/null @@ -1,481 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Album Artwork Reader", func() { - Describe("loadAlbumFoldersPaths", func() { - var ( - ctx context.Context - ds *fakeDataStore - repo *fakeFolderRepo - album model.Album - now time.Time - expectedAt time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - now = time.Now().Truncate(time.Second) - expectedAt = now.Add(5 * time.Minute) - - // Set up the test folders with image files - repo = &fakeFolderRepo{} - ds = &fakeDataStore{ - folderRepo: repo, - } - album = model.Album{ - ID: "album1", - Name: "Album", - FolderIDs: []string{"folder1", "folder2", "folder3"}, - } - }) - - It("returns sorted image files", func() { - repo.result = []model.Folder{ - { - Path: "Artist/Album/Disc1", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"}, - }, - { - Path: "Artist/Album/Disc2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - Path: "Artist/Album/Disc10", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - - // Check that image files are sorted by base name (without extension) - Expect(imgFiles).To(HaveLen(5)) - - // Files should be sorted by base filename without extension, then by full path - // "back" < "cover", so back.jpg comes first - // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) - Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) - Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) - }) - - It("prioritizes files without numeric suffixes", func() { - // Test case for issue #4683: cover.jpg should come before cover.1.jpg - repo.result = []model.Folder{ - { - Path: "Artist/Album", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(3)) - - // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) - }) - - It("handles case-insensitive sorting", func() { - // Test that Cover.jpg and cover.jpg are treated as equivalent - repo.result = []model.Folder{ - { - Path: "Artist/Album", - ImagesUpdatedAt: now, - ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(3)) - - // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) - }) - - It("includes images from parent folder for multi-disc albums", func() { - // Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/ - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "parentFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) - }) - - It("does not query parent when parent ID is already in album folders", func() { - // When the parent folder is already one of the album's folders, skip it - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "folder2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "", - Name: "Artist", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - // Get should not have been called (parent already in folder set) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("does not query parent when folders have different parents", func() { - // When album folders span different parents, don't search any parent - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist1/Album", - Name: "part1", - ParentID: "parentA", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist2/Album", - Name: "part2", - ParentID: "parentB", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) - // Get should not have been called (different parents) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("does not include library root parent for multi-folder albums", func() { - // Two album parts directly under the library root — parent is the root itself - repo.result = []model.Folder{ - { - ID: "folder1", - Path: ".", - Name: "AlbumPart1", - ParentID: "rootFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: ".", - Name: "AlbumPart2", - ParentID: "rootFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "rootFolder", - Path: "", - Name: ".", - ParentID: "", - ImageFiles: []string{"unrelated.jpg"}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("includes top-level album folder for multi-disc albums", func() { - // Album folder directly under library root, with disc subfolders - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Album", - Name: "Disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"folder.jpg"}, - }, - { - ID: "folder2", - Path: "Album", - Name: "Disc2", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"folder.jpg"}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: ".", - Name: "Album", - ParentID: "rootFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(3)) - Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) - Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) - Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("does not query parent for single-folder albums that already have images", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("includes parent images for single-disc-subfolder albums", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("promotes the album root parent into the returned paths", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ParentID: "libraryRoot", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"folder.jpg"}, - } - - paths, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(paths).To(Equal([]string{filepath.Join("Artist", "Album"), "Artist"})) - }) - - It("does not include parent images when other albums' audio lives under the parent", func() { - // Simulates: Artist/folder.jpg with Artist/Album (no images) and - // another album's tracks elsewhere under the artist folder - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ParentID: "libraryRoot", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"folder.jpg"}, - } - repo.hasOtherAudio = true - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(BeEmpty()) - }) - - It("propagates errors from the album-root check", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - repo.otherAudioErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - }) - - It("propagates non-ErrNotFound errors from parent folder lookup", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.getErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("continues gracefully when parent folder is not found", func() { - // Parent folder may have been deleted; should log a warning and continue - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "missingParent", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "missingParent", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - // parentResult is nil, so Get will return ErrNotFound - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - }) -}) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go deleted file mode 100644 index fe5bcb196..000000000 --- a/core/artwork/reader_artist.go +++ /dev/null @@ -1,359 +0,0 @@ -package artwork - -import ( - "context" - "crypto/md5" - "fmt" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" -) - -const ( - // maxArtistFolderTraversalDepth defines how many directory levels to search - // when looking for artist images (artist folder + parent directories) - maxArtistFolderTraversalDepth = 3 -) - -type artistReader struct { - cacheKey - a *artwork - provider external.Provider - artist model.Artist - artistFolder string - imgFiles []string - imgFolderImgPath string // cached path from ArtistImageFolder lookup - lib libraryView -} - -func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { - ar, err := artwork.ds.Artist(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - // Only consider albums where the artist is the sole album artist. - als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"album_artist_id": artID.ID}, - squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, - }, - }) - if err != nil { - return nil, err - } - albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als) - if err != nil { - return nil, err - } - artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths) - if err != nil { - return nil, err - } - var lib libraryView - if len(als) > 0 { - lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) - if err != nil { - return nil, err - } - } - a := &artistReader{ - a: artwork, - provider: provider, - artist: *ar, - artistFolder: artistFolder, - imgFiles: imgFiles, - lib: lib, - } - // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can - // change _after_ retrieving from external sources, making the key invalid - //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt - - a.cacheKey.lastUpdate = *imagesUpdatedAt - if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = *ar.UpdatedAt - } - if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = artistFolderLastUpdate - } - if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { - a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) - if a.imgFolderImgPath != "" { - if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = info.ModTime() - } - } - } - a.cacheKey.artID = artID - return a, nil -} - -func (a *artistReader) Key() string { - hash := md5.Sum([]byte(conf.Server.Agents)) - return fmt.Sprintf( - "%s.%t.%x", - a.cacheKey.Key(), - conf.Server.EnableExternalServices, - hash, - ) -} - -func (a *artistReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - ff := []sourceFunc{a.fromArtistUploadedImage()} - ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) - return selectImageReader(ctx, a.artID, ff...) -} - -func (a *artistReader) fromArtistUploadedImage() sourceFunc { - return fromLocalFile(a.artist.UploadedImagePath()) -} - -func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { - var ff []sourceFunc - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - switch { - case pattern == "external": - ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) - case pattern == "image-folder": - ff = append(ff, a.fromArtistImageFolder(ctx)) - case strings.HasPrefix(pattern, "album/"): - if a.lib.FS != nil { - ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) - } - default: - ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) - } - } - return ff -} - -// fromArtistFolder walks up from artistFolder toward libPath looking for a -// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth -// and the library root: once we reach libPath (or if artistFolder is outside -// libPath), the walk stops. All reads go through libFS, which keeps artwork -// resolution scoped to the configured library. -func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if libFS == nil { - return nil, "", fmt.Errorf("artist folder lookup unavailable") - } - rel, err := filepath.Rel(libPath, artistFolder) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) - } - // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may - // return backslash separators on Windows. - rel = filepath.ToSlash(rel) - current := artistFolder - for range maxArtistFolderTraversalDepth { - reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) - if err == nil { - return reader, hit, nil - } - if rel == "." { - break // reached library root; don't traverse above it - } - rel = path.Dir(rel) - current = filepath.Dir(current) - } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) - } -} - -// findImageInFolder globs libFS at relFolder for pattern and returns the first -// matching image. absFolder is used only for the returned display path and log -// messages so callers see absolute-looking paths consistent with the rest of -// the artwork pipeline. -func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) - globPattern := pattern - if relFolder != "." { - globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) - } - matches, err := fs.Glob(libFS, globPattern) - if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) - return nil, "", err - } - - // Filter to valid image files - var imagePaths []string - for _, m := range matches { - if !model.IsImageFile(m) { - continue - } - imagePaths = append(imagePaths, m) - } - - // Sort image files by prioritizing base filenames without numeric - // suffixes (e.g., artist.jpg before artist.1.jpg) - slices.SortFunc(imagePaths, compareImageFiles) - - for _, p := range imagePaths { - f, err := libFS.Open(p) - if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", p, err) - continue - } - _, name := path.Split(p) - return f, filepath.Join(absFolder, name), nil - } - - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) -} - -func escapeGlobLiteral(s string) string { - var b strings.Builder - b.Grow(len(s)) - for _, r := range s { - switch r { - case '\\', '*', '?', '[', ']': - b.WriteByte('\\') - } - b.WriteRune(r) - } - return b.String() -} - -// loadArtistAlbumRoots returns one path per album — the deepest folder holding -// all of that album's tracks — so an album split into disc subfolders can't -// pull the artist folder's common prefix below the artist level. -func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { - var folderIDs []string - for _, album := range albums { - folderIDs = append(folderIDs, album.FolderIDs...) - } - folders, err := loadFolders(ctx, ds, folderIDs) - if err != nil { - return nil, nil, nil, err - } - - pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { - return f.ID, f.AbsolutePath() - }) - var roots []string - for _, album := range albums { - var albumPaths []string - for _, fid := range album.FolderIDs { - if p, ok := pathByID[fid]; ok { - albumPaths = append(albumPaths, p) - } - } - if len(albumPaths) > 0 { - roots = append(roots, commonDir(albumPaths)) - } - } - - imgFiles, updatedAt := folderImages(folders) - return roots, imgFiles, &updatedAt, nil -} - -// commonDir returns the deepest directory containing all paths. Trailing -// separators keep the comparison on segment boundaries, so a shared name -// fragment (".../Album" and ".../Album2") is never read as a shared directory. -func commonDir(paths []string) string { - sep := string(filepath.Separator) - common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) - if !strings.HasSuffix(common, sep) { - common, _ = filepath.Split(common) - } - return filepath.Clean(common) -} - -func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { - if len(albums) == 0 { - return "", time.Time{}, nil - } - libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries - - // paths holds one root per album: two or more distinct roots already meet at - // the artist folder, while a single root is an album folder needing a climb. - roots := slices.Compact(slices.Sorted(slices.Values(paths))) - folderPath := commonDir(roots) - if len(roots) < 2 { - folderPath = filepath.Dir(folderPath) - } - - // Manipulate the path to get the folder ID - // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM - libPath := core.AbsolutePath(ctx, ds, libID, "") - folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) - - log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, - "libPath", libPath, "libID", libID, "albumPaths", paths) - - // Get the last update time for the folder - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) - if err != nil || len(folders) == 0 { - log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID, - "libPath", libPath, "libID", libID, err) - return "", time.Time{}, err - } - return folderPath, folders[0].ImagesUpdatedAt, nil -} - -func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - folder := conf.Server.ArtistImageFolder - if folder == "" { - return nil, "", nil - } - // Use cached path from newArtistArtworkReader if available, - // avoiding a second directory scan. - path := a.imgFolderImgPath - if path == "" { - path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) - } - if path == "" { - return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - -// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name -// (case-insensitive). Returns the full path, or empty string if not found. -func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { - entries, err := os.ReadDir(folder) - if err != nil { - return "" - } - for _, candidate := range []string{mbzArtistID, artistName} { - if candidate == "" { - continue - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - base := strings.TrimSuffix(name, filepath.Ext(name)) - if strings.EqualFold(base, candidate) && model.IsImageFile(name) { - return filepath.Join(folder, name) - } - } - } - return "" -} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go deleted file mode 100644 index c75f8447e..000000000 --- a/core/artwork/reader_artist_test.go +++ /dev/null @@ -1,761 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("artistArtworkReader", func() { - var _ = Describe("loadArtistFolder", func() { - var ( - ctx context.Context - fds *fakeDataStore - repo *fakeFolderRepo - albums model.Albums - paths []string - now time.Time - expectedUpdTime time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(stubCoreAbsolutePath()) - - now = time.Now().Truncate(time.Second) - expectedUpdTime = now.Add(5 * time.Minute) - repo = &fakeFolderRepo{ - result: []model.Folder{ - { - ImagesUpdatedAt: expectedUpdTime, - }, - }, - err: nil, - } - fds = &fakeDataStore{ - folderRepo: repo, - } - albums = model.Albums{ - {LibraryID: 1, ID: "album1", Name: "Album 1"}, - } - }) - - When("no albums provided", func() { - It("returns empty and zero time", func() { - folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"}) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - }) - - When("artist has only one album", func() { - It("returns the parent folder", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("the artist have multiple albums", func() { - It("returns the common prefix for the albums paths", func() { - paths = []string{ - filepath.FromSlash("/music/library/artist/one"), - filepath.FromSlash("/music/library/artist/two"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("two albums share the same folder", func() { - It("climbs above the shared album folder", func() { - paths = []string{ - filepath.FromSlash("/music/artist/split"), - filepath.FromSlash("/music/artist/split"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("the album paths contain same prefix", func() { - It("returns the common prefix", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("ds.Folder().GetAll returns an error", func() { - It("returns an error", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - } - repo.err = errors.New("fake error") - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).To(MatchError(ContainSubstring("fake error"))) - // Folder and time are empty on error. - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - }) - }) - - var _ = Describe("fromArtistFolder", func() { - var ( - ctx context.Context - tempDir string - libFS fs.FS - testFunc sourceFunc - ) - - BeforeEach(func() { - ctx = context.Background() - tempDir = GinkgoT().TempDir() - libFS = os.DirFS(tempDir) - }) - - When("artist folder contains matching image", func() { - BeforeEach(func() { - // Create test structure: /temp/artist/artist.jpg - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds and returns the image", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - - // Verify we can read the content - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("fake image data")) - reader.Close() - }) - }) - - When("artist folder name contains glob metacharacters", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "Artist [Live]") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("treats the folder path literally when globbing through the library fs", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("bracketed artist image")) - reader.Close() - }) - }) - - When("artist folder is empty but parent contains image", func() { - BeforeEach(func() { - // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ - parentDir := filepath.Join(tempDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - albumDir := filepath.Join(artistDir, "album") - Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) - - // Put artist image in parent directory - artistImagePath := filepath.Join(parentDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds image in parent directory", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("parent image")) - reader.Close() - }) - }) - - When("image is two levels up", func() { - BeforeEach(func() { - // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/ - grandparentDir := filepath.Join(tempDir, "grandparent") - parentDir := filepath.Join(grandparentDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Put artist image in grandparent directory - artistImagePath := filepath.Join(grandparentDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds image in grandparent directory", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("grandparent image")) - reader.Close() - }) - }) - - When("images exist at multiple levels", func() { - BeforeEach(func() { - // Create test structure with images at multiple levels - grandparentDir := filepath.Join(tempDir, "grandparent") - parentDir := filepath.Join(grandparentDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Put artist images at all levels - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("prioritizes the closest (artist folder) image", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist level")) - reader.Close() - }) - }) - - When("pattern matches multiple files", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create multiple matching files - Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns the first valid image file in sorted order", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - - // Should return an image file, - // Files are sorted: jpg comes before png alphabetically. - // .abc comes first, but it's not an image. - Expect(path).To(ContainSubstring("artist.jpg")) - reader.Close() - }) - }) - - When("prioritizing files without numeric suffixes", func() { - BeforeEach(func() { - // Test case for issue #4683: artist.jpg should come before artist.1.jpg - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create multiple matches with and without numeric suffixes - Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - - // Verify it's the main file, not a numbered variant - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist main")) - reader.Close() - }) - }) - - When("handling case-insensitive sorting", func() { - BeforeEach(func() { - // Test case to ensure case-insensitive natural sorting - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create files with mixed case names - Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") - }) - - It("sorts case-insensitively", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - - // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder") - Expect(path).To(ContainSubstring("artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist")) - reader.Close() - }) - }) - - When("no matching files exist anywhere", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create non-matching files - Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns an error", func() { - reader, path, err := testFunc() - Expect(err).To(HaveOccurred()) - Expect(reader).To(BeNil()) - Expect(path).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'")) - Expect(err.Error()).To(ContainSubstring("parent directories")) - }) - }) - - When("directory traversal reaches filesystem root", func() { - BeforeEach(func() { - // Start from a shallow directory to test root boundary - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("handles root boundary gracefully", func() { - reader, path, err := testFunc() - Expect(err).To(HaveOccurred()) - Expect(reader).To(BeNil()) - Expect(path).To(BeEmpty()) - // Should not panic or cause infinite loop - }) - }) - - When("file exists but cannot be opened", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create a file that cannot be opened (permission denied) - restrictedFile := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("logs warning and continues searching", func() { - // This test depends on the ability to restrict file permissions - // For now, we'll just ensure it doesn't panic and returns appropriate error - reader, _, err := testFunc() - // The file should be readable in test environment, so this will succeed - // In a real scenario with permission issues, it would continue searching - if err == nil { - Expect(reader).ToNot(BeNil()) - reader.Close() - } - }) - }) - - When("single album artist scenario (original issue)", func() { - BeforeEach(func() { - // Simulate the exact folder structure from the issue: - // /music/artist/album1/ (single album) - // /music/artist/artist.jpg (artist image that should be found) - artistDir := filepath.Join(tempDir, "music", "artist") - albumDir := filepath.Join(artistDir, "album1") - Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) - - // Create artist.jpg in the artist folder (this was not being found before) - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) - - // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds artist.jpg in artist folder for single album artist", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - Expect(path).To(ContainSubstring("artist")) - - // Verify the content - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("single album artist image")) - reader.Close() - }) - }) - }) - - Describe("fromArtistUploadedImage", func() { - var ( - tempDir string - reader *artistReader - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tempDir) - - // Create the artwork/artist directory - Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) - - reader = &artistReader{} - }) - - When("artist has an uploaded image", func() { - It("returns the uploaded image", func() { - imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) - - reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} - sf := reader.fromArtistUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("uploaded artist image")) - r.Close() - }) - }) - - When("artist has no uploaded image", func() { - It("returns nil reader (falls through)", func() { - reader.artist = model.Artist{ID: "ar-1"} - sf := reader.fromArtistUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - }) - - Describe("fromArtistImageFolder", func() { - var ( - ctx context.Context - tempDir string - ar *artistReader - ) - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - ar = &artistReader{} - }) - - When("ArtistImageFolder is not configured", func() { - It("returns nil (skips)", func() { - conf.Server.ArtistImageFolder = "" - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - - When("image exists matching MBID", func() { - It("finds the image by MBID", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - imgPath := filepath.Join(tempDir, mbid+".jpg") - Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("mbid image")) - r.Close() - }) - }) - - When("MBID match is case-insensitive", func() { - It("finds the image regardless of case", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" - imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") - Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("no MBID file exists but artist name file does", func() { - It("falls back to artist name match", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "Test Artist.jpg") - Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("name image")) - r.Close() - }) - }) - - When("artist name match is case-insensitive", func() { - It("matches regardless of case", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "test artist.jpg") - Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("both MBID and name files exist", func() { - It("prefers MBID over name match", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - mbidPath := filepath.Join(tempDir, mbid+".jpg") - namePath := filepath.Join(tempDir, "Test Artist.jpg") - Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) - Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(mbidPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("mbid image")) - r.Close() - }) - }) - - When("no matching image found", func() { - It("returns an error", func() { - conf.Server.ArtistImageFolder = tempDir - // Create an unrelated file - Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, _, err := sf() - Expect(err).To(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(err.Error()).To(ContainSubstring("no image found")) - }) - }) - - When("cached imgFolderImgPath is set", func() { - It("uses cached path instead of scanning", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "cached.jpg") - Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - ar.imgFolderImgPath = imgPath - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("cached image")) - r.Close() - }) - }) - }) - - Describe("findImageInArtistFolder", func() { - var tempDir string - - BeforeEach(func() { - tempDir = GinkgoT().TempDir() - }) - - When("matching file exists by MBID", func() { - It("returns the file path", func() { - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - imgPath := filepath.Join(tempDir, mbid+".jpg") - Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) - - path := findImageInArtistFolder(tempDir, mbid, "Test") - Expect(path).To(Equal(imgPath)) - }) - }) - - When("matching file exists by name", func() { - It("returns the file path", func() { - imgPath := filepath.Join(tempDir, "Test Artist.png") - Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) - - path := findImageInArtistFolder(tempDir, "", "Test Artist") - Expect(path).To(Equal(imgPath)) - }) - }) - - When("no matching file exists", func() { - It("returns empty string", func() { - path := findImageInArtistFolder(tempDir, "", "Unknown Artist") - Expect(path).To(BeEmpty()) - }) - }) - - When("folder does not exist", func() { - It("returns empty string", func() { - path := findImageInArtistFolder("/nonexistent/path", "", "Test") - Expect(path).To(BeEmpty()) - }) - }) - }) -}) - -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder - parentResult *model.Folder - getErr error - getCallCount int - err error - // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root - // check). False means the parent qualifies as an album root. - hasOtherAudio bool - otherAudioErr error -} - -func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { - return f.result, f.err -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { - return f.hasOtherAudio, f.otherAudioErr -} - -func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { - f.getCallCount++ - if f.getErr != nil { - return nil, f.getErr - } - if f.parentResult != nil { - return f.parentResult, nil - } - return nil, model.ErrNotFound -} - -type fakeDataStore struct { - model.DataStore - folderRepo *fakeFolderRepo -} - -func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository { - return fds.folderRepo -} - -func stubCoreAbsolutePath() func() { - // Override core.AbsolutePath to return a fixed string during tests. - original := core.AbsolutePath - core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string { - return filepath.FromSlash("/music") - } - return func() { - core.AbsolutePath = original - } -} diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go deleted file mode 100644 index eac3c5e70..000000000 --- a/core/artwork/reader_mediafile.go +++ /dev/null @@ -1,82 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" -) - -type mediafileArtworkReader struct { - cacheKey - a *artwork - mediafile model.MediaFile - album model.Album - lib libraryView -} - -func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { - mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - al, err := artwork.ds.Album(ctx).Get(mf.AlbumID) - if err != nil { - return nil, err - } - _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) - if err != nil { - return nil, err - } - lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) - if err != nil { - return nil, err - } - a := &mediafileArtworkReader{ - a: artwork, - mediafile: *mf, - album: *al, - lib: lib, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = mf.UpdatedAt - if al.UpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = al.UpdatedAt - } - if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = *imagesUpdatedAt - } - return a, nil -} - -func (a *mediafileArtworkReader) Key() string { - return fmt.Sprintf( - "%s.%t", - a.cacheKey.Key(), - conf.Server.EnableMediaFileCoverArt, - ) -} -func (a *mediafileArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff []sourceFunc - if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - ff = []sourceFunc{ - fromTag(ctx, a.lib.FS, a.mediafile.Path), - fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), - } - } - // For multi-disc albums, fall back to disc artwork first; for single-disc albums, - // skip disc resolution (it would just fall through to album art anyway). - if len(a.album.Discs) > 1 { - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) - } else { - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) - } - return selectImageReader(ctx, a.artID, ff...) -} diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go deleted file mode 100644 index 09707843d..000000000 --- a/core/artwork/reader_playlist.go +++ /dev/null @@ -1,269 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "image" - "image/draw" - "image/png" - "io" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - xdraw "golang.org/x/image/draw" -) - -type playlistArtworkReader struct { - cacheKey - a *artwork - pl model.Playlist -} - -const tileSize = 600 - -func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) { - pl, err := artwork.ds.Playlist(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - a := &playlistArtworkReader{ - a: artwork, - pl: *pl, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = pl.UpdatedAt - - // Check sidecar and ExternalImageURL local file ModTimes for cache invalidation. - // If either is newer than the playlist's UpdatedAt, use that instead so the - // cache is busted when a user replaces a sidecar image or local file reference. - for _, path := range []string{ - findPlaylistSidecarPath(ctx, pl.Path), - pl.ExternalImageURL, - } { - if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - continue - } - if info, err := os.Stat(path); err == nil { - if info.ModTime().After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = info.ModTime() - } - } - } - - return a, nil -} - -func (a *playlistArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - return selectImageReader(ctx, a.artID, - a.fromPlaylistUploadedImage(), - a.fromPlaylistSidecar(ctx), - a.fromPlaylistExternalImage(ctx), - a.fromGeneratedTiledCover(ctx), - fromAlbumPlaceholder(), - ) -} - -func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc { - return fromLocalFile(a.pl.UploadedImagePath()) -} - -func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc { - return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path)) -} - -func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - imgURL := a.pl.ExternalImageURL - if imgURL == "" { - return nil, "", nil - } - parsed, err := url.Parse(imgURL) - if err != nil { - return nil, "", err - } - if parsed.Scheme == "http" || parsed.Scheme == "https" { - if !conf.Server.EnableM3UExternalAlbumArt { - return nil, "", nil - } - return fromURL(ctx, parsed) - } - return fromLocalFile(imgURL)() - } -} - -// fromLocalFile returns a sourceFunc that opens the given local path. -// Returns (nil, "", nil) if path is empty — signalling "not found, try next source". -func fromLocalFile(path string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - -// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar -// image file with the same base name (case-insensitive). Returns empty string if -// no matching image is found or if plsPath is empty. -func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { - if plsPath == "" { - return "" - } - dir := filepath.Dir(plsPath) - base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) - - entries, err := os.ReadDir(dir) - if err != nil { - log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err) - return "" - } - for _, entry := range entries { - name := entry.Name() - nameBase := strings.TrimSuffix(name, filepath.Ext(name)) - if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { - return filepath.Join(dir, name) - } - } - return "" -} - -func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - tiles, err := a.loadTiles(ctx) - if err != nil { - return nil, "", err - } - r, err := a.createTiledImage(ctx, tiles) - return r, "", err - } -} - -func toAlbumArtworkIDs(albumIDs []string) []model.ArtworkID { - return slice.Map(albumIDs, func(id string) model.ArtworkID { - al := model.Album{ID: id} - return al.CoverArtID() - }) -} - -func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) { - tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false) - albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) - if err != nil { - log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err) - return nil, err - } - ids := toAlbumArtworkIDs(albumIds) - - var tiles []image.Image - for _, id := range ids { - r, _, err := fromAlbum(ctx, a.a, id)() - if err == nil { - tile, err := a.createTile(ctx, r) - if err == nil { - tiles = append(tiles, tile) - } - _ = r.Close() - } - if len(tiles) == 4 { - break - } - } - switch len(tiles) { - case 0: - return nil, errors.New("could not find any eligible cover") - case 2: - tiles = append(tiles, tiles[1], tiles[0]) - case 3: - tiles = append(tiles, tiles[0]) - } - return tiles, nil -} - -func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) { - img, _, err := image.Decode(r) - if err != nil { - return nil, err - } - return fillCenter(img, tileSize/2, tileSize/2), nil -} - -func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { - buf := new(bytes.Buffer) - var rgba draw.Image - var err error - if len(tiles) == 4 { - rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) - draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) - draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) - draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) - draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) - err = png.Encode(buf, rgba) - } else { - err = png.Encode(buf, tiles[0]) - } - if err != nil { - return nil, err - } - return io.NopCloser(buf), nil -} - -func rect(pos int) image.Rectangle { - r := image.Rectangle{} - switch pos { - case 1: - r.Min.X = tileSize / 2 - case 2: - r.Min.Y = tileSize / 2 - case 3: - r.Min.X = tileSize / 2 - r.Min.Y = tileSize / 2 - } - r.Max.X = r.Min.X + tileSize/2 - r.Max.Y = r.Min.Y + tileSize/2 - return r -} - -// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, -// equivalent to imaging.Fill with Center anchor. -func fillCenter(src image.Image, dstW, dstH int) image.Image { - srcBounds := src.Bounds() - srcW := srcBounds.Dx() - srcH := srcBounds.Dy() - - // Calculate crop rectangle (center crop to match destination aspect ratio) - srcAspect := float64(srcW) / float64(srcH) - dstAspect := float64(dstW) / float64(dstH) - - var cropRect image.Rectangle - if srcAspect > dstAspect { - // Source is wider — crop horizontally - cropW := int(float64(srcH) * dstAspect) - cropX := (srcW - cropW) / 2 - cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) - } else { - // Source is taller — crop vertically - cropH := int(float64(srcW) / dstAspect) - cropY := (srcH - cropH) / 2 - cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) - } - - dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) - return dst -} diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go deleted file mode 100644 index 22db6e302..000000000 --- a/core/artwork/reader_radio.go +++ /dev/null @@ -1,40 +0,0 @@ -package artwork - -import ( - "context" - "io" - "time" - - "github.com/navidrome/navidrome/model" -) - -type radioArtworkReader struct { - cacheKey - a *artwork - radio model.Radio -} - -func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { - r, err := artwork.ds.Radio(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - a := &radioArtworkReader{a: artwork, radio: *r} - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = r.UpdatedAt - return a, nil -} - -func (a *radioArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - return selectImageReader(ctx, a.artID, - a.fromRadioUploadedImage(), - ) -} - -func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { - return fromLocalFile(a.radio.UploadedImagePath()) -} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go deleted file mode 100644 index 37ce1d827..000000000 --- a/core/artwork/reader_radio_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package artwork - -import ( - "context" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("radioArtworkReader", func() { - var ( - tempDir string - reader *radioArtworkReader - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tempDir) - - Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) - - reader = &radioArtworkReader{} - }) - - Describe("fromRadioUploadedImage", func() { - When("radio has an uploaded image", func() { - It("returns the uploaded image", func() { - imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - sf := reader.fromRadioUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("radio has no uploaded image", func() { - It("returns nil reader (falls through)", func() { - reader.radio = model.Radio{ID: "rd-1"} - sf := reader.fromRadioUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - }) - - Describe("Reader", func() { - When("radio has an uploaded image", func() { - It("returns the image reader", func() { - imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} - r, _, err := reader.Reader(context.Background()) - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - r.Close() - }) - }) - - When("radio has no uploaded image", func() { - It("returns ErrUnavailable", func() { - reader.radio = model.Radio{ID: "rd-1"} - reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} - r, _, err := reader.Reader(context.Background()) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(r).To(BeNil()) - }) - }) - }) -}) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go deleted file mode 100644 index cd16cbada..000000000 --- a/core/artwork/reader_resized.go +++ /dev/null @@ -1,209 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "fmt" - "image" - "image/draw" - "image/jpeg" - "image/png" - "io" - "sync" - "time" - - "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - xdraw "golang.org/x/image/draw" -) - -func init() { - conf.AddHook(func() { - // gen2brain/webp selects native (purego/libwebp) vs WASM in its own - // package init() and exposes the result only via webp.Dynamic(); there is - // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path - // crashes (issue #5597), so those builds must be compiled with the - // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an - // error here and forces the safe WASM path. - if err := webp.Dynamic(); err != nil { - log.Debug("Using WASM WebP encoder/decoder", "reason", err) - } else { - log.Debug("Using native libwebp for WebP encoding/decoding") - } - }) -} - -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - -type resizedArtworkReader struct { - artID model.ArtworkID - cacheKey string - lastUpdate time.Time - size int - square bool - a *artwork -} - -func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) { - r := &resizedArtworkReader{a: a} - r.artID = artID - r.size = size - r.square = square - - // Get lastUpdated and cacheKey from original artwork - original, err := a.getArtworkReader(ctx, artID, 0, false) - if err != nil { - return nil, err - } - r.cacheKey = original.Key() - r.lastUpdate = original.LastUpdated() - return r, nil -} - -func (a *resizedArtworkReader) Key() string { - baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size) - if a.square { - return baseKey + ".square" - } - return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality) -} - -func (a *resizedArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - // Get artwork in original size, possibly from cache - orig, _, err := a.a.Get(ctx, a.artID, 0, false) - if err != nil { - return nil, "", err - } - defer orig.Close() - - resized, origSize, err := a.resizeImage(ctx, orig) - if resized == nil { - log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) - } else { - log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) - } - if err != nil { - log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err) - } - if err != nil || resized == nil { - // if we couldn't resize the image, return the original - orig, _, err = a.a.Get(ctx, a.artID, 0, false) - return orig, "", err - } - // Preserve ReadCloser semantics if the resized reader already supports Close - // (e.g., ffmpeg pipe), otherwise wrap with NopCloser - if rc, ok := resized.(io.ReadCloser); ok { - return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil - } - return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil -} - -func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) { - data, err := io.ReadAll(reader) - if err != nil { - return nil, 0, fmt.Errorf("reading image data: %w", err) - } - - // Preserve animation for animated images - if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) - } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil - } - - return resizeStaticImage(data, a.size, a.square) -} - -// toFastScaleType converts images whose concrete type has no optimized scaler -// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed -// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale -// falls back to a generic per-pixel At()/RGBA() loop that is several times -// slower. Fast-path types are returned unchanged. -func toFastScaleType(img image.Image) image.Image { - switch img.(type) { - case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: - return img - default: - rgba := image.NewRGBA(img.Bounds()) - draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) - return rgba - } -} - -func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return nil, 0, err - } - - bounds := original.Bounds() - originalSize := max(bounds.Max.X, bounds.Max.Y) - - // Clamp size to original dimensions - upscaling wastes resources and adds no information - if size > originalSize { - size = originalSize - } - - if originalSize <= size && !square { - return nil, originalSize, nil - } - - // Calculate aspect-fit dimensions - srcW, srcH := bounds.Dx(), bounds.Dy() - scale := float64(size) / float64(max(srcW, srcH)) - dstW := int(float64(srcW) * scale) - dstH := int(float64(srcH) * scale) - - var dst *image.NRGBA - var dstRect image.Rectangle - if square { - // Square canvas with image centered (transparent padding via zero-initialized NRGBA) - dst = image.NewNRGBA(image.Rect(0, 0, size, size)) - offsetX := (size - dstW) / 2 - offsetY := (size - dstH) / 2 - dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) - } else { - // Tight-fit canvas - dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - dstRect = dst.Bounds() - } - original = toFastScaleType(original) - xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) - - buf := bufPool.Get().(*bytes.Buffer) - buf.Reset() - if conf.Server.EnableWebPEncoding { - err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) - } else if format == "png" || square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - if err != nil { - bufPool.Put(buf) - return nil, originalSize, err - } - // Copy bytes before returning buffer to pool (pool may reuse the buffer) - encoded := make([]byte, buf.Len()) - copy(encoded, buf.Bytes()) - bufPool.Put(buf) - return bytes.NewReader(encoded), originalSize, nil -} diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go deleted file mode 100644 index 7c14f5e44..000000000 --- a/core/artwork/reader_resized_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "io" - - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizeImage", func() { - var mockFF *tests.MockFFmpeg - var r *resizedArtworkReader - - BeforeEach(func() { - mockFF = tests.NewMockFFmpeg("converted-animated-data") - r = &resizedArtworkReader{ - size: 300, - square: false, - a: &artwork{ffmpeg: mockFF}, - } - }) - - Describe("animated GIF handling", func() { - It("converts animated GIF via ffmpeg when available", func() { - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should have been processed by ffmpeg (mock returns "converted-animated-data") - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) // MockFFmpeg echoes input back - }) - - It("falls back to static resize when ffmpeg fails for animated GIF", func() { - mockFF.Error = errors.New("ffmpeg failed") - // Use size smaller than image so static resize actually produces output - r.size = 1 - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Should fall through to static resize successfully (no ffmpeg error propagated) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Verify it's a static image (WebP encoded), not the ffmpeg error - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(len(output)).To(BeNumerically(">", 0)) - }) - - It("preserves animation for square thumbnails with animated GIF", func() { - r.square = true - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should have been processed by ffmpeg (mock returns input data) - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("animated WebP handling", func() { - It("returns animated WebP data as-is when not square", func() { - data := createAnimatedWebPBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - - It("preserves animated WebP for square thumbnails", func() { - r.square = true - data := createAnimatedWebPBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("animated PNG handling", func() { - It("returns animated PNG data as-is when not square", func() { - data := createAPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - - It("preserves animated PNG for square thumbnails", func() { - r.square = true - data := createAPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("static image handling", func() { - It("resizes a static PNG normally", func() { - data := createStaticPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static PNG is 2x2, size 300 is larger, so should return nil (no upscale) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - }) - - Describe("ReadCloser preservation", func() { - It("preserves Close semantics from ffmpeg ReadCloser", func() { - // Create a trackable ReadCloser - tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))} - mockFF2 := &mockFFmpegWithCloser{tracker: tracker} - r.a = &artwork{ffmpeg: mockFF2} - - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - - // The result should be an io.ReadCloser (the tracker) - rc, ok := result.(io.ReadCloser) - Expect(ok).To(BeTrue()) - Expect(rc.Close()).ToNot(HaveOccurred()) - Expect(tracker.closed).To(BeTrue()) - }) - }) -}) - -// closeTracker is an io.ReadCloser that tracks whether Close was called. -type closeTracker struct { - io.Reader - closed bool -} - -func (c *closeTracker) Close() error { - c.closed = true - return nil -} - -// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser -// for ConvertAnimatedImage, allowing us to verify Close propagation. -type mockFFmpegWithCloser struct { - ffmpeg.FFmpeg - tracker *closeTracker -} - -func (m *mockFFmpegWithCloser) IsAvailable() bool { return true } -func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) { - return m.tracker, nil -} diff --git a/core/artwork/resize.go b/core/artwork/resize.go new file mode 100644 index 000000000..9536c3bb1 --- /dev/null +++ b/core/artwork/resize.go @@ -0,0 +1,144 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "image" + "image/draw" + "image/jpeg" + "image/png" + "io" + "sync" + "time" + + "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + xdraw "golang.org/x/image/draw" +) + +func init() { + conf.AddHook(func() { + // gen2brain/webp picks native vs WASM in its own init(), with no way to switch at + // runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM. + if err := webp.Dynamic(); err != nil { + log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err) + } else { + log.Debug("Artwork: Using native libwebp for WebP encoding/decoding") + } + }) +} + +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +// resizeImageData resizes raw image bytes to fit size, preserving animation where +// possible. A nil reader means the image was already within bounds (no resize needed). +func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) { + start := time.Now() + defer func() { + log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square, + "elapsed", time.Since(start)) + }() + if isAnimatedGIF(data) { + if ffm.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil + } + log.Warn(ctx, "Artwork: Could not convert animated GIF, falling back to static", err) + } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil + } + + return resizeStaticImage(data, size, square) +} + +// toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA, +// *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback. +func toFastScaleType(img image.Image) image.Image { + switch img.(type) { + case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: + return img + default: + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba + } +} + +func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { + original, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, 0, err + } + + bounds := original.Bounds() + originalSize := max(bounds.Max.X, bounds.Max.Y) + + // Clamp size to original dimensions - upscaling wastes resources and adds no information + if size > originalSize { + size = originalSize + } + + if originalSize <= size && !square { + return nil, originalSize, nil + } + + // Calculate aspect-fit dimensions + srcW, srcH := bounds.Dx(), bounds.Dy() + scale := float64(size) / float64(max(srcW, srcH)) + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) + + var dst *image.NRGBA + var dstRect image.Rectangle + if square { + // Square canvas with image centered (transparent padding via zero-initialized NRGBA) + dst = image.NewNRGBA(image.Rect(0, 0, size, size)) + offsetX := (size - dstW) / 2 + offsetY := (size - dstH) / 2 + dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) + } else { + // Tight-fit canvas + dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + dstRect = dst.Bounds() + } + original = toFastScaleType(original) + xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) + + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + if conf.Server.EnableWebPEncoding { + err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) + } + if err != nil { + bufPool.Put(buf) + return nil, originalSize, err + } + // Copy bytes before returning buffer to pool (pool may reuse the buffer) + encoded := bytes.Clone(buf.Bytes()) + bufPool.Put(buf) + return bytes.NewReader(encoded), originalSize, nil +} + +// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key +// fragment, so flipping either setting invalidates previously-encoded sized artwork. +func formatQualityTag() string { + if conf.Server.EnableWebPEncoding { + return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality) + } + return fmt.Sprintf("q%d", conf.Server.CoverArtQuality) +} diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go new file mode 100644 index 000000000..11711469d --- /dev/null +++ b/core/artwork/resolve.go @@ -0,0 +1,452 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "image" + "io" + "io/fs" + "net/url" + "os" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" +) + +// resolution is one attempted acquisition outcome for an entity. +type resolution struct { + reader io.ReadCloser // nil when no source yielded an image + source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" + sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise + refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath + // external source errored/timed out. With no reader it forces failed (never absent); + // on a hit a higher-priority external step failed—serve this, but retry later. + extError bool + // a local source that should have been readable wasn't. With no reader it forces failed, + // so a transient I/O fault never records absent. + localError bool +} + +// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a +// transient external failure still retries; localErr is dropped, as the scanner re-lists changes. +type chainState struct{ extErr, localErr bool } + +// try stamps the accumulated external failure onto a hit, and records the miss otherwise. +func (c *chainState) try(res resolution, ok bool) (resolution, bool) { + if ok { + res.extError = c.extErr + return res, true + } + c.localErr = c.localErr || res.localError + return resolution{}, false +} + +// exhausted is the outcome when no source in the chain yielded an image. +func (c *chainState) exhausted() resolution { + return resolution{extError: c.extErr, localError: c.localErr} +} + +// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. +type externalSource struct { + agents *agents.Agents + gate gateFunc +} + +// resolver walks a kind's priority chain and returns the first hit; a nil ext means local-only. +type resolver struct { + ds model.DataStore + ffmpeg ffmpeg.FFmpeg + ext *externalSource +} + +func newResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, gate gateFunc) *resolver { + if gate == nil { + gate = passthroughGate + } + return &resolver{ds: ds, ffmpeg: ffm, ext: &externalSource{agents: ag, gate: gate}} +} + +// newLocalResolver builds a resolver that can neither reach the network nor sample album art +// for the worker-built grid. +func newLocalResolver(ds model.DataStore, ffm ffmpeg.FFmpeg) *resolver { + return &resolver{ds: ds, ffmpeg: ffm} +} + +func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (resolution, error) { + kind, _ := model.ParseKind(item.ItemKind) + switch kind { + case model.KindAlbumArtwork: + return r.resolveAlbum(ctx, item.ItemID) + case model.KindArtistArtwork: + return r.resolveArtist(ctx, item.ItemID) + case model.KindPlaylistArtwork: + return r.resolvePlaylist(ctx, item.ItemID) + case model.KindRadioArtwork: + return r.resolveRadio(ctx, item.ItemID) + case model.KindMediaFileArtwork: + return r.resolveMediaFile(ctx, item.ItemID) + default: + return resolution{}, fmt.Errorf("artwork: kind %q is not resolvable by the worker", item.ItemKind) + } +} + +// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network, +// so a local-only resolver is stopped here rather than at each point in the chain walk. +func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { + if r.ext == nil { + return nil, "", false + } + return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) +} + +func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) { + if r.ext == nil { + return nil, "", false + } + return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) +} + +// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources. +func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) { + al, err := r.ds.Album(ctx).Get(albumID) + if err != nil { + return resolution{}, err + } + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, r.ds, *al) + if err != nil { + return resolution{}, err + } + lib, err := loadLibraryView(ctx, r.ds, al.LibraryID) + if err != nil { + return resolution{}, err + } + + var chain chainState + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok { + return res, nil + } + case pattern == "external": + if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { + return resolution{reader: rd, source: "external:" + name}, nil + } else if isErr { + chain.extErr = true + } + case len(imgFiles) > 0: + if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok { + return res, nil + } + } + } + return chain.exhausted(), nil +} + +// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority. +func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) { + ar, err := r.ds.Artist(ctx).Get(artistID) + if err != nil { + return resolution{}, err + } + upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload") + if ok { + return upload, nil + } + if upload.localError { + // The upload outranks every other source; falling through would persist a lower-priority + // image as if the upload were gone. + return upload, nil + } + + // Only consider albums where the artist is the sole album artist. + als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"album_artist_id": artistID}, + squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, + }, + }) + if err != nil { + return resolution{}, err + } + albumPaths, imgFiles, _, err := loadArtistAlbumRoots(ctx, r.ds, als) + if err != nil { + return resolution{}, err + } + artistFolder, _, err := loadArtistFolder(ctx, r.ds, als, albumPaths) + if err != nil { + return resolution{}, err + } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, r.ds, als[0].LibraryID) + if err != nil { + return resolution{}, err + } + } + + var chain chainState + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "external": + if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { + return resolution{reader: rd, source: "external:" + name}, nil + } else if isErr { + chain.extErr = true + } + case pattern == "image-folder": + if res, ok := chain.try(resolveArtistImageFolder(ar)); ok { + return res, nil + } + case strings.HasPrefix(pattern, "album/"): + if lib.FS == nil { + continue + } + if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok { + return res, nil + } + default: + if lib.FS == nil || artistFolder == "" { + continue + } + if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok { + return res, nil + } + } + } + return chain.exhausted(), nil +} + +// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. +func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { + pl, err := r.ds.Playlist(ctx).Get(playlistID) + if err != nil { + return resolution{}, err + } + + var extErr bool + for _, src := range []struct{ path, source string }{ + {pl.UploadedImagePath(), "upload"}, + {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, + } { + res, ok := resolveLocalFile(src.path, src.source) + if ok { + return res, nil + } + if res.localError { + // These outrank the generated grid; falling through would replace them with it. + return res, nil + } + } + // A local ExternalImageURL is file-backed and served in place; only http(s) needs the gated fetch. + localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL) + if localImg != "" { + res, ok := resolveLocalFile(localImg, "folder") + if ok { + return res, nil + } + if res.localError { + return res, nil + } + } + if r.ext == nil { + // The remote fetch and the generated grid are worker-only; a request must do neither. + return resolution{}, nil + } + if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { + sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } + if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok { + return res, nil + } else if isErr { + extErr = true + } + } + + albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + if err != nil { + return resolution{}, err + } + + var tiles []image.Image + var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight + for _, albumID := range albumIDs { + res, err := r.resolveAlbum(ctx, albumID) + if err != nil { + if tileErr == nil { + tileErr = err + } + continue + } + if res.extError { + extErr = true + } + if res.reader == nil { + continue + } + tile, decErr := decodeTile(res.reader) + res.reader.Close() + if decErr == nil { + tiles = append(tiles, tile) + } + if len(tiles) == 4 { + break + } + } + if len(tiles) == 0 { + // A tile-level failure must never resolve as a clean absent. + if tileErr != nil { + return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) + } + return resolution{extError: extErr}, nil + } + // Grow to 4 tiles by repeating what we have. + switch len(tiles) { + case 2: + tiles = append(tiles, tiles[1], tiles[0]) + case 3: + tiles = append(tiles, tiles[0]) + } + grid, err := assembleTiles(tiles) + if err != nil { + return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error + } + return resolution{reader: grid, source: "generated", extError: extErr}, nil +} + +// resolveRadio serves only an uploaded image; there is no fallback. +func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) { + radio, err := r.ds.Radio(ctx).Get(radioID) + if err != nil { + return resolution{}, err + } + res, _ := resolveLocalFile(radio.UploadedImagePath(), "upload") + return res, nil +} + +// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art +// is a definitive absent. +func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) { + mf, err := r.ds.MediaFile(ctx).Get(id) + if err != nil { + return resolution{}, err + } + if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt { + return resolution{}, nil + } + lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID) + if err != nil { + return resolution{}, err + } + res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) + return res, nil +} + +// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes +// a not-found, which is a definitive "no" rather than a failure. +func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) { + r, path, err := gate(name, sf) + if r != nil { + return resolution{reader: r, source: "external", sourcePath: path}, true, false + } + return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) +} + +// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a +// remote http(s) URL; at most one is set. +func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) { + if imageURL == "" { + return "", nil + } + u, err := url.Parse(imageURL) + if err != nil { + return imageURL, nil // unparseable → treat as a local path + } + switch u.Scheme { + case "http", "https": + return "", u + case "file": + return u.Path, nil + default: + return imageURL, nil + } +} + +func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { + if embedRel == "" { + return resolution{}, false + } + abs := lib.Abs(embedRel) + var unreadable bool + for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} { + r, _, err := sf() + if r != nil { + return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true + } + unreadable = unreadable || errors.Is(err, errSourceUnreadable) + } + return resolution{localError: unreadable}, false +} + +func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { + r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)() + if r == nil { + return resolution{localError: errors.Is(err, errSourceUnreadable)}, false + } + return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true +} + +func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return resolution{}, false + } + return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder") +} + +func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) { + r, path, err := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)() + if r == nil { + return resolution{localError: errors.Is(err, errSourceUnreadable)}, false + } + return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true +} + +// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other +// open failure says nothing about whether the image exists. +func resolveLocalFile(path, source string) (resolution, bool) { + if path == "" { + return resolution{}, false + } + f, err := os.Open(path) + if err != nil { + return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false + } + return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true +} + +func mtimeOf(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() +} + +// mtimeViaFS stats through the library FS, since library roots in tests may not be real OS paths. +func mtimeViaFS(fsys fs.FS, name string) int64 { + if fsys == nil || name == "" { + return 0 + } + info, err := fs.Stat(fsys, name) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() +} diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go new file mode 100644 index 000000000..fc1a6a3ff --- /dev/null +++ b/core/artwork/resolve_test.go @@ -0,0 +1,632 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "image" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resolveItem", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + repoRoot string + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + }) + + Describe("kind dispatch", func() { + It("returns an error for kinds the worker never enqueues", func() { + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"}) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("media file", func() { + BeforeEach(func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + }) + + It("resolves embedded art from the track file", func() { + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + Expect(res.extError).To(BeFalse()) + }) + + It("resolves absent when the track has no cover art", func() { + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("resolves absent when media file cover art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + }) + + It("returns the error when the track is not in the DB", func() { + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"}) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(res.reader).To(BeNil()) + }) + }) + + Describe("album", func() { + BeforeEach(func() { + conf.Server.CoverArtPriority = "cover.jpg, embedded" + ds.MockedAlbum = tests.CreateMockAlbumRepo() + }) + + It("resolves folder art from the library FS", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + Expect(res.extError).To(BeFalse()) + }) + + It("falls back to embedded art when no folder image matches", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + }) + + It("sets extError when the external source errors without being not-found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al3", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("does not set extError when the external source reports not-found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4", Name: "Album"}, + }) + // no image agents enabled -> the external step is a definitive not-found + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("carries extError onto a fallback folder hit after a transient external failure", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al6", Name: "Album", FolderIDs: []string{"f1"}}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.extError).To(BeTrue()) + }) + + It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, + }) + // no image agents enabled -> the external step is a definitive not-found + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.extError).To(BeFalse()) + }) + + It("routes the external step through the injected gate, keyed by agent name", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al5", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + Expect(gatedNames).To(Equal([]string{"failAgent"})) + }) + }) + + Describe("artist", func() { + It("resolves the uploaded image before any priority chain lookup", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) + ds.MockedArtist = artistRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + + It("falls through to the ArtistArtPriority chain when there is no upload", func() { + conf.Server.ArtistArtPriority = "album/artist.*" + folderRepo.result = []model.Folder{{ + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) + ds.MockedArtist = artistRepo + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{ + {ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}, + } + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) + }) + + It("sets extError when the external source errors without being not-found", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) + ds.MockedArtist = artistRepo + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("does not set extError when the external source reports not-found", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) + ds.MockedArtist = artistRepo + // no image agents enabled -> the external step is a definitive not-found + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("routes the external step through the injected gate, keyed by agent name", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) + ds.MockedArtist = artistRepo + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + Expect(gatedNames).To(Equal([]string{"failAgent"})) + }) + }) + + Describe("radio", func() { + It("yields an empty resolution when there is no uploaded image", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} + ds.MockedRadio = radioRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) + + It("resolves the uploaded image when set", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}} + ds.MockedRadio = radioRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + }) + + Describe("playlist", func() { + BeforeEach(func() { + conf.Server.CoverArtPriority = "cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "t1", Name: "T1", FolderIDs: []string{"f1"}}, + {ID: "t2", Name: "T2", FolderIDs: []string{"f1"}}, + {ID: "t3", Name: "T3", FolderIDs: []string{"f1"}}, + {ID: "t4", Name: "T4", FolderIDs: []string{"f1"}}, + }) + }) + + DescribeTable("yields a generated grid from up to 4 album tiles", + func(albumIDs []string, expectedSize int) { + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + + img, format, err := image.Decode(res.reader) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Dx()).To(Equal(expectedSize)) + Expect(img.Bounds().Dy()).To(Equal(expectedSize)) + }, + // tileSize-1: the 4-tile canvas is built as [0, tileSize-1]. + Entry("1 album -> single tile", []string{"t1"}, tileSize/2), + Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1), + Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1), + Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), + ) + + It("resolves the uploaded image before the generated grid", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + + It("resolves a sidecar image next to the playlist file before the grid", func() { + plDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg")) + }) + + It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.png") + Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.sourcePath).To(Equal(imgPath)) + Expect(res.refMtime).To(BeNumerically(">", 0)) + }) + + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // no grid tiles, so the external failure is what surfaces + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + var gatedNames []string + gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return nil, "", errors.New("network down") + } + + res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") + }) + + It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { + folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + Expect(res.extError).To(BeFalse()) + }) + + // A local resolver holds no agents: reaching the external branch would panic, not degrade. + It("skips the external step instead of dereferencing absent agents", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) + + res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one") + }) + + // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. + It("resolves a playlist locally without fetching remotely or building the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pllocal", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pllocal"} + + res, err := newLocalResolver(ds, ffm).resolve(ctx, item) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil(), "no local source, and the grid is worker-only") + Expect(hits.Load()).To(BeZero(), "a request must never reach the network") + + worker, err := newResolver(ds, ag, ffm, nil).resolve(ctx, item) + Expect(err).ToNot(HaveOccurred()) + Expect(worker.reader).ToNot(BeNil()) + defer worker.reader.Close() + Expect(worker.source).To(Equal("generated"), "the worker does build the grid") + Expect(hits.Load()).To(Equal(int32(1)), "and the worker does fetch") + }) + + It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // no grid tiles, so the external failure is what surfaces + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("yields an empty resolution when no album has art", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "empty1", Name: "Empty"}, + }) + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}} + ds.MockedPlaylist = plRepo + folderRepo.result = nil + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.source).To(BeEmpty()) + }) + + It("skips a grid tile whose declared dimensions are a decompression bomb", func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}} + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.source).To(BeEmpty()) + }) + + It("does not resolve as absent when every sampled album fails to resolve", func() { + // The album ids are absent from MockAlbumRepo, so every tile fails non-externally. + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) + }) +}) + +// decodeTile runs before the processor's own guards, so it must enforce the caps itself. +var _ = Describe("decodeTile", func() { + It("rejects a decompression bomb before the full decode", func() { + data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap + _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("dimensions")) + }) + + It("rejects a tile larger than the size cap", func() { + data := bytes.Repeat([]byte{0}, maxImageBytes+1) + _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 04a9257fb..885ca03cf 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -3,6 +3,7 @@ package artwork import ( "bytes" "context" + "errors" "fmt" "io" "io/fs" @@ -16,14 +17,16 @@ import ( "time" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" "go.senan.xyz/taglib" ) +// errSourceUnreadable marks a candidate the resolver knows exists but could not read. Failing +// to open it is not evidence the entity has no artwork, so callers must not settle on absent. +var errSourceUnreadable = errors.New("artwork source unreadable") + func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { for _, f := range extractFuncs { if ctx.Err() != nil { @@ -32,11 +35,11 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs start := time.Now() r, path, err := f() if r != nil { - msg := fmt.Sprintf("Found %s artwork", artID.Kind) + msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind) log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start)) return r, path, nil } - log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) + log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) } return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable) } @@ -55,11 +58,12 @@ func (f sourceFunc) String() string { func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { + var openErr error for _, file := range files { _, name := filepath.Split(file) match, err := filepath.Match(pattern, strings.ToLower(name)) if err != nil { - log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) + log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } if !match { @@ -67,11 +71,15 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern } f, err := libFS.Open(file) if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err) + openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err) continue } return f, file, nil } + if openErr != nil { + return nil, "", openErr + } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } } @@ -90,7 +98,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { } f, err := libFS.Open(relPath) if err != nil { - return nil, "", err + return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) } rs, ok := f.(io.ReadSeeker) if !ok { @@ -103,7 +111,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { ) if err != nil { f.Close() - return nil, "", err + return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) } // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() @@ -127,12 +135,12 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str for _, regex := range picTypeRegexes { for i, img := range images { if regex.MatchString(img.Type) { - log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path) + log.Trace(ctx, "Artwork: Found embedded image", "type", img.Type, "path", path) return i } } } - log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) + log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) return 0 } @@ -171,44 +179,6 @@ type readCloser struct { io.Closer } -func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { - return func() (io.ReadCloser, string, error) { - r, _, err := a.Get(ctx, id, 0, false) - if err != nil { - return nil, "", err - } - return r, id.String(), nil - } -} - -func fromAlbumPlaceholder() sourceFunc { - return func() (io.ReadCloser, string, error) { - r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) - return r, consts.PlaceholderAlbumArt, nil - } -} -func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.ArtistImage(ctx, ar.ID) - if err != nil { - return nil, "", err - } - - return fromURL(ctx, imageUrl) - } -} - -func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.AlbumImage(ctx, al.ID) - if err != nil { - return nil, "", err - } - - return fromURL(ctx, imageUrl) - } -} - func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { hc := http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) @@ -217,6 +187,12 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err if err != nil { return nil, "", err } + // An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent + // instead of retrying forever and tripping the artwork breaker. + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { + resp.Body.Close() + return nil, "", model.ErrNotFound + } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status) diff --git a/core/artwork/thumbhash/testdata/alpha.png b/core/artwork/thumbhash/testdata/alpha.png new file mode 100644 index 0000000000000000000000000000000000000000..6ac85943a9041b9592a0550e7ef5be8f34683785 GIT binary patch literal 24912 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 TQ)lJ100000NkvXXu0mjf_`5Pu literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/gen_fixtures.mjs b/core/artwork/thumbhash/testdata/gen_fixtures.mjs new file mode 100644 index 000000000..b2e488b51 --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_fixtures.mjs @@ -0,0 +1,90 @@ +// Generates lossless PNG fixtures <=100px. Run: node gen_fixtures.mjs +import { writeFileSync } from 'fs' +import { deflateSync } from 'zlib' + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + return c >>> 0 +}) +const crc32 = (buf) => { + let c = 0xffffffff + for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} +const chunk = (type, data) => { + const len = Buffer.alloc(4) + len.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(body)) + return Buffer.concat([len, body, crc]) +} +const png = (w, h, rgba) => { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(w, 0) + ihdr.writeUInt32BE(h, 4) + ihdr[8] = 8 // bit depth + ihdr[9] = 6 // truecolor + alpha + const raw = Buffer.alloc(h * (w * 4 + 1)) + for (let y = 0; y < h; y++) { + raw[y * (w * 4 + 1)] = 0 // filter: none + for (let x = 0; x < w * 4; x++) raw[y * (w * 4 + 1) + 1 + x] = rgba[y * w * 4 + x] + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +// A ramp has exactly-zero DCT coefficients, and a zero coefficient's nibble is decided by float +// rounding noise, so no two summation orders agree on it. Dither gives every coefficient signal. +let seed = 0 +const dither = () => { + seed ^= seed << 13 + seed ^= seed >>> 17 + seed ^= seed << 5 + seed >>>= 0 + return (seed % 9) - 4 +} +const shade = (v) => Math.max(0, Math.min(255, v + dither())) + +const make = (w, h, fn) => { + seed = 0x9e3779b9 + const rgba = new Uint8Array(w * h * 4) + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h) + return png(w, h, rgba) +} + +const gradient = (rgba, i, x, y, w, h) => { + rgba[i] = shade(Math.floor((255 * x) / w)) + rgba[i + 1] = shade(Math.floor((255 * y) / h)) + rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) + rgba[i + 3] = 255 +} +const solid = (rgba, i) => { + rgba[i] = 60 + rgba[i + 1] = 120 + rgba[i + 2] = 180 + rgba[i + 3] = 255 +} +// RGB must vary with position too: a constant color composited over its own average cancels to a +// flat L/P/Q (the bug this fixture exists to catch), so pair a color gradient with the alpha ramp. +const alphaRamp = (rgba, i, x, y, w, h) => { + rgba[i] = shade(Math.floor((255 * x) / w)) + rgba[i + 1] = shade(Math.floor((255 * y) / h)) + rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) + rgba[i + 3] = shade(Math.floor((255 * (x + 2 * y)) / (w + 2 * h))) +} + +const out = new URL('.', import.meta.url).pathname +writeFileSync(out + 'square.png', make(100, 100, gradient)) +writeFileSync(out + 'landscape.png', make(100, 60, gradient)) +writeFileSync(out + 'portrait.png', make(60, 100, gradient)) +writeFileSync(out + 'alpha.png', make(80, 80, alphaRamp)) +writeFileSync(out + 'solid.png', make(64, 64, solid)) +writeFileSync(out + 'tiny.png', make(1, 1, solid)) +console.log('fixtures written') diff --git a/core/artwork/thumbhash/testdata/gen_generated.mjs b/core/artwork/thumbhash/testdata/gen_generated.mjs new file mode 100644 index 000000000..e8eae585e --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_generated.mjs @@ -0,0 +1,28 @@ +// Produces generated.json: ThumbHashes of synthetic images, from the vendored reference. +// The pixels are a pure function of their index, so Go rebuilds them byte-for-byte from the same +// formula and only the hashes need committing. Run: node gen_generated.mjs +import { writeFileSync } from 'fs' +import { rgbaToThumbHash } from './thumbhash.js' + +const COUNT = 300 + +// mix is a stateless 32-bit finaliser; Go's mix in thumbhash_test.go must match it exactly. +const mix = (n) => { + n = Math.imul(n ^ (n >>> 16), 2246822507) >>> 0 + n = Math.imul(n ^ (n >>> 13), 3266489909) >>> 0 + return (n ^ (n >>> 16)) >>> 0 +} + +const out = [] +for (let i = 0; i < COUNT; i++) { + const w = 1 + (mix(i * 3 + 1) % 100) + const h = 1 + (mix(i * 3 + 2) % 100) + const rgba = new Uint8Array(w * h * 4) + for (let k = 0; k < rgba.length; k++) rgba[k] = mix(i * 1000003 + k) & 255 + // Random alpha is opaque essentially never, so half are forced opaque to exercise the 7x7 + // no-alpha layout as often as the 5x5-plus-alpha one. + if (i % 2 === 0) for (let k = 3; k < rgba.length; k += 4) rgba[k] = 255 + out.push({ w, h, hash: Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') }) +} +writeFileSync(new URL('.', import.meta.url).pathname + 'generated.json', JSON.stringify(out) + '\n') +console.log(`${out.length} vectors; first ${out[0].w}x${out[0].h} ${out[0].hash}`) diff --git a/core/artwork/thumbhash/testdata/gen_golden.mjs b/core/artwork/thumbhash/testdata/gen_golden.mjs new file mode 100644 index 000000000..2825e0709 --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_golden.mjs @@ -0,0 +1,37 @@ +// Produces golden.json from the vendored reference. Run: node gen_golden.mjs +import { readFileSync, writeFileSync, readdirSync } from 'fs' +import { inflateSync } from 'zlib' +import { rgbaToThumbHash } from './thumbhash.js' + +// Minimal reader for the exact PNGs gen_fixtures.mjs writes: 8-bit RGBA, filter 0, single IDAT. +const readPNG = (buf) => { + let w = 0, h = 0 + const idat = [] + for (let off = 8; off < buf.length; ) { + const len = buf.readUInt32BE(off) + const type = buf.toString('ascii', off + 4, off + 8) + const data = buf.subarray(off + 8, off + 8 + len) + if (type === 'IHDR') { + w = data.readUInt32BE(0) + h = data.readUInt32BE(4) + if (data[8] !== 8 || data[9] !== 6) throw new Error('expected 8-bit RGBA') + } else if (type === 'IDAT') idat.push(data) + off += 12 + len + } + const raw = inflateSync(Buffer.concat(idat)) + const rgba = new Uint8Array(w * h * 4) + for (let y = 0; y < h; y++) { + if (raw[y * (w * 4 + 1)] !== 0) throw new Error('expected filter 0') + for (let x = 0; x < w * 4; x++) rgba[y * w * 4 + x] = raw[y * (w * 4 + 1) + 1 + x] + } + return { w, h, rgba } +} + +const dir = new URL('.', import.meta.url).pathname +const golden = {} +for (const name of readdirSync(dir).filter((f) => f.endsWith('.png')).sort()) { + const { w, h, rgba } = readPNG(readFileSync(dir + name)) + golden[name] = Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') +} +writeFileSync(dir + 'golden.json', JSON.stringify(golden, null, 2) + '\n') +console.log(golden) diff --git a/core/artwork/thumbhash/testdata/generated.json b/core/artwork/thumbhash/testdata/generated.json new file mode 100644 index 000000000..1b49bbb03 --- /dev/null +++ b/core/artwork/thumbhash/testdata/generated.json @@ -0,0 +1 @@ +[{"w":28,"h":79,"hash":"4AcCCgBFgGVnc6OLh2u/b7Y="},{"w":94,"h":86,"hash":"4PeBBYAIBZlXeaVKmwl6Zx+i9EeEUqdlGA=="},{"w":61,"h":88,"hash":"3/cBBQCxf6l3ioV1WadpmG+B7Pm4"},{"w":29,"h":41,"hash":"H/iBDAIHX5Chbnyqawdo8otij7N3a0qJBA=="},{"w":63,"h":74,"hash":"3wcCBgB0x884dZeDiopMhclYDUpUAm4="},{"w":29,"h":25,"hash":"HwiCBIIHF79zi6+Gg2fAhk/4fEtnapaYDQ=="},{"w":57,"h":98,"hash":"H/gBBABIejmXynwHHoVAnvdXSw=="},{"w":71,"h":69,"hash":"IPiBBYAHmD2KUW/lQQyLaLCDh0KklFn32g=="},{"w":71,"h":50,"hash":"3/cBBYBohqhLaJpwZoZUd011gBCJ"},{"w":89,"h":83,"hash":"4PeBBYAHkBp+Q/bXx0CSiZ+sT4Z4fIyYZg=="},{"w":92,"h":57,"hash":"IPgBBICgJdaPlJqBnUfQcGbwdA=="},{"w":39,"h":95,"hash":"3/eBAgAIymeYlAuWDQ9RUidjZat7jxk="},{"w":100,"h":82,"hash":"4AcCBoDiuKh2N+TrR6fh9nFm+5lp8Kc="},{"w":92,"h":93,"hash":"HwiCBQAIuI2JaEYDVRA6gU/q5PxIULPapw=="},{"w":67,"h":90,"hash":"IPgBBQC1bGaGk47Hl3+nddp6vwuY"},{"w":78,"h":71,"hash":"HwiCBYAHmZZIYn9JhXCLjZaQSWV2ZUGrfw=="},{"w":85,"h":42,"hash":"4AcCA4KeKZCAlcZzkAz7i6k="},{"w":8,"h":50,"hash":"3/eBCQAH+IVwSkj7IiZPL/h0dYpCSEg="},{"w":13,"h":78,"hash":"HwgCCQJGjqtkmUowdno/3fY="},{"w":66,"h":95,"hash":"HwiCAwAHCjuAqh2Fn/H9K5loYGiYmJU="},{"w":2,"h":84,"hash":"nvcFEQJ7c7iHmKwJj5iHCGA="},{"w":37,"h":32,"hash":"IAiCBIIIldBHeXKZ6f3EA2DV0K2GZkvDBA=="},{"w":41,"h":25,"hash":"3wcCDII7Jst5c0WFhga8cEnTBg=="},{"w":57,"h":26,"hash":"IPiBAoAIuvRneZfgjPSmAhZrl9W8SPU="},{"w":31,"h":40,"hash":"IPgBDQK6o7REKkCxJ1cXRlNqsK+B"},{"w":7,"h":69,"hash":"3veBCQIHdoKrFgYOxqU5cAVYqapmgjQ="},{"w":82,"h":59,"hash":"3/cBBYD4M6tIe6iJenZX0jv3i6QF"},{"w":85,"h":48,"hash":"IAiCA4AHyKR2lgvPG0TFBnXQWGkydzw="},{"w":53,"h":67,"hash":"HwgCBgKbVol5V393ersDOea29bSlZPk="},{"w":79,"h":26,"hash":"IAiCAoAI6QZtSGnvJmnwcJiHZquG+Hc="},{"w":14,"h":28,"hash":"3/cBDAJIhLeYi1+pu1owyIVvMg=="},{"w":85,"h":78,"hash":"4AeCBYAHXPhldaZ2ibVPVQmKh/iJlZQrig=="},{"w":26,"h":67,"hash":"H/gBCwI4T6OnR3GPhq81kGo="},{"w":51,"h":13,"hash":"IPiBCYII24eGC4VNfYAJR/S6V8WLeyg="},{"w":57,"h":81,"hash":"4AcCBQCoojSIY0qYe3f3UQGuawz+"},{"w":8,"h":72,"hash":"4PeBCQIHSUVYVn8ANrQoB3UWWYiXWbA="},{"w":89,"h":40,"hash":"IAgCA4LDe0AG5hb2aM8bcEM="},{"w":55,"h":94,"hash":"3/eBAwAHpIVmT7VvjwahW2NPhkiaDok="},{"w":59,"h":81,"hash":"H/gBDQBERoZIo4nKhqgKaJYvSkVA"},{"w":41,"h":45,"hash":"3/eBBQAHdeyYm3UGRR99adcGQ0RwRZpGmA=="},{"w":11,"h":28,"hash":"HxgCCwLHZnhGlGVggoAJ6GU="},{"w":8,"h":36,"hash":"4PeBCQIIbWHpldBF78rO/EKZaaiAqHo="},{"w":40,"h":100,"hash":"IAgCAwAIT2uoiESGs1BDrxw="},{"w":63,"h":33,"hash":"3weCA4IHmrEpmk9KVK92+LOxI+sLqfs="},{"w":72,"h":63,"hash":"HwgCBoCYlQZkaHeXaeabRJit12VwH7Y="},{"w":29,"h":90,"hash":"3/eBAgAIzqlo+KRtoAl1qh2KO7ZwWWo="},{"w":81,"h":26,"hash":"IPgBAoJVdauz6giWSnhgCYk="},{"w":92,"h":76,"hash":"IPiBBIAHrET8bFvpCZQdZQubVvO0vEV5DQ=="},{"w":85,"h":99,"hash":"3wcCBgB4L9nVxUhpWUyqb76n4LjPOfw="},{"w":79,"h":72,"hash":"4AeCBYAHWneox5/01wN7aHnvSAZscNB3qA=="},{"w":76,"h":18,"hash":"4PcBCoL6mZdmelSFz5UU9EQ="},{"w":78,"h":90,"hash":"4PeBBAAIDnhKoKRQpgBqMJy+iar8L4pZDg=="},{"w":72,"h":13,"hash":"3+cBCYKsdkJZyx6/lICO+8I="},{"w":78,"h":19,"hash":"H/iBAYAImX9hwHSeDXImENV3UIN1hMc="},{"w":29,"h":34,"hash":"IAgCDgB+WIp4KreKyhKJX6mqKKCvEI0="},{"w":63,"h":43,"hash":"H/iBA4AI++eiSIlZkLl/ZwKZhrlnSqg="},{"w":19,"h":79,"hash":"3wcCCgD1k3t0hnYqBHm6Di4="},{"w":82,"h":44,"hash":"3/eBA4AIqi2AjW6pr5YLmwF+OlNou7s="},{"w":70,"h":24,"hash":"3wcCCoAyRWCKl2Z3kHa6A1o="},{"w":28,"h":93,"hash":"4PeBCgAHwrsHVqZgqIePdy6kkJh4RRY="},{"w":44,"h":28,"hash":"H/gBBIJ2xMevRKKpZ4bnUTD6hQ=="},{"w":77,"h":9,"hash":"HwiCCYIIxixdZgmgw/m6OIcDrDM6WZ0="},{"w":59,"h":39,"hash":"H/gBBYKlW6qoh11be/NmovTQCbYc"},{"w":9,"h":93,"hash":"4PeBAQIHFVn4p5mIv/DH95KWbUXgUOk="},{"w":9,"h":96,"hash":"3wcCAQKKqluVczewLNPP0G8="},{"w":54,"h":83,"hash":"IPiBAwAIZVRaj5dvnH4NiFfGpfFPxjg="},{"w":75,"h":3,"hash":"3/cFEYR4iPWKmIiV99OsmPc="},{"w":96,"h":42,"hash":"4PeBAoAHcJqRe3qfQ6f3THWIkFi2ivY="},{"w":74,"h":8,"hash":"4PcBCYJ8JYC0NJh5+a5HnaA="},{"w":2,"h":95,"hash":"oPeBCQR3f82Im7eL3/dIdXgHiHiHiHc="},{"w":40,"h":25,"hash":"3/cBDIJGPXu1PFWvGyI2SH8JWA=="},{"w":48,"h":56,"hash":"H/iBDAAHU4OHDHmKpoT1qngAiHZWl2d4CQ=="},{"w":71,"h":18,"hash":"3/cBCoBFzceDtlBaL20Z8Ok="},{"w":60,"h":44,"hash":"3/eBBIAHqreoZmb9ePMT+YS4hIZZmncHCg=="},{"w":42,"h":25,"hash":"3/cBDIKPdrNMd9eNmFlSn7SZCw=="},{"w":64,"h":67,"hash":"IPiBBQAHbSpXaJBWxEgLybIIa6jFdgxmsw=="},{"w":74,"h":85,"hash":"4AcCBgCVe0R0W1l7eTSq0/dH+zScBSU="},{"w":42,"h":71,"hash":"4AeCAwAIZyCedbhPdZlPiaKXHQRSNeA="},{"w":61,"h":34,"hash":"H/gBBII/nL94LK58lLTVCUz9Jw=="},{"w":26,"h":66,"hash":"4AeCCgAISob0BHp4m1BvNhVnCiiFAtM="},{"w":17,"h":62,"hash":"IAgCCgI8jI9nEofooGprka8="},{"w":58,"h":90,"hash":"IAiCAwAI+Rt0bsP6o/o02aef81rcY4g="},{"w":41,"h":96,"hash":"H/gBAwDLuolgvElQHQppYPY="},{"w":57,"h":6,"hash":"IPiBCYIIh0q8dn/PhQBnd8ApWKNbdKo="},{"w":38,"h":47,"hash":"4PcBDgI6lpeRR2aABrhXvWW0UNaEIHg="},{"w":76,"h":6,"hash":"3xeCCYAISIuiIk91a6CpEKQVvUdsBYc="},{"w":85,"h":66,"hash":"H/gBBYDvamWWd5h1Vo2YlvdTB6/w"},{"w":7,"h":52,"hash":"4AeCCQII+ZuGYmj6XPaXdqa3B0a2dNA="},{"w":1,"h":4,"hash":"Hhk+UjgIh2iYh4iYGIuPh/g="},{"w":19,"h":26,"hash":"3weCDAIHY2JJumCmuqb1ToC4Yy94dxtUBw=="},{"w":63,"h":98,"hash":"3/cBBQBSUpp6Kn2Gnn+ikgXX5EBM"},{"w":36,"h":27,"hash":"IPiBBIAHh6xAemiM6Of5b5+q+XOJtpUwBg=="},{"w":69,"h":50,"hash":"IAgCBYK2ilWImIhgd8ikFPeoVvSn"},{"w":17,"h":100,"hash":"HwiCAQAI8el0tLUJG2OAFon6I0mAZ8U="},{"w":59,"h":24,"hash":"4AcCC4Inv4c4e2OIYDUGV7g="},{"w":57,"h":48,"hash":"3/eBBIAHBXh6Y4qllpB1ryKBqvqpSomhDA=="},{"w":90,"h":41,"hash":"IAgCC4CidBWGNweJm6+coBY="},{"w":68,"h":3,"hash":"oBiCCYII8hsmqlAgxcXwJHh2mAVZhWc="},{"w":77,"h":25,"hash":"4PcBCoL0iEqFdXO2zo+n9Eo="},{"w":50,"h":41,"hash":"HwiCBIAHSK9XhZbW6qiAX4j+o1Yn5JPmBA=="},{"w":95,"h":87,"hash":"3/cBBoBbqfUutpKElbmKm4B0C4I0ePA="},{"w":19,"h":7,"hash":"H/iBCoIYmUl/V1VESZ+mA6eLeHenBnY="},{"w":62,"h":28,"hash":"3wcCC4JqV4G1jAeTn3ttsvs="},{"w":80,"h":84,"hash":"HwiCBQAItaKauK+3WPxqSRf6TQlTiYgs1w=="},{"w":58,"h":11,"hash":"IPgBCYJWdVboUvKJ+nc6wI4="},{"w":62,"h":1,"hash":"oRjCKYZ3iIiIiHCXiJCLCIeIiIiAd/g="},{"w":83,"h":84,"hash":"IAgCBwCXsHOreqRrykpmV4t8lq9nsqEP"},{"w":38,"h":50,"hash":"HwiCBAAIMJSGqHiF+EsGGyZ/MLJmynBjAA=="},{"w":90,"h":25,"hash":"4AcCAoAmQEenlsuKgE4WyvY="},{"w":44,"h":34,"hash":"4AeCDIAIlUgqYFOZB1WIemC1gMKUhIq2Bg=="},{"w":44,"h":58,"hash":"4AcCDQJ1xMubx4YIEjhy2ct7H/IW"},{"w":53,"h":97,"hash":"3/eBAwAIfvkuWL5QdinGj6Tb3EAkpKk="},{"w":46,"h":78,"hash":"3wcCBAD69gtnSeOmSQvGoFC4QA=="},{"w":1,"h":91,"hash":"Xui9GQR3CIiHeIgIiXhfqAj3h4iHiHg="},{"w":62,"h":53,"hash":"HwgCBoDPnGynWGlHeHhaWbZ4ymtwP7k="},{"w":31,"h":16,"hash":"HwiCC4IHYMrnVnx9T5138ke3RXBlSHs="},{"w":79,"h":89,"hash":"3wcCBgCwS65nmVZ1Z3pFOze0G4xfP4U="},{"w":88,"h":46,"hash":"HwiCA4AIeft1qUicgPivQEqYkHp7hEY="},{"w":42,"h":42,"hash":"IAgCBwJd0ybqWZCUd6pdbkhHCZnq9ZoE"},{"w":33,"h":75,"hash":"4AeCAgAIin9mWYoCq51AulUCcmlfBB0="},{"w":74,"h":1,"hash":"3ic+AYp4h4eIiAiHD4hcjQg="},{"w":55,"h":35,"hash":"4AeCA4AIeIClN5NyENOgNZyaiIljBmw="},{"w":2,"h":11,"hash":"HlgKIQZwiHh0aZdocCUKd0w="},{"w":29,"h":33,"hash":"IPiBDAIH9lRItpqqd6CWmKAo+ad49JdoCg=="},{"w":63,"h":40,"hash":"HwgCBIBmUIs2Vol2OVgfOEvSHw=="},{"w":77,"h":90,"hash":"4PeBBAAINniEP5lGAIykwV9XCmS5VmjHCg=="},{"w":78,"h":14,"hash":"3wcCCYBGGoFHx5agj3xrR+8="},{"w":37,"h":40,"hash":"4PeBBQAHy1gEeihmdftWmwDn5DhniHAnSA=="},{"w":79,"h":27,"hash":"H/gBCoCc92ZmanaKgGcGl5k="},{"w":32,"h":86,"hash":"3weCAgAHW6NocGpT+AjOVShetbR/aso="},{"w":14,"h":30,"hash":"H/gBCwLNrIeFxaZPd6YvX1o="},{"w":33,"h":30,"hash":"oAeCBYIHarRwplZVhZj915j1dwd5Y352aQ=="},{"w":62,"h":95,"hash":"H/gBBQB5kGdZ1udkd3lJiV2PbAVc"},{"w":38,"h":93,"hash":"3weCAgAH5D7FRwOFXbAZ+fVpeYk0iHY="},{"w":47,"h":32,"hash":"3/cBDYJ0r1iqSJRkd6p3V3s/a9B/"},{"w":24,"h":9,"hash":"3weCCoIXijxlaV+Eb3RI9odfWVeFVEo="},{"w":17,"h":54,"hash":"4PcBAgSYZHWYgGhbIwMCqHU="},{"w":8,"h":78,"hash":"X/iBAQIIiFJTAjdPiZYEl2PU18bU8nQ="},{"w":8,"h":29,"hash":"HvgBCgYccKvOOXhJ20mAnyg="},{"w":90,"h":54,"hash":"H/iBA4AIb+2Md4RWn/Kfe/FJlyZ1jaQ="},{"w":88,"h":16,"hash":"H/gBCYBnc+aN1jVQYFe1Ov4="},{"w":38,"h":43,"hash":"IAiCBAAHzaaMBvyTQkB3gJ94bziEt4h4AQ=="},{"w":45,"h":33,"hash":"H/gBDYLClnt/2qJ5pLuZZpLzfW+Z"},{"w":9,"h":54,"hash":"3weCAQIIGp+aj3Wd9/GWhYjzxmlbRNg="},{"w":97,"h":47,"hash":"HwgCC4DRtDd8iLn5CHJ3Dnc="},{"w":69,"h":50,"hash":"HwiCBIAHyWepeKMEBzTqh8/5k21oaT90Bw=="},{"w":13,"h":65,"hash":"3/cBCQJpo8dpn410PLfAMvg="},{"w":25,"h":30,"hash":"IPiBBAIHqzf2RJS69XfSTG8Ld2qHiYe2Bw=="},{"w":35,"h":77,"hash":"IPgBAwC/xkaVdZYoy6K/wGU="},{"w":71,"h":99,"hash":"3/eBBAAI8jHjH4eYVgrLOHcQSAdCRrQ3Cw=="},{"w":51,"h":61,"hash":"HwgCDgB4ioRm1K+IiVmHolm68zhYANY="},{"w":99,"h":81,"hash":"4AeCBIAH/JSLl4h51voehr91sfZoWH5pBw=="},{"w":58,"h":89,"hash":"4AcCBQAN9jWNunlhuGOXV/qTrGQG"},{"w":92,"h":16,"hash":"H/iBAYAHzWadOEC11RAUXzefbZKQ2lw="},{"w":20,"h":9,"hash":"3vcBC4R6lm+5cB20+YmS1Ag="},{"w":87,"h":10,"hash":"3+eBAYII7ZVjl0/0dZfQebdwhKU8pfo="},{"w":59,"h":3,"hash":"nwcCCYIU+4fttp4sIM3/v4Y="},{"w":13,"h":39,"hash":"H/iBCgII915YU3MIrA+InAykfK6VeHk="},{"w":22,"h":87,"hash":"3/cBCgJ3MMpXK6a/3GXAf2Y="},{"w":20,"h":61,"hash":"H/iBCgIHcMhmxIC4f1uGBYYJhDl0p9c="},{"w":66,"h":33,"hash":"HwgCBIIVdWCYY5inCWn6Zwzbhg=="},{"w":24,"h":56,"hash":"HwiCAgAIV60ZefWzRA93WHxypWdW1wc="},{"w":36,"h":4,"hash":"HvgBGYaQohaH1cJad41gsFs="},{"w":93,"h":76,"hash":"IPiBBIAIcF8CZi2l97tl7aDCr7ubuXQ4Bg=="},{"w":50,"h":49,"hash":"IAgCB4K8Q4h4hHd5l/iUSzW62DWwG5AE"},{"w":89,"h":33,"hash":"H/iBAoAItPphyoZ4+FLXCCappHhPZLM="},{"w":88,"h":38,"hash":"4PcBA4Dgi0XjhoOT8obwtio="},{"w":92,"h":5,"hash":"4PeBAYII9+Mpe4oUCPQYNXqZtjd56L8="},{"w":16,"h":43,"hash":"HwgCCwLaREjvVTaWYGcna/o="},{"w":43,"h":25,"hash":"H/iBC4IIh7kDmbD1XptgRBwqiJg/xcs="},{"w":23,"h":15,"hash":"IAgCFYLE742XWmeuhhpXpJRT8NAa"},{"w":81,"h":7,"hash":"IPiBAYII/XuJaMxPhJlP16ewLUeXWQQ="},{"w":43,"h":10,"hash":"4PcBCoLS3wRpnEkUcvkwb3Q="},{"w":40,"h":31,"hash":"4PeBBIAHbvoid+O8vCBou5CEhcBWSHukBg=="},{"w":2,"h":54,"hash":"XggCGQh2P1eHhUhzil+FCIM="},{"w":81,"h":51,"hash":"HwiCA4AIXq5fhicHMAp5mmqAO6paeLQ="},{"w":66,"h":34,"hash":"3wcCBIKiaF02Rp91WXV/yMDyNw=="},{"w":82,"h":74,"hash":"4PeBBYAIuB98SKHF1VCErjT2iqiJh49Jlw=="},{"w":68,"h":37,"hash":"H/gBBICMWJmAh1d2Z4isj9tQYw=="},{"w":97,"h":1,"hash":"nxe+GYR3d3iIiIBYh8+FCId4iIiAd/g="},{"w":35,"h":74,"hash":"IAgCAwJaeHZYpGRvC0sBaJg="},{"w":84,"h":16,"hash":"4AeCAYIHtlwCmBe5QIZI/dVrfKfVQJM="},{"w":25,"h":39,"hash":"HwgCDAJR+5x4R7xqGLm9dQAwSA=="},{"w":7,"h":89,"hash":"3weCAQIH71t0jh3R8bisB2CGlqVZa/w="},{"w":11,"h":93,"hash":"4PcBEQIIiUqUVniGpkCGDmY="},{"w":28,"h":16,"hash":"3/eBC4AHZgaLiVrzW/ntHXFCuDtPelc="},{"w":47,"h":1,"hash":"4OdBGYaHiHiIiAh4+ojcfPg="},{"w":83,"h":79,"hash":"4PeBBYAHWU/JlHJmlyqL8DdUi9V3hwhJZQ=="},{"w":31,"h":58,"hash":"3/cBDACMjykH1lXXk4f1m1lXwA=="},{"w":87,"h":27,"hash":"3/eBAoAIWAuadHnSkGf6ZDCpWDbDVHk="},{"w":16,"h":25,"hash":"HxgCDALVgxjVRLTpv0gXoIL7lg=="},{"w":95,"h":6,"hash":"3/eBAYAHNhipdw3ol29wPIUrqUSnB0Y="},{"w":40,"h":79,"hash":"3/cBBACXn5bth4n5V0ifeYb4CA=="},{"w":37,"h":88,"hash":"3/eBAgAHatYMrGagPXqAUTT0aZlkV6k="},{"w":7,"h":64,"hash":"H/gBCQI1U92VyEzQtwv3UiI="},{"w":73,"h":44,"hash":"4AeCA4AI27s6T2uqZi+wyMCreGFnrNs="},{"w":13,"h":53,"hash":"H/gBCgKVPabFiVmfjzmJPgc="},{"w":26,"h":88,"hash":"4AeCAQAH+JrYmobr0Ntl9nJ0DUiHpaU="},{"w":92,"h":96,"hash":"H/gBBwDjZwxVe4qVdpSGh3maxpnPRPAB"},{"w":22,"h":93,"hash":"IPiBAQAIem4GeFLLT5awR4mPYXKA5Tc="},{"w":11,"h":63,"hash":"3/cBCQIEtyd3dmiLRl8zsW8="},{"w":1,"h":16,"hash":"IUbCgRB3CIiHeHj3iIhgOAj3homHiJg="},{"w":86,"h":54,"hash":"3wcCBIBmpUU+NtE5BapGT91XLw=="},{"w":45,"h":45,"hash":"3/eBBQAIjRSWIMBjFweKEy/f4ohbs78Dtw=="},{"w":86,"h":72,"hash":"IPgBBoDpdbynqIevhmp4hEio9NP2gBk="},{"w":56,"h":61,"hash":"HwiCBQAH/ErFesNrqQBrC8foSKc/e7px+A=="},{"w":36,"h":82,"hash":"IPgBAwDXSB9klzdqJkDSCJM="},{"w":19,"h":26,"hash":"IPiBBAIHimHC+WqsTSHQA8CFXPR1aWR7Bw=="},{"w":63,"h":30,"hash":"4AcCC4I3YoJa+ltaWY+Hed8="},{"w":22,"h":67,"hash":"H/iBCgAIdFg69+dzCKkNMJDF6aaPSyc="},{"w":7,"h":4,"hash":"XggGJIgRdYiZWVxIaK+wffh5Sg=="},{"w":65,"h":44,"hash":"H/iBA4AI2jgH3cmnUEkHdX5wtrmFaHQ="},{"w":97,"h":19,"hash":"HwgCCYBPdSVmN45n94dKaAM="},{"w":67,"h":11,"hash":"HwiCCYIImJywpza6bza/d0WndXmo+Dc="},{"w":83,"h":76,"hash":"3wcCBoAIXnqYdVekp+pHk3pn3blfP2g="},{"w":80,"h":98,"hash":"IAiCBAAI39GbiZdfdejP/6+SXIhJSPW3BA=="},{"w":72,"h":86,"hash":"3/cBBgKS+2i4h4h1a46YhnlKwb9G+qc="},{"w":78,"h":29,"hash":"3weCAoAIY2hKCQcA2gp4mpvrhouJX2c="},{"w":91,"h":1,"hash":"IfhBEYZ3iIeIiAiIB4j1i4g="},{"w":68,"h":99,"hash":"IAiCAwAIvewBFkMMqfxbLHqvcVbUZms="},{"w":79,"h":4,"hash":"4AcCCYSbW4Z9nYBL6TpgcE0="},{"w":80,"h":44,"hash":"IAiCA4IHxb+flWn5zAqJiPi0z6Vxk5g="},{"w":43,"h":36,"hash":"4PcBDoC6Qb9xjJWgeX5rc6hoklAaxH8="},{"w":61,"h":21,"hash":"4PeBAoAHoJ2KVFQu/C75JX+S/veGdqI="},{"w":23,"h":93,"hash":"4AcCCgKo/IiJRxNo2ZmfDd0="},{"w":96,"h":65,"hash":"HwiCA4AIgHXGaTw2+itpEHn4d5dxd6c="},{"w":90,"h":27,"hash":"IPgBCoBhgm/V6idYX+m39Sg="},{"w":81,"h":31,"hash":"3weCAoAHn6dXpYipr98KiLRmcGlrmIc="},{"w":73,"h":41,"hash":"3/cBBIA25bcwaFfYQdYCSqbxlg=="},{"w":91,"h":68,"hash":"3/eBBIAI0Jd2OHJIevfJgHx8NNZGpwl/CA=="},{"w":94,"h":74,"hash":"IPgBBoBvTGmIeYhay4x3Z3U3pn8gD0I="},{"w":72,"h":34,"hash":"3weCAoAHQIe83bnPpAqzRjjYpIKvlzc="},{"w":51,"h":34,"hash":"HwgCBYA6fzbdPH26bIaJuMpavwGt"},{"w":55,"h":33,"hash":"H/iBA4AHNLA1nUbPzZ4NNVOQpXiZ11k="},{"w":92,"h":20,"hash":"4PcBAoL2t02KyoishKT/vWc="},{"w":1,"h":94,"hash":"Hvi9CQKICId4iIgYj3tPOAj3h4iHiHg="},{"w":46,"h":34,"hash":"HwgCDYCYTefPhieK2bw3PMCa2Lrw"},{"w":64,"h":30,"hash":"IAiCAoAHTDx/WamEcJBD9saIKnufjmc="},{"w":36,"h":19,"hash":"HwgCDIKHuMmduHeamoBgk/PTeg=="},{"w":90,"h":94,"hash":"IAiCBQAHZy+dmah1KFYwiwVXfURlxoBptQ=="},{"w":89,"h":49,"hash":"H/gBBIBpkIl5rJU5yJrcT/x9UQ=="},{"w":34,"h":2,"hash":"Y8iBCYSIVbsJgXeT+wc2iYeHh4eHiAc="},{"w":25,"h":40,"hash":"3wcCDAKkNHd3x4wKpngw3Pa3Cw=="},{"w":47,"h":32,"hash":"IPiBA4AHl3CHY4VS/WGvOem9n4xZNiY="},{"w":53,"h":96,"hash":"4PcBBAB4zFkHVnZ5Vnb+cQoyqA=="},{"w":47,"h":93,"hash":"4PeBAwAHovPYd5nJhr9y84volUge9qQ="},{"w":86,"h":89,"hash":"4AcCBwIxnclaVpZmhwuXeIqsOUOABWQF"},{"w":40,"h":90,"hash":"IPiBAgAIzWDGoI4lL8cOkpTyeDiGmHY="},{"w":38,"h":66,"hash":"H/gBBAK5wb10WqSEx3BqgHasQA=="},{"w":12,"h":21,"hash":"4AeCAwIHU5iHp/TaqbBfi7ypznOSRIA="},{"w":54,"h":29,"hash":"3/cBBILTCdd3yod7eIVwrqfOnw=="},{"w":78,"h":77,"hash":"3weCBYAIfO9WhGEMTcBzWAd3QJc6hLVohw=="},{"w":88,"h":96,"hash":"3/cBBgCfiF9LQRun2ME2UJ5yuGC1Dgg="},{"w":49,"h":89,"hash":"4PeBAwAIKc/XsRei+QLKXFcyRHdFSvM="},{"w":19,"h":41,"hash":"3wcCCwKIj4pZgGhJXAfKXfQ="},{"w":70,"h":53,"hash":"IPiBBIII2CpoSAcGTfhW94S4b3aFdlqmBQ=="},{"w":55,"h":57,"hash":"4PcBBwAGraR0aVs6blrol9aYrApGsBgG"},{"w":54,"h":29,"hash":"3/eBC4AHyLBqhpD4VxQX9hqwzY9DuVg="},{"w":15,"h":75,"hash":"HwgCCQKJ9qeHapW2EDvPhPc="},{"w":93,"h":81,"hash":"H/iBBIAHC2WedpVnfngIVnX/YBI7RopLBw=="},{"w":64,"h":96,"hash":"4AcCBQCMiJ5GnFu6rDv1yHqfSLQP"},{"w":76,"h":44,"hash":"IPiBA4AHqQeWWI2puG+/SsVZfH+EKFc="},{"w":88,"h":33,"hash":"IAgCA4L4N5JQnp1odH8Id50="},{"w":1,"h":34,"hash":"Xue9MQaICIh3iHj3h3WfiAj3h4iId4g="},{"w":30,"h":4,"hash":"IOgFGYa2AneplIqU+YGncKc="},{"w":92,"h":76,"hash":"HwiCBIAHKrOomZBJlckKC2XFT54JnMSLCA=="},{"w":91,"h":16,"hash":"IPgBCYLldw2ryLmLYHjxVkk="},{"w":3,"h":26,"hash":"3yeGCQIYiY/WpUpL9DmJ/2WYd4anCrk="},{"w":2,"h":14,"hash":"YNcFCQ56SgeK1ld+hvp2+FY="},{"w":93,"h":90,"hash":"IPiBBYAHlR+Hm5gJyPdlAqqImTuPWXlFeA=="},{"w":47,"h":88,"hash":"3/cBBAALd4h3lHZpeIlfs6NTDQ=="},{"w":35,"h":32,"hash":"4AeCBYIHdgJPGVxze8rQrvpIvplllljXBw=="},{"w":38,"h":5,"hash":"4AcCCYRTKNxEawW3UJsl/5Y="},{"w":8,"h":78,"hash":"H/iBCQIHjIUIg8cEM2XQxWp6oKdah5A="},{"w":39,"h":36,"hash":"3/cBDoJQZreZx2RPmKWGpxpkSXA6/EM="},{"w":55,"h":13,"hash":"HwiCAYAHfuUceQxcnO/ZMFE234F3aZY="},{"w":13,"h":56,"hash":"IAgCCgJ7gviHc4ur2H4A1gI="},{"w":26,"h":22,"hash":"3weCBIIHx2iLhiCf+OrZYBToaS9I6Zd1BA=="},{"w":83,"h":19,"hash":"3wcCCoL5dJtjKLdzdZvA/G0="},{"w":7,"h":9,"hash":"IRiCDAQXc8cFtlkd8SmVQIVf+rcJWDm7BA=="},{"w":90,"h":43,"hash":"4PcBA4BWXGyC3oWPkJfDrgo="},{"w":43,"h":77,"hash":"IPiBAwAHhT8asHZ/VIr3tMogVkNnm1g="},{"w":36,"h":27,"hash":"IPgBBYKokWWJhMqLlilFBLsGpL/F"},{"w":86,"h":62,"hash":"3weCBIAHsSsGVZlmSOT8f6tyeRCPwHh6DA=="},{"w":63,"h":89,"hash":"IAgCBQC69pVJd3nafFOo1Xr893oE"},{"w":69,"h":98,"hash":"4AeCBAAHidVTQLuXiQbeVJ9H32iHhUaHAg=="},{"w":12,"h":58,"hash":"HwgCCQKZX0iHh3rGb6X1Nec="},{"w":7,"h":7,"hash":"YPeFFQYXh8baRainTwmCfET7/Km5ia0jxw=="},{"w":40,"h":12,"hash":"IPgBEoSX8Ul3WeSbcLd1Zfg="},{"w":93,"h":47,"hash":"4PeBA4AHY4N7pPu7oJO5AnmeQLYcRLk="},{"w":15,"h":42,"hash":"4AcCCwJ0D/zATmOWM/r3xXM="},{"w":67,"h":45,"hash":"3/eBA4AHVbr4h517mv+eZprFsJioaoQ="},{"w":67,"h":74,"hash":"IAgCBgBqmUc1R2d6iqtrigl4lSf/VXQ="},{"w":29,"h":67,"hash":"3weCAgAHYJRmaGqgmaOAV5q0AZR2akk="},{"w":43,"h":10,"hash":"4AcCCoL8SVDXdVsyiECVaK8="},{"w":75,"h":40,"hash":"IAiCA4AHw2mXw6/AoA5Eo8onAwY4s2o="},{"w":73,"h":91,"hash":"3wcCBgDFhsbJlnmDaYxwRZZUl5GAfzY="},{"w":21,"h":54,"hash":"4AeCAgIHWamZCKf0fNZpTyw6aHxHXzM="},{"w":98,"h":52,"hash":"H/gBBICZZsltiKuMUHewoYEI5g=="},{"w":2,"h":29,"hash":"XwiCCQR3cpuYwKqODEIHWXgHiHiIiIc="}] diff --git a/core/artwork/thumbhash/testdata/golden.json b/core/artwork/thumbhash/testdata/golden.json new file mode 100644 index 000000000..578986e75 --- /dev/null +++ b/core/artwork/thumbhash/testdata/golden.json @@ -0,0 +1,8 @@ +{ + "alpha.png": "JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==", + "landscape.png": "3wcOFJpwh4eBh3d4iIePgAj3hw==", + "portrait.png": "3/cNFBqBB4iId4d3d4iAjwj4hw==", + "solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA", + "square.png": "H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I", + "tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP" +} diff --git a/core/artwork/thumbhash/testdata/landscape.png b/core/artwork/thumbhash/testdata/landscape.png new file mode 100644 index 0000000000000000000000000000000000000000..413851f47af829397cfbabc5b9ccb09b413a03cb GIT binary patch literal 20198 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 literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/portrait.png b/core/artwork/thumbhash/testdata/portrait.png new file mode 100644 index 0000000000000000000000000000000000000000..bc6e5f0de3954404c0f72c5a455f44e996ac8c92 GIT binary patch literal 20102 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 literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/solid.png b/core/artwork/thumbhash/testdata/solid.png new file mode 100644 index 0000000000000000000000000000000000000000..3c53a36bfbe8af789592aece32ddcedddfdf3899 GIT binary patch literal 209 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=Gdx`!Ln`LHy<jNFpdfHygLs=k z%R~N@lm*V;;?mz1*PB^6WH<0#kXpdJh3y5yT1Gl?-!hiQ6<N#gfBFvSMg~t;KbLh* G2~7a1heg@| literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/square.png b/core/artwork/thumbhash/testdata/square.png new file mode 100644 index 0000000000000000000000000000000000000000..a81000cbce450943a6b8143afdccf8dc8d27e2bf GIT binary patch literal 33071 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 T=2J6)00000NkvXXu0mjf9yvT# literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/thumbhash.js b/core/artwork/thumbhash/testdata/thumbhash.js new file mode 100644 index 000000000..369b118ec --- /dev/null +++ b/core/artwork/thumbhash/testdata/thumbhash.js @@ -0,0 +1,288 @@ +/** + * Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A. + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @returns The ThumbHash as a Uint8Array. + */ +export function rgbaToThumbHash(w, h, rgba) { + // Encoding an image larger than 100x100 is slow with no benefit + if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`) + let { PI, round, max, cos, abs } = Math + + // Determine the average color + let avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0 + for (let i = 0, j = 0; i < w * h; i++, j += 4) { + let alpha = rgba[j + 3] / 255 + avg_r += alpha / 255 * rgba[j] + avg_g += alpha / 255 * rgba[j + 1] + avg_b += alpha / 255 * rgba[j + 2] + avg_a += alpha + } + if (avg_a) { + avg_r /= avg_a + avg_g /= avg_a + avg_b /= avg_a + } + + let hasAlpha = avg_a < w * h + let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha + let lx = max(1, round(l_limit * w / max(w, h))) + let ly = max(1, round(l_limit * h / max(w, h))) + let l = [] // luminance + let p = [] // yellow - blue + let q = [] // red - green + let a = [] // alpha + + // Convert the image from RGBA to LPQA (composite atop the average color) + for (let i = 0, j = 0; i < w * h; i++, j += 4) { + let alpha = rgba[j + 3] / 255 + let r = avg_r * (1 - alpha) + alpha / 255 * rgba[j] + let g = avg_g * (1 - alpha) + alpha / 255 * rgba[j + 1] + let b = avg_b * (1 - alpha) + alpha / 255 * rgba[j + 2] + l[i] = (r + g + b) / 3 + p[i] = (r + g) / 2 - b + q[i] = r - g + a[i] = alpha + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + let encodeChannel = (channel, nx, ny) => { + let dc = 0, ac = [], scale = 0, fx = [] + for (let cy = 0; cy < ny; cy++) { + for (let cx = 0; cx * ny < nx * (ny - cy); cx++) { + let f = 0 + for (let x = 0; x < w; x++) + fx[x] = cos(PI / w * cx * (x + 0.5)) + for (let y = 0; y < h; y++) + for (let x = 0, fy = cos(PI / h * cy * (y + 0.5)); x < w; x++) + f += channel[x + y * w] * fx[x] * fy + f /= w * h + if (cx || cy) { + ac.push(f) + scale = max(scale, abs(f)) + } else { + dc = f + } + } + } + if (scale) + for (let i = 0; i < ac.length; i++) + ac[i] = 0.5 + 0.5 / scale * ac[i] + return [dc, ac, scale] + } + let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly)) + let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3) + let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3) + let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : [] + + // Write the constants + let isLandscape = w > h + let header24 = round(63 * l_dc) | (round(31.5 + 31.5 * p_dc) << 6) | (round(31.5 + 31.5 * q_dc) << 12) | (round(31 * l_scale) << 18) | (hasAlpha << 23) + let header16 = (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15) + let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8] + let ac_start = hasAlpha ? 6 : 5 + let ac_index = 0 + if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4)) + + // Write the varying factors + for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac]) + for (let f of ac) + hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2) + return new Uint8Array(hash) +} + +/** + * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The width, height, and pixels of the rendered placeholder image. + */ +export function thumbHashToRGBA(hash) { + let { PI, min, max, cos, round } = Math + + // Read the constants + let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16) + let header16 = hash[3] | (hash[4] << 8) + let l_dc = (header24 & 63) / 63 + let p_dc = ((header24 >> 6) & 63) / 31.5 - 1 + let q_dc = ((header24 >> 12) & 63) / 31.5 - 1 + let l_scale = ((header24 >> 18) & 31) / 31 + let hasAlpha = header24 >> 23 + let p_scale = ((header16 >> 3) & 63) / 63 + let q_scale = ((header16 >> 9) & 63) / 63 + let isLandscape = header16 >> 15 + let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7) + let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) + let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1 + let a_scale = (hash[5] >> 4) / 15 + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = hasAlpha ? 6 : 5 + let ac_index = 0 + let decodeChannel = (nx, ny, scale) => { + let ac = [] + for (let cy = 0; cy < ny; cy++) + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) + ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale) + return ac + } + let l_ac = decodeChannel(lx, ly, l_scale) + let p_ac = decodeChannel(3, 3, p_scale * 1.25) + let q_ac = decodeChannel(3, 3, q_scale * 1.25) + let a_ac = hasAlpha && decodeChannel(5, 5, a_scale) + + // Decode using the DCT into RGB + let ratio = thumbHashToApproximateAspectRatio(hash) + let w = round(ratio > 1 ? 32 : 32 * ratio) + let h = round(ratio > 1 ? 32 / ratio : 32) + let rgba = new Uint8Array(w * h * 4), fx = [], fy = [] + for (let y = 0, i = 0; y < h; y++) { + for (let x = 0; x < w; x++, i += 4) { + let l = l_dc, p = p_dc, q = q_dc, a = a_dc + + // Precompute the coefficients + for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) + fx[cx] = cos(PI / w * (x + 0.5) * cx) + for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) + fy[cy] = cos(PI / h * (y + 0.5) * cy) + + // Decode L + for (let cy = 0, j = 0; cy < ly; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) + l += l_ac[j] * fx[cx] * fy2 + + // Decode P and Q + for (let cy = 0, j = 0; cy < 3; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { + let f = fx[cx] * fy2 + p += p_ac[j] * f + q += q_ac[j] * f + } + } + + // Decode A + if (hasAlpha) + for (let cy = 0, j = 0; cy < 5; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) + a += a_ac[j] * fx[cx] * fy2 + + // Convert to RGB + let b = l - 2 / 3 * p + let r = (3 * l - b + q) / 2 + let g = r - q + rgba[i] = max(0, 255 * min(1, r)) + rgba[i + 1] = max(0, 255 * min(1, g)) + rgba[i + 2] = max(0, 255 * min(1, b)) + rgba[i + 3] = max(0, 255 * min(1, a)) + } + } + return { w, h, rgba } +} + +/** + * Extracts the average color from a ThumbHash. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The RGBA values for the average color. Each value ranges from 0 to 1. + */ +export function thumbHashToAverageRGBA(hash) { + let { min, max } = Math + let header = hash[0] | (hash[1] << 8) | (hash[2] << 16) + let l = (header & 63) / 63 + let p = ((header >> 6) & 63) / 31.5 - 1 + let q = ((header >> 12) & 63) / 31.5 - 1 + let hasAlpha = header >> 23 + let a = hasAlpha ? (hash[5] & 15) / 15 : 1 + let b = l - 2 / 3 * p + let r = (3 * l - b + q) / 2 + let g = r - q + return { + r: max(0, min(1, r)), + g: max(0, min(1, g)), + b: max(0, min(1, b)), + a + } +} + +/** + * Extracts the approximate aspect ratio of the original image. + * + * @param hash The bytes of the ThumbHash. + * @returns The approximate aspect ratio (i.e. width / height). + */ +export function thumbHashToApproximateAspectRatio(hash) { + let header = hash[3] + let hasAlpha = hash[2] & 0x80 + let isLandscape = hash[4] & 0x80 + let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7 + let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 + return lx / ly +} + +/** + * Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by + * A. This is optimized for speed and simplicity and does not optimize for size + * at all. This doesn't do any compression (all values are stored uncompressed). + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @returns A data URL containing a PNG for the input image. + */ +export function rgbaToDataURL(w, h, rgba) { + let row = w * 4 + 1 + let idat = 6 + h * (5 + row) + let bytes = [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, + w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, + idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255, + 73, 68, 65, 84, 120, 1 + ] + let table = [ + 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, + 1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376, + -2032938284, -1609899400, -1111625188 + ] + let a = 1, b = 0 + for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { + bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0) + for (b = (b + a) % 65521; i < end; i++) { + let u = rgba[i] & 255 + bytes.push(u) + a = (a + u) % 65521 + b = (b + a) % 65521 + } + } + bytes.push( + b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, + 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130 + ) + for (let [start, end] of [[12, 29], [37, 41 + idat]]) { + let c = ~0 + for (let i = start; i < end; i++) { + c ^= bytes[i] + c = (c >>> 4) ^ table[c & 15] + c = (c >>> 4) ^ table[c & 15] + } + c = ~c + bytes[end++] = c >>> 24 + bytes[end++] = (c >> 16) & 255 + bytes[end++] = (c >> 8) & 255 + bytes[end++] = c & 255 + } + return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes)) +} + +/** + * Decodes a ThumbHash to a PNG data URL. This is a convenience function that + * just calls "thumbHashToRGBA" followed by "rgbaToDataURL". + * + * @param hash The bytes of the ThumbHash. + * @returns A data URL containing a PNG for the rendered ThumbHash. + */ +export function thumbHashToDataURL(hash) { + let image = thumbHashToRGBA(hash) + return rgbaToDataURL(image.w, image.h, image.rgba) +} diff --git a/core/artwork/thumbhash/testdata/tiny.png b/core/artwork/thumbhash/testdata/tiny.png new file mode 100644 index 0000000000000000000000000000000000000000..ab635e11a904d71fce72f9361aa4fb420e9f5d18 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9x<|J=edE=8j1IrPnj0%CR Qyg&&CPgg&ebxsLQ0L54j#Q*>R literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go new file mode 100644 index 000000000..a255bec42 --- /dev/null +++ b/core/artwork/thumbhash/thumbhash.go @@ -0,0 +1,257 @@ +// Package thumbhash implements the ThumbHash encoding algorithm (https://github.com/evanw/thumbhash). +package thumbhash + +import ( + "errors" + "image" + "image/draw" + "math" + + xdraw "golang.org/x/image/draw" +) + +// maxInputSize is the algorithm's hard limit: larger inputs are rejected by every implementation. +const maxInputSize = 100 + +// term is one DCT coefficient's frequency pair, in the reference's triangular scan order. +type term struct{ cx, cy int } + +// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha. Output matches +// evanw/thumbhash except where a coefficient lands on a quantization tie, where a nibble may differ by 1. +func Encode(img image.Image) ([]byte, error) { + rgba := toNRGBA(downscale(img)) + b := rgba.Bounds() + w, h := b.Dx(), b.Dy() + if w == 0 || h == 0 { + return nil, errors.New("thumbhash: empty image") + } + + avgR, avgG, avgB, avgA := averageColor(rgba, w, h) + hasAlpha := avgA < float64(w*h) + if avgA > 0 { + avgR /= avgA + avgG /= avgA + avgB /= avgA + } + + lLimit := 7.0 + if hasAlpha { + lLimit = 5.0 + } + maxWH := float64(max(w, h)) + lx := max(1, int(math.Round(lLimit*float64(w)/maxWH))) + ly := max(1, int(math.Round(lLimit*float64(h)/maxWH))) + + lTerms := terms(max(3, lx), max(3, ly)) + pTerms := terms(3, 3) + qTerms := terms(3, 3) + var aTerms []term + if hasAlpha { + aTerms = terms(5, 5) + } + + // The widest coefficient region wins: 3x3 chroma, 5x5 alpha, and luma's own lx by ly. + chan5 := 3 + if hasAlpha { + chan5 = 5 + } + nx := max(max(3, lx), chan5) + cosX := cosTable(nx, w) + cosY := cosTable(max(max(3, ly), chan5), h) + + lAcc := make([]float64, len(lTerms)) + pAcc := make([]float64, len(pTerms)) + qAcc := make([]float64, len(qTerms)) + aAcc := make([]float64, len(aTerms)) + rowL := make([]float64, nx) + rowP := make([]float64, nx) + rowQ := make([]float64, nx) + rowA := make([]float64, nx) + + for y := range h { + clear(rowL) + clear(rowP) + clear(rowQ) + clear(rowA) + row := rgba.Pix[y*rgba.Stride:] + for x := range w { + j := x * 4 + alpha := float64(row[j+3]) / 255 + r := avgR*(1-alpha) + alpha/255*float64(row[j]) + g := avgG*(1-alpha) + alpha/255*float64(row[j+1]) + bl := avgB*(1-alpha) + alpha/255*float64(row[j+2]) + lv := (r + g + bl) / 3 + pv := (r+g)/2 - bl + qv := r - g + for cx := range nx { + f := cosX[cx][x] + rowL[cx] += lv * f + rowP[cx] += pv * f + rowQ[cx] += qv * f + } + // hasAlpha is loop-invariant, so this costs a predicted branch rather than a quarter + // of the inner loop that opaque images never need. + if hasAlpha { + for cx := range nx { + rowA[cx] += alpha * cosX[cx][x] + } + } + } + accumulate(lAcc, lTerms, rowL, cosY, y) + accumulate(pAcc, pTerms, rowP, cosY, y) + accumulate(qAcc, qTerms, rowQ, cosY, y) + accumulate(aAcc, aTerms, rowA, cosY, y) + } + + n := float64(w * h) + lDC, lAC, lScale := normalize(lAcc, n) + pDC, pAC, pScale := normalize(pAcc, n) + qDC, qAC, qScale := normalize(qAcc, n) + aDC, aAC, aScale := normalize(aAcc, n) + + return pack(w, h, hasAlpha, lx, ly, + lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale, lAC, pAC, qAC, aAC), nil +} + +// terms lists the (cx, cy) pairs of the reference's triangular coefficient region, in write order. +func terms(nx, ny int) []term { + var ts []term + for cy := range ny { + for cx := 0; cx*ny < nx*(ny-cy); cx++ { + ts = append(ts, term{cx, cy}) + } + } + return ts +} + +// cosTable precomputes cos(pi/size * c * (i+0.5)) with the reference's exact expression, so the +// table values are bit-identical to recomputing them per coefficient. +func cosTable(n, size int) [][]float64 { + t := make([][]float64, n) + for c := range n { + t[c] = make([]float64, size) + for i := range size { + t[c][i] = math.Cos(math.Pi / float64(size) * float64(c) * (float64(i) + 0.5)) + } + } + return t +} + +func averageColor(rgba *image.NRGBA, w, h int) (r, g, b, a float64) { + for y := range h { + row := rgba.Pix[y*rgba.Stride:] + for x := range w { + j := x * 4 + alpha := float64(row[j+3]) / 255 + r += alpha / 255 * float64(row[j]) + g += alpha / 255 * float64(row[j+1]) + b += alpha / 255 * float64(row[j+2]) + a += alpha + } + } + return r, g, b, a +} + +// accumulate folds one row's per-cx sums into the term accumulators, so the pixel loop costs +// nx multiplies per pixel instead of one per coefficient. +func accumulate(acc []float64, ts []term, row []float64, cosY [][]float64, y int) { + for k, t := range ts { + acc[k] += row[t.cx] * cosY[t.cy][y] + } +} + +// normalize splits the accumulators into DC and scaled AC terms, matching the reference: a constant +// image leaves scale at 0, which skips normalization rather than mapping the terms to the midpoint. +func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float64) { + if len(acc) == 0 { + return 0, nil, 0 + } + dc = acc[0] / n + ac = make([]float64, len(acc)-1) + for i, v := range acc[1:] { + ac[i] = v / n + scale = max(scale, math.Abs(ac[i])) + } + if scale > 0 { + for i := range ac { + ac[i] = 0.5 + 0.5/scale*ac[i] + } + } + return dc, ac, scale +} + +func pack(w, h int, hasAlpha bool, lx, ly int, + lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale float64, + lAC, pAC, qAC, aAC []float64, +) []byte { + isLandscape := 0 + if w > h { + isLandscape = 1 + } + alphaBit := 0 + if hasAlpha { + alphaBit = 1 + } + header24 := int(math.Round(63*lDC)) | int(math.Round(31.5+31.5*pDC))<<6 | + int(math.Round(31.5+31.5*qDC))<<12 | int(math.Round(31*lScale))<<18 | alphaBit<<23 + lead := lx + if isLandscape == 1 { + lead = ly + } + header16 := lead | int(math.Round(63*pScale))<<3 | int(math.Round(63*qScale))<<9 | isLandscape<<15 + + acs := [][]float64{lAC, pAC, qAC} + acStart := 5 + if hasAlpha { + acs = append(acs, aAC) + acStart = 6 + } + acCount := 0 + for _, ac := range acs { + acCount += len(ac) + } + + hash := make([]byte, acStart+(acCount+1)/2) + hash[0] = byte(header24 & 255) + hash[1] = byte((header24 >> 8) & 255) + hash[2] = byte(header24 >> 16) + hash[3] = byte(header16 & 255) + hash[4] = byte(header16 >> 8) + if hasAlpha { + hash[5] = byte(int(math.Round(15*aDC)) | int(math.Round(15*aScale))<<4) + } + acIndex := 0 + for _, ac := range acs { + for _, f := range ac { + hash[acStart+(acIndex>>1)] |= byte(int(math.Round(15*f)) << ((acIndex & 1) << 2)) + acIndex++ + } + } + return hash +} + +// NRGBA, not RGBA: ThumbHash requires non-premultiplied RGB and the pipeline hands us a +// premultiplied *image.RGBA, which draw.Draw un-premultiplies on the way in. +func toNRGBA(img image.Image) *image.NRGBA { + // Conservative: a sub-image re-slices Pix so the loops would read it correctly too, but the + // copy costs nothing on the origin-anchored images the pipeline actually produces. + if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) { + return nrgba + } + b := img.Bounds() + dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} diff --git a/core/artwork/thumbhash/thumbhash_suite_test.go b/core/artwork/thumbhash/thumbhash_suite_test.go new file mode 100644 index 000000000..11a6f0e25 --- /dev/null +++ b/core/artwork/thumbhash/thumbhash_suite_test.go @@ -0,0 +1,17 @@ +package thumbhash_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestThumbHash(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ThumbHash Suite") +} diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go new file mode 100644 index 000000000..4832c25bc --- /dev/null +++ b/core/artwork/thumbhash/thumbhash_test.go @@ -0,0 +1,194 @@ +package thumbhash_test + +import ( + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/draw" + _ "image/png" + "os" + "path/filepath" + "runtime" + "slices" + + "github.com/navidrome/navidrome/core/artwork/thumbhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs +// the process to the repo root, which would break a plain relative "testdata" path. +var testdataDir = func() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "testdata") +}() + +// fixtureImage decodes a testdata PNG. NRGBA, not RGBA: ThumbHash needs non-premultiplied pixels, +// and RGBA would silently premultiply every fixture that has alpha. +func fixtureImage(name string) *image.NRGBA { + GinkgoHelper() + f, err := os.Open(filepath.Join(testdataDir, name)) + Expect(err).ToNot(HaveOccurred()) + defer f.Close() + src, _, err := image.Decode(f) + Expect(err).ToNot(HaveOccurred()) + b := src.Bounds() + dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + return dst +} + +// headerOnlyFixtures have mathematically-zero AC content, so every AC nibble is float rounding +// noise sitting on a quantization tie; only the header bytes carry signal. +var headerOnlyFixtures = []string{"solid.png", "tiny.png"} + +func loadJSON[T any](name string) T { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join(testdataDir, name)) + Expect(err).ToNot(HaveOccurred()) + var out T + Expect(json.Unmarshal(data, &out)).To(Succeed()) + return out +} + +func loadGoldens() map[string]string { + GinkgoHelper() + golden := loadJSON[map[string]string]("golden.json") + Expect(golden).ToNot(BeEmpty()) + return golden +} + +// mix is a stateless 32-bit finaliser matching gen_generated.mjs, so both languages build the +// same synthetic pixels and only the reference's hashes need committing. +func mix(n uint32) uint32 { + n = (n ^ (n >> 16)) * 2246822507 + n = (n ^ (n >> 13)) * 3266489909 + return n ^ (n >> 16) +} + +func generatedImage(i int) *image.NRGBA { + w := 1 + int(mix(uint32(i)*3+1)%100) + h := 1 + int(mix(uint32(i)*3+2)%100) + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for k := range img.Pix { + img.Pix[k] = byte(mix(uint32(i)*1000003 + uint32(k))) + } + if i%2 == 0 { + for k := 3; k < len(img.Pix); k += 4 { + img.Pix[k] = 255 + } + } + return img +} + +var _ = Describe("Encode", func() { + It("matches every golden vector", func() { + for name, want := range loadGoldens() { + if slices.Contains(headerOnlyFixtures, name) { + continue // see the dedicated header-only spec below + } + got, err := thumbhash.Encode(fixtureImage(name)) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want), "fixture %s", name) + } + }) + + It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() { + for _, name := range headerOnlyFixtures { + want, err := base64.StdEncoding.DecodeString(loadGoldens()[name]) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + got, err := thumbhash.Encode(fixtureImage(name)) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + Expect(got[:5]).To(Equal(want[:5]), "fixture %s header bytes", name) + } + }) + + // The PNG fixtures cannot reach every layout; these sweep random sizes, aspects and both the + // 7x7 no-alpha and 5x5-plus-alpha coefficient regions against the same reference. + It("matches the reference on 300 generated images", func() { + vectors := loadJSON[[]struct { + W, H int + Hash string + }]("generated.json") + Expect(vectors).ToNot(BeEmpty()) + for i, want := range vectors { + img := generatedImage(i) + Expect(img.Bounds().Dx()).To(Equal(want.W), "vector %d width", i) + Expect(img.Bounds().Dy()).To(Equal(want.H), "vector %d height", i) + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred(), "vector %d", i) + Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want.Hash), "vector %d (%dx%d)", i, want.W, want.H) + } + }) + + It("quantizes a uniform image's scales to zero", func() { + got, err := thumbhash.Encode(fixtureImage("solid.png")) + Expect(err).ToNot(HaveOccurred()) + header24 := int(got[0]) | int(got[1])<<8 | int(got[2])<<16 + header16 := int(got[3]) | int(got[4])<<8 + Expect((header24>>18)&31).To(Equal(0), "lScale") + Expect((header16>>3)&63).To(Equal(0), "pScale") + Expect((header16>>9)&63).To(Equal(0), "qScale") + }) + + It("produces 24 bytes for a square opaque image", func() { + got, err := thumbhash.Encode(fixtureImage("square.png")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(24)) + }) + + It("produces 25 bytes when the image has alpha", func() { + got, err := thumbhash.Encode(fixtureImage("alpha.png")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(25)) + }) + + It("downscales an oversized image rather than failing", func() { + img := image.NewNRGBA(image.Rect(0, 0, 500, 300)) + for i := range img.Pix { + img.Pix[i] = byte(i) + } + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(len(got)).To(BeNumerically(">=", 5)) + }) + + It("encodes a 1x1 image", func() { + img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.NRGBA{R: 60, G: 120, B: 180, A: 255}) + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(got).ToNot(BeEmpty()) + }) + + It("encodes a sub-image like an origin-anchored copy of the same region", func() { + parent := image.NewNRGBA(image.Rect(0, 0, 60, 50)) + for i := range parent.Pix { + parent.Pix[i] = byte(i * 7 % 251) + } + region := image.Rect(10, 7, 40, 30) + cropped := image.NewNRGBA(image.Rect(0, 0, region.Dx(), region.Dy())) + draw.Draw(cropped, cropped.Bounds(), parent, region.Min, draw.Src) + + got, err := thumbhash.Encode(parent.SubImage(region)) + Expect(err).ToNot(HaveOccurred()) + want, err := thumbhash.Encode(cropped) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(want)) + }) + + It("rejects an empty image", func() { + _, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0))) + Expect(err).To(HaveOccurred()) + }) + + It("is deterministic", func() { + img := fixtureImage("square.png") + first, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + second, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(Equal(second)) + }) +}) diff --git a/core/image_upload.go b/core/artwork/uploader.go similarity index 55% rename from core/image_upload.go rename to core/artwork/uploader.go index eb61b225a..9c38ae2e5 100644 --- a/core/image_upload.go +++ b/core/artwork/uploader.go @@ -1,4 +1,4 @@ -package core +package artwork import ( "context" @@ -15,13 +15,7 @@ import ( "github.com/navidrome/navidrome/utils" ) -type ImageUploadService interface { - SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) - RemoveImage(ctx context.Context, path string) error -} - -// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default -// when it's unset/invalid. Shared by every API that accepts image uploads. +// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. func MaxImageUploadSize() int64 { if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { return int64(size) @@ -30,13 +24,30 @@ func MaxImageUploadSize() int64 { return int64(size) } -type imageUploadService struct{} - -func NewImageUploadService() ImageUploadService { - return &imageUploadService{} +// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state. +type Uploader interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error + // EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new + // filename, or the worker resolves the old one. + EnqueueArtwork(ctx context.Context, entityType, entityID string) } -func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { +var uploadEntityKind = map[string]model.Kind{ + consts.EntityArtist: model.KindArtistArtwork, + consts.EntityPlaylist: model.KindPlaylistArtwork, + consts.EntityRadio: model.KindRadioArtwork, +} + +type uploader struct { + ds model.DataStore +} + +func NewUploader(ds model.DataStore) Uploader { + return &uploader{ds: ds} +} + +func (s *uploader) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { filename := imageFilename(entityID, name, ext) absPath := model.UploadedImagePath(entityType, filename) @@ -44,14 +55,12 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en return "", fmt.Errorf("creating image directory: %w", err) } - // Remove old image if it exists if oldPath != "" { if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove old image", "path", oldPath, err) + log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err) } } - // Save new image f, err := os.Create(absPath) if err != nil { return "", fmt.Errorf("creating image file: %w", err) @@ -61,11 +70,20 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en if _, err := io.Copy(f, reader); err != nil { return "", fmt.Errorf("writing image file: %w", err) } - return filename, nil } -func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { +func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) { + kind, ok := uploadEntityKind[entityType] + if !ok { + return + } + if err := Refresh(ctx, s.ds, kind, id); err != nil { + log.Warn(ctx, "Artwork: Could not refresh artwork after upload", "kind", kind, "id", id, err) + } +} + +func (s *uploader) RemoveImage(ctx context.Context, path string) error { if path == "" { return nil } diff --git a/core/image_upload_test.go b/core/artwork/uploader_test.go similarity index 60% rename from core/image_upload_test.go rename to core/artwork/uploader_test.go index e7648df34..44f5ede26 100644 --- a/core/image_upload_test.go +++ b/core/artwork/uploader_test.go @@ -1,4 +1,4 @@ -package core_test +package artwork import ( "context" @@ -9,20 +9,26 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ImageUploadService", func() { - var svc core.ImageUploadService +var _ = Describe("Uploader", func() { + var svc Uploader var tmpDir string + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) - svc = core.NewImageUploadService() + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + svc = NewUploader(ds) }) Describe("SetImage", func() { @@ -69,6 +75,49 @@ var _ = Describe("ImageUploadService", func() { _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) }) + + It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { + ctx := context.Background() + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") + Expect(err).ToNot(HaveOccurred()) + + // SetImage only writes the file; the state row survives and nothing is queued until + // the caller has persisted the new filename and called EnqueueArtwork. + _, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + }) + }) + + Describe("EnqueueArtwork", func() { + It("clears artwork state and enqueues a Bump", func() { + ctx := context.Background() + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") + + _, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", "ar-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("is a no-op for an unknown entity type", func() { + svc.EnqueueArtwork(context.Background(), "unknown", "x-1") + Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + }) }) Describe("RemoveImage", func() { @@ -105,21 +154,21 @@ var _ = Describe("MaxImageUploadSize", func() { It("returns the configured size when valid", func() { conf.Server.MaxImageUploadSize = "20MB" - Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) + Expect(MaxImageUploadSize()).To(Equal(int64(20_000_000))) }) It("returns the default size when config is empty", func() { conf.Server.MaxImageUploadSize = "" - Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000))) }) It("returns the default size when config is invalid", func() { conf.Server.MaxImageUploadSize = "not-a-size" - Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000))) }) It("parses raw byte values", func() { conf.Server.MaxImageUploadSize = "52428800" - Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800))) + Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800))) }) }) diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 63231b54a..54c2ad397 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -7,5 +7,7 @@ import ( var Set = wire.NewSet( NewArtwork, GetImageCache, - NewCacheWarmer, + NewWorker, + GetImageStore, + NewUploader, ) diff --git a/core/artwork/worker.go b/core/artwork/worker.go new file mode 100644 index 000000000..3042c47b0 --- /dev/null +++ b/core/artwork/worker.go @@ -0,0 +1,318 @@ +package artwork + +import ( + "bytes" + "cmp" + "context" + "io" + "math" + "math/rand/v2" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/cache" +) + +const ( + workerPollInterval = 5 * time.Second + backoffBase = 5 * time.Second + // giveUpAfter bounds the retry budget from enqueue; past it the item falls to the + // periodic stale-absent recheck. + giveUpAfter = 12 * time.Hour +) + +// drainPool drains one class of work with its own slot budget, so a blocking kind cannot +// occupy slots another kind needs. +type drainPool struct { + name string + kinds []string + concurrency int +} + +// Worker drains the artwork queue: each external agent is rate-limited and circuit-broken +// independently, and pruneMu serializes prune against the store-write window. +type Worker struct { + proc *processor + cache cache.FileCache + ffmpeg ffmpeg.FFmpeg + broker events.Broker + pruneMu sync.RWMutex + pools []*drainPool + runCtx context.Context + + gatesMu sync.Mutex + gates map[string]*extGate +} + +func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { + w := &Worker{ + proc: &processor{ds: ds, store: store}, + cache: imgCache, + ffmpeg: ffmpeg, + broker: broker, + pools: newDrainPools(), + runCtx: context.Background(), + gates: map[string]*extGate{}, + } + w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate) + w.proc.pruneLock = w.pruneMu.RLocker() + return w +} + +// newDrainPools splits the drain by what bounds it: gate() holds a slot while waiting for its +// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk. +func newDrainPools() []*drainPool { + budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive + local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1) + // More external slots than the rate allows would only sleep in the limiter. + external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local) + return []*drainPool{ + {name: "local", kinds: localDrainKinds, concurrency: local}, + {name: "external", kinds: externalDrainKinds, concurrency: external}, + } +} + +// Kind is a proxy for cost: an album that reaches an external agent still costs a local slot. +var ( + externalDrainKinds = []string{model.KindArtistArtwork.Prefix()} + localDrainKinds = []string{ + model.KindAlbumArtwork.Prefix(), + model.KindPlaylistArtwork.Prefix(), + model.KindRadioArtwork.Prefix(), + model.KindMediaFileArtwork.Prefix(), + } +) + +// Run blocks draining the queue until ctx is cancelled. +func (w *Worker) Run(ctx context.Context) error { + w.runCtx = ctx + var wg sync.WaitGroup + for _, p := range w.pools { + wg.Go(func() { w.runPool(ctx, p) }) + } + wg.Wait() + return nil +} + +func (w *Worker) runPool(ctx context.Context, p *drainPool) { + ticker := time.NewTicker(workerPollInterval) + defer ticker.Stop() + for { + n, err := w.drain(ctx, p.concurrency, p.kinds...) + if err != nil && ctx.Err() == nil { + log.Warn(ctx, "Artwork: Worker drain failed", "pool", p.name, err) + } + if ctx.Err() != nil { + return + } + if n > 0 { + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// RunPrune runs prune under the worker's write lock, so no acquisition can place +// a file while orphans are being reclaimed. This is the only sanctioned prune path. +func (w *Worker) RunPrune(ctx context.Context) error { + w.pruneMu.Lock() + defer w.pruneMu.Unlock() + return prune(ctx, w.proc.ds, w.proc.store) +} + +// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, +// artists first. It reports whether anything was enqueued. +func (w *Worker) Backfill(ctx context.Context) (bool, error) { + return backfill(ctx, w.proc.ds) +} + +// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge. +func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { + return enqueueStaleAbsentAll(ctx, w.proc.ds) +} + +// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything +// a scan never enqueued. +func (w *Worker) EnqueueMissingAll(ctx context.Context) error { + return enqueueMissingAll(ctx, w.proc.ds) +} + +func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) { + // Dequeue well past the pool size so a slow external lookup never idles the other slots. + // DequeueBatch does not mark rows taken, so this is one query per pass, not per slot. + items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...) + if err != nil { + return 0, err + } + if len(items) == 0 { + return 0, nil + } + drainStart := time.Now() + // Private playlists need an admin identity; resolved per drain because the worker can + // start before any admin exists. + ctx = auth.WithAdminUser(ctx, w.proc.ds) + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + var refreshMu sync.Mutex + var refresh []model.ArtworkQueueItem + for _, item := range items { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + wg.Wait() + return len(items), nil + } + wg.Go(func() { + defer func() { <-sem }() + out, got := w.process(ctx, item) + // Absent counts as a visible change too: clients must drop a previously-served + // immutable cover. + if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent { + refreshMu.Lock() + refresh = append(refresh, item) + refreshMu.Unlock() + } + // Post-outcome: the queue row is already settled, so warming the cache can't + // block or alter queue ops. + if got != nil { + w.precache(ctx, got) + } + }) + } + wg.Wait() + w.broadcastRefresh(ctx, refresh) + log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items), + "refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart)) + return len(items), nil +} + +// artworkKindToResource maps a kind to its UI resource name; media_file maps to "song", so +// this can't derive from Kind.String(). +var artworkKindToResource = map[model.Kind]string{ + model.KindAlbumArtwork: "album", + model.KindArtistArtwork: "artist", + model.KindPlaylistArtwork: "playlist", + model.KindRadioArtwork: "radio", + model.KindMediaFileArtwork: "song", +} + +// broadcastRefresh emits one coalesced RefreshResource for the batch, so UIs re-fetch the +// affected records and pick up the new coverArt id. +func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) { + if len(found) == 0 { + return + } + event := &events.RefreshResource{} + byResource := map[string][]string{} + for _, it := range found { + kind, _ := model.ParseKind(it.ItemKind) + if res, ok := artworkKindToResource[kind]; ok { + byResource[res] = append(byResource[res], it.ItemID) + } + } + if len(byResource) == 0 { + return + } + for res, ids := range byResource { + event = event.With(res, ids...) + } + w.broker.SendBroadcastMessage(ctx, event) +} + +func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { + item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) + out, got := w.proc.acquire(ctx, item) + + queue := w.proc.ds.ArtworkQueue(ctx) + switch out { + case outcomeFound, outcomeAbsent: + // A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives + // here and the next drain re-resolves it. + if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { + log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + case outcomeFoundStale, outcomeFailed: + retryAt := time.Now().Add(backoff(item.Attempts)) + if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { + // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its + // fresh, immediate eligibility. + if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil { + log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, + "outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt), + "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) + break + } + // Absent is only recoverable where a periodic recheck revisits it, so other kinds keep + // no row; art already being served is kept, as exhaustion means unreachable, not removed. + settled := "kept previous state" + if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { + writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) + settled = "recorded absent" + } + log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, + "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) + if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { + log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + } + return out, got +} + +func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { + kind, ok := model.ParseKind(item.ItemKind) + if !ok { + return false + } + ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType) + return err == nil && ia.Hash != "" +} + +// precache warms the resize cache at the UI cover size from the bytes just acquired, so the +// first UI request hits without re-reading the rows or the file. +func (w *Worker) precache(ctx context.Context, got *acquired) { + if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) { + return + } + precacheStart := time.Now() + // Same key as the serving path: square must match what the list surfaces request, or this + // warms a key nothing reads. + item := &resizedItem{ + hash: got.ia.Hash, + size: conf.Server.UICoverArtSize, + square: true, + ffmpeg: w.ffmpeg, + open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil }, + } + stream, err := w.cache.Get(ctx, item) + if err != nil { + log.Debug(ctx, "Artwork: Precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err) + return + } + _, _ = io.Copy(io.Discard, stream) + _ = stream.Close() + log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID, + "size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart)) +} + +// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4]. +func backoffFor(attempts int, jitter float64) time.Duration { + d := min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter)) + return time.Duration(d * (1 + jitter)) +} + +func backoff(attempts int) time.Duration { + return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go new file mode 100644 index 000000000..eb7346102 --- /dev/null +++ b/core/artwork/worker_soak_test.go @@ -0,0 +1,146 @@ +package artwork + +import ( + "context" + "io" + "os" + "runtime" + "testing" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Deliberately >2000: a leak guard favors a stable signal over speed. +const soakCycles = 2200 + +var _ = Describe("Worker soak", func() { + It("does not leak goroutines, heap, or fds over many acquisition cycles", func() { + if testing.Short() { + Skip("skipping soak test in short mode") + } + DeferCleanup(configtest.SetupConfig()) + + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + folderRepo := &fakeFolderRepo{result: []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }}} + ffm := tests.NewMockFFmpeg("") + ag := agents.GetAgents(&tests.MockDataStore{}, nil) + artRepo := tests.CreateMockArtworkRepo() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, + {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + ds := &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedAlbum: albumRepo, + } + store := NewImageStore(GinkgoT().TempDir()) + proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} + conf.Server.CoverArtPriority = "cover.jpg, embedded" + + // Dangling refs mirror an entity deleted after being enqueued. + items := []model.ArtworkQueueItem{ + {ItemKind: "al", ItemID: "al-folder"}, + {ItemKind: "al", ItemID: "al-embed"}, + {ItemKind: "al", ItemID: "al-does-not-exist"}, + {ItemKind: "ra", ItemID: "ra-does-not-exist"}, + } + + fdCount := func() int { + if runtime.GOOS != "linux" { + return -1 + } + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return -1 + } + return len(entries) + } + + settleGoroutines := func() int { + // Background goroutines wind down late, so poll for two consecutive equal samples. + prev := -1 + for range 100 { + runtime.GC() + n := runtime.NumGoroutine() + if n == prev { + return n + } + prev = n + time.Sleep(10 * time.Millisecond) + } + return prev + } + + baselineGoroutines := settleGoroutines() + baselineFDs := fdCount() + + var heapAt10Pct uint64 + start := time.Now() + for i := range soakCycles { + it := items[i%len(items)] + out, _ := proc.acquire(context.Background(), it) + + // Read-back exercises the surfaces a caller would use after acquisition. + if out == outcomeFound { + kind, _ := model.ParseKind(it.ItemKind) + ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i) + rc, err := store.Open(ia.Hash, art.Mime) + switch { + case err == nil: + _, _ = io.Copy(io.Discard, rc) + rc.Close() + case os.IsNotExist(err): + // Folder-backed art has no store file; that's expected. + default: + Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i) + } + } + + if i == soakCycles/10 { + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + heapAt10Pct = ms.HeapAlloc + } + } + elapsed := time.Since(start) + + finalGoroutines := settleGoroutines() + finalFDs := fdCount() + + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n", + soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) + + Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) + if heapAt10Pct > 0 { + Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) + } + if runtime.GOOS == "linux" && baselineFDs >= 0 { + Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) + } + }) +}) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go new file mode 100644 index 000000000..1f3b52840 --- /dev/null +++ b/core/artwork/worker_test.go @@ -0,0 +1,813 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/goleak" +) + +type recordingCache struct { + cache.FileCache + mu sync.Mutex + keys []string + disabled bool +} + +func (c *recordingCache) Disabled(ctx context.Context) bool { + return c.disabled || c.FileCache.Disabled(ctx) +} + +func (c *recordingCache) Get(ctx context.Context, arg cache.Item) (*cache.CachedStream, error) { + c.mu.Lock() + c.keys = append(c.keys, arg.Key()) + c.mu.Unlock() + return c.FileCache.Get(ctx, arg) +} + +func (c *recordingCache) getKeys() []string { + c.mu.Lock() + defer c.mu.Unlock() + return slices.Clone(c.keys) +} + +// Simulates a concurrent Enqueue between DequeueBatch and the worker's delete, so +// DeleteIfUnchanged on the dequeued value no-ops. +type reenqueueOnDequeue struct { + *tests.MockArtworkQueueRepo + done bool +} + +func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...) + if !r.done && len(items) > 0 { + r.done = true + for k, it := range r.Data { + if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID { + it.RetryAt = items[0].RetryAt.Add(time.Minute) + r.Data[k] = it + } + } + } + return items, err +} + +type fakeEventBroker struct { + http.Handler + mu sync.Mutex + events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) getEvents() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.events) +} + +var _ events.Broker = (*fakeEventBroker)(nil) + +func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { + for _, it := range q.Data { + if it.ItemKind == kind && it.ItemID == id { + return &it + } + } + return nil +} + +var _ = Describe("Worker", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + store *ImageStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + broker *fakeEventBroker + imgCache *recordingCache + repoRoot string + w *Worker + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + } + ds.MockedAlbum = tests.CreateMockAlbumRepo() + store = NewImageStore(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg, embedded" + conf.Server.DevArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests + broker = &fakeEventBroker{} + imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + return arg.(artworkReader).Reader(ctx) + })} + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + }) + + Describe("drain", func() { + It("processes a seeded queue item and removes it from the queue", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero(), "a found item must be deleted from the queue") + }) + + It("processes an mf queue item, writing state and storing embedded bytes", func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).ToNot(BeEmpty()) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + r, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store") + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al4") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + + _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") + }) + + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "alstale") + Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry") + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "the served fallback art must live-refresh the UI") + Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("alstale")) + }) + + It("keeps a row re-enqueued between dequeue and delete", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, + }) + racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} + ds.MockedArtworkQueue = racing + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op. + Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + }) + + It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} + ds.MockedArtworkQueue = racing + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) + dequeued := findQueued(queueRepo, "al", "al8").RetryAt + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The re-enqueue reset retry_at; the failure path must not stomp it nor bump attempts. + it := findQueued(queueRepo, "al", "al8") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(BeZero()) + Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) + }) + + It("gives up and settles absent once the retry budget is exhausted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + // Age the row past the retry budget. + for k, v := range queueRepo.Data { + if v.ItemID == "al9" { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + queueRepo.Data[k] = v + } + } + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "al", "al9")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + }) + + It("keeps already-served art when the retry budget is exhausted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}}) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary, + Hash: "cafebabe", Source: "external:lastfm", + })).To(Succeed()) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) + for k, v := range queueRepo.Data { + if v.ItemID == "al10" { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + queueRepo.Data[k] = v + } + } + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "al", "al10")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") + }) + + // Media files are excluded from recheckKinds, so an absent row here would never be + // revisited: a transient read error would look permanent. + It("does not settle absent on exhaustion for a kind with no recheck path", func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) + for k, v := range queueRepo.Data { + if v.ItemID == "mfX" { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + queueRepo.Data[k] = v + } + } + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying") + _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), + "no row leaves the track unresolved, so a later view can still recover it") + }) + + It("resolves a private playlist under an admin context instead of failing forever", func() { + ds.MockedUser = adminUserRepo() + vds := &visibilityPlaylistDS{ + MockDataStore: ds, + private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, + tracks: &tests.MockPlaylistTrackRepo{}, + } + w = NewWorker(vds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + }) + + It("returns zero when the queue is empty", func() { + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + }) + + It("broadcasts a single refresh event for the found items in a batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}}, + {ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + + n, err := w.drain(ctx, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(3)) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "exactly one coalesced event per drain batch") + rr, ok := evts[0].(*events.RefreshResource) + Expect(ok).To(BeTrue()) + data := rr.Data(rr) + Expect(data).To(ContainSubstring(`"album"`)) + Expect(data).To(ContainSubstring("al1")) + Expect(data).To(ContainSubstring("al2")) + Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed") + Expect(data).ToNot(ContainSubstring("ar1")) + }) + + DescribeTable("only lists the kinds it actually resolved", + func(kinds []string, wantSong bool) { + items := slice.Map(kinds, func(k string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: k, ItemID: k + "1"} + }) + w.broadcastRefresh(ctx, items) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1)) + data := evts[0].(*events.RefreshResource).Data(evts[0]) + if wantSong { + Expect(data).To(ContainSubstring(`"song"`)) + } else { + Expect(data).ToNot(ContainSubstring(`"song"`)) + } + }, + // An album's tracks inherit its art, but the dependent ids are unbounded: the client + // fans an album refresh out to the tracks it has loaded. + Entry("album alone does not name songs", []string{"al"}, false), + Entry("artist alone does not", []string{"ar"}, false), + Entry("playlist alone does not", []string{"pl"}, false), + Entry("album mixed with others still does not", []string{"ar", "al"}, false), + Entry("songs resolving on their own are listed by id", []string{"mf"}, true), + ) + + It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { + conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) + folderRepo.result = nil + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it") + Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3")) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found") + }) + + It("does not broadcast when no item is found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(broker.getEvents()).To(BeEmpty(), "a drain with no found items sends no event") + }) + }) + + Describe("gate/breaker", func() { + It("opens after 5 consecutive external errors and short-circuits the step", func() { + var calls int + failing := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + for range 5 { + _, _, err := w.gate("A", failing) + Expect(err).To(HaveOccurred()) + } + Expect(calls).To(Equal(5)) + + _, _, err := w.gate("A", failing) + Expect(err).To(MatchError(errBreakerOpen)) + Expect(calls).To(Equal(5), "an open breaker must not call the external step") + }) + + It("resets the failure count on a successful call", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil } + for range 4 { + _, _, _ = w.gate("A", failing) + } + _, _, err := w.gate("A", ok) + Expect(err).ToNot(HaveOccurred()) + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + for range 5 { + _, _, _ = w.gate("A", counting) + } + Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") + }) + + It("does not open the breaker on a run of agent not-found misses", func() { + // agents.ErrNotFound is a definitive miss, not a fault: artless items must not + // trip the breaker, or they would loop in retry instead of settling absent. + notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } + for range breakerThreshold + 3 { + _, _, err := w.gate("A", notFound) + Expect(err).To(MatchError(agents.ErrNotFound), "a miss passes through, never errBreakerOpen") + } + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + _, _, _ = w.gate("A", counting) + Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") + }) + + It("isolates each agent's breaker: one open gate does not block another", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + for range breakerThreshold { + _, _, _ = w.gate("A", failing) + } + _, _, err := w.gate("A", failing) + Expect(err).To(MatchError(errBreakerOpen), "agent A's breaker is open") + + var bCalls int + bStep := func() (io.ReadCloser, string, error) { + bCalls++ + return io.NopCloser(nil), "p", nil + } + for range breakerThreshold + 2 { + _, _, err := w.gate("B", bStep) + Expect(err).ToNot(HaveOccurred(), "agent B keeps being called while A is open") + } + Expect(bCalls).To(Equal(breakerThreshold + 2)) + }) + }) + + Describe("precache", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, + }) + conf.Server.UICoverArtSize = 300 + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + }) + + It("warms the resize cache at the UI cover size after a found acquisition", func() { + conf.Server.EnableArtworkPrecache = true + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The list surfaces request square covers, so warming any other key is wasted work. + Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.true."))) + }) + + It("skips warming when precache is disabled", func() { + conf.Server.EnableArtworkPrecache = false + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(imgCache.getKeys()).To(BeEmpty()) + }) + + // It warms from the bytes acquisition already held, so no state row or store file is needed. + It("warms from the acquired bytes without reading them back", func() { + conf.Server.EnableArtworkPrecache = true + ia := &model.ItemArtwork{ + ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary, + Hash: "abcdef0123456789", UpdatedAt: time.Now(), + } + + data, err := os.ReadFile("tests/fixtures/artist/an-album/cover.jpg") + Expect(err).ToNot(HaveOccurred()) + + w.precache(ctx, &acquired{ia: ia, mime: "image/jpeg", data: data}) + + // Nothing backs that hash on disk, so a hit can only come from the bytes handed in; + // the probe refuses to open, proving nothing is re-read. + probe := &resizedItem{ + hash: ia.Hash, size: 300, square: true, ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return nil, errors.New("precache must not re-read the source") }, + } + stream, err := imgCache.Get(ctx, probe) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + Expect(io.ReadAll(stream)).ToNot(BeEmpty()) + }) + }) + + Describe("drain pools", func() { + // A kind in neither pool is never dequeued, with nothing to catch it at compile time. + It("covers every kind the worker can process, exactly once", func() { + var pooled []string + for _, p := range newDrainPools() { + pooled = append(pooled, p.kinds...) + } + for kind := range artworkKindToResource { + Expect(pooled).To(ContainElement(kind.Prefix()), "kind %q belongs to no drain pool", kind.Prefix()) + } + Expect(pooled).To(HaveLen(len(artworkKindToResource)), "a kind is claimed by more than one pool") + }) + + // Artists resolve through a rate-limited agent that holds its slot while waiting, so one + // shared pool would park every album behind them. + It("resolves albums while artists are stuck on a slow agent", func() { + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "external" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album", FolderIDs: []string{"f1"}}}) + ds.MockedArtist = tests.CreateMockArtistRepo() + + // Every artist lookup blocks until released, standing in for the rate limiter. + block := make(chan struct{}) + artists := model.Artists{} + for i := range 8 { + artists = append(artists, model.Artist{ID: fmt.Sprintf("arx%d", i), Name: "A"}) + } + ds.MockedArtist.(*tests.MockArtistRepo).SetData(artists) + imageAgents(&fakeImageAgent{name: "slowAgent", block: block}) + + // Artists first, exactly as Backfill orders them. + for _, a := range artists { + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill, + })).To(Succeed()) + } + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill, + })).To(Succeed()) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { defer close(done); _ = w.Run(runCtx) }() + // Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores. + DeferCleanup(func() { + cancel() + close(block) // unpark the blocked lookups so the pools can unwind + <-done + }) + + Eventually(func() bool { + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary) + return err == nil && ia.Hash != "" + }, 5*time.Second, 50*time.Millisecond).Should(BeTrue(), + "a blocked external pool must not hold up local artwork") + + _, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended") + }) + }) + + Describe("batching", func() { + It("leaves undispatched items queued when cancelled mid-batch", func() { + for i := range 8 { + id := fmt.Sprintf("alc%d", i) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: id, Name: "Album"}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + } + cancelledCtx, cancel := context.WithCancel(ctx) + cancel() + + _, err := w.drain(cancelledCtx, 1) + Expect(err).ToNot(HaveOccurred()) + + for i := range 8 { + id := fmt.Sprintf("alc%d", i) + Expect(findQueued(queueRepo, "al", id)).ToNot(BeNil(), "row "+id+" must survive a cancelled drain") + } + }) + + It("dequeues past the worker pool so one drain covers many items", func() { + for i := range 16 { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + } + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(16), "a batch sized to the pool would have stopped at 4") + }) + }) + + Describe("RunPrune", func() { + It("runs a prune under the worker mutex", func() { + Expect(w.RunPrune(ctx)).To(Succeed()) + }) + }) + + Describe("Run", func() { + It("exits cleanly when the context is cancelled", func() { + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- w.Run(runCtx) }() + + cancel() + Eventually(done, time.Second).Should(Receive(BeNil())) + }) + + It("does not leak goroutines after Run exits", func() { + DeferCleanup(configtest.SetupConfig()) + + ignore := goleak.IgnoreCurrent() + DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) + + localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} + lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, imgCache) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- lw.Run(runCtx) }() + + time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) + }) + }) +}) + +var _ = Describe("backoff", func() { + It("returns the expected schedule with no jitter", func() { + for _, c := range []struct { + attempts int + want time.Duration + }{ + {0, 5 * time.Second}, + {1, 20 * time.Second}, + {2, 80 * time.Second}, + {3, 320 * time.Second}, + {4, 1280 * time.Second}, + {5, 5120 * time.Second}, + {6, 20480 * time.Second}, + {7, 12 * time.Hour}, + {8, 12 * time.Hour}, + } { + Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) + } + }) + + It("applies jitter proportionally", func() { + base := backoffFor(2, 0) + Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) + Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) + }) + + It("keeps random jitter within +/-40%", func() { + lo := time.Duration(float64(320*time.Second) * 0.6) + hi := time.Duration(float64(320*time.Second) * 1.4) + for range 200 { + d := backoff(3) + Expect(d).To(BeNumerically(">=", lo)) + Expect(d).To(BeNumerically("<=", hi)) + } + }) +}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go new file mode 100644 index 000000000..f7315b20a --- /dev/null +++ b/core/artwork/worker_timing_test.go @@ -0,0 +1,76 @@ +package artwork + +import ( + "errors" + "io" + "testing" + "testing/synctest" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/gomega" +) + +// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest +// needs a *testing.T, which Ginkgo doesn't give. +func TestArtworkBreakerHalfOpen(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + b := newBreaker() + + for range breakerThreshold { + b.record("agentA", errors.New("boom")) + } + g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors") + + time.Sleep(breakerProbeAfter - time.Nanosecond) + g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval") + + time.Sleep(time.Nanosecond) + g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted") + g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval") + + b.record("agentA", errors.New("boom")) // probe fails -> stay open + time.Sleep(breakerProbeAfter) + g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval") + + b.record("agentA", nil) // probe succeeds -> close + g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely") + g.Expect(b.allow()).To(BeTrue()) + }) +} + +func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), + agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil) + + fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + for range breakerThreshold { + _, _, _ = w.gate("A", fail) + } + _, _, err := w.gate("A", fail) + g.Expect(err).To(MatchError(errBreakerOpen), "A opens after consecutive errors") + + // B has its own breaker, untouched by A being open. + var bCalls int + bStep := func() (io.ReadCloser, string, error) { bCalls++; return nil, "", errors.New("boom") } + for range breakerThreshold - 1 { + _, _, err := w.gate("B", bStep) + g.Expect(err).To(MatchError("boom")) + } + g.Expect(bCalls).To(Equal(breakerThreshold-1), "B keeps being called while A is open") + + // After the probe window, A admits exactly one probe again. + time.Sleep(breakerProbeAfter) + var aCalls int + aFail := func() (io.ReadCloser, string, error) { aCalls++; return nil, "", errors.New("boom") } + _, _, _ = w.gate("A", aFail) + g.Expect(aCalls).To(Equal(1), "A grants a single probe after the interval") + _, _, err = w.gate("A", aFail) + g.Expect(err).To(MatchError(errBreakerOpen), "the probe failed, so A stays open") + g.Expect(aCalls).To(Equal(1)) + }) +} diff --git a/core/external/provider.go b/core/external/provider.go index 869841e06..efd061c0c 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/url" "sort" "strings" "time" @@ -35,8 +34,6 @@ type Provider interface { UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) - ArtistImage(ctx context.Context, id string) (*url.URL, error) - AlbumImage(ctx context.Context, id string) (*url.URL, error) } type provider struct { @@ -258,7 +255,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au // Call all registered agents and collect information g := errgroup.Group{} g.SetLimit(2) - g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil }) + g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) @@ -370,76 +367,6 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in return similarSongs, nil } -func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, err - } - - imageUrl := artist.ArtistImageUrl() - if imageUrl == "" { - // No cached URL — must fetch from external source synchronously - e.callGetImage(ctx, e.ag, &artist) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) - return nil, ctx.Err() - } - imageUrl = artist.ArtistImageUrl() - } else { - // If cached info is expired, enqueue a background refresh so that config changes - // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. - updatedAt := V(artist.ExternalInfoUpdatedAt) - if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { - log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) - e.artistQueue.enqueue(&artist) - } - } - - if imageUrl == "" { - return nil, model.ErrNotFound - } - return url.Parse(imageUrl) -} - -func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { - album, err := e.getAlbum(ctx, id) - if err != nil { - return nil, err - } - - albumName := album.Name() - images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if err != nil { - switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist) - return nil, model.ErrNotFound - case errors.Is(err, context.Canceled): - log.Debug(ctx, "GetAlbumImages call canceled", err) - default: - log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err) - } - return nil, err - } - - if len(images) == 0 { - log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist) - return nil, model.ErrNotFound - } - - // Return the biggest image - var img agents.ExternalImage - for _, i := range images { - if img.Size <= i.Size { - img = i - } - } - if img.URL == "" { - return nil, model.ErrNotFound - } - return url.Parse(img.URL) -} - func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { artist, err := e.findArtist(ctx, artistName, id) if err != nil { @@ -524,10 +451,15 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ") } -func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) { +// callGetImage populates artist's image URLs. A transient agent failure is +// returned as-is; a definitive "no image" is normalized to model.ErrNotFound. +func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) error { images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return + if errors.Is(err, agents.ErrNotFound) { + return model.ErrNotFound + } + return err } sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size }) @@ -540,6 +472,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet if len(images) >= 3 { artist.SmallImageUrl = images[2].URL } + return nil } func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go deleted file mode 100644 index e801b7cce..000000000 --- a/core/external/provider_albumimage_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package external_test - -import ( - "context" - "errors" - "net/url" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - AlbumImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockAlbumAgent *mockAlbumInfoAgent - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockAlbum" // Configure mock agent - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockAlbumAgent = newMockAlbumInfoAgent() - - agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks - // Mocks for GetEntityByID sequence (initial failed lookups) - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - - // Default mock for non-existent entities - Use Maybe() for flexibility - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - }) - - It("returns the largest image URL when successful", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name - }) - - It("returns ErrNotFound if the album is not found in the DB", func() { - // Arrange: Explicitly expect the full GetEntityByID sequence for "not-found" - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - - imgURL, err := provider.AlbumImage(ctx, "not-found") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - agentErr := errors.New("agent failure") - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("agent failure")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns context error if context is canceled", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(ctx) - // Mock the necessary DB calls *before* canceling the context - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Expect the agent call even if context is cancelled, returning the context error - mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() - // Cancel the context *before* calling the function under test - cancelCtx() - - imgURL, err := provider.AlbumImage(cctx, "album-1") - - Expect(err).To(MatchError("context canceled")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - // Agent should now be called, verify this expectation - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "") - }) - - It("derives album ID from MediaFile ID", func() { - // Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1" - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once() - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "mf-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("handles different image orders from agent", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/single.jpg", Size: 700}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/single.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("returns ErrNotFound if deriving album ID fails", func() { - // Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found" - mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once() - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - - imgURL, err := provider.AlbumImage(ctx, "mf-no-album") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) - }) - - Context("Unicode handling in album names", func() { - var albumWithEnDash *model.Album - var expectedURL *url.URL - - const ( - originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash - normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in album name - albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/album.jpg") - - // Mock the album agent to return an image for the album - mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/album.jpg", Size: 1000}, - }, nil).Once() - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - - It("preserves Unicode characters in album names", func() { - // Act - imgURL, err := provider.AlbumImage(ctx, "album-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") - // This is the key assertion: ensure the original Unicode name is used - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.AlbumImage(ctx, "album-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "") - }) - }) - }) -}) - -// mockAlbumInfoAgent implementation -type mockAlbumInfoAgent struct { - mock.Mock - agents.AlbumInfoRetriever - agents.AlbumImageRetriever -} - -func newMockAlbumInfoAgent() *mockAlbumInfoAgent { - m := new(mockAlbumInfoAgent) - m.On("AgentName").Return("mockAlbum").Maybe() - return m -} - -func (m *mockAlbumInfoAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*agents.AlbumInfo), args.Error(1) -} - -func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]agents.ExternalImage), args.Error(1) -} - -// Ensure mockAgent implements the interfaces -var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) -var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go deleted file mode 100644 index 79612d651..000000000 --- a/core/external/provider_artistimage_test.go +++ /dev/null @@ -1,426 +0,0 @@ -package external_test - -import ( - "bytes" - "context" - "errors" - "net/url" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - ArtistImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockImageAgent *mockArtistImageAgent - var agentsCombined *mockAgents - var ctx context.Context - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockImage" // Configure only the mock agent - ctx = GinkgoT().Context() - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockImageAgent = newMockArtistImageAgent() - - // Use the mockAgents from helper, setting the specific agent - agentsCombined = &mockAgents{ - imageAgent: mockImageAgent, - } - - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks for successful Get calls - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe() - // Default mock for non-existent entities - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - - // Default successful image agent response - mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Maybe() - }) - - AfterEach(func() { - mockArtistRepo.AssertExpectations(GinkgoT()) - mockAlbumRepo.AssertExpectations(GinkgoT()) - mockMediaFileRepo.AssertExpectations(GinkgoT()) - mockImageAgent.AssertExpectations(GinkgoT()) - }) - - It("returns the largest image URL when successful", func() { - // Arrange - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the artist is not found in the DB", func() { - // Arrange - - // Act - imgURL, err := provider.ArtistImage(ctx, "not-found") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - agentErr := errors.New("agent failure") - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns context error if context is canceled before agent call", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(context.Background()) - mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) { - cancelCtx() // Cancel context *during* the DB call simulation - }).Once() - - // Act - imgURL, err := provider.ArtistImage(cctx, "artist-1") - - // Assert - Expect(err).To(MatchError(context.Canceled)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - }) - - It("derives artist ID from MediaFile ID", func() { - // Arrange: Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("derives artist ID from Album ID", func() { - // Arrange: Add mock for the initial GetEntityByID lookup - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "album-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if derived artist is not found", func() { - // Arrange - // Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once() - // Add expectation for the recursive GetEntityByID call for the MediaFileRepo - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - // The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("handles different image orders from agent", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/medium.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns cached URL and does not call agent when info is not expired", func() { - // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - cachedArtist := &model.Artist{ - ID: "artist-cached", - Name: "Cached Artist", - LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), - } - mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-cached") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) - - // Assert: background refresh was NOT enqueued - Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) - - }) - - It("returns stale URL and enqueues refresh when info is expired", func() { - // Arrange - conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - staleArtist := &model.Artist{ - ID: "artist-expired", - Name: "Expired Artist", - LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), - } - mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-expired") - - // Assert: returns stale URL immediately, no agent call - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) - - // Assert: background refresh was enqueued - Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) - }) - - Context("Unicode handling in artist names", func() { - var artistWithEnDash *model.Artist - var expectedURL *url.URL - - const ( - originalArtistName = "Run–D.M.C." // Artist name with en dash - normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in artist name like "Run–D.M.C." - artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/rundmc.jpg") - - // Mock the image agent to return an image for the artist - mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/rundmc.jpg", Size: 1000}, - }, nil).Once() - - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - It("preserves Unicode characters in artist names", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This is the key assertion: ensure the original Unicode name is used - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "") - }) - }) - }) -}) - -// mockArtistImageAgent implementation using testify/mock -// This remains local as it's specific to testing the ArtistImage functionality -type mockArtistImageAgent struct { - mock.Mock - agents.ArtistImageRetriever // Embed interface -} - -// Constructor for the mock agent -func newMockArtistImageAgent() *mockArtistImageAgent { - mock := new(mockArtistImageAgent) - // Set default AgentName if needed, although usually called via mockAgents - mock.On("AgentName").Return("mockImage").Maybe() - return mock -} - -func (m *mockArtistImageAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, id, artistName, mbid) - // Need careful type assertion for potentially nil slice - var res []agents.ExternalImage - if args.Get(0) != nil { - res = args.Get(0).([]agents.ExternalImage) - } - return res, args.Error(1) -} - -// Ensure mockAgent implements the interface -var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil) diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index f2866fb60..2a84d56b2 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -43,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -118,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -136,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -155,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -174,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -192,7 +192,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -226,7 +226,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -244,7 +244,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -258,7 +258,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -285,7 +285,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -311,7 +311,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -388,7 +388,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -448,7 +448,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("handles relative paths that reference files in other libraries", func() { @@ -604,7 +604,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -662,7 +662,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" plsFile := filepath.Join(tmpDir, "my-playlist.m3u") @@ -681,7 +681,7 @@ var _ = Describe("Playlists - Import", func() { libDir := filepath.Join(tmpDir, "music") Expect(os.Mkdir(libDir, 0755)).To(Succeed()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" plsFile := filepath.Join(tmpDir, "external.m3u") @@ -704,7 +704,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -724,7 +724,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -744,7 +744,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -767,7 +767,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 1ef083bbb..656bde05e 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -52,11 +52,12 @@ type Playlists interface { TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } -// ImageUploadService is a local interface satisfied by core.ImageUploadService. -// Defined here to avoid an import cycle between core and core/playlists. +// ImageUploadService is a local interface satisfied by artwork.Uploader. +// Defined here to avoid an import cycle between core/artwork and core/playlists. type ImageUploadService interface { SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) RemoveImage(ctx context.Context, path string) error + EnqueueArtwork(ctx context.Context, entityType, entityID string) } type playlists struct { @@ -320,7 +321,11 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } pls.UploadedImage = filename - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { @@ -334,5 +339,9 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { } pls.UploadedImage = "" - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 0c9674bed..e8134b2ef 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -8,7 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -42,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to delete their playlist", func() { @@ -82,7 +82,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("returns the playlist's track repository", func() { @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("creates a new playlist with owner set from context", func() { @@ -161,7 +161,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to update their playlist", func() { @@ -219,7 +219,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to add tracks", func() { @@ -267,7 +267,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to remove tracks", func() { @@ -301,7 +301,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to reorder", func() { @@ -330,7 +330,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("saves image file and updates UploadedImage", func() { @@ -400,7 +400,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("removes file and clears UploadedImage", func() { @@ -420,6 +420,24 @@ var _ = Describe("Playlists", func() { Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) + It("clears the resolved artwork state and re-queues after removing an upload", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", + })).To(Succeed()) + + Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) + + _, err := ds.Artwork(ctx).GetItemArtwork(model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pls-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-1") diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 58a327bde..7ae376b07 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -5,7 +5,7 @@ import ( "time" "github.com/deluan/rest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -37,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) Describe("Save", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index a2fffa34f..a09fcc108 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -24,8 +24,6 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, - NewImageUploadService, - wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)), stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, diff --git a/db/db.go b/db/db.go index 4ca996fe5..11a05b456 100644 --- a/db/db.go +++ b/db/db.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "embed" + "errors" "fmt" "time" @@ -106,6 +107,17 @@ func Init(ctx context.Context) func() { } } +// ErrorCodes reports the SQLite result code and extended result code carried by err. +// The extended code is what distinguishes errors that share a message: "database is locked" +// is both SQLITE_BUSY, which busy_timeout retries, and SQLITE_BUSY_SNAPSHOT, which it never can. +func ErrorCodes(err error) (code, extended int, ok bool) { + var se sqlite3.Error + if !errors.As(err, &se) { + return 0, 0, false + } + return int(se.Code), int(se.ExtendedCode), true +} + type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql new file mode 100644 index 000000000..e307d13e8 --- /dev/null +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -0,0 +1,46 @@ +-- +goose Up +CREATE TABLE artwork ( + hash TEXT PRIMARY KEY, + mime TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + blur_hash TEXT NOT NULL DEFAULT '', + thumb_hash TEXT NOT NULL DEFAULT '', + dominant_color TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE item_artwork ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + hash TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + ref_mtime INTEGER NOT NULL DEFAULT 0, + attempted_at TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +CREATE INDEX ix_item_artwork_hash ON item_artwork(hash); + +CREATE TABLE artwork_queue ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + priority INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + retry_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +-- Ordered to match DequeueBatch (priority DESC, enqueued_at) so drains stop after n rows; retry_at makes it covering. +-- item_kind leads: each drain pool dequeues only its own kinds, so it must seek straight to +-- them rather than scan past another pool's backlog. +CREATE INDEX ix_artwork_queue_drain ON artwork_queue(item_kind, priority DESC, enqueued_at, retry_at); + +-- +goose Down +DROP TABLE artwork_queue; +DROP TABLE item_artwork; +DROP TABLE artwork; diff --git a/go.mod b/go.mod index 5488b41e4..b72f4035c 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 + github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 golang.org/x/image v0.44.0 @@ -128,7 +129,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/log/formatters.go b/log/formatters.go index 0b27f3a43..5d864dae4 100644 --- a/log/formatters.go +++ b/log/formatters.go @@ -26,8 +26,15 @@ func ShortDur(d time.Duration) string { default: s = d.String() } - s = strings.TrimSuffix(s, "0s") - return strings.TrimSuffix(s, "0m") + // Drop whole zero-valued trailing components ("4h0m0s" -> "4h"). The suffix has to include + // the preceding unit, or a value that merely ends in a zero digit loses it: "10s" -> "1". + if strings.HasSuffix(s, "m0s") { + s = strings.TrimSuffix(s, "0s") + } + if strings.HasSuffix(s, "h0m") { + s = strings.TrimSuffix(s, "0m") + } + return s } func StringerValue(s fmt.Stringer) string { diff --git a/log/formatters_test.go b/log/formatters_test.go index 6ed43a094..64d72bdbd 100644 --- a/log/formatters_test.go +++ b/log/formatters_test.go @@ -25,6 +25,12 @@ var _ = DescribeTable("ShortDur", Entry("4m3s", 4*time.Minute+3*time.Second, "4m3s"), Entry("4h", 4*time.Hour, "4h"), Entry("4h", 4*time.Hour+2*time.Second, "4h"), + // A trailing zero digit is significant: only a whole zero-valued component may be dropped. + Entry("zero", time.Duration(0), "0s"), + Entry("10s", 10*time.Second, "10s"), + Entry("20s", 20*time.Second, "20s"), + Entry("1m30s", time.Minute+30*time.Second, "1m30s"), + Entry("2h30m", 2*time.Hour+30*time.Minute, "2h30m"), Entry("4h2m", 4*time.Hour+2*time.Minute+5*time.Second+200*time.Millisecond, "4h2m"), ) diff --git a/model/album.go b/model/album.go index 55df8c63c..f27ca12c0 100644 --- a/model/album.go +++ b/model/album.go @@ -13,6 +13,7 @@ import ( type Album struct { Annotations `structs:"-" hash:"ignore"` + ItemImage `structs:"-" hash:"ignore"` ID string `structs:"id" json:"id"` LibraryID int `structs:"library_id" json:"libraryId"` @@ -142,6 +143,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetAllIDs(...QueryOptions) ([]string, error) GetCursor(...QueryOptions) (AlbumCursor, error) GetYears(libraryIDs ...int) ([]int, error) diff --git a/model/artist.go b/model/artist.go index f9c4bffd5..f3704b669 100644 --- a/model/artist.go +++ b/model/artist.go @@ -11,6 +11,7 @@ import ( type Artist struct { Annotations `structs:"-"` + ItemImage `structs:"-"` ID string `structs:"id" json:"id"` @@ -89,6 +90,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/artwork.go b/model/artwork.go new file mode 100644 index 000000000..87b424f33 --- /dev/null +++ b/model/artwork.go @@ -0,0 +1,144 @@ +package model + +import "time" + +// Artwork is one unique image, identified by the XXH3-64 hash of its bytes. +type Artwork struct { + Hash string `structs:"hash"` + Mime string `structs:"mime"` + Width int `structs:"width"` + Height int `structs:"height"` + SizeBytes int64 `structs:"size_bytes"` + BlurHash string `structs:"blur_hash"` + ThumbHash string `structs:"thumb_hash"` + // DominantColor is "#rrggbb": a flat placeholder clients can paint before any decode. + DominantColor string `structs:"dominant_color"` + CreatedAt time.Time `structs:"created_at"` +} + +const ImageTypePrimary = "primary" + +// ItemImage is per-entity artwork state hydrated at query time; never persisted. +type ItemImage struct { + ImageHash string `structs:"-" json:"imageHash,omitempty"` + ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"` + // BlurHash is Jellyfin's; its mappers read this field directly, so it stays off native JSON. + BlurHash string `structs:"-" json:"-"` + ThumbHash string `structs:"-" json:"thumbHash,omitempty"` + // DominantColor is the only placeholder needing no decode, so it can paint on the first frame. + DominantColor string `structs:"-" json:"dominantColor,omitempty"` + // A thumbhash's own aspect is quantised, so clients need these to shape the placeholder exactly. + ImageWidth int `structs:"-" json:"imageWidth,omitempty"` + ImageHeight int `structs:"-" json:"imageHeight,omitempty"` +} + +// AspectRatio is the image's width/height, or nil when the image or its dimensions are unknown. +func (i ItemImage) AspectRatio() *float64 { + if i.ImageAbsent || i.ImageWidth <= 0 || i.ImageHeight <= 0 { + return nil + } + return new(float64(i.ImageWidth) / float64(i.ImageHeight)) +} + +// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. +type ItemArtwork struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Hash string `structs:"hash"` + Source string `structs:"source"` + // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. + SourcePath string `structs:"source_path"` + // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. + RefMtime int64 `structs:"ref_mtime"` + // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL. + AttemptedAt time.Time `structs:"attempted_at"` + UpdatedAt time.Time `structs:"updated_at"` +} + +// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). +type ItemArtworkInfo struct { + ItemID string + Hash string + BlurHash string + ThumbHash string + DominantColor string + Width int + Height int +} + +// Absent reports a known-absent artwork state (resolved, no image). +func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" } + +// Image projects the hydration entry onto the entity-facing struct. +func (i ItemArtworkInfo) Image() ItemImage { + return ItemImage{ + ImageHash: i.Hash, + ImageAbsent: i.Absent(), + BlurHash: i.BlurHash, + ThumbHash: i.ThumbHash, + DominantColor: i.DominantColor, + ImageWidth: i.Width, + ImageHeight: i.Height, + } +} + +type ArtworkQueueItem struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + RetryAt time.Time `structs:"retry_at"` + EnqueuedAt time.Time `structs:"enqueued_at"` +} + +// Queue priorities: higher drains first. +const ( + ArtworkPriorityRecheck = 0 + ArtworkPriorityBackfill = 10 + ArtworkPriorityScan = 50 + ArtworkPriorityBump = 100 +) + +// Delete* takes the rows to remove; Purge* finds them itself and reports how many went. +type ArtworkRepository interface { + GetImage(hash string) (*Artwork, error) + PutImage(a *Artwork) error + // PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff. + PurgeOrphans(createdBefore time.Time) (int64, error) + GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error) + PutItemArtwork(ia *ItemArtwork) error + DeleteForItems(kind Kind, ids []string) error + // GetInfoForItems hydrates a page in one batched query. + GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error) + // GetMimeByHash returns hash -> current mime for every stored artwork. + GetMimeByHash() (map[string]string, error) + // PurgeDanglingItems removes state rows whose entity no longer exists. + PurgeDanglingItems() (int64, error) +} + +type ArtworkQueueRepository interface { + // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. + Enqueue(items ...ArtworkQueueItem) error + // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a + // request-triggered read-through never resets a failed resolution's backoff. + EnqueuePreservingBackoff(items ...ArtworkQueueItem) error + // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. + EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error) + // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. + EnqueueAllMissing(kind Kind, priority int) (int64, error) + // EnqueueIfMissing inserts only for items with no item_artwork row yet. + EnqueueIfMissing(items ...ArtworkQueueItem) error + // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. + // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. + DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error) + // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches + // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error + // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. + DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error + Count() (int64, error) + // PurgeDangling removes queue rows whose entity no longer exists. + PurgeDangling() (int64, error) +} diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..634a6442f 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -17,6 +17,11 @@ func (k Kind) String() string { return k.name } +// Prefix is the short token used in artwork ids and the item_artwork.item_kind column. +func (k Kind) Prefix() string { + return k.prefix +} + var ( KindMediaFileArtwork = Kind{"mf", "media_file"} KindArtistArtwork = Kind{"ar", "artist"} @@ -35,10 +40,18 @@ var artworkKindMap = map[string]Kind{ KindRadioArtwork.prefix: KindRadioArtwork, } +// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known. +// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain. +func ParseKind(prefix string) (Kind, bool) { + k, ok := artworkKindMap[prefix] + return k, ok +} + type ArtworkID struct { Kind Kind ID string - LastUpdate time.Time + Hash string // content-hash suffix; "" = unknown/none + LastUpdate time.Time // legacy: populated only when parsing old _<hexTimestamp> tokens } func (id ArtworkID) String() string { @@ -46,14 +59,14 @@ func (id ArtworkID) String() string { return "" } s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if lu := id.LastUpdate.Unix(); lu > 0 { - return fmt.Sprintf("%s_%x", s, lu) + if id.Hash != "" { + return s + "_" + id.Hash } - return s + "_0" + return s } func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { - artID := ArtworkID{kind, id, time.Time{}} + artID := ArtworkID{Kind: kind, ID: id} if lastUpdate != nil { artID.LastUpdate = *lastUpdate } @@ -75,18 +88,34 @@ func ParseArtworkID(id string) (ArtworkID, error) { } parts = strings.SplitN(parts[1], "_", 2) if len(parts) == 2 { - if parts[1] != "0" { - lastUpdate, err := strconv.ParseInt(parts[1], 16, 64) - if err != nil { - return ArtworkID{}, err - } - parsedID.LastUpdate = time.Unix(lastUpdate, 0) - } parsedID.ID = parts[0] + suffix := parts[1] + switch { + // Hash detection must come first: a 16-hex value with the high bit set overflows int64. + case isImageHash(suffix): + parsedID.Hash = suffix + case suffix != "0": + if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil { + parsedID.LastUpdate = time.Unix(lastUpdate, 0) + } + } } return parsedID, nil } +// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash. +func isImageHash(s string) bool { + if len(s) != 16 { + return false + } + for _, c := range s { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} + func MustParseArtworkID(id string) ArtworkID { artID, err := ParseArtworkID(id) if err != nil { @@ -112,40 +141,21 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { - return ArtworkID{ - Kind: KindAlbumArtwork, - ID: al.ID, - LastUpdate: al.UpdatedAt, - } + return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash} } func artworkIDFromMediaFile(mf MediaFile) ArtworkID { - return ArtworkID{ - Kind: KindMediaFileArtwork, - ID: mf.ID, - LastUpdate: mf.UpdatedAt, - } + return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash} } func artworkIDFromPlaylist(pls Playlist) ArtworkID { - return ArtworkID{ - Kind: KindPlaylistArtwork, - ID: pls.ID, - LastUpdate: pls.UpdatedAt, - } + return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash} } func artworkIDFromArtist(ar Artist) ArtworkID { - return ArtworkID{ - Kind: KindArtistArtwork, - ID: ar.ID, - } + return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash} } func artworkIDFromRadio(r Radio) ArtworkID { - return ArtworkID{ - Kind: KindRadioArtwork, - ID: r.ID, - LastUpdate: r.UpdatedAt, - } + return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash} } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index ad66f7bb5..af6a12ffb 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -9,14 +9,31 @@ import ( ) var _ = Describe("ArtworkID", func() { + Describe("String()", func() { + It("returns a bare id when there is no hash", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234"} + Expect(id.String()).To(Equal("al-1234")) + }) + It("appends the hash suffix when set", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234", Hash: "abcdef0123456789"} + Expect(id.String()).To(Equal("al-1234_abcdef0123456789")) + }) + It("never emits a legacy timestamp/_0 suffix", func() { + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) + Expect(id.String()).To(Equal("al-1234")) + }) + It("returns empty string for an empty id", func() { + Expect(model.ArtworkID{Kind: model.KindAlbumArtwork}.String()).To(BeEmpty()) + }) + }) + Describe("NewArtworkID()", func() { - It("creates a valid parseable ArtworkID", func() { + It("round-trips Kind and ID through String()", func() { id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) It("creates a valid ArtworkID without lastUpdate info", func() { id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil) @@ -24,18 +41,16 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) + Describe("ParseArtworkID - disc kind", func() { It("parses a disc artwork ID with dc prefix", func() { - now := time.Now() - id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now) + id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", nil) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) Expect(parsedId.ID).To(Equal("albumid123:2")) - Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) }) }) @@ -67,6 +82,7 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(BeEmpty()) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1") @@ -74,12 +90,45 @@ var _ = Describe("ArtworkID", func() { Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) }) - It("parses playlists artwork ids", func() { + It("parses playlist artwork ids with dashed UUID", func() { id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindPlaylistArtwork)) Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a")) }) + It("captures a 16-hex suffix as Hash", func() { + id, err := model.ParseArtworkID("al-1234_abcdef0123456789") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("abcdef0123456789")) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("captures a high-bit 16-hex suffix as Hash without error", func() { + id, err := model.ParseArtworkID("al-1234_ffffffffffffffff") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("ffffffffffffffff")) + }) + It("parses a legacy hex-timestamp suffix as LastUpdate", func() { + id, err := model.ParseArtworkID("al-123_688a1b2c") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.Unix()).To(Equal(int64(0x688a1b2c))) + }) + It("parses a legacy _0 suffix", func() { + id, err := model.ParseArtworkID("al-123_0") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("silently drops a garbage suffix", func() { + id, err := model.ParseArtworkID("al-123_zz") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") Expect(err).To(MatchError("invalid artwork id")) diff --git a/model/artwork_test.go b/model/artwork_test.go new file mode 100644 index 000000000..baf741e68 --- /dev/null +++ b/model/artwork_test.go @@ -0,0 +1,73 @@ +package model_test + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func jsonOf(v any) map[string]any { + GinkgoHelper() + data, err := json.Marshal(v) + Expect(err).ToNot(HaveOccurred()) + var out map[string]any + Expect(json.Unmarshal(data, &out)).To(Succeed()) + return out +} + +var _ = Describe("ItemImage JSON", func() { + It("exposes the artwork state a client needs to render a placeholder", func() { + al := model.Album{ID: "al-1", Name: "Album"} + al.ImageHash = "0123456789abcdef" + al.ThumbHash = "1QcSHQRn" + al.ImageWidth, al.ImageHeight = 1200, 800 + + Expect(jsonOf(al)).To(SatisfyAll( + HaveKeyWithValue("imageHash", "0123456789abcdef"), + HaveKeyWithValue("thumbHash", "1QcSHQRn"), + HaveKeyWithValue("imageWidth", BeNumerically("==", 1200)), + HaveKeyWithValue("imageHeight", BeNumerically("==", 800)), + )) + }) + + It("keeps the blurhash off the native API, where nothing consumes it", func() { + al := model.Album{ID: "al-1", Name: "Album"} + al.BlurHash = "LEHV6nWB2yk8" + Expect(jsonOf(al)).ToNot(HaveKey("blurHash")) + }) + + It("omits every artwork field when the entity has none", func() { + out := jsonOf(model.Album{ID: "al-2", Name: "Album"}) + for _, key := range []string{ + "imageHash", "blurHash", "thumbHash", "imageAbsent", "imageWidth", "imageHeight", + } { + Expect(out).ToNot(HaveKey(key)) + } + }) + + It("exposes known-absent artwork so clients can skip the request", func() { + ar := model.Artist{ID: "ar-1", Name: "Artist"} + ar.ImageAbsent = true + Expect(jsonOf(ar)).To(HaveKeyWithValue("imageAbsent", true)) + }) + + Describe("AspectRatio", func() { + It("returns width/height", func() { + img := model.ItemImage{ImageHash: "abc", ImageWidth: 1200, ImageHeight: 800} + Expect(*img.AspectRatio()).To(BeNumerically("~", 1.5, 0.0001)) + }) + + It("returns nil when a dimension is missing, so callers never guess a ratio", func() { + Expect(model.ItemImage{ImageHash: "abc"}.AspectRatio()).To(BeNil()) + Expect(model.ItemImage{ImageHash: "abc", ImageWidth: 1200}.AspectRatio()).To(BeNil()) + Expect(model.ItemImage{ImageHash: "abc", ImageHeight: 800}.AspectRatio()).To(BeNil()) + }) + + It("returns nil for a known-absent image, whatever the dimensions say", func() { + img := model.ItemImage{ImageAbsent: true, ImageWidth: 1200, ImageHeight: 800} + Expect(img.AspectRatio()).To(BeNil()) + }) + }) +}) diff --git a/model/datastore.go b/model/datastore.go index 94c3c3622..273ca714b 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -40,6 +40,8 @@ type DataStore interface { ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository Scrobble(ctx context.Context) ScrobbleRepository Plugin(ctx context.Context) PluginRepository + Artwork(ctx context.Context) ArtworkRepository + ArtworkQueue(ctx context.Context) ArtworkQueueRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/mediafile.go b/model/mediafile.go index 22ab7fbbe..ac1fad67b 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -24,6 +24,11 @@ import ( type MediaFile struct { Annotations `structs:"-" hash:"ignore"` Bookmarkable `structs:"-" hash:"ignore"` + ItemImage `structs:"-" hash:"ignore"` + + // AlbumImage is the parent album's artwork state, hydrated alongside the track's own so a + // song's Jellyfin album-art tag can be pixel-versioned without a second query. + AlbumImage ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"` @@ -139,13 +144,15 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + return ArtworkID{Kind: KindDiscArtwork, ID: DiscArtworkID(mf.AlbumID, mf.DiscNumber), Hash: mf.ImageHash} } return mf.AlbumCoverArtID() } +// AlbumCoverArtID uses AlbumImage, not the track's own ItemImage: an album id must carry the +// album's content hash even when the track resolved art of its own. func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.AlbumImage}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { @@ -542,6 +549,11 @@ type MediaFileRepository interface { GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) + // GetAllIDs returns just the media_file IDs for the same row set as GetAll. + GetAllIDs(options ...QueryOptions) ([]string, error) + // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't + // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. + GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error DeleteMissing(ids []string) error DeleteAllMissing() (int64, error) diff --git a/model/playlist.go b/model/playlist.go index 185f6f942..9aa54bf13 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -13,6 +13,7 @@ import ( type Playlist struct { Annotations `structs:"-"` + ItemImage `structs:"-"` ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` @@ -143,6 +144,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error diff --git a/model/radio.go b/model/radio.go index 86f27c24c..466ff48b0 100644 --- a/model/radio.go +++ b/model/radio.go @@ -7,6 +7,8 @@ import ( ) type Radio struct { + ItemImage `structs:"-"` + ID string `structs:"id" json:"id"` StreamUrl string `structs:"stream_url" json:"streamUrl"` Name string `structs:"name" json:"name"` @@ -30,7 +32,9 @@ type RadioRepository interface { ResourceRepository CountAll(options ...QueryOptions) (int64, error) Delete(id string) error + Exists(id string) (bool, error) Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) + GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go index 860331f17..6ae74c3f4 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -14,12 +14,11 @@ import ( var _ = Describe("Radio", func() { Describe("CoverArtID", func() { It("returns a radio artwork ID", func() { - now := time.Now() - r := model.Radio{ID: "rd-1", UpdatedAt: now} + r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()} artID := r.CoverArtID() Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) Expect(artID.ID).To(Equal("rd-1")) - Expect(artID.LastUpdate).To(Equal(now)) + Expect(artID.LastUpdate.IsZero()).To(BeTrue()) }) }) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 2bb541003..1ebb09751 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -202,7 +202,9 @@ func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) } func (r *albumRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"album.id": id}) + // The exists() helper applies no library filter, so it would report rows the caller cannot see. + c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"album.id": id}))) + return c > 0, err } func (r *albumRepository) Put(al *model.Album) error { @@ -251,16 +253,36 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e if err != nil { return nil, err } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil +} + +func (r *albumRepository) hydrateArtwork(albums model.Albums) { + hydrateItems(r.ctx, r.db, model.KindAlbumArtwork, albums, + func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) +} + +// GetAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. +func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "album.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err } func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - sq := r.selectAlbum(options...) - cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) + ids, err := r.GetAllIDs(options...) if err != nil { return nil, err } - return wrapAlbumCursor(cursor), nil + opts := chunkOptions(options, "album.id") + return model.AlbumCursor(streamByIDs(ids, func(chunk []string) (model.Albums, error) { + return r.GetAll(opts(chunk)) + })), nil } func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { @@ -405,7 +427,9 @@ func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model if err != nil { return nil, fmt.Errorf("searching album %q: %w", q, err) } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil } func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 465617b10..1ead89529 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -84,6 +85,17 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := albumRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + ids, err := albumRepo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) @@ -977,6 +989,22 @@ var _ = Describe("AlbumRepository", func() { Expect(got.RGAlbumPeak).To(BeNil()) }) }) + + // Exists must apply the same library filter as Get/GetAll/CountAll. + Describe("Exists library visibility", func() { + It("hides an album the user has no library access to", func() { + Expect(albumRepo.Put(&model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "vis-album"})) + }) + + Expect(albumRepo.Exists("vis-album")).To(BeTrue(), "admin sees it") + + restricted := model.User{ID: "restricted_album_user", UserName: "ra", Name: "RA", Email: "ra@t.com"} + rctx := request.WithUser(GinkgoT().Context(), restricted) + Expect(NewAlbumRepository(rctx, GetDBXBuilder()).Exists("vis-album")).To(BeFalse()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..67d0df448 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "iter" "os" "slices" "strings" @@ -250,6 +249,7 @@ func (r *artistRepository) Get(id string) (*model.Artist, error) { return nil, model.ErrNotFound } res := dba.toModels() + r.hydrateArtwork(res) return &res[0], nil } @@ -261,20 +261,37 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return nil, err } res := dba.toModels() + r.hydrateArtwork(res) return res, err } +// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// heavy stats columns and JSON post-processing. +func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "artist.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + +// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *artistRepository) hydrateArtwork(artists model.Artists) { + hydrateItems(r.ctx, r.db, model.KindArtistArtwork, artists, + func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) +} + func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - sel := r.selectArtist(options...) - cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) + ids, err := r.GetAllIDs(options...) if err != nil { return nil, err } - return wrapArtistCursor(cursor), nil -} - -func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor { - return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist })) + opts := chunkOptions(options, "artist.id") + return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) { + return r.GetAll(opts(chunk)) + })), nil } func (r *artistRepository) getIndexKey(a model.Artist) string { @@ -635,7 +652,9 @@ func (r *artistRepository) Search(q string, options ...model.QueryOptions) (mode if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } - return res.toModels(), nil + artists := res.toModels() + r.hydrateArtwork(artists) + return artists, nil } // searchScope returns the library IDs the search must be restricted to, or nil to skip the filter diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index dc11ede36..25472ffe1 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -284,6 +285,17 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go new file mode 100644 index 000000000..fa9920f27 --- /dev/null +++ b/persistence/artwork_hydration.go @@ -0,0 +1,181 @@ +package persistence + +import ( + "context" + "iter" + "slices" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// Keeps the id IN-list under SQLite's bound-parameter limit; a whole multiple of artworkBatchSize +// so a page re-chunks into even hydration batches. +const artworkChunkSize = artworkBatchSize * 3 + +// streamByIDs yields rows in id chunks through the caller's hydrating fetch. Resolving ids first +// keeps OFFSET out of the joined query. +func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, error)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + for chunk := range slices.Chunk(ids, artworkChunkSize) { + rows, err := fetch(chunk) + if err != nil { + var zero T + yield(zero, err) + return + } + for _, row := range rows { + if !yield(row, nil) { + return + } + } + } + } +} + +// chunkOptions narrows options to a chunk of ids, dropping Max/Offset (the id pre-pass already +// consumed them) and reusing Sort, which may be a seeded random expression by now. +func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions { + var base model.QueryOptions + if len(options) > 0 { + base = model.QueryOptions{Sort: options[0].Sort, Order: options[0].Order, Filters: options[0].Filters} + } + return func(chunk []string) model.QueryOptions { + opts := base + opts.Filters = Eq{idField: chunk} + if base.Filters != nil { + opts.Filters = And{base.Filters, opts.Filters} + } + return opts + } +} + +// hydrateItemImages returns per-item artwork info in one batched query per kind. On error it logs +// and returns an empty map, so the page still renders. +func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo { + if len(ids) == 0 { + return map[string]model.ItemArtworkInfo{} + } + infos, err := NewArtworkRepository(ctx, db).GetInfoForItems(kind, ids) + if err != nil { + log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) + return map[string]model.ItemArtworkInfo{} + } + return infos +} + +// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved). +func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) { + if info, ok := infos[id]; ok { + *img = info.Image() + } +} + +// hydrateItems fills every item's embedded ItemImage from one batched lookup. ref yields an +// item's id and the ItemImage to fill, which is all that differs between the entity kinds. +func hydrateItems[T any](ctx context.Context, db dbx.Builder, kind model.Kind, items []T, + ref func(*T) (string, *model.ItemImage)) { + ids := make([]string, len(items)) + for i := range items { + ids[i], _ = ref(&items[i]) + } + infos := hydrateItemImages(ctx, db, kind, ids) + for i := range items { + id, img := ref(&items[i]) + applyItemImage(infos, id, img) + } +} + +// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own +// art uses it, else it falls back to the album's. +func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) { + if len(mfs) == 0 { + return + } + albumIDs := make([]string, len(mfs)) + var eligibleIDs []string + for i := range mfs { + albumIDs[i] = mfs[i].AlbumID + if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { + eligibleIDs = append(eligibleIDs, mfs[i].ID) + } + } + albumInfos := hydrateItemImages(ctx, db, model.KindAlbumArtwork, albumIDs) + mfInfos := hydrateItemImages(ctx, db, model.KindMediaFileArtwork, eligibleIDs) + for i := range mfs { + mf := &mfs[i] + applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage) + eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt + ownInfo, ownResolved := mfInfos[mf.ID] + if eligible && ownResolved && !ownInfo.Absent() { + mf.ItemImage = ownInfo.Image() + continue + } + ownWontResolve := !eligible || (ownResolved && ownInfo.Absent()) + // Inherit the album hash only when serving returns those exact bytes: a multi-disc track is + // served disc art, and an eligible-but-unresolved one still extracts its own embedded image. + if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { + if mf.DiscNumber == 0 && ownWontResolve { + mf.ItemImage = album.Image() + } + continue + } + // Mark absent only when serving would definitively yield a placeholder; disc art and + // still-extractable embedded art both keep a track requestable. + if mf.DiscNumber > 0 { + continue + } + if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve { + mf.ImageAbsent = true + } + } +} + +// hydrateCursor hydrates a streamed cursor in batches, avoiding a per-row query. +func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + buf := make([]T, 0, artworkBatchSize) + flush := func() bool { + hydrate(buf) + for i := range buf { + if !yield(buf[i], nil) { + return false + } + } + buf = buf[:0] + return true + } + for row, err := range cursor { + if err != nil { + var zero T + yield(zero, err) + return + } + buf = append(buf, row) + if len(buf) == artworkBatchSize && !flush() { + return + } + } + if len(buf) > 0 { + flush() + } + } +} + +// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track. +func hydratePlaylistTrackArtwork(ctx context.Context, db dbx.Builder, tracks model.PlaylistTracks) { + if len(tracks) == 0 { + return + } + mfs := make(model.MediaFiles, len(tracks)) + for i := range tracks { + mfs[i] = tracks[i].MediaFile + } + hydrateMediaFileArtwork(ctx, db, mfs) + for i := range tracks { + tracks[i].MediaFile = mfs[i] + } +} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go new file mode 100644 index 000000000..2b52d7d82 --- /dev/null +++ b/persistence/artwork_hydration_test.go @@ -0,0 +1,773 @@ +package persistence + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +// seedAnnotations gives each id distinct annotation values, writing them directly so that +// SetRating's average_rating update does not outlive the cleanup. +func seedAnnotations(itemType string, ids ...string) { + GinkgoHelper() + when := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) + for i, id := range ids { + _, err := GetDBXBuilder().NewQuery(`insert or replace into annotation + (user_id, item_id, item_type, play_count, play_date, rating, rated_at, starred, starred_at) + values ({:u}, {:id}, {:t}, {:pc}, {:d}, {:r}, {:d}, 1, {:d})`). + Bind(dbx.Params{"u": adminUser.ID, "id": id, "t": itemType, + "pc": i + 1, "r": 4 + i, "d": when.Add(time.Duration(i) * time.Hour)}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + DeferCleanup(func() { + for _, id := range ids { + _, err := GetDBXBuilder().NewQuery("delete from annotation where user_id={:u} and item_type={:t} and item_id={:id}"). + Bind(dbx.Params{"u": adminUser.ID, "t": itemType, "id": id}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + }) +} + +var _ = Describe("Artwork hydration", func() { + var ctx context.Context + var aw model.ArtworkRepository + + putInfo := func(kind, id, hash string) { + Expect(aw.PutItemArtwork(&model.ItemArtwork{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, + })).To(Succeed()) + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + ctx = request.WithUser(log.NewContext(context.Background()), adminUser) + aw = NewArtworkRepository(ctx, GetDBXBuilder()) + }) + + Describe("albums", func() { + var repo model.AlbumRepository + BeforeEach(func() { repo = NewAlbumRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("al", albumSgtPeppers.ID, "althash11111111") + putInfo("al", albumAbbeyRoad.ID, "") + // albumRadioactivity: no row -> unresolved + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(a model.Album) (string, model.Album) { return a.ID, a }) + + Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111")) + Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse()) + Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) + Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("al", albumSgtPeppers.ID, "gethash22222222") + got, err := repo.Get(albumSgtPeppers.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("gethash22222222")) + }) + + It("hydrates Search", func() { + putInfo("al", albumSgtPeppers.ID, "srchash33333333") + res, err := repo.Search("Peppers", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("srchash33333333")) + }) + + It("does not persist ImageHash/ImageAbsent on Put", func() { + al := albumSgtPeppers + al.ImageHash = "shouldnotpersist" + al.ImageAbsent = true + Expect(repo.(*albumRepository).Put(&al)).To(Succeed()) + + got, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(BeEmpty()) + Expect(got.ImageAbsent).To(BeFalse()) + }) + }) + + Describe("artists", func() { + var repo model.ArtistRepository + BeforeEach(func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("ar", artistBeatles.ID, "arhash444444444") + putInfo("ar", artistKraftwerk.ID, "") + // artistCJK: no row -> unresolved + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(a model.Artist) (string, model.Artist) { return a.ID, a }) + + Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444")) + Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse()) + Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue()) + Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("ar", artistBeatles.ID, "arget5555555555") + got, err := repo.Get(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("arget5555555555")) + }) + + It("hydrates Search", func() { + putInfo("ar", artistBeatles.ID, "arsrch666666666") + res, err := repo.Search("Beatles", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("arsrch666666666")) + }) + }) + + Describe("playlists", func() { + var repo model.PlaylistRepository + BeforeEach(func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("pl", plsBest.ID, "plhash777777777") + putInfo("pl", plsCool.ID, "") + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(p model.Playlist) (string, model.Playlist) { return p.ID, p }) + + Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777")) + Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse()) + Expect(byID[plsCool.ID].ImageHash).To(BeEmpty()) + Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("pl", plsBest.ID, "plget8888888888") + got, err := repo.Get(plsBest.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("plget8888888888")) + }) + + It("hydrates the tracks reached through a playlist", func() { + Expect(aw.PutImage(&model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed()) + putInfo("al", songDayInALife.AlbumID, "pltrackhash1234") + + pls, err := repo.GetWithTracks(plsBest.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + tracks := pls.Tracks + Expect(tracks).ToNot(BeEmpty()) + byID := slice.ToMap(tracks, func(t model.PlaylistTrack) (string, model.PlaylistTrack) { return t.MediaFile.ID, t }) + Expect(byID).To(HaveKey(songDayInALife.ID)) + Expect(byID[songDayInALife.ID].AlbumImage.ImageHash).To(Equal("pltrackhash1234")) + Expect(byID[songDayInALife.ID].BlurHash).To(Equal("LPLBLURhash")) + + cursor, err := repo.Tracks(plsBest.ID, true).GetCursor() + Expect(err).ToNot(HaveOccurred()) + var streamed *model.PlaylistTrack + for t, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + if t.MediaFile.ID == songDayInALife.ID { + streamed = &t + } + } + Expect(streamed).ToNot(BeNil()) + Expect(streamed.AlbumImage.ImageHash).To(Equal("pltrackhash1234"), + "the streamed cursor Jellyfin uses must hydrate too") + }) + }) + + Describe("radios", func() { + var repo model.RadioRepository + BeforeEach(func() { repo = NewRadioRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("ra", radioWithHomePage.ID, "rahash999999999") + putInfo("ra", radioWithoutHomePage.ID, "") + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(rd model.Radio) (string, model.Radio) { return rd.ID, rd }) + + Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999")) + Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse()) + Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty()) + Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") + got, err := repo.Get(radioWithHomePage.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) + }) + }) + + Describe("mediafiles", func() { + var repo model.MediaFileRepository + + setCover := func(id string, v bool) { + _, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}"). + Bind(dbx.Params{"v": v, "id": id}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + + getByID := func() map[string]model.MediaFile { + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf }) + } + + BeforeEach(func() { + repo = NewMediaFileRepository(ctx, GetDBXBuilder()) + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + + It("resolves the embedded-eligible fallback matrix", func() { + setCover("1001", true) // eligible, own hash + setCover("1002", true) // eligible, but embedded art absent -> album + DeferCleanup(func() { setCover("1001", false); setCover("1002", false) }) + + putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found) + putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found) + putInfo("al", "103", "") // songs 1003/1004 album known-absent + putInfo("mf", "1001", "mfh1001xxxxxxxx") + putInfo("mf", "1002", "") // embedded resolved absent + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx")) + Expect(byID["1002"].ImageAbsent).To(BeFalse()) + Expect(byID["1003"].ImageHash).To(BeEmpty()) + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + // 2002: not eligible, and its album has no row at all -> unresolved + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("populates AlbumImage from hydrateArtwork regardless of which continue branch a track takes", func() { + setCover("1001", true) // eligible, resolves its own art -> own-art-wins continue + DeferCleanup(func() { setCover("1001", false) }) + + putInfo("al", "101", "alh101albimgxxxx") // 1001's album: own-art-wins branch + putInfo("al", "102", "alh102albimgxxxx") // 1002's album: single-disc inheritance branch + putInfo("al", "104", "") // 2002's album: known-absent, multi-disc branch + putInfo("mf", "1001", "mfh1001albimgxxx") + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx")) + Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx")) + + Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx")) + Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx")) + + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue()) + }) + + It("carries both hashes and the dimensions alongside the hash in the own-art and inherited branches", func() { + setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch + DeferCleanup(func() { setCover("1001", false) }) + + Expect(aw.PutImage(&model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) + Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) + putInfo("mf", "1001", "mfh1001blurxxxxx") + putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx")) + Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur")) + Expect(byID["1001"].ThumbHash).To(Equal("THtrack")) + Expect(byID["1001"].ImageWidth).To(Equal(640)) + Expect(byID["1001"].ImageHeight).To(Equal(480)) + + Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx")) + Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur")) + Expect(byID["1002"].ThumbHash).To(Equal("THalbum")) + Expect(byID["1002"].ImageWidth).To(Equal(1200)) + Expect(byID["1002"].ImageHeight).To(Equal(800)) + }) + + It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "") // album known-absent + + byID := getByID() + + // 1004's own embedded art is still unresolved, so coverArt must stay requestable. + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(BeEmpty()) + // 1003 is not eligible, so it still inherits the album's absence. + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + }) + + It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() { + putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found + + byID := getByID() + + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { + putInfo("al", "104", "") // songs 2002/2004 album known-absent + + byID := getByID() + + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + Expect(byID["2002"].ImageHash).To(BeEmpty()) + }) + + // serveMediaFile serves this track's own embedded art, so the album's hash would + // advertise a content-version for bytes nobody will serve. + It("leaves the hash bare for an eligible file whose own art is unresolved", func() { + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "alh103found11111") + + byID := getByID() + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(BeEmpty()) + Expect(byID["1004"].AlbumImage.ImageHash).To(Equal("alh103found11111"), + "the album's own hash still hydrates, for AlbumCoverArtID") + }) + + It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { + conf.Server.EnableMediaFileCoverArt = false + setCover("1001", true) + DeferCleanup(func() { setCover("1001", false) }) + + putInfo("al", "101", "alh101offxxxxxxx") + putInfo("mf", "1001", "mfh1001offxxxxx") + + byID := getByID() + Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Search", func() { + putInfo("al", "101", "alsrchhhhhhhhhhh") + res, err := repo.Search("A Day In A Life", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) + }) + }) + + Describe("GetCursor", func() { + var albumRepo model.AlbumRepository + var artistRepo model.ArtistRepository + var playlistRepo model.PlaylistRepository + var onlyAlbums, onlyArtists, onlyPlaylists squirrel.Eq + + scoped := func(opts model.QueryOptions, only squirrel.Eq) model.QueryOptions { + if opts.Filters == nil { + opts.Filters = only + } else { + opts.Filters = squirrel.And{only, opts.Filters} + } + return opts + } + + BeforeEach(func() { + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()) + artistRepo = NewArtistRepository(ctx, GetDBXBuilder()) + playlistRepo = NewPlaylistRepository(ctx, GetDBXBuilder()) + // Other specs leave rows behind, so scope every cursor spec to the fixtures. + onlyAlbums = squirrel.Eq{"album.id": []string{albumSgtPeppers.ID, albumAbbeyRoad.ID, + albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}} + onlyArtists = squirrel.Eq{"artist.id": []string{artistKraftwerk.ID, artistBeatles.ID, + artistCJK.ID, artistPunctuation.ID}} + // Both fixture playlists share an owner, leaving the owner_name sort a single value to + // order by; this one is also private, which the non-admin visibility spec needs. + foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName} + Expect(playlistRepo.Put(&foreign)).To(Succeed()) + DeferCleanup(func() { Expect(playlistRepo.Delete(foreign.ID)).To(Succeed()) }) + onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}} + + // The suite annotates a single album and artist, leaving the annotation-backed sorts + // nothing to order. + seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID) + seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID) + + Expect(aw.PutImage(&model.Artwork{ + Hash: "curhash11111111", Mime: "image/jpeg", BlurHash: "LEHV6nWB2yk8", + })).To(Succeed()) + putInfo("al", albumSgtPeppers.ID, "curhash11111111") + putInfo("al", albumAbbeyRoad.ID, "") + putInfo("ar", artistBeatles.ID, "curhash11111111") + putInfo("pl", plsBest.ID, "curhash11111111") + }) + + It("hydrates every streamed album, like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(got).To(ConsistOf(want)) + byID := map[string]model.Album{} + for _, al := range got { + byID[al.ID] = al + } + Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("curhash11111111")) + Expect(byID[albumSgtPeppers.ID].BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) + Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates every streamed artist, like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyArtists} + want, err := artistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(artistRepo.GetCursor(opts)) + + Expect(got).To(ConsistOf(want)) + Expect(slice.Map(got, func(a model.Artist) string { return a.ImageHash })). + To(ContainElement("curhash11111111")) + }) + + It("hydrates every streamed playlist, like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} + want, err := playlistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(playlistRepo.GetCursor(opts)) + + Expect(got).To(ConsistOf(want)) + Expect(slice.Map(got, func(p model.Playlist) string { return p.ImageHash })). + To(ContainElement("curhash11111111")) + }) + + It("honors Max and Offset exactly once", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums, Max: 2, Offset: 1} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(slice.Map(got, func(a model.Album) string { return a.ID })). + To(Equal(slice.Map(want, func(a model.Album) string { return a.ID }))) + }) + + // The sorts the Jellyfin list endpoints issue; comparing sort keys, not ids, keeps ties out. + DescribeTable("orders albums like GetAll", + func(opts model.QueryOptions, key func(model.Album) string) { + opts = scoped(opts, onlyAlbums) + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) + Expect(slice.Map(got, func(a model.Album) string { return a.ID })). + To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) + }, + Entry("by name", model.QueryOptions{Sort: "name"}, + func(a model.Album) string { return a.OrderAlbumName }), + Entry("by artist", model.QueryOptions{Sort: "artist"}, + func(a model.Album) string { return a.OrderAlbumArtistName }), + Entry("by recently added", model.QueryOptions{Sort: "recently_added", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.CreatedAt) }), + Entry("by year", model.QueryOptions{Sort: "max_year"}, + func(a model.Album) string { return fmt.Sprint(a.MaxYear) }), + Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.PlayCount) }), + Entry("by last played", model.QueryOptions{Sort: "play_date", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.PlayDate) }), + Entry("by rating", model.QueryOptions{Sort: "rating", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.Rating) }), + Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", + Filters: squirrel.Eq{"starred": true}}, + func(a model.Album) string { return fmt.Sprint(a.StarredAt) }), + ) + + DescribeTable("orders artists like GetAll", + func(opts model.QueryOptions, key func(model.Artist) string) { + opts = scoped(opts, onlyArtists) + want, err := artistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(artistRepo.GetCursor(opts)) + + Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) + Expect(slice.Map(got, func(a model.Artist) string { return a.ID })). + To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) + }, + Entry("by name", model.QueryOptions{Sort: "name"}, + func(a model.Artist) string { return a.OrderArtistName }), + Entry("by album count", model.QueryOptions{Sort: "album_count", Order: "desc"}, + func(a model.Artist) string { return fmt.Sprint(a.AlbumCount) }), + Entry("by song count", model.QueryOptions{Sort: "song_count", Order: "desc"}, + func(a model.Artist) string { return fmt.Sprint(a.SongCount) }), + Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, + func(a model.Artist) string { return fmt.Sprint(a.PlayCount) }), + Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", + Filters: squirrel.Eq{"starred": true}}, + func(a model.Artist) string { return fmt.Sprint(a.StarredAt) }), + ) + + DescribeTable("orders playlists like GetAll", + func(opts model.QueryOptions, key func(model.Playlist) string) { + opts = scoped(opts, onlyPlaylists) + want, err := playlistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(playlistRepo.GetCursor(opts)) + + Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) + Expect(slice.Map(got, func(p model.Playlist) string { return p.ID })). + To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) + }, + Entry("by name", model.QueryOptions{Sort: "name"}, + func(p model.Playlist) string { return p.Name }), + Entry("by creation date", model.QueryOptions{Sort: "created_at", Order: "desc"}, + func(p model.Playlist) string { return fmt.Sprint(p.CreatedAt) }), + Entry("by owner", model.QueryOptions{Sort: "owner_name"}, + func(p model.Playlist) string { return p.OwnerName }), + ) + + It("streams every album exactly once when sorted randomly", func() { + opts := model.QueryOptions{Sort: "random", Filters: onlyAlbums} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(slice.Map(got, func(a model.Album) string { return a.ID })). + To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) + }) + + It("keeps a non-admin from streaming another user's private playlists", func() { + otherCtx := request.WithUser(log.NewContext(context.Background()), regularUser) + repo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} + + // Both phases must filter on their own: the id pre-pass and the chunk fetch. + Expect(repo.GetAllIDs(opts)).To(ConsistOf(plsBest.ID)) + all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists}) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) + + got := collectCursor(repo.GetCursor(opts)) + + Expect(slice.Map(got, func(p model.Playlist) string { return p.Name })). + To(ConsistOf(plsBest.Name)) + }) + }) + + Describe("GetCursorWithArtwork", func() { + var mfRepo model.MediaFileRepository + var onlySongs squirrel.Eq + + BeforeEach(func() { + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + putInfo("al", albumSgtPeppers.ID, "curhash11111111") + // Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which + // would make the positional comparisons against GetAll pass by tie-order coincidence. + onlySongs = squirrel.Eq{"media_file.id": []string{songDayInALife.ID, songComeTogether.ID, + songRadioactivity.ID, songAntenna.ID, songDisc1Track01.ID, songCJK.ID, songPunctuation.ID}} + }) + + It("hydrates artwork onto every streamed track, unlike GetCursor", func() { + opts := model.QueryOptions{Sort: "title", Filters: onlySongs} + want, err := mfRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + cursor, err := mfRepo.GetCursorWithArtwork(opts) + Expect(err).ToNot(HaveOccurred()) + var got model.MediaFiles + cursor(func(mf model.MediaFile, err error) bool { + Expect(err).ToNot(HaveOccurred()) + got = append(got, mf) + return true + }) + + Expect(got).To(HaveLen(len(want))) + for i := range want { + Expect(got[i].ID).To(Equal(want[i].ID)) + Expect(got[i].ImageHash).To(Equal(want[i].ImageHash)) + Expect(got[i].ImageAbsent).To(Equal(want[i].ImageAbsent)) + Expect(got[i].AlbumImage.ImageHash).To(Equal(want[i].AlbumImage.ImageHash)) + Expect(got[i].AlbumImage.BlurHash).To(Equal(want[i].AlbumImage.BlurHash)) + } + Expect(want).To(ContainElement(HaveField("AlbumImage.ImageHash", Not(BeEmpty()))), + "fixture must include at least one track with album artwork, or this proves nothing") + }) + + It("leaves the scanner's GetCursor unhydrated", func() { + cursor, err := mfRepo.GetCursor(model.QueryOptions{Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + var seen int + cursor(func(mf model.MediaFile, err error) bool { + Expect(err).ToNot(HaveOccurred()) + Expect(mf.AlbumImage.ImageHash).To(BeEmpty(), "GetCursor must stay unhydrated for the scanner") + seen++ + return true + }) + Expect(seen).To(BeNumerically(">", 0)) + }) + + It("streams the same ids in the same order as GetAll", func() { + opts := model.QueryOptions{Sort: "title", Filters: onlySongs} + want, err := mfRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(mfRepo.GetCursorWithArtwork(opts)) + + Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). + To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) + }) + + It("honors Max and Offset exactly once", func() { + opts := model.QueryOptions{Sort: "title", Filters: onlySongs, Max: 2, Offset: 1} + want, err := mfRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + got := collectCursor(mfRepo.GetCursorWithArtwork(opts)) + + Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). + To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) + }) + }) + + Describe("chunkOptions", func() { + It("carries Sort, Order and the caller's filters, but never Max/Offset", func() { + base := model.QueryOptions{Sort: "name", Order: "desc", Max: 10, Offset: 20, + Filters: squirrel.Eq{"missing": false}} + + got := chunkOptions([]model.QueryOptions{base}, "album.id")([]string{"al-1", "al-2"}) + + Expect(got.Max).To(BeZero()) + Expect(got.Offset).To(BeZero()) + Expect(got.Sort).To(Equal("name")) + Expect(got.Order).To(Equal("desc")) + Expect(got.Filters).To(Equal(squirrel.And{ + base.Filters, squirrel.Eq{"album.id": []string{"al-1", "al-2"}}, + })) + }) + + It("filters by ids alone when the caller passed no filters", func() { + got := chunkOptions(nil, "artist.id")([]string{"ar-1"}) + Expect(got.Filters).To(Equal(squirrel.Eq{"artist.id": []string{"ar-1"}})) + }) + }) + + Describe("streamByIDs", func() { + It("fetches in chunks and yields every row in order", func() { + ids := make([]string, artworkChunkSize+3) + for i := range ids { + ids[i] = fmt.Sprintf("id-%d", i) + } + var chunks [][]string + got := collectCursor(streamByIDs(ids, func(chunk []string) ([]string, error) { + chunks = append(chunks, chunk) + return chunk, nil + }), nil) + + Expect(chunks).To(HaveLen(2)) + Expect(chunks[0]).To(HaveLen(artworkChunkSize)) + Expect(chunks[1]).To(HaveLen(3)) + Expect(got).To(Equal(ids)) + }) + + It("yields the fetch error and stops", func() { + ids := make([]string, artworkChunkSize+1) + calls := 0 + var errs []error + for _, err := range streamByIDs(ids, func(chunk []string) ([]string, error) { + calls++ + return nil, errors.New("boom") + }) { + errs = append(errs, err) + } + + Expect(calls).To(Equal(1)) + Expect(errs).To(HaveLen(1)) + Expect(errs[0]).To(MatchError("boom")) + }) + + It("stops fetching when the consumer breaks", func() { + ids := make([]string, artworkChunkSize+1) + calls := 0 + for range streamByIDs(ids, func(chunk []string) ([]string, error) { + calls++ + return chunk, nil + }) { + break + } + + Expect(calls).To(Equal(1)) + }) + }) + + Describe("applyItemImage", func() { + It("copies hash, absence, blurhash and dimensions onto the item", func() { + infos := map[string]model.ItemArtworkInfo{ + "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", ThumbHash: "1QcSHQRn", Width: 1200, Height: 800}, + } + var img model.ItemImage + applyItemImage(infos, "al-1", &img) + Expect(img.ImageHash).To(Equal("0123456789abcdef")) + Expect(img.ImageAbsent).To(BeFalse()) + Expect(img.BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(img.ThumbHash).To(Equal("1QcSHQRn")) + Expect(img.ImageWidth).To(Equal(1200)) + Expect(img.ImageHeight).To(Equal(800)) + }) + + It("marks a hashless entry absent and carries no blurhash", func() { + infos := map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2"}} + var img model.ItemImage + applyItemImage(infos, "al-2", &img) + Expect(img.ImageAbsent).To(BeTrue()) + Expect(img.BlurHash).To(BeEmpty()) + Expect(img.ThumbHash).To(BeEmpty()) + }) + + It("leaves an unresolved item zero-valued", func() { + var img model.ItemImage + applyItemImage(map[string]model.ItemArtworkInfo{}, "al-3", &img) + Expect(img.ImageHash).To(BeEmpty()) + Expect(img.ImageAbsent).To(BeFalse()) + Expect(img.BlurHash).To(BeEmpty()) + }) + }) +}) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go new file mode 100644 index 000000000..e5469fbea --- /dev/null +++ b/persistence/artwork_queue_repository.go @@ -0,0 +1,149 @@ +package persistence + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// Keeps each multi-row insert under SQLite's bind-variable limit (at most 7 vars per row). +const enqueueChunkSize = 100 + +// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. +var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} + +type artworkQueueRepository struct { + sqlRepository +} + +func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.ArtworkQueueRepository { + r := &artworkQueueRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork_queue" + return r +} + +// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget. +func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, + attempts = 0, enqueued_at = excluded.enqueued_at`, items) +} + +func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority)`, items) +} + +func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { + now := time.Now() + return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ? + FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`, + model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore) +} + +func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { + entityTable, ok := artworkOwnerTables[kind] + if !ok { + return 0, fmt.Errorf("artwork queue: no entity table for kind %q", kind.Prefix()) + } + now := time.Now() + return r.insertIfNotQueued("", `SELECT ?, id, ?, ?, 0, ?, ? + FROM `+entityTable+` + WHERE id NOT IN (SELECT item_id FROM `+itemArtworkTable+` WHERE item_kind = ?)`, + kind.Prefix(), model.ImageTypePrimary, priority, now, now, kind.Prefix()) +} + +func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueItem) error { + now := time.Now() + for chunk := range slices.Chunk(items, enqueueChunkSize) { + rows := make([]string, 0, len(chunk)) + args := make([]any, 0, len(chunk)*4+2) + for _, it := range chunk { + rows = append(rows, "(?,?,?,?)") + args = append(args, it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority) + } + args = append(args, now, now) + _, err := r.insertIfNotQueued( + `WITH new_items(item_kind, item_id, image_type, priority) AS (VALUES `+strings.Join(rows, ",")+`) `, + `SELECT n.item_kind, n.item_id, n.image_type, n.priority, 0, ?, ? + FROM new_items n + WHERE NOT EXISTS ( + SELECT 1 FROM `+itemArtworkTable+` ia + WHERE ia.item_kind = n.item_kind AND ia.item_id = n.item_id AND ia.image_type = n.image_type)`, + args...) + if err != nil { + return err + } + } + return nil +} + +// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is +// deliberate: a recheck must not bump the priority or retry_at of an already-queued item. +func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) { + return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+ + ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+ + ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...)) +} + +func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { + now := time.Now() + for chunk := range slices.Chunk(items, enqueueChunkSize) { + ins := Insert(r.tableName).Columns(enqueueColumns...) + for _, it := range chunk { + ins = ins.Values(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority, 0, now, now) + } + ins = ins.Suffix(conflict) + if _, err := r.executeSQL(ins); err != nil { + return err + } + } + return nil +} + +func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + sel := Select("*").From(r.tableName). + Where(LtOrEq{"retry_at": time.Now()}). + OrderBy("priority DESC", "enqueued_at ASC"). + Limit(uint64(n)) + if len(kinds) > 0 { + sel = sel.Where(Eq{"item_kind": kinds}) + } + var res []model.ArtworkQueueItem + err := r.queryAll(sel, &res) + return res, err +} + +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + upd := Update(r.tableName). + Set("attempts", Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) + _, err := r.executeSQL(upd) + return err +} + +func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) +} + +func (r *artworkQueueRepository) PurgeDangling() (int64, error) { + return purgeDangling(r.sqlRepository) +} + +func (r *artworkQueueRepository) Count() (int64, error) { + var res struct{ Count int64 } + err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) + return res.Count, err +} + +var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go new file mode 100644 index 000000000..6638a204e --- /dev/null +++ b/persistence/artwork_queue_repository_test.go @@ -0,0 +1,284 @@ +package persistence + +import ( + "context" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("ArtworkQueueRepository", func() { + var repo model.ArtworkQueueRepository + + item := func(kind, id string, prio int) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, + ImageType: model.ImageTypePrimary, Priority: prio} + } + + // Writes the post-failure state directly: the production path, MarkFailedIfUnchanged, needs + // a retry_at only a dequeue can hand it. + backOff := func(kind, id string, retryAt time.Time) { + GinkgoHelper() + r := repo.(*artworkQueueRepository) + _, err := r.executeSQL(squirrel.Update(r.tableName). + Set("attempts", squirrel.Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})) + Expect(err).ToNot(HaveOccurred()) + } + + remove := func(kind, id string) { + GinkgoHelper() + r := repo.(*artworkQueueRepository) + Expect(r.delete(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})).To(Succeed()) + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + repo = NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + }) + + It("enqueues and dequeues by priority then FIFO", func() { + Expect(repo.Enqueue(item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(2)) + Expect(got[0].ItemID).To(Equal("high")) + }) + + It("keeps the higher priority on duplicate enqueue", func() { + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) + }) + + It("EnqueuePreservingBackoff raises priority without resetting a backing-off row's retry_at", func() { + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "b1", time.Now().Add(time.Hour)) + Expect(repo.DequeueBatch(10)).To(BeEmpty()) + + Expect(repo.EnqueuePreservingBackoff(item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.DequeueBatch(10)).To(BeEmpty(), "bump must not reset retry_at") + + // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") + }) + + It("EnqueuePreservingBackoff inserts a brand-new row eligible immediately", func() { + Expect(repo.EnqueuePreservingBackoff(item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].ItemID).To(Equal("n1")) + }) + + It("hides failed items until retry_at", func() { + Expect(repo.Enqueue(item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "f1", time.Now().Add(time.Hour)) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeEmpty()) + + backOff("al", "f1", time.Now().Add(-time.Minute)) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Attempts).To(Equal(2)) + }) + + It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past so it can never collide with the re-enqueue's now. + backOff("al", "m1", time.Now().Add(-time.Hour)) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") + Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") + current := got[0].RetryAt + + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") + all, _ := repo.Count() + Expect(all).To(Equal(int64(1))) + }) + + It("Enqueue restarts the retry budget an existing row had spent", func() { + Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "e1", time.Now().Add(-time.Hour)) + stale := time.Now().Add(-48 * time.Hour) + _, err := GetDBXBuilder().NewQuery("UPDATE artwork_queue SET enqueued_at = {:t} WHERE item_id = 'e1'"). + Bind(dbx.Params{"t": stale}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + Expect(got[0].EnqueuedAt).To(BeTemporally("~", time.Now(), time.Minute), + "a re-request must not inherit a spent 12h window and give up on its first attempt") + Expect(got[0].Attempts).To(BeZero()) + }) + + It("deletes on completion and counts", func() { + Expect(repo.Enqueue(item("al", "c1", 0))).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + remove("al", "c1") + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past so it can never collide with the re-enqueue's now. + backOff("al", "d1", time.Now().Add(-time.Hour)) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + + // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, original)).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("purges queue rows whose entity no longer exists, per kind", func() { + Expect(repo.Enqueue( + item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), + item("al", "no-such-album", model.ArtworkPriorityScan), + item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), + item("ar", "no-such-artist", model.ArtworkPriorityScan), + item("pl", plsBest.ID, model.ArtworkPriorityScan), + item("pl", "no-such-playlist", model.ArtworkPriorityScan), + item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), + item("ra", "no-such-radio", model.ArtworkPriorityScan), + item("mf", songDayInALife.ID, model.ArtworkPriorityScan), + item("mf", "no-such-mediafile", model.ArtworkPriorityScan), + )).To(Succeed()) + + purged, err := repo.PurgeDangling() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(5))) + + got, _ := repo.DequeueBatch(100) + ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) + }) + + It("enqueues stale absent states for recheck", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + old := time.Now().Add(-48 * time.Hour) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) + + n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + items, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(items).To(HaveLen(1)) + Expect(items[0].ItemID).To(Equal("stale1")) + Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) + }) + + It("enqueues entities that have no item_artwork row at all", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + + n, err := repo.EnqueueAllMissing(model.KindAlbumArtwork, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeNumerically(">=", 1)) + + got, err := repo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, 0, len(got)) + for _, it := range got { + Expect(it.ItemKind).To(Equal("al")) + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + ids = append(ids, it.ItemID) + } + Expect(ids).To(ContainElement(albumRadioactivity.ID), "an album with no row must be enqueued") + Expect(ids).ToNot(ContainElement(albumSgtPeppers.ID), "a resolved album must not be re-enqueued") + Expect(ids).ToNot(ContainElement(albumAbbeyRoad.ID), "an absent-state album must not be enqueued as missing") + }) + + It("EnqueueIfMissing skips items that already have an item_artwork row", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "resolved", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "absent", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + + Expect(repo.EnqueueIfMissing( + item("al", "resolved", model.ArtworkPriorityScan), + item("al", "absent", model.ArtworkPriorityScan), + item("al", "brandnew", model.ArtworkPriorityScan), + )).To(Succeed()) + + got, err := repo.DequeueBatch(100) + Expect(err).ToNot(HaveOccurred()) + ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + Expect(ids).To(ConsistOf("brandnew"), "only an item with no state row may be enqueued") + }) + + It("EnqueueIfMissing leaves an already-queued row untouched", func() { + Expect(repo.Enqueue(item("al", "queued", model.ArtworkPriorityBump))).To(Succeed()) + + Expect(repo.EnqueueIfMissing(item("al", "queued", model.ArtworkPriorityScan))).To(Succeed()) + + got, _ := repo.DequeueBatch(100) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") + }) + + It("does not disturb an already-queued entity when enqueueing missing rows", func() { + Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) + + _, err := repo.EnqueueAllMissing(model.KindAlbumArtwork, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + + got, _ := repo.DequeueBatch(1000) + var count int + for _, it := range got { + if it.ItemID == albumRadioactivity.ID { + count++ + Expect(it.Priority).To(Equal(model.ArtworkPriorityBump), "existing bump priority must survive") + } + } + Expect(count).To(Equal(1), "the already-queued row must not be duplicated") + }) +}) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go new file mode 100644 index 000000000..22662b575 --- /dev/null +++ b/persistence/artwork_repository.go @@ -0,0 +1,176 @@ +package persistence + +import ( + "cmp" + "context" + "slices" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +const ( + itemArtworkTable = "item_artwork" + artworkBatchSize = 200 +) + +type artworkRepository struct { + sqlRepository + items sqlRepository +} + +func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository { + r := &artworkRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork" + r.items.ctx = ctx + r.items.db = db + r.items.tableName = itemArtworkTable + return r +} + +func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) { + sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) + var res model.Artwork + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *artworkRepository) PutImage(a *model.Artwork) error { + // created_at is the last-acquisition-write time the prune grace window keys on. + a.CreatedAt = time.Now() + values, err := toSQLArgs(*a) + if err != nil { + return err + } + // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. + ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, + thumb_hash=excluded.thumb_hash, dominant_color=excluded.dominant_color, + created_at=excluded.created_at`) + _, err = r.executeSQL(ins) + return err +} + +func (r *artworkRepository) GetMimeByHash() (map[string]string, error) { + sel := Select("hash", "mime").From(r.tableName) + var rows []struct { + Hash string + Mime string + } + if err := r.queryAll(sel, &rows); err != nil { + return nil, err + } + res := make(map[string]string, len(rows)) + for _, row := range rows { + res[row.Hash] = row.Mime + } + return res, nil +} + +func (r *artworkRepository) PurgeOrphans(createdBefore time.Time) (int64, error) { + del := Delete(r.tableName).Where(And{ + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), + }) + return r.executeSQL(del) +} + +// artworkOwnerTables maps an artwork kind to the table that owns the entity. +var artworkOwnerTables = map[model.Kind]string{ + model.KindAlbumArtwork: "album", + model.KindArtistArtwork: "artist", + model.KindPlaylistArtwork: "playlist", + model.KindRadioArtwork: "radio", + model.KindMediaFileArtwork: "media_file", +} + +// purgeDangling deletes rows in r's table whose owning entity is gone, one statement per kind. +func purgeDangling(r sqlRepository) (int64, error) { + var total int64 + for kind, entityTable := range artworkOwnerTables { + del := Delete(r.tableName).Where(And{ + Eq{"item_kind": kind.Prefix()}, + Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), + }) + c, err := r.executeSQL(del) + if err != nil { + return total, err + } + total += c + } + return total, nil +} + +func (r *artworkRepository) PurgeDanglingItems() (int64, error) { + return purgeDangling(r.items) +} + +func (r *artworkRepository) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { + sel := Select("*").From(itemArtworkTable). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) + var res model.ItemArtwork + if err := r.items.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { + ia.ImageType = cmp.Or(ia.ImageType, model.ImageTypePrimary) + ia.UpdatedAt = time.Now() + // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } + values, err := toSQLArgs(*ia) + if err != nil { + return err + } + ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, + attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) + _, err = r.items.executeSQL(ins) + return err +} + +func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error { + for chunk := range slices.Chunk(ids, artworkBatchSize) { + if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { + return err + } + } + return nil +} + +func (r *artworkRepository) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { + res := map[string]model.ItemArtworkInfo{} + for chunk := range slices.Chunk(ids, artworkBatchSize) { + sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash", + "COALESCE(a.thumb_hash, '') as thumb_hash", + "COALESCE(a.dominant_color, '') as dominant_color", + "COALESCE(a.width, 0) as width", "COALESCE(a.height, 0) as height"). + From(itemArtworkTable + " ia"). + LeftJoin("artwork a ON a.hash = ia.hash"). + Where(And{ + Eq{"ia.item_kind": kind.Prefix()}, + Eq{"ia.image_type": model.ImageTypePrimary}, + Eq{"ia.item_id": chunk}, + }) + var rows []model.ItemArtworkInfo + if err := r.items.queryAll(sel, &rows); err != nil { + return nil, err + } + for _, row := range rows { + res[row.ItemID] = row + } + } + return res, nil +} + +var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go new file mode 100644 index 000000000..683dc2d0f --- /dev/null +++ b/persistence/artwork_repository_test.go @@ -0,0 +1,248 @@ +package persistence + +import ( + "context" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +func clearArtworkTables() { + db := GetDBXBuilder() + for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { + _, err := db.NewQuery("DELETE FROM " + t).Execute() + Expect(err).ToNot(HaveOccurred()) + } +} + +var _ = Describe("ArtworkRepository", func() { + var repo model.ArtworkRepository + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) + }) + + Context("image identity", func() { + It("stores and retrieves an artwork by hash", func() { + a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} + Expect(repo.PutImage(a)).To(Succeed()) + + got, err := repo.GetImage("abc123") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Mime).To(Equal("image/jpeg")) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.CreatedAt).ToNot(BeZero()) + }) + + It("round-trips the thumbhash alongside the blurhash", func() { + a := &model.Artwork{Hash: "both1", Mime: "image/jpeg", BlurHash: "LKO2?U%2Tw=w", + ThumbHash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", DominantColor: "#336699"} + Expect(repo.PutImage(a)).To(Succeed()) + + got, err := repo.GetImage("both1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.ThumbHash).To(Equal("1QcSHQRnh493V4dIh4eXh1h4kJUI")) + Expect(got.DominantColor).To(Equal("#336699")) + }) + + It("overwrites the thumbhash on re-acquisition", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first", DominantColor: "#111111"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second", DominantColor: "#222222"})).To(Succeed()) + + got, err := repo.GetImage("th2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.ThumbHash).To(Equal("second")) + Expect(got.DominantColor).To(Equal("#222222")) + }) + + It("is idempotent on Put (upsert by hash)", func() { + a := &model.Artwork{Hash: "dup1", Mime: "image/png"} + Expect(repo.PutImage(a)).To(Succeed()) + a.BlurHash = "XYZ" + Expect(repo.PutImage(a)).To(Succeed()) + got, _ := repo.GetImage("dup1") + Expect(got.BlurHash).To(Equal("XYZ")) + }) + + It("refreshes created_at when reacquiring an existing hash", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) + _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) + + got, err := repo.GetImage("reacq") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + + It("returns ErrNotFound for a missing hash", func() { + _, err := repo.GetImage("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns every stored hash with its current mime", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) + mimes, err := repo.GetMimeByHash() + Expect(err).ToNot(HaveOccurred()) + Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) + Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) + }) + + It("deletes only unreferenced rows older than the cutoff, reporting the count", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) + + Expect(repo.PurgeOrphans(time.Now().Add(time.Minute))).To(BeNumerically("==", 1)) + + _, err := repo.GetImage("d1") + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = repo.GetImage("dref") + Expect(err).ToNot(HaveOccurred()) + }) + + It("spares an unreferenced row younger than the cutoff", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PurgeOrphans(time.Now().Add(-time.Hour))).To(BeNumerically("==", 0)) + _, err := repo.GetImage("young") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("dangling state cleanup", func() { + It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) + + purged, err := repo.PurgeDanglingItems() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(5))) + + for _, kept := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: albumSgtPeppers.ID}, + {ItemKind: "ar", ItemID: artistKraftwerk.ID}, + {ItemKind: "pl", ItemID: plsBest.ID}, + {ItemKind: "ra", ItemID: radioWithHomePage.ID}, + {ItemKind: "mf", ItemID: songDayInALife.ID}, + } { + k, _ := model.ParseKind(kept.ItemKind) + _, err := repo.GetItemArtwork(k, kept.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + } + for _, gone := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: "no-such-album"}, + {ItemKind: "ar", ItemID: "no-such-artist"}, + {ItemKind: "pl", ItemID: "no-such-playlist"}, + {ItemKind: "ra", ItemID: "no-such-radio"}, + {ItemKind: "mf", ItemID: "no-such-mediafile"}, + } { + k, _ := model.ParseKind(gone.ItemKind) + _, err := repo.GetItemArtwork(k, gone.ItemID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + }) + }) + + Context("item state", func() { + It("upserts and reads state, including per-item provenance", func() { + ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, + Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + ia.Source = "embedded" + ia.SourcePath = "/music/a/track.mp3" + ia.RefMtime = 222 + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + + got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Source).To(Equal("embedded")) + Expect(got.SourcePath).To(Equal("/music/a/track.mp3")) + Expect(got.RefMtime).To(Equal(int64(222))) + Expect(got.UpdatedAt).ToNot(BeZero()) + }) + + It("defaults attempted_at to now when unset", func() { + before := time.Now().Add(-time.Second) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", + ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) + got, err := repo.GetItemArtwork(model.KindArtistArtwork, "noattempt", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AttemptedAt).To(BeTemporally(">", before)) + }) + + It("represents known-absent as empty hash", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", + ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + got, err := repo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Hash).To(BeEmpty()) + }) + + It("hydrates a page in one batch, including blurhash, dimensions and absence", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9", + DominantColor: "#abcdef", Width: 1200, Height: 800})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) + + info, err := repo.GetInfoForItems(model.KindAlbumArtwork, []string{"x1", "x2", "x3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(info).To(HaveLen(2)) + Expect(info["x1"].Hash).To(Equal("h9")) + Expect(info["x1"].BlurHash).To(Equal("BH9")) + Expect(info["x1"].DominantColor).To(Equal("#abcdef")) + Expect(info["x1"].Width).To(Equal(1200)) + Expect(info["x1"].Height).To(Equal(800)) + Expect(info["x1"].Absent()).To(BeFalse()) + Expect(info["x2"].Absent()).To(BeTrue()) + _, unresolved := info["x3"] + Expect(unresolved).To(BeFalse()) + }) + + It("deletes all rows for a single item", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.DeleteForItems(model.KindPlaylistArtwork, []string{"p1"})).To(Succeed()) + _, err := repo.GetItemArtwork(model.KindPlaylistArtwork, "p1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("deletes rows for many items in chunks, leaving others untouched", func() { + const n = artworkBatchSize + 5 + ids := make([]string, n) + for i := range n { + id := fmt.Sprintf("mf-%d", i) + ids[i] = id + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + } + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + + Expect(repo.DeleteForItems(model.KindMediaFileArtwork, ids)).To(Succeed()) + + for _, id := range ids { + _, err := repo.GetItemArtwork(model.KindMediaFileArtwork, id, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + kept, err := repo.GetItemArtwork(model.KindMediaFileArtwork, "keep", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.ItemID).To(Equal("keep")) + }) + }) +}) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index 1ff2139e6..2b617f5b0 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -13,7 +13,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -275,8 +274,8 @@ var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, initDS, events.NoopBroker(), + playlists.NewPlaylists(initDS, artwork.NewUploader(initDS)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ace61610c..df1472c2a 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -163,7 +163,9 @@ func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[ } func (r *mediaFileRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"media_file.id": id}) + // The exists() helper applies no library filter, so it would report rows the caller cannot see. + c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"media_file.id": id}))) + return c > 0, err } func (r *mediaFileRepository) Put(m *model.MediaFile) error { @@ -218,7 +220,13 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media if err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil +} + +func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { + hydrateMediaFileArtwork(r.ctx, r.db, mfs) } // GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the @@ -252,7 +260,9 @@ func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.Me if err := r.queryAll(sq, &res); err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { @@ -289,6 +299,29 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me return wrapMediaFileCursor(cursor), nil } +// GetAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. +func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "media_file.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + +// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. +func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { + ids, err := r.GetAllIDs(options...) + if err != nil { + return nil, err + } + opts := chunkOptions(options, "media_file.id") + return model.MediaFileCursor(streamByIDs(ids, func(chunk []string) (model.MediaFiles, error) { + return r.GetAll(opts(chunk)) + })), nil +} + // FindByPaths finds media files by their paths. // The paths can be library-qualified (format: "libraryID:path") or unqualified ("path"). // Library-qualified paths search within the specified library, while unqualified paths @@ -487,7 +520,9 @@ func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (m if err != nil { return nil, fmt.Errorf("searching media_file %q: %w", q, err) } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index f6a744d8d..b9cba03ff 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1093,4 +1093,40 @@ var _ = Describe("MediaRepository", func() { _ = mr.Delete(newID) }) }) + + Describe("AlbumImage hydration", func() { + It("carries the parent album's artwork state onto each track", func() { + mfs := model.MediaFiles{{ID: "mf-1", AlbumID: "al-1"}} + infos := map[string]model.ItemArtworkInfo{ + "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8"}, + } + applyItemImage(infos, mfs[0].AlbumID, &mfs[0].AlbumImage) + + Expect(mfs[0].AlbumImage.ImageHash).To(Equal("0123456789abcdef")) + Expect(mfs[0].AlbumImage.BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(mfs[0].AlbumImage.ImageAbsent).To(BeFalse()) + }) + + It("keeps the album state independent of the track's own art", func() { + mf := model.MediaFile{ID: "mf-2", AlbumID: "al-2"} + mf.ImageHash = "aaaaaaaaaaaaaaaa" // the track's own art + applyItemImage( + map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2", Hash: "bbbbbbbbbbbbbbbb"}}, + mf.AlbumID, &mf.AlbumImage, + ) + Expect(mf.ImageHash).To(Equal("aaaaaaaaaaaaaaaa")) + Expect(mf.AlbumImage.ImageHash).To(Equal("bbbbbbbbbbbbbbbb")) + }) + }) + + // Exists must apply the same library filter as Get/GetAll/CountAll. + Describe("Exists library visibility", func() { + It("hides a track the user has no library access to", func() { + restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"} + rctx := request.WithUser(GinkgoT().Context(), restricted) + + Expect(mr.Exists(songAntenna.ID)).To(BeTrue(), "admin sees it") + Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse()) + }) + }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 93f0e3e71..9d3a33cfc 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -97,6 +97,14 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { return NewPluginRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) Artwork(ctx context.Context) model.ArtworkRepository { + return NewArtworkRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { + return NewArtworkQueueRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index c78e0df1e..081dab3fa 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "iter" "slices" "time" @@ -112,7 +111,8 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { _, err := r.put(pls.ID, pls, cols...) return err } - if pls.ID == "" { + isNew := pls.ID == "" + if isNew { pls.CreatedAt = time.Now() } pls.UpdatedAt = time.Now() @@ -131,6 +131,12 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { if len(pls.Tracks) > 0 { return r.updateTracks(id, p.MediaFiles()) } + pls.ID = id // r.put assigns the generated id to p, not to this copy + if isNew { + // Even a trackless new playlist has art to find (an imported m3u can carry an + // ExternalImageURL); an update landing here changed only metadata, so leave its cover be. + r.enqueueCoverRebuild(id) + } return r.refreshCounters(&pls.Playlist) } @@ -172,7 +178,14 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { return nil, model.ErrNotFound } - return &pls[0].Playlist, nil + list := model.Playlists{pls[0].Playlist} + r.hydrateArtwork(list) + return &list[0], nil +} + +func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) { + hydrateItems(r.ctx, r.db, model.KindPlaylistArtwork, playlists, + func(p *model.Playlist) (string, *model.ItemImage) { return p.ID, &p.ItemImage }) } func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { @@ -186,22 +199,34 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, err } +// GetAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. +func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY + // on the playlist's own ambiguous. + sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name"). + Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter()) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "playlist.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { - // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. - sel := r.selectPlaylist(options...).Where(r.userFilter()) - cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel) + // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. + ids, err := r.GetAllIDs(options...) if err != nil { return nil, err } - return wrapPlaylistCursor(cursor), nil -} - -// dbPlaylist embeds a value, not a pointer, so its model is never nil. -func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor { - return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist })) + opts := chunkOptions(options, "playlist.id") + return model.PlaylistCursor(streamByIDs(ids, func(chunk []string) (model.Playlists, error) { + return r.GetAll(opts(chunk)) + })), nil } func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { @@ -220,6 +245,7 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, nil } @@ -264,6 +290,7 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media } } + r.enqueueCoverRebuild(playlistId) return r.refreshCounters(&model.Playlist{ID: playlistId}) } @@ -300,6 +327,16 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { return nil } +// enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes: +// the grid samples albums at random, so rebuilding after a mere rename would change the cover. +func (r *playlistRepository) enqueueCoverRebuild(id string) { + item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", id, err) + } +} + // tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder { query = r.applyLibraryFilter(query, "f") @@ -332,7 +369,9 @@ func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.P if err != nil { return nil, err } - return tracks.toModels(), err + res := tracks.toModels() + hydratePlaylistTrackArtwork(r.ctx, r.db, res) + return res, err } func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) { @@ -433,6 +472,7 @@ func (r *playlistRepository) renumber(id string) error { if err != nil { return err } + r.enqueueCoverRebuild(id) return r.refreshCounters(&model.Playlist{ID: id}) } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 00d4ff9f2..fc2d4ae3f 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -37,6 +38,17 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) @@ -245,6 +257,59 @@ var _ = Describe("PlaylistRepository", func() { Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) + It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path + Expect(repo.Put(&newPls)).To(Succeed()) + Expect(newPls.ID).ToNot(BeEmpty()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") + }) + + // The grid samples albums at random, so re-resolving after a rename would change the cover. + It("does not enqueue artwork when only metadata changes", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Rename Me", OwnerID: "userid"} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + // Clear the row creation just enqueued, so anything present afterwards came from the update. + queueRepo := NewArtworkQueueRepository(ctx, GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + for _, q := range queued { + if q.ItemID == newPls.ID { + Expect(queueRepo.DeleteIfUnchanged(q.ItemKind, q.ItemID, q.ImageType, q.RetryAt)).To(Succeed()) + } + } + + newPls.Name = "Renamed" + newPls.Comment = "edited" + Expect(repo.Put(&newPls)).To(Succeed()) + + queued, err = queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", newPls.ID))) + }) + + It("enqueues the playlist's artwork when its track set changes", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002"}) + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", newPls.ID), + ))) + }) + Describe("GetAll", func() { It("returns all playlists from DB", func() { all, err := repo.GetAll() @@ -341,4 +406,27 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) + + // Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist" + // -- the public image route serving a share -- must elevate, or a private playlist looks gone. + Describe("Exists visibility", func() { + It("hides a private playlist from an unauthenticated context", func() { + // "userid" is the fixture user; playlist.owner_id has a FK to user(id). + owner := model.User{ID: "userid", UserName: "userid"} + octx := request.WithUser(GinkgoT().Context(), owner) + ownerRepo := NewPlaylistRepository(octx, GetDBXBuilder()) + pls := model.Playlist{Name: "Private One", OwnerID: owner.ID, Public: false} + Expect(ownerRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = ownerRepo.Delete(pls.ID) }) + + Expect(ownerRepo.Exists(pls.ID)).To(BeTrue(), "the owner sees it") + + anon := NewPlaylistRepository(GinkgoT().Context(), GetDBXBuilder()) + Expect(anon.Exists(pls.ID)).To(BeFalse(), "no user: userFilter hides it") + + admin := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", IsAdmin: true}) + Expect(NewPlaylistRepository(admin, GetDBXBuilder()).Exists(pls.ID)).To(BeTrue(), + "elevating is what the public image route relies on") + }) + }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index e51ff8ea6..1a64efa20 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -130,8 +130,11 @@ func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (mode if err != nil { return nil, err } - return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { + tracks := wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { return t.PlaylistTrack + }) + return model.PlaylistTrackCursor(hydrateCursor(tracks, func(batch []model.PlaylistTrack) { + hydratePlaylistTrackArtwork(r.ctx, r.db, batch) })), nil } diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index a073643db..b73487e40 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -7,6 +7,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" @@ -36,6 +37,11 @@ func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) return r.count(sql, options...) } +// Exists needs no library or ownership filter: radios are visible to every user. +func (r *radioRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"id": id}) +} + func (r *radioRepository) Delete(id string) error { if !r.isPermitted() { return rest.ErrPermissionDenied @@ -48,14 +54,37 @@ func (r *radioRepository) Get(id string) (*model.Radio, error) { sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Radio{} err := r.queryOne(sel, &res) - return &res, err + if err != nil { + return &res, err + } + list := model.Radios{res} + r.hydrateArtwork(list) + return &list[0], nil } func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { sel := r.newSelect(options...).Columns("*") res := model.Radios{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return res, err + } + r.hydrateArtwork(res) + return res, nil +} + +// hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *radioRepository) hydrateArtwork(radios model.Radios) { + hydrateItems(r.ctx, r.db, model.KindRadioArtwork, radios, + func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) +} + +// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). +func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sel := r.newSelect(options...).Columns("id") + ids := []string{} + err := r.queryAllSlice(sel, &ids) + return ids, err } func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { @@ -72,7 +101,17 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error colsToUpdate = append(colsToUpdate, "UpdatedAt") } _, err := r.put(radio.ID, radio, colsToUpdate...) - return err + if err != nil { + return err + } + // Enqueue artwork resolution for the created/updated radio at Bump priority so a new + // radio's cover resolves proactively. Never fails the save. + item := model.ArtworkQueueItem{ItemKind: model.KindRadioArtwork.Prefix(), ItemID: radio.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err) + } + return nil } func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 05628ca41..e2564455d 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -78,6 +79,17 @@ var _ = Describe("RadioRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(want).ToNot(BeEmpty()) + ids, err := repo.GetAllIDs() + Expect(err).To(BeNil()) + Expect(ids).To(ConsistOf(slice.Map(want, func(r model.Radio) string { return r.ID }))) + }) + }) + Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ @@ -107,6 +119,27 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) + + It("enqueues artwork resolution for the saved radio", func() { + err := repo.Put(&model.Radio{ + Name: "Artwork radio", + StreamUrl: "https://example.com:4533/artwork", + }) + Expect(err).To(BeNil()) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + created := all[len(all)-1] + + queueRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).To(BeNil()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ra"), + HaveField("ItemID", created.ID), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index d0cbb2946..33450fe9f 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -15,6 +15,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" id2 "github.com/navidrome/navidrome/model/id" @@ -597,9 +598,15 @@ func (r sqlRepository) delete(cond Sqlizer) error { func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) + fields := []any{r.ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} if err == nil || errors.Is(err, context.Canceled) { - log.Trace(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) - } else { - log.Error(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) + log.Trace(append(fields, err)...) + return } + // The result codes separate errors that share a message, notably SQLITE_BUSY from + // SQLITE_BUSY_SNAPSHOT, which no busy_timeout can retry. + if code, extended, ok := db.ErrorCodes(err); ok { + fields = append(fields, "sqliteCode", code, "sqliteExtended", extended) + } + log.Error(append(fields, err)...) } diff --git a/scanner/controller.go b/scanner/controller.go index 463718ba3..df5aeb6f9 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -28,12 +27,11 @@ var ( ErrAlreadyScanning = errors.New("already scanning") ) -func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, +func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, pls playlists.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, - cw: cw, broker: broker, pls: pls, metrics: m, @@ -49,7 +47,7 @@ func (s *controller) getScanner() scanner { if s.devExternalScanner { return &scannerExternal{} } - return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} + return &scannerImpl{ds: s.ds, pls: s.pls} } // CallScan starts an in-process scan of specific library/folder pairs. @@ -66,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, progress := make(chan *ProgressInfo, 100) go func() { defer close(progress) - scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} + scanner := &scannerImpl{ds: ds, pls: pls} scanner.scanFolders(ctx, fullScan, targets, progress) }() return progress, nil @@ -97,7 +95,6 @@ type scanner interface { type controller struct { rootCtx context.Context ds model.DataStore - cw artwork.CacheWarmer broker events.Broker metrics metrics.Metrics pls playlists.Playlists diff --git a/scanner/controller_test.go b/scanner/controller_test.go index e4814da64..7ffdd69d4 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -5,7 +5,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -32,7 +31,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 5e898590b..e853948e6 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -16,7 +16,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -26,7 +25,7 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders { +func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders { var jobs []*scanJob // Create scan jobs for all libraries @@ -37,7 +36,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor targetFolders = state.targets[lib.ID] } - job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) + job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) @@ -52,14 +51,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor type scanJob struct { lib model.Library fs storage.MusicFS - cw artwork.CacheWarmer lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library targetFolders []string // Specific folders to scan (including all descendants) lock sync.Mutex numFolders atomic.Int64 } -func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { +func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { // Get folder updates, optionally filtered to specific target folders lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) if err != nil { @@ -85,7 +83,6 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, return &scanJob{ lib: lib, fs: fsys, - cw: cw, lastUpdates: lastUpdates, targetFolders: targetFolders, }, nil @@ -330,8 +327,8 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) - // Collect artwork IDs to pre-cache after the transaction commits - var artworkIDs []model.ArtworkID + // Collect artwork queue items for changed albums/artists, enqueued in the same transaction + var queueItems []model.ArtworkQueueItem err := p.ds.WithTx(func(tx model.DataStore) error { // Instantiate all repositories just once per folder @@ -371,7 +368,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: model.KindArtistArtwork.Prefix(), ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -383,7 +383,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -396,6 +399,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } } + // A re-imported track returns to unresolved so new embedded art is picked up lazily. + if len(entry.tracks) > 0 { + trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) + if err := tx.Artwork(p.ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil { + log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err) + } + } + // Mark all missing tracks as not available if len(entry.missingTracks) > 0 { err = mfRepo.MarkMissing(true, entry.missingTracks...) @@ -415,19 +426,25 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } } + + // Enqueue artwork resolution for changed albums/artists. Never fails the scan. + // A full scan re-imports every track, so a re-import is no evidence the art changed. + if len(queueItems) > 0 { + queue := tx.ArtworkQueue(p.ctx) + enqueue := queue.Enqueue + if p.state.fullScan { + enqueue = queue.EnqueueIfMissing + } + if err := enqueue(queueItems...); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err) + } + } return nil }, "scanner: persist changes") if err != nil { log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) } - // Pre-cache artwork after the transaction commits successfully - if err == nil { - for _, artID := range artworkIDs { - entry.job.cw.PreCache(artID) - } - } - return entry, err } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index 8ba014235..baa8b749a 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -12,7 +12,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,18 +23,16 @@ type phasePlaylists struct { scanState *scanState ds model.DataStore pls playlists.Playlists - cw artwork.CacheWarmer refreshed atomic.Uint32 pendingImport bool } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, ds: ds, pls: pls, - cw: cw, } } @@ -148,7 +145,11 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. } else { log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) } - p.cw.PreCache(pls.CoverArtID()) + item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) + } p.refreshed.Add(1) } return folder, nil diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 49ffc7fb7..93ec1a36d 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -27,7 +26,6 @@ var _ = Describe("phasePlaylists", func() { folderRepo *mockFolderRepository ds *tests.MockDataStore pls *mockPlaylists - cw artwork.CacheWarmer ) var userRepo *tests.MockedUserRepo @@ -48,9 +46,8 @@ var _ = Describe("phasePlaylists", func() { MockedProperty: propRepo, } pls = &mockPlaylists{} - cw = artwork.NoopCacheWarmer() state = &scanState{} - phase = createPhasePlaylists(ctx, state, ds, pls, cw) + phase = createPhasePlaylists(ctx, state, ds, pls) }) Describe("description", func() { @@ -193,6 +190,29 @@ var _ = Describe("phasePlaylists", func() { Expect(phase.refreshed.Load()).To(Equal(uint32(2))) }) + It("enqueues artwork resolution for the imported playlist", func() { + libPath := GinkgoT().TempDir() + folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} + _ = os.MkdirAll(folder.AbsolutePath(), 0755) + + file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") + _ = os.WriteFile(file1, []byte{}, 0600) + + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). + Return(&model.Playlist{ID: "pl1"}, nil) + + _, err := phase.processPlaylistsInFolder(folder) + Expect(err).ToNot(HaveOccurred()) + + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pl1"), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + }) + It("reports an error if there is an error reading files", func() { tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) diff --git a/scanner/scanner.go b/scanner/scanner.go index 27e2b19d2..29a1f9b4e 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,7 +11,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,7 +20,6 @@ import ( type scannerImpl struct { ds model.DataStore - cw artwork.CacheWarmer pls playlists.Playlists } @@ -136,7 +134,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)), + runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds)), // Phase 2: Process missing files, checking for moves runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), @@ -147,7 +145,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), // Phase 4: Import/update playlists - runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)), + runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls)), ), // Final Steps (cannot be parallelized): @@ -155,6 +153,9 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Run GC if there were any changes (Remove dangling tracks, empty albums and artists, and orphan annotations) s.runGC(ctx, &state), + // Queue artwork for entities that never resolved (after GC, so nothing dangling is queued) + s.runEnqueueMissingArtwork(ctx, &state), + // Refresh artist and tags stats s.runRefreshStats(ctx, &state), @@ -251,6 +252,29 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error } } +// runEnqueueMissingArtwork is the safety net for entities phase 1 never enqueued. +func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanState) func() error { + return func() error { + if !state.changesDetected.Load() { + log.Debug(ctx, "Scanner: No changes detected, skipping artwork enqueue") + return nil + } + start := time.Now() + queue := s.ds.ArtworkQueue(ctx) + var total int64 + for _, kind := range []model.Kind{model.KindAlbumArtwork, model.KindArtistArtwork} { + n, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityScan) + if err != nil { + log.Error(ctx, "Scanner: Error enqueueing missing artwork", "kind", kind, err) + return fmt.Errorf("enqueueing missing artwork: %w", err) + } + total += n + } + log.Debug(ctx, "Scanner: Enqueued missing artwork", "items", total, "elapsed", time.Since(start)) + return nil + } +} + func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) func() error { return func() error { if !state.changesDetected.Load() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 8f0dcd340..ca1064631 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -12,7 +12,6 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -40,8 +39,8 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false - s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(context.Background(), ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 3ae50933c..c0d5d4ece 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -78,8 +77,8 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 17772bf9d..acaa8f850 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -66,8 +65,8 @@ var _ = Describe("ScanFolders", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index cc3720717..6098cbd66 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -86,8 +85,8 @@ var _ = Describe("Scanner", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) @@ -98,6 +97,22 @@ var _ = Describe("Scanner", Ordered, func() { return err } + // Stands in for the artwork worker: drains the queue and records every item as resolved, + // so a later scan can only queue genuine reprocessing. + resolveQueuedArtwork := func() []model.ArtworkQueueItem { + GinkgoHelper() + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + for _, it := range queued { + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: it.ItemKind, ItemID: it.ItemID, ImageType: it.ImageType, + Hash: "resolved", Source: "embedded", UpdatedAt: time.Now(), + })).To(Succeed()) + Expect(ds.ArtworkQueue(ctx).DeleteIfUnchanged(it.ItemKind, it.ItemID, it.ImageType, it.RetryAt)).To(Succeed()) + } + return queued + } + Context("Simple library, 'artis/album/track - title.mp3'", func() { var help, revolver func(...map[string]any) *fstest.MapFile var fsys storagetest.FakeFS @@ -152,6 +167,40 @@ var _ = Describe("Scanner", Ordered, func() { HaveField("SongCount", Equal(4)), )) }) + It("should enqueue artwork resolution for the scanned albums and artists", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + albums, _ := ds.Album(ctx).GetAll() + artists, _ := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + + for _, al := range albums { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", al.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + for _, ar := range artists { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", ar.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + }) + It("should not re-enqueue already resolved artwork on a repeat full scan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + Expect(resolveQueuedArtwork()).ToNot(BeEmpty()) + + Expect(runScanner(ctx, true)).To(Succeed()) + + requeued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(requeued).To(BeEmpty()) + }) }) When("a file was changed", func() { It("should update the media_file", func() { @@ -169,6 +218,25 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf[0].Tags).To(HaveKeyWithValue(model.TagName("barcode"), []string{"123"})) }) + It("should re-enqueue the album artwork even though it already resolved", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") + Expect(runScanner(ctx, true)).To(Succeed()) + + resolveQueuedArtwork() + + fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) + Expect(runScanner(ctx, false)).To(Succeed()) + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + requeued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(requeued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", albums[0].ID), + ))) + }) + It("should update the album", func() { tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") Expect(runScanner(ctx, true)).To(Succeed()) @@ -187,6 +255,26 @@ var _ = Describe("Scanner", Ordered, func() { Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) Expect(albums[0].SongCount).To(Equal(3)) }) + + It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(mf).ToNot(BeEmpty()) + trackID := mf[0].ID + + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, + Source: "embedded", Hash: "stalehash", + })).To(Succeed()) + + fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) + Expect(runScanner(ctx, true)).To(Succeed()) + + _, err = ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, trackID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) }) }) diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go new file mode 100644 index 000000000..9308bcdab --- /dev/null +++ b/server/imghttp/headers.go @@ -0,0 +1,67 @@ +// Package imghttp holds the HTTP caching contract shared by the subsonic, public, and jellyfin +// image handlers, so they apply identical headers without importing each other. +package imghttp + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/core/artwork" +) + +// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written +// (the caller must then not write a body). requestedHash is the hash the client asserted, or "". +func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) { + h := w.Header() + // Placeholders are transient stand-ins for unresolved art: never cached, no validators. + if img.Placeholder { + h.Set("Cache-Control", "no-store") + return false + } + + // The ETag versions the served representation, so a re-encoding config change invalidates a + // revalidating client's cache; full-size originals are their own pixel hash. + etag := img.ETag + if etag == "" { + etag = img.Hash + } + // An empty validator would be shared by every such response, 304ing clients that echo it back. + if etag != "" { + h.Set("ETag", `"`+etag+`"`) + } + if !img.LastUpdated.IsZero() { + h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) + } + // Immutable only when the client asked for the current pixel hash; anything else revalidates, + // so art that gets re-resolved is never pinned. + if requestedHash != "" && requestedHash == img.Hash { + h.Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + h.Set("Cache-Control", "public, no-cache") + } + + if etag != "" && ifNoneMatch(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(http.StatusNotModified) + return true + } + return false +} + +// ifNoneMatch reports whether If-None-Match asserts hash, using RFC 9110 weak comparison. +func ifNoneMatch(header, hash string) bool { + header = strings.TrimSpace(header) + if header == "" { + return false + } + if header == "*" { + return true + } + for tag := range strings.SplitSeq(header, ",") { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(tag, "W/") + if strings.Trim(tag, `"`) == hash { + return true + } + } + return false +} diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go new file mode 100644 index 000000000..2e8313faf --- /dev/null +++ b/server/imghttp/headers_test.go @@ -0,0 +1,117 @@ +package imghttp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/server/imghttp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const testHash = "0123456789abcdef" +const testRepTag = testHash + ".300.false.q75" + +var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + +func found() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + LastUpdated: lastMod, + } +} + +func placeholder() *artwork.Image { + return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} +} + +// A re-encoded response carries a representation ETag distinct from the pixel hash, so the +// validator versions with the encode settings. +func resized() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + ETag: testRepTag, + LastUpdated: lastMod, + } +} + +func unvalidated() *artwork.Image { + return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("IMG")), LastUpdated: lastMod} +} + +var _ = Describe("WriteImageHeaders", func() { + type testCase struct { + img *artwork.Image + requestedHash string + ifNoneMatch string + want304 bool + wantCache string + wantETag string + wantLastMod bool + } + + DescribeTable("applies the artwork caching contract", + func(c testCase) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/img", nil) + if c.ifNoneMatch != "" { + r.Header.Set("If-None-Match", c.ifNoneMatch) + } + + Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304)) + + h := w.Header() + Expect(h.Get("Cache-Control")).To(Equal(c.wantCache)) + Expect(h.Get("ETag")).To(Equal(c.wantETag)) + if c.img.Placeholder { + Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag") + Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified") + } + Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod)) + if c.want304 { + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body") + } + }, + Entry("placeholder is never cached and carries no validators", + testCase{img: placeholder(), wantCache: "no-store"}), + Entry("found with matching requested hash is immutable", + testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with bare id revalidates via no-cache", + testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with mismatched requested hash revalidates", + testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("resized keeps pixel-hash immutable but serves the representation ETag", + testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized 304s on the representation ETag, not the pixel hash", + testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized does not 304 on a stale pixel-hash validator (config changed)", + testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("If-None-Match matching the hash yields 304", + testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("weak If-None-Match matches (weak comparison)", + testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match with multiple values matches one", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match star matches any current representation", + testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("non-matching If-None-Match serves body", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("placeholder ignores If-None-Match and never 304s", + testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}), + + // With no validator, an emitted ETag would be the same empty tag on every such response, + // and matching it would 304 changed bytes. + Entry("omits the ETag entirely when there is no validator", + testCase{img: unvalidated(), wantCache: "public, no-cache", wantLastMod: true}), + Entry("never 304s an empty validator echoed back by the client", + testCase{img: unvalidated(), ifNoneMatch: `""`, want304: false, + wantCache: "public, no-cache", wantLastMod: true}), + ) +}) diff --git a/server/imghttp/imghttp_suite_test.go b/server/imghttp/imghttp_suite_test.go new file mode 100644 index 000000000..7a747db5b --- /dev/null +++ b/server/imghttp/imghttp_suite_test.go @@ -0,0 +1,17 @@ +package imghttp_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestImgHTTP(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ImgHTTP Suite") +} diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go deleted file mode 100644 index aaf6ff2af..000000000 --- a/server/jellyfin/dto/blurhash.go +++ /dev/null @@ -1,36 +0,0 @@ -package dto - -import "hash/fnv" - -// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. -const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -// base83 encodes value as a fixed-width, big-endian base83 string of the given length. -func base83(value, length int) string { - b := make([]byte, length) - for i := 1; i <= length; i++ { - digit := (value / pow83(length-i)) % 83 - b[i-1] = base83Alphabet[digit] - } - return string(b) -} - -func pow83(n int) int { - result := 1 - for range n { - result *= 83 - } - return result -} - -// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a -// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so -// a solid color unique to the tag satisfies both without decoding cover art. -func blurHash(seed string) string { - h := fnv.New32a() - _, _ = h.Write([]byte(seed)) - sum := h.Sum(nil) - r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) - dc := (r << 16) | (g << 8) | b - return "00" + base83(dc, 4) -} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go deleted file mode 100644 index a6e36131d..000000000 --- a/server/jellyfin/dto/blurhash_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package dto - -import ( - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("blurHash", func() { - It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { - h := blurHash("x") - Expect(h).To(HaveLen(6)) - Expect(h).To(HavePrefix("00")) - for _, c := range h { - Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) - } - }) - - It("is deterministic for the same seed", func() { - Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) - }) - - It("differs for different seeds", func() { - Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) - }) -}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 9b2c35c69..5a2a4c2b1 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -81,12 +81,15 @@ type BaseItemDto struct { // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't // calculating blurhashes. - ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` - BackdropImageTags []string `json:"BackdropImageTags"` - UserData *UserItemDataDto `json:"UserData,omitempty"` - MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` - Container string `json:"Container,omitempty"` - CanDownload bool `json:"CanDownload"` + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + // PrimaryImageAspectRatio is width/height of the Primary image, attached only when the request's + // Fields asks for it; omitted rather than guessed, since a wrong ratio mis-shapes a placeholder. + PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` } // PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 444a25cac..352fce11d 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -192,31 +193,68 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { } else if mf.Genre != "" { item.Genres = []string{mf.Genre} } - // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. - if mf.AlbumID != "" { - item.AlbumPrimaryImageTag = mf.AlbumID - item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} + // Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here. + if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash { + tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields) + item.ImageTags = map[string]string{"Primary": tag} + item.ImageBlurHashes = blurs + item.PrimaryImageAspectRatio = ratio + } else if embeddedArtPending(mf) { + // Nothing enqueues media files: advertising the id is what makes a client ask, and that + // request is what extracts the embedded art and queues the track. + item.ImageTags = map[string]string{"Primary": mf.ID} + } else if mf.AlbumID != "" { + if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { + item.AlbumPrimaryImageTag = tag + item.ImageBlurHashes = blurs + item.PrimaryImageAspectRatio = ratio + } } return item } +func embeddedArtPending(mf model.MediaFile) bool { + return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt && + mf.ImageHash == "" && !mf.ItemImage.ImageAbsent +} + +// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would +// pin a stale cover forever. +func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { + if img.ImageAbsent { + return "", nil, nil + } + tag = cmp.Or(img.ImageHash, fallback) + if img.BlurHash != "" { + blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} + } + if fields.Has("PrimaryImageAspectRatio") { + ratio = img.AspectRatio() + } + return tag, blurs, ratio +} + func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields) item := BaseItemDto{ - Name: al.Name, - Id: EncodeID(al.ID), - Type: "MusicAlbum", - IsFolder: true, - ParentId: EncodeID(al.AlbumArtistID), - AlbumArtist: al.AlbumArtist, - Album: al.Name, - ChildCount: new(al.SongCount), - SongCount: new(al.SongCount), - RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: jellyfinDate(&al.CreatedAt), - ImageTags: map[string]string{"Primary": al.ID}, - ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, - BackdropImageTags: []string{}, - UserData: UserData(al.Annotations, al.ID), + Name: al.Name, + Id: EncodeID(al.ID), + Type: "MusicAlbum", + IsFolder: true, + ParentId: EncodeID(al.AlbumArtistID), + AlbumArtist: al.AlbumArtist, + Album: al.Name, + ChildCount: new(al.SongCount), + SongCount: new(al.SongCount), + RunTimeTicks: TicksFromSeconds(al.Duration), + DateCreated: jellyfinDate(&al.CreatedAt), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), + } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} } if al.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} @@ -246,20 +284,25 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { return item } -func ArtistToBaseItem(ar model.Artist) BaseItemDto { - return BaseItemDto{ - Name: ar.Name, - Id: EncodeID(ar.ID), - Type: "MusicArtist", - IsFolder: true, - AlbumCount: new(ar.AlbumCount), - SongCount: new(ar.SongCount), - DateCreated: jellyfinDate(ar.CreatedAt), - ImageTags: map[string]string{"Primary": ar.ID}, - ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, - BackdropImageTags: []string{}, - UserData: UserData(ar.Annotations, ar.ID), +func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields) + item := BaseItemDto{ + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} + } + return item } func GenreToBaseItem(g model.Genre) BaseItemDto { @@ -282,26 +325,28 @@ func StudioToBaseItem(t model.Tag) BaseItemDto { } // PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. -func PlaylistToBaseItem(p model.Playlist) BaseItemDto { - // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. - // UpdatedAt versions it (Put bumps it on upload); over-invalidation only costs a refetch. - tag := fmt.Sprintf("%s-%x", p.ID, p.UpdatedAt.UnixMilli()) - return BaseItemDto{ +func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields) + item := BaseItemDto{ Name: p.Name, Id: EncodeID(p.ID), Type: "Playlist", // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin // stores them under its data folder), so without this its Playlists tab hides them all. - Path: "/data/playlists/" + p.ID, - IsFolder: true, - MediaType: "Audio", - ChildCount: new(p.SongCount), - RunTimeTicks: TicksFromSeconds(p.Duration), - ImageTags: map[string]string{"Primary": tag}, - ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}}, - BackdropImageTags: []string{}, - UserData: UserData(p.Annotations, p.ID), + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} + } + return item } // LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c11f38eaf..a7b381c5c 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,8 +36,8 @@ var _ = Describe("mappers", func() { Expect(item.UserData.Played).To(BeTrue()) Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) - Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -254,8 +256,8 @@ var _ = Describe("mappers", func() { Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) - Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -288,9 +290,74 @@ var _ = Describe("mappers", func() { Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) + Describe("PrimaryImageAspectRatio", func() { + nonSquare := func() model.Album { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 + return al + } + + It("is omitted unless the request asks for it", func() { + Expect(AlbumToBaseItem(nonSquare(), nil).PrimaryImageAspectRatio).To(BeNil()) + b, err := json.Marshal(AlbumToBaseItem(nonSquare(), nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("PrimaryImageAspectRatio")) + }) + + It("carries the real ratio when asked", func() { + item := AlbumToBaseItem(nonSquare(), ParseFields("PrimaryImageAspectRatio")) + Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) + }) + + It("is omitted when the dimensions are unknown, rather than guessing square", func() { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageHash = "abc" + item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) + Expect(item.PrimaryImageAspectRatio).To(BeNil()) + }) + + It("is omitted when the item has no image at all", func() { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageAbsent = true + al.ImageWidth, al.ImageHeight = 1200, 800 + item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) + Expect(item.PrimaryImageAspectRatio).To(BeNil()) + }) + + It("carries the ratio for an artist", func() { + ar := model.Artist{ID: "ar1", Name: "Artist"} + ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 + Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 2.0, 0.0001)) + }) + + It("carries the ratio for a playlist", func() { + pl := model.Playlist{ID: "pl1", Name: "Playlist"} + pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 + Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 0.5, 0.0001)) + }) + + It("carries the ratio for a song with its own art", func() { + mf := model.MediaFile{ID: "mf1", Title: "Song"} + mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 + Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 0.5, 0.0001)) + }) + + // A track without its own art shows the album's, so the ratio has to describe that image. + It("uses the album's dimensions for a track falling back to album art", func() { + mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"} + mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 + item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) + Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) + Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) + }) + }) + It("maps an artist to a MusicArtist folder item", func() { ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} - item := ArtistToBaseItem(ar) + item := ArtistToBaseItem(ar, nil) Expect(item.Type).To(Equal("MusicArtist")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("art-1"))) @@ -352,7 +419,7 @@ var _ = Describe("mappers", func() { ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, } - item := PlaylistToBaseItem(p) + item := PlaylistToBaseItem(p, nil) Expect(item.Type).To(Equal("Playlist")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("pl-1"))) @@ -363,26 +430,202 @@ var _ = Describe("mappers", func() { Expect(item.UserData.IsFavorite).To(BeTrue()) Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) - tag := item.ImageTags["Primary"] - Expect(tag).ToNot(BeEmpty()) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag)) - Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6)) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) + Expect(item.ImageBlurHashes).To(BeNil()) }) - It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() { - p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} - before := PlaylistToBaseItem(p) - p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) - after := PlaylistToBaseItem(p) + It("changes the playlist image tag when the cover content changes", func() { + p := model.Playlist{ID: "pl-1", Name: "Chill"} + p.ImageHash = "1111111111111111" + before := PlaylistToBaseItem(p, nil) + p.ImageHash = "2222222222222222" + after := PlaylistToBaseItem(p, nil) - // Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover. - Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"])) - Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"])) + Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111")) + Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222")) }) - It("keeps the playlist image tag stable when nothing changed", func() { + It("keeps the playlist image tag stable across a metadata-only edit", func() { p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} - Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) + p.ImageHash = "1111111111111111" + before := PlaylistToBaseItem(p, nil) + p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) + after := PlaylistToBaseItem(p, nil) + + Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"])) + }) + + // Nothing enqueues media files, so a track's own art only resolves when a client requests it; + // advertising just the album image would leave that cover unreachable. + Describe("unresolved embedded art", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + + It("advertises the track id so the client triggers the read-through", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) + Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + }) + + It("falls back to the album when the track has no art of its own", func() { + mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + }) + + It("falls back to the album once the track's art is known absent", func() { + mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} + mf.ItemImage.ImageAbsent = true + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + }) + + It("falls back to the album when per-track art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + }) + }) + + Describe("primary image tags", func() { + It("uses the content hash as the tag and emits the real blurhash", func() { + al := model.Album{ID: "alb-1", Name: "Album"} + al.ImageHash = "0123456789abcdef" + al.BlurHash = "LEHV6nWB2yk8" + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) + }) + + It("omits the blurhash entirely when none was computed", func() { + al := model.Album{ID: "alb-2", Name: "Album"} + al.ImageHash = "0123456789abcdef" + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) + Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") + }) + + It("omits tags for known-absent artwork", func() { + al := model.Album{ID: "alb-3", Name: "Album"} + al.ImageAbsent = true + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("falls back to the entity id while artwork is still unresolved", func() { + item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("versions an artist's tag by content hash", func() { + ar := model.Artist{ID: "art-1", Name: "Artist"} + ar.ImageHash = "fedcba9876543210" + ar.BlurHash = "L6PZfSi_.AyE" + + item := ArtistToBaseItem(ar, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE")) + }) + }) + + Describe("song and playlist image tags", func() { + It("versions a song's album tag by the album's content hash", func() { + mf := model.MediaFile{ID: "song-1", Title: "Song", AlbumID: "alb-1"} + mf.AlbumImage.ImageHash = "0123456789abcdef" + mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" + + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) + }) + + It("never synthesizes a song blurhash when the album has none", func() { + mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("omits a song's album tag when the album art is known absent", func() { + mf := model.MediaFile{ID: "song-3", Title: "Song", AlbumID: "alb-3"} + mf.AlbumImage.ImageAbsent = true + + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("versions a playlist tag by content hash instead of UpdatedAt", func() { + pl := model.Playlist{ID: "pl-1", Name: "Playlist"} + pl.ImageHash = "abcdef0123456789" + + item := PlaylistToBaseItem(pl, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789")) + }) + }) + + Describe("per-song artwork", func() { + It("emits the track's own Primary tag when it has distinct art", func() { + mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"} + mf.ImageHash = "aaaaaaaaaaaaaaaa" + mf.BlurHash = "LTRACKblur" + mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" + mf.AlbumImage.BlurHash = "LALBUMblur" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1), + "exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId") + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur")) + }) + + It("falls back to the album tag when the track has no distinct art", func() { + mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"} + mf.ImageHash = "bbbbbbbbbbbbbbbb" + mf.BlurHash = "LALBUMblur" + mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" + mf.AlbumImage.BlurHash = "LALBUMblur" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's") + Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur")) + }) + + It("omits the track tag when its own art is known absent", func() { + mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"} + mf.ImageAbsent = true + mf.AlbumImage.ImageAbsent = true + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 31d98d1d9..ca1308e9e 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -33,7 +33,6 @@ import ( "strings" "testing" "testing/fstest" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -327,7 +326,7 @@ func setupTestDB() { decider, core.NewPlayers(ds), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), @@ -406,18 +405,18 @@ type spyArtwork struct { data []byte } -func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) { s.lastID = id s.lastCtx = c d := s.data if d == nil { d = []byte("IMG") } - return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil + return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil } var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index d2ff49db9..08d6f4f6f 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -6,8 +6,8 @@ import ( jpeglib "image/jpeg" "net/http" "os" - "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -215,26 +215,26 @@ var _ = Describe("Playlists", func() { To(Equal(http.StatusNotImplemented)) }) - // Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the - // tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever. - It("rotates the playlist's image tag and blurhash after a cover upload", func() { + // An upload must clear the resolved artwork state, or clients keep serving the stale cover + // from their tag-keyed cache until the next scan. + It("clears the resolved image tag after a cover upload", func() { plID := createPlaylist("Cover Tag", nil) + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: plID, Hash: "1111111111111111", + })).To(Succeed()) + imageTag := func() string { q := queryResult(get("/Items?ids=" + enc(plID))) Expect(q.Items).To(HaveLen(1)) return q.Items[0].ImageTags["Primary"] } - before := imageTag() - Expect(before).ToNot(BeEmpty()) + Expect(imageTag()).To(Equal("1111111111111111")) - time.Sleep(2 * time.Millisecond) // UpdatedAt has millisecond resolution in the tag Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). To(Equal(http.StatusNoContent)) - after := imageTag() - Expect(after).ToNot(Equal(before)) - q := queryResult(get("/Items?ids=" + enc(plID))) - Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after)) + // The upload re-queues resolution instead of resolving inline, so the tag goes bare. + Expect(imageTag()).ToNot(Equal("1111111111111111")) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 0ec34f491..b844e7c74 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -16,10 +16,11 @@ import ( "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -32,7 +33,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) - reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): return @@ -41,9 +42,27 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not Found", http.StatusNotFound) return } - defer reader.Close() + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) { + return + } // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). - _, _ = io.Copy(w, reader) + _, _ = io.Copy(w, img) +} + +// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content +// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable. +func hashFromTag(r *http.Request) string { + tag := r.URL.Query().Get("tag") + if len(tag) != 16 { + return "" + } + for _, c := range tag { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return "" + } + } + return tag } // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing @@ -79,7 +98,7 @@ func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { } // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode // the wire body (4/3 bigger), so the read cap allows for inflation. - limit := core.MaxImageUploadSize() + limit := artwork.MaxImageUploadSize() body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) if err != nil { log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index e435b99fd..bdd47e956 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -33,16 +33,21 @@ type fakeArtwork struct { recvId string recvCtx context.Context data []byte + hash string } -func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id f.recvCtx = ctx data := f.data if data == nil { data = []byte("IMG") } - return io.NopCloser(bytes.NewReader(data)), time.Now(), nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader(data)), + Hash: f.hash, + LastUpdated: time.Now(), + }, nil } func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { @@ -70,6 +75,19 @@ var _ = Describe("Images", func() { Expect(fa.recvId).To(ContainSubstring("a1")) }) + // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. + It("asks for no artwork once the item is deleted, rather than its lingering state", func() { + ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("deleted-item")) + api.getItemImage(w, r) + + Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") + Expect(w.Body.String()).ToNot(ContainSubstring("deleted-item")) + }) + It("sniffs the Content-Type instead of hardcoding it", func() { ds := &tests.MockDataStore{} ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) @@ -115,6 +133,38 @@ var _ = Describe("Images", func() { Expect(ok).To(BeTrue()) Expect(u.IsAdmin).To(BeTrue()) }) + + It("serves immutable when the tag param asserts the current hash", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + q := r.URL.Query() + q.Set("tag", hash) + r.URL.RawQuery = q.Encode() + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) + + It("revalidates via no-cache when no tag is provided", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) }) // Real image fixtures: postItemImage validates uploads by decoding them. diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 7cf2a17ba..631530b6d 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -588,7 +588,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursorWithArtwork(opts) }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -598,6 +598,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // genreIds isn't applied to search — a name lookup, like role (see below). func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { repo := api.ds.Artist(ctx) + toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) } // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a // search scope (artists have no library_id column). A compound or join-based filter @@ -613,7 +614,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil } if q.favOnly { @@ -629,7 +630,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Artist, error) bool), error) { return repo.GetCursor(opts) - }, dto.ArtistToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -663,7 +664,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q } open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { return repo.GetCursor(opts) - }, dto.PlaylistToBaseItem) + }, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) }) return streamed(open, int(total), opts.Offset), nil } @@ -698,7 +699,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if ar, err := api.ds.Artist(ctx).Get(id); err == nil { // TODO: an artist spans multiple libraries (library_artist), so there's no single // LibraryID to gate here; artist access relies on list-time scoping and persistence. - return dto.ArtistToBaseItem(*ar), true + return dto.ArtistToBaseItem(*ar, fields), true } if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { if !u.HasLibraryAccess(mf.LibraryID) { @@ -708,7 +709,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi } // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. if pl, err := api.playlists.Get(ctx, id); err == nil { - return dto.PlaylistToBaseItem(*pl), true + return dto.PlaylistToBaseItem(*pl, fields), true } return dto.BaseItemDto{}, false } diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 661fb95cc..2698073bf 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -138,7 +138,7 @@ func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto return result(nil, 0, 0) } present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) - items := slice.Map(present, dto.ArtistToBaseItem) + items := slice.Map(present, func(a model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(a, nil) }) return result(items, len(items), 0) } diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index daa918d00..193f88eda 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -46,7 +46,11 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { } ar.UploadedImage = filename ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } @@ -65,6 +69,10 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { } ar.UploadedImage = "" ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go new file mode 100644 index 000000000..3583c2db7 --- /dev/null +++ b/server/nativeapi/artwork.go @@ -0,0 +1,42 @@ +package nativeapi + +import ( + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +var refreshableArtworkKinds = []model.Kind{ + model.KindAlbumArtwork, + model.KindArtistArtwork, + model.KindPlaylistArtwork, + model.KindRadioArtwork, + model.KindMediaFileArtwork, +} + +func (api *Router) addArtworkRoute(r chi.Router) { + r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) +} + +// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved). +func (api *Router) refreshArtwork() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind, _ := model.ParseKind(chi.URLParam(r, "kind")) + id := chi.URLParam(r, "id") + if !slices.Contains(refreshableArtworkKinds, kind) { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/artwork_test.go b/server/nativeapi/artwork_test.go new file mode 100644 index 000000000..b26a94e6a --- /dev/null +++ b/server/nativeapi/artwork_test.go @@ -0,0 +1,95 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "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" +) + +var _ = Describe("Artwork API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("POST /api/artwork/{kind}/{id}/refresh", func() { + It("clears state and enqueues a Bump for admins", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", "al-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("returns 400 for an invalid kind", func() { + req := createAuthenticatedRequest("POST", "/artwork/xx/id-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("denies access to regular users", func() { + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("denies access without authentication", func() { + req := createUnauthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 077eac35e..c4ff62e42 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -14,7 +14,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -31,7 +31,7 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { - maxImageSize := core.MaxImageUploadSize() + maxImageSize := artwork.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..f97bd26b2 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -44,10 +45,10 @@ type Router struct { users core.User maintenance core.Maintenance pluginManager PluginManager - imgUpload core.ImageUploadService + imgUpload artwork.Uploader } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader) *Router { r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r @@ -91,6 +92,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) + api.addArtworkRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..3e88af287 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -47,7 +47,11 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { return err } radio.UploadedImage = filename - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } @@ -65,6 +69,10 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return err } radio.UploadedImage = "" - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 633665e0f..6416190bd 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -11,6 +11,8 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/utils/req" ) @@ -37,10 +39,13 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } + // The token is the authorization, so the entity check must ask "is it still there", not + // "may this user see it" -- the latter would hide a shared private playlist. + ctx = request.WithUser(ctx, model.User{IsAdmin: true}) size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) + img, err := pub.artwork.Get(ctx, artId, size, square) switch { case errors.Is(err, context.Canceled): return @@ -58,10 +63,11 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } - defer imgReader.Close() - w.Header().Set("Cache-Control", "public, max-age=315360000") - w.Header().Set("Last-Modified", lastUpdate.Format(http.TimeFormat)) - cnt, err := io.Copy(w, imgReader) + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, artId.Hash) { + return + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 669f879a6..d77b6f427 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -1,8 +1,18 @@ package public import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,3 +33,56 @@ var _ = Describe("decodeArtworkID", func() { Expect(err).To(HaveOccurred()) }) }) + +type fakeArtwork struct { + artwork.Artwork + img *artwork.Image + err error +} + +func (f *fakeArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return f.img, f.err +} + +var _ = Describe("handleImages", func() { + var w *httptest.ResponseRecorder + + newImageRequest := func(claimID string) *http.Request { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + token, _ := auth.CreatePublicToken(auth.Claims{ID: claimID}) + return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil) + } + + // The handler re-checks that the entity behind the token still exists, so "1" must be live. + var ds *tests.MockDataStore + + BeforeEach(func() { + w = httptest.NewRecorder() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "1"}}) + ds = &tests.MockDataStore{MockedAlbum: albumRepo} + }) + + It("returns 404 when the artwork is unavailable", func() { + pub := &Router{ds: ds, artwork: &fakeArtwork{err: artwork.ErrUnavailable}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the artwork is not found", func() { + pub := &Router{ds: ds, artwork: &fakeArtwork{err: model.ErrNotFound}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("serves the image immutable when the token asserts the current hash", func() { + const hash = "0123456789abcdef" + img := &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader([]byte("IMG"))), Hash: hash} + pub := &Router{ds: ds, artwork: &fakeArtwork{img: img}} + pub.handleImages(w, newImageRequest("al-1_"+hash)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) + +}) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 59d267321..d32a5c857 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -230,9 +230,11 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + if !album.ImageAbsent { + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + } response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -295,9 +297,11 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + if !artist.ImageAbsent { + base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID @@ -454,7 +458,7 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.AverageRating = album.AverageRating } dir.SongCount = int32(album.SongCount) - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index b8f510aed..d34da2f37 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -157,4 +158,57 @@ var _ = Describe("Browsing", func() { Expect(response.Artist).ToNot(BeNil()) }) }) + + Describe("GetAlbumInfo", func() { + It("emits image URLs when the album artwork is unresolved", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1"}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).ToNot(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the album artwork is known absent", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).To(BeEmpty()) + }) + }) + + Describe("GetArtistInfo", func() { + It("emits image URLs when the artist artwork is unresolved", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1"}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the artist artwork is known absent", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.LargeImageUrl).To(BeEmpty()) + }) + }) }) + +type fakeInfoProvider struct { + external.Provider + album *model.Album + artist *model.Artist +} + +func (f *fakeInfoProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return f.album, nil +} + +func (f *fakeInfoProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return f.artist, nil +} diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 97d7fb40a..8998731a4 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -311,12 +311,12 @@ func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { // noopArtwork implements artwork.Artwork type noopArtwork struct{} -func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { - return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) { + return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil } // noopArchiver implements core.Archiver @@ -357,14 +357,6 @@ func (n noopProvider) TopSongs(context.Context, string, string, int) (model.Medi return nil, nil } -func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - // Compile-time interface checks var ( _ artwork.Artwork = noopArtwork{} @@ -416,8 +408,8 @@ func setupTestDB() { // Create the Subsonic Router with real DS, streamer spy, and real Decider streamerSpy = &harness.SpyStreamer{} decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -427,7 +419,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go new file mode 100644 index 000000000..619619810 --- /dev/null +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -0,0 +1,328 @@ +package e2e + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "time" + + "github.com/Masterminds/squirrel" + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + _ "github.com/navidrome/navidrome/core/storage/local" // registers the "file" storage scheme + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// The serving path streams folder-backed originals via os.Open, which a fake FS cannot back, so +// this suite scans a small REAL on-disk library and drives the real worker and artwork.Artwork. +var _ = Describe("Artwork Serving", Ordered, func() { + var ( + artRouter *subsonic.Router + pubRouter *public.Router + artSvc artwork.Artwork + worker *artwork.Worker + artfulID string + artlessID string + artfulHash string + placeholder []byte + ) + + albumCoverInList := func(albumID string) string { + GinkgoHelper() + resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") + Expect(resp.AlbumList2).ToNot(BeNil()) + for _, al := range resp.AlbumList2.Album { + if al.Id == albumID { + return al.CoverArt + } + } + Fail("album " + albumID + " not found in getAlbumList2") + return "" + } + + getCover := func(params ...string) *httptest.ResponseRecorder { + GinkgoHelper() + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", params...) + artRouter.ServeHTTP(w, r) + return w + } + + BeforeAll(func() { + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + musicDir := GinkgoT().TempDir() + writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) + writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) + + conf.Server.MusicFolder = musicDir + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here + conf.Server.EnableMediaFileCoverArt = false + conf.Server.DevArtworkWorkerConcurrency = 1 + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.EnableSharing = true + conf.Server.DevArtworkMaxRequests = 100 + conf.Server.DevArtworkThrottleBacklogLimit = 100 + conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute + + goldenDB.Restore() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // Re-scanning already-populated data deadlocks the parallel album-refresh phase at the + // harness's MaxOpenConns=1, so wipe the golden content and import this library fresh. + wipeScannedContent() + artLib := model.Library{Name: "Artwork Library", Path: musicDir} + Expect(ds.Library(ctx).Put(&artLib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{artLib.ID})).To(Succeed()) + + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Focus the worker on the entities under test. + _, err = db.Db().Exec("DELETE FROM artwork_queue") + Expect(err).ToNot(HaveOccurred()) + + artfulID = albumIDByName("Artful Album") + artlessID = albumIDByName("Artless Album") + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + placeholder, err = io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + _ = ph.Close() + + store := artwork.NewImageStore(GinkgoT().TempDir()) + imgCache := newDummyImageCache(ctx) + ffm := harness.NoopFFmpeg{} + artSvc = artwork.NewArtwork(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + + artRouter = buildArtworkRouter(artSvc) + router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router + pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + }) + + It("emits a bare optimistic coverArt id before the queue is drained", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) + Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) + }) + + It("drains the queue: folder art is acquired, the artless album settles absent", func() { + // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. + for _, id := range []string{artfulID, artlessID} { + Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + } + runWorkerUntil(ctx, worker, func() bool { + found, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) + if err != nil || found.Hash == "" { + return false + } + absent, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artlessID, model.ImageTypePrimary) + return err == nil && absent.Hash == "" + }) + ia, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + artfulHash = ia.Hash + Expect(artfulHash).ToNot(BeEmpty()) + }) + + It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) + Expect(albumCoverInList(artlessID)).To(BeEmpty()) + }) + + It("serves the found album's bytes with revalidation headers for a bare id", func() { + w := getCover("id", "al-"+artfulID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) + + It("serves the found album immutably when the exact hash is requested", func() { + w := getCover("id", "al-"+artfulID+"_"+artfulHash) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with an empty body when the client already holds the hash", func() { + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) + r.Header.Set("If-None-Match", `"`+artfulHash+`"`) + artRouter.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Bytes()).To(BeEmpty()) + }) + + It("serves the placeholder with no-store for an absent album", func() { + w := getCover("id", "al-"+artlessID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + }) + + // Only adminUser was granted this library, so regularUser must get the placeholder even + // though the artwork state and bytes are perfectly servable by id. + It("serves the placeholder for an album in a library the caller cannot see", func() { + w := httptest.NewRecorder() + artRouter.ServeHTTP(w, buildReq(regularUser, "getCoverArt", "id", "al-"+artfulID)) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder), "must not leak the real cover") + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + + Expect(getCover("id", "al-"+artfulID).Body.Bytes()).ToNot(Equal(placeholder)) + }) + + // An id naming no entity is a different answer from an album with no art: no placeholder. + It("answers error 70 for an id that matches no entity", func() { + w := getCover("id", "al-nosuchalbum") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(ContainSubstring(`"code":70`)) + Expect(w.Body.Bytes()).ToNot(Equal(placeholder)) + }) + + It("serves /share/img immutably for a JWT whose payload carries the hash", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 404 from /share/img for an absent entity", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artlessID}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) + +// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Artwork. +func buildArtworkRouter(art artwork.Artwork) *subsonic.Router { + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + return subsonic.New( + ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, + events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + ) +} + +// wipeScannedContent clears all scanned content so the next scan is a clean import. FKs are +// disabled for the bulk delete, mirroring harness.Restore. +func wipeScannedContent() { + GinkgoHelper() + _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { + _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) + Expect(err).ToNot(HaveOccurred(), "wiping %s", t) + } + _, err = db.Db().Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +func albumIDByName(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) + return albums[0].ID +} + +// Distinct ALBUM/ALBUMARTIST tags keep the generated albums from merging into one. +func writeArtworkTrack(root, artist, album, title string, withCover bool) { + GinkgoHelper() + dir := filepath.Join(root, artist, album) + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + mp3 := filepath.Join(dir, title+".mp3") + Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) + Expect(taglib.WriteTags(mp3, map[string][]string{ + "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, + }, taglib.Clear)).To(Succeed()) + if withCover { + Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) + } +} + +func readArtworkFixture(name string) []byte { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// size=0 requests stream originals and never invoke the reader, so this resize cache only has +// to satisfy the constructor. +func newDummyImageCache(ctx context.Context) cache.FileCache { + GinkgoHelper() + c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in subsonic artwork e2e") + }) + Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + return c +} + +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index e652cf492..98f87ca17 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -6,7 +6,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -53,8 +52,8 @@ var _ = Describe("Multi-Library Support", Ordered, func() { adminWithLibs = *loadedAdmin // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index 775fefe89..c0cb1d359 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -9,6 +9,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" @@ -43,7 +44,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { noopProvider{}, nil, // scanner events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index b986200ae..e367cdee5 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -95,13 +95,24 @@ func getArtistAlbumCount(a *model.Artist) int32 { } } +// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty +// string + the field's omitempty tag) when artwork is known absent. +func coverArtOrEmpty(id model.ArtworkID, absent bool) string { + if absent { + return "" + } + return id.String() +} + func toArtist(r *http.Request, a model.Artist) responses.Artist { artist := responses.Artist{ - Id: a.ID, - Name: a.Name, - UserRating: int32(a.Rating), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), + Id: a.ID, + Name: a.Name, + UserRating: int32(a.Rating), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -114,12 +125,14 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ - Id: a.ID, - Name: a.Name, - AlbumCount: getArtistAlbumCount(&a), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), - UserRating: int32(a.Rating), + Id: a.ID, + Name: a.Name, + AlbumCount: getArtistAlbumCount(&a), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + UserRating: int32(a.Rating), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -208,7 +221,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Size = mf.Size child.Suffix = mf.Suffix child.BitRate = int32(mf.BitRate) - child.CoverArt = mf.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent) child.ContentType = mf.ContentType() if ok && player.ReportRealPath { @@ -369,7 +382,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre - child.CoverArt = al.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent) child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID @@ -460,7 +473,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) dir.SongCount = int32(album.SongCount) dir.Duration = int32(album.Duration) dir.PlayCount = album.PlayCount diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 8db624649..69fda4681 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -321,6 +321,82 @@ var _ = Describe("helpers", func() { }) }) + Describe("artwork coverArt emission", func() { + const hash = "0123456789abcdef" + ctx := context.Background() + + DescribeTable("coverArtOrEmpty", + func(id model.ArtworkID, absent bool, expected string) { + Expect(coverArtOrEmpty(id, absent)).To(Equal(expected)) + }, + Entry("emits the bare id when unresolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1"}, false, "al-1"), + Entry("emits the hash-suffixed id when resolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, false, "al-1_"+hash), + Entry("omits (empty) when known absent", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, true, ""), + ) + + Describe("childFromAlbum", func() { + It("suffixes coverArt with the content hash when resolved", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromAlbum(ctx, al).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + al := model.Album{ID: "al-1"} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("childFromMediaFile", func() { + // An al- id must carry the album's hash (AlbumImage), never the track's own. + It("suffixes coverArt with the album's content hash when resolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", AlbumImage: model.ItemImage{ImageHash: hash}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) + }) + + It("does not stamp a track's own art hash onto the album id", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", + ItemImage: model.ItemImage{ImageHash: "0000ownarthash00"}, + AlbumImage: model.ItemImage{ImageHash: hash}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1"} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("toArtist / toArtistID3", func() { + r := httptest.NewRequest("GET", "/test", nil) + It("emits both coverArt and imageUrl when resolved", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageHash: hash}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(Equal("ar-ar-1_" + hash)) + Expect(artist.ArtistImageUrl).ToNot(BeEmpty()) + }) + It("omits coverArt and imageUrl when known absent", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(BeEmpty()) + Expect(artist.ArtistImageUrl).To(BeEmpty()) + + id3 := toArtistID3(r, a) + Expect(id3.CoverArt).To(BeEmpty()) + Expect(id3.ArtistImageUrl).To(BeEmpty()) + }) + It("emits an imageUrl when unresolved", func() { + a := model.Artist{ID: "ar-1"} + Expect(toArtist(r, a).ArtistImageUrl).ToNot(BeEmpty()) + }) + }) + }) + Describe("osChildFromMediaFile", func() { var mf model.MediaFile var ctx context.Context diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 089a1fdda..8a5152a9d 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -66,7 +67,7 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) + img, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) switch { case errors.Is(err, context.Canceled): return nil, nil @@ -78,11 +79,13 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } - defer imgReader.Close() - w.Header().Set("cache-control", "public, max-age=315360000") - w.Header().Set("last-modified", lastUpdate.Format(http.TimeFormat)) + defer img.Close() - cnt, err := io.Copy(w, imgReader) + artID, _ := model.ParseArtworkID(id) + if imghttp.WriteImageHeaders(w, r, img, artID.Hash) { + return nil, nil + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 9331dfbe4..7610c866a 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -30,8 +31,14 @@ var _ = Describe("MediaRetrievalController", func() { var w *httptest.ResponseRecorder BeforeEach(func() { + albumRepo := &tests.MockAlbumRepo{} + albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible + radioRepo := tests.CreateMockedRadioRepo() + Expect(radioRepo.Put(&model.Radio{ID: "rd1", Name: "Radio"})).To(Succeed()) ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, + MockedAlbum: albumRepo, + MockedRadio: radioRepo, } artwork = &fakeArtwork{data: "image data"} router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) @@ -60,6 +67,22 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(Equal(artwork.data)) }) + // The service applies the library filter from the caller's context, so elevating here + // would bypass it. + It("passes the caller's context to the service rather than elevating", func() { + r := newGetRequest("id=al-34") + usr := model.User{ID: "u1", UserName: "u1"} + r = r.WithContext(request.WithUser(r.Context(), usr)) + + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + got, ok := request.UserFrom(artwork.recvCtx) + Expect(ok).To(BeTrue(), "the service must see who is asking") + Expect(got.ID).To(Equal("u1")) + Expect(got.IsAdmin).To(BeFalse(), "the handler must not elevate") + }) + It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound r := newGetRequest("id=34", "size=128", "square=true") @@ -108,6 +131,53 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(BeEmpty()) }) }) + + Describe("caching headers", func() { + const hash = "0123456789abcdef" + + It("sets an ETag and no-cache for a bare id", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + + It("marks the response immutable when the id asserts the current hash", func() { + artwork.hash = hash + r := newGetRequest("id=al-34_" + hash) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with no body when If-None-Match matches", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + r.Header.Set("If-None-Match", `"`+hash+`"`) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(304)) + Expect(w.Body.Len()).To(BeZero()) + }) + + It("never caches a placeholder", func() { + artwork.placeholder = true + r := newGetRequest("id=al-missing") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(200)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("ETag")).To(BeEmpty()) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + }) }) Describe("GetLyrics", func() { @@ -188,25 +258,35 @@ var _ = Describe("MediaRetrievalController", func() { type fakeArtwork struct { artwork.Artwork data string + hash string + lastUpdated time.Time + placeholder bool err error ctxCancelFunc func() recvId string recvSize int recvSquare bool + recvCtx context.Context } -func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (c *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { + c.recvCtx = ctx if c.err != nil { - return nil, time.Time{}, c.err + return nil, c.err } c.recvId = id c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { c.ctxCancelFunc() - return nil, time.Time{}, context.Canceled + return nil, context.Canceled } - return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader([]byte(c.data))), + Hash: c.hash, + LastUpdated: c.lastUpdated, + Placeholder: c.placeholder, + }, nil } type mockedMediaFile struct { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 17ba1b2c9..bd53528d9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -151,7 +151,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Comment = p.Comment pls.Owner = p.OwnerName pls.Public = p.Public - pls.CoverArt = p.CoverArtID().String() + pls.CoverArt = coverArtOrEmpty(p.CoverArtID(), p.ImageAbsent) pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f0a2f8ac5..a7c9e2ec7 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -129,6 +129,19 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("artwork emission", func() { + It("suffixes coverArt with the content hash when resolved", func() { + playlist.ImageHash = "0123456789abcdef" + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) + }) + It("omits coverArt when known absent", func() { + playlist.ImageAbsent = true + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(BeEmpty()) + }) + }) + Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 4fbd6a53d..1fb266f1d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -74,10 +74,10 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { continue } - // Add coverArt if not legacy client + // Add coverArt if not legacy client; only radios have an uploaded image as their sole art source var coverArt string if g.UploadedImage != "" { - coverArt = g.CoverArtID().String() + coverArt = coverArtOrEmpty(g.CoverArtID(), g.ImageAbsent) } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ CoverArt: coverArt, diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index e959ebe29..aa805abbf 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,10 +71,25 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) + + It("suffixes coverArt with the content hash when resolved and omits it when known absent", func() { + radioRepo.All = model.Radios{ + {ID: "rd-1", Name: "Radio 1", UploadedImage: "rd-1_cover.jpg", ItemImage: model.ItemImage{ImageHash: "0123456789abcdef"}}, + {ID: "rd-2", Name: "Radio 2", UploadedImage: "rd-2_cover.jpg", ItemImage: model.ItemImage{ImageAbsent: true}}, + } + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0123456789abcdef")) + Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) + }) }) Context("with a legacy client", func() { @@ -129,7 +144,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) }) }) diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index 5d4989ae5..cc787f041 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -110,11 +110,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { searchResult2 := &responses.SearchResult2{} searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist { a := responses.Artist{ - Id: artist.ID, - Name: artist.Name, - UserRating: int32(artist.Rating), - CoverArt: artist.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, artist.CoverArtID(), 600), + Id: artist.ID, + Name: artist.Name, + UserRating: int32(artist.Rating), + CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), + } + if !artist.ImageAbsent { + a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 4e72bd2e6..d31a50cfa 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -1,6 +1,8 @@ package subsonic import ( + "net/http" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -209,4 +211,32 @@ var _ = Describe("Search", func() { }) }) }) + + Describe("artwork emission", func() { + newSearchRequest := func() *http.Request { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ID: "user1", Libraries: []model.Library{{ID: 1}}}) + return r.WithContext(ctx) + } + + It("omits coverArt and imageUrl for a known-absent artist", func() { + mockArtistRepo.SetData(model.Artists{ + {ID: "ar-1", Name: "Absent Artist", ItemImage: model.ItemImage{ImageAbsent: true}}, + }) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(BeEmpty()) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).To(BeEmpty()) + }) + + It("emits coverArt and imageUrl for an unresolved artist", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Unresolved Artist"}}) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(Equal("ar-ar-1")) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).ToNot(BeEmpty()) + }) + }) }) diff --git a/tests/harness/harness.go b/tests/harness/harness.go index ff5ce8919..5949c4fae 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -13,7 +13,6 @@ import ( "testing/fstest" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" @@ -74,8 +73,8 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { u.Libraries = loaded.Libraries } - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -87,17 +86,12 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { return h } -// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a -// rescan. FTS shadow tables are skipped; they are kept in sync by their content tables' triggers. -func (h *DB) Restore() { - sqlDB := db.Db() - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) - Expect(err).ToNot(HaveOccurred()) - - rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") +// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded: +// their content tables' triggers keep them in sync, and writing them directly corrupts the index. +func ResettableTables() []string { + rows, err := db.Db().Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") Expect(err).ToNot(HaveOccurred()) + defer rows.Close() var tables []string for rows.Next() { var name string @@ -105,12 +99,37 @@ func (h *DB) Restore() { tables = append(tables, name) } Expect(rows.Err()).ToNot(HaveOccurred()) - rows.Close() + return tables +} + +// TruncateDB empties every resettable table, leaving the migrated schema in place — for suites +// whose specs each build their own library, so the schema is not re-migrated per spec. +func TruncateDB(tables []string) { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() for _, table := range tables { // Table names come from sqlite_master, not user input. _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec Expect(err).ToNot(HaveOccurred()) + } +} + +// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a +// rescan. +func (h *DB) Restore() { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) + Expect(err).ToNot(HaveOccurred()) + + for _, table := range ResettableTables() { + // Table names come from sqlite_master, not user input. + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec Expect(err).ToNot(HaveOccurred()) } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index cc7d66b4d..ff6f9cff6 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -2,10 +2,12 @@ package tests import ( "errors" + "sync" "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) func CreateMockAlbumRepo() *MockAlbumRepo { @@ -20,6 +22,7 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions + optionsMu sync.Mutex SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID @@ -68,7 +71,10 @@ func (m *MockAlbumRepo) Put(al *model.Album) error { func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { if len(qo) > 0 { + // Recording the last options is a read-path write, and callers resolve concurrently. + m.optionsMu.Lock() m.Options = qo[0] + m.optionsMu.Unlock() } if m.Err { return nil, errors.New("unexpected error") @@ -76,6 +82,14 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return slice.Map(all, func(a model.Album) string { return a.ID }), nil +} + func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index e6ea7aea4..9691a6584 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) func CreateMockArtistRepo() *MockArtistRepo { @@ -113,6 +114,14 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return slice.Map(all, func(a model.Artist) string { return a.ID }), nil +} + func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go new file mode 100644 index 000000000..1b097ca32 --- /dev/null +++ b/tests/mock_artwork_queue_repo.go @@ -0,0 +1,258 @@ +package tests + +import ( + "cmp" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockArtworkQueueRepo struct { + model.ArtworkQueueRepository + // mu guards Data: the worker drains this mock concurrently. + mu sync.Mutex + Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) + Err error + // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + ItemArtworkSource *MockArtworkRepo + // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. + ExistingIDs map[string]map[string]bool +} + +func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { + return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} +} + +func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + m.enqueueLocked(items) + return nil +} + +func (m *MockArtworkQueueRepo) enqueueLocked(items []model.ArtworkQueueItem) { + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + prev.RetryAt = now + prev.Attempts = 0 + prev.EnqueuedAt = now + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } +} + +// EnqueueIfMissing mirrors the SQL anti-join: skip anything that already has an item_artwork row. +func (m *MockArtworkQueueRepo) EnqueueIfMissing(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + var fresh []model.ArtworkQueueItem + for _, it := range items { + k := iaKey(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary)) + if m.ItemArtworkSource != nil { + if _, ok := m.ItemArtworkSource.ItemData[k]; ok { + continue + } + } + if _, ok := m.Data[k]; ok { // DO NOTHING + continue + } + fresh = append(fresh, it) + } + m.enqueueLocked(fresh) + return nil +} + +func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueItem + now := time.Now() + for _, it := range m.Data { + if !it.RetryAt.After(now) && (len(kinds) == 0 || slices.Contains(kinds, it.ItemKind)) { + res = append(res, it) + } + } + slices.SortFunc(res, func(a, b model.ArtworkQueueItem) int { + return cmp.Or(cmp.Compare(b.Priority, a.Priority), a.EnqueuedAt.Compare(b.EnqueuedAt)) + }) + if len(res) > n { + res = res[:n] + } + return res, nil +} + +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { + it.Attempts++ + it.RetryAt = retryAt + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) { + delete(m.Data, k) + } + return nil +} + +func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, it := range m.Data { + existing := m.ExistingIDs[it.ItemKind] + if existing == nil { + continue + } + if !existing[it.ItemID] { + delete(m.Data, k) + purged++ + } + } + return purged, nil +} + +func (m *MockArtworkQueueRepo) Count() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Preserve an existing row's retry_at: a bump raises priority without resetting backoff. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil || m.ItemArtworkSource == nil { + return 0, m.Err + } + now := time.Now() + var inserted int64 + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { + continue + } + k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: ia.ItemKind, + ItemID: ia.ItemID, + ImageType: ia.ImageType, + Priority: model.ArtworkPriorityRecheck, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} + +// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. +func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + hasRow := func(id string) bool { + if m.ItemArtworkSource == nil { + return false + } + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && ia.ItemID == id { + return true + } + } + return false + } + now := time.Now() + var inserted int64 + for id := range m.ExistingIDs[kind.Prefix()] { + if hasRow(id) { + continue + } + k := iaKey(kind.Prefix(), id, model.ImageTypePrimary) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), + ItemID: id, + ImageType: model.ImageTypePrimary, + Priority: priority, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go new file mode 100644 index 000000000..2ace0daba --- /dev/null +++ b/tests/mock_artwork_repo.go @@ -0,0 +1,175 @@ +package tests + +import ( + "maps" + "sync" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +type MockArtworkRepo struct { + model.ArtworkRepository + // mu guards the maps so the worker's concurrent drain can hit this mock race-free. + mu sync.Mutex + Data map[string]model.Artwork + ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) + Err error + // ExistingIDs, keyed by item_kind, backs PurgeDanglingItems; nil map keeps everything. + ExistingIDs map[string]map[string]bool +} + +func CreateMockArtworkRepo() *MockArtworkRepo { + return &MockArtworkRepo{Data: map[string]model.Artwork{}, ItemData: map[string]model.ItemArtwork{}} +} + +func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } + +func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + if a, ok := m.Data[hash]; ok { + return &a, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + // Mirrors the SQL repository: every upsert refreshes created_at. Age fixtures via Data directly. + a.CreatedAt = time.Now() + m.Data[a.Hash] = *a + return nil +} + +func (m *MockArtworkRepo) GetMimeByHash() (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + mimes := make(map[string]string, len(m.Data)) + for h, a := range m.Data { + mimes[h] = a.Mime + } + return mimes, nil +} + +func (m *MockArtworkRepo) PurgeDanglingItems() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, ia := range m.ItemData { + // A nil per-kind map means that kind isn't tracked by the test, so keep it. + existing := m.ExistingIDs[ia.ItemKind] + if existing == nil { + continue + } + if !existing[ia.ItemID] { + delete(m.ItemData, k) + purged++ + } + } + return purged, nil +} + +func (m *MockArtworkRepo) PurgeOrphans(createdBefore time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var deleted int64 + for h, a := range m.Data { + if m.referenced(h) || !a.CreatedAt.Before(createdBefore) { + continue + } + delete(m.Data, h) + deleted++ + } + return deleted, nil +} + +func (m *MockArtworkRepo) referenced(hash string) bool { + for _, ia := range m.ItemData { + if ia.Hash == hash { + return true + } + } + return false +} + +func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, imageType)]; ok { + return &ia, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + if ia.ImageType == "" { + ia.ImageType = model.ImageTypePrimary + } + ia.UpdatedAt = time.Now() + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } + m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia + return nil +} + +func (m *MockArtworkRepo) DeleteForItems(kind model.Kind, ids []string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + idSet := slice.ToSet(ids) + maps.DeleteFunc(m.ItemData, func(_ string, ia model.ItemArtwork) bool { + _, ok := idSet[ia.ItemID] + return ok && ia.ItemKind == kind.Prefix() + }) + return nil +} + +func (m *MockArtworkRepo) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + res := map[string]model.ItemArtworkInfo{} + for _, id := range ids { + if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, model.ImageTypePrimary)]; ok { + info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash} + if a, ok := m.Data[ia.Hash]; ok { + info.BlurHash, info.ThumbHash = a.BlurHash, a.ThumbHash + info.DominantColor = a.DominantColor + info.Width, info.Height = a.Width, a.Height + } + res[id] = info + } + } + return res, nil +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index e016a28de..32f56a4f0 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,8 @@ type MockDataStore struct { MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository MockedPlugin model.PluginRepository + MockedArtwork model.ArtworkRepository + MockedArtworkQueue model.ArtworkQueueRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -37,6 +39,8 @@ type MockDataStore struct { } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedLibrary != nil { return db.MockedLibrary } @@ -48,6 +52,8 @@ func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { } func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedFolder != nil { return db.MockedFolder } @@ -59,6 +65,8 @@ func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { } func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedTag != nil { return db.MockedTag } @@ -70,6 +78,8 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { } func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedAlbum != nil { return db.MockedAlbum } @@ -81,6 +91,8 @@ func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { } func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedArtist != nil { return db.MockedArtist } @@ -92,11 +104,11 @@ func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { } func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.RealDS != nil && db.MockedMediaFile == nil { return db.RealDS.MediaFile(ctx) } - db.repoMu.Lock() - defer db.repoMu.Unlock() if db.MockedMediaFile == nil { db.MockedMediaFile = CreateMockMediaFileRepo() } @@ -104,6 +116,8 @@ func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepositor } func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedGenre != nil { return db.MockedGenre } @@ -115,6 +129,8 @@ func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { } func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlaylist != nil { return db.MockedPlaylist } @@ -126,6 +142,8 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository } func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlayQueue != nil { return db.MockedPlayQueue } @@ -137,6 +155,8 @@ func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepositor } func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedUserProps != nil { return db.MockedUserProps } @@ -148,6 +168,8 @@ func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepositor } func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedProperty != nil { return db.MockedProperty } @@ -159,6 +181,8 @@ func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository } func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedShare != nil { return db.MockedShare } @@ -170,6 +194,8 @@ func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { } func (db *MockDataStore) User(ctx context.Context) model.UserRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedUser != nil { return db.MockedUser } @@ -181,6 +207,8 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository { } func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedTranscoding != nil { return db.MockedTranscoding } @@ -192,6 +220,8 @@ func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepos } func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlayer != nil { return db.MockedPlayer } @@ -203,6 +233,8 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { } func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.RealDS != nil && db.MockedScrobbleBuffer == nil { return db.RealDS.ScrobbleBuffer(ctx) } @@ -215,6 +247,8 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe } func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedScrobble != nil { return db.MockedScrobble } @@ -226,6 +260,8 @@ func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository } func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedRadio != nil { return db.MockedRadio } @@ -237,6 +273,8 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { } func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlugin != nil { return db.MockedPlugin } @@ -247,6 +285,42 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { return db.MockedPlugin } +func (db *MockDataStore) Artwork(ctx context.Context) model.ArtworkRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + return db.artworkLocked(ctx) +} + +// artworkLocked is the body of Artwork for callers already holding repoMu; repoMu is a plain +// Mutex, so re-entering through the exported method would deadlock. +func (db *MockDataStore) artworkLocked(ctx context.Context) model.ArtworkRepository { + if db.MockedArtwork != nil { + return db.MockedArtwork + } + if db.RealDS != nil { + return db.RealDS.Artwork(ctx) + } + db.MockedArtwork = CreateMockArtworkRepo() + return db.MockedArtwork +} + +func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedArtworkQueue != nil { + return db.MockedArtworkQueue + } + if db.RealDS != nil { + return db.RealDS.ArtworkQueue(ctx) + } + q := CreateMockArtworkQueueRepo() + if aw, ok := db.artworkLocked(ctx).(*MockArtworkRepo); ok { + q.ItemArtworkSource = aw + } + db.MockedArtworkQueue = q + return db.MockedArtworkQueue +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 990b91d7c..d993af1a8 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -123,6 +123,18 @@ func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFile }, nil } +func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + return m.GetCursor(qo...) +} + +func (m *MockMediaFileRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return slice.Map(all, func(mf model.MediaFile) string { return mf.ID }), nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 8f8842c8e..ee645e984 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -7,6 +7,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) func CreateMockPlaylistRepo() *MockPlaylistRepo { @@ -52,6 +53,14 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return slice.Map(all, func(p model.Playlist) string { return p.ID }), nil +} + func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 2835baadd..83751bb28 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -14,6 +14,7 @@ type MockPlaylistTrackRepo struct { Reordered bool AddCount int Err error + AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { @@ -66,6 +67,13 @@ func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model. }, nil } +func (m *MockPlaylistTrackRepo) GetAlbumIDs(...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return m.AlbumIDs, nil +} + func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { if m.Err != nil { return nil, m.Err diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index c50a529e5..2baeadc5c 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) type MockedRadioRepo struct { @@ -16,7 +17,7 @@ type MockedRadioRepo struct { } func CreateMockedRadioRepo() *MockedRadioRepo { - return &MockedRadioRepo{} + return &MockedRadioRepo{Data: map[string]*model.Radio{}} } func (m *MockedRadioRepo) SetError(err bool) { @@ -73,6 +74,14 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } +func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return slice.Map(all, func(r model.Radio) string { return r.ID }), nil +} + func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index cec66eb8b..f55d94ffc 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Card, CardContent, - CardMedia, Collapse, makeStyles, Typography, @@ -21,6 +20,7 @@ import Lightbox from 'react-image-lightbox' import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' +import { Artwork } from '../common/Artwork' import { ArtistLinkField, CollapsibleComment, @@ -30,7 +30,6 @@ import { RatingField, SizeField, useAlbumsPerPage, - useImageLoadingState, } from '../common' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' @@ -88,9 +87,6 @@ const useStyles = makeStyles( backgroundColor: 'transparent', transition: 'opacity 0.3s ease-in-out', }, - coverLoading: { - opacity: 0.5, - }, loveButton: { top: theme.spacing(-0.2), left: theme.spacing(0.5), @@ -223,15 +219,7 @@ const AlbumDetails = (props) => { const classes = useStyles() const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) let notes = albumInfo?.notes || record.notes @@ -254,27 +242,18 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( <Card className={classes.root}> <div className={classes.cardContents}> <div className={classes.coverParent}> - <CardMedia - key={record.id} - component={'img'} - src={imageUrl} - width="400" - height="400" - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} + <Artwork + record={record} + fit="contain" + className={classes.cover} title={record.name} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} + onClick={() => setLightboxOpen(true)} /> </div> <div className={classes.details}> @@ -363,13 +342,13 @@ const AlbumDetails = (props) => { </Collapse> </div> )} - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={fullImageUrl} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </Card> diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 33db9e40f..2e4cb10ba 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import { GridList, GridListTile, @@ -11,19 +11,18 @@ import withWidth from '@material-ui/core/withWidth' import { Link } from 'react-router-dom' import { linkToRecord, useListContext, Loading } from 'react-admin' import { withContentRect } from 'react-measure' +import { useRollChanged } from './useRollChanged' import { useDrag } from 'react-dnd' -import subsonic from '../subsonic' import { AlbumContextMenu, PlayButton, ArtistLinkField, OverflowTooltip, - useImageUrl, } from '../common' -import config from '../config' import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -105,12 +104,12 @@ const useCoverStyles = makeStyles({ display: 'inline-block', width: '100%', objectFit: 'contain', + // The image fills this box absolutely, so it lends no height: a remount that has not been + // re-measured yet would collapse the tile and blank the cover for a frame. + aspectRatio: '1', height: (props) => props.height, transition: 'opacity 0.3s ease-in-out', }, - coverLoading: { - opacity: 0, - }, }) const getColsForWidth = (width) => { @@ -138,16 +137,14 @@ const Cover = withContentRect('bounds')(({ [record], ) - const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) - const { imgUrl, loading: imageLoading } = useImageUrl(url) - return ( <div ref={measureRef} className={classes.coverContainer}> <div ref={dragAlbumRef}> - <img - src={imgUrl || undefined} - alt={record.name} - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + <Artwork + record={record} + square + className={classes.cover} + title={record.name} /> </div> </div> @@ -237,9 +234,21 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => { ) } -const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => { - const hide = - (loading && albumListType === 'random') || !props.data || !props.ids +const AlbumGridView = ({ + albumListType, + loaded, + loading, + seed, + shownSeed, + ...props +}) => { + // ArtistShow renders this grid too, with no roll to track, so own a ref when none is passed. + const ownSeed = useRef(null) + // A re-roll replaces every album, so the previous roll must not linger while it loads. + const rerolling = + useRollChanged(shownSeed ?? ownSeed, seed, loading) && + albumListType === 'random' + const hide = rerolling || !props.data || !props.ids return hide ? <Loading /> : <LoadedAlbumGrid {...props} /> } diff --git a/ui/src/album/AlbumGridView.test.jsx b/ui/src/album/AlbumGridView.test.jsx new file mode 100644 index 000000000..1f06c3fd7 --- /dev/null +++ b/ui/src/album/AlbumGridView.test.jsx @@ -0,0 +1,18 @@ +import { render } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import AlbumGridView from './AlbumGridView' + +// react-admin's Link/useListContext need a router and a store; the grid body is not under test here. +vi.mock('react-admin', () => ({ + linkToRecord: () => '/album/1', + useListContext: () => ({}), + Loading: () => <div data-testid="loading" />, +})) +describe('AlbumGridView', () => { + // ArtistShow renders the grid through ReferenceManyField, which passes no seed tracking. + it('renders without a shownSeed ref', () => { + expect(() => + render(<AlbumGridView data={{}} ids={[]} basePath="/album" width="md" />), + ).not.toThrow() + }) +}) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index cd73fa0c3..3cb8a648e 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -1,4 +1,4 @@ -import { cloneElement } from 'react' +import { cloneElement, useRef } from 'react' import { useSelector } from 'react-redux' import { Redirect, useLocation } from 'react-router-dom' import { @@ -30,6 +30,7 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' +import { useRollChanged } from './useRollChanged' import albumLists from './albumLists' import { getStoredDefaultView, @@ -186,9 +187,10 @@ const AlbumListTitle = ({ albumListType }) => { return <Title subTitle={title} args={{ smart_count: 2 }} /> } -const AlbumListPagination = ({ albumListType, ...rest }) => { +const AlbumListPagination = ({ albumListType, seed, shownSeed, ...rest }) => { const { loading } = useListContext() - if (loading && albumListType === 'random') { + const rerolling = useRollChanged(shownSeed, seed, loading) + if (rerolling && albumListType === 'random') { return null } return <Pagination {...rest} /> @@ -198,6 +200,7 @@ const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { const { width } = props + const shownSeed = useRef(null) const albumView = useSelector((state) => state.albumView) const [perPage, perPageOptions] = useAlbumsPerPage(width) const location = useLocation() @@ -260,13 +263,20 @@ const AlbumList = (props) => { <AlbumListPagination rowsPerPageOptions={perPageOptions} albumListType={albumListType} + seed={seed} + shownSeed={shownSeed} /> } title={<AlbumListTitle albumListType={albumListType} />} > <ScrollRestorer> {albumView.grid ? ( - <AlbumGridView albumListType={albumListType} {...props} /> + <AlbumGridView + albumListType={albumListType} + seed={seed} + shownSeed={shownSeed} + {...props} + /> ) : ( <AlbumTableView {...props} /> )} diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index d1a89d512..051d5988e 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -14,7 +14,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, - CoverArtAvatar, + ArtworkAvatar, DurationField, RangeField, SimpleList, @@ -164,7 +164,7 @@ const AlbumTableView = ({ )} leftIcon={(r) => ( <span style={{ marginRight: '8px' }}> - <CoverArtAvatar record={r} variant="square" /> + <ArtworkAvatar record={r} variant="square" /> </span> )} linkType={'show'} @@ -173,7 +173,7 @@ const AlbumTableView = ({ /> ) : ( <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> - <CoverArtAvatar source="id" variant="square" /> + <ArtworkAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <AlbumContextMenu diff --git a/ui/src/album/useRollChanged.jsx b/ui/src/album/useRollChanged.jsx new file mode 100644 index 000000000..b364bdc6b --- /dev/null +++ b/ui/src/album/useRollChanged.jsx @@ -0,0 +1,13 @@ +import { useRef } from 'react' + +// Reports that the albums on screen belong to a different roll than the one loading: only a seed +// change is a re-roll. `shown` is owned above the grid, which a refresh remounts under the new seed. +export const useRollChanged = (shown, seed, loading) => { + const wasLoading = useRef(loading) + + if (!loading && (shown.current === null || wasLoading.current)) { + shown.current = seed + } + wasLoading.current = loading + return shown.current !== seed +} diff --git a/ui/src/album/useRollChanged.test.jsx b/ui/src/album/useRollChanged.test.jsx new file mode 100644 index 000000000..0745efb08 --- /dev/null +++ b/ui/src/album/useRollChanged.test.jsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect } from 'vitest' +import { useRollChanged } from './useRollChanged' + +describe('useRollChanged', () => { + // Passing `shown` in mimics AlbumList owning it across a remount. + const setup = (props, shown = { current: null }) => ({ + shown, + ...renderHook(({ seed, loading }) => useRollChanged(shown, seed, loading), { + initialProps: props, + }), + }) + + it('reports a change while a load is in flight on a fresh mount', () => { + const { result } = setup({ seed: 's1', loading: true }) + expect(result.current).toBe(true) + }) + + it('reports no change when mounted with data already in hand', () => { + const { result } = setup({ seed: 's1', loading: false }) + expect(result.current).toBe(false) + }) + + it('stays false while loading a filter change on the same roll', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's1', loading: true }) + expect(result.current).toBe(false) + }) + + it('goes true while loading after the seed changes', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's2', loading: true }) + expect(result.current).toBe(true) + }) + + it('clears once the new roll has loaded', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's2', loading: true }) + expect(result.current).toBe(true) + rerender({ seed: 's2', loading: false }) + expect(result.current).toBe(false) + }) + + // The seed can land a render before loading flips; the new roll must not be recorded as shown. + it('still reports a change when the seed arrives before loading starts', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's2', loading: false }) + expect(result.current).toBe(true) + }) + + // Refresh remounts the grid under the new seed a render before the refetch starts. + it('reports a change when a refresh remounts the grid before loading starts', () => { + const { shown, unmount } = setup({ seed: 's1', loading: false }) + expect(shown.current).toBe('s1') + unmount() + + const remounted = setup({ seed: 's2', loading: false }, shown) + expect(remounted.result.current).toBe(true) + + remounted.rerender({ seed: 's2', loading: true }) + expect(remounted.result.current).toBe(true) + remounted.rerender({ seed: 's2', loading: false }) + expect(remounted.result.current).toBe(false) + }) + + it('reports no change when a remount keeps the same roll', () => { + const { shown, unmount } = setup({ seed: 's1', loading: false }) + unmount() + + const remounted = setup({ seed: 's1', loading: false }, shown) + expect(remounted.result.current).toBe(false) + }) +}) diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 4559aa8e6..f4147221e 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -22,7 +22,7 @@ import { useDrag } from 'react-dnd' import clsx from 'clsx' import { ArtistContextMenu, - CoverArtAvatar, + ArtworkAvatar, List, useGetHandleArtistClick, RatingField, @@ -173,7 +173,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { /> ) : ( <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> - <CoverArtAvatar source="id" /> + <ArtworkAvatar source="id" /> <TextField source="name" /> <FunctionField source="albumCount" diff --git a/ui/src/artist/ArtistSimpleList.jsx b/ui/src/artist/ArtistSimpleList.jsx index 55b6b1a0b..9dcae0470 100644 --- a/ui/src/artist/ArtistSimpleList.jsx +++ b/ui/src/artist/ArtistSimpleList.jsx @@ -8,7 +8,7 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemText from '@material-ui/core/ListItemText' import { makeStyles } from '@material-ui/core/styles' import { sanitizeListRestProps } from 'react-admin' -import { ArtistContextMenu, CoverArtAvatar, RatingField } from '../common' +import { ArtistContextMenu, ArtworkAvatar, RatingField } from '../common' import config from '../config' const useStyles = makeStyles( @@ -49,7 +49,7 @@ const ArtistSimpleList = ({ <span key={id} onClick={() => linkType(id)}> <ListItem className={classes.listItem} button={true}> <ListItemAvatar> - <CoverArtAvatar record={data[id]} /> + <ArtworkAvatar record={data[id]} /> </ListItemAvatar> <ListItemText style={{ marginLeft: '8px' }} diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index dda761097..9269dc8d7 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -3,20 +3,15 @@ import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core' import Card from '@material-ui/core/Card' import CardContent from '@material-ui/core/CardContent' -import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { - LoveButton, - RatingField, - ImageUploadOverlay, - useImageLoadingState, -} from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -49,9 +44,6 @@ const useStyles = makeStyles( transition: 'opacity 0.3s ease-in-out', objectFit: 'cover', }, - coverLoading: { - opacity: 0.5, - }, artistImage: { maxHeight: '12rem', minHeight: '12rem', @@ -91,35 +83,18 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) return ( <div className={classes.root}> <Card className={classes.artistDetail}> <Card className={classes.artistImage}> - {artistInfo && ( - <CardMedia - key={record.id} - component="img" - src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} - title={title} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} - /> - )} + <Artwork + record={record} + className={classes.cover} + title={title} + onClick={() => setLightboxOpen(true)} + /> <ImageUploadOverlay entityType="artist" entityId={record.id} @@ -175,13 +150,13 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { )} </Typography> </div> - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={subsonic.getCoverArtUrl(record)} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </Card> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index e82d4c28a..0925b6f10 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -2,17 +2,12 @@ import React, { useState } from 'react' import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' -import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { - LoveButton, - RatingField, - ImageUploadOverlay, - useImageLoadingState, -} from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -60,9 +55,6 @@ const useStyles = makeStyles( transition: 'opacity 0.3s ease-in-out', objectFit: 'cover', }, - coverLoading: { - opacity: 0.5, - }, artistImage: { marginLeft: '1em', maxHeight: '7rem', @@ -88,41 +80,24 @@ const useStyles = makeStyles( { name: 'NDMobileArtistDetails' }, ) -const MobileArtistDetails = ({ artistInfo, biography, record }) => { +const MobileArtistDetails = ({ biography, record }) => { const img = subsonic.getCoverArtUrl(record, 800) const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) return ( <> <div className={classes.root}> <div className={classes.bgContainer}> <Card className={classes.artistImage}> - {artistInfo && ( - <CardMedia - key={record.id} - component="img" - src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} - title={title} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} - /> - )} + <Artwork + record={record} + className={classes.cover} + title={title} + onClick={() => setLightboxOpen(true)} + /> <ImageUploadOverlay entityType="artist" entityId={record.id} @@ -165,13 +140,13 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { </Typography> </Collapse> </div> - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={img} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </> diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx new file mode 100644 index 000000000..21ac5e07b --- /dev/null +++ b/ui/src/common/Artwork.jsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import clsx from 'clsx' +import { makeStyles } from '@material-ui/core/styles' +import config from '../config' +import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' +import { ThumbHashCanvas } from './ThumbHashCanvas' + +// Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift. +const fadeMs = 150 + +const useStyles = makeStyles({ + // className supplies the size and shape; overflow:hidden clips the fills to it. + root: { + position: 'relative', + display: 'inline-flex', + overflow: 'hidden', + }, + fill: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + }, + img: { + opacity: 0, + transition: `opacity ${fadeMs}ms ease-out`, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + }, + imgVisible: { opacity: 1 }, + // Already-decoded blobs appear at once: fading them in would re-animate on every remount. + imgInstant: { opacity: 1, transition: 'none' }, +}) + +// Renders a cover through the shared useImageUrl blob cache, so it survives remounts without +// re-fetching. The image mounts only once its blob is ready, so it never renders broken. +export const Artwork = ({ + record, + size = config.uiCoverArtSize, + square = false, + fit = 'cover', + className, + title, + onClick, +}) => { + const classes = useStyles() + const url = record ? subsonic.getCoverArtUrl(record, size, square) : '' + const { imgUrl, fromCache } = useImageUrl(url) + + const [decoded, setDecoded] = useState(false) + const [faded, setFaded] = useState(false) + const frame = useRef(0) + useEffect(() => { + setDecoded(false) + setFaded(false) + return () => cancelAnimationFrame(frame.current) + }, [url]) + + // Two frames: a blob decodes from memory, so onLoad can beat the first paint of opacity 0, + // leaving the transition no start value and popping some tiles in while others fade. + const handleLoad = () => { + frame.current = requestAnimationFrame(() => { + frame.current = requestAnimationFrame(() => setDecoded(true)) + }) + } + + // Timer, not transitionend: under prefers-reduced-motion there is no transition to end. + useEffect(() => { + if (!decoded || faded) return undefined + const timer = setTimeout(() => setFaded(true), fadeMs) + return () => clearTimeout(timer) + }, [decoded, faded]) + + if (!record) return null + + // A blob already cached paints on the first frame, so it skips the fade. + const instant = fromCache + // Kept mounted until the fade ends; swapping on blob arrival would flash an empty container. + const showPlaceholder = !!record.thumbHash && !instant && !faded + // A square request is padded, not cropped, so `contain` keeps placeholder and image aligned. + const effectiveFit = square ? 'contain' : fit + const ratio = record.imageWidth / record.imageHeight + const handleClick = imgUrl && onClick ? onClick : undefined + return ( + <div + className={clsx(classes.root, className)} + onClick={handleClick} + style={{ cursor: handleClick ? 'pointer' : 'default' }} + > + {showPlaceholder && ( + <ThumbHashCanvas + hash={record.thumbHash} + ratio={ratio} + fit={effectiveFit} + className={classes.fill} + /> + )} + {imgUrl && ( + <img + src={imgUrl} + alt={title} + title={title} + className={clsx( + classes.fill, + classes.img, + instant && classes.imgInstant, + decoded && classes.imgVisible, + )} + style={{ objectFit: effectiveFit }} + onLoad={handleLoad} + /> + )} + </div> + ) +} + +Artwork.propTypes = { + record: PropTypes.object, + size: PropTypes.number, + square: PropTypes.bool, + fit: PropTypes.oneOf(['cover', 'contain']), + className: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, +} diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx new file mode 100644 index 000000000..18b3c916f --- /dev/null +++ b/ui/src/common/Artwork.test.jsx @@ -0,0 +1,232 @@ +import { render, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() })) +vi.mock('../subsonic', () => ({ + default: { + // Mirrors the real hash suffix, so a refreshed record yields a different URL. + getCoverArtUrl: (record) => + '/rest/getCoverArt?id=al-1' + + (record.imageHash ? `_${record.imageHash}` : ''), + }, +})) +vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } })) + +import { useImageUrl } from './useImageUrl' +import { Artwork } from './Artwork' + +const withArt = { + id: 'al-1', + name: 'Album', + thumbHash: 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', +} + +describe('Artwork', () => { + beforeEach(() => { + vi.clearAllMocks() + // jsdom has no 2D context; stub it so ThumbHashCanvas bails cleanly without console noise + HTMLCanvasElement.prototype.getContext = vi.fn(() => null) + }) + + it('renders nothing without a record', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: false }) + const { container } = render(<Artwork record={null} />) + expect(container.firstChild).toBeNull() + }) + + it('shows the placeholder and no <img> while loading', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container } = render(<Artwork record={withArt} title="Album" />) + expect(container.querySelector('canvas')).not.toBeNull() + expect(container.querySelector('img')).toBeNull() + }) + + it('shows neither a broken <img> nor a canvas while loading a record with no thumbhash', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container } = render(<Artwork record={{ id: 'al-2', name: 'X' }} />) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('canvas')).toBeNull() + }) + + // The placeholder has to land exactly where the image will, or it jumps when the image swaps in. + it('shapes the placeholder like the artwork and fits it like the image', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } + const { container } = render( + <Artwork record={nonSquare} fit="contain" title="Album" />, + ) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(21) + expect(canvas.style.objectFit).toBe('contain') + }) + + // A square request is padded, not cropped, so the placeholder has to letterbox with it. + it('letterboxes the placeholder when the server pads a non-square image to a square', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } + const { container } = render(<Artwork record={nonSquare} square />) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(21) + expect(canvas.style.objectFit).toBe('contain') + }) + + // The padded square is aspect-fit, so `square` overrides fit="cover" as it does for the canvas. + it('fits the image itself with contain when the server padded to a square', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: true, + }) + const { container } = render( + <Artwork record={withArt} square fit="cover" title="Album" />, + ) + expect(container.querySelector('img').style.objectFit).toBe('contain') + }) + + it('fills the box for square artwork, the overwhelmingly common case', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const sq = { ...withArt, imageWidth: 600, imageHeight: 600 } + const { container } = render(<Artwork record={sq} square />) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(32) + }) + + // Without dimensions the placeholder falls back to the aspect the hash itself carries. + it('falls back to the hash own aspect when the record has no dimensions', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container } = render(<Artwork record={withArt} />) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(32) + }) + + it('mounts the image only once its blob is ready', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: true, + }) + const { container } = render(<Artwork record={withArt} title="Album" />) + const img = container.querySelector('img') + expect(img).not.toBeNull() + expect(img.getAttribute('src')).toBe('blob:abc') + }) + + it('keeps the placeholder under the image until the fade ends', () => { + vi.useFakeTimers() + try { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render(<Artwork record={withArt} />) + + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: false, + }) + rerender(<Artwork record={withArt} />) + const img = container.querySelector('img') + expect(img).not.toBeNull() + expect(container.querySelector('canvas')).not.toBeNull() + + // The clock ends the fade, not transitionend, which never fires under reduced-motion. + act(() => { + fireEvent.load(img) + }) + expect(container.querySelector('canvas')).not.toBeNull() + act(() => { + fireEvent.transitionEnd(img) + }) + expect(container.querySelector('canvas')).not.toBeNull() + // Two frames pass before the fade starts, so opacity 0 gets painted first. + act(() => { + vi.advanceTimersByTime(32) + }) + expect(container.querySelector('canvas')).not.toBeNull() + act(() => { + vi.advanceTimersByTime(150) + }) + expect(container.querySelector('canvas')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('does not fade an image that was already cached on mount', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: true, + }) + const { container } = render(<Artwork record={withArt} />) + expect(container.querySelector('canvas')).toBeNull() + expect(container.querySelector('img').className).toContain('imgInstant') + }) + + it('keeps the placeholder visible when the image never decodes', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render(<Artwork record={withArt} />) + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: false, + }) + rerender(<Artwork record={withArt} />) + + expect(container.querySelector('canvas')).not.toBeNull() + }) + + it('fires onClick only when the image is loaded', () => { + const onClick = vi.fn() + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render( + <Artwork record={withArt} onClick={onClick} />, + ) + container.firstChild.click() + expect(onClick).not.toHaveBeenCalled() + + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: false, + }) + rerender(<Artwork record={withArt} onClick={onClick} />) + container.firstChild.click() + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('shows the placeholder for a cached fetch failure, which has no blob', () => { + useImageUrl.mockReturnValue({ + imgUrl: null, + loading: false, + error: true, + fromCache: false, + }) + const { container } = render(<Artwork record={withArt} />) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('canvas')).not.toBeNull() + }) + + it('falls back to the placeholder when a refresh swaps in an uncached hash', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:old', + loading: false, + fromCache: true, + }) + const cached = { ...withArt, imageHash: 'aaaa' } + const { container, rerender } = render(<Artwork record={cached} />) + expect(container.querySelector('canvas')).toBeNull() + + useImageUrl.mockReturnValue({ + imgUrl: null, + loading: true, + fromCache: false, + }) + rerender(<Artwork record={{ ...withArt, imageHash: 'bbbb' }} />) + + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('canvas')).not.toBeNull() + }) +}) diff --git a/ui/src/common/ArtworkAvatar.jsx b/ui/src/common/ArtworkAvatar.jsx new file mode 100644 index 000000000..7691050c6 --- /dev/null +++ b/ui/src/common/ArtworkAvatar.jsx @@ -0,0 +1,40 @@ +import { useRecordContext } from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' +import clsx from 'clsx' +import config from '../config' +import { Artwork } from './Artwork' + +const useStyles = makeStyles({ + avatar: { + width: '55px', + height: '55px', + }, + square: { + borderRadius: '4px', + }, + circular: { + borderRadius: '50%', + }, +}) + +export const ArtworkAvatar = ({ record: recordProp, variant = 'circular' }) => { + const classes = useStyles() + const recordContext = useRecordContext() + const record = recordProp || recordContext + if (!record) return null + const square = variant !== 'circular' + return ( + <Artwork + record={record} + size={config.uiCoverArtSize} + square={square} + className={clsx( + classes.avatar, + square ? classes.square : classes.circular, + )} + title={record.name} + /> + ) +} + +ArtworkAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx deleted file mode 100644 index f70403774..000000000 --- a/ui/src/common/CoverArtAvatar.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useRecordContext } from 'react-admin' -import { Avatar } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import clsx from 'clsx' -import config from '../config' -import subsonic from '../subsonic' -import { useImageUrl } from './useImageUrl' - -const useStyles = makeStyles({ - avatar: { - width: '55px', - height: '55px', - }, - avatarEmpty: { - backgroundColor: 'transparent', - }, - square: { - borderRadius: '4px', - }, -}) - -export const CoverArtAvatar = ({ - record: recordProp, - variant = 'circular', -}) => { - const classes = useStyles() - const recordContext = useRecordContext() - const record = recordProp || recordContext - const square = variant !== 'circular' - const url = record - ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square) - : null - const { imgUrl } = useImageUrl(url) - if (!record) return null - return ( - <Avatar - src={imgUrl || undefined} - variant={variant} - className={clsx( - classes.avatar, - square && classes.square, - !imgUrl && classes.avatarEmpty, - )} - alt={record.name} - > - {/* Empty child prevents default person icon while loading */} - {!imgUrl && <span />} - </Avatar> - ) -} - -CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/ThumbHashCanvas.jsx b/ui/src/common/ThumbHashCanvas.jsx new file mode 100644 index 000000000..03d349313 --- /dev/null +++ b/ui/src/common/ThumbHashCanvas.jsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react' +import PropTypes from 'prop-types' +import { decode, naturalSize } from '../utils/thumbhash' + +// A thumbhash carries no detail beyond a few dozen pixels; CSS upscales the canvas. +const DECODE_SIZE = 32 + +// bitmapSize prefers the artwork's true ratio, falling back to the aspect the hash itself carries, +// which is quantised to a ratio of small integers and so only approximates the image. +const bitmapSize = (hash, ratio) => { + if (!(ratio > 0) || !Number.isFinite(ratio)) { + try { + return naturalSize(hash) + } catch { + return { width: DECODE_SIZE, height: DECODE_SIZE } + } + } + return ratio >= 1 + ? { + width: DECODE_SIZE, + height: Math.max(1, Math.round(DECODE_SIZE / ratio)), + } + : { + width: Math.max(1, Math.round(DECODE_SIZE * ratio)), + height: DECODE_SIZE, + } +} + +export const ThumbHashCanvas = ({ hash, ratio, fit, className, style }) => { + const canvasRef = useRef(null) + const { width, height } = bitmapSize(hash, ratio) + + useEffect(() => { + if (!hash || !canvasRef.current) { + return + } + const ctx = canvasRef.current.getContext('2d') + if (!ctx) { + return + } + // Clear first so a hash change that fails to decode never leaves a stale frame. + ctx.clearRect(0, 0, width, height) + try { + const pixels = decode(hash, width, height) + const imageData = ctx.createImageData(width, height) + imageData.data.set(pixels) + ctx.putImageData(imageData, 0, 0) + } catch { + // A malformed hash simply leaves the canvas blank. + } + }, [hash, width, height]) + + if (!hash) { + return null + } + return ( + <canvas + ref={canvasRef} + width={width} + height={height} + className={className} + style={{ ...style, objectFit: fit }} + aria-hidden="true" + /> + ) +} + +ThumbHashCanvas.propTypes = { + hash: PropTypes.string, + // Aspect ratio (width / height) of the image this stands in for; the hash's own when omitted. + ratio: PropTypes.number, + fit: PropTypes.oneOf(['cover', 'contain']), + className: PropTypes.string, + style: PropTypes.object, +} diff --git a/ui/src/common/ThumbHashCanvas.test.jsx b/ui/src/common/ThumbHashCanvas.test.jsx new file mode 100644 index 000000000..b1f53fe5c --- /dev/null +++ b/ui/src/common/ThumbHashCanvas.test.jsx @@ -0,0 +1,95 @@ +import { render } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ThumbHashCanvas } from './ThumbHashCanvas' + +// Golden hashes from core/artwork/thumbhash/testdata/golden.json. +const SQUARE = 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I' +const LANDSCAPE = '3wcOFJpwh4eBh3d4iIePgAj3hw==' + +describe('ThumbHashCanvas', () => { + // jsdom has no real 2D context; stub it (tracked) so specs can assert the draw path ran. + let ctxMock + let getContextSpy + beforeEach(() => { + ctxMock = { + clearRect: vi.fn(), + createImageData: vi.fn((w, h) => ({ + data: new Uint8ClampedArray(w * h * 4), + })), + putImageData: vi.fn(), + } + getContextSpy = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(ctxMock) + }) + afterEach(() => { + getContextSpy.mockRestore() + }) + + it('renders nothing without a hash', () => { + const { container } = render(<ThumbHashCanvas hash="" />) + expect(container.querySelector('canvas')).toBeNull() + }) + + it('decodes a valid hash and draws non-trivial pixel data', () => { + const { container } = render(<ThumbHashCanvas hash={SQUARE} />) + expect(container.querySelector('canvas')).not.toBeNull() + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + const [imageData] = ctxMock.putImageData.mock.calls[0] + expect(imageData.data.some((byte) => byte !== 0)).toBe(true) + }) + + it('decodes into a bitmap shaped like the image, so the blur is not distorted', () => { + const { container } = render( + <ThumbHashCanvas hash={SQUARE} ratio={1200 / 800} />, + ) + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 21) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(21) + }) + + it('shapes a portrait ratio the other way round', () => { + render(<ThumbHashCanvas hash={SQUARE} ratio={0.5} />) + expect(ctxMock.createImageData).toHaveBeenCalledWith(16, 32) + }) + + it('never collapses an extreme ratio to a zero-sized bitmap', () => { + render(<ThumbHashCanvas hash={SQUARE} ratio={200} />) + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 1) + }) + + // Unlike a blurhash, a thumbhash carries its own approximate aspect, so an unknown ratio + // falls back to that rather than to a square. + it('falls back to the hash own aspect when the ratio is unknown', () => { + render(<ThumbHashCanvas hash={LANDSCAPE} ratio={0} />) + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 18) + }) + + it('applies the object-fit it is given, so it lands where the image will', () => { + const { container } = render( + <ThumbHashCanvas hash={SQUARE} ratio={1.5} fit="contain" />, + ) + expect(container.querySelector('canvas').style.objectFit).toBe('contain') + }) + + it('renders a canvas without throwing on a malformed hash, and draws nothing', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { container } = render( + <ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />, + ) + expect(container.querySelector('canvas')).not.toBeNull() + expect(ctxMock.putImageData).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('clears the canvas when a hash change fails to decode', () => { + const { rerender } = render(<ThumbHashCanvas hash={SQUARE} />) + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + + rerender(<ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />) + + expect(ctxMock.clearRect).toHaveBeenCalledTimes(2) + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index c88ea3e9c..7b5b376f3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -46,6 +46,6 @@ export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' -export * from './CoverArtAvatar' +export * from './ArtworkAvatar' export * from './useImageLoadingState' export * from './useImageUrl' diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js index 9bcc70d74..8a7f2bcc7 100644 --- a/ui/src/common/useImageUrl.js +++ b/ui/src/common/useImageUrl.js @@ -43,6 +43,18 @@ export const useImageUrl = (url) => { const [error, setError] = useState(cached?.error || false) const abortedRef = useRef(false) + // Resync during render: otherwise imgUrl holds the previous url's blob for one render, so only + // fromCache can answer "already cached" - and a remembered failure has no blob to paint. + const [fromCache, setFromCache] = useState(!!cached?.blobUrl) + const [trackedUrl, setTrackedUrl] = useState(url) + if (trackedUrl !== url) { + setTrackedUrl(url) + setImgUrl(cached?.blobUrl || null) + setLoading(!!url && !cached) + setError(cached?.error || false) + setFromCache(!!cached?.blobUrl) + } + useEffect(() => { abortedRef.current = false @@ -140,5 +152,5 @@ export const useImageUrl = (url) => { } }, [url]) - return { imgUrl, loading, error } + return { imgUrl, loading, error, fromCache } } diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js index 976317105..818732d06 100644 --- a/ui/src/common/useImageUrl.test.js +++ b/ui/src/common/useImageUrl.test.js @@ -200,6 +200,7 @@ describe('useImageUrl', () => { expect(global.fetch).toHaveBeenCalledTimes(1) expect(result2.current.imgUrl).toBe('blob:mock-url') expect(result2.current.loading).toBe(false) + expect(result2.current.fromCache).toBe(true) }) it('should cache errors and not re-fetch broken URLs', async () => { @@ -230,5 +231,7 @@ describe('useImageUrl', () => { expect(result2.current.error).toBe(true) expect(result2.current.imgUrl).toBeNull() expect(result2.current.loading).toBe(false) + // A remembered failure has no blob, so callers must not treat it as instantly painted. + expect(result2.current.fromCache).toBe(false) }) }) diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx index eabff6f92..b59ae8e88 100644 --- a/ui/src/common/useResourceRefresh.jsx +++ b/ui/src/common/useResourceRefresh.jsx @@ -63,6 +63,9 @@ import { useRefresh, useDataProvider } from 'react-admin' * - Global refresh: { '*': '*' } or { someResource: ['*'] } * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] } */ +// Resources whose records are media files, and so inherit their album's artwork. +const trackResources = ['song', 'playlistTrack'] + export const useResourceRefresh = (...visibleResources) => { const [lastTime, setLastTime] = useState(Date.now()) const refresh = useRefresh() @@ -70,6 +73,7 @@ export const useResourceRefresh = (...visibleResources) => { const refreshData = useSelector( (state) => state.activity?.refresh || { lastReceived: lastTime }, ) + const loadedResources = useSelector((state) => state.admin?.resources) const { resources, lastReceived } = refreshData if (lastReceived <= lastTime) { @@ -77,21 +81,49 @@ export const useResourceRefresh = (...visibleResources) => { } setLastTime(lastReceived) - if ( + const isWatched = (r) => + visibleResources.length === 0 || visibleResources.includes(r) + // A wildcard on a resource this component does not show is somebody else's business: reloading + // the page for it throws away the list the user is looking at. + const hasWildcard = resources && (resources['*'] === '*' || - Object.values(resources).find((v) => v.find((v2) => v2 === '*'))) - ) { + Object.entries(resources).some( + ([r, ids]) => isWatched(r) && ids.includes?.('*'), + )) + + if (hasWildcard) { refresh() return } - if (resources) { - Object.keys(resources).forEach((r) => { - if (visibleResources.length === 0 || visibleResources?.includes(r)) { - if (resources[r]?.length > 0) { - dataProvider.getMany(r, { ids: resources[r] }) + if (!resources) { + return + } + Object.keys(resources).forEach((r) => { + if (isWatched(r)) { + if (resources[r]?.length > 0) { + // Only refetch records already in the store; ones the UI never loaded will + // arrive fresh (with the new artwork) when navigated to, so fetching them is wasteful. + const loaded = loadedResources?.[r]?.data || {} + const ids = resources[r].filter((id) => loaded[id] !== undefined) + if (ids.length > 0) { + dataProvider.getMany(r, { ids }) } } + } + }) + + // A track with no art of its own is served its album's, so an album's new coverArt id moves its + // tracks' too. The dependent ids are unbounded server-side, but the store knows which are loaded. + if (resources.album?.length > 0) { + const albumIds = new Set(resources.album) + trackResources.filter(isWatched).forEach((r) => { + const ids = Object.values(loadedResources?.[r]?.data || {}) + .filter((t) => albumIds.has(t?.albumId)) + .map((t) => t.id) + if (ids.length > 0) { + dataProvider.getMany(r, { ids }) + } }) } } diff --git a/ui/src/common/useResourceRefresh.test.js b/ui/src/common/useResourceRefresh.test.js index eabd478db..ccd729836 100644 --- a/ui/src/common/useResourceRefresh.test.js +++ b/ui/src/common/useResourceRefresh.test.js @@ -38,6 +38,27 @@ describe('useResourceRefresh', () => { const useDataProviderMock = () => ({ getMany }) let lastTime + // Applies each real selector against a constructed store, so both useSelector + // calls in the hook (refresh payload + loaded records) resolve correctly. + const mockStore = ({ refresh: refreshPayload, loaded = {} }) => { + const state = { + activity: { refresh: refreshPayload }, + admin: { resources: loaded }, + } + vi.spyOn(Redux, 'useSelector').mockImplementation((selector) => + selector(state), + ) + } + + // Turns id lists into the react-admin store shape: { album: { data: { 'al-1': {...} } } } + const asStore = (byResource) => + Object.fromEntries( + Object.entries(byResource).map(([r, ids]) => [ + r, + { data: Object.fromEntries(ids.map((id) => [id, { id }])) }, + ]), + ) + beforeEach(() => { vi.spyOn(React, 'useState').mockImplementation(useStateMock) vi.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock) @@ -50,8 +71,7 @@ describe('useResourceRefresh', () => { }) it('stores last time checked, to avoid redundant runs', () => { - const useSelectorMock = () => ({ lastReceived: lastTime }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + mockStore({ refresh: { lastReceived: lastTime } }) useResourceRefresh() @@ -60,8 +80,7 @@ describe('useResourceRefresh', () => { it("does not run again if lastTime didn't change", () => { vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) - const useSelectorMock = () => ({ lastReceived: lastTime }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + mockStore({ refresh: { lastReceived: lastTime } }) useResourceRefresh() @@ -70,11 +89,9 @@ describe('useResourceRefresh', () => { describe('No visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { '*': '*' }, + mockStore({ + refresh: { lastReceived: lastTime, resources: { '*': '*' } }, }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -83,11 +100,9 @@ describe('useResourceRefresh', () => { }) it('triggers a UI refresh when received an "any" id', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['*'] }, + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['*'] } }, }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -95,12 +110,14 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('triggers a refetch of the resources received', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + it('refetches only the received resources already loaded in the store', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + }, + loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -109,15 +126,42 @@ describe('useResourceRefresh', () => { expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1', 'sg-2'] }) }) + + it('skips ids that are not loaded in the store', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2', 'al-3'] }, + }, + loaded: asStore({ album: ['al-2'] }), + }) + + useResourceRefresh() + + expect(getMany).toHaveBeenCalledTimes(1) + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-2'] }) + }) + + it('does not fetch when none of the received ids are loaded', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { artist: ['ar-1', 'ar-2'] }, + }, + loaded: asStore({ artist: ['ar-9'] }), + }) + + useResourceRefresh() + + expect(getMany).not.toHaveBeenCalled() + }) }) describe('Visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { '*': '*' }, + mockStore({ + refresh: { lastReceived: lastTime, resources: { '*': '*' } }, }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('album') @@ -125,12 +169,101 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('triggers a refetch of the resources received if they are visible', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + it('does not refresh the page when the wildcard is on a resource it does not watch', () => { + // Guards other senders: a wildcard on an unwatched resource must not reload this list. + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['*'] }, + }, + loaded: asStore({ album: ['al-1', 'al-2'] }), + }) + + useResourceRefresh('album') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) + }) + + it('refetches the loaded songs of a refreshed album', () => { + // A track with no art of its own is served its album's, so its coverArt id moves with the + // album's. The backend cannot know which tracks are loaded; the store can. + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, + loaded: { + album: { data: { 'al-1': { id: 'al-1' } } }, + song: { + data: { + 'sg-1': { id: 'sg-1', albumId: 'al-1' }, + 'sg-2': { id: 'sg-2', albumId: 'al-2' }, + }, + }, + }, + }) + + useResourceRefresh('song') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1'] }) + }) + + it('fans an album refresh out to loaded playlist tracks, which have their own ids', () => { + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, + loaded: { + playlistTrack: { + data: { + 'pt-1': { id: 'pt-1', albumId: 'al-1' }, + 'pt-2': { id: 'pt-2', albumId: 'al-2' }, + }, + }, + }, + }) + + useResourceRefresh('playlistTrack', 'song', 'playlist') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledWith('playlistTrack', { ids: ['pt-1'] }) + }) + + it('does not fan out to track resources the component does not show', () => { + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, + loaded: { + album: { data: { 'al-1': { id: 'al-1' } } }, + song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, + }, + }) + + useResourceRefresh('album') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledTimes(1) + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1'] }) + }) + + it('does not refetch songs when no loaded song belongs to the refreshed album', () => { + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-9'] } }, + loaded: { + song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, + }, + }) + + useResourceRefresh('song') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).not.toHaveBeenCalled() + }) + + it('refetches the received resources if they are visible and loaded', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + }, + loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('song') diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 894809ce0..5d0f6631e 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -1,10 +1,5 @@ -import { - Card, - CardContent, - CardMedia, - Typography, - useMediaQuery, -} from '@material-ui/core' +import { useState } from 'react' +import { Card, CardContent, Typography, useMediaQuery } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useTranslate } from 'react-admin' import Lightbox from 'react-image-lightbox' @@ -17,10 +12,9 @@ import { SizeField, isWritable, OverflowTooltip, - useImageLoadingState, } from '../common' -import config from '../config' import subsonic from '../subsonic' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -75,9 +69,6 @@ const useStyles = makeStyles( backgroundColor: 'transparent', transition: 'opacity 0.3s ease-in-out', }, - coverLoading: { - opacity: 0.5, - }, title: { overflow: 'hidden', textOverflow: 'ellipsis', @@ -107,37 +98,21 @@ const PlaylistDetails = (props) => { const translate = useTranslate() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) - const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( <Card className={classes.root}> <div className={classes.cardContents}> <div className={classes.coverParent}> - <CardMedia - key={record.id} // Force re-render when playlist changes - component={'img'} - src={imageUrl} - width="400" - height="400" - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} + <Artwork + record={record} + square + fit="contain" + className={classes.cover} title={record.name} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} + onClick={() => setLightboxOpen(true)} /> {isWritable(record.ownerId) && ( <ImageUploadOverlay @@ -187,13 +162,13 @@ const PlaylistDetails = (props) => { </CardContent> </div> </div> - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={fullImageUrl} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </Card> diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 642d90dd5..d2b17b108 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -20,7 +20,7 @@ import Switch from '@material-ui/core/Switch' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { - CoverArtAvatar, + ArtworkAvatar, DurationField, List, LoveButton, @@ -197,7 +197,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} > <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> - <CoverArtAvatar source="id" variant="square" /> + <ArtworkAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <Writable> diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index 4e269be18..4c2fea315 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -35,7 +35,7 @@ const PlaylistShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() - useResourceRefresh('song') + useResourceRefresh('playlistTrack', 'song') return ( <> diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index bbe38b4d5..4718a7cf4 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -100,7 +100,8 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const dataProvider = useDataProvider() const notify = useNotify() const version = useVersion() - useResourceRefresh('song', 'playlist') + // The rows are stored as playlistTrack, not song: their ids are playlist entries. + useResourceRefresh('playlistTrack', 'song', 'playlist') useEffect(() => { setPage(1) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index ccdb9f1ef..0cfb2cd68 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -18,11 +18,10 @@ import { List, defaultRowsPerPageOptions, getStoredPerPage, - useImageUrl, ToggleFieldsMenu, useSelectedFields, } from '../common' -import subsonic from '../subsonic' +import { Artwork } from '../common/Artwork' import { StreamField } from './StreamField' import { setTrack } from '../actions' import { songFromRadio } from './helper' @@ -83,15 +82,32 @@ const RadioListActions = ({ const avatarStyle = { width: 40, height: 40 } +const useCoverStyles = makeStyles({ + cover: { width: 40, height: 40, borderRadius: '4px' }, +}) + const CoverArtField = ({ record }) => { - const directUrl = record?.uploadedImage - ? subsonic.getCoverArtUrl(record, 40, true) - : null - const { imgUrl } = useImageUrl(directUrl) + const classes = useCoverStyles() if (!record) return null - const src = imgUrl || RADIO_PLACEHOLDER_IMAGE + // Radios resolve art only from an uploaded image; otherwise show the generic radio icon. + if (record.uploadedImage) { + return ( + <Artwork + record={record} + size={40} + square + className={classes.cover} + title={record.name} + /> + ) + } return ( - <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> + <Avatar + src={RADIO_PLACEHOLDER_IMAGE} + variant="rounded" + style={avatarStyle} + alt={record.name} + /> ) } CoverArtField.defaultProps = { label: '' } diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..9f0ad28d7 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -81,25 +81,27 @@ const getAvatarUrl = (username, size) => ) const getCoverArtUrl = (record, size, square) => { + const suffix = record.imageHash ? '_' + record.imageHash : '' const options = { - ...(record.updatedAt && { _: record.updatedAt }), + // A hash-suffixed url is already pixel-versioned; the buster would defeat immutable caching. + ...(!record.imageHash && record.updatedAt && { _: record.updatedAt }), ...(size && { size }), ...(square && { square }), } // TODO Move this logic to server if (record.album) { - return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) + return baseUrl(url('getCoverArt', 'mf-' + record.id + suffix, options)) } else if (record.albumArtist) { - return baseUrl(url('getCoverArt', 'al-' + record.id, options)) + return baseUrl(url('getCoverArt', 'al-' + record.id + suffix, options)) } else if (record.sync !== undefined) { // This is a playlist - return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) + return baseUrl(url('getCoverArt', 'pl-' + record.id + suffix, options)) } else if (record.streamUrl !== undefined) { // This is a radio station - return baseUrl(url('getCoverArt', 'ra-' + record.id, options)) + return baseUrl(url('getCoverArt', 'ra-' + record.id + suffix, options)) } else { - return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) + return baseUrl(url('getCoverArt', 'ar-' + record.id + suffix, options)) } } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index ad4764c24..32c699a41 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -120,6 +120,47 @@ describe('getCoverArtUrl', () => { expect(url).toContain('ar-test-123') expect(url).not.toContain('_=') }) + + it('appends the content hash to the artwork id', () => { + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + imageHash: '0123456789abcdef', + }) + expect(url).toContain('al-album-123_0123456789abcdef') + }) + + it('drops the updatedAt buster once the url is hash-versioned', () => { + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + updatedAt: '2023-01-01T00:00:00Z', + imageHash: '0123456789abcdef', + }) + expect(url).toContain('al-album-123_0123456789abcdef') + expect(url).not.toContain('_=') + }) + + it('keeps the updatedAt buster while artwork is unresolved', () => { + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + updatedAt: '2023-01-01T00:00:00Z', + }) + expect(url).toContain('al-album-123') + expect(url).toContain('_=') + }) + + it('still builds a url for known-absent artwork so the server placeholder renders', () => { + // Returning '' here made <img src=undefined> render as a broken icon; the server serves a + // proper placeholder for absent art, so the client must still request it. + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + imageAbsent: true, + }) + expect(url).toContain('al-album-123') + }) }) describe('getDiscCoverArtUrl', () => { diff --git a/ui/src/utils/thumbhash.js b/ui/src/utils/thumbhash.js new file mode 100644 index 000000000..f4ec673d2 --- /dev/null +++ b/ui/src/utils/thumbhash.js @@ -0,0 +1,138 @@ +// ThumbHash decoder (https://github.com/evanw/thumbhash), kept byte-compatible with the reference: +// core/artwork/thumbhash/testdata/thumbhash.js pins the pixels the specs assert on. + +const toBytes = (hash) => { + if (typeof hash !== 'string' || hash === '') { + throw new Error('thumbhash: empty hash') + } + const binary = atob(hash) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + if (bytes.length < 5) { + throw new Error('thumbhash: hash too short') + } + return bytes +} + +// header unpacks the fixed 5-byte prefix plus the optional alpha byte. +const header = (bytes) => { + const header24 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) + const header16 = bytes[3] | (bytes[4] << 8) + const hasAlpha = header24 >> 23 !== 0 + if (hasAlpha && bytes.length < 6) { + throw new Error('thumbhash: hash too short for alpha') + } + const isLandscape = header16 >> 15 !== 0 + const alphaLimit = hasAlpha ? 5 : 7 + return { + lDC: (header24 & 63) / 63, + pDC: ((header24 >> 6) & 63) / 31.5 - 1, + qDC: ((header24 >> 12) & 63) / 31.5 - 1, + lScale: ((header24 >> 18) & 31) / 31, + hasAlpha, + pScale: ((header16 >> 3) & 63) / 63, + qScale: ((header16 >> 9) & 63) / 63, + lx: Math.max(3, isLandscape ? alphaLimit : header16 & 7), + ly: Math.max(3, isLandscape ? header16 & 7 : alphaLimit), + aDC: hasAlpha ? (bytes[5] & 15) / 15 : 1, + aScale: hasAlpha ? (bytes[5] >> 4) / 15 : 0, + } +} + +// naturalSize is the reference decoder's own output size: 32 on the long edge, at the aspect the +// hash approximates. Callers with the true dimensions should decode at those instead. +export const naturalSize = (hash) => { + const { lx, ly } = header(toBytes(hash)) + const ratio = lx / ly + return ratio > 1 + ? { width: 32, height: Math.round(32 / ratio) } + : { width: Math.round(32 * ratio), height: 32 } +} + +const cosTable = (n, size) => { + const table = new Float64Array(n * size) + for (let c = 0; c < n; c++) { + for (let i = 0; i < size; i++) { + table[c * size + i] = Math.cos((Math.PI / size) * (i + 0.5) * c) + } + } + return table +} + +export const decode = (hash, width, height) => { + const bytes = toBytes(hash) + const h = header(bytes) + if (!(width > 0) || !(height > 0)) { + throw new Error('thumbhash: width and height must be positive') + } + + const acStart = h.hasAlpha ? 6 : 5 + let acIndex = 0 + const channel = (nx, ny, scale) => { + const ac = [] + for (let cy = 0; cy < ny; cy++) { + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) { + const byte = bytes[acStart + (acIndex >> 1)] ?? 0 + ac.push((((byte >> ((acIndex++ & 1) << 2)) & 15) / 7.5 - 1) * scale) + } + } + return ac + } + // The 1.25x chroma boost is the reference's quantisation compensation, not a free parameter. + const lAC = channel(h.lx, h.ly, h.lScale) + const pAC = channel(3, 3, h.pScale * 1.25) + const qAC = channel(3, 3, h.qScale * 1.25) + const aAC = h.hasAlpha ? channel(5, 5, h.aScale) : [] + + const nx = Math.max(h.lx, h.hasAlpha ? 5 : 3) + const ny = Math.max(h.ly, h.hasAlpha ? 5 : 3) + const fx = cosTable(nx, width) + const fy = cosTable(ny, height) + + const pixels = new Uint8ClampedArray(width * height * 4) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let l = h.lDC + let p = h.pDC + let q = h.qDC + let a = h.aDC + + for (let cy = 0, j = 0; cy < h.ly; cy++) { + const fy2 = fy[cy * height + y] * 2 + for (let cx = cy ? 0 : 1; cx * h.ly < h.lx * (h.ly - cy); cx++, j++) { + l += lAC[j] * fx[cx * width + x] * fy2 + } + } + for (let cy = 0, j = 0; cy < 3; cy++) { + const fy2 = fy[cy * height + y] * 2 + for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++, j++) { + const f = fx[cx * width + x] * fy2 + p += pAC[j] * f + q += qAC[j] * f + } + } + if (h.hasAlpha) { + for (let cy = 0, j = 0; cy < 5; cy++) { + const fy2 = fy[cy * height + y] * 2 + for (let cx = cy ? 0 : 1; cx < 5 - cy; cx++, j++) { + a += aAC[j] * fx[cx * width + x] * fy2 + } + } + } + + const b = l - (2 / 3) * p + const r = (3 * l - b + q) / 2 + const g = r - q + const idx = 4 * x + y * width * 4 + // Explicit floor: the reference writes into a Uint8Array, which truncates, while the + // Uint8ClampedArray that createImageData needs would round. + pixels[idx] = Math.floor(Math.max(0, 255 * Math.min(1, r))) + pixels[idx + 1] = Math.floor(Math.max(0, 255 * Math.min(1, g))) + pixels[idx + 2] = Math.floor(Math.max(0, 255 * Math.min(1, b))) + pixels[idx + 3] = Math.floor(Math.max(0, 255 * Math.min(1, a))) + } + } + return pixels +} diff --git a/ui/src/utils/thumbhash.test.js b/ui/src/utils/thumbhash.test.js new file mode 100644 index 000000000..6abb4d7e4 --- /dev/null +++ b/ui/src/utils/thumbhash.test.js @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { decode, naturalSize } from './thumbhash' + +// Digests pinned from evanw/thumbhash's reference decoder (vendored at +// core/artwork/thumbhash/testdata/thumbhash.js), so any drift from it fails here. +const fnv = (bytes) => { + let h = 2166136261 >>> 0 + for (const b of bytes) { + h ^= b + h = Math.imul(h, 16777619) >>> 0 + } + return h.toString(16) +} + +const REFERENCE = [ + ['alpha', 'JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==', 32, 32, '500141bc'], + ['landscape', '3wcOFJpwh4eBh3d4iIePgAj3hw==', 32, 18, '18465aed'], + ['portrait', '3/cNFBqBB4iId4d3d4iAjwj4hw==', 18, 32, '56500cc1'], + ['solid', 'HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA', 32, 32, '39cc5c5'], + ['square', 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', 32, 32, '740d8afc'], + ['tiny', 'HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP', 32, 32, 'f2555987'], +] + +describe('thumbhash decode', () => { + it.each(REFERENCE)( + 'reproduces the reference pixels for %s', + (_name, hash, w, h, digest) => { + expect(fnv(decode(hash, w, h))).toEqual(digest) + }, + ) + + it.each(REFERENCE)( + 'reports the reference natural size for %s', + (_name, hash, w, h) => { + expect(naturalSize(hash)).toEqual({ width: w, height: h }) + }, + ) + + it('decodes to any requested grid, not just the natural one', () => { + const [, hash] = REFERENCE[4] + const pixels = decode(hash, 8, 5) + expect(pixels).toHaveLength(8 * 5 * 4) + // Opaque hash: every alpha byte is saturated. + for (let i = 3; i < pixels.length; i += 4) { + expect(pixels[i]).toBe(255) + } + }) + + it('carries alpha through for a hash that has it', () => { + const [, hash, w, h] = REFERENCE[0] + const pixels = decode(hash, w, h) + const alphas = new Set() + for (let i = 3; i < pixels.length; i += 4) { + alphas.add(pixels[i]) + } + expect(alphas.size).toBeGreaterThan(1) + }) + + it.each([ + ['empty', ''], + ['too short to hold a header', 'AAAA'], + ['not base64', '!!!not-a-thumbhash!!!'], + ])('throws on a hash that is %s', (_name, hash) => { + expect(() => decode(hash, 32, 32)).toThrow() + }) +}) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index ed2374696..33fa046df 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -2,8 +2,10 @@ package cache import ( "context" + "errors" "fmt" "io" + "io/fs" "path/filepath" "sync" "sync/atomic" @@ -158,6 +160,13 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { key := arg.Key() r, w, err := fc.cache.Get(key) + if errors.Is(err, fs.ErrNotExist) { + // The entry outlived its data file. Drop it and retry, or every future Get + // for this key fails for the rest of the process's life. + log.Debug(ctx, "Cache entry lost its data file. Re-fetching", "cache", fc.name, "key", key) + _ = fc.invalidate(ctx, key) + r, w, err = fc.cache.Get(key) + } if err != nil { return nil, err } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index edcfbc6b9..7ce1b0393 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -278,6 +279,92 @@ var _ = Describe("File Caches", func() { }).Should(BeTrue()) }) }) + + Context("entry outliving its data file", func() { + It("re-fetches when the data file vanished behind the cache's back", func() { + var calls atomic.Int32 + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + calls.Add(1) + return strings.NewReader("payload"), nil + }) + + s, err := fc.Get(context.Background(), &testArg{"vanish"}) + Expect(err).To(BeNil()) + Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) + Expect(s.Close()).To(Succeed()) + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"vanish"}).Key()) + Eventually(func() error { _, e := os.Stat(dataPath); return e }).Should(Succeed()) + Expect(os.Remove(dataPath)).To(Succeed()) + + s2, err := fc.Get(context.Background(), &testArg{"vanish"}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(s2)).To(Equal([]byte("payload"))) + _ = s2.Close() + Expect(calls.Load()).To(BeNumerically("==", 2)) + }) + + It("survives an invalidated entry's deferred file removal", func() { + // invalidate() drops the map entry but defers the unlink until readers close; + // a Get in that window re-creates the file, which the deferred unlink then eats. + var n atomic.Int32 + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + if n.Add(1) == 1 { + return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("died")}, nil + } + return strings.NewReader("GOOD"), nil + }) + + key := (&testArg{"deferred"}).Key() + s1, err := fc.Get(context.Background(), &testArg{"deferred"}) + Expect(err).To(BeNil()) + + // The failed write invalidates the entry; the removal now waits on s1. + Eventually(func() bool { return fc.cache.Exists(key) }).Should(BeFalse()) + + s2, err := fc.Get(context.Background(), &testArg{"deferred"}) + Expect(err).To(BeNil()) + Expect(io.ReadAll(s2)).To(Equal([]byte("GOOD"))) + Expect(s2.Close()).To(Succeed()) + + Expect(s1.Close()).To(Succeed()) + + dataPath := fcSpreadFS(fc).KeyMapper(key) + Eventually(func() bool { + _, e := os.Stat(dataPath) + return os.IsNotExist(e) + }).Should(BeTrue(), "expected the deferred removal to take the re-created file") + + s3, err := fc.Get(context.Background(), &testArg{"deferred"}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(s3)).To(Equal([]byte("GOOD"))) + _ = s3.Close() + }) + + It("re-fetches when an adopted entry's data file vanished", func() { + // Entries adopted on startup take a different code path than in-process ones. + var calls atomic.Int32 + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + calls.Add(1) + return strings.NewReader("payload"), nil + }) + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"adopted"}).Key()) + Expect(os.MkdirAll(filepath.Dir(dataPath), 0755)).To(Succeed()) + Expect(os.WriteFile(dataPath, []byte("payload"), 0600)).To(Succeed()) + Expect(fcSpreadFS(fc).MarkComplete(dataPath)).To(Succeed()) + + adopted := callNewFileCache("test2", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + calls.Add(1) + return strings.NewReader("payload"), nil + }) + Expect(os.Remove(dataPath)).To(Succeed()) + + s, err := adopted.Get(context.Background(), &testArg{"adopted"}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) + _ = s.Close() + }) + }) }) }) diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 647439790..11801b324 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -128,7 +128,7 @@ func (sfs *spreadFS) Create(name string) (stream.File, error) { if err != nil { return nil, err } - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + return createDataFile(name) } func (sfs *spreadFS) Open(name string) (stream.File, error) { diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index 0f88d3a58..d0b54567d 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -1,10 +1,13 @@ package cache import ( + "io" "os" "path/filepath" + "runtime" "strings" + "github.com/djherbis/stream" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -39,6 +42,39 @@ var _ = Describe("Spread FS", func() { }) }) + Describe("Create", func() { + It("leaves an already-open reader's bytes intact", func() { + // Shrinking a file an older stream still serves spins its reader at a premature EOF. + if runtime.GOOS == "windows" { + Skip("Windows cannot unlink a file with open handles, so Create reuses the inode") + } + name := filepath.Join(rootDir, "aa", "bb", "data") + s, err := stream.NewStream(name, fs) + Expect(err).To(BeNil()) + _, err = s.Write([]byte("PARTIAL")) + Expect(err).To(BeNil()) + r, err := s.NextReader() + Expect(err).To(BeNil()) + Expect(s.Close()).To(Succeed()) + + f, err := fs.Create(name) + Expect(err).To(BeNil()) + _, err = f.Write([]byte("GOOD")) + Expect(err).To(BeNil()) + Expect(f.Close()).To(Succeed()) + + done := make(chan []byte, 1) + go func() { + b, _ := io.ReadAll(r) + done <- b + }() + Eventually(done).Should(Receive(Equal([]byte("PARTIAL")))) + Expect(r.Close()).To(Succeed()) + + Expect(os.ReadFile(name)).To(Equal([]byte("GOOD"))) + }) + }) + Describe("MarkComplete / Remove markers", func() { It("creates a .complete marker for a data file", func() { data := fs.KeyMapper("song1") diff --git a/utils/cache/spread_fs_unix.go b/utils/cache/spread_fs_unix.go new file mode 100644 index 000000000..c7cb19cf8 --- /dev/null +++ b/utils/cache/spread_fs_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package cache + +import ( + "os" + + "github.com/djherbis/stream" +) + +// createDataFile unlinks instead of truncating, so a re-created entry gets a fresh +// inode and readers still holding the old one see its full contents. +func createDataFile(name string) (stream.File, error) { + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return nil, err + } + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) +} diff --git a/utils/cache/spread_fs_windows.go b/utils/cache/spread_fs_windows.go new file mode 100644 index 000000000..ca4186d65 --- /dev/null +++ b/utils/cache/spread_fs_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package cache + +import ( + "os" + + "github.com/djherbis/stream" +) + +// createDataFile truncates in place: Windows refuses to unlink a file another handle +// has open, and failing the create would break every miss on a path with a live reader. +func createDataFile(name string) (stream.File, error) { + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) +} From 7993fb9158196c0ee33136ce54dee07a26dfd215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 10 Aug 2026 11:42:27 -0400 Subject: [PATCH 418/596] perf(persistence): use *_artists join tables for artist participant filters (#5930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(persistence): use *_artists join tables for artist participant filters The artist_id/artists_id, role_<role>_id and role_total_id filters, plus the AlbumsByArtistID/AlbumsByContributingArtistID/SongsByArtistID helpers, scanned every album or media_file row through json_tree(participants, ...), which no index can serve — the cause of multi-second artist pages on large libraries (discussion #5929). Rewrite them to semi-join the album_artists and media_file_artists tables via a shared ParticipantIDFilter helper. The join tables are written in the same transaction as the participants JSON, so results are unchanged. album_artists' unique constraint led with album_id, so artist-driven lookups had no usable index. Rebuild the table with the constraint reordered to (artist_id, album_id, role, sub_role), mirroring media_file_artists, instead of adding a fourth index: measured within 4% of a dedicated covering index (geomean -92.5% vs json_tree on a 96k-track production copy) while saving ~7MiB and per-scan write amplification. Album-side consumers (participant rewrites, FK cascades, markMissing) keep using album_artists_album_id, and updateParticipants' ON CONFLICT target already names artist_id first. The rebuild is linear work: 1.1s on a 113k-row production copy. * chore(gitignore): add temp benchmark files to ignore list * fix(persistence): clear album_artists when an album is saved without participants albumRepository.Put skipped updateParticipants when the Participants map was empty, so a hypothetical save with no participants would write {} to the JSON column but leave stale album_artists rows behind, now visible through the semi-join filters. No current caller can hit this (albums built by MediaFiles.ToAlbum always have participants), but make Put unconditional anyway, matching mediaFileRepository.Put, so the join table always moves with the JSON. Raised by Codex review on #5930. --- .gitignore | 3 + ...eorder_album_artists_unique_constraint.sql | 59 +++++++++++++ persistence/album_repository.go | 19 ++-- persistence/album_repository_test.go | 87 ++++++++++++++++--- persistence/mediafile_repository.go | 6 +- persistence/sql_participations.go | 24 +++++ server/filter/filters.go | 22 ++--- server/jellyfin/items_test.go | 6 +- 8 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql diff --git a/.gitignore b/.gitignore index 3567a7d90..6459ded9b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ openspec/ go.work* .worktrees/ .playwright-mcp/ + +# Temp benchmark files +zz_*_test.go \ No newline at end of file diff --git a/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql b/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql new file mode 100644 index 000000000..fd1598e93 --- /dev/null +++ b/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql @@ -0,0 +1,59 @@ +-- +goose Up +-- +goose StatementBegin +-- Lead the unique constraint with artist_id (mirroring media_file_artists) so artist-driven +-- filters and the artist-delete cascade get an index seek; album_id lookups keep album_artists_album_id. +CREATE TABLE album_artists_tmp +( + album_id varchar not null + REFERENCES album (id) + ON DELETE CASCADE, + artist_id varchar not null + REFERENCES artist (id) + ON DELETE CASCADE, + role varchar default '' not null, + sub_role varchar default '' not null, + CONSTRAINT album_artists + UNIQUE (artist_id, album_id, role, sub_role) +); + +INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) +SELECT album_id, artist_id, role, sub_role +FROM album_artists; + +DROP TABLE album_artists; +ALTER TABLE album_artists_tmp RENAME TO album_artists; + +CREATE INDEX album_artists_album_id + ON album_artists (album_id); +CREATE INDEX album_artists_role + ON album_artists (role); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE album_artists_tmp +( + album_id varchar not null + REFERENCES album (id) + ON DELETE CASCADE, + artist_id varchar not null + REFERENCES artist (id) + ON DELETE CASCADE, + role varchar default '' not null, + sub_role varchar default '' not null, + CONSTRAINT album_artists + UNIQUE (album_id, artist_id, role, sub_role) +); + +INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) +SELECT album_id, artist_id, role, sub_role +FROM album_artists; + +DROP TABLE album_artists; +ALTER TABLE album_artists_tmp RENAME TO album_artists; + +CREATE INDEX album_artists_album_id + ON album_artists (album_id); +CREATE INDEX album_artists_role + ON album_artists (role); +-- +goose StatementEnd diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 1ebb09751..91e90127c 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -172,24 +172,22 @@ func yearFilter(_ string, value any) Sqlizer { } func artistFilter(_ string, value any) Sqlizer { - return Or{ - Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}), - Exists("json_tree(participants, '$.artist')", Eq{"value": value}), - } + return ParticipantIDFilter("album", value, model.RoleAlbumArtist, model.RoleArtist) } func artistRoleFilter(name string, value any) Sqlizer { roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id") // Check if the role name is valid. If not, return an invalid filter - if _, ok := model.AllRoles[roleName]; !ok { + role, ok := model.AllRoles[roleName] + if !ok { return Gt{"": nil} } - return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value}) + return ParticipantIDFilter("album", value, role) } func allRolesFilter(_ string, value any) Sqlizer { - return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)} + return ParticipantIDFilter("album", value) } func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { @@ -214,12 +212,7 @@ func (r *albumRepository) Put(al *model.Album) error { return err } al.ID = id - if len(al.Participants) > 0 { - if err = r.updateParticipants(al.ID, al.Participants); err != nil { - return err - } - } - return nil + return r.updateParticipants(al.ID, al.Participants) } // TODO Move external metadata to a separated table diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 1ead89529..061083949 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -527,19 +527,16 @@ var _ = Describe("AlbumRepository", func() { Describe("artistRoleFilter", func() { DescribeTable("creates correct SQL expressions for artist roles", - func(filterName, artistID, expectedSQL string) { + func(filterName, artistID, expectedRole string) { sqlizer := artistRoleFilter(filterName, artistID) sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSQL)) - Expect(args).To(Equal([]any{artistID})) + Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) + Expect(args).To(Equal([]any{artistID, expectedRole})) }, - Entry("artist role", "role_artist_id", "123", - "exists (select 1 from json_tree(participants, '$.artist') where value = ?)"), - Entry("albumartist role", "role_albumartist_id", "456", - "exists (select 1 from json_tree(participants, '$.albumartist') where value = ?)"), - Entry("composer role", "role_composer_id", "789", - "exists (select 1 from json_tree(participants, '$.composer') where value = ?)"), + Entry("artist role", "role_artist_id", "123", "artist"), + Entry("albumartist role", "role_albumartist_id", "456", "albumartist"), + Entry("composer role", "role_composer_id", "789", "composer"), ) It("works with the actual filter map", func() { @@ -553,8 +550,8 @@ var _ = Describe("AlbumRepository", func() { sqlizer := filterFunc(filterName, "test-id") sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName))) - Expect(args).To(Equal([]any{"test-id"})) + Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) + Expect(args).To(Equal([]any{"test-id", roleName})) } }) @@ -644,6 +641,74 @@ var _ = Describe("AlbumRepository", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) + It("finds albums through the participant-based filters", func() { + artist := &model.Artist{ID: "filter-artist-1", Name: "Filter Artist", OrderArtistName: "filter artist"} + Expect(createArtistWithLibrary(artistRepo, artist, 1)).To(Succeed()) + + album := &model.Album{ + LibraryID: 1, + ID: "filter-album-1", + Name: "Filter Album", + AlbumArtistID: artist.ID, + AlbumArtist: artist.Name, + Participants: model.Participants{ + model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, + model.RoleComposer: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, + }, + } + Expect(albumRepo.Put(album)).To(Succeed()) + + byArtist, err := albumRepo.GetAll(model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byArtist).To(HaveLen(1)) + Expect(byArtist[0].ID).To(Equal(album.ID)) + + byComposer, err := albumRepo.GetAll(model.QueryOptions{Filters: artistRoleFilter("role_composer_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byComposer).To(HaveLen(1)) + + byLyricist, err := albumRepo.GetAll(model.QueryOptions{Filters: artistRoleFilter("role_lyricist_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byLyricist).To(BeEmpty()) + + byAnyRole, err := albumRepo.GetAll(model.QueryOptions{Filters: allRolesFilter("role_total_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byAnyRole).To(HaveLen(1)) + + count, err := albumRepo.CountAll(model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + + It("clears album_artists rows when saved with empty participants", func() { + artist := &model.Artist{ID: "clear-artist-1", Name: "Clear Artist", OrderArtistName: "clear artist"} + Expect(createArtistWithLibrary(artistRepo, artist, 1)).To(Succeed()) + + album := &model.Album{ + LibraryID: 1, + ID: "clear-album-1", + Name: "Clear Album", + AlbumArtistID: artist.ID, + AlbumArtist: artist.Name, + Participants: model.Participants{ + model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, + }, + } + DeferCleanup(func() { + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + Expect(albumRepo.Put(album)).To(Succeed()) + verifyAlbumArtists(album.ID, []albumArtistRecord{{ArtistID: artist.ID, Role: "albumartist", SubRole: ""}}) + + album.Participants = model.Participants{} + Expect(albumRepo.Put(album)).To(Succeed()) + verifyAlbumArtists(album.ID, []albumArtistRecord{}) + }) + It("filters out invalid artist IDs leaving only valid participants in database", func() { // Create two real artists in the database artist1 := &model.Artist{ diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index df1472c2a..f372b30f5 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -113,7 +113,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "has_rating": annotationBoolFilter("rating"), "genre_id": tagIDFilter, "missing": booleanFilter, - "artists_id": artistFilter, + "artists_id": mediaFileArtistFilter, "library_id": libraryIdFilter, "path": startsWithFilter("media_file.path"), } @@ -126,6 +126,10 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { return filters }) +func mediaFileArtistFilter(_ string, value any) Sqlizer { + return ParticipantIDFilter("media_file", value, model.RoleAlbumArtist, model.RoleArtist) +} + func mediaFileRecentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { return "media_file.updated_at, media_file.id" diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index 38b0203fa..746abed01 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -22,6 +22,30 @@ type flatParticipant struct { SubRole string `json:"sub_role,omitempty"` } +// ParticipantIDFilter matches rows of table where the artist participates in any of the given roles +// (any role when empty). Semi-joins <table>_artists; json_tree over the JSON is far slower at scale. +func ParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { + return participantIDFilter(table, artistID, false, roles) +} + +// NotParticipantIDFilter is the negation of ParticipantIDFilter. +func NotParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { + return participantIDFilter(table, artistID, true, roles) +} + +func participantIDFilter(table string, artistID any, negate bool, roles []model.Role) Sqlizer { + sel := Select(table + "_id").From(table + "_artists").Where(Eq{"artist_id": artistID}) + if len(roles) > 0 { + sel = sel.Where(Eq{"role": slice.Map(roles, func(r model.Role) string { return r.String() })}) + } + sql, args, _ := sel.ToSql() + op := " IN (" + if negate { + op = " NOT IN (" + } + return Expr(table+".id"+op+sql+")", args...) +} + func marshalParticipants(participants model.Participants) string { dbParticipants := make(map[model.Role][]participant) for role, artists := range participants { diff --git a/server/filter/filters.go b/server/filter/filters.go index 067f7b1f9..62d8f0523 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -47,17 +47,13 @@ func AlbumsByArtist() Options { } func AlbumsByArtistID(artistId string) Options { - filters := []Sqlizer{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), - } + roles := []model.Role{model.RoleAlbumArtist} if conf.Server.Subsonic.ArtistParticipations { - filters = append(filters, - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), - ) + roles = append(roles, model.RoleArtist) } return addDefaultFilters(Options{ Sort: "max_year", - Filters: Or(filters), + Filters: persistence.ParticipantIDFilter("album", artistId, roles...), }) } @@ -68,8 +64,8 @@ func AlbumsByContributingArtistID(artistId string) Options { return addDefaultFilters(Options{ Sort: "max_year", Filters: And{ - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), - persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + persistence.ParticipantIDFilter("album", artistId, model.RoleArtist), + persistence.NotParticipantIDFilter("album", artistId, model.RoleAlbumArtist), }, }) } @@ -104,13 +100,11 @@ func SongsByAlbum(albumId string) Options { } // SongsByArtistID matches media files where the artist participates as album or track artist, in -// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale. +// album order. func SongsByArtistID(artistId string) Options { return addDefaultFilters(Options{ - Sort: "album", - Filters: Expr( - "media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))", - artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()), + Sort: "album", + Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist), }) } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 401145461..4eddcc654 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -219,7 +219,7 @@ var _ = Describe("Items", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) sql, _, err := albumRepo.Options.Filters.ToSql() Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("json_tree")) + Expect(sql).To(ContainSubstring("album_artists")) }) It("lists artists when IncludeItemTypes=MusicArtist", func() { @@ -708,7 +708,7 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := albumRepo.Options.Filters.ToSql() Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("json_tree")) // not treated as an artist-parent filter + Expect(sql).NotTo(ContainSubstring("album_artists")) // not treated as an artist-parent filter Expect(sql).To(ContainSubstring("library_id")) Expect(args).To(ContainElement(2)) }) @@ -724,7 +724,7 @@ var _ = Describe("Items", func() { sql, args, err := albumRepo.Options.Filters.ToSql() Expect(err).NotTo(HaveOccurred()) // Falls back to treating "99" as an (empty-matching) artist-parent id... - Expect(sql).To(ContainSubstring("json_tree")) + Expect(sql).To(ContainSubstring("album_artists")) // ...while still scoping to the user's own accessible libraries. Expect(sql).To(ContainSubstring("library_id")) Expect(args).To(ContainElement(1)) From 7736bbb545da8c3fdfb3dadcd68cb7b1714baf86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 10 Aug 2026 14:10:05 -0400 Subject: [PATCH 419/596] fix(cache): write the completion marker before closing the cache writer (#5927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cache): write the completion marker before closing the cache writer Readers of an in-progress cache write see EOF the moment the writer closes, but the .complete marker was created after the close, on the background goroutine — so a fully-read stream did not mean the cache was done touching disk. The new artwork precache spec ends right at EOF, and its GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI job with 'unlinkat ...: The directory is not empty' (the race also reproduces on macOS, 2 of 3 runs, with the tightened test). Writing the marker after a clean copy but before Close makes reader-EOF imply every on-disk write for the entry is finished. A failed writer Close still invalidates the entry, which removes both the marker and the data file. The existing marker test now asserts the marker exists immediately at EOF instead of Eventually. * fix(artwork): never dispatch queue items after the drain context is cancelled The 10x Windows stress run for the previous commit surfaced a second flake in the same package: 'leaves undispatched items queued when cancelled mid-batch' lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the context is already cancelled, both cases of the blocking select are ready and Go picks one at random — so a cancelled drain could still dispatch items. A non-blocking Done check before the select gives cancellation priority. The race was invisible on Linux/macOS only by accident: the spec seeded the album repo with a single album (each SetData overwrote the last), so only the final row (alc7) resolved to absent and got deleted when dispatched; the others fell on the retry path and survived. Nanosecond enqueue timestamps made alc0 always first out of the mock dequeue, masking the race, while Windows' coarse clock ties the timestamps and randomizes the order. The spec now seeds all eight albums, which made the race reproduce locally on the first try (row alc0) and now guards the fix on every platform. * test: give cache-init waits a 10s timeout for loaded CI runners A 10x parallel Windows stress run timed out one artwork spec in BeforeEach: the FileCache init goroutine (mkdir + reload walk) took over Gomega's default 1s Eventually timeout under shared-runner disk contention. Bump the three identical init waits (two artwork suites and the utils/cache helper) to 10s. * test(scanner): widen watcher debounce margins for loaded CI runners The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently windows while the debounce wait was only 50ms — a 2.5x margin that a loaded Windows runner blows through by delaying the timer-reset notification, firing the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the test debounce wait to 200ms (10x the observation windows) and the scan-fired Eventually timeouts to 2s to match. * refactor(artwork): collapse drain cancellation into a single exit path Replace the non-blocking ctx pre-check plus duplicated select exit with one select and a ctx.Err() check after it. Besides removing the duplication, this closes the residual race: a cancellation landing between the two selects could still let the blocking select randomly pick the free semaphore slot and dispatch the item. Now a dispatch is only possible when the context was live after slot acquisition. * style: trim flaky-test fix comments to single lines Compress each two-line comment added by this PR to the one line that carries the invariant; drop the narration around it. --- core/artwork/artwork_test.go | 2 +- core/artwork/worker.go | 5 ++++- core/artwork/worker_test.go | 8 ++++++-- scanner/watcher_test.go | 19 ++++++++++--------- utils/cache/file_caches.go | 10 ++++++---- utils/cache/file_caches_test.go | 10 +++++----- 6 files changed, 32 insertions(+), 22 deletions(-) diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index c6b848101..7de5475d6 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -106,7 +106,7 @@ var _ = Describe("Artwork", func() { func(ctx context.Context, arg cache.Item) (io.Reader, error) { return arg.(artworkReader).Reader(ctx) }) - Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) svc = NewArtwork(ds, imgCache, store, ffm) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3042c47b0..3ded52629 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -170,8 +170,11 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i select { case sem <- struct{}{}: case <-ctx.Done(): + } + // select picks randomly when both cases are ready, so re-check to never dispatch after cancellation. + if ctx.Err() != nil { wg.Wait() - return len(items), nil + return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error } wg.Go(func() { defer func() { <-sem }() diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 1f3b52840..5b6e42885 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -151,7 +151,8 @@ var _ = Describe("Worker", func() { func(ctx context.Context, arg cache.Item) (io.Reader, error) { return arg.(artworkReader).Reader(ctx) })} - Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + // Init walks the cache dir on a goroutine; loaded CI runners can take >1s. + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) w = NewWorker(ds, store, ag, ffm, broker, imgCache) }) @@ -706,13 +707,16 @@ var _ = Describe("Worker", func() { Describe("batching", func() { It("leaves undispatched items queued when cancelled mid-batch", func() { + // Every album must resolve, so any dispatched item deletes its row regardless of dequeue order. + albums := model.Albums{} for i := range 8 { id := fmt.Sprintf("alc%d", i) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: id, Name: "Album"}}) + albums = append(albums, model.Album{ID: id, Name: "Album"}) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, })).To(Succeed()) } + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) cancelledCtx, cancel := context.WithCancel(ctx) cancel() diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 15e49e195..12c94602c 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -26,7 +26,8 @@ var _ = Describe("Watcher", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests + // Must dwarf the 20ms Consistently windows below, or a loaded runner's delayed timer reset flakes the debouncing spec. + conf.Server.Scanner.WatcherWait = 200 * time.Millisecond ctx, cancel = context.WithCancel(GinkgoT().Context()) DeferCleanup(cancel) @@ -91,7 +92,7 @@ var _ = Describe("Watcher", func() { return nil } return calls[0].Targets - }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) + }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) // Verify targets calls := mockScanner.GetScanFoldersCalls() @@ -111,7 +112,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -129,7 +130,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -170,7 +171,7 @@ var _ = Describe("Watcher", func() { // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -183,7 +184,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -205,7 +206,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -222,7 +223,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -266,7 +267,7 @@ var _ = Describe("Watcher", func() { return nil } return calls[0].Targets - }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) + }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different calls := mockScanner.GetScanFoldersCalls() diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 33fa046df..dff9e4e7a 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -183,12 +183,11 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { return nil, err } go func() { - if err := copyAndClose(w, reader); err != nil { + if err := fc.copyAndClose(ctx, key, w, reader); err != nil { log.Debug(ctx, "Error storing file in cache", "cache", fc.name, "key", key, err) _ = fc.invalidate(ctx, key) } else { log.Trace(ctx, "File successfully stored in cache", "cache", fc.name, "key", key) - fc.markComplete(ctx, key) } }() } @@ -243,7 +242,8 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 { return -1 } -func copyAndClose(w io.WriteCloser, r io.Reader) error { +// copyAndClose marks the entry complete before closing w, so EOF implies the entry is settled on disk. +func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteCloser, r io.Reader) error { _, err := io.Copy(w, r) if err != nil { err = fmt.Errorf("copying data to cache: %w", err) @@ -253,7 +253,9 @@ func copyAndClose(w io.WriteCloser, r io.Reader) error { err = multierror.Append(err, fmt.Errorf("closing source stream: %w", cErr)) } } - + if err == nil { + fc.markComplete(ctx, key) + } if cErr := w.Close(); cErr != nil { err = multierror.Append(err, fmt.Errorf("closing cache writer: %w", cErr)) } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 7ce1b0393..974200656 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync/atomic" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -18,7 +19,7 @@ import ( // Call NewFileCache and wait for it to be ready func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache { fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader).(*fileCache) - Eventually(func() bool { return fc.ready.Load() }).Should(BeTrue()) + Eventually(func() bool { return fc.ready.Load() }, 10*time.Second).Should(BeTrue()) return fc } @@ -114,11 +115,10 @@ var _ = Describe("File Caches", func() { _, _ = io.ReadAll(s) _ = s.Close() + // EOF must imply the entry is settled on disk (Windows temp-dir cleanups rely on it). dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) - Eventually(func() bool { - _, statErr := os.Stat(dataPath + ".complete") - return statErr == nil - }).Should(BeTrue()) + _, statErr := os.Stat(dataPath + ".complete") + Expect(statErr).ToNot(HaveOccurred()) }) It("serves a concurrent reader from an in-progress write and marks complete once", func() { From d0807070606ebb331839aa6c54a61853d857cfdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 10 Aug 2026 21:21:44 -0400 Subject: [PATCH 420/596] fix(jellyfin): return a mixed, globally-limited list for multi-type /Items requests (#5935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(jellyfin): allow random sort for artist/genre/playlist item types * feat(jellyfin): add round-robin interleave helper for merged item types * fix(jellyfin): mix and globally limit multi-type Items requests Run per-type queries in parallel and round-robin interleave the results so a request for multiple IncludeItemTypes (e.g. Finamp's random favorite) returns a mixed, globally-limited page instead of one type's rows followed by the next. * fix(jellyfin): dedupe repeated IncludeItemTypes to avoid duplicate items and redundant queries * refactor(jellyfin): dedupe via slice.Unique and extract queryTypeWindow helper * perf(jellyfin): serve random multi-type pages from offset 0 A random merge reshuffles every request, so paginating it is meaningless — page N is just another fresh draw (as in real Jellyfin). Serving from offset 0 caps the per-type fetch at limit instead of offset+limit, avoiding deep-offset blow-up for the random case (Finamp's random-favorite quick action). * refactor(jellyfin): resolve random-merge via applySort; simplify merge signatures Detect the random-page shortcut by resolving each type's sort through applySort (matching how the sort is actually chosen) instead of string-matching SortBy, and only when every type is random. Drop the always-zero window param from mergeTypesStreaming and derive the window inside mergeTypesPaged. --- server/jellyfin/items.go | 131 ++++++++++++++++++++++++++-------- server/jellyfin/items_test.go | 109 +++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 34 deletions(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 631530b6d..b14a272bb 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/sync/errgroup" ) // notMissing excludes items whose backing files are all gone ("missing" is a real column on @@ -330,52 +331,97 @@ func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model. } func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { - // Each per-type query needs at most offset+limit rows (the worst case where one type fills the - // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. - window := 0 - if q.limit > 0 { - window = q.offset + q.limit + if q.limit == 0 { + return api.mergeTypesStreaming(ctx, q) } - // A search can't stream, so the window is what each type materializes and StartIndex would drive - // it without bound. Only below the window are the merged rows the true order, hence the clip - // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. - if q.search != "" { - window = min(window, maxSearchLimit) + // A random page doesn't stack on the previous one (the order reshuffles each request), so serving + // from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type. + offset := q.offset + if randomlySorted(q) { + offset = 0 } + return api.mergeTypesPaged(ctx, q, offset) +} + +// randomlySorted reports whether every merged type resolves to a random sort — the case where a page +// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than +// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen. +func randomlySorted(q itemsQuery) bool { + for _, itemType := range q.types { + var opts model.QueryOptions + applySort(&opts, itemType, q.sortBy, q.sortOrder) + if opts.Sort != "random" { + return false + } + } + return true +} + +// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows +// in order minus the first offset, without pulling every row into memory. +func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) { var results []itemsResult total := 0 for _, itemType := range q.types { - var opts model.QueryOptions - opts.Max = window - applySort(&opts, itemType, q.sortBy, q.sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, q) + res, err := api.queryTypeWindow(ctx, itemType, 0, q) if err != nil { return itemsResult{}, err } results = append(results, res) total += res.total } - if q.limit == 0 { - // No cap above, so merging in memory would pull every row of every type. The merged page is - // just their rows in order minus the first offset — what chaining the cursors yields. - return chained(results, total, q.offset), nil + return chained(results, total, q.offset), nil +} + +// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied. +func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) { + var opts model.QueryOptions + opts.Max = window + applySort(&opts, itemType, q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, itemType, opts, q) +} + +// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page +// is a mix rather than one type's rows followed by the next. +func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window). + window := offset + q.limit + if q.search != "" { + window = min(window, maxSearchLimit) } - var items []dto.BaseItemDto - for _, res := range results { - typeItems, err := res.collect() - if err != nil { - return itemsResult{}, err - } - items = append(items, typeItems...) + lists := make([][]dto.BaseItemDto, len(q.types)) + totals := make([]int, len(q.types)) + g, ctx := errgroup.WithContext(ctx) + for i, itemType := range q.types { + g.Go(func() error { + res, err := api.queryTypeWindow(ctx, itemType, window, q) + if err != nil { + return err + } + items, err := res.collect() + if err != nil { + return err + } + lists[i] = items + totals[i] = res.total + return nil + }) } + if err := g.Wait(); err != nil { + return itemsResult{}, err + } + total := 0 + for _, t := range totals { + total += t + } + items := interleave(lists) if q.search != "" { // Past the window the merged order isn't the true one, so drop it rather than serve another - // type's rows. The total is what's pageable overall, not this page, or a client paging on it - // would stop after the first page. + // type's rows. The total is what's pageable overall, so a client paging on it won't stop early. items = items[:min(window, len(items))] total = min(total, maxSearchLimit) } - return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil + return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil } func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { @@ -440,6 +486,8 @@ func parseTypes(types string) []string { recognized = append(recognized, t) } } + // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. + recognized = slice.Unique(recognized) if len(recognized) == 0 { return []string{"MusicAlbum"} } @@ -459,6 +507,25 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// interleave merges per-type item lists round-robin: one item from each list in turn, preserving +// each list's own order, so no single type dominates the head of a mixed-type result. +func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { + total, maxLen := 0, 0 + for _, l := range lists { + total += len(l) + maxLen = max(maxLen, len(l)) + } + out := make([]dto.BaseItemDto, 0, total) + for i := 0; i < maxLen; i++ { + for _, l := range lists { + if i < len(l) { + out = append(out, l[i]) + } + } + } + return out +} + // Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without // the ceiling, Limit=999999 still materializes every match. const ( @@ -818,9 +885,8 @@ func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { } } -// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type. Each repository maps -// logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no -// "random"). +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields +// to different real columns, e.g. media_file has "title" not "name"). var sortColumnsByType = map[string]map[string]string{ "Audio": { "sortname": "title", "name": "title", @@ -848,6 +914,7 @@ var sortColumnsByType = map[string]map[string]string{ "playcount": "play_count", "dateplayed": "play_date", "communityrating": "rating", + "random": "random", }, "MusicAlbum": { "sortname": "name", "name": "name", "album": "name", @@ -862,10 +929,12 @@ var sortColumnsByType = map[string]map[string]string{ }, "MusicGenre": { "sortname": "name", "name": "name", + "random": "random", }, "Playlist": { "sortname": "name", "name": "name", "datecreated": "created_at", + "random": "random", }, } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 4eddcc654..d8b9e60e8 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -462,9 +462,9 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - // Clipped to the window, and still the real row at that index — not the album behind it. + // Clipped to the window; the interleaved album takes one slot, shifting this song in by one. Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID))) }) It("bounds an unbounded multi-type search to the default in total, not per type", func() { @@ -500,7 +500,8 @@ var _ = Describe("Items", func() { var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).ToNot(BeEmpty()) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+50].ID))) + // The interleaved album takes one slot ahead of it, shifting this song in by one. + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID))) }) It("reports a search total beyond the fetched page instead of the page length", func() { @@ -748,6 +749,68 @@ var _ = Describe("Items", func() { Expect(sql).NotTo(ContainSubstring("library_id")) }) }) + + Describe("mixed IncludeItemTypes merge", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "S1"}, {ID: "s2", Title: "S2"}}) + }) + + It("returns a mix of both types, not all of one", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(4)) + Expect(res.TotalRecordCount).To(Equal(4)) + types := map[string]int{} + for _, it := range res.Items { + types[it.Type]++ + } + Expect(types["Audio"]).To(Equal(2)) + Expect(types["MusicAlbum"]).To(Equal(2)) + }) + + It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type} + Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"})) + }) + + It("honors Limit across the merged set", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.TotalRecordCount).To(Equal(4)) + }) + + It("serves a full random page from offset 0 regardless of StartIndex", func() { + // A deep StartIndex on a random merge must not materialize offset+limit rows; since random + // reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty. + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(4)) + }) + + It("propagates a per-type query error", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) }) Describe("getItem", func() { @@ -896,4 +959,44 @@ var _ = Describe("Items", func() { Expect(args).To(ContainElements(1, 2)) }) }) + + Describe("applySort random for all merge types", func() { + DescribeTable("maps Random -> random", + func(itemType string) { + var opts model.QueryOptions + applySort(&opts, itemType, "Random", "") + Expect(opts.Sort).To(Equal("random")) + }, + Entry("Audio", "Audio"), + Entry("MusicAlbum", "MusicAlbum"), + Entry("MusicArtist", "MusicArtist"), + Entry("MusicGenre", "MusicGenre"), + Entry("Playlist", "Playlist"), + ) + }) + + Describe("interleave", func() { + It("round-robins one item per list in turn", func() { + lists := [][]dto.BaseItemDto{ + {{Id: "a0"}, {Id: "a1"}, {Id: "a2"}}, + {{Id: "b0"}, {Id: "b1"}}, + } + got := interleave(lists) + ids := make([]string, len(got)) + for i, it := range got { + ids[i] = it.Id + } + Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"})) + }) + + It("returns empty for no lists", func() { + Expect(interleave(nil)).To(BeEmpty()) + }) + }) + + Describe("parseTypes", func() { + It("dedupes repeated types, preserving first-seen order", func() { + Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) + }) + }) }) From 8e0ff1a23504252d8d9b0ef35656ef7eedb74258 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 10 Aug 2026 21:30:14 -0400 Subject: [PATCH 421/596] fix(jellyfin): resolve genre id as a MusicGenre item The Jellyfin /Items/{id} endpoint resolved albums, artists, songs and playlists by id but not genres, so a genre id returned 404. Finamp's genre "See all" fetches the genre as the track list's parent item, and that 404 crashed its screen to a blank page after the tracks flashed in. Add a Genre lookup to resolveItemByID (backed by a new GenreRepository.Get) so a genre id returns its MusicGenre BaseItemDto, matching real Jellyfin. --- model/genre.go | 1 + persistence/genre_repository.go | 10 +++++++--- server/jellyfin/items.go | 5 ++++- server/jellyfin/items_test.go | 15 +++++++++++++++ tests/mock_genre_repo.go | 11 +++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/model/genre.go b/model/genre.go index bb05e747e..fa5b6ec62 100644 --- a/model/genre.go +++ b/model/genre.go @@ -11,4 +11,5 @@ type Genres []Genre type GenreRepository interface { GetAll(...QueryOptions) (Genres, error) + Get(id string) (*Genre, error) } diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 22443284f..0bb22c21b 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -30,15 +30,19 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error return res, err } -// Override ResourceRepository methods to return Genre objects instead of Tag objects - -func (r *genreRepository) Read(id string) (any, error) { +func (r *genreRepository) Get(id string) (*model.Genre, error) { sel := r.selectGenre().Where(Eq{"tag.id": id}) var res model.Genre err := r.queryOne(sel, &res) return &res, err } +// Override ResourceRepository methods to return Genre objects instead of Tag objects + +func (r *genreRepository) Read(id string) (any, error) { + return r.Get(id) +} + func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b14a272bb..9b85b8a62 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -736,7 +736,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q } // resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, -// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access +// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access // to their library, so an id can't probe content outside the user's libraries. func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { // The synthetic playlists folder must resolve by the id we advertised, not 404. @@ -778,6 +778,9 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if pl, err := api.playlists.Get(ctx, id); err == nil { return dto.PlaylistToBaseItem(*pl, fields), true } + if g, err := api.ds.Genre(ctx).Get(id); err == nil { + return dto.GenreToBaseItem(*g), true + } return dto.BaseItemDto{}, false } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index d8b9e60e8..3213379e5 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -917,6 +917,21 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). + It("resolves a genre id as a MusicGenre item", func() { + Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: "g1", Name: "Rock"})).To(Succeed()) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("g1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("g1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("g1"))) + Expect(item.Name).To(Equal("Rock")) + Expect(item.Type).To(Equal("MusicGenre")) + }) + It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) w := httptest.NewRecorder() diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 50796efc0..ad3ee1a6a 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -32,6 +32,17 @@ func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, e return all, nil } +func (r *MockedGenreRepo) Get(id string) (*model.Genre, error) { + if r.Error != nil { + return nil, r.Error + } + r.init() + if g, ok := r.Data[id]; ok { + return &g, nil + } + return nil, model.ErrNotFound +} + func (r *MockedGenreRepo) Put(g *model.Genre) error { if r.Error != nil { return r.Error From 95b8d9dd041f53935f82eb9bca3f485d5b5f6a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 11 Aug 2026 08:00:50 -0400 Subject: [PATCH 422/596] perf(genre): index genre filtering via join tables across all APIs (#5940) Filtering by genre scanned every media_file/album row and JSON-parsed its `tags` column (a per-row json_tree(tags) EXISTS) with no usable index, so Finamp's genre screen took 1.9-6.5s per tap against a ~97k-track library. Album and album-artist genre queries had the same unindexed shape. Add normalized media_file_tags and album_tags join tables (genre only for now, via an indexedTagNames allowlist), populated by a new updateTags in Put (mirroring updateParticipants) and backfilled in the migration. Genre filtering across the Jellyfin, Subsonic and native APIs now runs as an index-backed semi-join through shared TagIDSemiJoin/TagNameSemiJoin helpers instead of a full scan. On a copy of the production DB the per-request cost for a typical genre drops from ~330ms to sub-millisecond, adding ~10MB. --- ...60811023338_add_item_genre_tag_indexes.sql | 34 ++++++ persistence/album_repository.go | 7 +- persistence/item_tags_test.go | 108 ++++++++++++++++++ persistence/mediafile_repository.go | 7 +- persistence/sql_tags.go | 71 ++++++++++++ server/filter/filters.go | 33 ++---- server/jellyfin/items.go | 4 +- server/subsonic/album_lists.go | 4 +- 8 files changed, 238 insertions(+), 30 deletions(-) create mode 100644 db/migrations/20260811023338_add_item_genre_tag_indexes.sql create mode 100644 persistence/item_tags_test.go diff --git a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql new file mode 100644 index 000000000..1a7d75190 --- /dev/null +++ b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql @@ -0,0 +1,34 @@ +-- +goose Up +create table if not exists media_file_tags( + media_file_id varchar not null + references media_file (id) on delete cascade, + tag_id varchar not null + references tag (id) on delete cascade, + constraint media_file_tags unique (media_file_id, tag_id) +); +create index if not exists media_file_tags_tag_id on media_file_tags (tag_id); + +create table if not exists album_tags( + album_id varchar not null + references album (id) on delete cascade, + tag_id varchar not null + references tag (id) on delete cascade, + constraint album_tags unique (album_id, tag_id) +); +create index if not exists album_tags_tag_id on album_tags (tag_id); + +-- Backfill genre rows from the per-row `tags` JSON. json_tree over the "$.genre" subtree yields one +-- row per node; the "id" key nodes carry the tag ids. Single scan per table, no correlated subquery. +insert or ignore into media_file_tags (media_file_id, tag_id) +select mf.id, jt.value +from media_file mf, json_tree(mf.tags, '$.genre') jt +where jt.key = 'id' and jt.atom is not null; + +insert or ignore into album_tags (album_id, tag_id) +select al.id, jt.value +from album al, json_tree(al.tags, '$.genre') jt +where jt.key = 'id' and jt.atom is not null; + +-- +goose Down +drop table if exists media_file_tags; +drop table if exists album_tags; diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 91e90127c..486099fc5 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -133,7 +133,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), "missing": booleanFilter, - "genre_id": tagIDFilter, + "genre_id": genreFilter(AlbumGenres), "role_total_id": allRolesFilter, "library_id": libraryIdFilter, } @@ -212,7 +212,10 @@ func (r *albumRepository) Put(al *model.Album) error { return err } al.ID = id - return r.updateParticipants(al.ID, al.Participants) + if err := r.updateParticipants(al.ID, al.Participants); err != nil { + return err + } + return r.updateTags(al.ID, al.Tags) } // TODO Move external metadata to a separated table diff --git a/persistence/item_tags_test.go b/persistence/item_tags_test.go new file mode 100644 index 000000000..a2317a1a5 --- /dev/null +++ b/persistence/item_tags_test.go @@ -0,0 +1,108 @@ +package persistence + +import ( + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("item genre tag indexes", func() { + var conn *dbx.DB + var mr model.MediaFileRepository + var ar model.AlbumRepository + var rock, jazz model.Tag + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}) + conn = GetDBXBuilder() + mr = NewMediaFileRepository(ctx, conn) + ar = NewAlbumRepository(ctx, conn) + // Test-only genre values, so they can't collide with the golden fixtures. + rock = model.NewTag(model.TagGenre, "GenreIdxRock") + jazz = model.NewTag(model.TagGenre, "GenreIdxJazz") + // The join tables FK to tag(id); the scanner adds tags before saving items. + Expect(NewTagRepository(ctx, conn).Add(1, rock, jazz)).To(Succeed()) + // The suite shares one golden DB with no per-test restore, so undo the rows we add + // (media_file/album deletes cascade to the *_tags join rows; tag deletes cascade too). + DeferCleanup(func() { + _, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'mf-%'").Execute() + _, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'al-%'").Execute() + _, _ = conn.NewQuery("DELETE FROM tag WHERE id={:r} OR id={:j}"). + Bind(dbx.Params{"r": rock.ID, "j": jazz.ID}).Execute() + }) + }) + + tagIDsFor := func(table, col, id string) []string { + var rows []struct { + TagID string `db:"tag_id"` + } + err := conn.NewQuery("SELECT tag_id FROM " + table + " WHERE " + col + "={:id}"). + Bind(dbx.Params{"id": id}).All(&rows) + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(rows)) + for i, r := range rows { + ids[i] = r.TagID + } + return ids + } + + Describe("media files", func() { + It("writes a media_file_tags row for each genre when the track is saved", func() { + mf := model.MediaFile{ID: "mf-g1", LibraryID: 1, Path: "/m/g1.mp3", Title: "G1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g1")).To(ConsistOf(rock.ID, jazz.ID)) + }) + + It("replaces the rows when the genres change", func() { + mf := model.MediaFile{ID: "mf-g2", LibraryID: 1, Path: "/m/g2.mp3", Title: "G2", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + mf.Tags = model.Tags{model.TagGenre: []string{jazz.TagValue}} + Expect(mr.Put(&mf)).To(Succeed()) + Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g2")).To(ConsistOf(jazz.ID)) + }) + + It("clears the rows when all genres are removed", func() { + mf := model.MediaFile{ID: "mf-g3", LibraryID: 1, Path: "/m/g3.mp3", Title: "G3", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + mf.Tags = model.Tags{} + Expect(mr.Put(&mf)).To(Succeed()) + Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g3")).To(BeEmpty()) + }) + }) + + Describe("albums", func() { + It("writes an album_tags row for each genre when the album is saved", func() { + al := model.Album{ID: "al-g1", LibraryID: 1, Name: "AG1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} + Expect(ar.Put(&al)).To(Succeed()) + Expect(tagIDsFor("album_tags", "album_id", "al-g1")).To(ConsistOf(rock.ID, jazz.ID)) + }) + }) + + // The native (REST) API filters by genre_id; it must resolve through the join table too. + Describe("native genre_id filter", func() { + It("filters media files by genre_id", func() { + mf := model.MediaFile{ID: "mf-nat1", LibraryID: 1, Path: "/m/nat1.mp3", Title: "Nat1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.MediaFiles)).To(ContainElement(HaveField("ID", "mf-nat1"))) + }) + + It("filters albums by genre_id", func() { + al := model.Album{ID: "al-nat1", LibraryID: 1, Name: "ANat1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(ar.Put(&al)).To(Succeed()) + res, err := ar.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Albums)).To(ContainElement(HaveField("ID", "al-nat1"))) + }) + }) +}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index f372b30f5..f37e394cc 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -111,7 +111,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), - "genre_id": tagIDFilter, + "genre_id": genreFilter(SongGenres), "missing": booleanFilter, "artists_id": mediaFileArtistFilter, "library_id": libraryIdFilter, @@ -181,7 +181,10 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error { return err } m.ID = id - return r.updateParticipants(m.ID, m.Participants) + if err := r.updateParticipants(m.ID, m.Participants); err != nil { + return err + } + return r.updateTags(m.ID, m.Tags) } func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 5177bc8e4..11f9fe00e 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,77 @@ func marshalTags(tags model.Tags) string { return string(res) } +// indexedTagNames are the tag types materialized into the <table>_tags join tables, so filtering by +// them is an index-backed semi-join instead of a per-row json_tree(tags) scan. Genre only for now. +var indexedTagNames = []model.TagName{model.TagGenre} + +// updateTags rewrites this item's <table>_tags rows from its in-memory tags, mirroring +// updateParticipants (delete-then-insert in the same Put; JOIN to tag skips not-yet-saved ids). +func (r sqlRepository) updateTags(itemID string, tags model.Tags) error { + del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID}) + if _, err := r.executeSQL(del); err != nil { + return err + } + var tagIDs []string + for _, name := range indexedTagNames { + for _, value := range tags.Values(name) { + tagIDs = append(tagIDs, model.NewTag(name, value).ID) + } + } + if len(tagIDs) == 0 { + return nil + } + idsJSON, err := json.Marshal(tagIDs) + if err != nil { + return fmt.Errorf("marshaling tag ids: %w", err) + } + query := fmt.Sprintf(` + INSERT INTO %[1]s_tags (%[1]s_id, tag_id) + SELECT ?, value FROM json_each(?) + JOIN tag ON tag.id = value + ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName) + _, err = r.executeSQL(Expr(query, itemID, string(idsJSON))) + return err +} + +// genreFilterDef builds indexed genre filters for one item type. Callers use the exported SongGenres / AlbumGenres instances. +type genreFilterDef struct{ idCol, table, joinCol string } + +var ( + SongGenres = genreFilterDef{"media_file.id", "media_file_tags", "media_file_id"} + AlbumGenres = genreFilterDef{"album.id", "album_tags", "album_id"} +) + +// ByID matches items tagged with any of the given genre tag ids (scalar or slice). +func (g genreFilterDef) ByID(tagIDs any) Sqlizer { + sub, args, _ := Select(g.joinCol).From(g.table).Where(Eq{"tag_id": tagIDs}).ToSql() + return Expr(g.idCol+" IN ("+sub+")", args...) +} + +// ByName matches by genre name (Subsonic passes a name, not an id), resolved through the tag +// dictionary, which is uniquely indexed on (tag_name, tag_value). +func (g genreFilterDef) ByName(genre string) Sqlizer { + sub, args, _ := Select("jt." + g.joinCol).From(g.table + " jt"). + Join("tag on tag.id = jt.tag_id"). + Where(And{Eq{"tag.tag_name": "genre"}, Like{"tag.tag_value": genre}}).ToSql() + return Expr(g.idCol+" IN ("+sub+")", args...) +} + +// AlbumArtistsByGenreID matches album artists of albums tagged with any of the genre ids. It's a +// two-table join (album_artists ⨝ album_tags), so it doesn't fit the single-table genreFilterDef. +func AlbumArtistsByGenreID(tagIDs []string) Sqlizer { + sub, args, _ := Select("aa.artist_id").From("album_artists aa"). + Join("album_tags at on at.album_id = aa.album_id"). + Where(And{Eq{"aa.role": "albumartist"}, Eq{"at.tag_id": tagIDs}}).ToSql() + return Expr("artist.id IN ("+sub+")", args...) +} + +func genreFilter(filter genreFilterDef) func(_ string, v any) Sqlizer { + return func(_ string, v any) Sqlizer { + return filter.ByID(v) + } +} + // tagIDFilter matches rows whose tags JSON contains the tag id(s); a "<name>_id" key maps to "$.<name>". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") diff --git a/server/filter/filters.go b/server/filter/filters.go index 62d8f0523..9014bb799 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -112,7 +112,7 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { options := Options{} ff := And{} if genre != "" { - ff = append(ff, filterByGenre(genre)) + ff = append(ff, persistence.SongGenres.ByName(genre)) } if fromYear != 0 { ff = append(ff, GtOrEq{"year": fromYear}) @@ -171,16 +171,17 @@ func ArtistsByRole(opts Options, role model.Role) Options { return opts } -func ByGenre(genre string) Options { - return addDefaultFilters(Options{ - Sort: "name", - Filters: filterByGenre(genre), - }) +// SongsByGenreID / AlbumsByGenreID (by tag id) and AlbumsByGenre / SongsByGenre (by name, wrapped +// as Options for Subsonic) delegate to the persistence genre filters, which own the join schema. +func SongsByGenreID(genreIds []string) Sqlizer { return persistence.SongGenres.ByID(genreIds) } +func AlbumsByGenreID(genreIds []string) Sqlizer { return persistence.AlbumGenres.ByID(genreIds) } + +func AlbumsByGenre(genre string) Options { + return addDefaultFilters(Options{Sort: "name", Filters: persistence.AlbumGenres.ByName(genre)}) } -// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids. -func ByGenreID(genreIds []string) Sqlizer { - return genreTagFilter(Eq{"value": genreIds}) +func SongsByGenre(genre string) Options { + return addDefaultFilters(Options{Sort: "name", Filters: persistence.SongGenres.ByName(genre)}) } // ByAlbumID matches media files belonging to any of the given albums. @@ -198,14 +199,8 @@ func SongsByYears(years []int) Sqlizer { return Eq{"year": years} } -// ArtistsByGenreID matches artists credited as album artist on an album with any of the given -// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { - return Expr( - `artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt - WHERE jt.atom IS NOT NULL AND ?)`, - genreTagFilter(Eq{"value": genreIds}), - ) + return persistence.AlbumArtistsByGenreID(genreIds) } // tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each @@ -214,17 +209,11 @@ func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) } -func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } - // ByStudioID matches items (albums or songs) whose record-label tag id is in ids. func ByStudioID(ids []string) Sqlizer { return tagIDFilter("recordlabel", Eq{"value": ids}) } -func filterByGenre(genre string) Sqlizer { - return genreTagFilter(Like{"value": genre}) -} - func ByRating() Options { return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}}) } diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 9b85b8a62..fed5bbbc8 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -577,7 +577,7 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it filters = append(filters, notMissing) } if len(q.genreIds) > 0 { - filters = append(filters, filter.ByGenreID(q.genreIds)) + filters = append(filters, filter.AlbumsByGenreID(q.genreIds)) } if len(q.years) > 0 { filters = append(filters, filter.AlbumsByYears(q.years)) @@ -624,7 +624,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite filters = append(filters, filter.ByAlbumID(q.albumIds)) } if len(q.genreIds) > 0 { - filters = append(filters, filter.ByGenreID(q.genreIds)) + filters = append(filters, filter.SongsByGenreID(q.genreIds)) } if len(q.years) > 0 { filters = append(filters, filter.SongsByYears(q.years)) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 041a3b8f2..ba5f88a68 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -46,7 +46,7 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { if err != nil { return nil, 0, err } - opts = filter.ByGenre(genre) + opts = filter.AlbumsByGenre(genre) case "byYear": fromYear, err := p.Int("fromYear") if err != nil { @@ -267,7 +267,7 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.ByGenre(genre) + opts := filter.SongsByGenre(genre) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) ctx := r.Context() From c4126fa674a0ac48731207ff395c08d41644833f Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 11 Aug 2026 09:14:17 -0400 Subject: [PATCH 423/596] fix(jellyfin): emit SortName for artists and albums, honoring PreferSortTags Finamp's A-Z fast scroll re-derives each item's sort key client-side from SortName, paging until it reaches the tapped letter. We only emitted SortName for songs, so Finamp fell back to reconstructing a key from the display name, which diverges from the order_* key the list is actually sorted by (curly quotes, non-English articles). The scan then believed it had passed the letter and scrolled back to the top instead of loading further pages. Emit SortName for artists and albums using the same key the persistence layer sorts by, via a sortName helper that mirrors Subsonic's PreferSortTags handling: order_* names by default, sort tags when the config is enabled. The song mapper now honors the config too, instead of always preferring the sort tag. Also parse Fields in the /Artists* handlers, which ignored the parameter entirely, so no field-gated data could ever be returned there. --- server/jellyfin/browsing.go | 1 + server/jellyfin/dto/mappers.go | 17 +++++++++- server/jellyfin/dto/mappers_test.go | 49 +++++++++++++++++++++++++--- server/jellyfin/e2e/browsing_test.go | 19 +++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 29fe48c2b..faf43d352 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -35,6 +35,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol scopeIDs: scopeIDs, genreIds: decodedQueryIDs(r, "genreids"), search: searchTerm(p), + fields: dto.ParseFields(p.Strings("fields")...), } if q.search != "" { opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 352fce11d..31cdba1dd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -10,6 +10,15 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) +// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump +// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top. +func sortName(sortTag, orderName, displayName string) string { + if conf.Server.PreferSortTags { + return cmp.Or(sortTag, orderName, displayName) + } + return cmp.Or(orderName, displayName) +} + // Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. const ticksPerMillis = 10_000 @@ -152,7 +161,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} } if fields.Has("SortName") { - item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) + item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title) } // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). // Participants holds the per-artist list; fall back to the flattened display fields when absent. @@ -281,6 +290,9 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain + if fields.Has("SortName") { + item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name) + } return item } @@ -302,6 +314,9 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { if tag != "" { item.ImageTags = map[string]string{"Primary": tag} } + if fields.Has("SortName") { + item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name) + } return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index a7b381c5c..77b78e55f 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -46,10 +46,9 @@ var _ = Describe("mappers", func() { mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} - It("omits MediaSources and SortName when Fields does not ask for them", func() { + It("omits MediaSources when Fields does not ask for them", func() { item := SongToBaseItem(mf, nil) Expect(item.MediaSources).To(BeNil()) - Expect(item.SortName).To(BeEmpty()) }) It("includes MediaSources only when Fields=MediaSources", func() { @@ -58,8 +57,50 @@ var _ = Describe("mappers", func() { Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) }) - It("includes SortName (from the sort title) only when Fields=SortName", func() { - Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song")) + // SortName must match the server sort order — see the sortName helper. + Describe("SortName", func() { + song := model.MediaFile{ID: "s1", Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} + ar := model.Artist{ID: "art-1", Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} + al := model.Album{ID: "alb-1", Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("is omitted unless Fields=SortName", func() { + Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty()) + Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty()) + Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty()) + }) + + It("uses the order names by default, ignoring sort tags", func() { + Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song")) + Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's")) + Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall")) + }) + + Context("with PreferSortTags", func() { + BeforeEach(func() { + conf.Server.PreferSortTags = true + }) + + It("prefers the sort tags", func() { + Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The")) + Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The")) + Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The")) + }) + + It("falls back to the order name when there is no sort tag", func() { + Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "The X", OrderArtistName: "x"}, + ParseFields("SortName")).SortName).To(Equal("x")) + }) + }) + + It("falls back to the display name when order name and sort tag are empty", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s", Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) + Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) + Expect(AlbumToBaseItem(model.Album{ID: "al", Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) + }) }) It("sets HasLyrics from the media file's lyrics", func() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index be6d6bb5d..14ab8b62b 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "slices" "sort" "time" @@ -79,6 +80,24 @@ var _ = Describe("Browsing", func() { Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) }) + // Finamp's A-Z jump scans SortName client-side, so it must follow the response order. + It("returns artists' SortName matching the server sort order when Fields=SortName", func() { + plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName")) + for _, it := range plain.Items { + Expect(it.SortName).To(BeEmpty()) + } + q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName")) + Expect(q.Items).ToNot(BeEmpty()) + sortNames := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.SortName).ToNot(BeEmpty()) + sortNames = append(sortNames, it.SortName) + } + Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames) + // "The Beatles" must be filed under B, exposing the article-stripped key to clients. + Expect(sortNames).To(ContainElement("beatles")) + }) + It("lists all genres", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(3)) From 9e95b19a4faa325d106630a7c19cd71f31db43b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 11 Aug 2026 10:42:39 -0400 Subject: [PATCH 424/596] feat(artwork): make the artwork image size cap configurable (#5931) * feat(artwork): make the artwork image size cap configurable Replace the hardcoded 20MB cap on resolved image reads with a new MaxImageSize config option. Load floors it at MaxImageUploadSize so an accepted upload can never be too large for the resolver to read back. * fix(conf): reject zero-valued byte-size options at startup ParseBytes accepts "0", but parseSize silently substitutes the default for it, so the accepted config would differ from the effective limit. * fix(conf): reject byte-size options that overflow int64 A raw value above math.MaxInt64 parses as a valid uint64 but wraps to a negative int64 in parseSize, giving readCapped a non-positive LimitReader bound so every artwork read comes back empty. --- conf/configuration.go | 31 +++++++++++++++++++++---- conf/configuration_test.go | 42 +++++++++++++++++++++++----------- conf/export_test.go | 2 +- consts/consts.go | 1 + core/artwork/processor.go | 13 +++++++---- core/artwork/processor_test.go | 13 ++++++++++- core/artwork/resolve_test.go | 2 +- core/artwork/uploader.go | 8 +++++-- 8 files changed, 85 insertions(+), 27 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 5122b15eb..fbbaaf252 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -5,6 +5,7 @@ import ( "encoding" "encoding/json" "fmt" + "math" "net/url" "os" "path/filepath" @@ -90,6 +91,7 @@ type configOptions struct { EnableUserEditing bool EnableArtworkUpload bool MaxImageUploadSize string + MaxImageSize string EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -421,7 +423,8 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, - validateMaxImageUploadSize, + validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize), + validateByteSize("MaxImageSize", Server.MaxImageSize), validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { @@ -481,6 +484,14 @@ func Load(noConfigDump bool) { Server.UICoverArtSize = newValue } + // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. + imgSize, _ := humanize.ParseBytes(Server.MaxImageSize) + uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize) + if imgSize < uploadSize { + log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize) + Server.MaxImageSize = Server.MaxImageUploadSize + } + // Call init hooks for _, hook := range hooks { hook() @@ -806,11 +817,20 @@ func validatePurgeMissingOption() error { return nil } -func validateMaxImageUploadSize() error { - if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { - return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) +func validateByteSize(name, value string) func() error { + return func() error { + size, err := humanize.ParseBytes(value) + if err != nil { + return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err) + } + if size == 0 { + return fmt.Errorf("invalid %s %q: must be greater than zero", name, value) + } + if size > math.MaxInt64 { + return fmt.Errorf("invalid %s %q: value is too large", name, value) + } + return nil } - return nil } func validateEnforceNonRootUser() error { @@ -980,6 +1000,7 @@ func setViperDefaults() { viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) + viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 4eaa8e3d8..ac6477572 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -336,19 +336,10 @@ var _ = Describe("Configuration", func() { }) - Describe("ValidateMaxImageUploadSize", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - + Describe("ValidateByteSize", func() { DescribeTable("accepts valid size values", func(input string) { - conf.Server.MaxImageUploadSize = input - Expect(conf.ValidateMaxImageUploadSize()).To(Succeed()) + Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed()) }, Entry("megabytes", "10MB"), Entry("gigabytes", "1GB"), @@ -359,14 +350,39 @@ var _ = Describe("Configuration", func() { DescribeTable("rejects invalid size values", func(input string) { - conf.Server.MaxImageUploadSize = input - Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize"))) + Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize"))) }, Entry("garbage string", "not-a-size"), Entry("negative-looking", "-10MB"), + Entry("zero", "0"), + Entry("zero with unit", "0MB"), + Entry("overflows int64", "9223372036854775808"), ) }) + Describe("MaxImageSize floor", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("is raised to MaxImageUploadSize when configured lower", func() { + viper.SetDefault("maximagesize", "5MB") + viper.SetDefault("maximageuploadsize", "50MB") + conf.Load(true) + Expect(conf.Server.MaxImageSize).To(Equal("50MB")) + }) + + It("keeps a larger MaxImageSize unchanged", func() { + viper.SetDefault("maximagesize", "30MB") + conf.Load(true) + Expect(conf.Server.MaxImageSize).To(Equal("30MB")) + }) + }) + Describe("EnforceNonRootUser", func() { It("defaults to false", func() { conf.Load(true) diff --git a/conf/export_test.go b/conf/export_test.go index cbb64b3d0..d1e1a6f99 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend var ToPascalCase = toPascalCase -var ValidateMaxImageUploadSize = validateMaxImageUploadSize +var ValidateByteSize = validateByteSize func SetRuntimeInfoForTest(goos string, euid int) func() { oldGOOS := currentGOOS diff --git a/consts/consts.go b/consts/consts.go index 46906b8b5..aed8ecf66 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -112,6 +112,7 @@ const ( const ( DefaultUICoverArtSize = 300 DefaultMaxImageUploadSize = "10MB" + DefaultMaxImageSize = "20MB" ) // Prometheus options diff --git a/core/artwork/processor.go b/core/artwork/processor.go index c3cf15b71..6ce76bf45 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -13,6 +13,8 @@ import ( "sync" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/core/artwork/dominant" "github.com/navidrome/navidrome/core/artwork/thumbhash" @@ -51,7 +53,9 @@ const thumbnailSize = 100 // maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at // an arbitrarily large endpoint. -const maxImageBytes = 20 << 20 +func maxImageBytes() int64 { + return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize) +} // maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that // image.Decode would expand into gigabytes. @@ -202,12 +206,13 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A } func readCapped(r io.Reader) ([]byte, error) { - data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + limit := maxImageBytes() + data, err := io.ReadAll(io.LimitReader(r, limit+1)) if err != nil { return nil, err } - if len(data) > maxImageBytes { - return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes) + if int64(len(data)) > limit { + return nil, fmt.Errorf("image exceeds size cap %d", limit) } return data, nil } diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index d326dde09..8d30975cd 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -394,7 +394,7 @@ var _ = Describe("processor.acquire", func() { imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") f, err := os.Create(imgPath) Expect(err).ToNot(HaveOccurred()) - Expect(f.Truncate(maxImageBytes + 1)).To(Succeed()) + Expect(f.Truncate(maxImageBytes() + 1)).To(Succeed()) Expect(f.Close()).To(Succeed()) radioRepo := tests.CreateMockedRadioRepo() @@ -493,3 +493,14 @@ var _ = Describe("makeThumbnail", func() { Expect(thumb.Pix[3]).To(BeNumerically("==", 128)) }) }) + +var _ = Describe("maxImageBytes", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("reads MaxImageSize from config", func() { + conf.Server.MaxImageSize = "30MB" + Expect(maxImageBytes()).To(Equal(int64(30_000_000))) + }) +}) diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index fc1a6a3ff..9a0962cf2 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -625,7 +625,7 @@ var _ = Describe("decodeTile", func() { }) It("rejects a tile larger than the size cap", func() { - data := bytes.Repeat([]byte{0}, maxImageBytes+1) + data := bytes.Repeat([]byte{0}, int(maxImageBytes())+1) _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) Expect(err).To(HaveOccurred()) }) diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go index 9c38ae2e5..76526af3c 100644 --- a/core/artwork/uploader.go +++ b/core/artwork/uploader.go @@ -17,10 +17,14 @@ import ( // MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. func MaxImageUploadSize() int64 { - if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return parseSize(conf.Server.MaxImageUploadSize, consts.DefaultMaxImageUploadSize) +} + +func parseSize(value, fallback string) int64 { + if size, err := humanize.ParseBytes(value); err == nil && size > 0 { return int64(size) } - size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + size, _ := humanize.ParseBytes(fallback) return int64(size) } From 036c9cab9671505c83fce6523f55d97b152b3b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 11 Aug 2026 18:34:27 -0400 Subject: [PATCH 425/596] feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(artwork): let callers pin the blurhash component counts * feat(artwork): synthesize a unique placeholder blurhash from a seed * fix(artwork): base the synthetic-hash no-collision guarantee on the prefix, not length The prior comment and test claimed a synthetic value could never collide with a real one because it's always shorter. That's false: components() targets ~16 tiles by scaling one axis down as the other hits the 9 cap, so an extreme aspect ratio (e.g. 10x200) collapses to 1x9 = 9 components, which encodes to the same 22 characters as a 3x3 synthetic hash. The old test only exercised a square 64x64 gradient, so it never caught this. The real guarantee is structural, not length-based: the first character encodes shape as (xComp-1)+(yComp-1)*9, and components() derives xf*yf = 16 exactly before flooring/capping (xf = sqrt(16w/h), yf = xf*h/w = sqrt(16h/w), so xf*yf = sqrt(256) = 16). Two factors both in [2,3) can't multiply to 16, so Encode can never derive 3x3 - the synthetic prefix 'K' is structurally exclusive to Synthetic. Replaced the length-based test with one that sweeps extreme aspect ratios and asserts no real encode ever produces prefix 'K', alongside the assertion that Synthetic always does. * feat(artwork): tint a synthetic blurhash from a base colour Parses baseColor into HSL and clamps saturation/lightness so the tint stays muted; per-cell hashing (unchanged) is what keeps a shared tint across an album's tracks from colliding, as the new 1M-seed spec proves under a single fixed colour. * fix(artwork): trim over-long comment in synthetic blurhash test Review flagged the collision spec's comment for exceeding the 2-line budget; the Finamp rationale it restated already lives in the design doc and commit history. * feat(jellyfin): send a synthetic blurhash for unresolved artwork Clients render nothing where a placeholder belongs when ImageBlurHashes is omitted for pending artwork. primaryImage now synthesizes a value (seeded on the tag, so a cover swap re-keys it) whenever a tag is present but no blurhash has been computed yet. Known-absent artwork (ImageAbsent) is unaffected: it still emits neither tag nor blurhash, since GetOrPlaceholder would otherwise pin a shared placeholder under a distinct cache key for a year. * fix(jellyfin): avoid computing a synthetic blurhash when a real one exists cmp.Or evaluates both arguments before choosing between them, so blurhash.Synthetic ran (and was discarded) on every call even when img.BlurHash was already set. That's the common case in production: artwork_repository.go populates BlurHash from persisted values once a scan resolves it, so a healthy library paid the synthesis cost (xxh3 hashing, HSL conversion, image alloc, DCT encode) on every mapped item for a value it never used. Branch on emptiness first instead. * feat(jellyfin): tint a pending track's placeholder with its album's colour primaryImage never reaches the embeddedArtPending branch of SongToBaseItem, since there is no resolved image yet to feed it. Seed the synthetic blurhash on mf.ID (so the value stays unique and the client still issues the read-through request) but tint it with the album's DominantColor, which is already hydrated on MediaFile at no extra cost. * style(artwork): trim synthetic blurhash comments to the budget * docs(jellyfin): fix stale blurhash README bullet + two review nits The "Blurhashes are synthetic" bullet under Known limitations described dto/blurhash.go, which was deleted when the real core/artwork-computed blurhash + synthetic-fallback pipeline landed; every claim in it was false. Replaced it with an accurate paragraph in the Images section, since the described behaviour is now the finished design, not a gap. Also: fix a doc/body comment mismatch in blurhash.go (component counts are 2..9, not 1..9), and deduplicate the inline DC-extraction logic in synthetic_test.go by reusing the existing dcOf helper. * refactor(artwork): slice the synthetic cell jitter on byte boundaries The three perturbations came off one hash with mismatched masks and shifts (0xFF at 0, 0x3F at 8, 0x3F at 14), so a reader had to do the arithmetic to confirm the fields did not overlap. Only 20 of the 64 bits were in use either way, so the narrower fields bought nothing. Uniform byte slices at 0/8/16 are non-overlapping by inspection and give each field the full 8 bits. Both 1,000,000-seed collision specs still measure 1,000,000 distinct values. Also drops the local `n` alias, which was a second name for synthComponents inside a 15-line function. * docs(jellyfin): fix the blurhash paragraph's opening sentence It opened with "follow the same principle", pointing back at the preceding paragraph on admin-context artwork resolution — an unrelated subject, so the reader looks for a connection that is not there. * fix(artwork): render the synthetic grid larger than its component count The 3x3 cell grid was handed straight to the encoder as a 3x3 image, so the source had exactly as many samples as basis functions. Blurhash normalises its coefficients by 1/(w*h) and 2/(w*h), which assumes many samples per component, so the AC terms came out far too large. At the bottom-right corner the x and y bases are both [1, -0.5, -0.5], everything lines up negative, and the result clamped to black — a dark blob on every synthetic placeholder. Rendering the same nine colours bilinearly at 8x8 first removes it: measured over three seeds, the darkest corner goes from 13 to 74 and the darkest pixel from 1 to 63. 8px is the smallest size that clears the artefact; 12 and 16 are visually indistinguishable and cost 1.7x and 2.7x more. The interpolation is hand-rolled rather than x/image's scaler, which allocated 528 times per call against 14 for this. Both 1,000,000-seed collision specs still measure 1,000,000 distinct values. * refactor(artwork): tidy the synthetic upscale helpers cellWeight nudged its upper bound with a 1e-9 epsilon so int() could never land on the last cell. Clamping the coordinate and then the index says the same thing without a magic constant, and makes the clamp-don't-extrapolate intent explicit — edge pixels map outside the cell centres, so the fraction would otherwise run past 1. The grid type is spelled once as colorGrid rather than repeated in the local and the upscale signature, and encodeAt's doc now states the source-size contract that Synthetic depends on, so the next caller sees it at the function rather than only in synthetic.go. Output is unchanged: all seven sample hashes match byte for byte. * perf(artwork): make the synthetic upscale separable Both axes are square and constant-sized, so the per-pixel cell index and weight were the same 8 values recomputed 64 times per call. They are now built once by sync.OnceValue, matching the srgbToLinearTable pattern. The interpolation is also separable: stretching each of the 3 grid rows horizontally once and then blending rows vertically does 264 lerps where the per-pixel form did 576. upscale drops from 347ns to 260ns, Synthetic from 1780ns to 1669ns. Output is byte-identical across all seven sample hashes — same operations in the same order, only hoisted. * refactor(artwork): let a caller supply the cosine basis encodeAt built its cosine tables inline, so Synthetic rebuilt bit-identical ones on every call — its shape is always 3 components over 8 pixels. Splitting the table construction into cosBasis and the encoder proper into encodePixels lets Synthetic build the basis once via sync.OnceValue, and leaves encodeAt's signature and behaviour untouched. Synthetic drops from 14 allocations to 6 (1669ns to 1508ns). The time saving is small and this path only runs while artwork is still unresolved; the allocation cut is the point, alongside a shorter encodeAt. Every hash is unchanged: nine real Encode outputs spanning square, 10x200, 200x10, 1x50 and a real JPEG, plus all seven synthetic samples, all byte for byte identical before and after. --- core/artwork/blurhash/blurhash.go | 41 +++--- core/artwork/blurhash/synthetic.go | 172 ++++++++++++++++++++++++ core/artwork/blurhash/synthetic_test.go | 103 ++++++++++++++ server/jellyfin/README.md | 18 ++- server/jellyfin/dto/mappers.go | 15 ++- server/jellyfin/dto/mappers_test.go | 54 ++++++-- 6 files changed, 364 insertions(+), 39 deletions(-) create mode 100644 core/artwork/blurhash/synthetic.go create mode 100644 core/artwork/blurhash/synthetic_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index ca5bf7026..28d3409f3 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -32,23 +32,32 @@ func Encode(img image.Image) (string, error) { } // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) - src := pixelsOf(downscale(img)) - w, h := src.w, src.h + return encodeAt(img, xComp, yComp), nil +} - cosX := make([][]float64, xComp) - for i := range cosX { - cosX[i] = make([]float64, w) - for x := range cosX[i] { - cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) - } - } - cosY := make([][]float64, yComp) - for j := range cosY { - cosY[j] = make([]float64, h) - for y := range cosY[j] { - cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) +// encodeAt encodes img at the given component counts (each 2..9). Callers guarantee non-empty +// bounds, and a source far larger than the counts: too few samples overshoot the AC terms. +func encodeAt(img image.Image, xComp, yComp int) string { + src := pixelsOf(downscale(img)) + return encodePixels(src, xComp, yComp, cosBasis(xComp, src.w), cosBasis(yComp, src.h)) +} + +// cosBasis is the cosine basis for comp components sampled across n pixels. It depends only +// on its arguments, so a caller with a fixed shape can build it once and reuse it. +func cosBasis(comp, n int) [][]float64 { + basis := make([][]float64, comp) + for i := range basis { + basis[i] = make([]float64, n) + for x := range basis[i] { + basis[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(n)) } } + return basis +} + +// encodePixels is the encoder proper; cosX and cosY must match src's dimensions. +func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { + w, h := src.w, src.h lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) @@ -86,7 +95,7 @@ func Encode(img image.Image) (string, error) { var sb strings.Builder sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) - // Derived counts are at least 1x9, so there is always at least one AC factor. + // Every caller passes at least 2 components, so there is always at least one AC factor. ac := factors[1:] actualMax := 0.0 for _, f := range ac { @@ -101,7 +110,7 @@ func Encode(img image.Image) (string, error) { for _, f := range ac { sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) } - return sb.String(), nil + return sb.String() } // pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. diff --git a/core/artwork/blurhash/synthetic.go b/core/artwork/blurhash/synthetic.go new file mode 100644 index 000000000..1e15507d8 --- /dev/null +++ b/core/artwork/blurhash/synthetic.go @@ -0,0 +1,172 @@ +package blurhash + +import ( + "image" + "math" + "strconv" + "sync" + + "github.com/zeebo/xxh3" +) + +// A real hash never has 3x3 components: components() always targets ~16 tiles. +const synthComponents = 3 + +// Encoding a source no bigger than the component grid overshoots the AC coefficients, +// clamping the corners to black; the encoder assumes many samples per component. +const synthSourceSize = 8 + +type colorGrid = [synthComponents * synthComponents][3]float64 + +// Synthetic returns a blurhash unique to seed, for artwork whose real hash does not exist +// yet. baseColor ("#rrggbb") sets the hue family; "" derives it from the seed. +func Synthetic(seed, baseColor string) string { + hue, sat, light := baseTone(baseColor, xxh3.HashStringSeed(seed, 0)) + + var grid colorGrid + for i := range grid { + // Each cell hashes the seed separately, so one tint shared by many items still + // yields one value per item. + bits := xxh3.HashStringSeed(seed, uint64(i)+1) + dh := (byteFrac(bits, 0) - 0.5) * 60 + ds := (byteFrac(bits, 8) - 0.5) * 0.16 + dl := (byteFrac(bits, 16) - 0.5) * 0.30 + r, g, b := hslToRGB(hue+dh, clamp01(sat+ds), clamp01(light+dl)) + grid[i] = [3]float64{float64(r), float64(g), float64(b)} + } + // The shape is fixed, so the cosine basis is reused instead of rebuilt per call. + basis := synthBasis() + return encodePixels(pixelsOf(upscale(&grid)), synthComponents, synthComponents, basis, basis) +} + +// synthBasis is the cosine basis for the fixed synthetic shape. Square, so one serves both axes. +var synthBasis = sync.OnceValue(func() [][]float64 { + return cosBasis(synthComponents, synthSourceSize) +}) + +// upscale renders the cell grid bilinearly at synthSourceSize. Hand-rolled because +// x/image's scaler allocates per call, and this runs once per mapped item. +func upscale(grid *colorGrid) *image.NRGBA { + const n, size = synthComponents, synthSourceSize + axis := axisWeights() + + // Separable: each grid row is stretched horizontally once, then rows blend vertically. + // Doing it per pixel instead would repeat every horizontal lerp `size` times. + var rows [n][size][3]float64 + for r := range n { + for x, a := range axis { + for c := range 3 { + rows[r][x][c] = grid[r*n+a.cell][c]*(1-a.frac) + grid[r*n+a.cell+1][c]*a.frac + } + } + } + + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for y, a := range axis { + top, bot := &rows[a.cell], &rows[a.cell+1] + for x := range size { + p := img.Pix[y*img.Stride+x*4:] + for c := range 3 { + p[c] = uint8(top[x][c]*(1-a.frac) + bot[x][c]*a.frac + 0.5) + } + p[3] = 255 + } + } + return img +} + +// axisWeights maps each source pixel to its lower cell and the fraction toward the next. +// Both axes are square and constant-sized, so one table serves both and outlives the call. +var axisWeights = sync.OnceValue(func() *[synthSourceSize]struct { + cell int + frac float64 +} { + var t [synthSourceSize]struct { + cell int + frac float64 + } + for i := range t { + // Edge pixels fall outside the cell centres, so both ends clamp rather than extrapolate. + f := min(max((float64(i)+0.5)*synthComponents/synthSourceSize-0.5, 0), synthComponents-1) + t[i].cell = min(int(f), synthComponents-2) + t[i].frac = f - float64(t[i].cell) + } + return &t +}) + +func byteFrac(bits uint64, shift int) float64 { + return float64(bits>>shift&0xFF) / 255 +} + +// baseTone clamps saturation and lightness away from the extremes, so a placeholder +// never reads as neon or as solid black. +func baseTone(baseColor string, hueBits uint64) (hue, sat, light float64) { + if r, g, b, ok := parseHex(baseColor); ok { + h, s, l := rgbToHSL(r, g, b) + return h, min(max(s, 0.10), 0.35), min(max(l, 0.15), 0.75) + } + return float64(hueBits&0x1FF) / 512 * 360, 0.22, 0.40 +} + +func parseHex(s string) (r, g, b uint8, ok bool) { + if len(s) != 7 || s[0] != '#' { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(s[1:], 16, 32) + if err != nil { + return 0, 0, 0, false + } + return uint8(v >> 16), uint8(v >> 8), uint8(v), true +} + +func rgbToHSL(r, g, b uint8) (h, s, l float64) { + rf, gf, bf := float64(r)/255, float64(g)/255, float64(b)/255 + mx, mn := max(rf, gf, bf), min(rf, gf, bf) + l = (mx + mn) / 2 + if mx == mn { + return 0, 0, l + } + d := mx - mn + s = d / (1 - math.Abs(2*l-1)) + switch mx { + case rf: + h = math.Mod((gf-bf)/d, 6) + case gf: + h = (bf-rf)/d + 2 + default: + h = (rf-gf)/d + 4 + } + h *= 60 + if h < 0 { + h += 360 + } + return h, s, l +} + +func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { + h = math.Mod(math.Mod(h, 360)+360, 360) + c := (1 - math.Abs(2*l-1)) * s + hp := h / 60 + x := c * (1 - math.Abs(math.Mod(hp, 2)-1)) + var r, g, b float64 + switch int(hp) { + case 0: + r, g, b = c, x, 0 + case 1: + r, g, b = x, c, 0 + case 2: + r, g, b = 0, c, x + case 3: + r, g, b = 0, x, c + case 4: + r, g, b = x, 0, c + default: + r, g, b = c, 0, x + } + m := l - c/2 + return to8(r + m), to8(g + m), to8(b + m) +} + +func clamp01(v float64) float64 { return min(max(v, 0), 1) } + +func to8(v float64) uint8 { return uint8(math.Round(clamp01(v) * 255)) } diff --git a/core/artwork/blurhash/synthetic_test.go b/core/artwork/blurhash/synthetic_test.go new file mode 100644 index 000000000..671c1cd30 --- /dev/null +++ b/core/artwork/blurhash/synthetic_test.go @@ -0,0 +1,103 @@ +package blurhash_test + +import ( + "fmt" + "strings" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/zeebo/xxh3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Synthetic", func() { + It("returns a well-formed 3x3 blurhash", func() { + h := blurhash.Synthetic("alb-1", "") + // 3x3 encodes as (3-1)+(3-1)*9 = 20, which is base83 'K'; length is 1+1+4+8*2. + Expect(h).To(HaveLen(22)) + Expect(h).To(HavePrefix("K")) + for _, c := range h { + Expect(strings.ContainsRune(alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("keeps its prefix exclusive to real encodes, across aspect ratios", func() { + Expect(blurhash.Synthetic("alb-1", "")).To(HavePrefix("K")) + + ratios := []struct{ w, h int }{ + {10, 200}, {200, 10}, {1, 50}, {50, 1}, {64, 64}, {100, 300}, {300, 100}, + } + for _, r := range ratios { + encoded, err := blurhash.Encode(gradientImage(r.w, r.h)) + Expect(err).ToNot(HaveOccurred()) + Expect(encoded).ToNot(HavePrefix("K"), "real encode at %dx%d produced the synthetic prefix", r.w, r.h) + } + }) + + It("is deterministic for one seed", func() { + Expect(blurhash.Synthetic("alb-1", "")).To(Equal(blurhash.Synthetic("alb-1", ""))) + }) + + It("differs across seeds", func() { + Expect(blurhash.Synthetic("alb-1", "")).ToNot(Equal(blurhash.Synthetic("alb-2", ""))) + }) + + dcOf := func(hash string) (int, int, int) { + dc := decode83(hash[2:6]) + return dc >> 16 & 0xFF, dc >> 8 & 0xFF, dc & 0xFF + } + + It("decodes to a muted DC colour", func() { + r, g, b := dcOf(blurhash.Synthetic("alb-1", "")) + spread := max(r, g, b) - min(r, g, b) + Expect(spread).To(BeNumerically("<", 120), "saturation is capped, so no channel should run away") + Expect(max(r, g, b)).To(BeNumerically("<", 240), "lightness is capped below white") + Expect(min(r, g, b)).To(BeNumerically(">", 10), "lightness is capped above black") + }) + + It("keeps 1,000,000 seeds effectively distinct", func() { + const count = 1_000_000 + seen := make(map[uint64]struct{}, count) + for i := range count { + seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("item-%d", i), ""))] = struct{}{} + } + Expect(len(seen)).To(BeNumerically(">=", 999_900)) + }) + + It("leans the DC towards a red tint", func() { + r, g, b := dcOf(blurhash.Synthetic("alb-1", "#c04040")) + Expect(r).To(BeNumerically(">", g)) + Expect(r).To(BeNumerically(">", b)) + }) + + It("leans the DC towards a blue tint", func() { + r, g, b := dcOf(blurhash.Synthetic("alb-1", "#4040c0")) + Expect(b).To(BeNumerically(">", r)) + Expect(b).To(BeNumerically(">", g)) + }) + + It("changes the value when only the tint changes", func() { + Expect(blurhash.Synthetic("alb-1", "#c04040")).ToNot(Equal(blurhash.Synthetic("alb-1", "#4040c0"))) + }) + + It("falls back to the seed hue for an unparseable tint", func() { + Expect(blurhash.Synthetic("alb-1", "not-a-colour")).To(Equal(blurhash.Synthetic("alb-1", ""))) + }) + + It("tracks a dark tint's lightness", func() { + dark, _, _ := dcOf(blurhash.Synthetic("alb-1", "#101820")) + light, _, _ := dcOf(blurhash.Synthetic("alb-1", "#e8f0f8")) + Expect(dark).To(BeNumerically("<", light)) + }) + + // A shared tint must not become a shared value: the seed alone carries uniqueness. + It("keeps 1,000,000 seeds distinct under a single fixed tint", func() { + const count = 1_000_000 + seen := make(map[uint64]struct{}, count) + for i := range count { + seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("track-%d", i), "#3a5f7d"))] = struct{}{} + } + Expect(len(seen)).To(BeNumerically(">=", 999_900)) + }) +}) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 13ec79aa8..b07aaee8d 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -180,6 +180,14 @@ warmer uses — so user-scoped items like private playlists still resolve their falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to their Navidrome `ArtworkID`. +Blurhashes come in three tiers. A real blurhash is computed once in +`core/artwork` from the decoded image and stored per artwork row; the mappers read it whenever it +exists. While artwork is still unresolved, `dto/mappers.go` instead emits a synthetic 3x3 blurhash +(`core/artwork/blurhash.Synthetic`), seeded on the image tag so it's effectively unique per image +and can never collide with a real hash's leading byte, giving clients a valid cache key while art +loads. A track awaiting embedded-art extraction has its synthetic hash tinted from the parent +album's dominant colour. Known-absent artwork emits no tag and no blurhash at all. + ## Finamp saved-queue id truncation Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that @@ -330,16 +338,6 @@ make test PKG=./server/jellyfin/... Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client that already has an artist id from elsewhere is not re-checked against library membership. -- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is - populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** - blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a - multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and - stores it per image, so its placeholder approximates the art. Ours satisfies the protocol - (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash - warning) but renders as a flat color while art loads. A proper implementation would compute the - real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it - keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback - for art that hasn't been rendered yet. - **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 31cdba1dd..d2bdb1b2e 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -212,6 +213,8 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { // Nothing enqueues media files: advertising the id is what makes a client ask, and that // request is what extracts the embedded art and queues the track. item.ImageTags = map[string]string{"Primary": mf.ID} + hash := blurhash.Synthetic(mf.ID, mf.AlbumImage.DominantColor) + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.ID: hash}} } else if mf.AlbumID != "" { if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { item.AlbumPrimaryImageTag = tag @@ -227,15 +230,19 @@ func embeddedArtPending(mf model.MediaFile) bool { mf.ImageHash == "" && !mf.ItemImage.ImageAbsent } -// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would -// pin a stale cover forever. +// primaryImage synthesizes a blurhash when none was computed yet: clients key their cover +// cache on the value, so it must be unique per image, never shared or reused. func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { if img.ImageAbsent { return "", nil, nil } tag = cmp.Or(img.ImageHash, fallback) - if img.BlurHash != "" { - blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} + if tag != "" { + hash := img.BlurHash + if hash == "" { + hash = blurhash.Synthetic(tag, "") + } + blurs = map[string]map[string]string{"Primary": {tag: hash}} } if fields.Has("PrimaryImageAspectRatio") { ratio = img.AspectRatio() diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 77b78e55f..451e1e08b 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -37,7 +38,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -298,7 +299,7 @@ var _ = Describe("mappers", func() { Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -472,7 +473,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("pl-1", blurhash.Synthetic("pl-1", ""))) }) It("changes the playlist image tag when the cover content changes", func() { @@ -507,13 +508,37 @@ var _ = Describe("mappers", func() { It("advertises the track id so the client triggers the read-through", func() { mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" + mf.AlbumImage.DominantColor = "#3a5f7d" item := SongToBaseItem(mf, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) - Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") + Expect(item.ImageBlurHashes["Primary"]).To( + HaveKeyWithValue("mf-1", blurhash.Synthetic("mf-1", "#3a5f7d"))) Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) }) + // The client's image request is what extracts the embedded art. Reusing the album's + // value would let it answer from cache and never send it. + It("gives the track a value distinct from its album's", func() { + mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} + mf.AlbumImage.ImageHash = "0123456789abcdef" + mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" + mf.AlbumImage.DominantColor = "#3a5f7d" + + track := SongToBaseItem(mf, nil).ImageBlurHashes["Primary"]["mf-3"] + album := AlbumToBaseItem(model.Album{ID: "alb-1", ItemImage: mf.AlbumImage}, nil). + ImageBlurHashes["Primary"]["0123456789abcdef"] + Expect(track).ToNot(BeEmpty()) + Expect(track).ToNot(Equal(album)) + }) + + It("still emits a value when the album has no dominant colour", func() { + mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} + + item := SongToBaseItem(mf, nil) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("mf-4", blurhash.Synthetic("mf-4", ""))) + }) + It("falls back to the album when the track has no art of its own", func() { mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} mf.AlbumImage.ImageHash = "0123456789abcdef" @@ -555,13 +580,14 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("omits the blurhash entirely when none was computed", func() { + It("synthesizes a blurhash when none was computed", func() { al := model.Album{ID: "alb-2", Name: "Album"} al.ImageHash = "0123456789abcdef" item := AlbumToBaseItem(al, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") + Expect(item.ImageBlurHashes["Primary"]).To( + HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) }) It("omits tags for known-absent artwork", func() { @@ -573,10 +599,19 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) + It("keeps known-absent artwork free of any synthesized value", func() { + al := model.Album{ID: "alb-5", Name: "Album"} + al.ImageAbsent = true + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageBlurHashes).To(BeNil(), + "there is no image to key a cache on, and nothing will ever re-key it") + }) + It("falls back to the entity id while artwork is still unresolved", func() { item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-4", blurhash.Synthetic("alb-4", ""))) }) It("versions an artist's tag by content hash", func() { @@ -601,13 +636,14 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("never synthesizes a song blurhash when the album has none", func() { + It("synthesizes a song's album blurhash when the album has none", func() { mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To( + HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) }) It("omits a song's album tag when the album art is known absent", func() { From 8978c7b9facee94d52e371d367a1f74394439dd9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 12 Aug 2026 11:46:23 -0400 Subject: [PATCH 426/596] Revert "feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941)" This reverts commit 036c9cab9671505c83fce6523f55d97b152b3b32. --- core/artwork/blurhash/blurhash.go | 39 +++--- core/artwork/blurhash/synthetic.go | 172 ------------------------ core/artwork/blurhash/synthetic_test.go | 103 -------------- server/jellyfin/README.md | 18 +-- server/jellyfin/dto/mappers.go | 15 +-- server/jellyfin/dto/mappers_test.go | 54 ++------ 6 files changed, 38 insertions(+), 363 deletions(-) delete mode 100644 core/artwork/blurhash/synthetic.go delete mode 100644 core/artwork/blurhash/synthetic_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index 28d3409f3..ca5bf7026 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -32,32 +32,23 @@ func Encode(img image.Image) (string, error) { } // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) - return encodeAt(img, xComp, yComp), nil -} - -// encodeAt encodes img at the given component counts (each 2..9). Callers guarantee non-empty -// bounds, and a source far larger than the counts: too few samples overshoot the AC terms. -func encodeAt(img image.Image, xComp, yComp int) string { src := pixelsOf(downscale(img)) - return encodePixels(src, xComp, yComp, cosBasis(xComp, src.w), cosBasis(yComp, src.h)) -} + w, h := src.w, src.h -// cosBasis is the cosine basis for comp components sampled across n pixels. It depends only -// on its arguments, so a caller with a fixed shape can build it once and reuse it. -func cosBasis(comp, n int) [][]float64 { - basis := make([][]float64, comp) - for i := range basis { - basis[i] = make([]float64, n) - for x := range basis[i] { - basis[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(n)) + cosX := make([][]float64, xComp) + for i := range cosX { + cosX[i] = make([]float64, w) + for x := range cosX[i] { + cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) + } + } + cosY := make([][]float64, yComp) + for j := range cosY { + cosY[j] = make([]float64, h) + for y := range cosY[j] { + cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) } } - return basis -} - -// encodePixels is the encoder proper; cosX and cosY must match src's dimensions. -func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { - w, h := src.w, src.h lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) @@ -95,7 +86,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { var sb strings.Builder sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) - // Every caller passes at least 2 components, so there is always at least one AC factor. + // Derived counts are at least 1x9, so there is always at least one AC factor. ac := factors[1:] actualMax := 0.0 for _, f := range ac { @@ -110,7 +101,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { for _, f := range ac { sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) } - return sb.String() + return sb.String(), nil } // pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. diff --git a/core/artwork/blurhash/synthetic.go b/core/artwork/blurhash/synthetic.go deleted file mode 100644 index 1e15507d8..000000000 --- a/core/artwork/blurhash/synthetic.go +++ /dev/null @@ -1,172 +0,0 @@ -package blurhash - -import ( - "image" - "math" - "strconv" - "sync" - - "github.com/zeebo/xxh3" -) - -// A real hash never has 3x3 components: components() always targets ~16 tiles. -const synthComponents = 3 - -// Encoding a source no bigger than the component grid overshoots the AC coefficients, -// clamping the corners to black; the encoder assumes many samples per component. -const synthSourceSize = 8 - -type colorGrid = [synthComponents * synthComponents][3]float64 - -// Synthetic returns a blurhash unique to seed, for artwork whose real hash does not exist -// yet. baseColor ("#rrggbb") sets the hue family; "" derives it from the seed. -func Synthetic(seed, baseColor string) string { - hue, sat, light := baseTone(baseColor, xxh3.HashStringSeed(seed, 0)) - - var grid colorGrid - for i := range grid { - // Each cell hashes the seed separately, so one tint shared by many items still - // yields one value per item. - bits := xxh3.HashStringSeed(seed, uint64(i)+1) - dh := (byteFrac(bits, 0) - 0.5) * 60 - ds := (byteFrac(bits, 8) - 0.5) * 0.16 - dl := (byteFrac(bits, 16) - 0.5) * 0.30 - r, g, b := hslToRGB(hue+dh, clamp01(sat+ds), clamp01(light+dl)) - grid[i] = [3]float64{float64(r), float64(g), float64(b)} - } - // The shape is fixed, so the cosine basis is reused instead of rebuilt per call. - basis := synthBasis() - return encodePixels(pixelsOf(upscale(&grid)), synthComponents, synthComponents, basis, basis) -} - -// synthBasis is the cosine basis for the fixed synthetic shape. Square, so one serves both axes. -var synthBasis = sync.OnceValue(func() [][]float64 { - return cosBasis(synthComponents, synthSourceSize) -}) - -// upscale renders the cell grid bilinearly at synthSourceSize. Hand-rolled because -// x/image's scaler allocates per call, and this runs once per mapped item. -func upscale(grid *colorGrid) *image.NRGBA { - const n, size = synthComponents, synthSourceSize - axis := axisWeights() - - // Separable: each grid row is stretched horizontally once, then rows blend vertically. - // Doing it per pixel instead would repeat every horizontal lerp `size` times. - var rows [n][size][3]float64 - for r := range n { - for x, a := range axis { - for c := range 3 { - rows[r][x][c] = grid[r*n+a.cell][c]*(1-a.frac) + grid[r*n+a.cell+1][c]*a.frac - } - } - } - - img := image.NewNRGBA(image.Rect(0, 0, size, size)) - for y, a := range axis { - top, bot := &rows[a.cell], &rows[a.cell+1] - for x := range size { - p := img.Pix[y*img.Stride+x*4:] - for c := range 3 { - p[c] = uint8(top[x][c]*(1-a.frac) + bot[x][c]*a.frac + 0.5) - } - p[3] = 255 - } - } - return img -} - -// axisWeights maps each source pixel to its lower cell and the fraction toward the next. -// Both axes are square and constant-sized, so one table serves both and outlives the call. -var axisWeights = sync.OnceValue(func() *[synthSourceSize]struct { - cell int - frac float64 -} { - var t [synthSourceSize]struct { - cell int - frac float64 - } - for i := range t { - // Edge pixels fall outside the cell centres, so both ends clamp rather than extrapolate. - f := min(max((float64(i)+0.5)*synthComponents/synthSourceSize-0.5, 0), synthComponents-1) - t[i].cell = min(int(f), synthComponents-2) - t[i].frac = f - float64(t[i].cell) - } - return &t -}) - -func byteFrac(bits uint64, shift int) float64 { - return float64(bits>>shift&0xFF) / 255 -} - -// baseTone clamps saturation and lightness away from the extremes, so a placeholder -// never reads as neon or as solid black. -func baseTone(baseColor string, hueBits uint64) (hue, sat, light float64) { - if r, g, b, ok := parseHex(baseColor); ok { - h, s, l := rgbToHSL(r, g, b) - return h, min(max(s, 0.10), 0.35), min(max(l, 0.15), 0.75) - } - return float64(hueBits&0x1FF) / 512 * 360, 0.22, 0.40 -} - -func parseHex(s string) (r, g, b uint8, ok bool) { - if len(s) != 7 || s[0] != '#' { - return 0, 0, 0, false - } - v, err := strconv.ParseUint(s[1:], 16, 32) - if err != nil { - return 0, 0, 0, false - } - return uint8(v >> 16), uint8(v >> 8), uint8(v), true -} - -func rgbToHSL(r, g, b uint8) (h, s, l float64) { - rf, gf, bf := float64(r)/255, float64(g)/255, float64(b)/255 - mx, mn := max(rf, gf, bf), min(rf, gf, bf) - l = (mx + mn) / 2 - if mx == mn { - return 0, 0, l - } - d := mx - mn - s = d / (1 - math.Abs(2*l-1)) - switch mx { - case rf: - h = math.Mod((gf-bf)/d, 6) - case gf: - h = (bf-rf)/d + 2 - default: - h = (rf-gf)/d + 4 - } - h *= 60 - if h < 0 { - h += 360 - } - return h, s, l -} - -func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { - h = math.Mod(math.Mod(h, 360)+360, 360) - c := (1 - math.Abs(2*l-1)) * s - hp := h / 60 - x := c * (1 - math.Abs(math.Mod(hp, 2)-1)) - var r, g, b float64 - switch int(hp) { - case 0: - r, g, b = c, x, 0 - case 1: - r, g, b = x, c, 0 - case 2: - r, g, b = 0, c, x - case 3: - r, g, b = 0, x, c - case 4: - r, g, b = x, 0, c - default: - r, g, b = c, 0, x - } - m := l - c/2 - return to8(r + m), to8(g + m), to8(b + m) -} - -func clamp01(v float64) float64 { return min(max(v, 0), 1) } - -func to8(v float64) uint8 { return uint8(math.Round(clamp01(v) * 255)) } diff --git a/core/artwork/blurhash/synthetic_test.go b/core/artwork/blurhash/synthetic_test.go deleted file mode 100644 index 671c1cd30..000000000 --- a/core/artwork/blurhash/synthetic_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package blurhash_test - -import ( - "fmt" - "strings" - - "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/zeebo/xxh3" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Synthetic", func() { - It("returns a well-formed 3x3 blurhash", func() { - h := blurhash.Synthetic("alb-1", "") - // 3x3 encodes as (3-1)+(3-1)*9 = 20, which is base83 'K'; length is 1+1+4+8*2. - Expect(h).To(HaveLen(22)) - Expect(h).To(HavePrefix("K")) - for _, c := range h { - Expect(strings.ContainsRune(alphabet, c)).To(BeTrue(), "unexpected char %q", c) - } - }) - - It("keeps its prefix exclusive to real encodes, across aspect ratios", func() { - Expect(blurhash.Synthetic("alb-1", "")).To(HavePrefix("K")) - - ratios := []struct{ w, h int }{ - {10, 200}, {200, 10}, {1, 50}, {50, 1}, {64, 64}, {100, 300}, {300, 100}, - } - for _, r := range ratios { - encoded, err := blurhash.Encode(gradientImage(r.w, r.h)) - Expect(err).ToNot(HaveOccurred()) - Expect(encoded).ToNot(HavePrefix("K"), "real encode at %dx%d produced the synthetic prefix", r.w, r.h) - } - }) - - It("is deterministic for one seed", func() { - Expect(blurhash.Synthetic("alb-1", "")).To(Equal(blurhash.Synthetic("alb-1", ""))) - }) - - It("differs across seeds", func() { - Expect(blurhash.Synthetic("alb-1", "")).ToNot(Equal(blurhash.Synthetic("alb-2", ""))) - }) - - dcOf := func(hash string) (int, int, int) { - dc := decode83(hash[2:6]) - return dc >> 16 & 0xFF, dc >> 8 & 0xFF, dc & 0xFF - } - - It("decodes to a muted DC colour", func() { - r, g, b := dcOf(blurhash.Synthetic("alb-1", "")) - spread := max(r, g, b) - min(r, g, b) - Expect(spread).To(BeNumerically("<", 120), "saturation is capped, so no channel should run away") - Expect(max(r, g, b)).To(BeNumerically("<", 240), "lightness is capped below white") - Expect(min(r, g, b)).To(BeNumerically(">", 10), "lightness is capped above black") - }) - - It("keeps 1,000,000 seeds effectively distinct", func() { - const count = 1_000_000 - seen := make(map[uint64]struct{}, count) - for i := range count { - seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("item-%d", i), ""))] = struct{}{} - } - Expect(len(seen)).To(BeNumerically(">=", 999_900)) - }) - - It("leans the DC towards a red tint", func() { - r, g, b := dcOf(blurhash.Synthetic("alb-1", "#c04040")) - Expect(r).To(BeNumerically(">", g)) - Expect(r).To(BeNumerically(">", b)) - }) - - It("leans the DC towards a blue tint", func() { - r, g, b := dcOf(blurhash.Synthetic("alb-1", "#4040c0")) - Expect(b).To(BeNumerically(">", r)) - Expect(b).To(BeNumerically(">", g)) - }) - - It("changes the value when only the tint changes", func() { - Expect(blurhash.Synthetic("alb-1", "#c04040")).ToNot(Equal(blurhash.Synthetic("alb-1", "#4040c0"))) - }) - - It("falls back to the seed hue for an unparseable tint", func() { - Expect(blurhash.Synthetic("alb-1", "not-a-colour")).To(Equal(blurhash.Synthetic("alb-1", ""))) - }) - - It("tracks a dark tint's lightness", func() { - dark, _, _ := dcOf(blurhash.Synthetic("alb-1", "#101820")) - light, _, _ := dcOf(blurhash.Synthetic("alb-1", "#e8f0f8")) - Expect(dark).To(BeNumerically("<", light)) - }) - - // A shared tint must not become a shared value: the seed alone carries uniqueness. - It("keeps 1,000,000 seeds distinct under a single fixed tint", func() { - const count = 1_000_000 - seen := make(map[uint64]struct{}, count) - for i := range count { - seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("track-%d", i), "#3a5f7d"))] = struct{}{} - } - Expect(len(seen)).To(BeNumerically(">=", 999_900)) - }) -}) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index b07aaee8d..13ec79aa8 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -180,14 +180,6 @@ warmer uses — so user-scoped items like private playlists still resolve their falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to their Navidrome `ArtworkID`. -Blurhashes come in three tiers. A real blurhash is computed once in -`core/artwork` from the decoded image and stored per artwork row; the mappers read it whenever it -exists. While artwork is still unresolved, `dto/mappers.go` instead emits a synthetic 3x3 blurhash -(`core/artwork/blurhash.Synthetic`), seeded on the image tag so it's effectively unique per image -and can never collide with a real hash's leading byte, giving clients a valid cache key while art -loads. A track awaiting embedded-art extraction has its synthetic hash tinted from the parent -album's dominant colour. Known-absent artwork emits no tag and no blurhash at all. - ## Finamp saved-queue id truncation Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that @@ -338,6 +330,16 @@ make test PKG=./server/jellyfin/... Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client that already has an artist id from elsewhere is not re-checked against library membership. +- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is + populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** + blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a + multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and + stores it per image, so its placeholder approximates the art. Ours satisfies the protocol + (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash + warning) but renders as a flat color while art loads. A proper implementation would compute the + real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it + keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback + for art that hasn't been rendered yet. - **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index d2bdb1b2e..31cdba1dd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,7 +6,6 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -213,8 +212,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { // Nothing enqueues media files: advertising the id is what makes a client ask, and that // request is what extracts the embedded art and queues the track. item.ImageTags = map[string]string{"Primary": mf.ID} - hash := blurhash.Synthetic(mf.ID, mf.AlbumImage.DominantColor) - item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.ID: hash}} } else if mf.AlbumID != "" { if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { item.AlbumPrimaryImageTag = tag @@ -230,19 +227,15 @@ func embeddedArtPending(mf model.MediaFile) bool { mf.ImageHash == "" && !mf.ItemImage.ImageAbsent } -// primaryImage synthesizes a blurhash when none was computed yet: clients key their cover -// cache on the value, so it must be unique per image, never shared or reused. +// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would +// pin a stale cover forever. func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { if img.ImageAbsent { return "", nil, nil } tag = cmp.Or(img.ImageHash, fallback) - if tag != "" { - hash := img.BlurHash - if hash == "" { - hash = blurhash.Synthetic(tag, "") - } - blurs = map[string]map[string]string{"Primary": {tag: hash}} + if img.BlurHash != "" { + blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} } if fields.Has("PrimaryImageAspectRatio") { ratio = img.AspectRatio() diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 451e1e08b..77b78e55f 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -38,7 +37,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -299,7 +298,7 @@ var _ = Describe("mappers", func() { Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -473,7 +472,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("pl-1", blurhash.Synthetic("pl-1", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("changes the playlist image tag when the cover content changes", func() { @@ -508,37 +507,13 @@ var _ = Describe("mappers", func() { It("advertises the track id so the client triggers the read-through", func() { mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" - mf.AlbumImage.DominantColor = "#3a5f7d" item := SongToBaseItem(mf, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) - Expect(item.ImageBlurHashes["Primary"]).To( - HaveKeyWithValue("mf-1", blurhash.Synthetic("mf-1", "#3a5f7d"))) + Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) }) - // The client's image request is what extracts the embedded art. Reusing the album's - // value would let it answer from cache and never send it. - It("gives the track a value distinct from its album's", func() { - mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} - mf.AlbumImage.ImageHash = "0123456789abcdef" - mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" - mf.AlbumImage.DominantColor = "#3a5f7d" - - track := SongToBaseItem(mf, nil).ImageBlurHashes["Primary"]["mf-3"] - album := AlbumToBaseItem(model.Album{ID: "alb-1", ItemImage: mf.AlbumImage}, nil). - ImageBlurHashes["Primary"]["0123456789abcdef"] - Expect(track).ToNot(BeEmpty()) - Expect(track).ToNot(Equal(album)) - }) - - It("still emits a value when the album has no dominant colour", func() { - mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} - - item := SongToBaseItem(mf, nil) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("mf-4", blurhash.Synthetic("mf-4", ""))) - }) - It("falls back to the album when the track has no art of its own", func() { mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} mf.AlbumImage.ImageHash = "0123456789abcdef" @@ -580,14 +555,13 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("synthesizes a blurhash when none was computed", func() { + It("omits the blurhash entirely when none was computed", func() { al := model.Album{ID: "alb-2", Name: "Album"} al.ImageHash = "0123456789abcdef" item := AlbumToBaseItem(al, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To( - HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) + Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") }) It("omits tags for known-absent artwork", func() { @@ -599,19 +573,10 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) - It("keeps known-absent artwork free of any synthesized value", func() { - al := model.Album{ID: "alb-5", Name: "Album"} - al.ImageAbsent = true - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageBlurHashes).To(BeNil(), - "there is no image to key a cache on, and nothing will ever re-key it") - }) - It("falls back to the entity id while artwork is still unresolved", func() { item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-4", blurhash.Synthetic("alb-4", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("versions an artist's tag by content hash", func() { @@ -636,14 +601,13 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("synthesizes a song's album blurhash when the album has none", func() { + It("never synthesizes a song blurhash when the album has none", func() { mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To( - HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("omits a song's album tag when the album art is known absent", func() { From 752b38609c1599e15dc4fb3eef55fbb6f340f516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 11:59:56 -0400 Subject: [PATCH 427/596] feat(artist): add Share and Download actions to the Artist detail page (#5944) * feat(ui): add Share button to artist detail page * feat(ui): add Download button to artist detail page * fix(ui): scope artist share/download to album-artist content Gate the artist Share/Download actions on album-artist stats and show the album-artist size, since ZipArtist and the share query only cover album_artist_id songs. Previously the total (role-inclusive) size was shown and guest-only artists could produce an empty archive. Applies to the artist toolbar, the shared context menu, and the download dialog title. * fix: match artist download/share to album-artist participation ZipArtist and the artist share query filtered the deprecated album_artist_id column, which only stores the first album artist of a track. Secondary album-artists (co-credited but not first) got an empty download/share even though the UI offered it. Filter by the album-artist role participation instead, matching the artist's album-artist stats used to gate the actions. Also cover the artist-specific size branch of the download dialog. * fix(share): scope artist shares to the owner's libraries The artist share query broadened to album-artist participation, which could pull a secondary album artist's tracks from libraries the (non-admin) share owner cannot access into the public share. Load the artist share as the owner so their library access is applied, mirroring how playlist shares already work. Adds a repository test covering co-album-artist inclusion and library scoping. * test(share): assert album participation branch of artist shares Link the co-album-artist fixtures to albums and assert share.Albums (used by Subsonic getShares) includes the accessible album and excludes the one in a library the owner cannot access, so the album participation + scoping branch is covered too. * fix: exclude missing files from artist download/share actions An artist's stats still count files that went missing, so the toolbar/context menu could offer Download/Share for an artist whose files are all gone, while the share query (missing=false) returns nothing and downloads open dead paths. Hide the actions when the artist is missing and exclude missing files from ZipArtist, matching the share semantics. * refactor: dedupe artist download-size and share-owner lookups Extract the 'album-artist download size (or none when missing)' rule into a single artistDownloadSize() helper shared by the toolbar, context menu, and download dialog, and factor the duplicated share-owner context lookup into a shareRepository.ownerContext() method used by both the artist and playlist share cases. * refactor(ui): move artistDownloadSize helper to common utils is for domain-agnostic, potentially portable code; this helper is Navidrome-specific (artist stats shape), so it belongs in common. Consumers import it directly from common/artist to avoid pulling in the common barrel. --- core/archiver.go | 9 ++- core/archiver_test.go | 8 ++- persistence/share_repository.go | 36 +++++++--- persistence/share_repository_test.go | 83 ++++++++++++++++++++++ ui/src/artist/ArtistActions.jsx | 41 +++++++++++ ui/src/artist/ArtistActions.test.jsx | 65 ++++++++++++++++- ui/src/common/ContextMenus.jsx | 10 ++- ui/src/common/ContextMenus.test.jsx | 78 ++++++++++++++++++++ ui/src/common/artist.js | 6 ++ ui/src/common/artist.test.js | 24 +++++++ ui/src/common/index.js | 1 + ui/src/dialogs/DownloadMenuDialog.jsx | 11 ++- ui/src/dialogs/DownloadMenuDialog.test.jsx | 58 +++++++++++++++ 13 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 ui/src/common/ContextMenus.test.jsx create mode 100644 ui/src/common/artist.js create mode 100644 ui/src/common/artist.test.js create mode 100644 ui/src/dialogs/DownloadMenuDialog.test.jsx diff --git a/core/archiver.go b/core/archiver.go index 8c42f8f49..c9436279e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) @@ -40,7 +41,13 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitra } func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id}) + // Match by album-artist participation, not the deprecated album_artist_id + // column (first album artist only), so co-album-artists are included too. + filter := squirrel.And{ + persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + } + return a.zipAlbums(ctx, id, format, bitrate, out, filter) } func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error { diff --git a/core/archiver_test.go b/core/archiver_test.go index 2ba8f1fc0..461af1800 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -69,8 +70,11 @@ var _ = Describe("Archiver", func() { mfRepo := &mockMediaFileRepository{} mfRepo.On("GetAll", []model.QueryOptions{{ - Filters: squirrel.Eq{"album_artist_id": "1"}, - Sort: "album", + Filters: squirrel.And{ + persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + }, + Sort: "album", }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 0013e782b..4b6dc9240 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -82,13 +82,20 @@ func (r *shareRepository) loadMedia(share *model.Share) error { } switch share.ResourceType { case "artist": - albumRepo := NewAlbumRepository(r.ctx, r.db) - share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) + // Match by album-artist participation, not the deprecated album_artist_id + // column (first album artist only), so co-album-artists are included too. + // Load as the share owner so their library access is applied. + ctx, err := r.ownerContext(share) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.ctx, r.db) - share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) + albumRepo := NewAlbumRepository(ctx, r.db) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) + if err != nil { + return err + } + mfRepo := NewMediaFileRepository(ctx, r.db) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) return err case "album": albumRepo := NewAlbumRepository(r.ctx, r.db) @@ -101,14 +108,10 @@ func (r *shareRepository) loadMedia(share *model.Share) error { return err case "playlist": // Load tracks as the share owner so their library access is applied. - owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + ctx, err := r.ownerContext(share) if err != nil { - return fmt.Errorf("loading share owner %q: %w", share.UserID, err) + return err } - if owner == nil { - return fmt.Errorf("share owner %q not found", share.UserID) - } - ctx := request.WithUser(r.ctx, *owner) plsRepo := NewPlaylistRepository(ctx, r.db) // Tracks returns nil when the playlist is no longer visible to the owner // (e.g. it was made private after the share was created); leave the share @@ -133,6 +136,19 @@ func (r *shareRepository) loadMedia(share *model.Share) error { return nil } +// ownerContext returns a context scoped to the share owner, so repository +// queries apply the owner's library access when a public share is rendered. +func (r *shareRepository) ownerContext(share *model.Share) (context.Context, error) { + owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + if err != nil { + return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err) + } + if owner == nil { + return nil, fmt.Errorf("share owner %q not found", share.UserID) + } + return request.WithUser(r.ctx, *owner), nil +} + func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { m := map[string]int{} for i, mf := range mfs { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 3ae456031..3af91b2af 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -228,6 +228,89 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Artist share library scoping", func() { + var otherLib model.Library + var owner model.User + const primaryID = "share-aa-primary" + const secondaryID = "share-aa-secondary" + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + + // A second library the owner has no access to + lr := NewLibraryRepository(adminCtx, b) + otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"} + Expect(lr.Put(&otherLib)).To(Succeed()) + + ar := NewArtistRepository(adminCtx, b) + Expect(createArtistWithLibrary(ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed()) + Expect(createArtistWithLibrary(ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed()) + + // Secondary is a co-album-artist (not the first): album_artist_id points at + // primary, so the legacy-column filter would miss both tracks. + aaParticipants := model.Participants{model.RoleAlbumArtist: { + {Artist: model.Artist{ID: primaryID, Name: "AA Primary"}}, + {Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}}, + }} + alr := NewAlbumRepository(adminCtx, b) + Expect(alr.Put(&model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) + Expect(alr.Put(&model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) + + mr := NewMediaFileRepository(adminCtx, b) + Expect(mr.Put(&model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) + + // Non-admin owner with access to library 1 only + owner = createUserWithLibraries("artist-share-owner", []int{1}) + ur := NewUserRepository(adminCtx, b) + Expect(ur.Put(&owner)).To(Succeed()) + Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + + _, err := b.NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "art-share", "user": owner.ID, "desc": "Artist scope share", + "type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute() + mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) + _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) + alr := NewAlbumRepository(adminCtx, b).(*albumRepository) + _, _ = alr.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}})) + ar := NewArtistRepository(adminCtx, b).(*artistRepository) + _, _ = ar.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}})) + lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + }) + + It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() { + // Read as admin (mimics the public-share render path); loadMedia must still + // scope to the owner's libraries. + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + share, err := adminRepo.Get("art-share") + Expect(err).ToNot(HaveOccurred()) + + Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")), + "a co-album-artist track (not matched by album_artist_id) must be included") + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")), + "a track outside the owner's libraries must not appear in the share") + + Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")), + "a co-album-artist album must be included") + Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")), + "an album outside the owner's libraries must not appear in the share") + }) + }) + Describe("Ownership Checks", func() { var ownerUser = model.User{ID: "2222", UserName: "regular-user"} var otherUser = model.User{ID: "3333", UserName: "third-user"} diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 0b48f232d..2f329a034 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -13,9 +13,19 @@ import { } from 'react-admin' import ShuffleIcon from '@material-ui/icons/Shuffle' import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import ShareIcon from '@material-ui/icons/Share' +import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' import { IoIosRadio } from 'react-icons/io' import { playShuffle, playTopSongs } from './actions.js' import { playSimilar } from '../common/playbackActions.js' +import { + openShareMenu, + openDownloadMenu, + DOWNLOAD_MENU_ARTIST, +} from '../actions' +import config from '../config' +import { formatBytes } from '../utils' +import { artistDownloadSize } from '../common/artist' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -62,6 +72,9 @@ const ArtistActions = ({ className, record, ...rest }) => { const [loadingAction, setLoadingAction] = React.useState(null) const isLoading = !!loadingAction + const albumArtistSize = artistDownloadSize(record) + const hasAlbumArtistContent = Boolean(albumArtistSize) + const handlePlay = React.useCallback(async () => { setLoadingAction('play') try { @@ -101,6 +114,14 @@ const ArtistActions = ({ className, record, ...rest }) => { } }, [dispatch, notify, record]) + const handleShare = React.useCallback(() => { + dispatch(openShareMenu([record.id], 'artist', record.name)) + }, [dispatch, record]) + + const handleDownload = React.useCallback(() => { + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ARTIST)) + }, [dispatch, record]) + return ( <TopToolbar className={`${className} ${classes.toolbar}`} @@ -133,6 +154,26 @@ const ArtistActions = ({ className, record, ...rest }) => { loading={loadingAction === 'radio'} icon={<IoIosRadio className={classes.radioIcon} />} /> + {config.enableSharing && hasAlbumArtistContent && ( + <LoadingButton + onClick={handleShare} + label={translate('ra.action.share')} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + icon={<ShareIcon />} + /> + )} + {config.enableDownloads && hasAlbumArtistContent && ( + <LoadingButton + onClick={handleDownload} + label={`${translate('ra.action.download')} (${formatBytes( + albumArtistSize, + )})`} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + icon={<CloudDownloadOutlinedIcon />} + /> + )} </TopToolbar> ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index a11ee50e3..ad25177b3 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -4,6 +4,11 @@ import { TestContext } from 'ra-test' import { describe, it, expect, vi, beforeEach } from 'vitest' import ArtistActions from './ArtistActions' import subsonic from '../subsonic' +import { + openShareMenu, + openDownloadMenu, + DOWNLOAD_MENU_ARTIST, +} from '../actions' import { ThemeProvider, createTheme } from '@material-ui/core/styles' const mockDispatch = vi.fn() @@ -13,6 +18,11 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { enableSharing: true, enableDownloads: true }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) @@ -27,7 +37,11 @@ vi.mock('react-admin', async (importOriginal) => { }) describe('ArtistActions', () => { - const defaultRecord = { id: 'ar1', name: 'Artist' } + const defaultRecord = { + id: 'ar1', + name: 'Artist', + stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, + } const renderArtistActions = (record = defaultRecord) => { const theme = createTheme() @@ -48,6 +62,8 @@ describe('ArtistActions', () => { vi.clearAllMocks() // Mock console.error to suppress error logging in tests vi.spyOn(console, 'error').mockImplementation(() => {}) + mockConfig.enableSharing = true + mockConfig.enableDownloads = true const songWithReplayGain = { id: 'rec1', @@ -227,4 +243,51 @@ describe('ArtistActions', () => { expect(mockDispatch).not.toHaveBeenCalled() }) }) + + describe('Share action', () => { + it('shows the share button and dispatches openShareMenu when clicked', () => { + renderArtistActions() + fireEvent.click(screen.getByText('ra.action.share')) + expect(mockDispatch).toHaveBeenCalledWith( + openShareMenu(['ar1'], 'artist', 'Artist'), + ) + }) + + it('hides the share button when sharing is disabled', () => { + mockConfig.enableSharing = false + renderArtistActions() + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + }) + }) + + describe('Download action', () => { + it('shows the download button with album-artist size and dispatches openDownloadMenu when clicked', () => { + renderArtistActions() + expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() + fireEvent.click(screen.getByText(/ra\.action\.download/)) + expect(mockDispatch).toHaveBeenCalledWith( + openDownloadMenu(defaultRecord, DOWNLOAD_MENU_ARTIST), + ) + }) + + it('hides the download button when downloads are disabled', () => { + mockConfig.enableDownloads = false + renderArtistActions() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + }) + + describe('Album-artist gating', () => { + it('hides Share and Download for artists with no album-artist content', () => { + renderArtistActions({ id: 'ar1', name: 'Artist', stats: {} }) + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + + it('hides Share and Download for a missing artist', () => { + renderArtistActions({ ...defaultRecord, missing: true }) + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 47c9c6786..7ad8c735c 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -24,6 +24,7 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' +import { artistDownloadSize } from './artist' const useStyles = makeStyles({ noWrap: { @@ -70,6 +71,9 @@ const ContextMenu = ({ const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) + const isArtist = resource === 'artist' + const downloadSize = isArtist ? artistDownloadSize(record) : record?.size + const options = { play: { enabled: true, @@ -103,7 +107,7 @@ const ContextMenu = ({ }, ...(!hideShare && { share: { - enabled: config.enableSharing, + enabled: config.enableSharing && (!isArtist || downloadSize), needData: false, label: translate('ra.action.share'), action: (record) => @@ -111,9 +115,9 @@ const ContextMenu = ({ }, }), download: { - enabled: config.enableDownloads && record.size, + enabled: config.enableDownloads && downloadSize, needData: false, - label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, + label: `${translate('ra.action.download')} (${formatBytes(downloadSize)})`, action: () => { dispatch( openDownloadMenu( diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx new file mode 100644 index 000000000..72a98b64b --- /dev/null +++ b/ui/src/common/ContextMenus.test.jsx @@ -0,0 +1,78 @@ +import React from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus' + +const mockDispatch = vi.fn() +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: true, + enableDownloads: true, + enableFavourites: false, + }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ getList: vi.fn() }), + useTranslate: () => (x) => x, + } +}) + +describe('ContextMenus', () => { + const renderMenu = (Menu, record) => { + render( + <TestContext> + <ThemeProvider theme={createTheme()}> + <Menu record={record} /> + </ThemeProvider> + </TestContext>, + ) + fireEvent.click(screen.getByLabelText('more')) + } + + beforeEach(() => { + vi.clearAllMocks() + mockConfig.enableSharing = true + mockConfig.enableDownloads = true + }) + + describe('ArtistContextMenu', () => { + const withAlbumArtist = { + id: 'ar1', + name: 'Artist', + stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, + } + + it('shows the album-artist size on the download item', () => { + renderMenu(ArtistContextMenu, withAlbumArtist) + expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() + }) + + it('hides download and share for artists with no album-artist content', () => { + renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + }) + }) + + describe('AlbumContextMenu', () => { + it('uses the total size on the album download item', () => { + renderMenu(AlbumContextMenu, { + id: 'al1', + name: 'Album', + duration: 100, + size: 1024 * 1024, + }) + expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() + }) + }) +}) diff --git a/ui/src/common/artist.js b/ui/src/common/artist.js new file mode 100644 index 000000000..370700160 --- /dev/null +++ b/ui/src/common/artist.js @@ -0,0 +1,6 @@ +// Size of an artist's downloadable album-artist content, or undefined when there +// is nothing to download (a missing artist, or no album-artist songs). Download +// and Share only cover album-artist songs, so callers gate on this, not the +// role-inclusive total. +export const artistDownloadSize = (record) => + record?.missing ? undefined : record?.stats?.albumartist?.size diff --git a/ui/src/common/artist.test.js b/ui/src/common/artist.test.js new file mode 100644 index 000000000..eff9935a6 --- /dev/null +++ b/ui/src/common/artist.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { artistDownloadSize } from './artist' + +describe('artistDownloadSize', () => { + it('returns the album-artist size', () => { + expect( + artistDownloadSize({ stats: { albumartist: { size: 1024 } } }), + ).toEqual(1024) + }) + + it('returns undefined for a missing artist', () => { + expect( + artistDownloadSize({ + missing: true, + stats: { albumartist: { size: 1024 } }, + }), + ).toBeUndefined() + }) + + it('returns undefined when there is no album-artist content', () => { + expect(artistDownloadSize({ stats: {} })).toBeUndefined() + expect(artistDownloadSize(undefined)).toBeUndefined() + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 7b5b376f3..047f5b3cf 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -1,4 +1,5 @@ export * from './AddToPlaylistButton' +export * from './artist' export * from './ArtistLinkField' export * from './BatchPlayButton' export * from './BitrateField' diff --git a/ui/src/dialogs/DownloadMenuDialog.jsx b/ui/src/dialogs/DownloadMenuDialog.jsx index 2104cbcad..61e84a08a 100644 --- a/ui/src/dialogs/DownloadMenuDialog.jsx +++ b/ui/src/dialogs/DownloadMenuDialog.jsx @@ -8,8 +8,9 @@ import { DialogTitle, } from '@material-ui/core' import subsonic from '../subsonic' -import { closeDownloadMenu } from '../actions' +import { closeDownloadMenu, DOWNLOAD_MENU_ARTIST } from '../actions' import { formatBytes } from '../utils' +import { artistDownloadSize } from '../common/artist' import { useTranscodingOptions } from './useTranscodingOptions' const DownloadMenuDialog = () => { @@ -22,6 +23,12 @@ const DownloadMenuDialog = () => { const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = useTranscodingOptions() + // Artist downloads only include album-artist songs, so show that size + const downloadSize = + recordType === DOWNLOAD_MENU_ARTIST + ? artistDownloadSize(record) + : record?.size + const handleClose = (e) => { dispatch(closeDownloadMenu()) e.stopPropagation() @@ -55,7 +62,7 @@ const DownloadMenuDialog = () => { smart_count: 1, }).toLocaleLowerCase(), name: record?.name || record?.title, - size: formatBytes(record?.size), + size: formatBytes(downloadSize), })} </DialogTitle> <DialogContent> diff --git a/ui/src/dialogs/DownloadMenuDialog.test.jsx b/ui/src/dialogs/DownloadMenuDialog.test.jsx new file mode 100644 index 000000000..b9ce7f077 --- /dev/null +++ b/ui/src/dialogs/DownloadMenuDialog.test.jsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { render, screen, cleanup } from '@testing-library/react' +import { describe, afterEach, it, expect, vi } from 'vitest' +import DownloadMenuDialog from './DownloadMenuDialog' +import { DOWNLOAD_MENU_ALBUM, DOWNLOAD_MENU_ARTIST } from '../actions' + +vi.mock('./useTranscodingOptions', () => ({ + useTranscodingOptions: () => ({ + TranscodingOptionsInput: () => null, + format: '', + maxBitRate: 0, + originalFormat: true, + }), +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (key, opts) => + opts?.size ? `${key}:${opts.name}:${opts.size}` : key, + } +}) + +const renderDialog = (record, recordType) => + render( + <TestContext + initialState={{ downloadMenuDialog: { open: true, record, recordType } }} + > + <DownloadMenuDialog /> + </TestContext>, + ) + +describe('DownloadMenuDialog', () => { + afterEach(cleanup) + + it('shows the album-artist size (not the total) for an artist download', () => { + renderDialog( + { + id: 'ar1', + name: 'Artist', + size: 999999999, + stats: { albumartist: { size: 1024 } }, + }, + DOWNLOAD_MENU_ARTIST, + ) + expect(screen.getByText(/:Artist:1 KB$/)).toBeInTheDocument() + }) + + it('shows the total size for an album download', () => { + renderDialog( + { id: 'al1', name: 'Album', size: 1024 * 1024 }, + DOWNLOAD_MENU_ALBUM, + ) + expect(screen.getByText(/:Album:1 MB$/)).toBeInTheDocument() + }) +}) From 5a4a3099f1a65e31c9c1201674067987ae9fe990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 14:55:04 -0400 Subject: [PATCH 428/596] fix(cli): accept absolute paths in selective scan --target (#5947) * fix(scanner): accept absolute paths in selective scan --target The scanner's fs.FS only accepts paths relative to the library root, so an absolute --target path (e.g. 2:/jukebox/collection) failed with an opaque "invalid argument" error. Rebase absolute targets onto the library root before scanning; relative paths are unchanged. Fixes #5943 * refactor(scanner): simplify libraryRelativePath with IsLocal and slice.ToMap * fix(scanner): make libraryRelativePath cross-platform Windows CI failed: the tests hardcoded Unix-style absolute paths, which are not absolute on Windows, and filepath.Rel yields backslash-separated paths that the io/fs-based scanner FS rejects. Build the test paths with filepath.Abs so they are absolute on every OS, and normalize the rebased result with filepath.ToSlash. * fix(scanner): resolve relative library root before rebasing target filepath.Rel cannot rebase an absolute target onto a relative library root (e.g. the default MusicFolder=./music), so an absolute --target was left unchanged and rejected by the rooted io/fs. Make the library root absolute first; it resolves against the same cwd as the scanner's fs. --- model/scanner.go | 2 +- scanner/scanner.go | 28 ++++++++++++++++++++++- scanner/scanner_internal_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/model/scanner.go b/model/scanner.go index 54f81037c..36c9007fb 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -12,7 +12,7 @@ import ( // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { LibraryID int - FolderPath string // Relative path within the library, or "" for entire library + FolderPath string // Path within the library (relative or absolute), or "" for entire library } func (st ScanTarget) String() string { diff --git a/scanner/scanner.go b/scanner/scanner.go index 29a1f9b4e..d73007bdd 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "maps" + "path/filepath" "slices" "sync/atomic" "time" @@ -51,6 +52,27 @@ func (s *scanState) sendError(err error) { s.sendProgress(&ProgressInfo{Error: err.Error()}) } +// libraryRelativePath rebases an absolute scan target path onto the library root, since the +// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside +// the library root, are returned unchanged. +func libraryRelativePath(libPath, folderPath string) string { + if !filepath.IsAbs(folderPath) { + return folderPath + } + // The library root may be relative (e.g. the default "./music"); it must be made absolute + // to match against an absolute target, and it resolves against the same cwd as the scanner's fs. + absLib, err := filepath.Abs(libPath) + if err != nil { + return folderPath + } + rel, err := filepath.Rel(absLib, folderPath) + if err != nil || !filepath.IsLocal(rel) { + return folderPath + } + // The scanner's fs.FS is an io/fs, which always uses forward slashes. + return filepath.ToSlash(rel) +} + func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { startTime := time.Now() @@ -77,8 +99,12 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Selective scan: filter libraries and build targets map state.targets = make(map[int][]string) + libPaths := slice.ToMap(allLibs, func(lib model.Library) (int, string) { + return lib.ID, lib.Path + }) + for _, target := range targets { - folderPath := target.FolderPath + folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath) if folderPath == "" { folderPath = "." } diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index e8abb7c7d..0778bd6ec 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -4,6 +4,8 @@ package scanner import ( "context" "errors" + "os" + "path/filepath" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -11,6 +13,43 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("libraryRelativePath", func() { + // Paths are built with filepath so the "absolute" cases stay absolute on every OS + // (a Unix-style "/foo" is not absolute on Windows). + libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) + outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) + + It("returns a relative path unchanged", func() { + Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) + }) + + It("rebases an absolute target when the library root is relative", func() { + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) + }) + + It("rebases an absolute path that equals the library root to '.'", func() { + Expect(libraryRelativePath(libRoot, libRoot)).To(Equal(".")) + }) + + It("rebases an absolute path under the library root", func() { + Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("handles a trailing slash on the library path", func() { + Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("leaves an absolute path outside the library root unchanged", func() { + Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside)) + }) + + It("returns an empty path unchanged", func() { + Expect(libraryRelativePath(libRoot, "")).To(Equal("")) + }) +}) + type mockPhase struct { num int produceFunc func() ppl.Producer[int] From c66ef04dd3111712a44d60d5895d8b77216baba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 19:02:34 -0400 Subject: [PATCH 429/596] refactor(jellyfin): emit real 128-bit GUIDs as item ids (#5942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(jellyfin): use canonical ids in dto fixtures Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from id.NewHash keeps the labels readable while exercising the real id shape. * test(jellyfin): use canonical ids in handler fixtures Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions stay decimal, matching the integer playlist_tracks.id column. * test(jellyfin): use canonical ids in e2e fixtures Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from id.NewHash keeps the labels readable while exercising the real id shape. * test(jellyfin): use canonical ids in audiomuse fixtures audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as the rest of the fixtures: fake labels through id.NewHash via testID. * test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from "resolves to nothing" to "empty path segment". * refactor(jellyfin): emit real 128-bit GUIDs as item ids Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing 44 hex chars where Jellyfin uses 32. Integer library ids, the synthetic playlists folder, and playlist entry positions (a playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of passing through unchanged. BREAKING: Jellyfin clients see entirely new item ids. * fix(jellyfin): 404 malformed playlist ids instead of silently creating updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A malformed id now 404s before reaching Create. Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which now decodes to "" and only passed because the fakes ignore the id argument; and a batch of not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space. * refactor(jellyfin): drop the id truncation workaround Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the server-side prefix recovery is dead code. Removes an indexed range scan per restored queue and the ambiguous-prefix path that could resolve to the wrong item. * fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A ServerId persisted before this change is normalized on read rather than rewritten. PlaySessionId was emitting a raw internal id instead of the encoded form. BREAKING: the ServerId change makes clients treat the server as new, so users re-login once. * fix(jellyfin): 404 on undecodable id filters instead of widening the query DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead of a scoped result. Every existing client hits this on first launch after the id format changes. Scalar id params now distinguish the two cases and report not-found. List-valued params already failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not happen post-migration and would otherwise ship an unaddressable item silently. * test(jellyfin): drop comments that restate the spec names * refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the hex string and parsing it a second time. Reading them off the byte slice matches how the format is specified and removes the duplicate parse. Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only EncodePlaylistEntryID enforced before. Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a playlist_tracks position. Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit once per item per request on exactly the bad-data population it was meant to surface. * refactor(jellyfin): make DecodeID report whether the id was decodable DecodeID returned the empty string for both an absent param and an undecodable one, and downstream an empty id means no filter. That conflation is what let a stale id widen /Items to the whole library; it had been patched at two call sites, leaving three different policies for an undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail. Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the ~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its len() > 0 filter gate vanish — the original bug by another route. A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed ids now also 404 on the image and similar/instant-mix routes, which previously answered with a placeholder or an empty list. --- server/jellyfin/README.md | 44 ++- server/jellyfin/annotations.go | 16 +- server/jellyfin/annotations_test.go | 146 +++++----- server/jellyfin/api_test.go | 4 +- server/jellyfin/audiomuse.go | 20 +- server/jellyfin/audiomuse_test.go | 70 +++-- server/jellyfin/auth_test.go | 6 +- server/jellyfin/browsing.go | 25 +- server/jellyfin/browsing_test.go | 49 +++- server/jellyfin/dto/dto_suite_test.go | 5 + server/jellyfin/dto/ids.go | 146 +++++++++- server/jellyfin/dto/ids_test.go | 162 +++++++++-- server/jellyfin/dto/mappers.go | 5 +- server/jellyfin/dto/mappers_test.go | 204 ++++++------- server/jellyfin/e2e/annotations_test.go | 4 +- server/jellyfin/e2e/audiomuse_test.go | 4 +- server/jellyfin/e2e/auth_test.go | 8 +- server/jellyfin/e2e/browsing_test.go | 132 ++++++--- server/jellyfin/e2e/e2e_suite_test.go | 13 +- server/jellyfin/e2e/lyrics_test.go | 2 +- server/jellyfin/e2e/multiuser_test.go | 2 +- server/jellyfin/e2e/playlists_test.go | 54 +++- server/jellyfin/e2e/search_test.go | 3 +- server/jellyfin/e2e/similar_test.go | 59 ++-- server/jellyfin/e2e/streaming_test.go | 4 +- server/jellyfin/images.go | 21 +- server/jellyfin/images_test.go | 124 ++++---- server/jellyfin/items.go | 104 ++++--- server/jellyfin/items_test.go | 366 +++++++++++++++--------- server/jellyfin/jellyfin_suite_test.go | 5 + server/jellyfin/library.go | 34 ++- server/jellyfin/lyrics_test.go | 38 +-- server/jellyfin/middlewares_test.go | 6 +- server/jellyfin/playlists.go | 68 +++-- server/jellyfin/playlists_test.go | 171 +++++------ server/jellyfin/sessions.go | 9 +- server/jellyfin/sessions_test.go | 34 ++- server/jellyfin/similar.go | 16 +- server/jellyfin/similar_test.go | 32 +-- server/jellyfin/socket_test.go | 4 +- server/jellyfin/stream.go | 7 +- server/jellyfin/stream_test.go | 100 +++---- server/jellyfin/system.go | 17 +- server/jellyfin/system_test.go | 20 +- server/jellyfin/truncated_ids.go | 113 -------- server/jellyfin/users_test.go | 14 +- 46 files changed, 1549 insertions(+), 941 deletions(-) delete mode 100644 server/jellyfin/truncated_ids.go diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 13ec79aa8..195173c2a 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -85,15 +85,6 @@ player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the p client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is skipped, so it doesn't create a nameless player. -## ID encoding - -Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id -is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients -parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`, -which chokes on Navidrome's base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can -itself be valid hex, correctness depends on every emit path encoding and every receive path -decoding — see `dto/ids.go`. - ## Multi-library behavior Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each @@ -180,26 +171,25 @@ warmer uses — so user-scoped items like private playlists still resolve their falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to their Navidrome `ArtworkID`. -## Finamp saved-queue id truncation +## Item ids are GUIDs -Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that -when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16 -bytes. Navidrome ids are 22-character base62 strings, not 32-hex GUIDs, which means Finamp silently -stores only the first 16 characters of each id and asks for those **truncated ids** back when -restoring the queue — item lookups, then streaming, images, favorites and playback reports for the -restored tracks. +Jellyfin item ids are GUIDs, serialized as 32 lowercase hex chars with no dashes +(`Guid.ToString("N")`). Navidrome ids are canonical 22-char base62 encodings of a 128-bit value, +so `dto.EncodeID`/`dto.DecodeID` map between the two via `model/id` — losslessly except for the +~2⁻⁹⁶ chance an id's 128-bit value falls in the reserved space below (leading 12 bytes all zero). -This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome -id uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; -ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as -requested**, because Finamp matches restored items back to its stored ids, and the other item -endpoints accept truncated ids transparently. +Three emitted ids aren't 128-bit values: integer library ids, the synthetic playlists folder, and +`PlaylistItemId` (a playlist *entry position* — `playlist_tracks.id` is an `integer` column). +They use a reserved GUID space — 12 zero bytes, a non-zero kind tag, a 24-bit payload — so +library `1` is `00000000000000000000000001000001`. The tag is never zero, because Jellyfin +serializes the all-zero GUID as `null`. -**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`) -should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the -queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids, -so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer -can be removed. +`DecodeID` accepts dashed and uppercase GUIDs (Jellyfin's `Guid.Parse` does) and returns +`ok=false` for anything malformed — including "" — which handlers surface as a 404. + +The wire format must stay GUID-shaped for this reason: Finamp's saved-queue persistence bit-packs +each item id into exactly 16 bytes (`packIds()` in `lib/models/finamp_models.dart`), so a 32-hex +GUID round-trips exactly, whereas a longer id would be silently truncated. ## Streaming and transcoding @@ -242,7 +232,7 @@ plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400 with `start_song_id and end_song_id are required.` when either id is missing. -`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients. +`item_id`/`start_song_id`/`end_song_id` are the GUID-form ids Navidrome hands Jellyfin clients. `tempo` comes from the track's BPM when known; the richer AudioMuse per-track features (`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index f2c2f546e..ec84f65e0 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -5,7 +5,6 @@ import ( "math" "net/http" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" @@ -49,7 +48,10 @@ func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id s // fetches this per item to render played/favourite indicators; resolveItemByID enforces the // library-access gate. func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } item, ok := api.resolveItemByID(r.Context(), id, nil) if !ok { http.Error(w, "Not Found", http.StatusNotFound) @@ -64,7 +66,10 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { } func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } repo, resource := api.resolveAnnotated(w, r, id) if repo == nil { return @@ -84,7 +89,10 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { } func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } repo, resource := api.resolveAnnotated(w, r, id) if repo == nil { return diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 9e811a379..dd1487011 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -21,7 +21,7 @@ var _ = Describe("Annotations", func() { var broker *fakeEventBroker // alice has access to library 1 only. ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) } BeforeEach(func() { @@ -33,98 +33,98 @@ var _ = Describe("Annotations", func() { Describe("markFavorite / unmarkFavorite", func() { It("stars a song and returns IsFavorite=true", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.IsFavorite).To(BeTrue()) - Expect(mfRepo.Data["s1"].Starred).To(BeTrue()) + Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue()) }) It("stars an album and returns IsFavorite=true", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.IsFavorite).To(BeTrue()) - Expect(albumRepo.Data["a1"].Starred).To(BeTrue()) + Expect(albumRepo.Data[testID("a1")].Starred).To(BeTrue()) }) It("stars an artist without checking library access (artists span multiple libraries)", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() // alice only has access to library 1, but artists aren't gated per-library. r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "ar1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("ar1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.IsFavorite).To(BeTrue()) - Expect(artistRepo.Data["ar1"].Starred).To(BeTrue()) + Expect(artistRepo.Data[testID("ar1")].Starred).To(BeTrue()) }) It("stars a visible playlist", func() { playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Starred["p1"]).To(BeTrue()) + Expect(playlistRepo.Starred[testID("p1")]).To(BeTrue()) }) It("unstars a song and returns IsFavorite=false", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.unmarkFavorite, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.IsFavorite).To(BeFalse()) - Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) }) It("returns 404 and does not star an album in a library the user can't access", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data["a1"].Starred).To(BeFalse()) + Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse()) }) It("returns 404 and does not star a song in a library the user can't access", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) }) It("returns 404 when the id doesn't match any entity", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -133,39 +133,39 @@ var _ = Describe("Annotations", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "x1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) It("emits a refreshResource event when starring a song", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.markFavorite, w, r) Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) }) It("emits a refreshResource event when starring an album", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`)) }) It("does not emit an event when the item is not accessible", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) Expect(broker.Events).To(BeEmpty()) @@ -175,13 +175,13 @@ var _ = Describe("Annotations", func() { Describe("setRating / removeRating", func() { It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.Rating).NotTo(BeNil()) @@ -190,35 +190,35 @@ var _ = Describe("Annotations", func() { It("rates an album", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Data["a1"].Rating).To(Equal(5)) + Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(5)) }) It("rates a visible playlist", func() { playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Ratings["p1"]).To(Equal(4)) + Expect(playlistRepo.Ratings[testID("p1")]).To(Equal(4)) }) It("removes a rating", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.removeRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.Rating).To(BeNil()) @@ -226,79 +226,79 @@ var _ = Describe("Annotations", func() { It("returns 404 and does not rate an album in a library the user can't access", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data["a1"].Rating).To(Equal(0)) + Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0)) }) It("rounds an odd rating to the nearest star instead of truncating", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) }) It("stores the minimum star for Rating=1 instead of clearing the rating", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(1)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1)) }) It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) }) It("clamps a Rating above 10 to Navidrome's max (5)", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) }) It("clamps a negative Rating to Navidrome's min (0)", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) }) It("emits a refreshResource event when rating a song", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) }) }) }) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index e8e9cbd4b..fe69c8321 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -48,8 +48,8 @@ var _ = Describe("Router", func() { ds := &tests.MockDataStore{} auth.Init(ds) ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) - token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) + token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go index b01a4bf92..4cc1a2cc5 100644 --- a/server/jellyfin/audiomuse.go +++ b/server/jellyfin/audiomuse.go @@ -62,12 +62,13 @@ func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request tracks := []audioMuseSimilarTrack{} itemID := p.StringOr("item_id", "") - if itemID == "" { + id, ok := dto.DecodeID(itemID) + if !ok { + // Every other failure here (no provider, no match) degrades to an empty list rather than an + // error, so a malformed id does too instead of being the one path that 404s. api.ok(w, r, tracks) return } - - id := api.resolveItemID(ctx, dto.DecodeID(itemID)) n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) @@ -129,11 +130,16 @@ func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { } resp := audioMusePathResponse{Path: []audioMusePathTrack{}} + startDecoded, startOk := dto.DecodeID(startID) + endDecoded, endOk := dto.DecodeID(endID) + if !startOk || !endOk { + // This endpoint already 400s when an endpoint id is absent, so an unusable one reports the + // same way rather than looking like a successful search that found no path. + http.Error(w, "start_song_id and end_song_id must be valid item ids.", http.StatusBadRequest) + return + } maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count - matches, err := api.sonic.FindSonicPath(ctx, - api.resolveItemID(ctx, dto.DecodeID(startID)), - api.resolveItemID(ctx, dto.DecodeID(endID)), - maxSteps) + matches, err := api.sonic.FindSonicPath(ctx, startDecoded, endDecoded, maxSteps) if err != nil { api.ok(w, r, resp) return diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go index e9d6d4e85..6815753fd 100644 --- a/server/jellyfin/audiomuse_test.go +++ b/server/jellyfin/audiomuse_test.go @@ -118,28 +118,28 @@ var _ = Describe("AudioMuse similar_tracks", func() { It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5}, + {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5}, } - w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true}) Expect(w.Code).To(Equal(200)) - Expect(fake.gotID).To(Equal("seed")) + Expect(fake.gotID).To(Equal(testID("seed"))) Expect(fake.gotCount).To(Equal(5)) var body []audioMuseSimilarTrack Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) Expect(body).To(HaveLen(2)) Expect(body[0]).To(Equal(audioMuseSimilarTrack{ - Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1", + Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("mf1")), Title: "T1", })) }) It("collapses to one track per artist when eliminate_duplicates defaults on", func() { fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, } - w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) var body []audioMuseSimilarTrack Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) Expect(body).To(HaveLen(1)) @@ -147,18 +147,18 @@ var _ = Describe("AudioMuse similar_tracks", func() { It("keeps same-artist tracks when eliminate_duplicates=false", func() { fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, } - w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("seed"))+"&eliminate_duplicates=false", model.User{IsAdmin: true}) var body []audioMuseSimilarTrack Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) Expect(body).To(HaveLen(2)) }) It("filters out tracks in libraries the user cannot access", func() { - fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}} - w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}}) + fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}}) Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) }) @@ -169,16 +169,25 @@ var _ = Describe("AudioMuse similar_tracks", func() { Expect(fake.gotID).To(Equal("")) }) + // A malformed item_id degrades the same way as a missing one (200, empty array) rather than + // 404ing: every other failure on this endpoint already degrades gracefully. + It("returns an empty array without calling the engine when item_id is malformed", func() { + w := call("item_id=not-a-valid-id", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + Expect(fake.gotID).To(Equal("")) + }) + It("returns 404 when no sonic provider is loaded", func() { fake.provider = false - w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(404)) }) It("returns an empty array when the engine errors", func() { fake.similarErr = errors.New("boom") - fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}} - w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(200)) Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) }) @@ -198,53 +207,60 @@ var _ = Describe("AudioMuse find_path", func() { }) It("returns 400 with the exact message when start_song_id is missing", func() { - w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + w := call("end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(400)) Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) }) It("returns 400 when end_song_id is missing", func() { - w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true}) + w := call("start_song_id="+dto.EncodeID(testID("s")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(400)) }) It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { bpm := 120 - withBPM := mf("mf1", "A", "T1", 1) + withBPM := mf(testID("mf1"), "A", "T1", 1) withBPM.BPM = &bpm fake.path = []sonic.SimilarMatch{ {MediaFile: withBPM, Similarity: 1.5}, - {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0}, + {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 2.0}, } - w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true}) + w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e"))+"&max_steps=10", model.User{IsAdmin: true}) Expect(w.Code).To(Equal(200)) - Expect(fake.gotStart).To(Equal("s")) - Expect(fake.gotEnd).To(Equal("e")) + Expect(fake.gotStart).To(Equal(testID("s"))) + Expect(fake.gotEnd).To(Equal(testID("e"))) Expect(fake.gotCount).To(Equal(10)) var body audioMusePathResponse Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) Expect(body.Path).To(HaveLen(2)) Expect(body.TotalDistance).To(Equal(3.5)) - Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1"))) + Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID(testID("mf1")))) Expect(*body.Path[0].Tempo).To(Equal(120.0)) Expect(body.Path[1].Tempo).To(BeNil()) }) It("returns 404 when no sonic provider is loaded", func() { fake.provider = false - w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(404)) }) It("returns an empty path object when the engine errors", func() { fake.pathErr = errors.New("boom") - fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}} - w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}} + w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(200)) var body audioMusePathResponse Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) Expect(body.Path).To(BeEmpty()) Expect(body.TotalDistance).To(Equal(0.0)) }) + + It("400s without calling the engine when an id is malformed", func() { + w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fake.gotStart).To(Equal("")) + Expect(fake.gotEnd).To(Equal("")) + }) }) diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index b51420f1a..5a613f828 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -22,7 +22,7 @@ var _ = Describe("AuthenticateByName", func() { ds = &tests.MockDataStore{} auth.Init(ds) ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) @@ -68,7 +68,7 @@ var _ = Describe("AuthenticateByName", func() { It("reflects an administrator in the User.Policy", func() { ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateByName", @@ -92,7 +92,7 @@ var _ = Describe("AuthenticateByName", func() { It("rejects an empty password even for a user with an empty stored password with 401", func() { ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateByName", diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index faf43d352..9acd8d5a8 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -28,12 +28,21 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _ := parentIDScope(ctx, r) + scopeIDs, _, ok := parentIDScope(ctx, r) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + genreIds, ok := decodedQueryIDs(r, "genreids") + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. q := itemsQuery{ scopeIDs: scopeIDs, - genreIds: decodedQueryIDs(r, "genreids"), + genreIds: genreIds, search: searchTerm(p), fields: dto.ParseFields(p.Strings("fields")...), } @@ -67,7 +76,11 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { ctx := r.Context() p := req.Params(r) - scope, _ := parentIDScope(ctx, r) + scope, _, ok := parentIDScope(ctx, r) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) if err != nil { @@ -83,7 +96,11 @@ func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { // library when accessible. Tags/OfficialRatings have no music source, so they are always empty. func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - scope, _ := parentIDScope(ctx, r) + scope, _, ok := parentIDScope(ctx, r) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} genres, err := api.ds.Genre(ctx).GetAll(genreOpts) if err != nil { diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index e346b56cb..2825c03e8 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -18,12 +18,12 @@ var _ = Describe("Browsing", func() { var api *Router var ds *tests.MockDataStore ctxUser := func(libs model.Libraries) context.Context { - return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) } // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. ctxAdmin := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) } BeforeEach(func() { @@ -33,7 +33,7 @@ var _ = Describe("Browsing", func() { Describe("getArtists", func() { It("lists artists via /Artists", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -45,7 +45,7 @@ var _ = Describe("Browsing", func() { }) It("handles /Artists/AlbumArtists the same way", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -57,7 +57,7 @@ var _ = Describe("Browsing", func() { It("scopes results to the user's accessible libraries", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) @@ -71,10 +71,10 @@ var _ = Describe("Browsing", func() { It("scopes to a single library when ParentId is an accessible library id", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(2), nil).WithContext(ctxUser(libs)) invoke(api.getArtists, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := artistRepo.Options.Filters.ToSql() @@ -86,10 +86,10 @@ var _ = Describe("Browsing", func() { It("does not let ParentId=<inaccessible library id> narrow the scope", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs)) + r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(99), nil).WithContext(ctxUser(libs)) invoke(api.getArtists, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := artistRepo.Options.Filters.ToSql() @@ -101,7 +101,7 @@ var _ = Describe("Browsing", func() { It("forwards SearchTerm to the repo's Search method", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -113,7 +113,7 @@ var _ = Describe("Browsing", func() { It("bounds a search the client left unbounded, and clamps an oversized one", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) @@ -130,7 +130,7 @@ var _ = Describe("Browsing", func() { It("forwards StartIndex/Limit as Offset/Max", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -141,7 +141,7 @@ var _ = Describe("Browsing", func() { It("does not restrict results for an admin user", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) invoke(api.getArtists, w, r) @@ -155,6 +155,15 @@ var _ = Describe("Browsing", func() { Expect(err).NotTo(HaveOccurred()) Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) }) + + It("404s a malformed ParentId instead of listing every library's artists", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("getGenres", func() { @@ -199,6 +208,13 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(tagRepo.Options.Filters).To(BeNil()) }) + + It("404s a malformed ParentId instead of listing every library's studios", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("getQueryFiltersLegacy", func() { @@ -222,5 +238,12 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(genreRepo.Options.Filters).To(BeNil()) }) + + It("404s a malformed ParentId instead of listing every library's filters", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) }) diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go index 1d8ec47e4..a4eebb0d3 100644 --- a/server/jellyfin/dto/dto_suite_test.go +++ b/server/jellyfin/dto/dto_suite_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +16,7 @@ func TestDto(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Jellyfin DTO Suite") } + +// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same +// id shape production uses. +func testID(label string) string { return id.NewHash("jellyfin-test", label) } diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go index 2ac7e2a79..2c8b5317b 100644 --- a/server/jellyfin/dto/ids.go +++ b/server/jellyfin/dto/ids.go @@ -1,23 +1,143 @@ package dto -import "encoding/hex" +import ( + "bytes" + "encoding/hex" + "strconv" + "strings" -// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g. -// Finamp's queue packing) and crash on Navidrome's base62 ids if emitted as-is. -func EncodeID(id string) string { - if id == "" { + "github.com/navidrome/navidrome/model/id" +) + +// guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes +// (what Guid.ToString("N") produces). +const guidLen = 32 + +// Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag, +// then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null. +const ( + kindIdx = 12 + maxPayload = 1<<24 - 1 +) + +const ( + kindLibrary byte = iota + 1 + kindPlaylistsFolder + kindPlaylistEntry +) + +var zeroPrefix [kindIdx]byte + +// PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be +// mistaken for a real id, which is always 22-char base62. +const PlaylistsFolderID = "playlists" + +// PlaylistsFolderGUID is the wire form of PlaylistsFolderID. +var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0) + +// EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes +// to "" rather than to a shape clients can't parse. +func EncodeID(ndID string) string { + b, err := id.Decode(ndID) + if err != nil { return "" } - return hex.EncodeToString([]byte(id)) + return hex.EncodeToString(b) } -// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id. -func DecodeID(id string) string { - if id == "" { +// EncodeLibraryID renders a library's integer id in the reserved GUID space. +func EncodeLibraryID(libID int) string { + return encodeReserved(kindLibrary, libID) +} + +// EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer +// column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song. +func EncodePlaylistEntryID(entryID string) string { + n, err := strconv.Atoi(entryID) + if err != nil { return "" } - if b, err := hex.DecodeString(id); err == nil && len(b) > 0 { - return string(b) - } - return id + return encodeReserved(kindPlaylistEntry, n) +} + +func encodeReserved(kind byte, payload int) string { + if payload < 0 || payload > maxPayload { + return "" + } + var b [16]byte + b[kindIdx] = kind + b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload) + return hex.EncodeToString(b[:]) +} + +// DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id, +// a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed +// GUID — including the empty string — so an undecodable id can't reach a caller as "no filter". +// Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries +// decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an +// entity id. +func DecodeID(guid string) (string, bool) { + b, ok := decodeGUID(guid) + if !ok { + return "", false + } + kind, payload, reserved := reservedFields(b) + if !reserved { + return id.Encode(b), true + } + switch kind { + case kindLibrary: + return strconv.Itoa(payload), true + case kindPlaylistsFolder: + return PlaylistsFolderID, true + } + return "", false +} + +// DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a +// caller can't mistake "every entry failed" for "no filter" (see DecodeID). +func DecodeIDs(guids []string) ([]string, bool) { + out := make([]string, len(guids)) + for i, guid := range guids { + decoded, ok := DecodeID(guid) + if !ok { + return nil, false + } + out[i] = decoded + } + return out, true +} + +// DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind, +// so an entity id can't be taken for a playlist_tracks row. +func DecodePlaylistEntryID(guid string) (string, bool) { + b, ok := decodeGUID(guid) + if !ok { + return "", false + } + if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry { + return strconv.Itoa(payload), true + } + return "", false +} + +func decodeGUID(guid string) ([16]byte, bool) { + guid = strings.ToLower(strings.ReplaceAll(guid, "-", "")) + if len(guid) != guidLen { + return [16]byte{}, false + } + bs, err := hex.DecodeString(guid) + if err != nil { + return [16]byte{}, false + } + return [16]byte(bs), true +} + +// reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the +// entity GUIDs that make up almost all traffic. +func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) { + if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) { + return 0, 0, false + } + return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true } diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go index d85786d99..c95dbd790 100644 --- a/server/jellyfin/dto/ids_test.go +++ b/server/jellyfin/dto/ids_test.go @@ -1,35 +1,161 @@ package dto import ( + "encoding/hex" + "strconv" + "strings" + + "github.com/navidrome/navidrome/model/id" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("id codec", func() { - It("round-trips a base62 id", func() { - id := "5QFKvMsJrd57QE2Le2dKKo" - Expect(DecodeID(EncodeID(id))).To(Equal(id)) + Describe("canonical ids", func() { + It("round-trips a random id", func() { + ndID := id.NewRandom() + decoded, ok := DecodeID(EncodeID(ndID)) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) + + It("round-trips a hash id", func() { + ndID := id.NewHash("artist", "Weird Al") + decoded, ok := DecodeID(EncodeID(ndID)) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) + + It("emits exactly 32 lowercase hex chars", func() { + for range 20 { + Expect(EncodeID(id.NewRandom())).To(MatchRegexp("^[0-9a-f]{32}$")) + } + }) + + It("accepts a dashed GUID, matching Jellyfin's Guid.Parse", func() { + ndID := id.NewRandom() + guid := EncodeID(ndID) + dashed := guid[0:8] + "-" + guid[8:12] + "-" + guid[12:16] + "-" + guid[16:20] + "-" + guid[20:32] + decoded, ok := DecodeID(dashed) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) + + It("accepts an uppercase GUID", func() { + ndID := id.NewRandom() + decoded, ok := DecodeID(strings.ToUpper(EncodeID(ndID))) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) }) - It("passes a raw (non-hex) id through DecodeID unchanged", func() { - Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo")) + Describe("reserved space", func() { + DescribeTable("round-trips library ids", + func(libID int, guid string) { + Expect(EncodeLibraryID(libID)).To(Equal(guid)) + decoded, ok := DecodeID(guid) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(strconv.Itoa(libID))) + }, + Entry("first library", 1, "00000000000000000000000001000001"), + Entry("double digit", 42, "0000000000000000000000000100002a"), + Entry("max payload", 0xffffff, "00000000000000000000000001ffffff"), + ) + + It("round-trips the playlists folder", func() { + Expect(PlaylistsFolderGUID).To(Equal("00000000000000000000000002000000")) + decoded, ok := DecodeID(PlaylistsFolderGUID) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(PlaylistsFolderID)) + }) + + DescribeTable("round-trips playlist entry positions", + func(entryID, guid string) { + Expect(EncodePlaylistEntryID(entryID)).To(Equal(guid)) + decoded, ok := DecodePlaylistEntryID(guid) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(entryID)) + }, + Entry("first entry", "1", "00000000000000000000000003000001"), + Entry("later entry", "300", "0000000000000000000000000300012c"), + ) + + It("rejects a non-integer playlist entry id", func() { + Expect(EncodePlaylistEntryID("s1")).To(Equal("")) + Expect(EncodePlaylistEntryID("")).To(Equal("")) + Expect(EncodePlaylistEntryID("-1")).To(Equal("")) + }) + + It("rejects a payload wider than the reserved 24 bits", func() { + Expect(EncodeLibraryID(1 << 24)).To(Equal("")) + Expect(EncodeLibraryID(-1)).To(Equal("")) + Expect(EncodePlaylistEntryID("16777216")).To(Equal("")) + }) + + It("keeps library and playlist-entry GUIDs distinct for the same number", func() { + Expect(EncodeLibraryID(3)).ToNot(Equal(EncodePlaylistEntryID("3"))) + }) + + It("does not let one reserved kind decode as another", func() { + _, ok := DecodeID(EncodePlaylistEntryID("3")) + Expect(ok).To(BeFalse()) + _, ok = DecodePlaylistEntryID(EncodeLibraryID(3)) + Expect(ok).To(BeFalse()) + _, ok = DecodePlaylistEntryID(PlaylistsFolderGUID) + Expect(ok).To(BeFalse()) + }) + + It("rejects an unknown kind tag", func() { + _, ok := DecodeID("000000000000000000000000ff000000") + Expect(ok).To(BeFalse()) + }) + + It("never emits the all-zero GUID, which Jellyfin serializes as null", func() { + Expect(EncodeLibraryID(0)).ToNot(Equal("00000000000000000000000000000000")) + Expect(PlaylistsFolderGUID).ToNot(Equal("00000000000000000000000000000000")) + }) }) - It("produces valid lowercase hex", func() { - encoded := EncodeID("song-1") - Expect(encoded).To(MatchRegexp("^[0-9a-f]+$")) - Expect(encoded).To(HaveLen(len("song-1") * 2)) + Describe("malformed input", func() { + DescribeTable("DecodeID reports ok=false", + func(input string) { + decoded, ok := DecodeID(input) + Expect(ok).To(BeFalse()) + Expect(decoded).To(BeEmpty()) + }, + Entry("empty", ""), + Entry("too short", "abc123"), + Entry("too long", "000000000000000000000000010000011"), + Entry("non-hex chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), + Entry("a raw base62 id", id.NewRandom()), + Entry("the old 44-char hex format", hex.EncodeToString([]byte("5QFKvMsJrd57QE2Le2dKKo"))), + ) + + It("encodes a non-canonical id to the empty string", func() { + Expect(EncodeID("")).To(Equal("")) + Expect(EncodeID("playlists")).To(Equal("")) + Expect(EncodeID("42")).To(Equal("")) + }) }) - It("round-trips the empty string", func() { - Expect(EncodeID("")).To(Equal("")) - Expect(DecodeID("")).To(Equal("")) - }) + Describe("DecodeIDs", func() { + It("decodes every entry when all are well-formed", func() { + a, b := id.NewRandom(), id.NewRandom() + decoded, ok := DecodeIDs([]string{EncodeID(a), EncodeID(b)}) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal([]string{a, b})) + }) - It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() { - // "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex - // string apart from one we encoded. Callers must always encode ids on emission and - // decode them on receipt so this ambiguity never surfaces in practice. - Expect(DecodeID(EncodeID("a1"))).To(Equal("a1")) + It("is all-or-nothing: one malformed entry fails the whole list", func() { + decoded, ok := DecodeIDs([]string{EncodeID(id.NewRandom()), "not-a-guid"}) + Expect(ok).To(BeFalse()) + Expect(decoded).To(BeNil()) + }) + + It("succeeds on an empty list", func() { + decoded, ok := DecodeIDs(nil) + Expect(ok).To(BeTrue()) + Expect(decoded).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 31cdba1dd..1ebf66ca1 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -138,6 +138,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { // only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a // plain list response); a nil fields set means neither. func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { + albumID := EncodeID(mf.AlbumID) item := BaseItemDto{ Name: mf.Title, Id: EncodeID(mf.ID), @@ -146,9 +147,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { IsFolder: false, LocationType: "FileSystem", HasLyrics: mf.HasEmbeddedLyrics(), - ParentId: EncodeID(mf.AlbumID), + ParentId: albumID, Album: mf.Album, - AlbumId: EncodeID(mf.AlbumID), + AlbumId: albumID, AlbumArtist: mf.AlbumArtist, RunTimeTicks: TicksFromSeconds(mf.Duration), DateCreated: jellyfinDate(&mf.CreatedAt), diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 77b78e55f..576482e61 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -14,10 +14,10 @@ import ( var _ = Describe("mappers", func() { It("maps a song to an Audio BaseItemDto", func() { mf := model.MediaFile{ - ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", + ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}, + Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, } mf.PlayCount = 2 mf.Starred = true @@ -26,24 +26,24 @@ var _ = Describe("mappers", func() { Expect(item.MediaType).To(Equal("Audio")) Expect(item.IsFolder).To(BeFalse()) Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.Id).To(Equal(EncodeID("song-1"))) - Expect(item.AlbumId).To(Equal(EncodeID("alb-1"))) - Expect(item.ParentId).To(Equal(EncodeID("alb-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("song-1")))) + Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1")))) + Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1")))) Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) Expect(*item.IndexNumber).To(Equal(3)) Expect(item.UserData.IsFavorite).To(BeTrue()) Expect(item.UserData.PlayCount).To(Equal(2)) Expect(item.UserData.Played).To(BeTrue()) - Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) - Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) - Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) + Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1")))) + Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1")))) + Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1"))) Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) }) Describe("Fields gating (matches real Jellyfin)", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} It("omits MediaSources when Fields does not ask for them", func() { @@ -59,9 +59,9 @@ var _ = Describe("mappers", func() { // SortName must match the server sort order — see the sortName helper. Describe("SortName", func() { - song := model.MediaFile{ID: "s1", Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} - ar := model.Artist{ID: "art-1", Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} - al := model.Album{ID: "alb-1", Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} + song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} + ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} + al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -91,85 +91,85 @@ var _ = Describe("mappers", func() { }) It("falls back to the order name when there is no sort tag", func() { - Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "The X", OrderArtistName: "x"}, + Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"}, ParseFields("SortName")).SortName).To(Equal("x")) }) }) It("falls back to the display name when order name and sort tag are empty", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s", Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) - Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) - Expect(AlbumToBaseItem(model.Album{ID: "al", Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) + Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) + Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) }) }) It("sets HasLyrics from the media file's lyrics", func() { Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) - Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) // "[]" is the no-lyrics sentinel, not a truthy value. - Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) }) }) It("omits ImageBlurHashes when a song has no album", func() { - mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60} + mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60} item := SongToBaseItem(mf, nil) Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) Expect(item.ImageBlurHashes).To(BeNil()) }) It("sets DateCreated from the media file's CreatedAt", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) }) It("omits DateCreated when CreatedAt is the zero time", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty()) }) It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { mf := model.MediaFile{ - ID: "s1", Title: "Song", - Artist: "The Band", ArtistID: "ar-1", - AlbumArtist: "Various", AlbumArtistID: "ar-2", + ID: testID("s1"), Title: "Song", + Artist: "The Band", ArtistID: testID("ar-1"), + AlbumArtist: "Various", AlbumArtistID: testID("ar-2"), } item := SongToBaseItem(mf, nil) - Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}})) - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}})) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}})) }) It("omits ArtistItems when the track has no artist id", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) }) It("omits Artists when the track has no artist name or participants", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil()) }) It("splits Artists and ArtistItems per track artist from Participants", func() { mf := model.MediaFile{ - ID: "s1", Title: "Oooh", - Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul", - AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul", + ID: testID("s1"), Title: "Oooh", + Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"), + AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"), } mf.Participants = model.Participants{ model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}}, - {Artist: model.Artist{ID: "ar-redman", Name: "Redman"}}, + {Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}}, + {Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}}, }, } item := SongToBaseItem(mf, nil) Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) Expect(item.ArtistItems).To(Equal([]NameGuidPair{ - {Name: "De La Soul", Id: EncodeID("ar-delasoul")}, - {Name: "Redman", Id: EncodeID("ar-redman")}, + {Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}, + {Name: "Redman", Id: EncodeID(testID("ar-redman"))}, })) // AlbumArtists stays single, matching real Jellyfin. - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}})) }) It("serializes normalization gains with Jellyfin's exact key casing", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", + mf := model.MediaFile{ID: testID("s1"), Title: "Song", RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} b, err := json.Marshal(SongToBaseItem(mf, nil)) Expect(err).ToNot(HaveOccurred()) @@ -178,16 +178,16 @@ var _ = Describe("mappers", func() { }) It("omits normalization gains when the file has no ReplayGain tags", func() { - b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil)) + b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil)) Expect(err).ToNot(HaveOccurred()) // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) It("builds a MediaSourceInfo from a media file", func() { - mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) - Expect(src.Id).To(Equal(EncodeID("s1"))) + Expect(src.Id).To(Equal(EncodeID(testID("s1")))) Expect(src.Size).To(Equal(int64(5242880))) Expect(src.Container).To(Equal("mp3")) Expect(src.Bitrate).To(Equal(320_000)) @@ -198,7 +198,7 @@ var _ = Describe("mappers", func() { It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { mf := model.MediaFile{ - ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, + ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, Channels: 2, SampleRate: 44100, Codec: "mp3", } src := MediaSourceFromMediaFile(mf) @@ -213,7 +213,7 @@ var _ = Describe("mappers", func() { }) It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { - mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) b, err := json.Marshal(src) Expect(err).ToNot(HaveOccurred()) @@ -241,7 +241,7 @@ var _ = Describe("mappers", func() { Describe("Lyric media stream advertising", func() { It("adds a Lyric media stream when the file has embedded lyrics", func() { - mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`} + mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`} src := MediaSourceFromMediaFile(mf) Expect(src.MediaStreams).To(HaveLen(2)) Expect(src.MediaStreams[0].Type).To(Equal("Audio")) @@ -251,20 +251,20 @@ var _ = Describe("mappers", func() { }) It("emits only the Audio stream without lyrics", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"}) + src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")}) Expect(src.MediaStreams).To(HaveLen(1)) Expect(src.MediaStreams[0].Type).To(Equal("Audio")) }) It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"}) + src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"}) Expect(src.MediaStreams).To(HaveLen(1)) }) }) It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { mf := model.MediaFile{ - ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", + ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, Duration: 60, } @@ -276,7 +276,7 @@ var _ = Describe("mappers", func() { It("maps PlayDate to UserData.LastPlayedDate", func() { playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) mf := model.MediaFile{ - ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1", + ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", Duration: 60, } mf.PlayDate = &playDate @@ -286,25 +286,25 @@ var _ = Describe("mappers", func() { }) It("maps an album to a MusicAlbum folder item", func() { - al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} + al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}} item := AlbumToBaseItem(al, nil) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID("alb-1"))) - Expect(item.ParentId).To(Equal(EncodeID("art-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("alb-1")))) + Expect(item.ParentId).To(Equal(EncodeID(testID("art-1")))) Expect(item.AlbumArtists).To(HaveLen(1)) - Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1"))) + Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1")))) Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1"))) Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) }) It("populates album Studios from record-label tags only when Fields=Studios", func() { - al := model.Album{ID: "alb-2", Name: "Alb2"} + al := model.Album{ID: testID("alb-2"), Name: "Alb2"} al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) @@ -317,7 +317,7 @@ var _ = Describe("mappers", func() { }) It("sets NormalizationGain on the album from its ReplayGain", func() { - al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)} b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) @@ -326,14 +326,14 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) Describe("PrimaryImageAspectRatio", func() { nonSquare := func() model.Album { - al := model.Album{ID: "al1", Name: "Album"} + al := model.Album{ID: testID("al1"), Name: "Album"} al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 return al } @@ -351,14 +351,14 @@ var _ = Describe("mappers", func() { }) It("is omitted when the dimensions are unknown, rather than guessing square", func() { - al := model.Album{ID: "al1", Name: "Album"} + al := model.Album{ID: testID("al1"), Name: "Album"} al.ImageHash = "abc" item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) Expect(item.PrimaryImageAspectRatio).To(BeNil()) }) It("is omitted when the item has no image at all", func() { - al := model.Album{ID: "al1", Name: "Album"} + al := model.Album{ID: testID("al1"), Name: "Album"} al.ImageAbsent = true al.ImageWidth, al.ImageHeight = 1200, 800 item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) @@ -366,21 +366,21 @@ var _ = Describe("mappers", func() { }) It("carries the ratio for an artist", func() { - ar := model.Artist{ID: "ar1", Name: "Artist"} + ar := model.Artist{ID: testID("ar1"), Name: "Artist"} ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). To(BeNumerically("~", 2.0, 0.0001)) }) It("carries the ratio for a playlist", func() { - pl := model.Playlist{ID: "pl1", Name: "Playlist"} + pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"} pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). To(BeNumerically("~", 0.5, 0.0001)) }) It("carries the ratio for a song with its own art", func() { - mf := model.MediaFile{ID: "mf1", Title: "Song"} + mf := model.MediaFile{ID: testID("mf1"), Title: "Song"} mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). To(BeNumerically("~", 0.5, 0.0001)) @@ -388,7 +388,7 @@ var _ = Describe("mappers", func() { // A track without its own art shows the album's, so the ratio has to describe that image. It("uses the album's dimensions for a track falling back to album art", func() { - mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"} + mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")} mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) @@ -397,73 +397,73 @@ var _ = Describe("mappers", func() { }) It("maps an artist to a MusicArtist folder item", func() { - ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} + ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20} item := ArtistToBaseItem(ar, nil) Expect(item.Type).To(Equal("MusicArtist")) Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID("art-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("art-1")))) Expect(*item.AlbumCount).To(Equal(2)) }) It("maps a genre to a MusicGenre folder item", func() { - g := model.Genre{ID: "genre-1", Name: "Rock"} + g := model.Genre{ID: testID("genre-1"), Name: "Rock"} item := GenreToBaseItem(g) Expect(item.Type).To(Equal("MusicGenre")) Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID("genre-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("genre-1")))) Expect(item.Name).To(Equal("Rock")) }) It("maps a tag to a Studio BaseItemDto", func() { - item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"}) Expect(item.Type).To(Equal("Studio")) Expect(item.Name).To(Equal("Blue Note")) - Expect(item.Id).To(Equal(EncodeID("t1"))) + Expect(item.Id).To(Equal(EncodeID(testID("t1")))) }) Describe("premiereDate", func() { // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. It("serializes a full date", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007} item := SongToBaseItem(mf, nil) Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) }) It("pads a year-only date so clients can parse it", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007} Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) }) It("pads a year-month date", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"} Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) }) It("falls back to the year when no date tag exists", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999} Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) }) It("is omitted when the track has no date at all", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil()) }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) - Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil()) }) }) It("maps a playlist to a Playlist BaseItemDto", func() { p := model.Playlist{ - ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, + ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120, Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, } item := PlaylistToBaseItem(p, nil) Expect(item.Type).To(Equal("Playlist")) Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID("pl-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("pl-1")))) Expect(item.Name).To(Equal("Chill")) Expect(item.MediaType).To(Equal("Audio")) Expect(*item.ChildCount).To(Equal(7)) @@ -471,12 +471,12 @@ var _ = Describe("mappers", func() { Expect(item.UserData.IsFavorite).To(BeTrue()) Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1"))) Expect(item.ImageBlurHashes).To(BeNil()) }) It("changes the playlist image tag when the cover content changes", func() { - p := model.Playlist{ID: "pl-1", Name: "Chill"} + p := model.Playlist{ID: testID("pl-1"), Name: "Chill"} p.ImageHash = "1111111111111111" before := PlaylistToBaseItem(p, nil) p.ImageHash = "2222222222222222" @@ -487,7 +487,7 @@ var _ = Describe("mappers", func() { }) It("keeps the playlist image tag stable across a metadata-only edit", func() { - p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} p.ImageHash = "1111111111111111" before := PlaylistToBaseItem(p, nil) p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) @@ -505,17 +505,17 @@ var _ = Describe("mappers", func() { }) It("advertises the track id so the client triggers the read-through", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} + mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1"))) Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) }) It("falls back to the album when the track has no art of its own", func() { - mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} + mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) @@ -524,7 +524,7 @@ var _ = Describe("mappers", func() { }) It("falls back to the album once the track's art is known absent", func() { - mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} + mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true} mf.ItemImage.ImageAbsent = true mf.AlbumImage.ImageHash = "0123456789abcdef" @@ -535,7 +535,7 @@ var _ = Describe("mappers", func() { It("falls back to the album when per-track art is disabled", func() { conf.Server.EnableMediaFileCoverArt = false - mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} + mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) @@ -546,7 +546,7 @@ var _ = Describe("mappers", func() { Describe("primary image tags", func() { It("uses the content hash as the tag and emits the real blurhash", func() { - al := model.Album{ID: "alb-1", Name: "Album"} + al := model.Album{ID: testID("alb-1"), Name: "Album"} al.ImageHash = "0123456789abcdef" al.BlurHash = "LEHV6nWB2yk8" @@ -556,7 +556,7 @@ var _ = Describe("mappers", func() { }) It("omits the blurhash entirely when none was computed", func() { - al := model.Album{ID: "alb-2", Name: "Album"} + al := model.Album{ID: testID("alb-2"), Name: "Album"} al.ImageHash = "0123456789abcdef" item := AlbumToBaseItem(al, nil) @@ -565,7 +565,7 @@ var _ = Describe("mappers", func() { }) It("omits tags for known-absent artwork", func() { - al := model.Album{ID: "alb-3", Name: "Album"} + al := model.Album{ID: testID("alb-3"), Name: "Album"} al.ImageAbsent = true item := AlbumToBaseItem(al, nil) @@ -574,13 +574,13 @@ var _ = Describe("mappers", func() { }) It("falls back to the entity id while artwork is still unresolved", func() { - item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) + item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4"))) Expect(item.ImageBlurHashes).To(BeNil()) }) It("versions an artist's tag by content hash", func() { - ar := model.Artist{ID: "art-1", Name: "Artist"} + ar := model.Artist{ID: testID("art-1"), Name: "Artist"} ar.ImageHash = "fedcba9876543210" ar.BlurHash = "L6PZfSi_.AyE" @@ -592,7 +592,7 @@ var _ = Describe("mappers", func() { Describe("song and playlist image tags", func() { It("versions a song's album tag by the album's content hash", func() { - mf := model.MediaFile{ID: "song-1", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")} mf.AlbumImage.ImageHash = "0123456789abcdef" mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" @@ -602,7 +602,7 @@ var _ = Describe("mappers", func() { }) It("never synthesizes a song blurhash when the album has none", func() { - mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} + mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) @@ -611,7 +611,7 @@ var _ = Describe("mappers", func() { }) It("omits a song's album tag when the album art is known absent", func() { - mf := model.MediaFile{ID: "song-3", Title: "Song", AlbumID: "alb-3"} + mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")} mf.AlbumImage.ImageAbsent = true item := SongToBaseItem(mf, nil) @@ -620,7 +620,7 @@ var _ = Describe("mappers", func() { }) It("versions a playlist tag by content hash instead of UpdatedAt", func() { - pl := model.Playlist{ID: "pl-1", Name: "Playlist"} + pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"} pl.ImageHash = "abcdef0123456789" item := PlaylistToBaseItem(pl, nil) @@ -630,7 +630,7 @@ var _ = Describe("mappers", func() { Describe("per-song artwork", func() { It("emits the track's own Primary tag when it has distinct art", func() { - mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")} mf.ImageHash = "aaaaaaaaaaaaaaaa" mf.BlurHash = "LTRACKblur" mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" @@ -644,7 +644,7 @@ var _ = Describe("mappers", func() { }) It("falls back to the album tag when the track has no distinct art", func() { - mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")} mf.ImageHash = "bbbbbbbbbbbbbbbb" mf.BlurHash = "LALBUMblur" mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" @@ -658,7 +658,7 @@ var _ = Describe("mappers", func() { }) It("omits the track tag when its own art is known absent", func() { - mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")} mf.ImageAbsent = true mf.AlbumImage.ImageAbsent = true @@ -673,7 +673,7 @@ var _ = Describe("mappers", func() { var _ = Describe("LyricDtoFromLyrics", func() { ms := func(v int64) *int64 { return &v } - mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} It("maps synced lyrics with tick conversion", func() { l := model.Lyrics{ diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go index b1ad850e3..182fd5436 100644 --- a/server/jellyfin/e2e/annotations_test.go +++ b/server/jellyfin/e2e/annotations_test.go @@ -79,7 +79,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 when favoriting an unknown item", func() { - Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound)) }) }) @@ -102,7 +102,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 for an unknown item", func() { - Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound)) + Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound)) }) }) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go index 383dbab83..074c8e1a4 100644 --- a/server/jellyfin/e2e/audiomuse_test.go +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -61,7 +61,9 @@ var _ = Describe("AudioMuse endpoints", func() { parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) Expect(body).To(HaveLen(2)) Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) - Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title))) + decoded, ok := dto.DecodeID(body[0].ItemID) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(songID(body[0].Title))) }) It("collapses to one track per artist by default", func() { diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 7128972ba..806b0e5e7 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -27,9 +27,9 @@ var _ = Describe("Authentication", func() { Expect(res.AccessToken).ToNot(BeEmpty()) Expect(res.User).ToNot(BeNil()) Expect(res.User.Name).To(Equal("admin")) - Expect(res.User.Id).To(Equal(enc("admin-1"))) + Expect(res.User.Id).To(Equal(enc(testID("admin-1")))) Expect(res.User.Policy.IsAdministrator).To(BeTrue()) - Expect(res.ServerId).ToNot(BeEmpty()) + Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // The returned token must actually authenticate a protected request. r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() { users := publicUsers() Expect(users).To(HaveLen(1)) Expect(users[0].Name).To(Equal("regular")) - Expect(users[0].Id).To(Equal(enc("regular-1"))) + Expect(users[0].Id).To(Equal(enc(testID("regular-1")))) Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login }) }) @@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() { var u dto.UserDto parseInto(getAs(regularUser, "/Users/Me"), &u) Expect(u.Name).To(Equal("regular")) - Expect(u.Id).To(Equal(enc("regular-1"))) + Expect(u.Id).To(Equal(enc(testID("regular-1")))) }) It("returns the caller from GET /Users/{userId}", func() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 14ab8b62b..004090de6 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -46,7 +46,7 @@ var _ = Describe("Browsing", func() { Expect(it.Type).To(Equal("Audio")) Expect(it.MediaType).To(Equal("Audio")) Expect(it.LocationType).To(Equal("FileSystem")) - Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it + Expect(it.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form Expect(it.AlbumId).ToNot(BeEmpty()) } }) @@ -151,7 +151,7 @@ var _ = Describe("Browsing", func() { // Finamp's download sync asks a library for the tracks outside any album this way; answering // with every track would stream the whole library. Describe("Recursive=false", func() { - lib1 := enc("1") + lib1 := dto.EncodeLibraryID(1) It("returns no songs for a library parent", func() { q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) @@ -173,7 +173,7 @@ var _ = Describe("Browsing", func() { // Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=<artistId>. Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { - lib1 := enc("1") + lib1 := dto.EncodeLibraryID(1) It("filters albums by AlbumArtistIds", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) @@ -209,6 +209,87 @@ var _ = Describe("Browsing", func() { }) }) + // A malformed id must 404, not silently drop the filter and widen the query to the whole + // library; a well-formed but unknown one must still fail closed to zero results. + Describe("stale and malformed id filtering", func() { + lib1 := dto.EncodeLibraryID(1) + + It("404s a malformed ParentId instead of listing every song", func() { + w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed AlbumArtistIds instead of listing every album", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed ArtistIds instead of listing every song", func() { + w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed Ids entry instead of batch-fetching nothing", func() { + w := get("/Items?Ids=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s when only one of several Ids entries is malformed", func() { + w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() { + q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed GenreIds entry instead of listing every album", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s when only one of several GenreIds entries is malformed", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed AlbumIds entry instead of listing every song", func() { + w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed StudioIds entry instead of listing every album", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + }) + // Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true. Describe("album filtering (AlbumIds)", func() { It("filters songs by AlbumIds", func() { @@ -224,7 +305,7 @@ var _ = Describe("Browsing", func() { }) It("returns nothing for an unknown album id", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album"))) + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album")))) Expect(q.Items).To(BeEmpty()) Expect(q.TotalRecordCount).To(Equal(0)) }) @@ -261,16 +342,16 @@ var _ = Describe("Browsing", func() { It("returns filter lists scoped to a ParentId library", func() { var filters dto.QueryFiltersLegacy - parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters) Expect(filters.Years).To(ContainElements(1959, 1965)) - studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1))) Expect(names(studios.Items)).To(ContainElement("Columbia")) }) }) // Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>. Describe("genre filtering (GenreIds)", func() { - lib1 := enc("1") + lib1 := dto.EncodeLibraryID(1) It("filters albums by GenreIds", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) @@ -295,7 +376,7 @@ var _ = Describe("Browsing", func() { }) It("returns nothing for an unknown genre id", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre"))) + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre")))) Expect(q.Items).To(BeEmpty()) Expect(q.TotalRecordCount).To(Equal(0)) }) @@ -317,7 +398,7 @@ var _ = Describe("Browsing", func() { }) It("returns no artists for an unknown genre id", func() { - q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre"))) + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre")))) Expect(q.Items).To(BeEmpty()) }) }) @@ -327,7 +408,7 @@ var _ = Describe("Browsing", func() { // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. Describe("camelCase query params (Jellify / JS SDK)", func() { - lib1 := enc("1") + lib1 := dto.EncodeLibraryID(1) It("filters albums by camelCase albumArtistIds", func() { q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) @@ -364,35 +445,6 @@ var _ = Describe("Browsing", func() { Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) }) - // Finamp restores its saved queue with ids truncated to 16 bytes (see README). - Describe("Finamp-truncated ids (saved queue restore)", func() { - It("resolves a truncated id by unique prefix and echoes the requested id", func() { - full := songID("Come Together") - truncated := full[:16] - q := queryResult(get("/Items?ids=" + enc(truncated))) - Expect(names(q.Items)).To(ConsistOf("Come Together")) - // Finamp matches restored items by its stored ids, so the requested id must be echoed. - Expect(q.Items[0].Id).To(Equal(enc(truncated))) - }) - - It("batch-resolves a mixed list of truncated and full ids, keeping order", func() { - ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16]) - q := queryResult(get("/Items?ids=" + ids)) - Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"})) - }) - - It("streams a track by its truncated id", func() { - full := songID("So What") - w := get("/Audio/" + enc(full[:16]) + "/stream") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(full)) - }) - - It("still 404s for a truncated id matching nothing", func() { - Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound)) - }) - }) - It("applies Limit while reporting the full TotalRecordCount", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) Expect(q.Items).To(HaveLen(2)) @@ -496,7 +548,7 @@ var _ = Describe("Browsing", func() { }) It("returns 404 for an unknown id", func() { - Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound)) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ca1308e9e..1ee19ea01 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -51,6 +51,7 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/server/events" @@ -70,6 +71,10 @@ func TestJellyfinE2E(t *testing.T) { RunSpecs(t, "Jellyfin API E2E Suite") } +// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same +// id shape production uses. +func testID(label string) string { return id.NewHash("jellyfin-test", label) } + // Easy aliases for the storagetest package type _t = map[string]any @@ -91,14 +96,14 @@ var ( dataFolder string adminUser = model.User{ - ID: "admin-1", + ID: testID("admin-1"), UserName: "admin", Name: "Admin User", IsAdmin: true, } regularUser = model.User{ - ID: "regular-1", + ID: testID("regular-1"), UserName: "regular", Name: "Regular User", IsAdmin: false, @@ -219,7 +224,9 @@ func createPlaylistAs(user model.User, name string, encodedIds ...string) string var res map[string]string parseInto(postAs(user, "/Playlists", string(body)), &res) Expect(res["Id"]).ToNot(BeEmpty()) - return dto.DecodeID(res["Id"]) + id, ok := dto.DecodeID(res["Id"]) + Expect(ok).To(BeTrue()) + return id } // --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go index 279f288b8..a49019bbf 100644 --- a/server/jellyfin/e2e/lyrics_test.go +++ b/server/jellyfin/e2e/lyrics_test.go @@ -53,7 +53,7 @@ var _ = Describe("Lyrics", func() { }) It("returns 404 for a fabricated id", func() { - Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + Expect(get("/Audio/" + enc(testID("nope")) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) }) }) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go index 015d82d91..d3a521332 100644 --- a/server/jellyfin/e2e/multiuser_test.go +++ b/server/jellyfin/e2e/multiuser_test.go @@ -18,7 +18,7 @@ var _ = Describe("Multi-user access control", func() { }) It("hides all content from a user with no library access", func() { - noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"} + noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"} Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) loaded, err := ds.User(ctx).FindByUsername("noaccess") Expect(err).ToNot(HaveOccurred()) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 08d6f4f6f..50c2e17f2 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -46,6 +46,20 @@ var _ = Describe("Playlists", func() { plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) }) + + // dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get + // dropped while the well-formed entries are still used to create a playlist. + It("404s when one of the Ids is malformed, without creating a playlist", func() { + before, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + + body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` + Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound)) + + after, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(after).To(Equal(before)) + }) }) Describe("items", func() { @@ -81,6 +95,13 @@ var _ = Describe("Playlists", func() { Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) }) + It("404s when one of the ids to add is malformed, without adding any track", func() { + plID := createPlaylist("AddMalformed", nil) + url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + ",not-a-valid-id" + Expect(post(url, "").Code).To(Equal(http.StatusNotFound)) + Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) + }) + It("removes an entry by its PlaylistItemId", func() { plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) entryID := playlistItems(plID).Items[0].PlaylistItemId @@ -103,7 +124,7 @@ var _ = Describe("Playlists", func() { var perms []dto.PlaylistUserPermissions parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) Expect(perms).To(HaveLen(1)) - Expect(perms[0].UserId).To(Equal(enc("admin-1"))) + Expect(perms[0].UserId).To(Equal(enc(testID("admin-1")))) Expect(perms[0].CanEdit).To(BeTrue()) }) }) @@ -135,12 +156,12 @@ var _ = Describe("Playlists", func() { q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) Expect(q.Items).To(HaveLen(1)) Expect(q.Items[0].CollectionType).To(Equal("playlists")) - Expect(q.Items[0].Id).To(Equal(enc("playlists"))) + Expect(q.Items[0].Id).To(Equal(dto.PlaylistsFolderGUID)) }) It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { createPlaylist("My Mix", nil) - q := queryResult(get("/Items?parentId=" + enc("playlists"))) + q := queryResult(get("/Items?parentId=" + dto.PlaylistsFolderGUID)) Expect(names(q.Items)).To(ContainElement("My Mix")) Expect(q.Items[0].Type).To(Equal("Playlist")) // Jellify keeps only playlists whose Path contains "data". @@ -149,10 +170,10 @@ var _ = Describe("Playlists", func() { It("resolves the synthetic playlists folder by its own advertised id", func() { var item dto.BaseItemDto - parseInto(get("/Items/"+enc("playlists")), &item) + parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item) Expect(item.Type).To(Equal("ManualPlaylistsFolder")) Expect(item.CollectionType).To(Equal("playlists")) - Expect(item.Id).To(Equal(enc("playlists"))) + Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID)) }) }) @@ -239,6 +260,15 @@ var _ = Describe("Playlists", func() { }) Describe("update", func() { + It("404s when one of the replacement Ids is malformed, leaving the track list unchanged", func() { + plID := createPlaylist("UpdateMalformed", []string{enc(songID("Come Together"))}) + body := `{"Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` + Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNotFound)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(names(q.Items)).To(ConsistOf("Come Together")) + }) + It("makes a playlist public", func() { plID := createPlaylist("Make Public", nil) Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) @@ -295,6 +325,20 @@ var _ = Describe("Playlists", func() { post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) }) + + // An id that decodes to "" would tell Create to make a new playlist instead of updating one — + // itemIDParam must 404 before that decode ever runs, not silently create one. + It("404s for a malformed playlist id, without creating a playlist", func() { + before, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + + w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`) + Expect(w.Code).To(Equal(http.StatusNotFound)) + + after, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(after).To(Equal(before)) + }) }) Describe("delete", func() { diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go index 6c26569fc..fa14f6811 100644 --- a/server/jellyfin/e2e/search_test.go +++ b/server/jellyfin/e2e/search_test.go @@ -3,6 +3,7 @@ package e2e import ( "fmt" + "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -12,7 +13,7 @@ import ( var _ = Describe("Search", func() { BeforeEach(func() { setupTestDB() }) - lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + lib1 := func() string { return dto.EncodeLibraryID(1) } Describe("artists", func() { It("searches all album artists", func() { diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 43ef857fa..5f1a88a21 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -1,6 +1,8 @@ package e2e import ( + "net/http" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -12,7 +14,7 @@ var _ = Describe("Similar", func() { Describe("GET /Artists/{id}/Similar", func() { It("returns the provider's similar artists, excluding ones not in the library", func() { providerFake.similarArtists = model.Artists{ - {ID: "z", Name: "Led Zeppelin"}, + {ID: testID("z"), Name: "Led Zeppelin"}, {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded } q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) @@ -25,11 +27,18 @@ var _ = Describe("Similar", func() { Expect(q.Items).To(BeEmpty()) Expect(q.TotalRecordCount).To(Equal(0)) }) + + // Unlike an unresolvable-but-well-formed id (empty result above), a malformed itemId never + // reaches provider lookup at all — itemIDParam rejects it first. + It("404s a malformed itemId", func() { + w := get("/Artists/not-a-valid-id/Similar") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("GET /Items/{id}/Similar", func() { It("returns similar songs for a track", func() { - providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}} q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) Expect(names(q.Items)).To(ConsistOf("Similar Song")) Expect(q.Items[0].Type).To(Equal("Audio")) @@ -37,8 +46,8 @@ var _ = Describe("Similar", func() { It("excludes similar songs from libraries the user can't access", func() { providerFake.similarSongs = model.MediaFiles{ - {ID: "x1", Title: "In Library", LibraryID: 1}, - {ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access + {ID: testID("x1"), Title: "In Library", LibraryID: 1}, + {ID: testID("x2"), Title: "Other Library", LibraryID: 2}, // regularUser has no access } q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) Expect(names(q.Items)).To(ConsistOf("In Library")) @@ -46,9 +55,9 @@ var _ = Describe("Similar", func() { It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { providerFake.similarSongs = model.MediaFiles{ - {ID: "x1", AlbumID: albumID("IV")}, - {ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once - {ID: "x3", AlbumID: albumID("Kind of Blue")}, + {ID: testID("x1"), AlbumID: albumID("IV")}, + {ID: testID("x2"), AlbumID: albumID("IV")}, // same album -> counted once + {ID: testID("x3"), AlbumID: albumID("Kind of Blue")}, } q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) @@ -60,27 +69,32 @@ var _ = Describe("Similar", func() { // provider similar-song at it. otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) - otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2} + otherAlbum := model.Album{ID: testID("other-album"), Name: "Other Album", LibraryID: 2} Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed()) providerFake.similarSongs = model.MediaFiles{ - {ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible - {ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser + {ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible + {ID: testID("x2"), AlbumID: testID("other-album")}, // library 2 -> filtered for regularUser } q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) Expect(names(q.Items)).To(ConsistOf("IV")) }) It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { - q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar")) + q := queryResult(get("/Items/" + enc(testID("does-not-exist")) + "/Similar")) Expect(q.Items).To(BeEmpty()) }) + + It("404s a malformed itemId", func() { + w := get("/Items/not-a-valid-id/Similar") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. Describe("GET /Items/{id}/InstantMix", func() { It("returns the seed track first, followed by similar songs", func() { - providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}} q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) Expect(q.Items[0].Type).To(Equal("Audio")) @@ -89,7 +103,7 @@ var _ = Describe("Similar", func() { It("does not duplicate the seed when the provider returns it", func() { providerFake.similarSongs = model.MediaFiles{ {ID: songID("So What"), Title: "So What", LibraryID: 1}, - {ID: "x1", Title: "Similar Song", LibraryID: 1}, + {ID: testID("x1"), Title: "Similar Song", LibraryID: 1}, } q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) @@ -97,9 +111,9 @@ var _ = Describe("Similar", func() { It("caps the mix at the requested limit", func() { providerFake.similarSongs = model.MediaFiles{ - {ID: "x1", Title: "S1", LibraryID: 1}, - {ID: "x2", Title: "S2", LibraryID: 1}, - {ID: "x3", Title: "S3", LibraryID: 1}, + {ID: testID("x1"), Title: "S1", LibraryID: 1}, + {ID: testID("x2"), Title: "S2", LibraryID: 1}, + {ID: testID("x3"), Title: "S3", LibraryID: 1}, } q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) @@ -107,21 +121,21 @@ var _ = Describe("Similar", func() { It("excludes similar songs from libraries the user can't access", func() { providerFake.similarSongs = model.MediaFiles{ - {ID: "x1", Title: "In Library", LibraryID: 1}, - {ID: "x2", Title: "Other Library", LibraryID: 2}, + {ID: testID("x1"), Title: "In Library", LibraryID: 1}, + {ID: testID("x2"), Title: "Other Library", LibraryID: 2}, } q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) }) It("returns a mix of the provider's similar songs for an artist seed", func() { - providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}} + providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Artist Mix Song", LibraryID: 1}} q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) }) It("returns an empty result (not 404) for an unknown item", func() { - w := get("/Items/" + enc("does-not-exist") + "/InstantMix") + w := get("/Items/" + enc(testID("does-not-exist")) + "/InstantMix") Expect(w.Code).To(Equal(200)) Expect(queryResult(w).Items).To(BeEmpty()) }) @@ -130,5 +144,10 @@ var _ = Describe("Similar", func() { q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) Expect(names(q.Items)).To(Equal([]string{"Help!"})) }) + + It("404s a malformed itemId", func() { + w := get("/Items/not-a-valid-id/InstantMix") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index c096d904a..954981910 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -43,7 +43,7 @@ var _ = Describe("Streaming", func() { }) It("returns 404 for an unknown track", func() { - Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) }) }) @@ -98,7 +98,7 @@ var _ = Describe("Streaming", func() { parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) Expect(info.MediaSources).To(HaveLen(1)) Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) - Expect(info.PlaySessionId).ToNot(BeEmpty()) + Expect(info.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) }) It("returns a MediaSource via POST", func() { diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index b844e7c74..65f02d7a0 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -14,14 +14,12 @@ import ( "strconv" "github.com/dustin/go-humanize" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/imghttp" - "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -29,7 +27,10 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) - itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + itemId, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) @@ -84,11 +85,14 @@ func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { } // postItemImage handles cover upload. Only playlists are writable here; album/artist covers come -// from scanning. The body is always drained first (even on the not-implemented path) because -// Finamp writes it synchronously and sees a broken pipe if we respond before reading it. +// from scanning. Past the auth and id gates the body is drained before answering — including on the +// not-implemented path — because Finamp writes it synchronously and would see a broken pipe. func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } // Honor the same artwork-upload gate and size cap as the native endpoint. u, _ := request.UserFrom(ctx) @@ -143,7 +147,10 @@ func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { // deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } if _, err := api.playlists.Get(ctx, id); err != nil { http.Error(w, "Not Implemented", http.StatusNotImplemented) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index bdd47e956..0e0d6220d 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -63,16 +63,29 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) var _ = Describe("Images", func() { It("streams album artwork", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(Equal("IMG")) - Expect(fa.recvId).To(ContainSubstring("a1")) + Expect(fa.recvId).To(ContainSubstring(testID("a1"))) + }) + + // A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image. + It("404s a malformed itemId instead of serving a placeholder", func() { + ds := &tests.MockDataStore{} + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest("not-a-valid-id") + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id") }) // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. @@ -81,22 +94,23 @@ var _ = Describe("Images", func() { fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("deleted-item")) + w, r := newImageRequest(dto.EncodeID(testID("deleted-item"))) api.getItemImage(w, r) + // 200 here is what separates a well-formed unknown id from a malformed one, which 404s. + Expect(w.Code).To(Equal(http.StatusOK)) Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") - Expect(w.Body.String()).ToNot(ContainSubstring("deleted-item")) }) It("sniffs the Content-Type instead of hardcoding it", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) fa := &fakeArtwork{data: png} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -105,15 +119,15 @@ var _ = Describe("Images", func() { It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("pl1")) + w, r := newImageRequest(dto.EncodeID(testID("pl1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring(testID("pl1"))) }) // This endpoint is public (no user in the request), so artwork must be resolved under an @@ -121,11 +135,11 @@ var _ = Describe("Images", func() { // silently falls back to the placeholder. It("resolves artwork under an elevated admin context", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -137,11 +151,11 @@ var _ = Describe("Images", func() { It("serves immutable when the tag param asserts the current hash", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) q := r.URL.Query() q.Set("tag", hash) r.URL.RawQuery = q.Encode() @@ -155,11 +169,11 @@ var _ = Describe("Images", func() { It("revalidates via no-cache when no tag is provided", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -199,21 +213,21 @@ var _ = Describe("postItemImage", func() { var fp *fakePlaylists BeforeEach(func() { - fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} api = &Router{playlists: fp} }) It("uploads a raw JPEG body and returns 204", func() { body := jpegBytes() w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImagePlaylistID).To(Equal(testID("pl1"))) Expect(fp.setImageBytes).To(Equal(body)) Expect(fp.setImageExt).To(Equal(".jpeg")) }) @@ -222,9 +236,9 @@ var _ = Describe("postItemImage", func() { raw := pngBytes() encoded := base64.StdEncoding.EncodeToString(raw) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded))) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded))) r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -238,9 +252,9 @@ var _ = Describe("postItemImage", func() { fp.getByIDErr = model.ErrNotFound bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) api.postItemImage(w, r) @@ -251,9 +265,9 @@ var _ = Describe("postItemImage", func() { It("returns 500 when the service fails", func() { fp.setImageErr = errors.New("boom") w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -263,9 +277,9 @@ var _ = Describe("postItemImage", func() { It("accepts a raw WebP body", func() { body := webpBytes() w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/webp") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -277,9 +291,9 @@ var _ = Describe("postItemImage", func() { It("accepts a raw GIF body", func() { body := gifBytes() w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/gif") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -292,9 +306,9 @@ var _ = Describe("postItemImage", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.MaxImageUploadSize = "16" // 16 bytes w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -310,9 +324,9 @@ var _ = Describe("postItemImage", func() { body := []byte(base64.StdEncoding.EncodeToString(img)) Expect(len(body)).To(BeNumerically(">", len(img))) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -326,9 +340,9 @@ var _ = Describe("postItemImage", func() { conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) body := []byte(base64.StdEncoding.EncodeToString(img)) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -338,9 +352,9 @@ var _ = Describe("postItemImage", func() { It("rejects a body that is neither an image nor base64 with 400", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!")) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -351,9 +365,9 @@ var _ = Describe("postItemImage", func() { It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -365,10 +379,10 @@ var _ = Describe("postItemImage", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableArtworkUpload = false w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false})) api.postItemImage(w, r) @@ -380,10 +394,10 @@ var _ = Describe("postItemImage", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableArtworkUpload = false w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true})) api.postItemImage(w, r) @@ -393,24 +407,24 @@ var _ = Describe("postItemImage", func() { var _ = Describe("deleteItemImage", func() { It("removes the playlist image and returns 204", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} api := &Router{playlists: fp} w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + Expect(fp.removeImagePlaylistID).To(Equal(testID("pl1"))) }) It("returns 501 for a non-playlist item", func() { fp := &fakePlaylists{getByIDErr: model.ErrNotFound} api := &Router{playlists: fp} w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) api.deleteItemImage(w, r) @@ -418,11 +432,11 @@ var _ = Describe("deleteItemImage", func() { }) It("returns 500 when the service fails", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}, removeImageErr: errors.New("boom")} api := &Router{playlists: fp} w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index fed5bbbc8..de0da88eb 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -2,6 +2,7 @@ package jellyfin import ( "context" + "errors" "io" "iter" "net/http" @@ -10,7 +11,6 @@ import ( "strings" "github.com/Masterminds/squirrel" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -34,6 +34,10 @@ func searchTerm(p *req.Values) string { func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Not Found", http.StatusNotFound) + return + } api.internalError(w, r, err) return } @@ -229,12 +233,37 @@ type itemsQuery struct { // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because -// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). -func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that +// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) { p := req.Params(r) + parentId, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Any malformed entry in one of these id lists must 404, not silently drop out of the filter + // (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything. + ids, ok := decodedQueryIDs(r, "ids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. + genreIds, ok := decodedQueryIDs(r, "genreids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Feishin fetches an album's tracks with AlbumIds instead of ParentId. + albumIds, ok := decodedQueryIDs(r, "albumids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + studioIds, ok := decodedQueryIDs(r, "studioids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } q := itemsQuery{ fields: dto.ParseFields(p.Strings("fields")...), - ids: decodedQueryIDs(r, "ids"), + ids: ids, rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), sortBy: p.StringOr("sortby", ""), @@ -243,20 +272,22 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu limit: p.IntOr("limit", 0), // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), - parentId: dto.DecodeID(p.StringOr("parentid", "")), - // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. - genreIds: decodedQueryIDs(r, "genreids"), - // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: parentId, + genreIds: genreIds, + albumIds: albumIds, years: parseYears(r), - studioIds: decodedQueryIDs(r, "studioids"), + studioIds: studioIds, } // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) contributingScope := p.StringOr("contributingartistids", "") - q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + if !ok { + return itemsQuery{}, model.ErrNotFound + } + q.artistId = artistId q.contributingOnly = albumArtistScope == "" && contributingScope != "" q.types = parseTypes(q.rawTypes) @@ -272,7 +303,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { - if q.parentId == playlistsFolderID { + if q.parentId == dto.PlaylistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. q.types = []string{"Playlist"} } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { @@ -285,14 +316,17 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu if q.isLibraryParent || len(q.types) > 1 { q.entityParent = "" } - return q + return q, nil } // queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via // the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites // screen requests). func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { - q := api.parseItemsQuery(ctx, r) + q, err := api.parseItemsQuery(ctx, r) + if err != nil { + return itemsResult{}, err + } switch { // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. case len(q.ids) > 0: @@ -322,7 +356,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult // ok is false when ParentId isn't a visible playlist, so the caller falls through to the type // dispatch: ParentId is usually an album or artist. func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { - if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { + if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID { return nil, false } // Tracks enforces visibility. @@ -450,18 +484,20 @@ func firstNonEmpty(vals ...string) string { return "" } -// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. -func firstDecodedID(s string) string { +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting +// whether it decoded successfully (see decodeFilterParam). +func firstDecodedID(s string) (string, bool) { if s == "" { - return "" + return "", true } first, _, _ := strings.Cut(s, ",") - return dto.DecodeID(strings.TrimSpace(first)) + return decodeFilterParam(strings.TrimSpace(first)) } -// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. -func decodedQueryIDs(r *http.Request, key string) []string { - return slice.Map(queryIDs(r, key), dto.DecodeID) +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if +// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter. +func decodedQueryIDs(r *http.Request, key string) ([]string, bool) { + return dto.DecodeIDs(queryIDs(r, key)) } // parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. @@ -740,7 +776,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q // to their library, so an id can't probe content outside the user's libraries. func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { // The synthetic playlists folder must resolve by the id we advertised, not 404. - if id == playlistsFolderID { + if id == dto.PlaylistsFolderID { return playlistsFolder(), true } u, _ := request.UserFrom(ctx) @@ -802,14 +838,11 @@ func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]mode } // itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. -// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored -// queue items against its stored (truncated) ids. func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { u, _ := request.UserFrom(ctx) - fullIDs := api.resolveItemIDs(ctx, ids) - songs := api.songsByIDs(ctx, fullIDs) + songs := api.songsByIDs(ctx, ids) var items []dto.BaseItemDto - for i, id := range fullIDs { + for _, id := range ids { var item dto.BaseItemDto if mf, ok := songs[id]; ok { if !u.HasLibraryAccess(mf.LibraryID) { @@ -819,16 +852,16 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { continue } - if id != ids[i] { - item.Id = dto.EncodeID(ids[i]) - } items = append(items, item) } return result(items, len(items), 0) } func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) @@ -841,7 +874,10 @@ func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { // scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } if err := api.playlists.Delete(ctx, id); err != nil { api.playlistError(w, r, err) return diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 3213379e5..b227ab719 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" + "strings" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" @@ -30,14 +32,14 @@ var _ = Describe("Items", func() { var fp *fakePlaylists // alice has access to library 1 only; used by tests that don't care about scoping. ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) } ctxUserWithLibraries := func(libs model.Libraries) context.Context { - return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) } // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. ctxAdmin := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) } BeforeEach(func() { ds = &tests.MockDataStore{} @@ -47,7 +49,7 @@ var _ = Describe("Items", func() { Describe("getItems", func() { It("lists albums when IncludeItemTypes=MusicAlbum", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -60,44 +62,44 @@ var _ = Describe("Items", func() { }) It("lists an album's songs when ParentId is an album and type is Audio", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { - fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, }} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) Expect(res.TotalRecordCount).To(Equal(2)) }) It("pages a playlist parent's tracks in the query, not in memory", func() { - fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, - {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, + {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, }} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -105,24 +107,24 @@ var _ = Describe("Items", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.TotalRecordCount).To(Equal(3)) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) Expect(fp.tracksRepo.Options.Max).To(Equal(1)) }) It("falls through to the type dispatch when ParentId is not a playlist", func() { fp.getErr = model.ErrNotFound - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { @@ -137,13 +139,13 @@ var _ = Describe("Items", func() { // looking for tracks outside any album; answering with every track streams the whole library. Describe("Recursive=false", func() { BeforeEach(func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) }) It("returns no songs for a library parent, as tracks are never its direct children", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -155,7 +157,7 @@ var _ = Describe("Items", func() { It("drops only Audio from a multi-type library query", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -166,7 +168,7 @@ var _ = Describe("Items", func() { It("still lists albums for a library parent, as they are its direct children", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -177,13 +179,13 @@ var _ = Describe("Items", func() { It("still lists an album's tracks, as they are its direct children", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("keeps returning every song when no parent scopes the query", func() { @@ -199,7 +201,7 @@ var _ = Describe("Items", func() { // `recursive ?? false`, so an omitted Recursive is a non-recursive request. It("treats an omitted Recursive as false, like Jellyfin", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -209,9 +211,9 @@ var _ = Describe("Items", func() { }) It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) @@ -223,7 +225,7 @@ var _ = Describe("Items", func() { }) It("lists artists when IncludeItemTypes=MusicArtist", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -244,7 +246,7 @@ var _ = Describe("Items", func() { }) It("lists playlists when IncludeItemTypes=Playlist", func() { - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -253,13 +255,13 @@ var _ = Describe("Items", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Type).To(Equal("Playlist")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1")))) Expect(res.TotalRecordCount).To(Equal(1)) }) It("merges results from every requested type in IncludeItemTypes", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -274,11 +276,11 @@ var _ = Describe("Items", func() { It("merges favorite songs, albums, and playlists", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -298,8 +300,8 @@ var _ = Describe("Items", func() { }) It("applies StartIndex/Limit to the merged multi-type result set", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -313,9 +315,9 @@ var _ = Describe("Items", func() { It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -327,7 +329,7 @@ var _ = Describe("Items", func() { It("applies a starred filter when Filters=IsFavorite", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -339,7 +341,7 @@ var _ = Describe("Items", func() { It("forwards SearchTerm to the repo's Search method", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -351,7 +353,7 @@ var _ = Describe("Items", func() { It("caps a search the client left unbounded", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -361,7 +363,7 @@ var _ = Describe("Items", func() { It("honors an explicit search Limit up to the ceiling", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). WithContext(ctxUser()) @@ -372,7 +374,7 @@ var _ = Describe("Items", func() { It("clamps a search Limit that would materialize the library", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). WithContext(ctxUser()) @@ -383,7 +385,7 @@ var _ = Describe("Items", func() { It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). WithContext(ctxUser()) @@ -398,10 +400,10 @@ var _ = Describe("Items", func() { It("reports a multi-type search total past the page, so clients keep paging", func() { songs := make(model.MediaFiles, defaultSearchLimit*2) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). WithContext(ctxUser()) @@ -415,8 +417,8 @@ var _ = Describe("Items", func() { It("bounds the multi-type search window however large StartIndex is", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). WithContext(ctxUser()) @@ -431,10 +433,10 @@ var _ = Describe("Items", func() { // past that window the merged order is no longer the true one. songs := make(model.MediaFiles, maxSearchLimit+1) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), @@ -450,10 +452,12 @@ var _ = Describe("Items", func() { It("serves the last page below the ceiling in full", func() { songs := make(model.MediaFiles, maxSearchLimit+1) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } + // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. + slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), @@ -470,10 +474,10 @@ var _ = Describe("Items", func() { It("bounds an unbounded multi-type search to the default in total, not per type", func() { songs := make(model.MediaFiles, defaultSearchLimit*2) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). WithContext(ctxUser()) @@ -487,10 +491,12 @@ var _ = Describe("Items", func() { It("pages an unbounded multi-type search past the default without dropping matches", func() { songs := make(model.MediaFiles, defaultSearchLimit*2) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } + // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. + slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), @@ -506,7 +512,7 @@ var _ = Describe("Items", func() { It("reports a search total beyond the fetched page instead of the page length", func() { ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ - {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, + {ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"}, }) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) @@ -520,7 +526,7 @@ var _ = Describe("Items", func() { It("forwards StartIndex/Limit as Offset/Max", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -533,31 +539,31 @@ var _ = Describe("Items", func() { // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; without // this, queryItems ignored Ids and returned the default type-dispatched list instead. It("returns exactly the requested item when Ids has a single id", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.Items[0].Name).To(Equal("Song")) Expect(res.TotalRecordCount).To(Equal(1)) }) It("returns items of different types for a lowercase ids param with multiple ids", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(2)) ids := []string{res.Items[0].Id, res.Items[1].Id} - Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1"))) + Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1")))) types := []string{res.Items[0].Type, res.Items[1].Type} Expect(types).To(ConsistOf("MusicAlbum", "Audio")) Expect(res.TotalRecordCount).To(Equal(2)) @@ -565,9 +571,9 @@ var _ = Describe("Items", func() { It("resolves song ids with one batched IN query, not a Get per id", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult @@ -576,20 +582,20 @@ var _ = Describe("Items", func() { sql, args, err := mfRepo.Options.Filters.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("media_file.id IN")) - Expect(args).To(ConsistOf("s1", "s2")) + Expect(args).To(ConsistOf(testID("s1"), testID("s2"))) }) It("omits an id in a library the user can't access, without erroring the whole batch", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1 w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) Expect(res.TotalRecordCount).To(Equal(1)) }) }) @@ -597,7 +603,7 @@ var _ = Describe("Items", func() { Describe("sorting", func() { It("maps SortBy=PlayCount to the play_count column", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -607,7 +613,7 @@ var _ = Describe("Items", func() { It("maps SortBy=DatePlayed to the play_date column", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -617,7 +623,7 @@ var _ = Describe("Items", func() { It("uses the first recognized key in a comma-separated SortBy list", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -627,7 +633,7 @@ var _ = Describe("Items", func() { It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -637,7 +643,7 @@ var _ = Describe("Items", func() { It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -647,7 +653,7 @@ var _ = Describe("Items", func() { It("leaves Sort at the repo default when no SortBy key is recognized", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -659,7 +665,7 @@ var _ = Describe("Items", func() { Describe("library scoping", func() { It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) @@ -673,7 +679,7 @@ var _ = Describe("Items", func() { It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) @@ -687,7 +693,7 @@ var _ = Describe("Items", func() { It("scopes a MusicArtist listing to the user's accessible libraries", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) @@ -701,10 +707,10 @@ var _ = Describe("Items", func() { It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := albumRepo.Options.Filters.ToSql() @@ -716,10 +722,10 @@ var _ = Describe("Items", func() { It("does not let ParentId=<inaccessible library id> scope results to that library", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := albumRepo.Options.Filters.ToSql() @@ -734,7 +740,7 @@ var _ = Describe("Items", func() { It("does not restrict a default MusicAlbum listing for an admin user", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) invoke(api.getItems, w, r) @@ -750,10 +756,63 @@ var _ = Describe("Items", func() { }) }) + // A malformed id must 404, not silently drop the filter; a well-formed but unknown one must + // still reach the entity filter, not the unfiltered default. + Describe("stale and malformed id filtering", func() { + It("404s a malformed ParentId instead of listing every song", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed AlbumArtistIds instead of listing every album", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed ArtistIds instead of listing every song", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("album_artists")) + }) + + It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("album_id")) + Expect(args).To(ContainElement(testID("no-such-album"))) + }) + }) + Describe("mixed IncludeItemTypes merge", func() { BeforeEach(func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "S1"}, {ID: "s2", Title: "S2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) }) It("returns a mix of both types, not all of one", func() { @@ -815,54 +874,54 @@ var _ = Describe("Items", func() { Describe("getItem", func() { It("returns an album by id", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) Expect(item.Type).To(Equal("MusicAlbum")) }) It("returns 404 when the id doesn't match any entity", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 for an album in a library the user can't access", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 for a song in a library the user can't access", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns an album to an admin even when it's outside their (empty) Libraries", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) }) // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the @@ -870,13 +929,13 @@ var _ = Describe("Items", func() { It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 1, Name: "Music Library"}} - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) Expect(item.Name).To(Equal("Music Library")) Expect(item.Type).To(Equal("CollectionFolder")) Expect(item.CollectionType).To(Equal("music")) @@ -886,8 +945,8 @@ var _ = Describe("Items", func() { It("does not resolve a library-view id the user has no access to", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -895,15 +954,15 @@ var _ = Describe("Items", func() { // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it // 404s with "Could not fetch BaseItemDto <playlist> from server." It("resolves a playlist id via the playlists service", func() { - fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("p1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("p1")))) Expect(item.Name).To(Equal("My Mix")) Expect(item.Type).To(Equal("Playlist")) }) @@ -911,23 +970,23 @@ var _ = Describe("Items", func() { It("returns 404 for a non-owned or absent playlist id", func() { fp.getByIDErr = model.ErrNotFound w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). It("resolves a genre id as a MusicGenre item", func() { - Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: "g1", Name: "Rock"})).To(Succeed()) + Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("g1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("g1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("g1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("g1")))) Expect(item.Name).To(Equal("Rock")) Expect(item.Type).To(Equal("MusicGenre")) }) @@ -935,13 +994,13 @@ var _ = Describe("Items", func() { It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin()) - r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) Expect(item.Name).To(Equal("Music Library")) Expect(item.Type).To(Equal("CollectionFolder")) }) @@ -949,7 +1008,7 @@ var _ = Describe("Items", func() { Describe("getLatest", func() { It("returns a bare array of the newest albums", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) invoke(api.getLatest, w, r) @@ -957,12 +1016,12 @@ var _ = Describe("Items", func() { var items []dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) Expect(items).To(HaveLen(1)) - Expect(items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) }) It("scopes to the user's accessible libraries", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) @@ -1014,4 +1073,47 @@ var _ = Describe("Items", func() { Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) }) }) + + Describe("decodeFilterParam", func() { + It("reports ok for an absent param, decoding to \"\"", func() { + id, ok := decodeFilterParam("") + Expect(id).To(BeEmpty()) + Expect(ok).To(BeTrue()) + }) + + It("reports ok for a well-formed id, whether or not it exists", func() { + id, ok := decodeFilterParam(dto.EncodeID(testID("a1"))) + Expect(id).To(Equal(testID("a1"))) + Expect(ok).To(BeTrue()) + }) + + It("reports not ok for a non-empty param that fails to decode", func() { + id, ok := decodeFilterParam("not-a-valid-id") + Expect(id).To(BeEmpty()) + Expect(ok).To(BeFalse()) + }) + }) + + Describe("decodedQueryIDs", func() { + It("decodes every entry when all are well-formed", func() { + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeTrue()) + Expect(ids).To(Equal([]string{testID("a1"), testID("a2")})) + }) + + It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() { + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeFalse()) + Expect(ids).To(BeEmpty()) + }) + + It("reports ok for an absent param, decoding to an empty list", func() { + r := httptest.NewRequest("GET", "/Items", nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeTrue()) + Expect(ids).To(BeEmpty()) + }) + }) }) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go index aab9628a0..2ae7da6e2 100644 --- a/server/jellyfin/jellyfin_suite_test.go +++ b/server/jellyfin/jellyfin_suite_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -17,6 +18,10 @@ func TestJellyfinApi(t *testing.T) { RunSpecs(t, "Jellyfin API Suite") } +// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same +// id shape production uses. +func testID(label string) string { return id.NewHash("jellyfin-test", label) } + // invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call // handlers directly (with withChiURLParam for path params) instead of routing, so without this the // case-insensitive query folding real requests get would be skipped and PascalCase params dropped. diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2830f1105..b21603843 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/Masterminds/squirrel" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -33,9 +34,36 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// decodeFilterParam separates an absent param from an undecodable one: dropping the filter for a +// stale id would silently widen the query to the whole library. It is the only place that opts +// into treating "absent" as fine — every other caller of dto.DecodeID must 404 on ok=false. +func decodeFilterParam(raw string) (id string, ok bool) { + if raw == "" { + return "", true + } + return dto.DecodeID(raw) +} + +// itemIDParam decodes a chi URL id param, writing 404 and reporting false when it isn't a +// well-formed GUID. Handlers must return immediately when ok is false. +func itemIDParam(w http.ResponseWriter, r *http.Request, key string) (string, bool) { + id, ok := dto.DecodeID(chi.URLParam(r, key)) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return "", false + } + return id, true +} + // parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). -func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { - return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +// ok is false when a non-empty ParentId fails to decode (see decodeFilterParam). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool, ok bool) { + parentId, ok := decodeFilterParam(req.Params(r).StringOr("parentid", "")) + if !ok { + return nil, false, false + } + scopeIDs, isLibraryParent = resolveLibraryScope(ctx, parentId) + return scopeIDs, isLibraryParent, true } // libraryScopeFilter restricts a tag query to the given library scope. Empty scope means @@ -51,7 +79,7 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer { // Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. func libraryView(lib model.Library) dto.BaseItemDto { return dto.BaseItemDto{ - Id: dto.EncodeID(strconv.Itoa(lib.ID)), + Id: dto.EncodeLibraryID(lib.ID), Name: lib.Name, Type: "CollectionFolder", CollectionType: "music", diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index 402d14ed0..2cf53a280 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -53,8 +53,8 @@ var _ = Describe("getLyrics", func() { BeforeEach(func() { ds = &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", LibraryID: 1}, - {ID: "s2", Title: "Silent Song", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", LibraryID: 1}, + {ID: testID("s2"), Title: "Silent Song", LibraryID: 1}, }) fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} api = &Router{ @@ -66,7 +66,7 @@ var _ = Describe("getLyrics", func() { doRequest := func(id string) *httptest.ResponseRecorder { w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}) // Clients send hex-encoded ids (matching real traffic and the other handler tests). enc := dto.EncodeID(id) r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) @@ -76,10 +76,10 @@ var _ = Describe("getLyrics", func() { } It("returns 200 with a LyricDto for a track with synced lyrics", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - w := doRequest("s1") + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.LyricDto @@ -91,11 +91,11 @@ var _ = Describe("getLyrics", func() { }) It("serves the main-kind lyric when a translation is also present", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - w := doRequest("s1") + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.LyricDto @@ -105,13 +105,13 @@ var _ = Describe("getLyrics", func() { }) It("returns 404 when the service returns no lyrics", func() { - w := doRequest("s2") + w := doRequest(testID("s2")) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 when the main lyric has no lines", func() { - fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}} - w := doRequest("s1") + fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}} + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -121,35 +121,35 @@ var _ = Describe("getLyrics", func() { }) It("caches results so a second request doesn't re-invoke the service", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) Expect(fake.calls).To(Equal(1)) }) It("caches empty results too", func() { - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) Expect(fake.calls).To(Equal(1)) }) It("completes and caches the fetch even when the request context is cancelled", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } ctx, cancel := context.WithCancel(context.Background()) cancel() - list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"}) + list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")}) Expect(list).ToNot(BeEmpty()) - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) Expect(fake.calls).To(Equal(1)) }) It("bounds the detached fetch with a timeout", func() { - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) Expect(fake.hadDeadline).To(BeTrue()) }) }) diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index f3aa65d6f..b17b9a4ec 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -24,12 +24,12 @@ var _ = Describe("authenticate middleware", func() { ds = &tests.MockDataStore{} auth.Init(ds) ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) tokenFor := func(name string) string { - t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name}) + t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name}) Expect(err).ToNot(HaveOccurred()) return t } @@ -129,7 +129,7 @@ var _ = Describe("withPlayer middleware", func() { }) It("injects the player's server-forced transcoding into the context", func() { - players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"} _, trc, hasTrc := callWith() Expect(hasTrc).To(BeTrue()) Expect(trc.TargetFormat).To(Equal("opus")) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 9afee5ac3..805b369c8 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -17,16 +17,11 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve -// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal -// can't collide with real ids (those are hashes). -const playlistsFolderID = "playlists" - // playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be // "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. func playlistsFolder() dto.BaseItemDto { return dto.BaseItemDto{ - Id: dto.EncodeID(playlistsFolderID), + Id: dto.PlaylistsFolderGUID, Name: "Playlists", Type: "ManualPlaylistsFolder", CollectionType: "playlists", @@ -61,7 +56,12 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad Request", http.StatusBadRequest) return } - ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID)) + decoded, ok := dto.DecodeIDs(body.Ids) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(r.Context(), decoded) id, err := api.playlists.Create(r.Context(), "", body.Name, ids) if err != nil { api.internalError(w, r, err) @@ -80,7 +80,10 @@ type updatePlaylistRequest struct { func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } var body updatePlaylistRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) @@ -96,7 +99,12 @@ func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { return } } else { - ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + decoded, ok := dto.DecodeIDs(*body.Ids) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { api.playlistError(w, r, err) return @@ -146,7 +154,7 @@ func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields // individually removable. func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { item := dto.SongToBaseItem(t.MediaFile, fields) - item.PlaylistItemId = dto.EncodeID(t.ID) + item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID) return item } @@ -155,7 +163,10 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { // be probed. func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } pls, err := api.playlists.Get(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -184,7 +195,10 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { // playlist id can't probe for private playlists. func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } repo, err := api.playlists.Tracks(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -232,7 +246,7 @@ func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []strin } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) } else { - out = append(out, id) // unknown id — pass through unchanged + out = append(out, id) // well-formed but unresolved — left for the caller's write to handle } } return out @@ -251,8 +265,16 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin // AddTracks enforces ownership; any error maps to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) - ids := api.expandContainerIDs(ctx, slice.Map(queryIDs(r, "ids"), dto.DecodeID)) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + decoded, ok := dto.DecodeIDs(queryIDs(r, "ids")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { http.Error(w, "Not Found", http.StatusNotFound) return @@ -265,8 +287,20 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { // ownership; any error maps to 404. func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) - ids := slice.Map(queryIDs(r, "entryids"), dto.DecodeID) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + raw := queryIDs(r, "entryids") + ids := make([]string, 0, len(raw)) + for _, entryGUID := range raw { + entry, ok := dto.DecodePlaylistEntryID(entryGUID) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids = append(ids, entry) + } if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { http.Error(w, "Not Found", http.StatusNotFound) return diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 7770cb6db..3599cc784 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -67,7 +67,7 @@ func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []s if f.createErr != nil { return "", f.createErr } - return "pl-new", nil + return testID("pl-new"), nil } // Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real @@ -145,15 +145,15 @@ var _ = Describe("Playlists", func() { Describe("createPlaylist", func() { It("creates a playlist and returns its id", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)). + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("s2"))+`"]}`)). WithContext(context.Background()) invoke(api.createPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res map[string]string Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new"))) + Expect(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new")))) Expect(fp.createdName).To(Equal("Mix")) - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("returns 400 on an invalid JSON body", func() { @@ -177,48 +177,48 @@ var _ = Describe("Playlists", func() { Describe("getPlaylistItems", func() { It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { fp.getPls = &model.Playlist{ - ID: "pl1", + ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}}, }, } w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) api.getPlaylistItems(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.TotalRecordCount).To(Equal(2)) Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) - Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2"))) - Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2")))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2"))) }) It("pages with StartIndex/Limit, pushing them down to the query", func() { fp.getPls = &model.Playlist{ - ID: "pl1", + ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, - {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, + {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, }, } w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil). WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.getPlaylistItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.TotalRecordCount).To(Equal(3)) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) Expect(fp.tracksRepo.Options.Max).To(Equal(1)) }) @@ -227,7 +227,7 @@ var _ = Describe("Playlists", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "missing") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) api.getPlaylistItems(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -245,73 +245,73 @@ var _ = Describe("Playlists", func() { createWith := func(id string) { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(id)+`"]}`)). WithContext(ctx) invoke(api.createPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusOK)) } It("passes a bare song id through unchanged", func() { - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}}) - createWith("s1") - Expect(fp.createdIds).To(Equal([]string{"s1"})) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) + createWith(testID("s1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1")})) }) It("expands an album id into its songs, filtered by album", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + {ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")}, }) - createWith("al1") - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters)) + createWith(testID("al1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters)) }) It("expands an artist id into its songs", func() { - ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}}) - createWith("ar1") - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters)) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) + createWith(testID("ar1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters)) }) It("expands a playlist id into its tracks' media file ids", func() { - fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"}, + fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")}, }} - createWith("pl9") - Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + createWith(testID("pl9")) + Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")})) }) }) Describe("getPlaylist", func() { It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { pls := &model.Playlist{ - ID: "pl1", + ID: testID("pl1"), Public: true, Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, }, } fp.getPls, fp.getByIDPls = pls, pls w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.getPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaylistInfo Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.OpenAccess).To(BeTrue()) Expect(res.Shares).To(BeEmpty()) - Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")})) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("s2"))})) }) It("returns 404 for a non-owned or absent playlist", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "missing") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) invoke(api.getPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -325,29 +325,29 @@ var _ = Describe("Playlists", func() { It("deletes the playlist and returns 204", func() { w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.deletePlaylistID).To(Equal("pl1")) + Expect(fp.deletePlaylistID).To(Equal(testID("pl1"))) }) It("returns 403 when the user doesn't own the playlist", func() { fp.deleteErr = model.ErrNotAuthorized w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusForbidden)) }) It("returns 404 for a missing playlist or non-playlist id", func() { fp.deleteErr = model.ErrNotFound w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("al1")) + api.deleteItem(w, deleteReq(testID("al1"))) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 500 on an unexpected error", func() { fp.deleteErr = errors.New("boom") w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) }) @@ -355,39 +355,39 @@ var _ = Describe("Playlists", func() { Describe("addToPlaylist", func() { It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal("pl1")) - Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) + Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("returns 404 when the service rejects the request (not found/not owned)", func() { fp.addErr = model.ErrNotAuthorized w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("passes no ids (not a spurious empty string) when the ids param is absent", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) Expect(fp.addIds).To(BeEmpty()) }) }) @@ -395,39 +395,48 @@ var _ = Describe("Playlists", func() { Describe("removeFromPlaylist", func() { It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) Expect(fp.removeIds).To(Equal([]string{"1", "2"})) }) It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.removeIds).To(Equal([]string{"1", "2"})) }) + It("404s an entryId that is a song id rather than a playlist-entry position", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(fp.removeIds).To(BeEmpty()) + }) + It("returns 404 when the service rejects the request (not found/not owned)", func() { fp.removeErr = model.ErrNotFound w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) Expect(fp.removeIds).To(BeEmpty()) }) }) @@ -435,30 +444,30 @@ var _ = Describe("Playlists", func() { Describe("getPlaylistUsers", func() { It("returns the current user with CanEdit true", func() { w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", "pl1") + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) api.getPlaylistUsers(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res []dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}})) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) }) }) Describe("getPlaylistUser", func() { It("returns CanEdit true for the requested user", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background()) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", "pl1") - rctx.URLParams.Add("userId", "u1") + rctx.URLParams.Add("playlistId", testID("pl1")) + rctx.URLParams.Add("userId", testID("u1")) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) api.getPlaylistUser(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true})) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) }) }) }) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f83e5182d..a145e7359 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -21,14 +21,19 @@ type playbackReport struct { // decodeReport reads the playback report body. ItemId falls back to a query param (some clients send // it there) and is decoded here since it flows straight into scrobbler lookups by media file id. -// Finamp reports restored-queue playback with truncated ids, hence resolveItemID. +// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request +// failure; it just can't identify a track, same as an absent one. func (api *Router) decodeReport(r *http.Request) playbackReport { var body playbackReport _ = json.NewDecoder(r.Body).Decode(&body) if body.ItemId == "" { body.ItemId = r.URL.Query().Get("itemid") } - body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId)) + if id, ok := dto.DecodeID(body.ItemId); ok { + body.ItemId = id + } else { + body.ItemId = "" + } return body } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 24f945efd..05151c39a 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -57,8 +58,8 @@ var _ = Describe("Sessions", func() { var pt *fakePlayTracker authed := func(r *http.Request) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) - ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"}) return r.WithContext(ctx) } @@ -70,35 +71,46 @@ var _ = Describe("Sessions", func() { Describe("reportPlaybackStart", func() { It("reports playback start with the item id and position", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":10000000}`))) invoke(api.reportPlaybackStart, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) - Expect(pt.reported[0].ClientId).To(Equal("p1")) + Expect(pt.reported[0].ClientId).To(Equal(testID("p1"))) Expect(pt.reported[0].ClientName).To(Equal("Finamp")) }) It("falls back to the ItemId query param when the body has none", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil)) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("s2")), nil)) invoke(api.reportPlaybackStart, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal("s2")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s2"))) + }) + + It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","PositionTicks":10000000}`))) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(BeEmpty()) }) }) Describe("reportPlaybackProgress", func() { It("reports the playing state when not paused", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`))) invoke(api.reportPlaybackProgress, w, r) @@ -110,7 +122,7 @@ var _ = Describe("Sessions", func() { It("reports the paused state when IsPaused is true", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`))) invoke(api.reportPlaybackProgress, w, r) @@ -123,14 +135,14 @@ var _ = Describe("Sessions", func() { Describe("reportPlaybackStopped", func() { It("reports the stopped state and lets the scrobbler apply its play threshold", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":600000000}`))) invoke(api.reportPlaybackStopped, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 2698073bf..3e0b3b11b 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -6,7 +6,6 @@ import ( "net/http" "time" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -56,7 +55,10 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch // external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are // returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { return api.similarArtists(ctx, id, limit) @@ -68,7 +70,10 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { // result (not 404) so the client stops retrying. func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) @@ -93,7 +98,10 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { // results, never a 404 the client would surface as an error. func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index 302566195..dc8c1e605 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -34,7 +34,7 @@ var _ = Describe("awaitSimilar", func() { }) It("returns the fetch result when it completes within the wait", func() { - res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult { return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) }) Expect(res.Items).To(HaveLen(1)) @@ -45,7 +45,7 @@ var _ = Describe("awaitSimilar", func() { shortenWait() release := make(chan struct{}) DeferCleanup(func() { close(release) }) - res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + res := api.awaitSimilar(ctxFor(testID("u1")), "id2", 20, func(context.Context) dto.QueryResult { <-release // hung provider; would finish caching in the background return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) }) @@ -64,8 +64,8 @@ var _ = Describe("awaitSimilar", func() { } // Both calls time out, but the flight can't complete before release closes, so the // second call must join it rather than start a new fetch. - api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) - api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) close(release) Eventually(calls.Load).Should(Equal(int32(1))) Consistently(calls.Load, "50ms").Should(Equal(int32(1))) @@ -77,15 +77,15 @@ var _ = Describe("awaitSimilar", func() { calls.Add(1) return result(nil, 0, 0) } - api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) - api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch) + api.awaitSimilar(ctxFor(testID("u2")), "id4", 20, fetch) Expect(calls.Load()).To(Equal(int32(2))) }) It("hands the fetch a deadline-bounded background context", func() { var deadline time.Time var hasDeadline bool - api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult { + api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult { deadline, hasDeadline = ctx.Deadline() return result(nil, 0, 0) }) @@ -123,16 +123,16 @@ var _ = Describe("getInstantMix", func() { ds := &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Seed Song", LibraryID: 1}, + {ID: testID("s1"), Title: "Seed Song", LibraryID: 1}, }) release := make(chan struct{}) DeferCleanup(func() { close(release) }) api := &Router{ds: ds, provider: &blockingProvider{release: release}} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getInstantMix(w, r) var res dto.QueryResult @@ -145,18 +145,18 @@ var _ = Describe("getInstantMix", func() { // queue, so InstantMix gets its own, higher ceiling. It("honors a mix-sized limit above the Similar ceiling", func() { const want = 250 - songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + songs := model.MediaFiles{{ID: testID("s1"), Title: "Seed Song", LibraryID: 1}} for i := range want + 50 { // more than requested, so only the limit bounds the result - songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + songs = append(songs, model.MediaFile{ID: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) } ds := &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). - WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getInstantMix(w, r) var res dto.QueryResult diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 8c509fe91..401c791fd 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -88,9 +88,9 @@ var _ = Describe("handleSocket", func() { ds = &tests.MockDataStore{} auth.Init(ds) ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) token = t diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 022c00c96..86746f89a 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -23,7 +23,10 @@ import ( // Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { ctx := r.Context() - id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return nil, false + } mf, err := api.ds.MediaFile(ctx).Get(id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -62,7 +65,7 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { src.TranscodingSubProtocol = "http" src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) } - api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } // streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index f77c10ec1..d6220d33f 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -26,7 +26,7 @@ var _ = Describe("Stream", func() { // alice has access to library 1 only. ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) } BeforeEach(func() { @@ -43,30 +43,30 @@ var _ = Describe("Stream", func() { Describe("getPlaybackInfo", func() { It("returns a media source for an accessible track", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.MediaSources[0].Container).To(Equal("mp3")) Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) - Expect(res.PlaySessionId).ToNot(BeEmpty()) + Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) }) It("returns 404 for a track in a library the user can't access", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -75,7 +75,7 @@ var _ = Describe("Stream", func() { It("returns 404 when the id doesn't match any media file", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -83,8 +83,8 @@ var _ = Describe("Stream", func() { playbackInfo := func() dto.PlaybackInfoResponse { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) @@ -103,10 +103,10 @@ var _ = Describe("Stream", func() { It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, }} Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) @@ -114,7 +114,7 @@ var _ = Describe("Stream", func() { It("advertises no Lyric stream when the pipeline finds nothing", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) Expect(lyricStreams(playbackInfo())).To(BeEmpty()) @@ -122,10 +122,10 @@ var _ = Describe("Stream", func() { It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Lang: "eng"}}, + testID("s1"): {{Kind: "main", Lang: "eng"}}, }} Expect(lyricStreams(playbackInfo())).To(BeEmpty()) @@ -133,10 +133,10 @@ var _ = Describe("Stream", func() { It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, }} Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) @@ -146,20 +146,20 @@ var _ = Describe("Stream", func() { // Own ID: an erroring loader isn't cached, but a shared ID could still pick up // another test's cached (non-error) result and mask this assertion. ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + {ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) api.lyrics = &fakeLyricsService{err: errors.New("boom")} w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s-err")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s-err"))) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s-err")))) Expect(lyricStreams(res)).To(BeEmpty()) }) }) @@ -167,12 +167,12 @@ var _ = Describe("Stream", func() { Describe("streamAudio", func() { It("invokes the transcode decider and streamer for an accessible track", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.content = "audio-bytes" w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -183,11 +183,11 @@ var _ = Describe("Stream", func() { It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -198,7 +198,7 @@ var _ = Describe("Stream", func() { It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -208,11 +208,11 @@ var _ = Describe("Stream", func() { It("converts the bps audioBitRate param to kbps", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(decider.req.BitRate).To(Equal(320)) @@ -220,11 +220,11 @@ var _ = Describe("Stream", func() { It("uses the audioCodec param as target format when no container is given", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(decider.req.Format).To(Equal("aac")) @@ -232,12 +232,12 @@ var _ = Describe("Stream", func() { It("returns 500 and logs when the streamer fails", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.err = errors.New("boom") w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) @@ -247,14 +247,14 @@ var _ = Describe("Stream", func() { Describe("streamHls", func() { BeforeEach(func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, }) }) hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamHls, w, r) return w } @@ -302,7 +302,7 @@ var _ = Describe("Stream", func() { It("returns 404 for a track in a library the user can't access", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2}, }) Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) }) @@ -316,12 +316,12 @@ var _ = Describe("Stream", func() { Describe("streamFile", func() { It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.content = "audio-bytes" w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -333,11 +333,11 @@ var _ = Describe("Stream", func() { It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -348,7 +348,7 @@ var _ = Describe("Stream", func() { It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 6f0c359d4..ea553fd87 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -2,10 +2,12 @@ package jellyfin import ( "context" + "encoding/hex" "errors" "fmt" "net/http" "path" + "strings" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" @@ -42,25 +44,32 @@ func (api *Router) serverID(ctx context.Context) string { return api.serverIDVal } if api.ds == nil { - api.serverIDVal = uuid.NewString() + api.serverIDVal = newServerID() return api.serverIDVal } id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): - id = uuid.NewString() + id = newServerID() if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { log.Error(ctx, "Jellyfin API: could not persist server id", err) return id } case err != nil: log.Error(ctx, "Jellyfin API: could not read server id", err) - return uuid.NewString() + return newServerID() } - api.serverIDVal = id + // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. + api.serverIDVal = strings.ReplaceAll(id, "-", "") return api.serverIDVal } +// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). +func newServerID() string { + u := uuid.New() + return hex.EncodeToString(u[:]) +} + func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { return dto.PublicSystemInfo{ LocalAddress: localAddress(r), diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 7846e4a20..a7f7df6a4 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -125,21 +125,33 @@ var _ = Describe("System", func() { }) It("does not overwrite or pin over a stored id when the property read fails transiently", func() { - Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed()) r := &Router{ds: ds} props := ds.Property(ctx).(*tests.MockedPropertyRepo) props.Error = errors.New("database is locked") degraded := r.serverID(ctx) Expect(degraded).ToNot(BeEmpty()) - Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one + Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one props.Error = nil // Once the DB recovers, the stored id is intact and served again. - Expect(r.serverID(ctx)).To(Equal("stable-id")) + Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal("stable-id")) + Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) + }) + + It("returns Jellyfin's no-dash GUID form", func() { + r := &Router{ds: ds} + Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$")) + }) + + It("strips dashes from an already-persisted id", func() { + Expect(ds.Property(ctx).Put( + consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed()) + r := &Router{ds: ds} + Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427")) }) }) }) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go deleted file mode 100644 index bb3e4ce9b..000000000 --- a/server/jellyfin/truncated_ids.go +++ /dev/null @@ -1,113 +0,0 @@ -package jellyfin - -import ( - "context" - "slices" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming -// Jellyfin GUIDs). All Navidrome ids are 22 chars (share ids 10), so length alone flags a -// truncated id. See README. -// -// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers -// and ParentId scoping don't (a restored queue never edits playlists or browses by container id). -const truncatedIDLen = 16 - -// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is -// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous. -func (api *Router) resolveItemID(ctx context.Context, id string) string { - if len(id) != truncatedIDLen { - return id - } - probes := []func() []string{ - func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) }, - func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) }, - func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) }, - func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) }, - } - for _, probe := range probes { - switch ids := probe(); len(ids) { - case 0: - continue - case 1: - log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0]) - return ids[0] - default: - log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id) - return id - } - } - return id -} - -// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of -// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the -// leftovers (containers, unknowns) fall back to the per-id probes. -func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string { - var truncated []string - for _, id := range ids { - if len(id) == truncatedIDLen { - truncated = append(truncated, id) - } - } - if len(truncated) == 0 { - return ids - } - - byPrefix := make(map[string][]string, len(truncated)) - for chunk := range slice.CollectChunks(slices.Values(truncated), 100) { - ranges := make(squirrel.Or, len(chunk)) - for i, p := range chunk { - ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}} - } - mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges}) - if err != nil { - log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err) - break - } - for _, mf := range mfs { - p := mf.ID[:truncatedIDLen] - byPrefix[p] = append(byPrefix[p], mf.ID) - } - } - - out := make([]string, len(ids)) - for i, id := range ids { - switch full := byPrefix[id]; { - case len(full) == 1: - out[i] = full[0] - case len(id) == truncatedIDLen: - out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide - default: - out[i] = id - } - } - return out -} - -// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to -// detect ambiguity). '\x7f' is above every character the id alphabets use. -func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string { - rows, err := getAll(model.QueryOptions{ - Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}}, - Max: 2, - }) - if err != nil { - return nil - } - ids := make([]string, len(rows)) - for i, row := range rows { - ids[i] = id(row) - } - return ids -} - -func mediaFileID(mf model.MediaFile) string { return mf.ID } -func albumID(al model.Album) string { return al.ID } -func artistID(ar model.Artist) string { return ar.ID } -func playlistID(pl model.Playlist) string { return pl.ID } diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 6a1597b70..3e53d598f 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -19,7 +19,7 @@ import ( var _ = Describe("Users", func() { var api *Router authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) return r.WithContext(ctx) } BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) @@ -35,13 +35,13 @@ var _ = Describe("Users", func() { Expect(res.Items).To(HaveLen(2)) Expect(res.TotalRecordCount).To(Equal(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) Expect(res.Items[0].Name).To(Equal("Music")) Expect(res.Items[0].Type).To(Equal("CollectionFolder")) Expect(res.Items[0].CollectionType).To(Equal("music")) Expect(res.Items[0].IsFolder).To(BeTrue()) - Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeLibraryID(2))) Expect(res.Items[1].Name).To(Equal("Podcasts")) }) @@ -53,7 +53,7 @@ var _ = Describe("Users", func() { var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) }) It("returns no views for a user with no library access", func() { @@ -92,8 +92,8 @@ var _ = Describe("Users", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed()) - Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) }) It("returns an empty list when the config is unset", func() { @@ -106,7 +106,7 @@ var _ = Describe("Users", func() { users := publicUsers() Expect(users).To(HaveLen(2)) Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal(dto.EncodeID("u2"))) + Expect(users[0].Id).To(Equal(dto.EncodeID(testID("u2")))) Expect(users[1].Name).To(Equal("alice")) // The public list must not expose Policy/Configuration to unauthenticated callers. Expect(users[0].Policy).To(BeNil()) From 6c3e7e268be20f7a090542dfd2a65781b3ef2ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 23:02:13 -0400 Subject: [PATCH 430/596] feat(instant-mix): support album, playlist and genre sources (#5948) * feat(agents): local agent genre-hint similar songs fallback * feat(external): playlist instant mix via seed-track sampling * test(external): cover playlist mix never-empty fallback and maxSeeds cap Adds coverage for the empty-match seed fallback and the maxSeeds call cap on GetSimilarSongsByTrack, per code review finding. * feat(external): genre instant mix via seed-track sampling * feat(external): album instant mix falls back to AudioMuse track similarity * feat(external): artist instant mix falls back to seed-track sampling * fix(jellyfin): route genre seeds through instant mix instead of empty * feat(jellyfin): add /Albums/{id}/Similar route for albumMix radio * perf(external): bound playlist seed sampling to a random N samplePlaylistTracks loaded an entire playlist's joined rows just to keep 5 random seeds; push the bound and randomization into the query instead, matching the other samplers (GetRandom/GetAllByTags with Max). Fixing this surfaced a real bug: resetSeededRandom's SEEDEDRAND rewrite assumed every table's id is TEXT, but playlist_tracks.id is an INTEGER position, so the random sort silently dropped every row. Cast the id to TEXT before hashing (no-op for the other, TEXT-id tables). Also trims a changelog-flavored comment and a duplicated rationale in server/jellyfin/similar_test.go. * refactor(external): parallelize seed mix and dedup mix helpers Run the up-to-5 per-seed GetSimilarSongsByTrack calls concurrently (errgroup), route the four container cases through a shared seedMix helper, flatten the genre lookup, and sample playlist seeds without forcing a smart-playlist rebuild. Share the media-file->Song mapping in the local agent. * perf(agents): use the indexed genre filter for local similarity Replace GetAllByTags (a json_tree scan of every media_file row) with the media_file_tags semi-join from #5940, deriving the seed's genre tag ids locally since they hash from (name, value). Also carry the library id and the recording MBID on the returned songs: the matcher resolves by id first and looks up mbz_recording_id, so the release-track id it got before matched nothing and the local fallback silently returned no songs. * refactor: drop redundant MBID and fold mixFromSeeds into seedMix The local agent returns library tracks, so the id alone resolves them in the matcher's first phase; the MBID was never consulted. mixFromSeeds had no caller other than seedMix. * docs: trim redundant comments * fix(jellyfin): adopt the GUID id codec in the merged similar routes getSimilarAlbums still used resolveItemID/DecodeID, which #5942 replaced with itemIDParam; its tests passed raw ids that the strict codec now rejects. * fix(external): guard non-positive counts and blend every seed A negative Subsonic count reached matched[:count] and panicked. The matcher also keeps input order and stops at count, so seed-grouped results let the first seed fill the whole mix; interleaving gives every seed a share. Drops the duplicate playlist-track mock in favour of tests.MockPlaylistTrackRepo, which pages like the real repository and records the query options. * fix(external): refresh smart playlists before sampling seeds A smart playlist materializes no playlist_tracks until it is evaluated, so sampling without the refresh mixed an empty seed set. The refresh is a no-op for regular playlists, inside the refresh delay, and for non-owners. * fix(external): skip missing tracks and a nil playlist-track repo when sampling Tracks() logs and returns a nil repository when its own lookup fails, so the chained GetAll panicked. Seeds can also reach the mix verbatim when the agents find nothing, so a missing file would surface as an unplayable entry. * fix(jellyfin): never report the seed album as its own similar album The sampled-seed fallback returns the album's own tracks, which similarAlbums mapped straight back to the requested album, often as the only result. * test(agents): assert the genre predicate instead of relying on the mock MockMediaFileRepo ignores QueryOptions.Filters, so the spec passed even with no genre filter at all. It now checks the generated predicate carries the seed's own tag id, the indexed join and the missing exclusion. * fix(external): clamp the requested count before it becomes a query limit Subsonic passes the client's count through unbounded. At MaxInt64 the local agent's count+1 overflows negative, and GetRandom omits the SQL limit unless Max is positive, so one request would hydrate every matching track. 500 is what the widest caller (similarAlbums, limit*5) legitimately asks for. * fix(external): deduplicate playlist seeds by media file A playlist can hold the same file at several positions, so sampling its rows could seed the mix twice: a wasted agent call, and a duplicate track whenever the seed fallback kicks in. * fix(external): drop tracks two seeds both recommend The matcher re-emits a track when two inputs are identical, so overlapping recommendations took several slots in the mix. Match the whole merged set and dedup before trimming. Playlist sampling now over-fetches before its own dedup, so repeated positions cannot collapse the seed count. * test(external): make the seed-blend assertion independent of the shuffle It matched four tracks and kept two at random, so both could come from the first seed once in six runs. Keeping three of the four makes a seed-two track unavoidable. * fix(external): seed artist mixes from every credited role media_file.artist_id is the deprecated primary artist, so an artist credited only on the album, as on compilations, sampled no seeds at all. Use the same participant filter the artist listings use. * refactor(external): drop the now-vestigial seed interleaving Matching the whole merged set removed the early truncation the interleave guarded against, and the shuffle before the trim makes input order irrelevant. Its comment described the old behaviour. * test(agents): give the id-mapping fixture a matching genre The related track carried no genre, so the real query would never return it; the spec only passed because the mock ignores QueryOptions.Filters. * test(agents): drop the MBID from the id-mapping fixture Local agent candidates are non-missing library rows, so the matcher always resolves them in its id phase and never reads the MBID. The field guarded a regression that could not change behaviour. * test(agents): remove unnecessary comment about MBID in GetArtistTopSongs test * fix(jellyfin): only let a not-found entity fall through in getInstantMix Discarding the error conflated a genre id, which never resolves, with a real lookup failure, which then made a provider call that fails the same way. * test: pin the invariants the specs only appeared to cover The missing filter was asserted by substring, so flipping it to true passed everywhere, including the spec named for it. Matching the whole merged set, the local agent's over-fetch, and its no-genres early return had no coverage at all; each is now pinned by a spec that fails when the code is broken. * test: make the remaining specs say what they actually guard The playlist-track spec named a sort whitelist it does not exercise; it guards the integer-id CAST, so it now asserts no rows are dropped. The maxSeeds cap passed with either bound removed, and the over-fetch was pinned by its literal value rather than the duplicate positions it exists for. Also drops setup the count guard returns before reaching. * fix(external): fall back when the agent's picks are not in this library A non-empty answer whose songs are all absent locally matched nothing and was returned as-is, so the mix came back empty with sampleable source tracks sitting right there. * refactor(external): name the agent-then-fallback flow once Each entity case repeated the same error and emptiness plumbing around the matcher. mixFromAgent states it once and each case supplies only what differs: how to ask, and what to do when the answer is unusable. --- core/agents/agents_test.go | 4 +- core/agents/local_agent.go | 53 +- core/agents/local_agent_test.go | 96 ++++ core/external/extdata_helper_test.go | 13 + core/external/provider.go | 186 ++++++- core/external/provider_similarsongs_test.go | 468 +++++++++++++++++- persistence/playlist_track_repository.go | 1 + persistence/playlist_track_repository_test.go | 14 + persistence/sql_base_repository.go | 4 +- server/jellyfin/api.go | 1 + server/jellyfin/similar.go | 21 +- server/jellyfin/similar_test.go | 67 +++ tests/mock_playlist_repo.go | 24 +- 13 files changed, 891 insertions(+), 61 deletions(-) create mode 100644 core/agents/local_agent_test.go diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e3087f995..e79b2b3c8 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -34,10 +34,10 @@ var _ = Describe("Agents", func() { }) It("calls the placeholder GetArtistImages", func() { - mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}}) + mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}}) songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2) Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}})) + Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}})) }) }) diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index ce8f9f07c..1cb9060a1 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -5,6 +5,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/utils/slice" ) const LocalAgentName = "local" @@ -37,14 +39,51 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid if err != nil { return nil, err } - var result []Song - for _, s := range top { - result = append(result, Song{ - Name: s.Title, - MBID: s.MbzReleaseTrackID, - }) + return songsFrom(top), nil +} + +func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { + seed, err := p.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err } - return result, nil + // Tag ids derive from (name, value), so the seed's genre ids need no extra query. + genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID }) + if len(genreIDs) == 0 { + return nil, nil + } + // Ask for extra so we can drop the seed itself and still fill the count. + candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + Filters: squirrel.And{ + persistence.SongGenres.ByID(genreIDs), + squirrel.Eq{"missing": false}, + }, + Max: count + 1, + }) + if err != nil { + return nil, err + } + filtered := make(model.MediaFiles, 0, len(candidates)) + for _, s := range candidates { + if s.ID == id { + continue + } + filtered = append(filtered, s) + if len(filtered) >= count { + break + } + } + return songsFrom(filtered), nil +} + +func songsFrom(mfs model.MediaFiles) []Song { + if len(mfs) == 0 { + return nil + } + + return slice.Map(mfs, func(mf model.MediaFile) Song { + return Song{ID: mf.ID, Name: mf.Title} + }) } func init() { diff --git a/core/agents/local_agent_test.go b/core/agents/local_agent_test.go new file mode 100644 index 000000000..50a0ce297 --- /dev/null +++ b/core/agents/local_agent_test.go @@ -0,0 +1,96 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("localAgent GetSimilarSongsByTrack", func() { + var ds *tests.MockDataStore + var mfRepo *tests.MockMediaFileRepo + var agent *localAgent + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + mfRepo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: mfRepo} + agent = &localAgent{ds: ds} + }) + + It("excludes the seed track from its own similars", func() { + seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + // SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed. + mfRepo.SetData(model.MediaFiles{seed, related}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10) + + Expect(err).ToNot(HaveOccurred()) + names := slice.Map(songs, func(s Song) string { return s.Name }) + Expect(names).ToNot(ContainElement("Seed")) + }) + + // The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec + // would pass just as well with no genre filter at all. + It("queries the indexed genre join for the seed's own genres, skipping missing files", func() { + rock := model.NewTag(model.TagGenre, "Rock") + seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + mfRepo.SetData(model.MediaFiles{seed}) + + _, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10) + Expect(err).ToNot(HaveOccurred()) + + sql, args, sqlErr := mfRepo.Options.Filters.ToSql() + Expect(sqlErr).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan") + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(false), "must exclude missing files, not select them") + Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id") + Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID)) + }) + + It("returns the library id so the matcher can resolve the song", func() { + seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + // Without the id the matcher falls through to its MBID/title phases and resolves nothing, + // so the local fallback silently returns an empty mix. + related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + mfRepo.SetData(model.MediaFiles{seed, related}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"})) + }) + + It("asks for one extra candidate so dropping the seed still fills the count", func() { + // The mock returns rows sorted by id, so the seed comes first and would consume the only + // slot if the query did not over-fetch. + seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + mfRepo.SetData(model.MediaFiles{seed, related}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].Name).To(Equal("Related")) + }) + + It("returns nil when the seed track has no genres", func() { + seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"} + mfRepo.SetData(model.MediaFiles{seed}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + // Without the early return an empty tag filter would scan the whole library. + Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all") + }) +}) diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index 8fabf4490..d68147168 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -110,6 +110,19 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi return args.Get(0).(model.MediaFiles), args.Error(1) } +// GetRandom implements model.MediaFileRepository. +func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.MediaFiles), args.Error(1) +} + // SetError is a helper to set up a generic error for GetAll. func (m *mockMediaFileRepo) SetError(hasError bool) { if hasError { diff --git a/core/external/provider.go b/core/external/provider.go index efd061c0c..383e7d4c9 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "sort" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/random" @@ -23,7 +25,11 @@ import ( ) const ( - maxSimilarArtists = 100 + maxSimilarArtists = 100 + maxSeeds = 5 + // Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is + // what the widest caller (similarAlbums, limit*5) legitimately asks for. + maxSimilarSongs = 500 refreshDelay = 5 * time.Second refreshTimeout = 15 * time.Second refreshQueueLength = 2000 @@ -278,32 +284,188 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au } func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + // Subsonic passes the client's count straight through: a non-positive one has no valid + // interpretation, and an enormous one overflows the +1 in the local agent's query limit. + if count <= 0 { + return nil, nil + } + count = min(count, maxSimilarSongs) entity, err := model.GetEntityByID(ctx, e.ds, id) if err != nil { - return nil, err + // Genre ids don't resolve via GetEntityByID; look them up before giving up. + if !errors.Is(err, model.ErrNotFound) { + return nil, err + } + genre, err := e.ds.Genre(ctx).Get(id) + if err != nil { + return nil, err + } + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleGenreTracks(ctx, genre, maxSeeds) + }) } - var songs []agents.Song - - // Try entity-specific similarity first + // Try entity-specific similarity first, then fall back to seed-track sampling. switch v := entity.(type) { case *model.MediaFile: - songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + }, + func() (model.MediaFiles, error) { + return e.similarSongsFallback(ctx, id, count) + }) case *model.Album: - songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + }, + func() (model.MediaFiles, error) { + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleAlbumTracks(ctx, v.ID, maxSeeds) + }) + }) case *model.Artist: - songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + }, + func() (model.MediaFiles, error) { + if res, ferr := e.similarSongsFallback(ctx, id, count); ferr == nil && len(res) > 0 { + return res, nil + } + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleArtistTracks(ctx, v.ID, maxSeeds) + }) + }) + case *model.Playlist: + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.samplePlaylistTracks(ctx, v.ID, maxSeeds) + }) default: log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) return nil, model.ErrNotFound } +} - if err == nil && len(songs) > 0 { - return e.matcher.MatchSongs(ctx, songs, count) +// mixFromAgent returns the agent's recommendations matched to library tracks, or the fallback +// when the agent errors or none of its picks are in the library. +func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallback func() (model.MediaFiles, error)) (model.MediaFiles, error) { + songs, err := fetch() + if err == nil { + matched, merr := e.matcher.MatchSongs(ctx, songs, count) + if merr != nil { + return nil, merr + } + if len(matched) > 0 { + return matched, nil + } } + return fallback() +} - // Fallback to existing similar artists + top songs algorithm - return e.similarSongsFallback(ctx, id, count) +// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges +// the results, falling back to the seeds themselves so the result is never empty. +func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) { + seeds, err := sample() + if err != nil { + return nil, err + } + if len(seeds) == 0 { + return nil, nil + } + seeds = seeds[:min(len(seeds), maxSeeds)] + + // The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so + // run them concurrently. Best-effort: a seed that errors just contributes nothing. + perSeed := make([][]agents.Song, len(seeds)) + var g errgroup.Group + for i, seed := range seeds { + g.Go(func() error { + if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil { + perSeed[i] = s + } + return nil + }) + } + _ = g.Wait() + + var songs []agents.Song + for _, s := range perSeed { + songs = append(songs, s...) + } + // Match the whole merged set, not just count of it: the matcher re-emits a track when two + // seeds recommend it identically, so the duplicates have to be dropped before trimming. Every + // seed reaches the shuffle, so no seed can crowd out the others. + matched, err := e.matcher.MatchSongs(ctx, songs, len(songs)) + if err != nil { + return nil, err + } + matched = dedupByID(matched) + if len(matched) == 0 { + matched = seeds + } + rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) + if len(matched) > count { + matched = matched[:count] + } + return matched, nil +} + +func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { + // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would + // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. + repo := e.ds.Playlist(ctx).Tracks(playlistID, true) + if repo == nil { + return nil, model.ErrNotFound + } + // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated + // seed wastes an agent call and can reach the mix twice through the seed fallback. + tracks, err := repo.GetAll(model.QueryOptions{ + Sort: "random", + Max: n * 4, + Filters: squirrel.Eq{"missing": false}, + }) + if err != nil { + return nil, err + } + mfs := dedupByID(tracks.MediaFiles()) + return mfs[:min(len(mfs), n)], nil +} + +func dedupByID(mfs model.MediaFiles) model.MediaFiles { + seen := make(map[string]struct{}, len(mfs)) + return slice.Filter(mfs, func(mf model.MediaFile) bool { + if _, dup := seen[mf.ID]; dup { + return false + } + seen[mf.ID] = struct{}{} + return true + }) +} + +func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) { + return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n) +} + +func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) { + // media_file.artist_id is the deprecated primary artist, so it misses an artist credited only + // on the album, as on compilations. Same filter the artist listings use. + filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist) + return e.sampleTracks(ctx, filter, n) +} + +func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) { + return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n) +} + +// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so +// missing files would surface as unplayable entries. +func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { + return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, + Max: n, + }) } // similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 563003f83..5617269b3 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -3,6 +3,8 @@ package external_test import ( "context" "errors" + "math" + "slices" "strings" "github.com/Masterminds/squirrel" @@ -11,6 +13,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -26,6 +29,9 @@ var _ = Describe("Provider - SimilarSongs", func() { var artistRepo *mockArtistRepo var mediaFileRepo *mockMediaFileRepo var albumRepo *mockAlbumRepo + var playlistRepo *tests.MockPlaylistRepo + var playlistTrackRepo *tests.MockPlaylistTrackRepo + var genreRepo *tests.MockedGenreRepo var ctx context.Context BeforeEach(func() { @@ -34,11 +40,17 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo = newMockArtistRepo() mediaFileRepo = newMockMediaFileRepo() albumRepo = newMockAlbumRepo() + playlistTrackRepo = &tests.MockPlaylistTrackRepo{} + playlistRepo = tests.CreateMockPlaylistRepo() + playlistRepo.TracksRepo = playlistTrackRepo + genreRepo = &tests.MockedGenreRepo{} ds = &tests.MockDataStore{ MockedArtist: artistRepo, MockedMediaFile: mediaFileRepo, MockedAlbum: albumRepo, + MockedPlaylist: playlistRepo, + MockedGenre: genreRepo, } mockAgent = &mockSimilarArtistAgent{} @@ -203,46 +215,68 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs[0].ID).To(Equal("matched-1")) }) - It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() { + It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() { album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"} - artist := model.Artist{ID: "artist-1", Name: "Artist"} - song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"} - // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() - // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album - // and recursively calls getArtist(v.AlbumArtistID) - artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "album-1").Return(&album, nil).Once() + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "album_id") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1")) + })).Return(model.MediaFiles{seed}, nil).Once() - // Then it recurses with the artist-1 ID - artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist}, nil).Maybe() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() + // seedMix falls back to the seed itself when the agent finds nothing. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything). + Return([]agents.Song{}, nil).Once() songs, err := provider.SimilarSongs(ctx, "album-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) + Expect(songs[0].ID).To(Equal("seed-1")) + }) + }) + + Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() { + It("samples the album's tracks and returns their track-similars", func() { + album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"} + artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "al-1").Return(&album, nil).Once() + + // AudioMuse doesn't implement album similarity -> empty. + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5). + Return([]agents.Song{}, nil).Once() + + // sampleAlbumTracks -> GetRandom(album_id) -> one seed track + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "album_id") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1")) + })).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once() + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5). + Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() + + // Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A". + aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"} + matchedTrack := model.MediaFile{ + ID: "m1", Title: "AudioMuseResult", Artist: "A", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}}, + } + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "al-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + Expect(songs[0].ID).To(Equal("m1")) }) }) @@ -280,6 +314,381 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs[0].ID).To(Equal("matched-1")) }) }) + + Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() { + It("samples the artist's tracks and returns their track-similars", func() { + artist := model.Artist{ID: "ar-1", Name: "The Artist"} + // Get is called twice: once to resolve the entity, once inside similarSongsFallback. + artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5). + Return([]agents.Song{}, nil).Once() + // similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups). + mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Artist{}, nil).Maybe() + mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + // Seeds come from the participant join covering both roles, so an artist credited + // only on the album (compilations, classical) still yields seeds. + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "media_file_artists") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) && + slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String())) + })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). + Return([]agents.Song{{Name: "Result"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "ar-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + }) + }) + + Context("when ID is a Playlist", func() { + It("samples playlist tracks and returns their track-similars", func() { + pls := model.Playlist{ID: "pl-1", Name: "My List"} + seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} + + // GetEntityByID order: Artist, Album, Playlist(hit) + artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + + // samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: seedTrack}, + }) + + // seedMix -> GetSimilarSongsByTrack for the seed + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). + Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() + + // Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + }) + + It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() { + // A smart playlist materializes no playlist_tracks until it is evaluated, so sampling + // without the refresh would mix an empty seed set. + pls := model.Playlist{ID: "pl-smart", Name: "Smart"} + artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + }) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-smart", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + Expect(playlistRepo.TracksRefreshed).To(BeTrue()) + }) + + It("returns an error instead of panicking when the track repository is unavailable", func() { + // Tracks() logs and returns a nil repository when its own lookup fails. + pls := model.Playlist{ID: "pl-nil", Name: "Gone"} + artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistRepo.TracksRepo = nil + + _, err := provider.SimilarSongs(ctx, "pl-nil", 5) + + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not seed a mix with missing tracks", func() { + pls := model.Playlist{ID: "pl-missing", Name: "Missing"} + artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + }) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + _, err := provider.SimilarSongs(ctx, "pl-missing", 5) + + Expect(err).ToNot(HaveOccurred()) + sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql() + Expect(sqlErr).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(false), "must exclude missing files, not select them") + }) + + It("keeps a later seed's picks when an earlier seed overlaps it", func() { + // The matcher stops once it has count matches, and it re-emits the shared track, so + // matching only count of the merged set would spend slots on the duplicate. + pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"} + artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, + }) + shared := agents.Song{ID: "x", Name: "X"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). + Return([]agents.Song{shared}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). + Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"}, + }, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot") + }) + + It("returns a track once when two seeds recommend it", func() { + // The matcher re-emits a track when two inputs are identical, so overlapping + // recommendations would otherwise take two slots in the mix. + pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"} + artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, + }) + shared := agents.Song{ID: "m1", Name: "Shared"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). + Return([]agents.Song{shared}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5). + Return([]agents.Song{shared}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1), "the shared recommendation must appear once") + }) + + It("does not seed a mix twice with a track the playlist repeats", func() { + pls := model.Playlist{ID: "pl-dup", Name: "Dupes"} + artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + // The same file at two positions, which playlists allow. + dup := model.MediaFile{ID: "s1", Title: "Seed One"} + playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}}) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). + Return([]agents.Song{}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "pl-dup", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1), "the repeated track must appear once") + agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1) + }) + + It("clamps an enormous count before it reaches the queries", func() { + // count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL + // limit unless Max is positive, so the query would hydrate the whole library. + pls := model.Playlist{ID: "pl-huge", Name: "Huge"} + artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + }) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500). + Return([]agents.Song{}, nil).Once() + + _, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64) + + Expect(err).ToNot(HaveOccurred()) + agentsCombined.AssertExpectations(GinkgoT()) + }) + + It("does not panic when the caller asks for a non-positive count", func() { + // Subsonic passes count straight through, so a negative one reaches the provider. + // The guard returns before any lookup, so no repository setup is needed. + songs, err := provider.SimilarSongs(ctx, "pl-neg", -1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + + It("blends results from every seed, not just the first", func() { + pls := model.Playlist{ID: "pl-blend", Name: "Blend"} + artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, + }) + + // Each seed returns a full count's worth, as a real similarity agent does. Asking for + // one more than seed one can supply makes this independent of the final shuffle: + // three of the four matches always include a seed-two track. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). + Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). + Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once() + + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"}, + {ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"}, + }, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-blend", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + ids := slice.Map(songs, func(mf model.MediaFile) string { return mf.ID }) + Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") + }) + + It("falls back to the seed tracks themselves when no similar songs are found", func() { + pls := model.Playlist{ID: "pl-2", Name: "Fallback List"} + seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} + seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"} + + artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: seed1}, + {MediaFile: seed2}, + }) + + // Both seeds come back empty, so the mix must fall back to the seeds themselves. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). + Return([]agents.Song{}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5). + Return([]agents.Song{}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "pl-2", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(2)) + Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2")) + }) + + It("samples the album when the agent's picks are not in this library", func() { + // Last.fm answers from its own catalogue, so a small library can match none of it. + // An unmatched non-empty answer must not shortcut the sampling fallback. + album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"} + artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "al-nm").Return(&album, nil).Once() + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5). + Return([]agents.Song{{Name: "Not In Library"}}, nil).Once() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() + + // The matcher resolves nothing; the sampled seed is what reaches the mix. + mediaFileRepo.On("GetRandom", mock.Anything). + Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5). + Return([]agents.Song{}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "al-nm", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("s1")) + }) + + It("caps agent calls at maxSeeds when the repository ignores the bound", func() { + // Isolates seedMix's own cap: the album sampler bounds the query with Max, so this + // exercises the guard by having the repo hand back more rows than were asked for. + album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"} + artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "al-cap").Return(&album, nil).Once() + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5). + Return([]agents.Song{}, nil).Once() + + var overflow model.MediaFiles + for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} { + overflow = append(overflow, model.MediaFile{ID: id, Title: id}) + } + mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). + Return([]agents.Song{}, nil) + + _, err := provider.SimilarSongs(ctx, "al-cap", 5) + + Expect(err).ToNot(HaveOccurred()) + agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) + }) + + It("still finds maxSeeds distinct seeds when the leading positions repeat", func() { + // The sampler over-fetches for exactly this case: a page bounded at maxSeeds could + // be entirely one repeated file and collapse to a single seed. + pls := model.Playlist{ID: "pl-3", Name: "Big List"} + dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"} + tracks := model.PlaylistTracks{ + {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, + } + for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} { + tracks = append(tracks, model.PlaylistTrack{ + MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"}, + }) + } + + artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(tracks) + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). + Return([]agents.Song{}, nil) + + songs, err := provider.SimilarSongs(ctx, "pl-3", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(5)) + Expect(playlistTrackRepo.Options.Sort).To(Equal("random")) + agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) + }) + }) + + Context("when ID is a Genre (not resolved by GetEntityByID)", func() { + It("samples genre songs and returns their track-similars", func() { + // GetEntityByID misses everywhere; the empty/auto-created mocks need no setup. + artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() + genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}} + + // sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan) + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + if opt.Filters == nil { + return false + } + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "media_file_tags") && + !strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) + })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). + Return([]agents.Song{{Name: "Similar"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "g-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + }) + }) }) It("returns similar songs from main artist and similar artists", func() { @@ -412,6 +821,13 @@ var _ = Describe("Provider - SimilarSongs", func() { mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, errors.New("error getting top songs")).Once() + // Fallback yields nothing, so the sampling path is tried and also finds no tracks. + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "artist_id") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1")) + })).Return(model.MediaFiles{}, nil).Once() + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a64efa20..c1f6fcf69 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -57,6 +57,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "album_artist": "order_album_artist_name", "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", "title": "order_title", + "random": "random()", // To make sure these fields will be whitelisted "duration": "duration", "year": "year", diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 36f9ae4a9..a5c67b92c 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -37,6 +37,20 @@ var _ = Describe("PlaylistTrackRepository", func() { }) }) + Describe("GetAll", func() { + It("returns every row under a random sort, despite the integer id", func() { + // playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to + // TEXT, and it fails silently: no error, just no rows. + all, err := repo.GetAll(model.QueryOptions{Sort: "random"}) + Expect(err).ToNot(HaveOccurred()) + Expect(all).To(HaveLen(2), "a random sort must not silently drop rows") + + got, err := repo.GetAll(model.QueryOptions{Sort: "random", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + }) + }) + Describe("CountAll", func() { It("returns the number of tracks in the playlist", func() { Expect(repo.CountAll()).To(Equal(int64(2))) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 33450fe9f..428ba7a7b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -284,7 +284,9 @@ func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { if len(options) == 0 || options[0].Sort != "random" { return } - options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName) + // CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to + // SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha). + options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(), r.tableName) if options[0].Seed != "" { hasher.SetSeed(r.seedKey(), options[0].Seed) return diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 1f46c08b4..8a175df35 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -143,6 +143,7 @@ func (api *Router) routes() http.Handler { r.Get("/artists/{itemId}/similar", api.getSimilarArtists) r.Get("/items/{itemId}/similar", api.getSimilarItems) + r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 3e0b3b11b..82d12f52b 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -2,6 +2,7 @@ package jellyfin import ( "context" + "errors" "fmt" "net/http" "time" @@ -93,6 +94,19 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { })) } +// getSimilarAlbums answers GET /Albums/{itemId}/Similar, powering Finamp's albumMix radio mode. +func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + api.ok(w, r, api.awaitSimilar(ctx, "albsim|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarAlbums(ctx, id, limit) + })) +} + // getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so // a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty // results, never a 404 the client would surface as an error. @@ -104,8 +118,10 @@ func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) + // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a + // song" and the provider knows what to do with it. A real lookup failure still stops here. entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil { + if err != nil && !errors.Is(err, model.ErrNotFound) { api.ok(w, r, result(nil, 0, 0)) return } @@ -177,7 +193,8 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. return result(nil, 0, 0) } u, _ := request.UserFrom(ctx) - seen := make(map[string]bool, limit) + // An album is not similar to itself, and the sampled-seed fallback returns its own tracks. + seen := map[string]bool{id: true} var items []dto.BaseItemDto for _, s := range songs { if s.AlbumID == "" || seen[s.AlbumID] { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index dc8c1e605..cffcce0a8 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -164,4 +164,71 @@ var _ = Describe("getInstantMix", func() { Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") }) + + It("returns a mix for a genre id, which GetEntityByID can't resolve", func() { + ds := &tests.MockDataStore{} + songs := model.MediaFiles{ + {ID: testID("m1"), Title: "Track 1", LibraryID: 1}, + {ID: testID("m2"), Title: "Track 2", LibraryID: 1}, + } + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1"))+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) + api.getInstantMix(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + }) +}) + +var _ = Describe("getSimilarAlbums", func() { + It("does not return the seed album as its own similar album", func() { + // With no external agent the provider falls back to the album's own tracks, which map + // straight back to the requested album. + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: testID("al-1"), Name: "Seed Album", LibraryID: 1}, + }) + api := &Router{ds: ds, provider: &fakeSimilarProvider{ + songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-1"), LibraryID: 1}}, + }} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) + api.getSimilarAlbums(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + + It("returns albums derived from the provider's similar songs", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: testID("al-2"), Name: "Other", LibraryID: 1}, + }) + api := &Router{ds: ds, provider: &fakeSimilarProvider{ + songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-2"), LibraryID: 1}}, + }} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) + api.getSimilarAlbums(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("Other")) + }) }) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index ee645e984..f04ed98c6 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -19,16 +19,17 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo { type MockPlaylistRepo struct { model.PlaylistRepository - Data map[string]*model.Playlist // keyed by ID - PathMap map[string]*model.Playlist // keyed by path - All model.Playlists - Options model.QueryOptions - Last *model.Playlist - Deleted []string - Starred map[string]bool // itemID -> starred - Ratings map[string]int // itemID -> rating - Err bool - TracksRepo model.PlaylistTrackRepository + Data map[string]*model.Playlist // keyed by ID + PathMap map[string]*model.Playlist // keyed by path + All model.Playlists + Options model.QueryOptions + Last *model.Playlist + Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating + Err bool + TracksRepo model.PlaylistTrackRepository + TracksRefreshed bool } func (m *MockPlaylistRepo) SetError(err bool) { @@ -163,7 +164,8 @@ func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { return nil } -func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { +func (m *MockPlaylistRepo) Tracks(_ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { + m.TracksRefreshed = refreshSmartPlaylist return m.TracksRepo } From 757ca783d37ba6df1c2e186b3654bd01273d3b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 13 Aug 2026 18:50:34 -0400 Subject: [PATCH 431/596] fix(instant-mix): top short mixes up instead of returning what the first source found (#5951) * fix(instant-mix): top short mixes up instead of returning what the first source found SimilarSongs returned the first non-empty source's tracks, however few. For a thinly-represented artist that meant a 3-track mix no matter the requested count: the artist agent found nothing, the similar-artists fallback matched 3 library tracks, and `len(res) > 0` kept seed-track sampling from ever running. Clients treat that as a failed mix and retry with a bigger limit forever. Finamp cycles limit 34 through 472 and starts over, ~1 request every 2s indefinitely, each one re-hitting Last.fm, Deezer and AudioMuse. Sources are now chained rather than raced: each one tops the mix up until it holds count tracks, so the agent's picks, the similar-artists fallback and seed-track sampling all contribute instead of the first one winning outright. * refactor(external): move similar-songs code to its own file provider.go held two distinct concerns: artist/album external metadata and the similar-songs mix pipeline. The mix code was already one contiguous block, and maxSeeds, maxSimilarSongs and dedupByID were used by nothing else. Moved SimilarSongs and its helpers to provider_similarsongs.go, matching the existing provider_similarsongs_test.go. Pure code motion: the moved block is byte-for-byte unchanged and provider.go has no additions, only deletions. * fix(instant-mix): dedup before deciding a mix is full topUp measured res before deduplicating it. Matcher.MatchSongs deliberately re-emits a library track when the same input song repeats, and the similar-artists fallback can reach one track through several artists, so len(res) could equal count while holding fewer unique tracks. That returned a mix with duplicates in it and stopped the top-up early; the caller then deduplicated and handed back a short mix, which is the client retry loop this branch set out to fix. Deduplicate first, so the length check counts what the client will actually receive. * perf(instant-mix): skip a fallback once the mix is already full The artist path nested one topUp inside another, so the inner one measured only similarSongsFallback's own result against the full count. With 49 agent matches and one fallback match for count=50 the mix was already full, yet seed-track sampling still ran and fired up to five GetSimilarSongsByTrack calls whose results the outer topUp then truncated away. topUp now takes the sources as a variadic list and re-checks the accumulated mix before each one, so a later, costlier source only runs while the mix is still short. That also flattens the artist case: the agent, the similar-artists fallback and seed-track sampling are now three peers in one chain instead of two nested calls. * fix(instant-mix): count distinct tracks when picking the fallback mix similarSongsFallback stopped after count picks from the weighted chooser, but a track can sit in that chooser once per artist listing it in their top songs, and Pick removes the entry it returns. Repeats therefore consumed pick slots and left unique candidates stranded, so the batch could come back short of count. On the track path this is the only source, so that short mix reached the client and kept the retry loop alive. Track the ids already picked and keep drawing until count distinct tracks are held or the chooser is empty. * fix(instant-mix): match the whole agent response before trimming MatchSongs was capped at count, and it re-emits a track when the same song repeats, so [A, A, B] with count=2 returned [A, A] and never reached B. topUp then shrank that to [A] and, with an empty or overlapping fallback, the mix stayed short even though B had been available all along. seedMix already matched its full merged set for this reason; mixFromAgent now does the same and leaves the trim to topUp. Also drop the capacity hint on the picked-ids map. It was sized from the caller's count, which CodeQL flags as an allocation sized by user input (go/uncontrolled- allocation-size). SimilarSongs clamps count to maxSimilarSongs long before this point, so the hint bought nothing worth the alert. * refactor(instant-mix): tidy the mix chain and its specs Quality pass over the new code, no behaviour change: - topUp: drop the first-vs-last error bookkeeping (the value is only read when the mix is empty, so the distinction is unobservable) and the redundant nil guard (dedupByID returns nil for an empty result, so both branches already agreed). - mixFromAgent: assign through the if-scoped err instead of a second error name. - Hoist the similar-artists fallback closure written verbatim in two switch arms. - Use map[string]struct{} in the pick loop, matching dedupByID in the same file. - Trim three comments back within budget; two restated the line below them and one carried commit-message rationale. - Tests: add an ids() helper for the ID assertion repeated seven times, and fold the track-entity stub block copied into three specs into stubTrackEntity. The block hard-coded .Twice() on GetEntityByID, which pinned an implementation detail no spec asserts. * revert(instant-mix): inline the similar-artists fallback closure again Hoisting it to a shared artistFallback var moved the call away from the arm that uses it and saved nothing: each arm reads better spelling out its own sources. --- core/external/extdata_helper_test.go | 5 + core/external/provider.go | 255 +---------------- core/external/provider_similarsongs.go | 299 ++++++++++++++++++++ core/external/provider_similarsongs_test.go | 219 +++++++++++++- 4 files changed, 515 insertions(+), 263 deletions(-) create mode 100644 core/external/provider_similarsongs.go diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index d68147168..f7a155cd9 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/stretchr/testify/mock" ) @@ -324,3 +325,7 @@ func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid } return nil, args.Error(1) } + +func ids(mfs model.MediaFiles) []string { + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) +} diff --git a/core/external/provider.go b/core/external/provider.go index 383e7d4c9..782c7c3aa 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math/rand/v2" "sort" "strings" "time" @@ -15,21 +14,15 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/random" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "golang.org/x/sync/errgroup" ) const ( - maxSimilarArtists = 100 - maxSeeds = 5 - // Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is - // what the widest caller (similarAlbums, limit*5) legitimately asks for. - maxSimilarSongs = 500 + maxSimilarArtists = 100 refreshDelay = 5 * time.Second refreshTimeout = 15 * time.Second refreshQueueLength = 2000 @@ -283,252 +276,6 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, nil } -func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { - // Subsonic passes the client's count straight through: a non-positive one has no valid - // interpretation, and an enormous one overflows the +1 in the local agent's query limit. - if count <= 0 { - return nil, nil - } - count = min(count, maxSimilarSongs) - entity, err := model.GetEntityByID(ctx, e.ds, id) - if err != nil { - // Genre ids don't resolve via GetEntityByID; look them up before giving up. - if !errors.Is(err, model.ErrNotFound) { - return nil, err - } - genre, err := e.ds.Genre(ctx).Get(id) - if err != nil { - return nil, err - } - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleGenreTracks(ctx, genre, maxSeeds) - }) - } - - // Try entity-specific similarity first, then fall back to seed-track sampling. - switch v := entity.(type) { - case *model.MediaFile: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) - }, - func() (model.MediaFiles, error) { - return e.similarSongsFallback(ctx, id, count) - }) - case *model.Album: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) - }, - func() (model.MediaFiles, error) { - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleAlbumTracks(ctx, v.ID, maxSeeds) - }) - }) - case *model.Artist: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) - }, - func() (model.MediaFiles, error) { - if res, ferr := e.similarSongsFallback(ctx, id, count); ferr == nil && len(res) > 0 { - return res, nil - } - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleArtistTracks(ctx, v.ID, maxSeeds) - }) - }) - case *model.Playlist: - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.samplePlaylistTracks(ctx, v.ID, maxSeeds) - }) - default: - log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) - return nil, model.ErrNotFound - } -} - -// mixFromAgent returns the agent's recommendations matched to library tracks, or the fallback -// when the agent errors or none of its picks are in the library. -func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallback func() (model.MediaFiles, error)) (model.MediaFiles, error) { - songs, err := fetch() - if err == nil { - matched, merr := e.matcher.MatchSongs(ctx, songs, count) - if merr != nil { - return nil, merr - } - if len(matched) > 0 { - return matched, nil - } - } - return fallback() -} - -// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges -// the results, falling back to the seeds themselves so the result is never empty. -func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) { - seeds, err := sample() - if err != nil { - return nil, err - } - if len(seeds) == 0 { - return nil, nil - } - seeds = seeds[:min(len(seeds), maxSeeds)] - - // The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so - // run them concurrently. Best-effort: a seed that errors just contributes nothing. - perSeed := make([][]agents.Song, len(seeds)) - var g errgroup.Group - for i, seed := range seeds { - g.Go(func() error { - if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil { - perSeed[i] = s - } - return nil - }) - } - _ = g.Wait() - - var songs []agents.Song - for _, s := range perSeed { - songs = append(songs, s...) - } - // Match the whole merged set, not just count of it: the matcher re-emits a track when two - // seeds recommend it identically, so the duplicates have to be dropped before trimming. Every - // seed reaches the shuffle, so no seed can crowd out the others. - matched, err := e.matcher.MatchSongs(ctx, songs, len(songs)) - if err != nil { - return nil, err - } - matched = dedupByID(matched) - if len(matched) == 0 { - matched = seeds - } - rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) - if len(matched) > count { - matched = matched[:count] - } - return matched, nil -} - -func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { - // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would - // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. - repo := e.ds.Playlist(ctx).Tracks(playlistID, true) - if repo == nil { - return nil, model.ErrNotFound - } - // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated - // seed wastes an agent call and can reach the mix twice through the seed fallback. - tracks, err := repo.GetAll(model.QueryOptions{ - Sort: "random", - Max: n * 4, - Filters: squirrel.Eq{"missing": false}, - }) - if err != nil { - return nil, err - } - mfs := dedupByID(tracks.MediaFiles()) - return mfs[:min(len(mfs), n)], nil -} - -func dedupByID(mfs model.MediaFiles) model.MediaFiles { - seen := make(map[string]struct{}, len(mfs)) - return slice.Filter(mfs, func(mf model.MediaFile) bool { - if _, dup := seen[mf.ID]; dup { - return false - } - seen[mf.ID] = struct{}{} - return true - }) -} - -func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) { - return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n) -} - -func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) { - // media_file.artist_id is the deprecated primary artist, so it misses an artist credited only - // on the album, as on compilations. Same filter the artist listings use. - filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist) - return e.sampleTracks(ctx, filter, n) -} - -func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) { - return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n) -} - -// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so -// missing files would surface as unplayable entries. -func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { - return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ - Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, - Max: n, - }) -} - -// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to -// get the artist of the given entity, retrieve similar artists, get their top songs, and pick -// a weighted random selection of songs to return as similar songs. -func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, err - } - - e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return nil, ctx.Err() - } - - weightedSongs := random.NewWeightedChooser[model.MediaFile]() - addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return ctx.Err() - } - - topCount := max(count, 20) - topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) - if err != nil { - log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) - return nil - } - - weight := topCount * (4 + artistWeight) - for _, mf := range topSongs { - weightedSongs.Add(mf, weight) - weight -= 4 - } - return nil - } - - err = addArtist(artist.Artist, weightedSongs, count, 10) - if err != nil { - return nil, err - } - for _, a := range artist.SimilarArtists { - err := addArtist(a, weightedSongs, count, 0) - if err != nil { - return nil, err - } - } - - var similarSongs model.MediaFiles - for len(similarSongs) < count && weightedSongs.Size() > 0 { - s, err := weightedSongs.Pick() - if err != nil { - log.Warn(ctx, "Error getting weighted song", err) - continue - } - similarSongs = append(similarSongs, s) - } - - return similarSongs, nil -} - func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { artist, err := e.findArtist(ctx, artistName, id) if err != nil { diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go new file mode 100644 index 000000000..22b20565b --- /dev/null +++ b/core/external/provider_similarsongs.go @@ -0,0 +1,299 @@ +package external + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/random" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/sync/errgroup" +) + +const ( + maxSeeds = 5 + // Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is + // what the widest caller (similarAlbums, limit*5) legitimately asks for. + maxSimilarSongs = 500 +) + +func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + // Subsonic passes the client's count straight through: a non-positive one has no valid + // interpretation, and an enormous one overflows the +1 in the local agent's query limit. + if count <= 0 { + return nil, nil + } + count = min(count, maxSimilarSongs) + entity, err := model.GetEntityByID(ctx, e.ds, id) + if err != nil { + // Genre ids don't resolve via GetEntityByID; look them up before giving up. + if !errors.Is(err, model.ErrNotFound) { + return nil, err + } + genre, err := e.ds.Genre(ctx).Get(id) + if err != nil { + return nil, err + } + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleGenreTracks(ctx, genre, maxSeeds) + }) + } + + // Try entity-specific similarity first, then fall back to seed-track sampling. + switch v := entity.(type) { + case *model.MediaFile: + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + }, + func() (model.MediaFiles, error) { + return e.similarSongsFallback(ctx, id, count) + }) + case *model.Album: + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + }, + func() (model.MediaFiles, error) { + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleAlbumTracks(ctx, v.ID, maxSeeds) + }) + }) + case *model.Artist: + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + }, + func() (model.MediaFiles, error) { + return e.similarSongsFallback(ctx, id, count) + }, + func() (model.MediaFiles, error) { + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleArtistTracks(ctx, v.ID, maxSeeds) + }) + }) + case *model.Playlist: + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.samplePlaylistTracks(ctx, v.ID, maxSeeds) + }) + default: + log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) + return nil, model.ErrNotFound + } +} + +// mixFromAgent returns the agent's recommendations matched to library tracks, topped up from the +// fallbacks in order when they don't fill the mix on their own. +func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallbacks ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { + var matched model.MediaFiles + if songs, err := fetch(); err == nil { + // Match the whole response: capping at count can stop before a later unique pick. + matched, err = e.matcher.MatchSongs(ctx, songs, len(songs)) + if err != nil { + return nil, err + } + } + return topUp(ctx, matched, count, fallbacks...) +} + +// topUp draws on each source in turn until the mix holds count distinct tracks. +func topUp(ctx context.Context, res model.MediaFiles, count int, sources ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { + // The matcher can re-emit a track, so a full-looking res may hold fewer than count unique ones. + res = dedupByID(res) + var lastErr error + for _, more := range sources { + if len(res) >= count { + break + } + extra, err := more() + if err != nil { + log.Debug(ctx, "Could not top up a short mix", "have", len(res), "want", count, err) + lastErr = err + continue + } + res = dedupByID(append(res, extra...)) + } + if len(res) == 0 { + return nil, lastErr + } + return res[:min(len(res), count)], nil +} + +// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges +// the results, falling back to the seeds themselves so the result is never empty. +func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) { + seeds, err := sample() + if err != nil { + return nil, err + } + if len(seeds) == 0 { + return nil, nil + } + seeds = seeds[:min(len(seeds), maxSeeds)] + + // The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so + // run them concurrently. Best-effort: a seed that errors just contributes nothing. + perSeed := make([][]agents.Song, len(seeds)) + var g errgroup.Group + for i, seed := range seeds { + g.Go(func() error { + if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil { + perSeed[i] = s + } + return nil + }) + } + _ = g.Wait() + + var songs []agents.Song + for _, s := range perSeed { + songs = append(songs, s...) + } + // Match the whole merged set, not just count of it: the matcher re-emits a track when two + // seeds recommend it identically, so the duplicates have to be dropped before trimming. Every + // seed reaches the shuffle, so no seed can crowd out the others. + matched, err := e.matcher.MatchSongs(ctx, songs, len(songs)) + if err != nil { + return nil, err + } + matched = dedupByID(matched) + if len(matched) == 0 { + matched = seeds + } + rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) + if len(matched) > count { + matched = matched[:count] + } + return matched, nil +} + +func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { + // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would + // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. + repo := e.ds.Playlist(ctx).Tracks(playlistID, true) + if repo == nil { + return nil, model.ErrNotFound + } + // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated + // seed wastes an agent call and can reach the mix twice through the seed fallback. + tracks, err := repo.GetAll(model.QueryOptions{ + Sort: "random", + Max: n * 4, + Filters: squirrel.Eq{"missing": false}, + }) + if err != nil { + return nil, err + } + mfs := dedupByID(tracks.MediaFiles()) + return mfs[:min(len(mfs), n)], nil +} + +func dedupByID(mfs model.MediaFiles) model.MediaFiles { + seen := make(map[string]struct{}, len(mfs)) + return slice.Filter(mfs, func(mf model.MediaFile) bool { + if _, dup := seen[mf.ID]; dup { + return false + } + seen[mf.ID] = struct{}{} + return true + }) +} + +func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) { + return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n) +} + +func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) { + // media_file.artist_id is the deprecated primary artist, so it misses an artist credited only + // on the album, as on compilations. Same filter the artist listings use. + filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist) + return e.sampleTracks(ctx, filter, n) +} + +func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) { + return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n) +} + +// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so +// missing files would surface as unplayable entries. +func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { + return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, + Max: n, + }) +} + +// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to +// get the artist of the given entity, retrieve similar artists, get their top songs, and pick +// a weighted random selection of songs to return as similar songs. +func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return nil, ctx.Err() + } + + weightedSongs := random.NewWeightedChooser[model.MediaFile]() + addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return ctx.Err() + } + + topCount := max(count, 20) + topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) + if err != nil { + log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) + return nil + } + + weight := topCount * (4 + artistWeight) + for _, mf := range topSongs { + weightedSongs.Add(mf, weight) + weight -= 4 + } + return nil + } + + err = addArtist(artist.Artist, weightedSongs, count, 10) + if err != nil { + return nil, err + } + for _, a := range artist.SimilarArtists { + err := addArtist(a, weightedSongs, count, 0) + if err != nil { + return nil, err + } + } + + // Count distinct tracks, not picks: a collaboration sits in the chooser once per artist that + // lists it, and letting those repeats consume the budget strands unique candidates. + var similarSongs model.MediaFiles + picked := map[string]struct{}{} + for len(similarSongs) < count && weightedSongs.Size() > 0 { + s, err := weightedSongs.Pick() + if err != nil { + log.Warn(ctx, "Error getting weighted song", err) + continue + } + if _, dup := picked[s.ID]; dup { + continue + } + picked[s.ID] = struct{}{} + similarSongs = append(similarSongs, s) + } + + return similarSongs, nil +} diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 5617269b3..ac54495f4 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -65,6 +64,18 @@ var _ = Describe("Provider - SimilarSongs", func() { provider = NewProvider(ds, agentsCombined, matcher.New(ds)) }) + // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: + // no spec here asserts how many times the entity is looked up. + stubTrackEntity := func() { + track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} + artist := model.Artist{ID: "artist-1", Name: "Artist"} + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe() + artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() + } + Describe("dispatch by entity type", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { @@ -83,7 +94,7 @@ var _ = Describe("Provider - SimilarSongs", func() { albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1). Return([]agents.Song{ {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, }, nil).Once() @@ -126,7 +137,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return false })).Return(model.MediaFiles{matchedSong}, nil).Maybe() - songs, err := provider.SimilarSongs(ctx, "track-1", 5) + songs, err := provider.SimilarSongs(ctx, "track-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -176,6 +187,76 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("song-1")) }) + + It("tops the mix up with the fallback when the agent's picks alone are too few", func() { + stubTrackEntity() + + // The agent knows one track of the three asked for. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). + Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("agent-1", "song-1")) + }) + + It("reaches a unique pick that sits past the count-th repeat", func() { + stubTrackEntity() + mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Artist{}, nil).Maybe() + mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + // "Song B" sits past the second repeat, so a matcher capped at count never reaches it. + repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2). + Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, + {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, + }, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b")) + }) + + It("keeps topping up when the agent's picks repeat a track", func() { + stubTrackEntity() + + // The matcher re-emits a track when the same input song repeats, so these three + // picks resolve to only two distinct library tracks. + repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). + Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, + {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, + }, nil).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) + }) }) Context("when ID is an Album", func() { @@ -187,7 +268,7 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5). + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1). Return([]agents.Song{ {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() @@ -208,7 +289,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "album-1", 5) + songs, err := provider.SimilarSongs(ctx, "album-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -286,7 +367,7 @@ var _ = Describe("Provider - SimilarSongs", func() { matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1). Return([]agents.Song{ {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() @@ -307,7 +388,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -348,6 +429,72 @@ var _ = Describe("Provider - SimilarSongs", func() { }) }) + Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() { + It("tops the mix up with the artist's own track-similars", func() { + artist := model.Artist{ID: "ar-1", Name: "Thin Artist"} + topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"} + + artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5). + Return([]agents.Song{}, nil).Once() + + // No similar artist is in the library, so the fallback yields only the seed artist's + // own matching top song: one track for a mix of five. + mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once() + + mediaFileRepo.On("GetRandom", mock.Anything). + Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "ar-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("top-1", "mix-1")) + }) + }) + + Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() { + It("does not pay for seed-track sampling", func() { + artist := model.Artist{ID: "ar-1", Name: "The Artist"} + artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3). + Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"}, + {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, + }, nil).Once() + + // The similar-artists fallback supplies the third track, so the mix is full. + mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything). + Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once() + + mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "ar-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) + mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything) + }) + }) + Context("when ID is a Playlist", func() { It("samples playlist tracks and returns their track-similars", func() { pls := model.Playlist{ID: "pl-1", Name: "My List"} @@ -553,7 +700,7 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) - ids := slice.Map(songs, func(mf model.MediaFile) string { return mf.ID }) + ids := ids(songs) Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") }) @@ -750,6 +897,60 @@ var _ = Describe("Provider - SimilarSongs", func() { } }) + It("keeps picking until the fallback holds count distinct tracks", func() { + // A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed + // count of entries lets those duplicates eat the budget and strand unique candidates. + track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"} + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} + + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice() + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3). + Return([]agents.Song{}, nil).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return opt.Max == 0 && ok + })).Return(model.Artists{}, nil).Once() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Or) + return opt.Max == 0 && ok + })).Return(model.Artists{similarArtist}, nil).Once() + + shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"} + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")). + Return(model.MediaFiles{shared}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). + Return([]agents.Song{ + {Name: "Shared", MBID: "mbid-a"}, + {Name: "B", MBID: "mbid-b"}, + {Name: "C", MBID: "mbid-c"}, + }, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{ + shared, + {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, + {ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}, + }, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) + }) + It("returns ErrNotFound when artist is not found", func() { artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) @@ -792,7 +993,7 @@ var _ = Describe("Provider - SimilarSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) From 59a4ed8e794e73fd41f3af7000a34ac86e718858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 13 Aug 2026 22:56:09 -0400 Subject: [PATCH 432/596] fix(plugins): stop reporting plugin call failures as not-found (#5953) * fix(plugins): stop reporting plugin call failures as not-found MetadataAgent joined agents.ErrNotFound onto every failed plugin call, so a transport fault was indistinguishable from a definitive miss. The artwork circuit breaker treats a not-found as a successful, definitive answer and resets its failure counter, so it never opened for a failing plugin and kept calling it on every request. Observed with the apple-music plugin against prod: ~900 iTunes 429s in 27 minutes with the breaker never tripping. Return the underlying error instead. The genuine empty-result branches still return agents.ErrNotFound, and agent fallback is unaffected because callAgentMethod/callAgentSliceMethod continue on any error, not only on ErrNotFound. * test(plugins): fold duplicate metadata agent error specs into one table The error-handling container drove all 11 MetadataAgent methods twice: once to assert the message, once to assert the failure is not an ErrNotFound. The argument lists were identical, so each method cost two WASM instantiations for one method's worth of coverage, and a new capability had to be registered in two places to stay guarded. Fold both assertions into a single DescribeTable, document the ErrNotFound contract at the sentinel where agent implementers will read it, and collapse breaker.record's hand-inlined predicate onto isTransientExternal, which it already duplicated by hand with a keep-in-sync comment. * fix(plugins): keep an unimplemented plugin method a definitive miss Returning the raw plugin error made errNotImplemented and errFunctionNotFound look like provider faults. Every MetadataAgent satisfies ArtistImageRetriever and AlbumImageRetriever regardless of what the plugin actually exports, so artwork resolution calls those stubs on a partially-implemented plugin: each call counted toward the artwork circuit breaker and kept the item in the retry queue instead of settling it absent. Map both sentinels back onto agents.ErrNotFound, joined so the underlying reason survives for diagnostics, and leave real call failures untouched. This matches what ScrobblerPlugin already does for the same two sentinels. The partial-implementation specs asserted only MatchError(errNotImplemented), which the previous errors.Join satisfied incidentally, so nothing caught the lost not-found semantics. They now assert both and are folded into one table. * test(plugins): cover the missing-export arm of agentErr The partial-metadata-agent fixture registers through the Go PDK, which exports every method and answers with the not-implemented code, so no fixture reaches the errFunctionNotFound branch. Building one would mean hand-writing Extism exports to deliberately omit a function, which tests the manager's function lookup rather than the mapping this PR added. Cover agentErr directly instead: both sentinels classify as a definitive miss, a call failure and a non-zero exit stay faults, and the underlying reason survives in every case. * fix(artwork): stop counting a cancelled run against the circuit breaker callPluginFunction returns ctx.Err() when a plugin call is cancelled, and that reached breaker.record as an ordinary error, so cancellations counted toward the five consecutive failures that open a gate. A cancellation says nothing about the provider, so it now neither counts nor clears the failure run. Deliberately scoped to breaker.record rather than isTransientExternal: the latter also drives whether the queue item is rescheduled, and a cancelled item must still be retried rather than settling absent. context.DeadlineExceeded is left counting as a fault, since a provider that blows the budget is one worth backing off from. Reachable today only at shutdown, where the in-memory breaker state is discarded anyway. It becomes live the moment Worker.gate is used on a request-scoped context, which is why it is worth closing now. --- core/agents/interfaces.go | 2 + core/artwork/gate.go | 8 +- core/artwork/worker_test.go | 37 +++++ plugins/metadata_agent.go | 27 ++-- plugins/metadata_agent_test.go | 262 ++++++++++++++++----------------- 5 files changed, 187 insertions(+), 149 deletions(-) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index d5f4a6580..7fc5de361 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -53,6 +53,8 @@ func (s Song) Equals(other Song) bool { } var ( + // ErrNotFound means the provider answered and had nothing. Return the underlying error + // for a fault instead, or callers that back off on faults will treat it as definitive. ErrNotFound = errors.New("not found") ) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index a64c11a20..ff77f733d 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -1,6 +1,7 @@ package artwork import ( + "context" "errors" "io" "sync" @@ -101,10 +102,13 @@ func (b *breaker) allow() bool { } func (b *breaker) record(name string, err error) { + // A cancelled run says nothing about the provider, so it neither counts nor clears. + if errors.Is(err, context.Canceled) { + return + } b.mu.Lock() defer b.mu.Unlock() - // A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal. - if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) { + if !isTransientExternal(err) { if b.failures >= breakerThreshold { log.Info("Artwork: Circuit breaker closed for agent", "agent", name) } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 5b6e42885..9fad4b503 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -539,6 +539,43 @@ var _ = Describe("Worker", func() { Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") }) + It("does not open the breaker when the run is cancelled", func() { + cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } + for range breakerThreshold + 3 { + _, _, err := w.gate("A", cancelled) + Expect(err).To(MatchError(context.Canceled), "a cancellation passes through, never errBreakerOpen") + } + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + _, _, _ = w.gate("A", counting) + Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") + }) + + It("ignores a cancellation mid-run, neither counting nor clearing the failures", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } + for range breakerThreshold - 1 { + _, _, _ = w.gate("A", failing) + } + _, _, _ = w.gate("A", cancelled) + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + _, _, _ = w.gate("A", counting) + Expect(calls).To(Equal(1), "the cancellation must not have counted as the final failure") + + _, _, err := w.gate("A", counting) + Expect(err).To(MatchError(errBreakerOpen), "the cancellation must not have cleared the earlier failures") + Expect(calls).To(Equal(1), "an open breaker must not call the external step") + }) + It("does not open the breaker on a run of agent not-found misses", func() { // agents.ErrNotFound is a definitive miss, not a fault: artless items must not // trip the breaker, or they would loop in retry instead of settling absent. diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index e5d6d43fc..607926438 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -50,6 +50,15 @@ func newMetadataAgent(p *plugin) *MetadataAgent { return &MetadataAgent{name: p.name, plugin: p} } +// agentErr keeps a plugin fault distinguishable from a definitive miss: a method the plugin +// simply does not implement has answered, so it must not count against a caller's back-off. +func agentErr(err error) error { + if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { + return errors.Join(agents.ErrNotFound, err) + } + return err +} + // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -69,7 +78,7 @@ func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name strin input := capabilities.ArtistMBIDRequest{ID: id, Name: name} result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input) if err != nil { - return "", errors.Join(agents.ErrNotFound, err) + return "", agentErr(err) } if result == nil || result.MBID == "" { @@ -84,7 +93,7 @@ func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input) if err != nil { - return "", errors.Join(agents.ErrNotFound, err) + return "", agentErr(err) } if result == nil || result.URL == "" { return "", agents.ErrNotFound @@ -97,7 +106,7 @@ func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid s input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input) if err != nil { - return "", errors.Join(agents.ErrNotFound, err) + return "", agentErr(err) } if result == nil || result.Biography == "" { @@ -112,7 +121,7 @@ func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid st input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)} result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Artists) == 0 { @@ -132,7 +141,7 @@ func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid stri input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Images) == 0 { @@ -152,7 +161,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)} result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Songs) == 0 { @@ -167,7 +176,7 @@ func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid str input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil { @@ -187,7 +196,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Images) == 0 { @@ -205,7 +214,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) { result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input) if err != nil { - return nil, err + return nil, agentErr(err) } if result == nil || len(result.Songs) == 0 { return nil, agents.ErrNotFound diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index a14db5d34..2dc67d41c 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -3,12 +3,36 @@ package plugins import ( + "errors" + "fmt" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// The partial-metadata-agent fixture registers through the Go PDK, which exports every method +// and answers -2, so only errNotImplemented reaches agentErr through a real plugin. A plugin +// that omits the export entirely yields errFunctionNotFound, covered here directly. +var _ = Describe("agentErr", func() { + DescribeTable("classifies a plugin error as a miss or a fault", + func(err error, wantMiss bool) { + got := agentErr(err) + Expect(errors.Is(got, agents.ErrNotFound)).To(Equal(wantMiss)) + Expect(got).To(MatchError(err), "the underlying reason must survive for diagnostics") + }, + Entry("an unimplemented method is a miss", + fmt.Errorf("%w: %s", errNotImplemented, FuncGetArtistImages), true), + Entry("a missing export is a miss", + fmt.Errorf("%w: %s", errFunctionNotFound, FuncGetArtistImages), true), + Entry("a call failure is a fault", + fmt.Errorf("plugin call failed: %w", errors.New("returned status 429")), false), + Entry("a non-zero exit is a fault", + errors.New("plugin call exited with code 1"), false), + ) +}) + var _ = Describe("MetadataAgent", Ordered, func() { var agent agents.Interface @@ -166,82 +190,57 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() { Expect(ok).To(BeTrue()) }) - It("returns error from GetArtistMBID", func() { - retriever := errorAgent.(agents.ArtistMBIDRetriever) - _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistURL", func() { - retriever := errorAgent.(agents.ArtistURLRetriever) - _, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistBiography", func() { - retriever := errorAgent.(agents.ArtistBiographyRetriever) - _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistImages", func() { - retriever := errorAgent.(agents.ArtistImageRetriever) - _, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarArtists", func() { - retriever := errorAgent.(agents.ArtistSimilarRetriever) - _, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistTopSongs", func() { - retriever := errorAgent.(agents.ArtistTopSongsRetriever) - _, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetAlbumInfo", func() { - retriever := errorAgent.(agents.AlbumInfoRetriever) - _, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetAlbumImages", func() { - retriever := errorAgent.(agents.AlbumImageRetriever) - _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarSongsByTrack", func() { - retriever := errorAgent.(agents.SimilarSongsByTrackRetriever) - _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarSongsByAlbum", func() { - retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever) - _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarSongsByArtist", func() { - retriever := errorAgent.(agents.SimilarSongsByArtistRetriever) - _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) + DescribeTable("surfaces the plugin failure, not a definitive not-found", + func(call func() error) { + err := call() + Expect(err).To(MatchError(ContainSubstring("simulated plugin error"))) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + }, + Entry("GetArtistMBID", func() error { + _, err := errorAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") + return err + }), + Entry("GetArtistURL", func() error { + _, err := errorAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") + return err + }), + Entry("GetArtistBiography", func() error { + _, err := errorAgent.(agents.ArtistBiographyRetriever).GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") + return err + }), + Entry("GetSimilarArtists", func() error { + _, err := errorAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) + return err + }), + Entry("GetArtistImages", func() error { + _, err := errorAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") + return err + }), + Entry("GetArtistTopSongs", func() error { + _, err := errorAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) + return err + }), + Entry("GetAlbumInfo", func() error { + _, err := errorAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetAlbumImages", func() error { + _, err := errorAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetSimilarSongsByTrack", func() error { + _, err := errorAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByAlbum", func() error { + _, err := errorAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByArtist", func() error { + _, err := errorAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + return err + }), + ) }) var _ = Describe("MetadataAgent partial implementation", Ordered, func() { @@ -268,68 +267,55 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { Expect(bio).To(Equal("Partial agent biography for Test Artist")) }) - It("returns ErrNotFound for unimplemented method (GetArtistMBID)", func() { - retriever := partialAgent.(agents.ArtistMBIDRetriever) - _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetArtistURL)", func() { - retriever := partialAgent.(agents.ArtistURLRetriever) - _, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetArtistImages)", func() { - retriever := partialAgent.(agents.ArtistImageRetriever) - _, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarArtists)", func() { - retriever := partialAgent.(agents.ArtistSimilarRetriever) - _, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - - }) - - It("returns ErrNotFound for unimplemented method (GetArtistTopSongs)", func() { - retriever := partialAgent.(agents.ArtistTopSongsRetriever) - _, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - - }) - - It("returns ErrNotFound for unimplemented method (GetAlbumInfo)", func() { - retriever := partialAgent.(agents.AlbumInfoRetriever) - _, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - - }) - - It("returns ErrNotFound for unimplemented method (GetAlbumImages)", func() { - retriever := partialAgent.(agents.AlbumImageRetriever) - _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() { - retriever := partialAgent.(agents.SimilarSongsByTrackRetriever) - _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() { - retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever) - _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() { - retriever := partialAgent.(agents.SimilarSongsByArtistRetriever) - _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - }) + // An unimplemented optional method is a definitive miss. Reported as a fault it would + // count against the artwork circuit breaker and keep the item in the retry queue. + DescribeTable("reports an unimplemented method as a definitive not-found", + func(call func() error) { + err := call() + Expect(err).To(MatchError(errNotImplemented)) + Expect(err).To(MatchError(agents.ErrNotFound)) + }, + Entry("GetArtistMBID", func() error { + _, err := partialAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") + return err + }), + Entry("GetArtistURL", func() error { + _, err := partialAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + return err + }), + Entry("GetArtistImages", func() error { + _, err := partialAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + return err + }), + Entry("GetSimilarArtists", func() error { + _, err := partialAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) + return err + }), + Entry("GetArtistTopSongs", func() error { + _, err := partialAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) + return err + }), + Entry("GetAlbumInfo", func() error { + _, err := partialAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetAlbumImages", func() error { + _, err := partialAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetSimilarSongsByTrack", func() error { + _, err := partialAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByAlbum", func() error { + _, err := partialAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByArtist", func() error { + _, err := partialAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + return err + }), + ) }) var _ = Describe("songRefToAgentSong multi-artist", func() { From aa0824e03bfd617caeddfe52a41a421a77e6289f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 14 Aug 2026 09:17:00 -0400 Subject: [PATCH 433/596] feat(jellyfin): add System/Endpoint so Finamp's connection test passes (#5955) Finamp's connection test GETs /System/Endpoint and treats anything other than a 200 carrying an IsInNetwork key as "not a Jellyfin server", so the test failed against Navidrome and dual-connection setups could never switch to the local address. IsInNetwork mirrors Jellyfin's default LAN set (NetworkManager with no LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. Notably that set omits 169.254.0.0/16, so Go's IsLinkLocalUnicast is deliberately restricted to its IPv6 half. IsLocal mirrors HttpContext.IsLocal(): the caller shares the connection's local address, not merely "is loopback". It falls back to the loopback check when the local address is unavailable. --- server/jellyfin/README.md | 2 +- server/jellyfin/api.go | 1 + server/jellyfin/dto/dto.go | 6 ++++ server/jellyfin/e2e/system_test.go | 16 +++++++++ server/jellyfin/system.go | 44 +++++++++++++++++++++++ server/jellyfin/system_test.go | 56 ++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 195173c2a..5759e6cc0 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -112,7 +112,7 @@ returns direct children only (no tracks — no track is a library's direct child | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 8a175df35..d64366a63 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -104,6 +104,7 @@ func (api *Router) routes() http.Handler { // player) even before the first playback report. r.Use(api.withPlayer) r.Get("/system/info", api.getSystemInfo) + r.Get("/system/endpoint", api.getEndpointInfo) r.Get("/userviews", api.getUserViews) r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 5a2a4c2b1..58a6b919e 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -20,6 +20,12 @@ type SystemInfo struct { CachePath string `json:"CachePath,omitempty"` } +// EndPointInfo describes the caller's network location (GET /System/Endpoint). +type EndPointInfo struct { + IsLocal bool `json:"IsLocal"` + IsInNetwork bool `json:"IsInNetwork"` +} + type NameGuidPair struct { Name string `json:"Name"` Id string `json:"Id"` diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index c6f9145d5..ac8c350c4 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -51,6 +51,22 @@ var _ = Describe("System", func() { }) }) + Describe("GET /System/Endpoint", func() { + It("always reports IsInNetwork, which Finamp's connection test probes for", func() { + w := getAs(regularUser, "/System/Endpoint") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info).To(HaveKey("IsInNetwork")) + Expect(info).To(HaveKey("IsLocal")) + }) + + It("rejects unauthenticated requests", func() { + w := rawReq("GET", "/System/Endpoint", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + Describe("GET/POST /System/Ping", func() { It("answers GET with a plain-text server name", func() { w := rawReq("GET", "/System/Ping", "") diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index ea553fd87..8b847987f 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -5,7 +5,9 @@ import ( "encoding/hex" "errors" "fmt" + "net" "net/http" + "net/netip" "path" "strings" @@ -108,6 +110,48 @@ func (api *Router) ping(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(api.serverName())) } +// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a +// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server". +func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) { + remote := remoteIP(r) + api.ok(w, r, dto.EndPointInfo{ + IsLocal: isSameMachine(r, remote), + IsInNetwork: isInLocalNetwork(remote), + }) +} + +// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no +// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. +func isInLocalNetwork(ip netip.Addr) bool { + return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast()) +} + +// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local +// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback. +func isSameMachine(r *http.Request, remote netip.Addr) bool { + local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok { + return remote.IsLoopback() + } + return parseIP(local.String()) == remote +} + +// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP. +func remoteIP(r *http.Request) netip.Addr { + return parseIP(r.RemoteAddr) +} + +func parseIP(addr string) netip.Addr { + if h, _, err := net.SplitHostPort(addr); err == nil { + addr = h + } + ip, err := netip.ParseAddr(addr) + if err != nil { + return netip.Addr{} + } + return ip.Unmap() +} + func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { api.ok(w, r, false) } diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index a7f7df6a4..4b3ac240e 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "net/http/httptest" @@ -88,6 +89,61 @@ var _ = Describe("System", func() { Expect(w.Body.String()).To(HavePrefix("Navidrome")) }) + DescribeTable("reports the caller's network location on /System/Endpoint", + func(remoteAddr string, isLocal, isInNetwork bool) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = remoteAddr + api.getEndpointInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + // Finamp's connection test only probes for the key's presence, so it must always be emitted. + Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`)) + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(Equal(isLocal)) + Expect(info.IsInNetwork).To(Equal(isInNetwork)) + }, + Entry("loopback", "127.0.0.1:12345", true, true), + Entry("IPv6 loopback", "[::1]:12345", true, true), + Entry("LAN address", "192.168.1.20:54321", false, true), + Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true), + Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true), + Entry("IPv6 link-local", "[fe80::1]:80", false, true), + Entry("IPv6 unique-local", "[fd00::1]:80", false, true), + // Jellyfin's default LAN set omits 169.254.0.0/16, so we do too. + Entry("IPv4 link-local", "169.254.1.1:80", false, false), + Entry("public address", "8.8.8.8:443", false, false), + Entry("unparseable address", "not-an-ip", false, false), + ) + + It("reports IsLocal when the caller shares the connection's local address", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = "192.168.1.20:54321" + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, + &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) + api.getEndpointInfo(w, r.WithContext(ctx)) + + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(BeTrue()) + }) + + It("does not report IsLocal for a different host on the same LAN", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = "192.168.1.99:54321" + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, + &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) + api.getEndpointInfo(w, r.WithContext(ctx)) + + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(BeFalse()) + Expect(info.IsInNetwork).To(BeTrue()) + }) + It("reports quick connect as disabled", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) From 95615bcb18ece3f88af0d4dccb0e9faa25fb3e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 14 Aug 2026 09:43:13 -0400 Subject: [PATCH 434/596] fix(artwork): serve images whose format has no registered decoder (#5952) * fix(artwork): serve images whose format has no registered decoder The new pipeline derives dimensions, mime and the placeholder hashes at resolution time, so a decode became a precondition for recording artwork at all. An image in a format Go has no decoder for therefore failed acquisition, retried until the 12h budget ran out, and then settled as absent, serving a placeholder from that point on. The old pipeline decoded only to resize and fell back to the original bytes when that failed, so these covers used to work. A local file is picked by matching an image extension, so bytes it cannot decode are most likely a codec we lack: image.ErrFormat on a folder, upload or embedded source now yields an Artwork row carrying just the hash and mime, and the bytes stay servable. An external response carries no such guarantee, so it still fails and retries rather than pinning a non-image body as a cover. A corrupt image of a known format and an over-cap declared size still fail, so the decompression bomb guard is unchanged. Absent rows recorded by earlier builds are re-resolved by the existing stale-absent recheck within a day, so no epoch bump is needed to repair them. Reusing a stored image now re-decodes when it carries no dimensions, so a row recorded while a decoder was missing can still be upgraded later. Registers jxl, heic and heif in mime_types.yaml: image detection resolves the extension through the host MIME table, and the Alpine release image ships no /etc/mime.types, so those covers were never recorded in folder.ImageFiles there and never reached the pipeline at all. * fix(artwork): never record empty bytes as artwork image.DecodeConfig returns image.ErrFormat for an empty payload just as it does for a codec with no registered decoder, so a zero-byte cover file was recorded as found artwork and served as an empty response instead of falling back to the placeholder. A truncated image of a known format already fails with unexpected EOF rather than ErrFormat, so only the empty case needed the guard. --- core/artwork/e2e/acquire_serve_test.go | 23 ++++++ core/artwork/processor.go | 23 +++++- core/artwork/processor_test.go | 108 ++++++++++++++++++++++++- resources/mime_types.yaml | 3 + 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index d6c374e39..0a9363b5d 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -116,6 +117,25 @@ var _ = Describe("Acquisition → serve loop", func() { albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) } + It("acquires and serves a cover whose format has no registered decoder (#5950)", func() { + libDir := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed()) + + conf.Server.CoverArtPriority = "cover.*" + libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}}) + folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}} + albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) + + bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(jxlFixture)) + }) + It("acquires album folder art and serves the exact bytes under its hash", func() { seedFolderAlbum("al1") bump("al", "al1") @@ -317,6 +337,9 @@ func mustGet(img *artwork.Image, err error) *artwork.Image { } // Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking +// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. +var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} + // the production import the spec above guards. var gifFixture = []byte{ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00, diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 6ce76bf45..4d38ced95 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -123,12 +123,20 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o art, err := repo.GetImage(hash) switch { - case err == nil: + case err == nil && art.Width > 0: log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, "id", item.ItemID, "hash", hash) - case errors.Is(err, model.ErrNotFound): + // A row with no dimensions was stored when no decoder matched; retry in case one exists now. + case err == nil, errors.Is(err, model.ErrNotFound): decodeStart := time.Now() art, err = decodeArtwork(ctx, hash, data) + // Extension-matched local bytes we cannot decode are most likely a codec we lack; an + // external body carries no such guarantee, and empty bytes are no image at all. + if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) { + log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders", + "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) + art, err = undecodedArtwork(hash), nil + } if err != nil { log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil @@ -234,6 +242,12 @@ func decodeCapped(data []byte) (image.Image, string, error) { return img, format, nil } +// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions +// and none of the placeholders a decode would have produced. +func undecodedArtwork(hash string) *model.Artwork { + return &model.Artwork{Hash: hash, Mime: mimeForFormat("")} +} + // decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two // placeholder hashes, both encoded from one shared downscaled thumbnail. func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { @@ -288,6 +302,11 @@ func isFileBacked(source string) bool { return source == "folder" || source == "upload" } +// isLocalSource reports whether the bytes came off disk rather than off the network. +func isLocalSource(source string) bool { + return isFileBacked(source) || source == "embedded" +} + // placeBytes reports the item's backing-file provenance and writes the bytes into the store // for the sources that have none. func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 8d30975cd..1ada8415d 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -1,6 +1,7 @@ package artwork import ( + "bytes" "context" "encoding/binary" "errors" @@ -24,6 +25,9 @@ import ( . "github.com/onsi/gomega" ) +// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. +var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} + // DecodeConfig reads only the header, so the pixel data can be omitted entirely. func pngHeaderWithDims(w, h uint32) []byte { ihdr := make([]byte, 13) @@ -259,6 +263,59 @@ var _ = Describe("processor.acquire", func() { Expect(ia.Source).To(Equal("folder")) }) + It("undecodable local file: acquires it anyway, with no placeholder metadata", func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Width).To(BeZero()) + Expect(art.BlurHash).To(BeEmpty()) + }) + + It("empty local file: fails without writing state", func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + // An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover. + It("undecodable external body: fails without writing state", func() { + conf.Server.CoverArtPriority = "external" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("<html>rate limited</html>")) + })) + DeferCleanup(srv.Close) + + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() { conf.Server.CoverArtPriority = "external" imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) @@ -374,7 +431,8 @@ var _ = Describe("processor.acquire", func() { conf.Server.DataFolder = conf.NewDir(tmpDir) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed()) + // Truncated PNG: a known format, so this is a real decode failure, not a missing decoder. + Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed()) radioRepo := tests.CreateMockedRadioRepo() radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} @@ -428,6 +486,54 @@ var _ = Describe("processor.acquire", func() { Expect(err.Error()).To(ContainSubstring("dimensions")) }) + It("unknown format: reports ErrFormat so the caller can decide", func() { + _, err := decodeArtwork(ctx, "jxl", jxlCodestream) + Expect(err).To(MatchError(image.ErrFormat)) + }) + + It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() { + art := undecodedArtwork("jxl") + Expect(art.Hash).To(Equal("jxl")) + Expect(art.Mime).To(Equal("application/octet-stream")) + Expect(art.Width).To(BeZero()) + Expect(art.Height).To(BeZero()) + Expect(art.BlurHash).To(BeEmpty()) + Expect(art.ThumbHash).To(BeEmpty()) + Expect(art.DominantColor).To(BeEmpty()) + }) + + It("corrupt image of a known format: still fails", func() { + data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing + _, err := decodeArtwork(ctx, "truncated", data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("decode image")) + }) + + // Without this a metadata-less row would be reused forever, so a decoder added later + // could never upgrade it. + It("metadata-less row: re-decodes on reuse instead of skipping", func() { + libRoot := GinkgoT().TempDir() + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} + + hash, err := hashImage(bytes.NewReader(imgBytes)) + Expect(err).ToNot(HaveOccurred()) + Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) + Expect(out).To(Equal(outcomeFound)) + + upgraded, err := artRepo.GetImage(hash) + Expect(err).ToNot(HaveOccurred()) + Expect(upgraded.Width).To(BeNumerically(">", 0)) + Expect(upgraded.BlurHash).ToNot(BeEmpty()) + }) + It("store write failure: fails without writing state", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index f67b26af4..83abf2e5c 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -37,6 +37,9 @@ types: .webp: image/webp .png: image/png .bmp: image/bmp + .jxl: image/jxl + .heic: image/heic + .heif: image/heif # List of audio formats that are considered lossless lossless: From b617a878b90a9834579c0a36e20dc1e10d13a6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 14 Aug 2026 13:46:52 -0400 Subject: [PATCH 435/596] feat(insights): report the app store or hosting platform via ND_PLATFORM (#5956) * feat(insights): report the app store or hosting platform via ND_PLATFORM Insights had no way to tell where an instance is deployed. The existing `os.package` field is written only by our own packagers and holds just `deb`, `rpm` or `msi`, so it answers "which installer", not "which platform". Overloading it would mix two unrelated dimensions in the same field. This adds a separate top-level `platform` field, self-declared by the deployer through the `ND_PLATFORM` environment variable. App stores and hosting providers (ZimaOS, PikaPods, TrueNAS, Unraid, and others) generally deploy our container image unmodified and can only inject environment variables, so an env var is the one marker they can all set. It is deliberately not a config option: it is a packager marker, not something users should tune, and it stays out of the config surface. Both values are now whitespace-trimmed. The msi packager writes the file with `echo`, so `os.package` has been arriving as `"msi\n"` and sorting separately from `"msi"` in any aggregation. * test(insights): isolate hostingPlatform specs from an inherited ND_PLATFORM The spec asserting an empty result read the real environment, so it failed on any machine that already had ND_PLATFORM set. Unset it per-spec, using Setenv first so Ginkgo restores the original value on cleanup. --- core/metrics/insights.go | 19 +++++-- core/metrics/insights/data.go | 4 +- core/metrics/insights_internal_test.go | 70 ++++++++++++++++++++++++++ core/metrics/metrics_suite_test.go | 17 +++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 core/metrics/insights_internal_test.go create mode 100644 core/metrics/metrics_suite_test.go diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 78391779a..66d0b89bd 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "runtime/debug" + "strings" "sync" "sync/atomic" "time" @@ -153,6 +154,17 @@ func getFSInfo(path string) *insights.FSInfo { return &info } +// installedPackage returns the official installer format used, as written by our own packagers. +func installedPackage() string { + data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package")) + return strings.TrimSpace(string(data)) +} + +// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image. +func hostingPlatform() string { + return strings.TrimSpace(os.Getenv("ND_PLATFORM")) +} + var staticData = sync.OnceValue(func() insights.Data { // Basic info data := insights.Data{ @@ -165,11 +177,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package") - packageFileData, err := os.ReadFile(packageFilename) - if err == nil { - data.OS.Package = string(packageFileData) - } + data.OS.Package = installedPackage() + data.Platform = hostingPlatform() // OS info data.OS.Type = runtime.GOOS diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 126d759bc..8559d4204 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -4,7 +4,9 @@ type Data struct { InsightsID string `json:"id"` Version string `json:"version"` Uptime int64 `json:"uptime"` - Build struct { + // Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM + Platform string `json:"platform,omitempty"` + Build struct { // build settings used by the Go compiler Settings map[string]string `json:"settings"` GoVersion string `json:"goVersion"` diff --git a/core/metrics/insights_internal_test.go b/core/metrics/insights_internal_test.go new file mode 100644 index 000000000..74c8ce236 --- /dev/null +++ b/core/metrics/insights_internal_test.go @@ -0,0 +1,70 @@ +package metrics + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("installedPackage", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + }) + + It("returns empty when there's no .package file", func() { + Expect(installedPackage()).To(BeEmpty()) + }) + + It("reads the .package file from the data folder", func() { + writePackageFile("deb") + + Expect(installedPackage()).To(Equal("deb")) + }) + + It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() { + writePackageFile("msi\n") + + Expect(installedPackage()).To(Equal("msi")) + }) + + It("ignores ND_PLATFORM", func() { + GinkgoT().Setenv("ND_PLATFORM", "zimaos") + + Expect(installedPackage()).To(BeEmpty()) + }) +}) + +var _ = Describe("hostingPlatform", func() { + BeforeEach(func() { + // Setenv registers the restore, then unset so an inherited value can't leak in + GinkgoT().Setenv("ND_PLATFORM", "") + Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed()) + }) + + It("returns empty when ND_PLATFORM is not set", func() { + Expect(hostingPlatform()).To(BeEmpty()) + }) + + It("reads ND_PLATFORM", func() { + GinkgoT().Setenv("ND_PLATFORM", "zimaos") + + Expect(hostingPlatform()).To(Equal("zimaos")) + }) + + It("trims surrounding whitespace", func() { + GinkgoT().Setenv("ND_PLATFORM", " pikapods\n") + + Expect(hostingPlatform()).To(Equal("pikapods")) + }) +}) + +func writePackageFile(content string) { + GinkgoHelper() + path := filepath.Join(conf.Server.DataFolder.String(), ".package") + Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) +} diff --git a/core/metrics/metrics_suite_test.go b/core/metrics/metrics_suite_test.go new file mode 100644 index 000000000..bae622e90 --- /dev/null +++ b/core/metrics/metrics_suite_test.go @@ -0,0 +1,17 @@ +package metrics + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMetrics(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Metrics Suite") +} From dc40bcaf808a40a2b5d6e62c9338e84f40ca9407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 14 Aug 2026 21:07:56 -0400 Subject: [PATCH 436/596] feat(cli): add an artwork command group for diagnosing and re-driving artwork (#5957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): add a resolution chain trace collector * feat(artwork): trace the local priority chain * fix(artwork): record priority candidates the chain never evaluated * refactor(artwork): report never-evaluated candidates as skipped * feat(artwork): trace external agents at the gate seam * feat(artwork): add repository queries to enqueue by current source * feat(artwork): expose a tracing resolver for the CLI * feat(artwork): read a single queue row by item The explain CLI must report whether an item is queued, at what priority and when it retries; the queue repository could only be drained in eligibility batches, which cannot see a row that is still backing off. * feat(cli): add artwork explain Prints why an item has the artwork it has: the stored state, its queue row, the governing config, the resolver's priority-chain walk and the verdict. Offline by default so a diagnostic run cannot add load to an external provider; --live asks the agents for real. Playlists and radios do not walk a priority chain, so they report that instead of an empty chain table. * fix(artwork): trace an external tier that never reaches an agent A configured 'external' token vanished from the chain when no enabled agent provided images for that entity type, and for synthetic artists, leaving the trace unable to say whether the tier was even considered. * fix(cli): never state an artwork outcome the walk did not observe A transient external failure traced as 'error' fell through to 'not resolved', which is the most common state behind a missing-artwork report. It is now indeterminate, and an offline win that a skipped higher-priority external candidate could have taken says so instead of naming a winner the live chain might not pick. * feat(cli): add artwork refresh * feat(cli): add artwork reprocess Bulk re-enqueues artwork by kind and/or by the source an item currently resolves from, previewing the matched count and confirming before queueing. The preview counts with CountBySource (rows matched) and reports separately what EnqueueBySource inserted: its DO NOTHING conflict policy leaves an already-queued row untouched, so the two numbers differ and the output must not claim the skipped rows were re-queued. An unknown --source is rejected against the sources present in item_artwork, rather than silently matching nothing and printing a reassuring 0. * fix(cli): cover the reprocess selection rule and validate sources table-wide The reconciliation that makes --source alone target every kind was only exercised through runReprocess, which no test calls: mutating it to `all := reprocessAll` left the suite green. It is now reprocessSelectsAll, covered for all three selectors. Scoping source validation to the selected kinds made the same well-formed filter valid or invalid depending on which other kinds were selected, and its error read the same for a typo as for a source that simply does not apply to the chosen kind. Validation is now table-wide: a typo still aborts, while a valid-but-inapplicable source falls through to "Nothing matches". Also: the prompt now counts only the kinds that reach an external agent as external cost, and --dry-run on an empty selection reports a dry run. * fix(cli): cover the reprocess --yes guard and preview the external cost Mutating the --yes check to `if true` left the suite green, so the one bypass of the confirmation was unverified. The choice is now reprocessConfirm(yes, in), covered in both directions. The external estimate only reached the operator through the prompt, which --dry-run skips — hiding the number in the one mode that exists to show it before committing. The preview now carries it, and the prompt drops the clause when no lookup will be made. An empty selection says so again under --dry-run. * feat(artwork): add read-only queue and absent counters Both are needed by the artwork status CLI: a queue breakdown by kind and priority, and the absent totals split against the recheck cutoff. * feat(cli): add artwork status Reports the queue, where artwork currently resolves from, absent counts against the 24h recheck window, and the stored config fingerprint versus the current one — the line that turns 'why is my server re-resolving everything?' into one command. fingerprint() and staleAbsentAge are exported so the CLI reports the values backfill itself compares, instead of a second copy of the formula that can silently drift. * fix(cli): lead the artwork status backfill line with the queued backlog By the time anyone runs a diagnostic, backfill has usually already stored the new fingerprint, so 'up to date' was printed while thousands of items churned through external providers. The backlog is the finding; the fingerprint is context. Also echoes the config inputs the fingerprint covers, so a change can be traced to the setting that caused it, and pins the rendered rows: the Absent values, the queue TOTAL and a queue-scoped kind/priority pair were all unasserted, so kindName and priorityName were effectively untested. FingerprintInputs is now the single listing ConfigFingerprint hashes; a pinned hash proves the value did not change. * refactor(artwork): export the trace outcome vocabulary The CLI hardcoded the outcome literals and the "external:" prefix, so renaming a constant's value in core/artwork left cmd compiling and the suite green while `artwork explain` silently degraded its verdict. Renaming a value now fails the golden vocabulary test in core/artwork and the explainResult tests in cmd. * fix(cli): keep the re-enqueue warning when a backfill is already running A stale stored fingerprint with items already queued is the worst state the system can be in: a second full re-enqueue is pending on top of the one running. The line carried the weakest wording of the three, and was untested. * refactor(artwork): drop the unreachable breaker branch from the tracing gate --live wires the tracing gate straight to passthroughGate, so errBreakerOpen can never reach it; the test only passed by injecting a fake gate. * refactor(artwork): delete the never-emitted not-reached outcome Candidates after the winner are lower priority and say nothing about why a source won; the ones that matter sit above it and are already recorded. * refactor(artwork): make the trace nil-safe in one place only add already handles a nil trace, so record's own guard was dead; Steps was the odd one out and would panic where every other method tolerates nil. * refactor(artwork): export the trace types directly ChainTrace and TraceStep were unexported types re-exported through aliases, which existed only so the CLI had a name to refer to them by. The types are public API — Resolver.Steps returns []TraceStep and the CLI constructs a ChainTrace — so name them that way and drop the indirection. Encapsulation is unchanged: add, mu and steps stay unexported, so only this package can write a step. * refactor(cli): simplify parseArtworkKind with slices.Contains Replaces a nested loop and a manual append with slices.Contains and the repo's slice.Map helper. Same behaviour, same error message. * fix(cli): print the absent artwork source under the name --source accepts `artwork explain` rendered the stored empty source as "(absent)", while `artwork reprocess --source` only accepts "absent", so pasting what explain printed straight back into reprocess was rejected as an unknown source. * refactor(artwork): own the kind list and the chain predicate in the package Export RecheckKinds and add WalksPriorityChain so the CLI stops keeping its own copies of both, and unexport externalCandidate, which nothing outside the package consumes. * refactor(cli): drop the artwork command's duplicated state and formatting Reuse artwork.RecheckKinds and artwork.WalksPriorityChain, extract newTabWriter and externalEstimate, fold reprocessSelectsAll into selectedKinds, and derive the queue total and the walks-chain flag instead of carrying them in the report structs. * test(persistence): drop two artwork-queue specs that cannot fail One seeded hash and source together and then asserted the two counts agree, so its setup guaranteed the result; the other repeated the count-does-not- enqueue property already covered by the CountBySource spec. * refactor(artwork): rename Resolver to TracingResolver for clarity * fix(cli): count playlists in the artwork reprocess external estimate The estimate used WalksPriorityChain, which is true only for artist and album, so a playlist-only reprocess reported "External lookups: none" and the confirmation prompt dropped the external-cost warning. Playlists do reach the network: through the m3u ExternalImageURL fetch when EnableM3UExternalAlbumArt is on, and — verified by test — through the generated grid, whose tiles resolve album art via the full album priority chain. Adds artwork.MayFetchExternal, a config-aware predicate for "can this kind's resolver reach the network", and uses it for the estimate. WalksPriorityChain keeps its separate job of deciding whether explain prints a chain block. * fix(cli): estimate artwork reprocess external lookups per agent, not per item The reprocess prompt billed one external lookup per externally-capable item. fetchArtistImage/fetchAlbumImage try every enabled image agent and stop early only on a hit, and resolvePlaylist can fetch the m3u image and then resolve up to four sampled albums for the grid, each walking the album agents again. The number the operator confirmed could understate real provider traffic several fold, in the prompt whose whole job is to stop a provider flood. ExternalLookupsPerItem now multiplies by the visible image-agent count and adds the playlist grid factor. It stays a floor: the CLI never calls Manager.Start(), so the plugin registry is empty and plugin-provided agents are dropped by getEnabledAgentNames. On an install with 5 agents of which 3 are plugins the count is well under the truth, so the wording is now "at least N" rather than "up to N" — a zero visible count still bills one lookup for the same reason. Fixing the plugin visibility is out of scope: Manager.Start() needs a Subsonic router and writes to the DB via syncPlugins, breaking this command group's read-only guarantee. * fix(cli): state the artwork reprocess estimate as an estimate, not a bound Neither bound is true. A ceiling is false because plugin agents are invisible to a CLI that never starts the plugin manager, and a floor is false because a local hit ends the walk before any agent is asked and a hit on the first agent skips the rest. "at least N" traded one wrong claim for another. The line now names its blind spots instead: External lookups: ~340 estimated (plugin agents not counted; local hits may need fewer). The same line is reused in the confirmation prompt, and the zero case still reads "External lookups: none." with the prompt dropping the clause entirely. The count itself is unchanged. * fix(cli): account for every configured agent in artwork explain The Agents: line printed the raw config while the Chain only showed the agents the CLI could construct, with nothing explaining the gap: plugin agents are never registered in a CLI that does not start the plugin manager, and a built-in without credentials returns nil. Three of five agents could vanish, including ones ranked above the one shown. Also treat a live external error before the winning hit like the already-handled would-try case: the resolver serves such a hit provisionally and retries later, so the verdict is indeterminate. The Result line is still not qualified when an unavailable agent might have won; that needs agent ranking, and is left to the follow-up that makes the CLI load plugin agents for real. * fix(cli): do not call an external artwork win indeterminate explainResult qualified the verdict whenever an external OutcomeError appeared before the winning hit. When a later external agent returns an image, fetchArtistImage/fetchAlbumImage discard the earlier error, so extError is false: the worker settles the item and schedules no retry. Telling the operator it may resolve differently on a retry was wrong. The warning is only correct when a lower-priority local source won while an external error was recorded, which is the case that carries extError. * fix(cli): accept --source absent when nothing is currently absent validateSources checks the requested sources against the ones item_artwork actually uses, to catch a typo. The reserved empty source (spelled 'absent' on the CLI) is a valid filter even when it matches nothing, so a scheduled 'artwork reprocess --source absent --yes' stopped working the moment the library finished resolving. Treat it as intrinsically valid and let the existing zero-match path report it. * feat(artwork): explain disc and media file artwork from the CLI `artwork explain` rejected `dc` and `mf` because it validated against RecheckKinds, the list of kinds the backfill revisits. Those are different questions: a kind with no recheck path still has artwork someone can report as wrong. Disc artwork now walks DiscArtPriority under a trace, so explain reports which entry won and why the others lost, including entries that map to no source at all (external is unsupported, a disc with no subtitle, an album folder with no images). Media file artwork traces its single embedded candidate, separating "EnableMediaFileCoverArt is off" from "the track has no embedded art" — stored state cannot tell those apart. Each command now validates against the kinds it can actually serve: explain takes all six, refresh takes artwork.RefreshableKinds (which nativeapi now shares instead of keeping its own copy), reprocess still takes RecheckKinds. Disc artwork stays out of refresh: the worker cannot resolve it, so the queue row would be rejected on every drain. WalksPriorityChain becomes Explainable, and ResolveArtist/ResolveAlbum collapse into Resolve(kind, id). * refactor(artwork): one disc-artwork walk for serving and explain resolveDisc duplicated the loop selectImageReader already ran: try each source in priority order, take the first that yields an image. The serving path and the CLI diverged on two details as a result — only selectImageReader checked ctx between candidates and logged each attempt. Both now call discArtworkReader.selectImage, which takes the chainState the CLI already uses for the other kinds. The serving path passes an untraced one, whose nil trace makes recording a no-op. selectImageReader had no other caller and is gone. The disc tests move from fromDiscArtPriority to discCandidates, so they assert the skip reason for an entry that maps to no source rather than that it silently vanished, and cancellation mid-walk is now covered. * fix(artwork): reject a nil reader in the resize cache instead of panicking resizedItem.Reader closes what open() hands back, so an open() that reports "no image" as (nil, nil) rather than an error takes the request down with a nil-pointer panic. Every caller returns an error today, and no test covered it: the resolution e2e harness stubs the resize reader out entirely, so no e2e path reaches this code at all. Guard it and cover Reader directly. * refactor(artwork): move the keeps-state fact into core, drop a redundant guard keepsArtworkState lived in package cmd and re-derived by hand what RefreshableKinds already encodes: the same five-of-six kinds. It is now artwork.KeepsState, beside the list, with a test pinning the two together — nothing else stopped them drifting, and a drift would have explain report stored state for a kind that keeps none. serveDisc's closure also hand-rolled a nil-reader error that both consumers of open() now produce themselves: serveSource for a full-size request, resizedItem.Reader for a resized one. * fix(artwork): route disc candidates through the shared resolvers openCandidate ran its own source loop and threw the error away, so a disc track that exists but cannot be parsed traced as "miss" — indistinguishable from a track with no embedded art. fromTag and fromFFmpegTag already report that case as errSourceUnreadable; only this loop was discarding it. Telling those two apart is what the trace is for. Candidates now carry a resolve func instead of raw sources: embedded goes to resolveEmbedded, and the folder-backed entries to resolveFolderSource, extracted from resolveFolderFile so both callers classify an unopenable file the same way. openCandidate and its absolute-path special case go away with it. Disc's own fromExternalFile and fromDiscSubtitle still swallow open errors, so folder candidates cannot report unreadable yet; that is a change to their error contracts. * fix(artwork): report an unreadable local candidate as indeterminate processor.acquire treats resolution.localError exactly as it treats extError: a fault is not a definitive "no image", so it retries instead of settling absent. explainResult qualified only the external case, so a chain that ended on an unreadable local candidate printed "not resolved" — the one verdict that says the walk was conclusive. The qualification belongs only to the unresolved branch. chainState.try stamps extErr onto a hit and deliberately drops localErr, so an unreadable step followed by a hit is settled as found and must not carry a warning; a test pins that. Found by Codex on 5f65d7cfa. --- cmd/artwork.go | 778 ++++++++++++++++ cmd/artwork_test.go | 899 +++++++++++++++++++ cmd/plugin.go | 3 +- cmd/utils.go | 7 + cmd/wire_gen.go | 12 + cmd/wire_injectors.go | 7 + core/artwork/agent_images.go | 17 +- core/artwork/agent_images_test.go | 36 + core/artwork/artwork.go | 60 +- core/artwork/disc.go | 72 +- core/artwork/disc_test.go | 179 ++-- core/artwork/housekeeping.go | 55 +- core/artwork/housekeeping_test.go | 55 +- core/artwork/image_cache.go | 4 + core/artwork/image_cache_test.go | 41 + core/artwork/resolve.go | 177 +++- core/artwork/resolve_test.go | 129 +++ core/artwork/sources.go | 17 - core/artwork/trace.go | 99 ++ core/artwork/trace_test.go | 628 +++++++++++++ core/artwork/worker.go | 2 +- core/artwork/worker_test.go | 2 +- model/artwork.go | 26 + persistence/artwork_queue_repository.go | 72 +- persistence/artwork_queue_repository_test.go | 138 +++ server/nativeapi/artwork.go | 10 +- tests/mock_artwork_queue_repo.go | 116 +++ 27 files changed, 3469 insertions(+), 172 deletions(-) create mode 100644 cmd/artwork.go create mode 100644 cmd/artwork_test.go create mode 100644 core/artwork/image_cache_test.go create mode 100644 core/artwork/trace.go create mode 100644 core/artwork/trace_test.go diff --git a/cmd/artwork.go b/cmd/artwork.go new file mode 100644 index 000000000..2b6a50164 --- /dev/null +++ b/cmd/artwork.go @@ -0,0 +1,778 @@ +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "github.com/spf13/cobra" +) + +var explainLive bool + +var ( + reprocessKinds []string + reprocessSources []string + reprocessAll bool + reprocessDryRun bool + reprocessYes bool +) + +func init() { + artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, + "perform real external lookups instead of reporting what would be tried") + artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil, + "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") + artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil, + "only items currently resolved from these sources (e.g. folder, external:deezer, absent)") + artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind") + artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false, + "report what would be queued and exit without queueing") + artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt") + artworkCmd.AddCommand(artworkExplainCmd) + artworkCmd.AddCommand(artworkRefreshCmd) + artworkCmd.AddCommand(artworkReprocessCmd) + artworkCmd.AddCommand(artworkStatusCmd) + rootCmd.AddCommand(artworkCmd) +} + +var artworkCmd = &cobra.Command{ + Use: "artwork", + Short: "Inspect and re-resolve artwork", +} + +var artworkExplainCmd = &cobra.Command{ + Use: "explain <kind> <id>", + Short: "Explain why an item's artwork resolved the way it did", + Long: "Explain why an item's artwork resolved the way it did.\n\n" + + "<kind> is one of: " + kindPrefixes(explainKinds) + ".\n" + + "A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + kind, err := parseArtworkKind(args[0], explainKinds) + if err != nil { + log.Fatal(cmd.Context(), err) + } + runExplain(cmd.Context(), kind, args[1]) + }, +} + +var artworkRefreshCmd = &cobra.Command{ + Use: "refresh <kind> <id>...", + Short: "Clear an item's artwork state and re-resolve it", + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + kind, err := parseArtworkKind(args[0], artwork.RefreshableKinds) + if err != nil { + log.Fatal(cmd.Context(), err) + } + runRefresh(cmd.Context(), kind, args[1:]) + }, +} + +var artworkReprocessCmd = &cobra.Command{ + Use: "reprocess", + Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runReprocess(cmd.Context()) + }, +} + +var artworkStatusCmd = &cobra.Command{ + Use: "status", + Short: "Report the artwork queue, where artwork resolves from, and the backfill state", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runStatus(cmd.Context()) + }, +} + +func runStatus(ctx context.Context) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + rep, err := collectStatus(ctx, ds) + if err != nil { + log.Fatal(ctx, err) + } + fmt.Print(formatStatus(rep)) +} + +type sourceCount struct { + kind model.Kind + source string + count int64 +} + +type absentCount struct { + kind model.Kind + model.ArtworkAbsentStat +} + +type statusReport struct { + queue []model.ArtworkQueueStat + sources []sourceCount + absent []absentCount + inputs []artwork.FingerprintInput + stored string + current string +} + +func (r statusReport) queueTotal() int64 { + var n int64 + for _, s := range r.queue { + n += s.Count + } + return n +} + +func (r statusReport) backfillQueued() int64 { + var n int64 + for _, s := range r.queue { + if s.Priority == model.ArtworkPriorityBackfill { + n += s.Count + } + } + return n +} + +func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { + q := ds.ArtworkQueue(ctx) + var rep statusReport + var err error + if rep.queue, err = q.CountByKindAndPriority(); err != nil { + return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) + } + + cutoff := time.Now().Add(-artwork.StaleAbsentAge) + for _, k := range artwork.RecheckKinds { + sources, err := q.SourcesInUse(k) + if err != nil { + return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) + } + slices.Sort(sources) + for _, s := range sources { + n, err := q.CountBySource(k, []string{s}) + if err != nil { + return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) + } + rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) + } + stat, err := q.CountAbsent(k, cutoff) + if err != nil { + return rep, fmt.Errorf("counting absent %s artwork: %w", k, err) + } + rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat}) + } + + rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() + if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil { + return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err) + } + return rep, nil +} + +func formatStatus(rep statusReport) string { + var sb strings.Builder + w := newTabWriter(&sb) + + fmt.Fprintln(w, "Queue") + if len(rep.queue) == 0 { + fmt.Fprintln(w, " (empty)") + } else { + fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS") + for _, s := range rep.queue { + fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count) + } + fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal()) + } + + fmt.Fprintln(w, "\nSources") + fmt.Fprintln(w, " KIND\tSOURCE\tITEMS") + for _, s := range rep.sources { + fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count) + } + + fmt.Fprintln(w, "\nAbsent (resolved, no image found)") + fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK") + for _, a := range rep.absent { + fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale) + } + fmt.Fprintf(w, " (rechecked once the last attempt is older than %gh)\n", artwork.StaleAbsentAge.Hours()) + + fmt.Fprintln(w, "\nBackfill") + fmt.Fprintf(w, " State:\t%s\n", backfillState(rep)) + fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) + fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) + if len(rep.inputs) > 0 { + fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):") + for _, in := range rep.inputs { + fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) + } + } + + w.Flush() + return sb.String() +} + +// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually +// already stored the new fingerprint, and "up to date" would bury the flood it is still working through. +func backfillState(rep statusReport) string { + pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup" + if n := rep.backfillQueued(); n > 0 { + if rep.stored != rep.current { + return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending) + } + return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n) + } + if rep.stored != rep.current { + return pending + } + return "up to date" +} + +func kindName(prefix string) string { + if k, ok := model.ParseKind(prefix); ok { + return k.String() + } + return prefix +} + +func priorityName(p int) string { + switch p { + case model.ArtworkPriorityRecheck: + return "recheck" + case model.ArtworkPriorityBackfill: + return "backfill" + case model.ArtworkPriorityScan: + return "scan" + case model.ArtworkPriorityBump: + return "bump" + } + return strconv.Itoa(p) +} + +func runReprocess(ctx context.Context) { + kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll) + if err != nil { + log.Fatal(ctx, err) + } + + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgentCount(ds), + reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil { + log.Fatal(ctx, err) + } +} + +func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { + // A source filter on its own is already a complete selection, so it does not also need a kind. + if all || (len(kinds) == 0 && len(sources) > 0) { + return artwork.RecheckKinds, nil + } + if len(kinds) == 0 { + return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") + } + out := make([]model.Kind, 0, len(kinds)) + for _, k := range kinds { + kind, err := parseArtworkKind(k, artwork.RecheckKinds) + if err != nil { + return nil, err + } + out = append(out, kind) + } + // A repeated kind would be counted twice, overstating the cost the operator confirms. + return slice.Unique(out), nil +} + +// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI. +const absentSource = "absent" + +func repositorySources(sources []string) []string { + return slice.Map(sources, func(s string) string { + if s == absentSource { + return "" + } + return s + }) +} + +func displaySource(s string) string { return cmp.Or(s, absentSource) } + +type confirmFunc func(out io.Writer, total, external int64) bool + +func reprocessConfirm(yes bool, in io.Reader) confirmFunc { + if yes { + return func(io.Writer, int64, int64) bool { return true } + } + return promptConfirm(in) +} + +// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and plugin +// agents are unregistered in a CLI that never starts the plugin manager. +func externalEstimate(n int64) string { + if n == 0 { + return "none" + } + return fmt.Sprintf("~%d estimated (plugin agents not counted; local hits may need fewer)", n) +} + +func externalLookupLine(n int64) string { + return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) +} + +// imageAgentCount counts only the built-in image agents, for the same reason. +func imageAgentCount(ds model.DataStore) artwork.ImageAgentCount { + ag := agents.GetAgents(ds, getPluginManager()) + return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} +} + +func promptConfirm(in io.Reader) confirmFunc { + return func(out io.Writer, total, external int64) bool { + var cost string + if external > 0 { + cost = fmt.Sprintf(" %s", externalLookupLine(external)) + } + fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost) + var answer string + if _, err := fmt.Fscanln(in, &answer); err != nil { + return false + } + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes" + } +} + +// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when +// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only. +func validateSources(q model.ArtworkQueueRepository, sources []string) error { + if len(sources) == 0 { + return nil + } + var inUse []string + for _, k := range artwork.RecheckKinds { + found, err := q.SourcesInUse(k) + if err != nil { + return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) + } + inUse = slice.Unique(append(inUse, found...)) + } + var unknown []string + for _, s := range sources { + if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent + unknown = append(unknown, displaySource(s)) + } + } + if len(unknown) == 0 { + return nil + } + valid := slice.Map(inUse, displaySource) + slices.Sort(valid) + return fmt.Errorf("no artwork resolves from %s; sources in use: %s", + strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) +} + +// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource +// actually inserted; the two differ because an already-queued row is left untouched. +func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string, + imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error { + q := ds.ArtworkQueue(ctx) + if err := validateSources(q, sources); err != nil { + return err + } + + matched := make([]int64, len(kinds)) + var total, external int64 + for i, k := range kinds { + n, err := q.CountBySource(k, sources) + if err != nil { + return fmt.Errorf("counting %s artwork: %w", k, err) + } + matched[i] = n + total += n + external += n * artwork.ExternalLookupsPerItem(k, imageAgents) + } + printReprocessPreview(out, kinds, matched, total, external, sources) + + switch { + case dryRun: + fmt.Fprintln(out, "\nDry run: nothing was queued.") + return nil + case total == 0: + fmt.Fprintln(out, "Nothing was queued.") + return nil + case !confirm(out, total, external): + fmt.Fprintln(out, "Aborted: nothing was queued.") + return nil + } + + var queued int64 + for i, k := range kinds { + if matched[i] == 0 { + continue + } + n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck) + if err != nil { + return fmt.Errorf("queueing %s artwork: %w", k, err) + } + queued += n + fmt.Fprintf(out, "%s: %d queued\n", k, n) + } + fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total) + if skipped := total - queued; skipped > 0 { + fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) + } + return nil +} + +// printReprocessPreview also states the external estimate, which --dry-run must show because it +// skips the prompt that would otherwise carry it. +func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) { + w := newTabWriter(out) + shown := slice.Map(sources, displaySource) + fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)")) + fmt.Fprintln(w, "KIND\tMATCHED") + for i, k := range kinds { + fmt.Fprintf(w, "%s\t%d\n", k, matched[i]) + } + fmt.Fprintf(w, "TOTAL\t%d\n", total) + w.Flush() + + fmt.Fprintf(out, "\n%s\n", externalLookupLine(external)) + if total == 0 { + fmt.Fprintln(out, "\nNothing matches this selection.") + } +} + +func runRefresh(ctx context.Context, kind model.Kind, ids []string) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + if failed := refreshItems(ctx, ds, kind, ids, os.Stdout); failed > 0 { + log.Fatal(ctx, "Failed to refresh artwork", "kind", kind, "failed", failed, "total", len(ids)) + } +} + +// refreshItems keeps going after a failure — the ids are independent — and returns how many failed. +func refreshItems(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string, out io.Writer) int { + var failed int + for _, id := range ids { + // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. + if _, err := artworkItemName(ctx, ds, kind, id); err != nil { + log.Error(ctx, "Item not found", "kind", kind, "id", id, err) + failed++ + continue + } + if err := artwork.Refresh(ctx, ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + failed++ + continue + } + fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id) + } + return failed +} + +// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind +// with no chain to walk still has something to answer with. +var explainKinds = []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, + model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, +} + +func kindPrefixes(kinds []model.Kind) string { + return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ") +} + +func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { + kind, ok := model.ParseKind(s) + if ok && slices.Contains(valid, kind) { + return kind, nil + } + return kind, fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) +} + +// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a +// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it. +func explainAgents(configured string, available []string) string { + if strings.TrimSpace(configured) == "" { + return "(none)" + } + var unavailable bool + names := slice.Map(strings.Split(configured, ","), func(name string) string { + name = strings.TrimSpace(name) + if slices.Contains(available, name) { + return name + } + unavailable = true + return name + "*" + }) + line := strings.Join(names, ", ") + if unavailable { + line += " (* not available to the CLI)" + } + return line +} + +// availableImageAgents names the agents that can actually supply an image for kind. +func availableImageAgents(ds model.DataStore, kind model.Kind) []string { + ag := agents.GetAgents(ds, getPluginManager()) + if kind == model.KindArtistArtwork { + return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name }) + } + return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name }) +} + +// explainResult states the verdict of the walk. A skipped or failed external tier, or a local +// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork. +func explainResult(source string, steps []artwork.TraceStep) string { + if source != "" { + for _, s := range steps { + if s.Outcome == artwork.OutcomeHit { + break + } + if s.Outcome == artwork.OutcomeWouldTry { + return "resolved from " + source + + " (offline: a higher-priority external candidate was not tried; re-run with --live)" + } + // An external winner discards the earlier error, so the resolver settles it with no retry. + if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) && + !strings.HasPrefix(source, artwork.ExternalPrefix) { + return "resolved from " + source + + " (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)" + } + } + return "resolved from " + source + } + for _, s := range steps { + switch { + case s.Outcome == artwork.OutcomeWouldTry: + return "indeterminate (external agents not called; re-run with --live)" + case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix): + return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" + // The worker treats an unreadable local candidate exactly as it treats a failed external one: + // it retries instead of settling absent, so the verdict must not read as a clean miss. + case s.Outcome == artwork.OutcomeUnreadable: + return "indeterminate (a candidate exists but could not be read; the worker retries rather than settling absent)" + } + } + return "not resolved" +} + +// explainConfig names the setting that decides where a kind's artwork comes from, and its value. +func explainConfig(kind model.Kind) (name, value string) { + switch kind { + case model.KindArtistArtwork: + return "ArtistArtPriority", conf.Server.ArtistArtPriority + case model.KindAlbumArtwork: + return "CoverArtPriority", conf.Server.CoverArtPriority + case model.KindDiscArtwork: + return "DiscArtPriority", conf.Server.DiscArtPriority + case model.KindMediaFileArtwork: + return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt) + } + return "", "" +} + +type explainReport struct { + kind model.Kind + id string + name string + stored *model.ItemArtwork + queued *model.ArtworkQueueItem + agents string + steps []artwork.TraceStep + source string + resolveErr error +} + +func formatExplain(rep explainReport) string { + var sb strings.Builder + w := newTabWriter(&sb) + explainable := artwork.Explainable(rep.kind) + stateful := artwork.KeepsState(rep.kind) + + fmt.Fprintln(w, "Item") + fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix()) + fmt.Fprintf(w, " ID:\t%s\n", rep.id) + fmt.Fprintf(w, " Name:\t%s\n", rep.name) + + fmt.Fprintln(w, "\nStored") + switch { + case !stateful: + fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind) + case rep.stored == nil: + fmt.Fprintln(w, " (no artwork state recorded)") + default: + fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source)) + fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)")) + if rep.stored.SourcePath != "" { + fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath) + } + fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt)) + } + + fmt.Fprintln(w, "\nQueue") + switch { + case !stateful: + fmt.Fprintln(w, " (never queued)") + case rep.queued == nil: + fmt.Fprintln(w, " (not queued)") + default: + fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority) + fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts) + fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt)) + } + + fmt.Fprintln(w, "\nConfig") + if setting, value := explainConfig(rep.kind); setting == "" { + fmt.Fprintln(w, " (no artwork source configuration applies)") + } else { + fmt.Fprintf(w, " %s:\t%s\n", setting, value) + if rep.agents != "" { + fmt.Fprintf(w, " Agents:\t%s\n", rep.agents) + } + } + + fmt.Fprintln(w, "\nChain") + if !explainable { + fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind) + } else { + fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL") + for _, s := range rep.steps { + // A row with an empty last cell would end tabwriter's column block, breaking alignment. + fmt.Fprintf(w, " %s\t%s\t%s\n", s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) + } + } + + fmt.Fprintln(w, "\nResult") + switch { + case rep.resolveErr != nil: + fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr) + case !explainable: + fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)") + default: + fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps)) + } + + w.Flush() + return sb.String() +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format(time.RFC3339) +} + +func runExplain(ctx context.Context, kind model.Kind, id string) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + name, err := artworkItemName(ctx, ds, kind, id) + if err != nil { + log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) + } + rep := explainReport{kind: kind, id: id, name: name} + if artwork.KeepsState(kind) { + rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err) + } + rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err) + } + } + + if artwork.Explainable(kind) { + if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork { + rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, kind)) + } + trace := &artwork.ChainTrace{} + rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) + rep.steps = trace.Steps() + } + + fmt.Print(formatExplain(rep)) + // The steps taken before a failed walk are the diagnosis, so report them before exiting. + if rep.resolveErr != nil { + log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr) + } +} + +// artworkItemName looks the entity up under its own kind, so a mismatched kind/id pair is +// reported as not found instead of silently explaining another entity's artwork. +func artworkItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { + switch kind { + case model.KindArtistArtwork: + ar, err := ds.Artist(ctx).Get(id) + if err != nil { + return "", err + } + return ar.Name, nil + case model.KindAlbumArtwork: + al, err := ds.Album(ctx).Get(id) + if err != nil { + return "", err + } + return al.Name, nil + case model.KindPlaylistArtwork: + pls, err := ds.Playlist(ctx).Get(id) + if err != nil { + return "", err + } + return pls.Name, nil + case model.KindRadioArtwork: + rd, err := ds.Radio(ctx).Get(id) + if err != nil { + return "", err + } + return rd.Name, nil + case model.KindMediaFileArtwork: + mf, err := ds.MediaFile(ctx).Get(id) + if err != nil { + return "", err + } + return mf.Title, nil + case model.KindDiscArtwork: + return discArtworkName(ctx, ds, id) + } + return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) +} + +func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { + albumID, discNumber, err := model.ParseDiscArtworkID(id) + if err != nil { + return "", err + } + al, err := ds.Album(ctx).Get(albumID) + if err != nil { + return "", err + } + name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) + // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. + if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { + name += ": " + subtitle + } + return name, nil +} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go new file mode 100644 index 000000000..949fdb42a --- /dev/null +++ b/cmd/artwork_test.go @@ -0,0 +1,899 @@ +package cmd + +import ( + "context" + "errors" + "io" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseArtworkKind", func() { + It("accepts a supported kind", func() { + k, err := parseArtworkKind("ar", artwork.RecheckKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(k).To(Equal(model.KindArtistArtwork)) + }) + + It("rejects an unknown kind and lists the valid ones", func() { + _, err := parseArtworkKind("zz", artwork.RecheckKinds) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ar")) + Expect(err.Error()).To(ContainSubstring("al")) + }) + + It("rejects a known kind the command does not accept", func() { + _, err := parseArtworkKind("mf", artwork.RecheckKinds) + Expect(err).To(HaveOccurred()) + }) + + DescribeTable("accepts the kinds each command supports", + func(prefix string, valid []model.Kind) { + _, err := parseArtworkKind(prefix, valid) + Expect(err).ToNot(HaveOccurred()) + }, + Entry("explain reads disc artwork", "dc", explainKinds), + Entry("explain reads media file artwork", "mf", explainKinds), + // Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not + // accept it: the queue row would be rejected on every drain. + Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds), + ) + + It("rejects disc artwork for refresh", func() { + _, err := parseArtworkKind("dc", artwork.RefreshableKinds) + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("explainResult", func() { + It("reports the winning source", func() { + steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} + Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder")) + }) + + It("reports not resolved when every candidate was tried and missed", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "miss"}, + {Candidate: "external:deezer", Outcome: "miss"}, + } + Expect(explainResult("", steps)).To(Equal("not resolved")) + }) + + It("reports indeterminate when a local candidate exists but could not be read", func() { + steps := []artwork.TraceStep{ + {Candidate: "cover.*", Outcome: "miss"}, + {Candidate: "embedded", Outcome: "unreadable"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss") + }) + + It("does not qualify a hit that an earlier unreadable candidate preceded", func() { + // chainState.try stamps only the external error onto a hit and drops the local one, so the + // worker settles this as found; warning about it would be a false alarm. + steps := []artwork.TraceStep{ + {Candidate: "embedded", Outcome: "unreadable"}, + {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, + } + Expect(explainResult("folder", steps)).To(Equal("resolved from folder")) + }) + + It("reports indeterminate when an external lookup failed transiently", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "miss"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "a failed network call is not evidence that the item has no artwork") + }) + + It("qualifies a win a skipped higher-priority external candidate could have taken", func() { + steps := []artwork.TraceStep{ + {Candidate: "external:deezer", Outcome: "would-try"}, + {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, + } + res := explainResult("artist.*", steps) + Expect(res).To(ContainSubstring("resolved from artist.*")) + Expect(res).To(ContainSubstring("--live"), + "offline, the winner is only the winner because the external tier was skipped") + }) + + It("does not qualify a win that no skipped candidate outranked", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "hit"}, + {Candidate: "external:deezer", Outcome: "would-try"}, + } + Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) + }) + + It("reports indeterminate when external agents were never called", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "miss"}, + {Candidate: "external:deezer", Outcome: "would-try"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "an offline run must not claim an item is unresolvable when external agents were skipped") + }) + + It("qualifies a win a failed higher-priority external lookup could have taken", func() { + steps := []artwork.TraceStep{ + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, + } + res := explainResult("artist.*", steps) + Expect(res).To(ContainSubstring("resolved from artist.*")) + Expect(res).To(ContainSubstring("indeterminate"), + "the resolver serves this hit but retries later, so the winner is provisional") + }) + + It("does not qualify an external win that followed a failed external lookup", func() { + steps := []artwork.TraceStep{ + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + {Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"}, + } + Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"), + "a later agent supplying the image discards the earlier error, so there is no retry to warn about") + }) + + It("does not qualify a win that outranked the failed external lookup", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "hit"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + } + Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) + }) +}) + +var _ = Describe("explainAgents", func() { + It("accounts for every configured agent, marking the ones the CLI could not use", func() { + out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"}) + for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} { + Expect(out).To(ContainSubstring(name), + "a configured agent missing from this line reads as if it had never been configured") + } + Expect(out).To(ContainSubstring("not available to the CLI")) + }) + + It("does not mark anything when every configured agent is available", func() { + out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"}) + Expect(out).To(Equal("deezer, lastfm")) + }) + + It("reports an empty configuration as none, not as an unavailable agent", func() { + Expect(explainAgents("", nil)).To(Equal("(none)")) + }) +}) + +var _ = Describe("formatExplain", func() { + var rep explainReport + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.ArtistArtPriority = "external, artist.*" + rep = explainReport{ + kind: model.KindArtistArtwork, + id: "ar-1", + name: "Radiohead", + agents: "lastfm,spotify", + steps: []artwork.TraceStep{ + {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, + {Candidate: "external:deezer", Outcome: "would-try"}, + }, + source: "", + } + }) + + It("reports the item, its config and the chain it walked", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Radiohead")) + Expect(out).To(ContainSubstring("ar-1")) + Expect(out).To(ContainSubstring("ArtistArtPriority")) + Expect(out).To(ContainSubstring("lastfm,spotify")) + Expect(out).To(ContainSubstring("external:deezer")) + Expect(out).To(ContainSubstring("would-try")) + Expect(out).To(ContainSubstring("indeterminate")) + }) + + It("reports the absence of stored state and of a queue row", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("no artwork state recorded")) + Expect(out).To(ContainSubstring("not queued")) + }) + + It("prints the stored state and the queue row when they exist", func() { + attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123", + SourcePath: "/music/cover.jpg", AttemptedAt: attempted} + rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2, + RetryAt: attempted.Add(time.Hour)} + rep.source = "folder" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("abc123")) + Expect(out).To(ContainSubstring("/music/cover.jpg")) + Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z")) + Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up") + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + It("marks a known-absent stored state instead of printing an empty hash", func() { + rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()} + Expect(formatExplain(rep)).To(ContainSubstring("absent")) + }) + + It("reports a failed walk as failed, not as unresolved", func() { + rep.resolveErr = errors.New("no such directory") + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("resolution failed: no such directory")) + Expect(out).ToNot(ContainSubstring("indeterminate")) + Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print") + }) + + It("says a kind that does not walk a chain has no chain, without an empty table", func() { + conf.Server.CoverArtPriority = "cover.*, embedded" + rep.kind = model.KindPlaylistArtwork + rep.steps = nil + rep.agents = "" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("does not walk a priority chain")) + Expect(out).ToNot(ContainSubstring("CANDIDATE"), + "an empty chain table reads as 'nothing was tried', which is false") + Expect(out).ToNot(ContainSubstring("not resolved"), + "nothing was resolved because nothing was attempted") + Expect(out).ToNot(ContainSubstring("CoverArtPriority"), + "the priority chain config does not govern this kind") + }) + + It("says disc artwork keeps no state instead of reporting it as unresolved state", func() { + conf.Server.DiscArtPriority = "cover.jpg, embedded" + rep = explainReport{ + kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)", + steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}}, + source: "folder", + } + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("never recorded")) + Expect(out).To(ContainSubstring("never queued")) + Expect(out).ToNot(ContainSubstring("no artwork state recorded"), + "a missing row would read as a lookup that failed, when disc artwork has no row by design") + Expect(out).To(ContainSubstring("DiscArtPriority")) + Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent") + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + It("reports the setting that governs media file artwork", func() { + conf.Server.EnableMediaFileCoverArt = false + rep = explainReport{ + kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag", + steps: []artwork.TraceStep{ + {Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"}, + }, + } + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("EnableMediaFileCoverArt")) + Expect(out).To(ContainSubstring("false")) + Expect(out).To(ContainSubstring("not resolved")) + Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state") + }) +}) + +var _ = Describe("explainConfig", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DiscArtPriority = "cover.jpg" + conf.Server.EnableMediaFileCoverArt = true + }) + + DescribeTable("names the setting that decides where a kind's artwork comes from", + func(kind model.Kind, setting, value string) { + gotSetting, gotValue := explainConfig(kind) + Expect(gotSetting).To(Equal(setting)) + Expect(gotValue).To(Equal(value)) + }, + Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"), + Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"), + Entry("playlist has none", model.KindPlaylistArtwork, "", ""), + ) +}) + +var _ = Describe("discArtworkName", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}}) + ds = &tests.MockDataStore{MockedAlbum: albumRepo} + }) + + It("names the album, the disc and its subtitle", func() { + name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2") + Expect(err).ToNot(HaveOccurred()) + Expect(name).To(Equal("Sandinista! (disc 2): Side Three")) + }) + + It("omits the subtitle when the disc has none", func() { + name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1") + Expect(err).ToNot(HaveOccurred()) + Expect(name).To(Equal("Sandinista! (disc 1)")) + }) + + It("rejects an id that is not <albumID>:<disc>", func() { + _, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("artwork refresh command", func() { + It("requires at least a kind and one id", func() { + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar"})).To(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1", "id2"})).ToNot(HaveOccurred()) + }) +}) + +var _ = Describe("artwork reprocess selection", func() { + It("errors when no selector is given", func() { + _, err := selectedKinds(nil, nil, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("--all")) + }) + + It("returns every kind for --all", func() { + ks, err := selectedKinds(nil, nil, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(ConsistOf(artwork.RecheckKinds)) + }) + + It("returns every kind for a source filter given without a kind", func() { + ks, err := selectedKinds(nil, []string{"folder"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection") + }) + + It("returns only the named kinds", func() { + ks, err := selectedKinds([]string{"ar"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) + }) + + It("keeps a named kind selection alongside a source filter", func() { + ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) + }) + + It("rejects an unknown kind", func() { + _, err := selectedKinds([]string{"zz"}, nil, false) + Expect(err).To(HaveOccurred()) + }) + + It("counts a repeated kind once", func() { + ks, err := selectedKinds([]string{"ar", "ar"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) + }) +}) + +var _ = Describe("explain/reprocess source round trip", func() { + ctx := context.Background() + + // storedSource reads back the Source line explain printed, as an operator would copy it. + storedSource := func(out string) string { + GinkgoHelper() + for line := range strings.SplitSeq(out, "\n") { + if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok { + return strings.TrimSpace(after) + } + } + Fail("explain printed no Source line") + return "" + } + + It("names the absent state as reprocess --source accepts it", func() { + ds := &tests.MockDataStore{} + art := ds.Artwork(ctx).(*tests.MockArtworkRepo) + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), + ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed()) + + shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1", + stored: &model.ItemArtwork{AttemptedAt: time.Now()}})) + + q := ds.ArtworkQueue(ctx) + Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(), + "explain's spelling of a source must be pasteable into --source") + Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), + "a parenthesised name would be rejected, so explain must not print one") + }) +}) + +var _ = Describe("repositorySources", func() { + It("maps the user-facing absent name onto the stored empty source", func() { + Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) + }) + + It("keeps an empty selection empty, meaning every source", func() { + Expect(repositorySources(nil)).To(BeEmpty()) + }) +}) + +var _ = Describe("promptConfirm", func() { + var out strings.Builder + + BeforeEach(func() { out.Reset() }) + + It("states the external cost and accepts an explicit yes", func() { + Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) + Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) + }) + + It("defaults to no on anything else", func() { + Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse()) + }) + + It("drops the external clause when no lookup will be made", func() { + Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("re-resolve 3 items.")) + Expect(out.String()).ToNot(ContainSubstring("External lookups")) + }) +}) + +var _ = Describe("reprocessConfirm", func() { + var out strings.Builder + + BeforeEach(func() { out.Reset() }) + + It("prompts when --yes was not given", func() { + Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("Continue?")) + }) + + It("bypasses the prompt only for --yes", func() { + Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue()) + Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") + }) +}) + +var _ = Describe("reprocessArtwork", func() { + var ds *tests.MockDataStore + var art *tests.MockArtworkRepo + var queue *tests.MockArtworkQueueRepo + var out strings.Builder + var imageAgents artwork.ImageAgentCount + ctx := context.Background() + kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} + accept := func(io.Writer, int64, int64) bool { return true } + decline := func(io.Writer, int64, int64) bool { return false } + + put := func(kind model.Kind, id, source string) { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed()) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1} + ds = &tests.MockDataStore{} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + put(model.KindArtistArtwork, "ar-1", "external:deezer") + put(model.KindArtistArtwork, "ar-2", "") + put(model.KindAlbumArtwork, "al-1", "external:deezer") + put(model.KindAlbumArtwork, "al-2", "folder") + }) + + It("previews the per-kind breakdown and queues nothing on a dry run", func() { + Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("external:deezer")) + Expect(out.String()).To(ContainSubstring("artist")) + Expect(out.String()).To(ContainSubstring("album")) + Expect(out.String()).To(ContainSubstring("TOTAL")) + Expect(out.String()).To(ContainSubstring("Dry run")) + Expect(queue.Count()).To(BeZero()) + }) + + It("queues nothing when the operator declines", func() { + Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Aborted")) + Expect(queue.Count()).To(BeZero()) + }) + + It("queues the matching items at recheck priority, leaving their artwork state alone", func() { + Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(Equal(int64(2))) + queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck)) + _, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued") + + stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork") + }) + + It("targets the absent state", func() { + Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(Equal(int64(1))) + _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + }) + + It("reports matched and queued separately when part of the set is already queued", func() { + Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed()) + + Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items")) + Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1")) + queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump), + "an already-queued row keeps its priority and backoff") + }) + + It("stops at a selection that matches nothing instead of prompting", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false, + func(io.Writer, int64, int64) bool { + Fail("must not prompt when there is nothing to queue") + return true + }, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing")) + Expect(queue.Count()).To(BeZero()) + }) + + It("reports an empty selection as a dry run when one was asked for", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches")) + Expect(out.String()).To(ContainSubstring("Dry run")) + }) + + It("shows the external estimate on a dry run, which never reaches the prompt", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated")) + }) + + It("bills every agent per item, not one lookup per item", func() { + imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3} + var external int64 + capture := func(_ io.Writer, _, e int64) bool { external = e; return false } + + Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents") + Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated")) + }) + + It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() { + Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("plugin agents not counted")) + Expect(out.String()).To(ContainSubstring("local hits may need fewer")) + Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false") + Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false") + }) + + It("says so when the selection needs no external lookup", func() { + conf.Server.CoverArtPriority = "cover.*" + put(model.KindPlaylistArtwork, "pl-1", "playlist") + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("External lookups: none")) + }) + + It("counts playlists as external cost when the m3u image fetch is enabled", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.EnableM3UExternalAlbumArt = true + put(model.KindPlaylistArtwork, "pl-1", "playlist") + var external int64 + capture := func(_ io.Writer, _, e int64) bool { external = e; return false } + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(external).To(Equal(int64(1))) + Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated")) + }) + + It("bills a playlist for every album its grid samples, at every agent", func() { + imageAgents = artwork.ImageAgentCount{Album: 3} + put(model.KindPlaylistArtwork, "pl-1", "playlist") + var external int64 + capture := func(_ io.Writer, _, e int64) bool { external = e; return false } + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)), + "one playlist samples 4 albums, each walking all 3 album agents") + }) + + It("counts only the kinds that call an external agent as external cost", func() { + put(model.KindRadioArtwork, "ra-1", "upload") + var total, external int64 + capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false } + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork}, + nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(total).To(Equal(int64(3))) + Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent") + }) + + It("rejects an unknown source and names the ones in use", func() { + err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("externa:deezer")) + Expect(err.Error()).To(ContainSubstring("external:deezer")) + Expect(err.Error()).To(ContainSubstring("folder")) + Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name") + Expect(queue.Count()).To(BeZero()) + }) + + It("accepts the absent filter with nothing absent, still rejecting a typo", func() { + put(model.KindArtistArtwork, "ar-2", "folder") + + Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}), + imageAgents, false, accept, &out)).To(Succeed(), + "a reserved source must stay valid once the library has none of it") + Expect(out.String()).To(ContainSubstring("Nothing matches")) + Expect(queue.Count()).To(BeZero()) + + Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}), + imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") + }) + + It("accepts a source another kind uses, letting the empty selection report itself", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, + imageAgents, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches"), + "a well-formed filter must not be reported as a typo because of the kinds selected") + Expect(queue.Count()).To(BeZero()) + }) +}) + +var _ = Describe("artwork status command", func() { + It("takes no arguments", func() { + Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred()) + Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred()) + }) +}) + +var _ = Describe("collectStatus", func() { + var ds *tests.MockDataStore + var art *tests.MockArtworkRepo + var queue *tests.MockArtworkQueueRepo + ctx := context.Background() + + BeforeEach(func() { + ds = &tests.MockDataStore{} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + put := func(kind model.Kind, id, source, hash string, attempted time.Time) { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) + } + put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour)) + put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) + put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) + Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) + }) + + It("reports the queue, the source distribution and the absent ages", func() { + rep, err := collectStatus(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar", + Priority: model.ArtworkPriorityBackfill, Count: 1})) + Expect(rep.sources).To(ContainElements( + sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1}, + sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, + sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, + )) + Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, + ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}})) + }) + + It("compares the stored fingerprint against the current one", func() { + Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) + + rep, err := collectStatus(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(rep.stored).To(Equal("old-fingerprint")) + Expect(rep.current).To(Equal(artwork.ConfigFingerprint()), + "the CLI must report the value backfill itself compares") + }) + + It("queues nothing", func() { + _, err := collectStatus(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything") + }) +}) + +var _ = Describe("formatStatus", func() { + var rep statusReport + + BeforeEach(func() { + rep = statusReport{ + queue: []model.ArtworkQueueStat{ + {ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, + {ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, + }, + sources: []sourceCount{ + {kind: model.KindArtistArtwork, source: "external:deezer", count: 5}, + {kind: model.KindArtistArtwork, source: "", count: 2}, + }, + absent: []absentCount{ + {kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}, + }, + inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, + stored: "abc123", + current: "abc123", + } + }) + + // block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere. + block := func(out, header string) string { + GinkgoHelper() + _, after, found := strings.Cut(out, header+"\n") + Expect(found).To(BeTrue(), "the %q block must be printed", header) + body, _, _ := strings.Cut(after, "\n\n") + return body + } + + It("names the kind and the priority of every queued row", func() { + queue := block(formatStatus(rep), "Queue") + Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`)) + Expect(queue).To(MatchRegexp(`album\s+scan\s+1`)) + }) + + It("totals the queue", func() { + Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`)) + }) + + It("counts each source, naming the empty one absent", func() { + sources := block(formatStatus(rep), "Sources") + Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`)) + Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) + }) + + It("prints the absent total and how many are due for recheck", func() { + absent := block(formatStatus(rep), "Absent (resolved, no image found)") + Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) + }) + + It("states the recheck window the absent counts are bucketed against", func() { + Expect(formatStatus(rep)).To(ContainSubstring("24h")) + }) + + It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { + out := block(formatStatus(rep), "Backfill") + Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`), + "an operator scanning for trouble must not read 'up to date' while 2 items churn") + Expect(out).To(ContainSubstring("fingerprint up to date")) + }) + + It("keeps the re-enqueue warning while a backfill is already running", func() { + rep.stored = "older" + + out := block(formatStatus(rep), "Backfill") + Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`)) + Expect(out).To(ContainSubstring("re-enqueued"), + "the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one") + }) + + It("reports up to date only once the backfill has drained", func() { + rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}} + + Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`)) + }) + + It("echoes the config inputs a fingerprint change would have come from", func() { + out := block(formatStatus(rep), "Backfill") + Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) + Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") + }) + + It("reports a changed fingerprint as a pending re-resolve of everything", func() { + rep.stored = "older" + rep.queue = nil + + out := formatStatus(rep) + Expect(out).To(ContainSubstring("fingerprint changed")) + Expect(out).ToNot(ContainSubstring("up to date")) + }) + + It("reports a never-recorded fingerprint without printing an empty value", func() { + rep.stored = "" + + out := formatStatus(rep) + Expect(out).To(ContainSubstring("(none)")) + Expect(out).To(ContainSubstring("fingerprint changed")) + }) + + It("says the queue is empty instead of printing a headless table", func() { + rep.queue = nil + + out := formatStatus(rep) + Expect(out).To(ContainSubstring("empty")) + Expect(out).ToNot(ContainSubstring("PRIORITY")) + }) +}) + +var _ = Describe("refreshItems", func() { + var ds *tests.MockDataStore + var queue *tests.MockArtworkQueueRepo + var art *tests.MockArtworkRepo + var out strings.Builder + ctx := context.Background() + + BeforeEach(func() { + albums := tests.CreateMockAlbumRepo() + albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}}) + ds = &tests.MockDataStore{MockedAlbum: albums} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + }) + + It("clears the stored state and queues each id at Bump priority", func() { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), + ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) + + Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-1", "al-3"}, &out)).To(BeZero()) + + _, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump)) + Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n")) + }) + + It("skips an id that does not exist instead of queuing it", func() { + Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-2"}, &out)).To(Equal(1)) + + _, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") + Expect(out.String()).To(BeEmpty()) + }) + + It("continues past a failing id and counts the failures", func() { + Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, + []string{"al-1", "al-2", "al-3"}, &out)).To(Equal(1)) + + Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), + "the ids after a failure are still refreshed") + }) +}) diff --git a/cmd/plugin.go b/cmd/plugin.go index 6cce8ea5f..ded28e969 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -9,7 +9,6 @@ import ( "os" "strconv" "strings" - "text/tabwriter" "time" "github.com/navidrome/navidrome/conf" @@ -314,7 +313,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) { return sb.String(), w.Error() case "table": var sb strings.Builder - w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0) + w := newTabWriter(&sb) fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR") for _, p := range list { name, version := manifestSummary(p) diff --git a/cmd/utils.go b/cmd/utils.go index 81d646cf1..74da51828 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io" + "text/tabwriter" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/db" @@ -13,6 +15,11 @@ import ( "github.com/navidrome/navidrome/persistence" ) +// newTabWriter keeps every CLI table on the same column settings. +func newTabWriter(out io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) +} + func getAdminContext(ctx context.Context) (model.DataStore, context.Context) { sqlDB := db.Db() ds := persistence.New(sqlDB) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4bdd48422..49a99f8ca 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -223,6 +223,18 @@ func CreateArtworkWorker() *artwork.Worker { return worker } +func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + fFmpeg := ffmpeg.New() + tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live) + return tracingResolver +} + func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index ae24b61fa..8bc404fb1 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -144,6 +144,13 @@ func CreateArtworkWorker() *artwork.Worker { )) } +func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { + panic(wire.Build( + allProviders, + artwork.NewTracingResolver, + )) +} + func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index c7032b62b..a6f746959 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -47,10 +47,17 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar // Synthetic artists would otherwise get an unrelated agent result assigned to them. switch ar.ID { case consts.UnknownArtistID, consts.VariousArtistsID: + traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) return nil, "", false } name := externalName(ar.Name) - for _, a := range ag.ArtistImageAgents() { + imageAgents := ag.ArtistImageAgents() + if len(imageAgents) == 0 { + traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, + Detail: "no enabled agent provides artist images"}) + return nil, "", false + } + for _, a := range imageAgents { reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) if err != nil { @@ -76,7 +83,13 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar // fetchAlbumImage is the album counterpart of fetchArtistImage. func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { name, artist := externalName(al.Name), externalName(al.AlbumArtist) - for _, a := range ag.AlbumImageAgents() { + imageAgents := ag.AlbumImageAgents() + if len(imageAgents) == 0 { + traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, + Detail: "no enabled agent provides album images"}) + return nil, "", false + } + for _, a := range imageAgents { reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) if err != nil { diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 716245c93..60a34352d 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -173,6 +173,30 @@ var _ = Describe("agent images", func() { Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") }) + It("records a skipped external candidate when no agent provides artist images", func() { + ag := imageAgents() + t := &ChainTrace{} + + r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeFalse()) + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, + Detail: "no enabled agent provides artist images"}}), + "a configured external token must never be silently absent from the chain") + }) + + It("records a skipped external candidate for synthetic artists", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + t := &ChainTrace{} + + _, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, + model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"}) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped)) + Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic")) + }) + It("clears typographic characters from the query name unless preserving unicode", func() { conf.Server.DevPreserveUnicodeInExternalCalls = false a := &fakeImageAgent{name: "agentA"} @@ -231,6 +255,18 @@ var _ = Describe("agent images", func() { Expect(a.albumCalls).To(Equal(1)) }) + It("records a skipped external candidate when no agent provides album images", func() { + ag := imageAgents() + t := &ChainTrace{} + + r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeFalse()) + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, + Detail: "no enabled agent provides album images"}}), + "a configured external token must never be silently absent from the chain") + }) + It("reports extErr when the only agent fails transiently", func() { a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} ag := imageAgents(a) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 8ac8d9c9d..7edc80e99 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -118,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa } // requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't -// hammer external services; below staleAbsentAge to catch younger absences. +// hammer external services; below StaleAbsentAge to catch younger absences. const requestRecheckAge = time.Hour func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { @@ -317,16 +318,15 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int return nil, err } // Single-disc albums run the chain too: a disc can carry art distinct from the album cover. - selectImage := func() (io.ReadCloser, string, error) { - funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) - return selectImageReader(ctx, artID, funcs...) + selectImage := func() (io.ReadCloser, error) { + res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{}) + return res.reader, err } albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} // Disc art has no state row, hence no content hash: keying on id, album mtime and // DiscArtPriority lets a warm cache answer without running the chain or touching the disk. key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority) - img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, - func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err }) + img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage) if err != nil { if errors.Is(err, context.Canceled) { return nil, err @@ -386,6 +386,54 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI return model.ArtworkID{}, model.ErrNotFound } +// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records +// the walk and reports the winning source, without ever writing artwork state. +type TracingResolver struct { + inner *resolver + trace *ChainTrace +} + +// NewTracingResolver builds a TracingResolver that records its priority-chain walk. With live +// false the external tier is reported but never called. +func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver { + gate := offlineGate(t) + if live { + // A diagnostic must show the provider's real answer, and one item is at most one call + // per agent, so --live deliberately bypasses the rate limiter and circuit breaker. + gate = tracingGate(t, passthroughGate) + } + return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), trace: t} +} + +// Resolve walks kind's sources for id, recording the walk, and reports the winning source +// ("" when none produced an image). +func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) { + switch kind { + case model.KindArtistArtwork: + return r.explain(ctx, r.inner.resolveArtist, id) + case model.KindAlbumArtwork: + return r.explain(ctx, r.inner.resolveAlbum, id) + case model.KindDiscArtwork: + return r.explain(ctx, r.inner.resolveDisc, id) + case model.KindMediaFileArtwork: + return r.explain(ctx, r.inner.resolveMediaFile, id) + } + return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind) +} + +// explain discards the bytes: nothing downstream persists this resolution, so nothing else +// would close the reader either. +func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) { + res, err := resolve(withTrace(ctx, r.trace), id) + if err != nil { + return "", err + } + if res.reader != nil { + _ = res.reader.Close() + } + return res.source, nil +} + func unixMtime(mtime int64) time.Time { if mtime <= 0 { return time.Time{} diff --git a/core/artwork/disc.go b/core/artwork/disc.go index 21f596b60..acd8a3740 100644 --- a/core/artwork/disc.go +++ b/core/artwork/disc.go @@ -113,27 +113,71 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A }, nil } -func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { - var ff []sourceFunc +// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at +// all, so a chain walk can say why instead of leaving a configured entry unaccounted for. +type discCandidate struct { + pattern string + resolve func() (resolution, bool) + skip string +} + +func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate { + folder := func(sf sourceFunc) func() (resolution, bool) { + return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) } + } + var cc []discCandidate for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + c := discCandidate{pattern: pattern} switch { case pattern == "embedded": - ff = append(ff, - fromTag(ctx, d.lib.FS, d.firstTrackRel), - fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)), - ) - case pattern == "external": - // Not supported for disc art, silently ignore - case pattern == "discsubtitle": - if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" { - ff = append(ff, d.fromDiscSubtitle(ctx, subtitle)) + c.resolve = func() (resolution, bool) { + return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel) } - case len(d.imgFiles) > 0: - ff = append(ff, d.fromExternalFile(ctx, pattern)) + case pattern == externalCandidate: + c.skip = "external sources are not supported for disc artwork" + case pattern == "discsubtitle": + subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]) + if subtitle == "" { + c.skip = "disc has no subtitle" + } else { + c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle)) + } + case len(d.imgFiles) == 0: + c.skip = "no images in album folder" + default: + c.resolve = folder(d.fromExternalFile(ctx, pattern)) + } + cc = append(cc, c) + } + return cc +} + +// selectImage walks the DiscArtPriority entries and returns the first that yields an image. +// chain records the walk; the serving path passes an untraced one and pays nothing for it. +func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string, + chain *chainState) (resolution, error) { + for _, c := range d.discCandidates(ctx, ffmpeg, priority) { + if err := ctx.Err(); err != nil { + return resolution{}, err + } + if c.skip != "" { + chain.record(c.pattern, OutcomeSkipped, c.skip) + continue + } + start := time.Now() + res, ok := c.resolve() + log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID, + "disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath, + "elapsed", time.Since(start)) + if res, ok = chain.try(c.pattern, res, ok); ok { + return res, nil } } - return ff + return chain.exhausted(), nil } // fromDiscSubtitle returns a sourceFunc that matches image files whose stem diff --git a/core/artwork/disc_test.go b/core/artwork/disc_test.go index 8264ee27b..8852741cf 100644 --- a/core/artwork/disc_test.go +++ b/core/artwork/disc_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -179,19 +181,19 @@ var _ = Describe("Disc Artwork Reader", func() { lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } - ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") - Expect(ff).To(HaveLen(2)) - r, path, err := ff[0]() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(f2)) - r.Close() + cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*") + Expect(cc).To(HaveLen(2)) + res, ok := cc[0].resolve() + Expect(ok).To(BeTrue()) + Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2))) + res.reader.Close() - ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*") - Expect(ff).To(HaveLen(2)) - r, path, err = ff[0]() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(f1)) - r.Close() + cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*") + Expect(cc).To(HaveLen(2)) + res, ok = cc[0].resolve() + Expect(ok).To(BeTrue()) + Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1))) + res.reader.Close() }) DescribeTable("numbered match wins over shared fallback within a pattern", @@ -428,64 +430,109 @@ var _ = Describe("Disc Artwork Reader", func() { }) Describe("discArtworkReader", func() { - Describe("fromDiscArtPriority", func() { - var ( - reader *discArtworkReader - tmpDir string + var ( + reader *discArtworkReader + tmpDir string + ) + + BeforeEach(func() { + tmpDir = GinkgoT().TempDir() + reader = &discArtworkReader{ + discNumber: 2, + isMultiFolder: true, + discFoldersRel: map[string]bool{"music/album/cd2": true}, + imgFiles: []string{ + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", + }, + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + }) + + Describe("selectImage", func() { + It("abandons the walk when the context is cancelled", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{}) + + Expect(err).To(MatchError(context.Canceled)) + Expect(res.reader).To(BeNil()) + }) + + // "the track has no embedded art" and "the track is there but unreadable" are the two + // answers a wrong-artwork report needs told apart; only the second is worth retrying. + It("reports a track it cannot parse as unreadable, not as a miss", func() { + trace := &ChainTrace{} + track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel)) + Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed()) + Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed()) + + res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", + &chainState{trace: trace}) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.localError).To(BeTrue()) + Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}})) + }) + + It("reports a disc with no tracks to read as a miss", func() { + trace := &ChainTrace{} + reader.firstTrackRel = "" + + res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", + &chainState{trace: trace}) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read") + Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}})) + }) + }) + + Describe("discCandidates", func() { + It("returns a resolvable candidate for glob patterns", func() { + cc := reader.discCandidates(context.Background(), nil, "disc*.*") + Expect(cc).To(HaveLen(1)) + Expect(cc[0].resolve).ToNot(BeNil()) + }) + + It("returns one candidate per entry, in order", func() { + cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded") + Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })). + To(Equal([]string{"disc*.*", "cd*.*", "embedded"})) + }) + + It("skips an empty entry rather than building a glob that matches nothing", func() { + cc := reader.discCandidates(context.Background(), nil, "disc*.*,") + Expect(cc).To(HaveLen(1)) + }) + + // The skip reasons below are what `artwork explain` prints, so an entry that maps to no + // source must say why instead of vanishing from the walk. + DescribeTable("keeps an entry that maps to no source, with its reason", + func(setup func(), priority, reason string) { + setup() + cc := reader.discCandidates(context.Background(), nil, priority) + Expect(cc).To(HaveLen(1)) + Expect(cc[0].resolve).To(BeNil()) + Expect(cc[0].skip).To(Equal(reason)) + }, + Entry("external is unsupported", func() {}, "external", + "external sources are not supported for disc artwork"), + Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*", + "no images in album folder"), + Entry("the disc has no subtitle", + func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle", + "disc has no subtitle"), ) - BeforeEach(func() { - tmpDir = GinkgoT().TempDir() - reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFoldersRel: map[string]bool{"music/album/cd2": true}, - imgFiles: []string{ - "music/album/cd1/disc.jpg", - "music/album/cd2/disc.jpg", - "music/album/cd2/disc2.jpg", - }, - firstTrackRel: "music/album/cd2/track1.flac", - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - }) - - It("returns source funcs for glob patterns", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") - Expect(ff).To(HaveLen(1)) - }) - - It("returns source funcs for embedded pattern", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded") - Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag - }) - - It("handles multiple comma-separated patterns", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded") - Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag - }) - - It("ignores 'external' pattern silently", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "external") - Expect(ff).To(HaveLen(0)) - }) - - It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() { - reader.imgFiles = nil - ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") - Expect(ff).To(HaveLen(0)) - }) - It("returns source func for discsubtitle pattern", func() { reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}} - ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") - Expect(ff).To(HaveLen(1)) - }) - - It("returns no source func for discsubtitle when disc has no subtitle", func() { - reader.album = model.Album{Discs: model.Discs{2: ""}} - ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") - Expect(ff).To(HaveLen(0)) + cc := reader.discCandidates(context.Background(), nil, "discsubtitle") + Expect(cc).To(HaveLen(1)) + Expect(cc[0].resolve).ToNot(BeNil()) }) }) }) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ce72a6c8e..ae1fc0a0d 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -6,6 +6,8 @@ import ( "encoding/hex" "fmt" "slices" + "strconv" + "strings" "time" "github.com/navidrome/navidrome/conf" @@ -16,27 +18,54 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -const staleAbsentAge = 24 * time.Hour +// StaleAbsentAge is how long an absent state is trusted before a recheck retries it. +const StaleAbsentAge = 24 * time.Hour -// recheckKinds omits media files: they resolve embedded-only, at scan or on view. -var recheckKinds = []model.Kind{ +// RecheckKinds omits media files: they resolve embedded-only, at scan or on view. +var RecheckKinds = []model.Kind{ model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, } +// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc +// artwork is read through on every request and cached by content key, so it has neither. +func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } + +// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds +// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker +// resolves them, it just never revisits them on its own. +var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork) + // hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. func hasRecheckPath(prefix string) bool { kind, ok := model.ParseKind(prefix) - return ok && slices.Contains(recheckKinds, kind) + return ok && slices.Contains(RecheckKinds, kind) } // artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. const artworkEpoch = 1 -// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. -func fingerprint() string { - raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d", - conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, - conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch) +// FingerprintInput is one config value the fingerprint covers, named after the setting it came from. +type FingerprintInput struct { + Name string + Value string +} + +// FingerprintInputs is the single listing of what ConfigFingerprint hashes. +func FingerprintInputs() []FingerprintInput { + return []FingerprintInput{ + {"CoverArtPriority", conf.Server.CoverArtPriority}, + {"ArtistArtPriority", conf.Server.ArtistArtPriority}, + {"ArtistImageFolder", conf.Server.ArtistImageFolder}, + {"Agents", conf.Server.Agents}, + {"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)}, + {"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)}, + } +} + +// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. +func ConfigFingerprint() string { + values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value }) + raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch) sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive return hex.EncodeToString(sum[:]) } @@ -45,7 +74,7 @@ func fingerprint() string { func backfill(ctx context.Context, ds model.DataStore) (bool, error) { start := time.Now() ctx = auth.WithAdminUser(ctx, ds) - current := fingerprint() + current := ConfigFingerprint() props := ds.Property(ctx) stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { @@ -95,9 +124,9 @@ func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kin } func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { - cutoff := time.Now().Add(-staleAbsentAge) + cutoff := time.Now().Add(-StaleAbsentAge) queue := ds.ArtworkQueue(ctx) - for _, kind := range recheckKinds { + for _, kind := range RecheckKinds { if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { return err } @@ -108,7 +137,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { // enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { queue := ds.ArtworkQueue(ctx) - for _, kind := range recheckKinds { + for _, kind := range RecheckKinds { if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { return err } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 3b75c5186..32a7688b4 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -52,6 +52,19 @@ func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error return o.MockArtworkQueueRepo.Enqueue(items...) } +var _ = Describe("RefreshableKinds", func() { + // The two are meant to describe the same fact. Nothing but this test stops them from drifting, + // and a drift would have `artwork explain` report state for a kind that keeps none. + It("holds exactly the kinds that keep state", func() { + for _, k := range []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, + model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork, + } { + Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String()) + } + }) +}) + var _ = Describe("Housekeeping", func() { var ( ctx context.Context @@ -93,32 +106,54 @@ var _ = Describe("Housekeeping", func() { Describe("Fingerprint", func() { It("changes when a fingerprint-affecting config value changes", func() { - f1 := fingerprint() + f1 := ConfigFingerprint() conf.Server.CoverArtPriority = "folder, embedded" - f2 := fingerprint() + f2 := ConfigFingerprint() Expect(f1).NotTo(Equal(f2)) }) It("changes when ArtistImageFolder changes", func() { conf.Server.ArtistImageFolder = "/before" - f1 := fingerprint() + f1 := ConfigFingerprint() conf.Server.ArtistImageFolder = "/after" - Expect(fingerprint()).NotTo(Equal(f1)) + Expect(ConfigFingerprint()).NotTo(Equal(f1)) }) It("changes when EnableM3UExternalAlbumArt is toggled", func() { conf.Server.EnableM3UExternalAlbumArt = false - f1 := fingerprint() + f1 := ConfigFingerprint() conf.Server.EnableM3UExternalAlbumArt = true - Expect(fingerprint()).NotTo(Equal(f1)) + Expect(ConfigFingerprint()).NotTo(Equal(f1)) + }) + + // Pinned: a changed formula re-resolves every library on upgrade, flooding external providers. + It("hashes a given config to a stable value", func() { + conf.Server.CoverArtPriority = "cover.*, embedded" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.ArtistImageFolder = "" + conf.Server.Agents = "lastfm,spotify" + conf.Server.EnableExternalServices = true + conf.Server.EnableM3UExternalAlbumArt = false + + Expect(ConfigFingerprint()).To(Equal("7e537a22febc07d3d5ca40546e88da54")) + }) + + It("reports the config inputs it hashes, so a change can be traced to a setting", func() { + conf.Server.Agents = "lastfm,spotify" + conf.Server.CoverArtPriority = "cover.*, embedded" + + Expect(FingerprintInputs()).To(ContainElements( + FingerprintInput{Name: "Agents", Value: "lastfm,spotify"}, + FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"}, + )) }) It("does not change when the server version changes", func() { original := consts.Version DeferCleanup(func() { consts.Version = original }) - f1 := fingerprint() + f1 := ConfigFingerprint() consts.Version = original + "-next" - Expect(fingerprint()).To(Equal(f1), + Expect(ConfigFingerprint()).To(Equal(f1), "the version must not invalidate artwork state: it would re-resolve every entity on every build") }) }) @@ -126,7 +161,7 @@ var _ = Describe("Housekeeping", func() { Describe("Backfill", func() { It("enqueues nothing and returns false when the stored fingerprint matches", func() { seedEntities() - Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed()) + Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed()) did, err := backfill(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -150,7 +185,7 @@ var _ = Describe("Housekeeping", func() { stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey) Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal(fingerprint())) + Expect(stored).To(Equal(ConfigFingerprint())) }) It("enqueues a private playlist by resolving it under an admin context", func() { diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index 39938a755..b1970d21d 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -55,6 +55,10 @@ func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { if err != nil { return nil, err } + // An open() that reports "no image" as a nil reader would otherwise panic on the Close below. + if orig == nil { + return nil, ErrUnavailable + } defer orig.Close() data, err := readCapped(orig) if err != nil { diff --git a/core/artwork/image_cache_test.go b/core/artwork/image_cache_test.go new file mode 100644 index 000000000..a74c51088 --- /dev/null +++ b/core/artwork/image_cache_test.go @@ -0,0 +1,41 @@ +package artwork + +import ( + "context" + "errors" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizedItem", func() { + Describe("Reader", func() { + newItem := func(open func() (io.ReadCloser, error)) *resizedItem { + return &resizedItem{hash: "abc123", size: 300, open: open} + } + + It("reports a nil reader as unavailable instead of panicking on it", func() { + // Every caller is expected to report "no image" as an error, but a nil reader reaches + // the deferred Close as a nil interface, which takes the whole request down. + _, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("propagates the open error", func() { + boom := errors.New("boom") + _, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background()) + Expect(err).To(MatchError(boom)) + }) + + It("serves the original bytes when they cannot be resized", func() { + rc, err := newItem(func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("not an image")), nil + }).Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + Expect(io.ReadAll(rc)).To(Equal([]byte("not an image"))) + }) + }) +}) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 11711469d..518b6e100 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -34,18 +34,31 @@ type resolution struct { // chainState carries what a priority walk has seen so far. A hit takes extErr with it so a // transient external failure still retries; localErr is dropped, as the scanner re-lists changes. -type chainState struct{ extErr, localErr bool } +type chainState struct { + extErr, localErr bool + trace *ChainTrace // nil unless the CLI asked for a trace +} // try stamps the accumulated external failure onto a hit, and records the miss otherwise. -func (c *chainState) try(res resolution, ok bool) (resolution, bool) { +func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { if ok { res.extError = c.extErr + c.record(candidate, OutcomeHit, res.sourcePath) return res, true } c.localErr = c.localErr || res.localError + if res.localError { + c.record(candidate, OutcomeUnreadable, "") + } else { + c.record(candidate, OutcomeMiss, "") + } return resolution{}, false } +func (c *chainState) record(candidate string, out Outcome, detail string) { + c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail}) +} + // exhausted is the outcome when no source in the chain yielded an image. func (c *chainState) exhausted() resolution { return resolution{extError: c.extErr, localError: c.localErr} @@ -95,8 +108,69 @@ func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (re } } -// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network, -// so a local-only resolver is stopped here rather than at each point in the chain walk. +// Explainable reports whether TracingResolver can walk this kind's sources and report which one +// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain. +func Explainable(kind model.Kind) bool { + switch kind { + case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork: + return true + } + return false +} + +// MayFetchExternal reports whether resolving this kind can issue an external request under the +// current config. Playlists inherit the album chain: the generated grid resolves album art. +func MayFetchExternal(kind model.Kind) bool { + switch kind { + case model.KindArtistArtwork: + return chainFetchesExternal(conf.Server.ArtistArtPriority) + case model.KindAlbumArtwork: + return chainFetchesExternal(conf.Server.CoverArtPriority) + case model.KindPlaylistArtwork: + return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority) + default: + return false + } +} + +// ImageAgentCount is how many enabled agents provide artist and album images. +type ImageAgentCount struct{ Artist, Album int } + +// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is +// tried, and a zero count still bills one, so agents the caller cannot see never read as free. +func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 { + if !MayFetchExternal(kind) { + return 0 + } + switch kind { + case model.KindArtistArtwork: + return int64(max(agents.Artist, 1)) + case model.KindAlbumArtwork: + return int64(max(agents.Album, 1)) + case model.KindPlaylistArtwork: + var n int64 + if conf.Server.EnableM3UExternalAlbumArt { + n++ + } + if chainFetchesExternal(conf.Server.CoverArtPriority) { + n += PlaylistGridSamples * int64(max(agents.Album, 1)) + } + return n + } + return 0 +} + +func chainFetchesExternal(priority string) bool { + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { + if strings.TrimSpace(pattern) == externalCandidate { + return true + } + } + return false +} + +// Album and artist fetches stop here when the resolver is local-only, rather than at each point in +// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { if r.ext == nil { return nil, "", false @@ -126,24 +200,31 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution return resolution{}, err } - var chain chainState + chain := chainState{trace: traceFrom(ctx)} for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } switch { case pattern == "embedded": - if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok { + res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } - case pattern == "external": + case pattern == externalCandidate: if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { - return resolution{reader: rd, source: "external:" + name}, nil + return resolution{reader: rd, source: ExternalPrefix + name}, nil } else if isErr { chain.extErr = true } case len(imgFiles) > 0: - if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok { + res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } + default: + chain.record(pattern, OutcomeSkipped, "no images in album folder") } } return chain.exhausted(), nil @@ -155,9 +236,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti if err != nil { return resolution{}, err } - upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload") - if ok { - return upload, nil + chain := chainState{trace: traceFrom(ctx)} + upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload") + if res, ok := chain.try("upload", upload, uploadOK); ok { + return res, nil } if upload.localError { // The upload outranks every other source; falling through would persist a lower-priority @@ -191,32 +273,43 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } } - var chain chainState for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } switch { - case pattern == "external": + case pattern == externalCandidate: if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { - return resolution{reader: rd, source: "external:" + name}, nil + return resolution{reader: rd, source: ExternalPrefix + name}, nil } else if isErr { chain.extErr = true } case pattern == "image-folder": - if res, ok := chain.try(resolveArtistImageFolder(ar)); ok { + res, ok := resolveArtistImageFolder(ar) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } case strings.HasPrefix(pattern, "album/"): if lib.FS == nil { + chain.record(pattern, OutcomeSkipped, "artist has no albums") continue } - if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok { + res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } default: - if lib.FS == nil || artistFolder == "" { + if lib.FS == nil { + chain.record(pattern, OutcomeSkipped, "artist has no albums") continue } - if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok { + if artistFolder == "" { + chain.record(pattern, OutcomeSkipped, "no artist folder") + continue + } + res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } } @@ -224,6 +317,9 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti return chain.exhausted(), nil } +// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid. +const PlaylistGridSamples = 4 + // resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { pl, err := r.ds.Playlist(ctx).Get(playlistID) @@ -269,7 +365,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } } - albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false). + GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if err != nil { return resolution{}, err } @@ -295,7 +392,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if decErr == nil { tiles = append(tiles, tile) } - if len(tiles) == 4 { + if len(tiles) == PlaylistGridSamples { break } } @@ -337,15 +434,35 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, if err != nil { return resolution{}, err } - if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt { + chain := chainState{trace: traceFrom(ctx)} + switch { + case !conf.Server.EnableMediaFileCoverArt: + chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off") + return resolution{}, nil + case !mf.HasCoverArt: + chain.record("embedded", OutcomeMiss, "the track has no embedded cover art") return resolution{}, nil } lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID) if err != nil { return resolution{}, err } - res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) - return res, nil + res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) + if res, ok = chain.try("embedded", res, ok); ok { + return res, nil + } + return chain.exhausted(), nil +} + +// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never +// queued: the serving path reads it through on every request, so this only ever explains. +func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) { + dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id}) + if err != nil { + return resolution{}, err + } + chain := chainState{trace: traceFrom(ctx)} + return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain) } // resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes @@ -353,7 +470,7 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) { r, path, err := gate(name, sf) if r != nil { - return resolution{reader: r, source: "external", sourcePath: path}, true, false + return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false } return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) } @@ -394,14 +511,20 @@ func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, em return resolution{localError: unreadable}, false } -func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { - r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)() +// resolveFolderSource turns a source that yields a library-relative image path into a folder +// resolution, keeping an existing-but-unopenable file distinct from an absent one. +func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) { + r, path, err := sf() if r == nil { return resolution{localError: errors.Is(err, errSourceUnreadable)}, false } return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true } +func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { + return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern)) +} + func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { folder := conf.Server.ArtistImageFolder if folder == "" { diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 9a0962cf2..25fb727de 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -394,6 +394,28 @@ var _ = Describe("resolveItem", func() { Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), ) + // The grid samples album art through the full album chain, so a playlist reaches the + // network even with the m3u fetch off. + It("calls the album image agents for its grid tiles when m3u art is disabled", func() { + conf.Server.EnableM3UExternalAlbumArt = false + conf.Server.CoverArtPriority = "external" + folderRepo.result = nil + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + _, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"}) + Expect(err).ToNot(HaveOccurred()) + Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album") + }) + It("resolves the uploaded image before the generated grid", func() { tmpDir := GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) @@ -630,3 +652,110 @@ var _ = Describe("decodeTile", func() { Expect(err).To(HaveOccurred()) }) }) + +var _ = Describe("Explainable", func() { + It("is true for the kinds the resolver walks", func() { + Expect(Explainable(model.KindArtistArtwork)).To(BeTrue()) + Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue()) + Expect(Explainable(model.KindDiscArtwork)).To(BeTrue()) + Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue()) + }) + + It("is false for the kinds resolved from a fixed internal order", func() { + Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse()) + Expect(Explainable(model.KindRadioArtwork)).To(BeFalse()) + }) +}) + +var _ = Describe("MayFetchExternal", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, embedded" + conf.Server.ArtistArtPriority = "artist.*" + conf.Server.EnableM3UExternalAlbumArt = false + }) + + It("is true for the kinds whose chain includes the external candidate", func() { + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue()) + Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue()) + }) + + It("is false for a chain with no external candidate", func() { + Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse()) + Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse()) + }) + + It("is true for playlists when the m3u image fetch is enabled", func() { + conf.Server.EnableM3UExternalAlbumArt = true + Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) + }) + + It("is true for playlists whose grid tiles resolve through an external album chain", func() { + conf.Server.CoverArtPriority = "cover.*, external" + Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) + }) + + It("is false for playlists with both paths off", func() { + Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse()) + }) + + It("is false for the kinds that only read local files", func() { + conf.Server.CoverArtPriority = "external" + conf.Server.ArtistArtPriority = "external" + conf.Server.EnableM3UExternalAlbumArt = true + Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse()) + Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse()) + }) +}) + +var _ = Describe("ExternalLookupsPerItem", func() { + count := ImageAgentCount{Artist: 3, Album: 2} + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + }) + + It("bills one call per agent, since the walk only stops early on a hit", func() { + Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3))) + Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2))) + }) + + It("bills a playlist for every album its grid samples", func() { + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). + To(Equal(int64(PlaylistGridSamples) * 2)) + }) + + It("adds the m3u image fetch on top of the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). + To(Equal(int64(PlaylistGridSamples)*2 + 1)) + }) + + It("bills only the m3u fetch when the album chain stays local", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.EnableM3UExternalAlbumArt = true + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1))) + }) + + It("still bills a call when no agent is visible, which plugins never are offline", func() { + none := ImageAgentCount{} + Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1))) + Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1))) + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)). + To(Equal(int64(PlaylistGridSamples))) + }) + + It("is zero whenever the kind reaches no agent at all", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.ArtistArtPriority = "artist.*" + Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero()) + Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero()) + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero()) + Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero()) + }) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 885ca03cf..78b7dd68d 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -27,23 +27,6 @@ import ( // to open it is not evidence the entity has no artwork, so callers must not settle on absent. var errSourceUnreadable = errors.New("artwork source unreadable") -func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { - for _, f := range extractFuncs { - if ctx.Err() != nil { - return nil, "", ctx.Err() - } - start := time.Now() - r, path, err := f() - if r != nil { - msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind) - log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start)) - return r, path, nil - } - log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) - } - return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable) -} - type sourceFunc func() (r io.ReadCloser, path string, err error) func (f sourceFunc) String() string { diff --git a/core/artwork/trace.go b/core/artwork/trace.go new file mode 100644 index 000000000..5d02fa4ff --- /dev/null +++ b/core/artwork/trace.go @@ -0,0 +1,99 @@ +package artwork + +import ( + "context" + "errors" + "io" + "slices" + "sync" +) + +// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these. +type Outcome string + +const ( + OutcomeHit Outcome = "hit" + OutcomeMiss Outcome = "miss" + OutcomeUnreadable Outcome = "unreadable" + OutcomeSkipped Outcome = "skipped" + OutcomeWouldTry Outcome = "would-try" + OutcomeError Outcome = "error" +) + +const ( + // externalCandidate labels the external tier itself, for the cases that never reach an agent. + externalCandidate = "external" + // ExternalPrefix qualifies a candidate or a stored source with the agent that produced it. + ExternalPrefix = externalCandidate + ":" +) + +// TraceStep is one candidate the priority chain considered. +type TraceStep struct { + Candidate string + Outcome Outcome + Detail string +} + +// ChainTrace collects the walk of a single resolution. The artwork worker never attaches +// one; only the CLI does, so resolution stays allocation-free in the hot path. +type ChainTrace struct { + mu sync.Mutex + steps []TraceStep +} + +func (t *ChainTrace) add(step TraceStep) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + t.steps = append(t.steps, step) +} + +func (t *ChainTrace) Steps() []TraceStep { + if t == nil { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + return slices.Clone(t.steps) +} + +type traceCtxKey struct{} + +func withTrace(ctx context.Context, t *ChainTrace) context.Context { + return context.WithValue(ctx, traceCtxKey{}, t) +} + +func traceFrom(ctx context.Context) *ChainTrace { + t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace) + return t +} + +var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)") + +// tracingGate records each external agent's outcome without changing what the gate returns. +func tracingGate(t *ChainTrace, inner gateFunc) gateFunc { + return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + r, path, err := inner(name, f) + candidate := ExternalPrefix + name + switch { + case r != nil: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) + case isTransientExternal(err): + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) + default: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) + } + return r, path, err + } +} + +// offlineGate reports which agents would be asked without asking them, so a diagnostic +// command cannot add load to a provider that is already rate-limiting us. +func offlineGate(t *ChainTrace) gateFunc { + return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry}) + return nil, "", errOfflineSkipped + } +} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go new file mode 100644 index 000000000..5a54c9e91 --- /dev/null +++ b/core/artwork/trace_test.go @@ -0,0 +1,628 @@ +package artwork + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("trace vocabulary", func() { + // The CLI renders these verbatim and branches on them; a value change is a change to + // what `artwork explain` tells an operator, so it must be made deliberately. + It("pins the wire values the CLI reads", func() { + Expect([]Outcome{ + OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError, + }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"})) + Expect(externalCandidate).To(Equal("external")) + Expect(ExternalPrefix).To(Equal("external:")) + }) +}) + +var _ = Describe("chainTrace", func() { + It("returns nil when no trace is attached", func() { + Expect(traceFrom(context.Background())).To(BeNil()) + }) + + It("collects steps in order", func() { + t := &ChainTrace{} + ctx := withTrace(context.Background(), t) + + traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) + traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}) + + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "cover.*", Outcome: OutcomeMiss}, + {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, + })) + + s := t.Steps() + s[0].Candidate = "mutated" + Expect(t.Steps()[0].Candidate).To(Equal("cover.*")) + }) + + It("does not panic when the trace is nil", func() { + var t *ChainTrace + Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic()) + Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it") + }) + + It("is safe to use concurrently", func() { + t := &ChainTrace{} + done := make(chan struct{}) + for range 10 { + go func() { + defer GinkgoRecover() + t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss}) + done <- struct{}{} + }() + } + for range 10 { + <-done + } + Expect(t.Steps()).To(HaveLen(10)) + }) +}) + +var _ = Describe("chainState tracing", func() { + It("records a miss when the candidate was absent", func() { + t := &ChainTrace{} + c := chainState{trace: t} + + _, ok := c.try("cover.*", resolution{}, false) + + Expect(ok).To(BeFalse()) + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}})) + }) + + It("records unreadable when the candidate existed but could not be read", func() { + t := &ChainTrace{} + c := chainState{trace: t} + + _, ok := c.try("cover.*", resolution{localError: true}, false) + + Expect(ok).To(BeFalse()) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), + "a candidate that existed and failed to decode must be distinguishable from one that was absent") + }) + + It("records a hit with the backing path", func() { + t := &ChainTrace{} + c := chainState{trace: t} + + res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true) + + Expect(ok).To(BeTrue()) + Expect(res.source).To(Equal("embedded")) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, + })) + }) +}) + +var _ = Describe("external gate tracing", func() { + hit := func() (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader("x")), "http://img", nil + } + miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } + boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") } + + It("records a hit with the image path", func() { + t := &ChainTrace{} + g := tracingGate(t, passthroughGate) + + r, _, err := g("deezer", hit) + + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, + })) + }) + + It("records a miss for a not-found", func() { + t := &ChainTrace{} + _, _, _ = tracingGate(t, passthroughGate)("deezer", miss) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) + }) + + It("records a miss for a model not-found", func() { + t := &ChainTrace{} + notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound } + _, _, _ = tracingGate(t, passthroughGate)("deezer", notFound) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), + "both not-found flavours are definitive answers, not faults") + }) + + It("records an error with its reason", func() { + t := &ChainTrace{} + _, _, _ = tracingGate(t, passthroughGate)("apple-music", boom) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) + Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) + }) + + It("never calls the agent in offline mode", func() { + t := &ChainTrace{} + called := false + counting := func() (io.ReadCloser, string, error) { + called = true + return hit() + } + + _, _, err := offlineGate(t)("deezer", counting) + + Expect(called).To(BeFalse(), "offline mode must not perform external requests") + Expect(err).To(MatchError(errOfflineSkipped)) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "external:deezer", Outcome: OutcomeWouldTry}, + })) + }) +}) + +var _ = Describe("resolveAlbum tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + albumRepo *tests.MockAlbumRepo + folderRepo *fakeFolderRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.jpg, embedded" + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + albumRepo = tests.CreateMockAlbumRepo() + folderRepo = &fakeFolderRepo{} + ds = &tests.MockDataStore{ + MockedAlbum: albumRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("records a pattern skipped because the album folder holds no images", func() { + albumRepo.SetData(model.Albums{ + {ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it") + Expect(t.Steps()[0]).To(Equal(TraceStep{ + Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder", + })) + Expect(t.Steps()[1].Candidate).To(Equal("embedded")) + Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) + }) + + It("records an evaluated pattern that matched nothing as a miss, not a skip", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + albumRepo.SetData(model.Albums{ + {ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}), + "the folder was searched and held no cover.jpg, which is not the same as never looking") + }) + + It("ignores an empty priority token", func() { + conf.Server.CoverArtPriority = "cover.jpg," + albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, + })) + }) +}) + +var _ = Describe("resolveArtist tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artistRepo *tests.MockArtistRepo + albumRepo *tests.MockAlbumRepo + folderRepo *fakeFolderRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + t *ChainTrace + repoRoot string + ) + + uploadPath := func(file string) string { + path := model.UploadedImagePath(consts.EntityArtist, file) + Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) + Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed()) + return path + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.ArtistArtPriority = "album/artist.*" + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + artistRepo = tests.CreateMockArtistRepo() + albumRepo = tests.CreateMockAlbumRepo() + folderRepo = &fakeFolderRepo{} + ds = &tests.MockDataStore{ + MockedArtist: artistRepo, + MockedAlbum: albumRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("records the upload short-circuit as a hit", func() { + path := uploadPath("ar1_test.jpg") + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}})) + }) + + It("records an upload miss before walking the chain", func() { + artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss})) + }) + + It("labels each step with the configured priority token", func() { + folderRepo.result = []model.Folder{{ + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) + albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()).To(HaveLen(2)) + Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"), + "the step must be labelled with the priority token, not the pattern it was rewritten into") + Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) + Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) + }) + + It("records a configured pattern that could not be evaluated", func() { + artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "upload", Outcome: OutcomeMiss}, + {Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"}, + }), "a configured pattern that was never evaluated must still appear, and say why") + }) + + It("records why an artist folder pattern was skipped", func() { + conf.Server.ArtistArtPriority = "artist.*" + artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}}) + albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "upload", Outcome: OutcomeMiss}, + {Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"}, + })) + }) + + It("records an upload that exists but cannot be read as unreadable", func() { + if runtime.GOOS == "windows" { + Skip("chmod does not restrict read access on Windows") + } + path := uploadPath("ar3_test.jpg") + Expect(os.Chmod(path, 0o000)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(path, 0o600) }) + artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), + "an upload that exists and will not open must not look like an absent upload") + }) +}) + +var _ = Describe("NewTracingResolver", func() { + var ( + ds *tests.MockDataStore + albumRepo *tests.MockAlbumRepo + artistRepo *tests.MockArtistRepo + artworkRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + ffm *tests.MockFFmpeg + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "external, embedded" + conf.Server.ArtistArtPriority = "external" + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + albumRepo = tests.CreateMockAlbumRepo() + artistRepo = tests.CreateMockArtistRepo() + artworkRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{ + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedFolder: &fakeFolderRepo{}, + MockedLibrary: libRepo, + MockedArtwork: artworkRepo, + MockedArtworkQueue: queueRepo, + } + ffm = tests.NewMockFFmpeg("") + t = &ChainTrace{} + }) + + Context("offline", func() { + var fake *fakeImageAgent + + BeforeEach(func() { + fake = &fakeImageAgent{name: "offline-probe"} + albumRepo.SetData(model.Albums{{ + ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, + }}) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + }) + + It("reports the external tier without asking any agent", func() { + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(Equal("embedded")) + Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider") + Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) + }) + + It("records the local chain steps too", func() { + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + last := t.Steps()[len(t.Steps())-1] + Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate") + Expect(last.Outcome).To(Equal(OutcomeHit)) + }) + + It("never persists artwork state", func() { + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + Expect(artworkRepo.ItemData).To(BeEmpty(), + "an offline resolution carries extError, which must never be recorded as a real provider failure") + Expect(queueRepo.Data).To(BeEmpty()) + }) + + It("resolves an artist without persisting anything", func() { + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1") + + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(BeEmpty()) + Expect(fake.artistCalls).To(BeZero()) + Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) + Expect(artworkRepo.ItemData).To(BeEmpty()) + Expect(queueRepo.Data).To(BeEmpty()) + }) + + It("closes the reader it does not hand back", func() { + conf.Server.CoverArtPriority = "embedded" + ffm = tests.NewMockFFmpeg("fake image bytes") + albumRepo.SetData(model.Albums{{ + ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, + }}) + + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2") + + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(Equal("embedded")) + Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") + }) + + It("propagates a lookup error", func() { + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + It("asks the agents when live is true", func() { + fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound} + albumRepo.SetData(model.Albums{{ + ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, + }}) + + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.albumCalls).To(Equal(1)) + Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss})) + }) +}) + +var _ = Describe("resolveDisc tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + albumRepo *tests.MockAlbumRepo + folderRepo *fakeFolderRepo + ffm *tests.MockFFmpeg + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + albumRepo = tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo = &fakeFolderRepo{} + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}}) + ds = &tests.MockDataStore{ + MockedAlbum: albumRepo, + MockedMediaFile: mfRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("accounts for every configured entry, including the ones that map to no source", func() { + conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg" + + res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"}, + {Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"}, + {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, + })) + }) + + It("records the entry that won and stops there", func() { + conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + + res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it") + Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss})) + Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg")) + Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) + Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg"))) + Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"), + "a library-relative path sends the operator looking in the wrong place") + }) + + It("reports an unparseable disc id rather than explaining another disc", func() { + conf.Server.DiscArtPriority = "cover.jpg" + _, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("resolveMediaFile tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + mfRepo *tests.MockMediaFileRepo + ffm *tests.MockFFmpeg + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + mfRepo = tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ + ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3", + }}) + ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo} + ffm = tests.NewMockFFmpeg("") + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("separates a disabled setting from a track with nothing embedded", func() { + conf.Server.EnableMediaFileCoverArt = false + + _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") + + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"}, + })) + }) + + It("records a track with no embedded art as a miss", func() { + mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}}) + + _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2") + + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"}, + })) + }) + + It("records the embedded hit", func() { + res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") + + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit)) + }) +}) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3ded52629..be8495305 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -137,7 +137,7 @@ func (w *Worker) Backfill(ctx context.Context) (bool, error) { return backfill(ctx, w.proc.ds) } -// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge. +// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge. func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { return enqueueStaleAbsentAll(ctx, w.proc.ds) } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 9fad4b503..53b6a43b2 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -362,7 +362,7 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") }) - // Media files are excluded from recheckKinds, so an absent row here would never be + // Media files are excluded from RecheckKinds, so an absent row here would never be // revisited: a transient read error would look permanent. It("does not settle absent on exhaustion for a kind with no recheck path", func() { conf.Server.EnableMediaFileCoverArt = true diff --git a/model/artwork.go b/model/artwork.go index 87b424f33..ea724265a 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -119,6 +119,8 @@ type ArtworkRepository interface { } type ArtworkQueueRepository interface { + // Get returns the pending row for an item, or ErrNotFound when it is not queued. + Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error) // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. Enqueue(items ...ArtworkQueueItem) error // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a @@ -130,6 +132,14 @@ type ArtworkQueueRepository interface { EnqueueAllMissing(kind Kind, priority int) (int64, error) // EnqueueIfMissing inserts only for items with no item_artwork row yet. EnqueueIfMissing(items ...ArtworkQueueItem) error + // CountBySource reports how many items of a kind currently resolve from the given sources. + // An empty sources slice means every source; "" matches absent state. + CountBySource(kind Kind, sources []string) (int64, error) + // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. + SourcesInUse(kind Kind) ([]string, error) + // EnqueueBySource inserts queue rows for items of a kind whose current source matches. + // It does not clear existing artwork state: the current image stays until it is replaced. + EnqueueBySource(kind Kind, sources []string, priority int) (int64, error) // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error) @@ -139,6 +149,22 @@ type ArtworkQueueRepository interface { // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error Count() (int64, error) + // CountByKindAndPriority reports the pending queue rows grouped by kind and priority. + CountByKindAndPriority() ([]ArtworkQueueStat, error) + // CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent + // would pick up at the given cutoff. + CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) } + +type ArtworkQueueStat struct { + ItemKind string + Priority int + Count int64 +} + +type ArtworkAbsentStat struct { + Total int64 + Stale int64 +} diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index e5469fbea..1ff754dc3 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -10,6 +10,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -31,6 +32,16 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor return r } +func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { + var res model.ArtworkQueueItem + err := r.queryOne(Select("*").From(r.tableName). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res) + if err != nil { + return nil, err + } + return &res, nil +} + // Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget. func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET @@ -87,12 +98,49 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte return nil } -// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is -// deliberate: a recheck must not bump the priority or retry_at of an already-queued item. +// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item. +const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING` + +// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) { return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+ - ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+ - ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...)) + ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) +} + +// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state. +func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { + f := And{Eq{"item_kind": kind.Prefix()}} + if len(sources) > 0 { + f = append(f, Eq{"source": sources}) + } + return f +} + +func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) { + var res struct{ Count int64 } + err := r.queryOne(Select("count(*) as count").From(itemArtworkTable). + Where(artworkSourceFilter(kind, sources)), &res) + return res.Count, err +} + +func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) { + var res []struct{ Source string } + err := r.queryAll(Select("distinct source").From(itemArtworkTable). + Where(Eq{"item_kind": kind.Prefix()}), &res) + if err != nil { + return nil, err + } + return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil +} + +// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the +// library's artwork until every item is resolved again. +func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { + now := time.Now() + sel := Select("item_kind", "item_id", "image_type"). + Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)). + From(itemArtworkTable).Where(artworkSourceFilter(kind, sources)) + return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued)) } func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { @@ -146,4 +194,20 @@ func (r *artworkQueueRepository) Count() (int64, error) { return res.Count, err } +func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { + var res []model.ArtworkQueueStat + err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName). + GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res) + return res, err +} + +// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue. +func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { + var res model.ArtworkAbsentStat + err := r.queryOne(Select("count(*) as total"). + Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)). + From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res) + return res, err +} + var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 6638a204e..d11d89a1f 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -54,6 +54,22 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(got[0].ItemID).To(Equal("high")) }) + It("Get returns a queued row, including one still backing off", func() { + Expect(repo.Enqueue(item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("ar", "g1", time.Now().Add(time.Hour)) + + got, err := repo.Get(model.KindArtistArtwork, "g1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Priority).To(Equal(model.ArtworkPriorityScan)) + Expect(got.Attempts).To(Equal(1)) + Expect(got.RetryAt).To(BeTemporally(">", time.Now())) + }) + + It("Get reports ErrNotFound when the item is not queued", func() { + _, err := repo.Get(model.KindArtistArtwork, "nope", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("keeps the higher priority on duplicate enqueue", func() { Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) @@ -265,6 +281,89 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") }) + Describe("EnqueueBySource", func() { + BeforeEach(func() { + artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"}, + {ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"}, + {ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""}, + {ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"}, + } { + Expect(artRepo.PutItemArtwork(&ia)).To(Succeed()) + } + }) + + It("enqueues only the matching source within the kind", func() { + n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched") + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1")) + }) + + It("treats the empty source as absent", func() { + n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + got, _ := repo.DequeueBatch(10) + Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3")) + }) + + It("enqueues every source when none is given", func() { + n, err := repo.EnqueueBySource(model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(3))) + }) + + It("leaves the current artwork state in place", func() { + _, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + + artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced") + Expect(ia.Source).To(Equal("external:deezer")) + }) + + It("does not disturb an already-queued row", func() { + Expect(repo.Enqueue(item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed()) + + n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) + }) + + It("counts without enqueueing", func() { + n, err := repo.CountBySource(model.KindArtistArtwork, []string{"external:deezer"}) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + queued, err := repo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(BeZero(), "CountBySource must not enqueue") + }) + + It("counts the absent source and every source", func() { + Expect(repo.CountBySource(model.KindArtistArtwork, []string{""})).To(Equal(int64(1))) + Expect(repo.CountBySource(model.KindArtistArtwork, nil)).To(Equal(int64(3))) + }) + + It("lists the distinct sources in use by a kind", func() { + Expect(repo.SourcesInUse(model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm")) + Expect(repo.SourcesInUse(model.KindAlbumArtwork)).To(ConsistOf("external:deezer")) + Expect(repo.SourcesInUse(model.KindRadioArtwork)).To(BeEmpty()) + }) + }) + It("does not disturb an already-queued entity when enqueueing missing rows", func() { Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) @@ -281,4 +380,43 @@ var _ = Describe("ArtworkQueueRepository", func() { } Expect(count).To(Equal(1), "the already-queued row must not be duplicated") }) + + Describe("status counters", func() { + It("groups queue rows by kind and priority", func() { + Expect(repo.Enqueue(item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + + Expect(repo.CountByKindAndPriority()).To(ConsistOf( + model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, + model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, + model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, + )) + }) + + It("reports an empty queue as no rows", func() { + Expect(repo.CountByKindAndPriority()).To(BeEmpty()) + }) + + It("counts absent states and how many are due for recheck", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + old := time.Now().Add(-48 * time.Hour) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, + {ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}, + {ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old}, + {ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, + } { + Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + } + + Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))). + To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1})) + }) + + It("reports a kind with no absent state as zero, not as an error", func() { + Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{})) + }) + }) }) diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go index 3583c2db7..cfd943b1f 100644 --- a/server/nativeapi/artwork.go +++ b/server/nativeapi/artwork.go @@ -10,14 +10,6 @@ import ( "github.com/navidrome/navidrome/model" ) -var refreshableArtworkKinds = []model.Kind{ - model.KindAlbumArtwork, - model.KindArtistArtwork, - model.KindPlaylistArtwork, - model.KindRadioArtwork, - model.KindMediaFileArtwork, -} - func (api *Router) addArtworkRoute(r chi.Router) { r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) } @@ -28,7 +20,7 @@ func (api *Router) refreshArtwork() http.HandlerFunc { ctx := r.Context() kind, _ := model.ParseKind(chi.URLParam(r, "kind")) id := chi.URLParam(r, "id") - if !slices.Contains(refreshableArtworkKinds, kind) { + if !slices.Contains(artwork.RefreshableKinds, kind) { http.Error(w, "invalid artwork kind", http.StatusBadRequest) return } diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 1b097ca32..c8e915daa 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -7,6 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) type MockArtworkQueueRepo struct { @@ -25,6 +26,19 @@ func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} } +func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)] + if !ok { + return nil, model.ErrNotFound + } + return &it, nil +} + func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -161,6 +175,49 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } +func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueStat + for _, it := range m.Data { + i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { + return s.ItemKind == it.ItemKind && s.Priority == it.Priority + }) + if i < 0 { + res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1}) + continue + } + res[i].Count++ + } + slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int { + return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority)) + }) + return res, nil +} + +// CountAbsent mirrors the SQL predicate: an absent state is one with no hash. +func (m *MockArtworkQueueRepo) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res model.ArtworkAbsentStat + if m.Err != nil || m.ItemArtworkSource == nil { + return res, m.Err + } + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind != kind.Prefix() || ia.Hash != "" { + continue + } + res.Total++ + if ia.AttemptedAt.Before(attemptedBefore) { + res.Stale++ + } + } + return res, nil +} + func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -216,6 +273,65 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefo return inserted, nil } +// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state. +func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { + if m.ItemArtworkSource == nil { + return nil + } + var res []model.ItemArtwork + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && (len(sources) == 0 || slices.Contains(sources, ia.Source)) { + res = append(res, ia) + } + } + return res +} + +func (m *MockArtworkQueueRepo) CountBySource(kind model.Kind, sources []string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.matchingSource(kind, sources))), nil +} + +func (m *MockArtworkQueueRepo) SourcesInUse(kind model.Kind) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source }) + return slice.Unique(sources), nil +} + +func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + now := time.Now() + var inserted int64 + for _, ia := range m.matchingSource(kind, sources) { + k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: ia.ItemKind, + ItemID: ia.ItemID, + ImageType: ia.ImageType, + Priority: priority, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} + // EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { m.mu.Lock() From 24311918c7825176b2603ae9a10278283ff7ebfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 15 Aug 2026 10:36:14 -0400 Subject: [PATCH 437/596] fix(artwork): ramp the external circuit breaker back up instead of closing on one answer (#5961) * fix(artwork): ramp the external circuit breaker back up instead of closing on one answer The breaker went straight from open to fully closed on a single non-transient response, so recovery was a burst: the agent resumed at the limiter's full rate until five consecutive failures reopened it. A not-found counted as that response, and a provider that is blocking still answers the occasional request, so the cycle never settled. Observed on a production library over 100 minutes with apple-music blocked. Of 232 responses, 228 were 403 and 4 were not-found, and those four closed the breaker four times. Each close was followed by another open 1 to 3 seconds later, with about five requests in between: 00:59:05 closed -> 00:59:06 opened 01:23:13 closed -> 01:23:16 opened 01:41:21 closed -> 01:41:24 opened Closing now needs breakerRecoveries consecutive answers, one per probe interval, and any failure discards the count. A not-found still counts, because the provider did answer, but it can no longer close the breaker by itself. Unrelated to the plugin loading in the rest of this PR; it came out of investigating why iTunes kept returning 403 while the breaker was open. * fix(artwork): count only current-episode probes toward breaker recovery The worker drains concurrently, so when the breaker opens there are already calls past allow(), queued in the rate limiter or waiting on a response. Their answers arrive after the open and reached the recovery counter, so breakerRecoveries of them closed the breaker with no probe interval elapsed at all: the burst the ramp exists to prevent. allow() now returns the open episode a call was admitted under, zero when the breaker was closed, and only an answer whose generation matches the current episode counts. The generation also invalidates a probe whose answer lands after the breaker closed and reopened, which a plain probe flag would credit to the wrong episode. The token never crosses the gateFunc seam: allow and record are both called inside Worker.gate, so passthroughGate, tracingGate and offlineGate are untouched. The regression test needs no fake clock. The race is an ordering, not a duration, so it is reproduced by calling allow and record in the order concurrency produces, which is deterministic where a goroutine-based test would pass on a lucky schedule. Found by Codex. * test(artwork): move the breaker ordering spec into the Ginkgo suite The ordering regression does not need a fake clock, so it does not need the plain testing.T runner either. That runner is only used here because testing/synctest requires it; every other spec belongs in the Ginkgo suite. The three specs left in worker_timing_test.go all drive the fake clock. --- core/artwork/gate.go | 60 ++++++++++++++++++++++-------- core/artwork/gate_test.go | 43 +++++++++++++++++++++ core/artwork/worker_timing_test.go | 59 ++++++++++++++++++++++++----- 3 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 core/artwork/gate_test.go diff --git a/core/artwork/gate.go b/core/artwork/gate.go index ff77f733d..534bd8ce3 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -17,6 +17,11 @@ import ( const ( breakerThreshold = 5 breakerProbeAfter = time.Minute + // breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the + // provider again. One is not enough: a provider that is rate-limiting or blocking us still + // answers the occasional request, and closing on the first of those puts the agent straight + // back to full rate, which is what earns the next block. + breakerRecoveries = 3 ) var errBreakerOpen = errors.New("artwork: external circuit breaker open") @@ -44,7 +49,8 @@ type extGate struct { // gate runs a named external step through that agent's rate limiter and circuit breaker. func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { g := w.gateFor(name) - if !g.breaker.allow() { + allowed, gen := g.breaker.allow() + if !allowed { log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name) return nil, "", errBreakerOpen } @@ -55,7 +61,7 @@ func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io. } callStart := time.Now() r, path, err := f() - g.breaker.record(name, err) + g.breaker.record(name, gen, err) log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil, "limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err) return r, path, err @@ -84,41 +90,65 @@ type breaker struct { mu sync.Mutex failures int openedAt time.Time + // recoveries counts consecutive good answers while open; a single failure discards them. + recoveries int + // generation identifies the current open episode, so an answer from a call admitted before + // the breaker opened cannot be mistaken for evidence that it has recovered. + generation int } func newBreaker() *breaker { return &breaker{} } -func (b *breaker) allow() bool { +// allow reports whether a call may proceed, and the open episode it was admitted under: zero +// when the breaker was closed, the current generation when admitted as a half-open probe. +func (b *breaker) allow() (bool, int) { b.mu.Lock() defer b.mu.Unlock() if b.failures < breakerThreshold { - return true + return true, 0 } if time.Since(b.openedAt) >= breakerProbeAfter { b.openedAt = time.Now() // start a fresh probe window so only one caller passes - return true + return true, b.generation } - return false + return false, 0 } -func (b *breaker) record(name string, err error) { +func (b *breaker) record(name string, gen int, err error) { // A cancelled run says nothing about the provider, so it neither counts nor clears. if errors.Is(err, context.Canceled) { return } b.mu.Lock() defer b.mu.Unlock() - if !isTransientExternal(err) { - if b.failures >= breakerThreshold { - log.Info("Artwork: Circuit breaker closed for agent", "agent", name) + if isTransientExternal(err) { + b.recoveries = 0 + b.failures++ + if b.failures == breakerThreshold { + b.openedAt = time.Now() + b.generation++ + log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, + "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) } + return + } + if b.failures < breakerThreshold { b.failures = 0 return } - b.failures++ - if b.failures == breakerThreshold { - b.openedAt = time.Now() - log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, - "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) + // Only a probe from this open episode is evidence of recovery. The worker drains concurrently, + // so answers keep arriving from calls admitted before the breaker opened; counting those would + // close it with no probe interval elapsed, which is the burst this exists to prevent. + if gen == 0 || gen != b.generation { + return } + // A not-found counts because the provider did answer, but on its own it is thin evidence that + // a provider which just blocked us is well. + b.recoveries++ + if b.recoveries < breakerRecoveries { + return + } + log.Info("Artwork: Circuit breaker closed for agent", "agent", name, + "consecutiveAnswers", b.recoveries) + b.failures, b.recoveries = 0, 0 } diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go new file mode 100644 index 000000000..abe723508 --- /dev/null +++ b/core/artwork/gate_test.go @@ -0,0 +1,43 @@ +package artwork + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// allowed drops the generation token when a caller only cares about admission. +func allowed(b *breaker) bool { ok, _ := b.allow(); return ok } + +var _ = Describe("breaker", func() { + // The worker drains concurrently, so when the breaker opens there are already calls past + // allow(), queued in the rate limiter or waiting on a response. Their answers arrive + // afterwards. Counting those as recovery closes the breaker with no probe interval elapsed, + // which is the burst the ramp exists to prevent. No clock is involved: the race is an + // ordering, so it is reproduced by making the calls in the order concurrency produces. + It("ignores answers from calls admitted before it opened", func() { + b := newBreaker() + + // A batch clears allow() while the breaker is still closed. + for range breakerThreshold + breakerRecoveries { + ok, gen := b.allow() + Expect(ok).To(BeTrue()) + Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe") + } + + // The fast failures in that batch open it. + for range breakerThreshold { + b.record("agentA", 0, errors.New("blocked")) + } + Expect(allowed(b)).To(BeFalse(), "breaker is open") + + // The slower answers from the same batch land now. + for range breakerRecoveries { + b.record("agentA", 0, nil) + } + + Expect(allowed(b)).To(BeFalse(), + "answers from calls admitted before the breaker opened must not close it") + }) +}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index f7315b20a..63f12d03c 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -20,24 +20,63 @@ func TestArtworkBreakerHalfOpen(t *testing.T) { b := newBreaker() for range breakerThreshold { - b.record("agentA", errors.New("boom")) + b.record("agentA", 0, errors.New("boom")) } - g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors") + g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors") time.Sleep(breakerProbeAfter - time.Nanosecond) - g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval") + g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval") time.Sleep(time.Nanosecond) - g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted") - g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval") + ok, gen := b.allow() + g.Expect(ok).To(BeTrue(), "half-open: one probe is granted") + g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to") + g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval") - b.record("agentA", errors.New("boom")) // probe fails -> stay open + b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open time.Sleep(breakerProbeAfter) - g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval") + ok, gen = b.allow() + g.Expect(ok).To(BeTrue(), "another probe after the next interval") - b.record("agentA", nil) // probe succeeds -> close - g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely") - g.Expect(b.allow()).To(BeTrue()) + // One good answer must not reopen the floodgates: closing here is what let a burst out at + // full rate and got the provider to escalate from throttling to blocking. + b.record("agentA", gen, nil) + g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker") + + for range breakerRecoveries - 1 { + time.Sleep(breakerProbeAfter) + ok, gen = b.allow() + g.Expect(ok).To(BeTrue()) + b.record("agentA", gen, nil) + } + g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely") + g.Expect(allowed(b)).To(BeTrue()) + }) +} + +// The failure seen in production: while an agent was blocked, the occasional answer it did serve +// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were +// seconds apart, over and over. +func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + b := newBreaker() + open := func() { + for range breakerThreshold { + b.record("agentA", 0, errors.New("blocked")) + } + } + open() + + // A not-found is an answer, so it counts toward recovery, but never on its own. + for range breakerRecoveries * 2 { + time.Sleep(breakerProbeAfter) + ok, gen := b.allow() + g.Expect(ok).To(BeTrue(), "one probe per interval") + b.record("agentA", gen, agents.ErrNotFound) + b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again + g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker") + } }) } From 5b87a60b5db07dbf4e4ea93d9766825a91995ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 15 Aug 2026 11:01:36 -0400 Subject: [PATCH 438/596] fix(plugins): load plugin agents in CLI commands (#5959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): load plugin agents in CLI commands A CLI that goes through core/agents saw only built-in agents: getEnabledAgentNames asks Manager.PluginNames, which reads a map populated solely by Manager.Start, and only the server calls that. On a plugin-using install the CLI's agent list was quietly short — artwork explain --live could name deezer for an artist whose stored source was external:apple-music, because apple-music was invisible to it. Adds Manager.LoadPlugins, the read-only counterpart to Start: extism/wazero init plus loadEnabledPlugins, without the folder sync, the error clearing, the cache purge or the watcher. It follows what the read-only plugin commands already do — list, info and validate read the DB and never start the manager — except that capabilities are detected from the WASM exports, not declared in the manifest, so instantiating is the only accurate source for what a plugin provides. loadEnabledPlugins disabled a plugin and recorded LastError when a load failed. That is right for the server and wrong for a diagnostic, so it is now gated on the read-only flag: inspecting a plugin must not disable it. No Subsonic router is required. Start log.Fatals without one, but the host function it feeds already nil-checks and reports 'SubsonicAPI router not available' at call time, so a plugin that reaches for it gets an error instead of the process dying. That Fatal's message also claimed the DataStore was missing; it checks the router. * fix(cli): correct the reprocess estimate's plugin caveat imageAgentCount now receives a manager with plugins loaded, so the external estimate already includes plugin image agents — but the disclaimer still said they were not counted, which told operators the opposite of what the number meant. Replaced rather than dropped: loadPluginAgents warns and continues when LoadPlugins fails, and LoadPlugins is a no-op when plugins are disabled or no folder is set, so there are still runs where plugin agents genuinely are not counted. The wording now covers all three cases, and the stale comment above it said the CLI never starts the plugin manager, which is what this branch changed. Found by Codex on 648cf38e9. * fix(plugins): load only the configured agents, and gate plugin init Loading a plugin is not free: the service constructors create a KVStore or TaskQueue database and a Storage directory for any plugin whose manifest declares those permissions, and the plugin's own init then runs arbitrary code. loadEnabledPlugins loads every enabled plugin, so inspecting artwork was starting scrobblers, schedulers and lyrics plugins that could never supply an image. Measured on a copy of a production library: 'artwork explain' created apple-music/kvstore.db, nd-lyrics/kvstore.db and listenbrainz-daily-playlist/taskqueue.db. The last one matters most — CreateQueue resets rows with status='running' to 'pending', which against a live server sets up its in-flight tasks to run twice. LoadPlugins now takes the names to load, and the artwork CLI passes the Agents list: a plugin that is not a configured agent can never win, so there is nothing to gain by instantiating it. The same two runs now create only apple-music, which is a configured agent and therefore the cost of answering the question. Init is gated separately on the caller's intent rather than on read-only. 'explain --live' already means 'reach the provider', so it runs init; plain 'explain' and 'reprocess' promise no external requests and must not. Documented in the --live flag help. Found by Codex on 28eb39ac4. * perf(cli): load plugin agents only when the selection can consult one Explaining disc or media file artwork loaded every configured metadata plugin, though neither resolver ever reaches an agent: resolveMediaFile is embedded-only and discArtworkReader.selectImage refuses external outright. With --live that also ran plugin init for a walk that provably cannot reach the network. The load now sits inside the artist/album branch that already exists, so it is a move rather than a new condition. reprocess did the same for a radio-only selection, whose estimate is unconditionally zero. It is gated on needsImageAgents, which asks exactly what ExternalLookupsPerItem asks, so the two cannot disagree. An artist/album test would look equivalent and would silently zero the playlist estimate, whose generated grid resolves album art through those agents; a test pins that, and reverting the predicate to a whitelist fails it. Found by Codex on b78e67b07. * docs(artwork): trim the comments this branch added to the project budget Six blocks ran past the one-to-two line limit. The LoadPlugins doc was eleven lines over three paragraphs, needsImageAgents spent two of its four explaining an alternative that was rejected, and inspectOpts restated what LoadPlugins already says. What went is reviewer-facing prose that belongs in a commit message: the enumeration of what Start does that this skips, and why an artist/album predicate would have been wrong. What stayed is the reasoning a future reader needs at that line, notably that instantiating a plugin creates its declared services, and that playlists consume the album agent count. * docs(artwork): correct the breaker comment after the recovery ramp It still said a success re-closes the breaker, which stopped being true when closing started requiring breakerRecoveries consecutive answers. * refactor(plugins): rename the scoped-load options to transientLoad inspect claimed the load was only looking, which is false when runInit is true: it instantiates the plugin and runs its init, which may open sockets. transient is accurate for every use of the field, and explains all three behaviours it gates. A load that will not outlive the command has no business persisting findings, instantiating plugins it will never consult, or starting background work it is about to tear down. --- cmd/artwork.go | 63 +++++++++++++--- cmd/artwork_test.go | 49 +++++++++++- core/artwork/gate.go | 2 +- plugins/manager.go | 65 ++++++++++++---- plugins/manager_loader.go | 29 ++++--- plugins/manager_readonly_test.go | 125 +++++++++++++++++++++++++++++++ 6 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 plugins/manager_readonly_test.go diff --git a/cmd/artwork.go b/cmd/artwork.go index 2b6a50164..e9c910f11 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/utils/slice" "github.com/spf13/cobra" ) @@ -35,7 +36,8 @@ var ( func init() { artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, - "perform real external lookups instead of reporting what would be tried") + "perform real external lookups instead of reporting what would be tried; "+ + "also initializes plugin agents, which may open external connections") artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil, "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil, @@ -277,7 +279,16 @@ func runReprocess(ctx context.Context) { defer db.Init(ctx)() ds, ctx := getAdminContext(ctx) - if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgentCount(ds), + // Only a kind that can reach an agent needs the count, and loading a plugin creates its + // services. A preview must not reach the network, so init never runs here. + var imageAgents artwork.ImageAgentCount + if needsImageAgents(kinds) { + mgr := loadPluginAgents(ctx, false) + defer func() { _ = mgr.Stop() }() + imageAgents = imageAgentCount(ds, mgr) + } + + if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents, reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil { log.Fatal(ctx, err) } @@ -326,25 +337,51 @@ func reprocessConfirm(yes bool, in io.Reader) confirmFunc { return promptConfirm(in) } -// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and plugin -// agents are unregistered in a CLI that never starts the plugin manager. +// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the +// plugin agents it counts are only the ones this process managed to load. func externalEstimate(n int64) string { if n == 0 { return "none" } - return fmt.Sprintf("~%d estimated (plugin agents not counted; local hits may need fewer)", n) + return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n) } func externalLookupLine(n int64) string { return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) } -// imageAgentCount counts only the built-in image agents, for the same reason. -func imageAgentCount(ds model.DataStore) artwork.ImageAgentCount { - ag := agents.GetAgents(ds, getPluginManager()) +func imageAgentCount(ds model.DataStore, mgr *plugins.Manager) artwork.ImageAgentCount { + ag := agents.GetAgents(ds, mgr) return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} } +// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a +// running server would. A load failure is reported, not fatal: the built-in agents still answer. +func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager { + mgr := getPluginManager() + if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil { + log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err) + } + return mgr +} + +// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with +// the estimate it guards. Playlists count: their generated grid resolves album art through agents. +func needsImageAgents(kinds []model.Kind) bool { + return slices.ContainsFunc(kinds, artwork.MayFetchExternal) +} + +// configuredAgents names the agents in priority order; one absent from it can never supply an image. +func configuredAgents() []string { + var names []string + for name := range strings.SplitSeq(conf.Server.Agents, ",") { + if name = strings.TrimSpace(name); name != "" { + names = append(names, name) + } + } + return names +} + func promptConfirm(in io.Reader) confirmFunc { return func(out io.Writer, total, external int64) bool { var cost string @@ -533,8 +570,8 @@ func explainAgents(configured string, available []string) string { } // availableImageAgents names the agents that can actually supply an image for kind. -func availableImageAgents(ds model.DataStore, kind model.Kind) []string { - ag := agents.GetAgents(ds, getPluginManager()) +func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string { + ag := agents.GetAgents(ds, mgr) if kind == model.KindArtistArtwork { return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name }) } @@ -705,8 +742,12 @@ func runExplain(ctx context.Context, kind model.Kind, id string) { } if artwork.Explainable(kind) { + // Only artist and album reach an agent, and the load must precede the resolver, which reads + // the same manager. if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork { - rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, kind)) + mgr := loadPluginAgents(ctx, explainLive) + defer func() { _ = mgr.Stop() }() + rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind)) } trace := &artwork.ChainTrace{} rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 949fdb42a..b28d8dac5 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -594,7 +594,9 @@ var _ = Describe("reprocessArtwork", func() { It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() { Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed()) - Expect(out.String()).To(ContainSubstring("plugin agents not counted")) + // The count includes plugin agents once they load, so the caveat is about a failed load, + // not about plugins being invisible to the CLI. + Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load")) Expect(out.String()).To(ContainSubstring("local hits may need fewer")) Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false") Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false") @@ -897,3 +899,48 @@ var _ = Describe("refreshItems", func() { "the ids after a failure are still refreshed") }) }) + +var _ = Describe("needsImageAgents", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + }) + + It("is false for a selection no agent can serve", func() { + Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse()) + }) + + // The generated playlist grid resolves album art through the image agents, so a playlist + // selection needs the count even though no agent is asked for a playlist image directly. + It("is true for playlists, whose grid tiles resolve through the album chain", func() { + Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue()) + }) + + It("is true when any one of several kinds can reach an agent", func() { + Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue()) + }) + + It("is false once the chains no longer reach an agent", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.ArtistArtPriority = "artist.*" + Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse()) + }) +}) + +var _ = Describe("configuredAgents", func() { + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) }) + + It("splits and trims the configured list", func() { + conf.Server.Agents = "lastfm, spotify ,deezer" + Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"})) + }) + + // An empty name would match no plugin, but it also must not make the list look non-empty: + // LoadPlugins treats an empty list as "load nothing". + It("drops empty entries rather than passing a name nothing can match", func() { + conf.Server.Agents = " , ," + Expect(configuredAgents()).To(BeEmpty()) + }) +}) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index 534bd8ce3..ebb65bcbe 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -85,7 +85,7 @@ func (w *Worker) gateFor(name string) *extGate { } // breaker opens after breakerThreshold consecutive errors and admits a single probe once -// breakerProbeAfter has elapsed; a success re-closes it. +// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers. type breaker struct { mu sync.Mutex failures int diff --git a/plugins/manager.go b/plugins/manager.go index b3a71ce6c..bab67e987 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -61,6 +61,9 @@ type Manager struct { debounceTimers map[string]*time.Timer debounceMu sync.Mutex + // transient is set by LoadPlugins, and nil for a server Start. + transient *transientLoad + // SubsonicAPI host function dependencies (set once before Start, not modified after) subsonicRouter SubsonicRouter ds model.DataStore @@ -110,27 +113,14 @@ func (m *Manager) Start(ctx context.Context) error { } if m.subsonicRouter == nil { - log.Fatal(ctx, "Plugin manager requires DataStore to be configured") + log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured") } - // Set extism log level based on plugin-specific config or global log level - pluginLogLevel := conf.Server.Plugins.LogLevel - if pluginLogLevel == "" { - pluginLogLevel = conf.Server.LogLevel - } - extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) - - m.ctx, m.cancel = context.WithCancel(ctx) - - // Initialize wazero compilation cache for better performance cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) - var err error - m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) - if err != nil { - log.Error(ctx, "Failed to create wazero compilation cache", err) - return fmt.Errorf("creating wazero compilation cache: %w", err) + if err := m.initRuntime(ctx, cacheDir); err != nil { + return err } if conf.Server.Plugins.Folder.String() == "" { @@ -171,6 +161,49 @@ func (m *Manager) Start(ctx context.Context) error { return nil } +// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs. +func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error { + pluginLogLevel := conf.Server.Plugins.LogLevel + if pluginLogLevel == "" { + pluginLogLevel = conf.Server.LogLevel + } + extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) + + m.ctx, m.cancel = context.WithCancel(ctx) + + var err error + m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) + if err != nil { + log.Error(ctx, "Failed to create wazero compilation cache", err) + return fmt.Errorf("creating wazero compilation cache: %w", err) + } + return nil +} + +// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins. +type transientLoad struct { + only []string + runInit bool +} + +// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is +// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done. +func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error { + if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 { + return nil + } + m.transient = &transientLoad{only: only, runInit: runInit} + + cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") + if err := m.initRuntime(ctx, cacheDir); err != nil { + return err + } + if err := m.loadEnabledPlugins(ctx); err != nil { + return fmt.Errorf("loading enabled plugins: %w", err) + } + return nil +} + // Stop shuts down the plugin manager and releases all resources. func (m *Manager) Stop() error { // Mark as stopped first to prevent new operations diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 439c944f5..e5e3dbfc0 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "time" extism "github.com/extism/go-sdk" @@ -232,6 +233,11 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { if !p.Enabled { continue } + // Instantiating a plugin creates its host services, so a transient load takes only the + // ones it may actually consult. + if m.transient != nil && !slices.Contains(m.transient.only, p.ID) { + continue + } plugin := p // Capture for goroutine g.Go(func() error { @@ -246,19 +252,21 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { }() if err := m.loadPluginWithConfig(&plugin); err != nil { - // Store error in DB - plugin.LastError = err.Error() - plugin.Enabled = false - plugin.UpdatedAt = time.Now() - if putErr := repo.Put(&plugin); putErr != nil { - log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) + // A transient load must not disable the user's plugin just for looking at it. + if m.transient == nil { + plugin.LastError = err.Error() + plugin.Enabled = false + plugin.UpdatedAt = time.Now() + if putErr := repo.Put(&plugin); putErr != nil { + log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) + } } log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err) return nil } // Clear any previous error - if plugin.LastError != "" { + if plugin.LastError != "" && m.transient == nil { plugin.LastError = "" plugin.UpdatedAt = time.Now() if putErr := repo.Put(&plugin); putErr != nil { @@ -444,8 +452,11 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { m.mu.Unlock() loaded = true - // Call plugin init function - callPluginInit(ctx, m.plugins[p.ID]) + // Init is the plugin's first chance to run arbitrary code: open sockets, create task queues, + // schedule work. Only a caller that already intends to reach the network asks for it. + if m.transient == nil || m.transient.runInit { + callPluginInit(ctx, m.plugins[p.ID]) + } return nil } diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go new file mode 100644 index 000000000..9d16b76e9 --- /dev/null +++ b/plugins/manager_readonly_test.go @@ -0,0 +1,125 @@ +//go:build !windows + +package plugins + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Manager.LoadPlugins", func() { + var ( + mgr *Manager + repo *tests.MockPluginRepo + tmpDir string + ) + + // newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI + // has none, and Start would log.Fatal on that. + newManager := func(rows model.Plugins) *Manager { + DeferCleanup(configtest.SetupConfig()) + var err error + tmpDir, err = os.MkdirTemp("", "plugins-readonly-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) + + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = conf.NewDir(tmpDir) + + if rows == nil { + rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension) + for i := range rows { + rows[i].AllUsers = true + } + } + repo = tests.CreateMockPluginRepo() + repo.Permitted = true + repo.SetData(rows) + m := &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: repo}, + metrics: noopMetricsRecorder{}, + } + DeferCleanup(func() { _ = m.Stop() }) + return m + } + + It("detects capabilities without a Subsonic router configured", func() { + mgr = newManager(nil) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent")) + }) + + Context("when a plugin cannot be loaded", func() { + brokenRows := func() model.Plugins { + return model.Plugins{{ + ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"), + Enabled: true, AllUsers: true, + }} + } + + It("leaves the stored row untouched", func() { + mgr = newManager(brokenRows()) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + + stored, err := repo.Get("broken") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it") + Expect(stored.LastError).To(BeEmpty()) + }) + + // Without this the test above would pass for the wrong reason. Start cannot be used: it + // syncs the folder first, dropping a row whose file is missing before any load. + It("still disables it when not read-only", func() { + mgr = newManager(brokenRows()) + + Expect(mgr.loadEnabledPlugins(GinkgoT().Context())).To(Succeed()) + + stored, err := repo.Get("broken") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Enabled).To(BeFalse()) + Expect(stored.LastError).ToNot(BeEmpty()) + }) + }) + + // Loading a plugin creates its host services — a KVStore or task queue database on disk — so a + // plugin that could never supply an image must not be instantiated just to be ignored. + It("does not load a plugin that is not in the agent list", func() { + mgr = newManager(nil) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"some-other-agent"}, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) + }) + + It("does nothing when no agents are configured", func() { + mgr = newManager(nil) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), nil, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) + // Not even the wazero cache: with nothing to load there is nothing to compile. + Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory()) + }) + + It("does nothing when the plugin system is disabled", func() { + mgr = newManager(nil) + conf.Server.Plugins.Enabled = false + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) + }) +}) From 82fde00eccfb36c89fd722b1dabcc8d2a902aebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 15 Aug 2026 16:10:53 -0400 Subject: [PATCH 439/596] feat(scrobbler): add per-user scrobble filter (#5964) * feat(scrobbler): add scrobble_filter column to user * feat(scrobbler): validate scrobble filter criteria on user save * refactor(persistence): make smart playlist join helpers package-level * feat(scrobbler): add MediaFileRepository.MatchesCriteria * feat(scrobbler): filter external scrobbles with per-user criteria * feat(ui): add scrobble filter field to user form * fix(scrobbler): default scrobble_filter to empty string for existing users * refactor(scrobbler): also gate playback reports on the scrobble filter Playback reports carry the same track metadata to plugin scrobblers, so a filtered track leaked through that third dispatch path. Skip the filter evaluation entirely when no scrobbler is active. * refactor(persistence): move criteria join building into criteria_sql.go The join set a criteria needs was decided in criteria_sql.go but built in smart_playlist_repository.go, so both callers had to pair the two by hand. * refactor(persistence): unexport smartPlaylistCriteria methods The type never leaves the package, so the exported names advertised an API that callers outside persistence could never reach. Also disambiguates where/orderBy from squirrel's SelectBuilder methods of the same name. * fix(ui): cap the scrobble filter field width fullWidth stretched it across the whole page next to 256px inputs. Bounded at 40em, with two rows and a resize handle so JSON rules stay readable. * refactor(ui): move scrobble filter input in UserEdit component * feat(ui): add pt-BR translations for the scrobble filter * fix(scrobbler): take the filter verdict before incPlay incPlay mutates play counts and dates a filter can test on, so evaluating at dispatch time let one play decide differently on either side of the increment: a track could be scrobbled despite matching, or lose only its stopped report and strand presence plugins. Reject limit/offset too, rather than silently ignoring part of a rule copied from a smart playlist. * fix(scrobbler): filter the report from an expired session The expiry callback runs with a stub user carrying no filter, so evaluating there always returned false and leaked the track to plugin scrobblers. That is the normal path for clients that never send stopped, such as legacy Subsonic now-playing. Carry the last verdict on the session instead. * refactor(scrobbler): skip the now-playing enqueue instead of threading the verdict Queuing an entry only to drop it at dispatch also cancelled a pending announcement for the previous, unfiltered track, since the queue is keyed by player and a new entry replaces the old one. * fix(scrobbler): evaluate the filter regardless of active scrobblers The verdict is stored on the session and dispatched at expiry, so skipping evaluation when no scrobbler was active let a plugin enabled mid-session receive a filtered track. The empty-filter guard above already gives servers without scrobbling the same free path, so the shortcut only ever applied to users who had a filter set. --- core/scrobbler/play_tracker.go | 69 +++++- core/scrobbler/play_tracker_test.go | 200 ++++++++++++++++++ core/scrobbler/playbackreport_worker.go | 15 +- ...0260815015320_add_user_scrobble_filter.sql | 5 + model/mediafile.go | 4 + model/user.go | 2 + persistence/criteria_sql.go | 45 +++- persistence/criteria_sql_benchmark_test.go | 4 +- persistence/criteria_sql_test.go | 88 ++++---- persistence/mediafile_repository.go | 18 ++ persistence/mediafile_repository_test.go | 28 +++ persistence/smart_playlist_repository.go | 46 +--- persistence/user_repository.go | 34 +++ persistence/user_repository_test.go | 66 ++++++ resources/i18n/pt-br.json | 9 +- tests/mock_mediafile_repo.go | 10 + ui/src/i18n/en.json | 9 +- ui/src/user/UserEdit.jsx | 11 + ui/src/user/UserEdit.test.jsx | 6 + ui/src/user/userValidation.js | 10 + ui/src/user/userValidation.test.js | 23 ++ 21 files changed, 590 insertions(+), 112 deletions(-) create mode 100644 db/migrations/20260815015320_add_user_scrobble_filter.sql diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index e21db42d2..63397f8f6 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -2,6 +2,7 @@ package scrobbler import ( "context" + "encoding/json" "maps" "slices" "sync" @@ -11,6 +12,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/cache" @@ -43,6 +45,10 @@ type PlaybackSession struct { PositionMs int64 PlaybackRate float64 LastReport time.Time + + // Verdict from the last report, for the expiry callback: its context carries only + // a stub user, so it cannot evaluate the filter itself. + filtered bool } type Submission struct { @@ -68,8 +74,9 @@ type nowPlayingEntry struct { } type playbackReportEntry struct { - ctx context.Context - info PlaybackSession + ctx context.Context + info PlaybackSession + filtered bool } type PlayTracker interface { @@ -145,7 +152,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug log.Trace("Enqueueing PlaybackReport for expired session", "session", info) info.State = StateExpired info.LastReport = time.Now() - p.enqueuePlaybackReport(ctx, info) + p.enqueuePlaybackReport(ctx, info, info.filtered) } }) @@ -273,6 +280,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP now := time.Now() + // One verdict per report, reused by every dispatch below, so a filter reading + // annotations cannot decide differently on either side of incPlay. + var filtered bool + switch params.State { case StateStarting: // Clients may send starting/playing unordered; a late "starting" must not downgrade @@ -285,8 +296,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } + filtered = p.isFilteredOut(ctx, mf) info := PlaybackSession{ MediaFile: *mf, + filtered: filtered, Start: now, UserId: user.ID, Username: user.UserName, @@ -309,7 +322,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } - p.enqueuePlaybackReport(ctx, info) + p.enqueuePlaybackReport(ctx, info, filtered) case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) @@ -331,6 +344,8 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP info.PositionMs = params.PositionMs info.PlaybackRate = params.PlaybackRate info.LastReport = now + filtered = p.isFilteredOut(ctx, &info.MediaFile) + info.filtered = filtered ttl := 30 * time.Minute if params.State == StatePlaying { ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) @@ -342,16 +357,19 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } - p.enqueuePlaybackReport(ctx, info) + p.enqueuePlaybackReport(ctx, info, filtered) case StateStopped: var loadedMF *model.MediaFile + haveVerdict := false if !params.IgnoreScrobble && player.ScrobbleEnabled { mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } loadedMF = mf + filtered = p.isFilteredOut(ctx, mf) + haveVerdict = true trackDurationMs := int64(mf.Duration * 1000) threshold := min(trackDurationMs*50/100, 240_000) if params.PositionMs >= threshold { @@ -359,7 +377,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) } - p.dispatchScrobble(ctx, mf, now) + p.dispatchScrobble(ctx, mf, now, filtered) } } p.sessionsMu.Lock() @@ -397,7 +415,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP } stoppedInfo.MediaFile = *mf } - p.enqueuePlaybackReport(ctx, stoppedInfo) + if !haveVerdict { + filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile) + } + p.enqueuePlaybackReport(ctx, stoppedInfo, filtered) } if conf.Server.EnableNowPlaying { @@ -413,7 +434,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. if player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { - if info, err := p.playMap.Get(clientId); err == nil { + if filtered { + log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId) + } else if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) } } @@ -452,6 +475,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err) continue } + filtered := p.isFilteredOut(ctx, mf) err = p.incPlay(ctx, mf, s.Timestamp) if err != nil { log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err) @@ -460,7 +484,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID) log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp) if player.ScrobbleEnabled { - p.dispatchScrobble(ctx, mf, s.Timestamp) + p.dispatchScrobble(ctx, mf, s.Timestamp, filtered) } } } @@ -494,11 +518,36 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times }) } -func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) { +// Take this verdict before incPlay mutates what a filter reads, and independently of +// which scrobblers are active: it can be stored on a session and dispatched much later. +// Any parse or query failure fails open, because filtering must not break scrobbling. +func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool { + u, _ := request.UserFrom(ctx) + if u.ScrobbleFilter == "" { + return false + } + var c criteria.Criteria + if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { + log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err) + return false + } + match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c) + if err != nil { + log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err) + return false + } + return match +} + +func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) { if t.Artist == consts.UnknownArtist { log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) return } + if filtered { + log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist) + return + } allScrobblers := p.getActiveScrobblers() u, _ := request.UserFrom(ctx) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f49d9a0bf..72f802c2e 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" @@ -46,6 +47,26 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { return s, ok } +// flipOnPlayRepo reports one filter verdict before the play is counted and another +// after, reproducing a filter that reads annotations incPlay mutates. +type flipOnPlayRepo struct { + model.MediaFileRepository + before, after bool + played atomic.Bool +} + +func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error { + r.played.Store(true) + return r.MediaFileRepository.IncPlayCount(id, ts) +} + +func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) { + if r.played.Load() { + return r.after, nil + } + return r.before, nil +} + // slowMediaFileRepo widens the window between a report's session check and its // write, making check-then-write races reproducible. type slowMediaFileRepo struct { @@ -320,6 +341,185 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("Scrobble filter", func() { + var repo *tests.MockMediaFileRepo + + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", + ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`}) + repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + }) + + It("does not send a matching track to the agent", func() { + repo.MatchesCriteriaValue = true + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + + It("still increments play counts for a filtered track", func() { + repo.MatchesCriteriaValue = true + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + }) + + It("sends a non-matching track to the agent", func() { + repo.MatchesCriteriaValue = false + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + + It("fails open when evaluation errors", func() { + repo.MatchesCriteriaErr = errors.New("boom") + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + + It("fails open when the stored filter is not valid JSON", func() { + ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`}) + repo.MatchesCriteriaValue = true + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + + It("does not send now-playing for a filtered track", func() { + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does not send playback reports for a filtered track", func() { + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + + It("sends playback reports for a non-matching track", func() { + repo.MatchesCriteriaValue = false + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + }) + + It("evaluates the filter even when no scrobbler is active yet", func() { + // The verdict is stored on the session and dispatched at expiry, by which + // time a plugin scrobbler may have been enabled. + tracker.builtinScrobblers = map[string]Scrobbler{} + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + stored, getErr := tracker.playMap.Get("player-12") + Expect(getErr).ToNot(HaveOccurred()) + Expect(stored.filtered).To(BeTrue()) + }) + + Context("when incPlay itself flips the filter", func() { + // A filter on playCount or lastPlayed changes verdict the moment incPlay + // commits, so the verdict has to be taken before it, not at dispatch time. + var flip *flipOnPlayRepo + + install := func(before, after bool) { + flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after} + ds.(*tests.MockDataStore).MockedMediaFile = flip + } + + It("does not scrobble a track the filter matched before the play was counted", func() { + install(true, false) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + + It("still sends the stopped report when the filter only starts matching after the play", func() { + install(false, true) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + }) + + It("does not report an expired session for a filtered track", func() { + // The expiry callback runs with a stub user, so it cannot evaluate the + // filter itself and must reuse the verdict stored on the session. + info := PlaybackSession{ + MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", + PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true, + } + _ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond) + + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + + It("still reports an expired session for a track that is not filtered", func() { + info := PlaybackSession{ + MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", + PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false, + } + _ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + }) + + It("stores the verdict on the session so expiry can reuse it", func() { + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + stored, getErr := tracker.playMap.Get("player-11") + Expect(getErr).ToNot(HaveOccurred()) + Expect(stored.filtered).To(BeTrue()) + }) + + It("does not scrobble a Submit whose filter matched before the play was counted", func() { + install(true, false) + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + }) + }) + Describe("ReportPlayback", func() { const defaultClientId = "client-1" diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go index 78ca6e0f7..799dac616 100644 --- a/core/scrobbler/playbackreport_worker.go +++ b/core/scrobbler/playbackreport_worker.go @@ -6,13 +6,14 @@ import ( "github.com/navidrome/navidrome/log" ) -func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) { p.prMu.Lock() defer p.prMu.Unlock() ctx = context.WithoutCancel(ctx) p.prQueue = append(p.prQueue, playbackReportEntry{ - ctx: ctx, - info: info, + ctx: ctx, + info: info, + filtered: filtered, }) p.sendPlaybackReportSignal() } @@ -44,12 +45,16 @@ func (p *playTracker) playbackReportWorker() { allScrobblers := p.getActiveScrobblers() for _, entry := range entries { - p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered) } } } -func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) { + if filtered { + log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State) + return + } for name, s := range allScrobblers { if !s.IsAuthorized(ctx, info.UserId) { continue diff --git a/db/migrations/20260815015320_add_user_scrobble_filter.sql b/db/migrations/20260815015320_add_user_scrobble_filter.sql new file mode 100644 index 000000000..82ed95f3d --- /dev/null +++ b/db/migrations/20260815015320_add_user_scrobble_filter.sql @@ -0,0 +1,5 @@ +-- +goose Up +alter table user add column scrobble_filter varchar default '' not null; + +-- +goose Down +alter table user drop column scrobble_filter; diff --git a/model/mediafile.go b/model/mediafile.go index ac1fad67b..888425d07 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -15,6 +15,7 @@ import ( "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" @@ -548,6 +549,9 @@ type MediaFileRepository interface { // filters as GetAll. Sort/Order are ignored. GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) + // MatchesCriteria reports whether the media file matches the criteria's rule + // expression, using the logged user's annotations. Limit and offset are ignored. + MatchesCriteria(id string, c criteria.Criteria) (bool, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) // GetAllIDs returns just the media_file IDs for the same row set as GetAll. GetAllIDs(options ...QueryOptions) ([]string, error) diff --git a/model/user.go b/model/user.go index 1c8541ccf..b6f792c9a 100644 --- a/model/user.go +++ b/model/user.go @@ -14,6 +14,8 @@ type User struct { LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + // Smart-playlist criteria JSON; matching songs are not sent to external scrobblers + ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"` // Library associations (many-to-many relationship) Libraries Libraries `structs:"-" json:"libraries,omitempty"` diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 70f9a1fb5..890f757d7 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -129,7 +129,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "random": {order: "random()"}, } -func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) { if c.Criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } @@ -786,7 +786,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { return field.joinType } -func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { +func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType { var joins smartPlaylistJoinType _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { for field := range criteria.Fields(expr) { @@ -797,15 +797,50 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { return joins } -func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { - joins := c.ExpressionJoins() +func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType { + joins := c.expressionJoins() for _, name := range c.Criteria.SortFieldNames() { joins |= fieldJoinType(name) } return joins } -func (c smartPlaylistCriteria) OrderBy() string { +// applyExpressionJoins adds every join the criteria's WHERE clause resolves against. +func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { + return c.applyJoins(sq, c.expressionJoins(), userID) +} + +// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against. +func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { + return c.applyJoins(sq, c.requiredJoins(), userID) +} + +// applyJoins joins the media_file annotation unconditionally — annotation fields +// COALESCE a missing row to a default, so the row has to be reachable to be absent. +func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder { + sq = sq.LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", userID) + if joins.has(smartPlaylistJoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinAlbum) { + sq = sq.LeftJoin("album ON album.id = media_file.album_id") + } + return sq +} + +func (c smartPlaylistCriteria) orderBy() string { sortFields := c.Criteria.OrderByFields() parts := make([]string, 0, len(sortFields)) for _, sf := range sortFields { diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go index 1dcf97871..523a5d825 100644 --- a/persistence/criteria_sql_benchmark_test.go +++ b/persistence/criteria_sql_benchmark_test.go @@ -124,7 +124,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria. // Build the full query matching buildSmartPlaylistQuery + addCriteria sq := squirrel.Select("media_file.id").From("media_file") - cond, err := cSQL.Where() + cond, err := cSQL.where() if err != nil { b.Fatal(err) } @@ -132,7 +132,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria. if expr.Limit > 0 { sq = sq.Limit(uint64(expr.Limit)) } - if order := cSQL.OrderBy(); order != "" { + if order := cSQL.orderBy(); order != "" { sq = sq.OrderBy(order) } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 0cd7187c5..8d0069b0d 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -20,7 +20,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { DescribeTable("expressions", func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -204,7 +204,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), - ).Where() + ).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -217,7 +217,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), - ).Where() + ).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -228,7 +228,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("builds relative date expressions", func() { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -238,7 +238,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("builds negated relative date expressions", func() { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -248,76 +248,76 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("returns an error for unknown fields", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where() Expect(err).To(MatchError("invalid field in criteria: unknown")) }) It("returns an error when isMissing is used with a regular field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).where() Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isPresent is used with a regular field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where() Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isMissing has a non-boolean value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where() Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) It("returns an error for a range over a tag/role field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where() Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) }) It("returns a clear error for a malformed range value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where() Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair"))) }) Describe("sort", func() { It("sorts by regular fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc")) }) It("sorts by tag fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) }) It("sorts by role fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) }) It("casts numeric tags when sorting", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) }) It("sorts by albumtype alias", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) }) It("sorts by random", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc")) }) It("sorts by album columns bare, with no COALESCE default", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()). + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()). To(Equal("album.created_at desc, media_file.track_number asc")) }) It("sorts by multiple fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) }) It("reverts order when order is desc", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) }) It("ignores invalid sort fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc")) }) }) @@ -362,7 +362,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Kraftwerk"}, criteria.Contains{"artist": "Pink Floyd"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -376,7 +376,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Beatles"}, criteria.Contains{"composer": "Lennon"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -391,7 +391,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"artist": "Beatles"}, criteria.NotContains{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -406,7 +406,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { for i := range anyExprs { anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -424,7 +424,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Beatles"}, criteria.Contains{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -440,7 +440,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"genre": "Metal"}, criteria.Contains{"genre": "Punk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -454,7 +454,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"genre": "Rock"}, criteria.Contains{"mood": "Happy"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -467,7 +467,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"genre": "Rock"}, criteria.NotContains{"genre": "Metal"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -482,7 +482,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"genre": "Rock"}, criteria.Contains{"genre": "Metal"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -499,7 +499,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.IsNot{"artist": "Beatles"}, criteria.IsNot{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -515,7 +515,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"artist": "Beatles"}, criteria.NotContains{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -530,7 +530,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"genre": "Rock"}, criteria.NotContains{"genre": "Metal"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -547,7 +547,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Beatles"}, criteria.IsNot{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -562,7 +562,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.IsNot{"artist": "Beatles"}, criteria.IsNot{"composer": "Lennon"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -575,7 +575,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { for i := range allExprs { allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)} } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -591,14 +591,14 @@ var _ = Describe("Smart playlist criteria SQL", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} cSQL := newSmartPlaylistCriteria(c) - Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) }) It("includes expression-based joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) }) It("detects nested album and artist joins", func() { @@ -607,7 +607,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Any{criteria.Gt{"artistPlayCount": 10}}, }} - joins := newSmartPlaylistCriteria(c).RequiredJoins() + joins := newSmartPlaylistCriteria(c).requiredJoins() Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) @@ -615,20 +615,20 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("detects join types from sort fields with direction prefixes", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"} - Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) It("keeps a sort-only album join out of the expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"} cSQL := newSmartPlaylistCriteria(c) - Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) + Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) }) It("distinguishes the album join from the album annotation join", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - joins := newSmartPlaylistCriteria(c).RequiredJoins() + joins := newSmartPlaylistCriteria(c).requiredJoins() Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse()) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index f37e394cc..5f0addc5e 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" @@ -517,6 +518,23 @@ var mediaFileSearchConfig = searchConfig{ MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"}, } +func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (bool, error) { + usr := loggedUser(r.ctx) + rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr)) + cond, err := rulesSQL.where() + if err != nil { + return false, err + } + sq := Select("count(*) as count").From("media_file") + sq = rulesSQL.applyExpressionJoins(sq, usr.ID) + sq = sq.Where(And{Eq{"media_file.id": id}, cond}) + var res struct{ Count int64 } + if err := r.queryOne(sq, &res); err != nil { + return false, err + } + return res.Count > 0, nil +} + func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { var opts model.QueryOptions if len(options) > 0 { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index b9cba03ff..ba89b5def 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" @@ -1129,4 +1130,31 @@ var _ = Describe("MediaRepository", func() { Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse()) }) }) + + Describe("MatchesCriteria", func() { + It("returns true when the track matches", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}} + match, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).ToNot(HaveOccurred()) + Expect(match).To(BeTrue()) + }) + It("returns false when the track does not match", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}} + match, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).ToNot(HaveOccurred()) + Expect(match).To(BeFalse()) + }) + It("treats missing annotations as their COALESCE default", func() { + // unrated track: rating coalesces to 0, so "rating < 4" matches + c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}} + match, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).ToNot(HaveOccurred()) + Expect(match).To(BeTrue()) + }) + It("returns an error for an invalid field", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}} + _, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).To(HaveOccurred()) + }) + }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 5f84981b3..65ae4656b 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -119,13 +119,11 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ return nil } - exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file") - countSq = r.addMediaFileAnnotationJoin(countSq, userID) - countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID) + countSq = rulesSQL.applyExpressionJoins(countSq, userID) countSq = r.applyLibraryFilter(countSq, "media_file") - cond, err := rulesSQL.Where() + cond, err := rulesSQL.where() if err != nil { log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return err @@ -146,50 +144,18 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ // buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, // including the joins its fields require and library filtering. func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { - orderBy := rulesSQL.OrderBy() + orderBy := rulesSQL.orderBy() sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file") - sq = r.addMediaFileAnnotationJoin(sq, userID) - - requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID) + sq = rulesSQL.applyRequiredJoins(sq, userID) sq = r.applyLibraryFilter(sq, "media_file") return sq } -// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include -// user-specific annotations in the smart playlist criteria evaluation. -func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder { - return sq.LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", userID) -} - -// addSmartPlaylistJoins adds the left joins required by the criteria's fields. -func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinAlbum) { - sq = sq.LeftJoin("album ON album.id = media_file.album_id") - } - return sq -} - // addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the // smart playlist criteria. func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { - cond, err := cSQL.Where() + cond, err := cSQL.where() if err != nil { return sql, err } @@ -197,7 +163,7 @@ func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCr if cSQL.Criteria.Limit > 0 { sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) } - if order := cSQL.OrderBy(); order != "" { + if order := cSQL.orderBy(); order != "" { sql = sql.OrderBy(order) } return sql, nil diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9decff4e5..3c030a640 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" @@ -252,6 +253,9 @@ func (r *userRepository) Save(entity any) (string, error) { if err := validateUsernameUnique(r, u); err != nil { return "", err } + if err := validateScrobbleFilter(u); err != nil { + return "", err + } err := r.Put(u) if err != nil { return "", err @@ -284,6 +288,9 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error { if err := validateUsernameUnique(r, u); err != nil { return err } + if err := validateScrobbleFilter(u); err != nil { + return err + } err := r.Put(u) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound @@ -331,6 +338,33 @@ func validateUsernameUnique(r model.UserRepository, u *model.User) error { return nil } +func validateScrobbleFilter(u *model.User) error { + u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter) + if u.ScrobbleFilter == "" { + return nil + } + var c criteria.Criteria + if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { + return invalidScrobbleFilter() + } + // A filter is a per-track test, so a result-set size means nothing here. Reject it + // rather than silently ignoring part of a rule copied from a smart playlist. + if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 { + return invalidScrobbleFilter() + } + // Building the WHERE clause is what validates field names and operators + if _, err := newSmartPlaylistCriteria(c).where(); err != nil { + return invalidScrobbleFilter() + } + return nil +} + +func invalidScrobbleFilter() error { + return &rest.ValidationError{Errors: map[string]string{ + "scrobbleFilter": "resources.user.validation.invalidScrobbleFilter", + }} +} + func (r *userRepository) Delete(id string) error { usr := loggedUser(r.ctx) if !usr.IsAdmin { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 6f8ab9161..ec417c193 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -70,6 +70,26 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) + It("persists and reads back the scrobble filter", func() { + usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", + ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} + Expect(repo.Put(&usr)).To(Succeed()) + + saved, err := repo.Get("u-filter") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`)) + }) + It("reads back a user row inserted without scrobble_filter", func() { + // Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable + _, err := GetDBXBuilder().NewQuery( + "insert into user (id, user_name, name, email, password, created_at, updated_at) " + + "values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute() + Expect(err).ToNot(HaveOccurred()) + + saved, err := repo.Get("u-rawsql") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.ScrobbleFilter).To(Equal("")) + }) }) Describe("validatePasswordChange", func() { @@ -607,6 +627,52 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("validateScrobbleFilter", func() { + It("accepts an empty filter", func() { + u := &model.User{} + Expect(validateScrobbleFilter(u)).To(Succeed()) + }) + It("trims a whitespace-only filter to empty", func() { + u := &model.User{ScrobbleFilter: " "} + Expect(validateScrobbleFilter(u)).To(Succeed()) + Expect(u.ScrobbleFilter).To(Equal("")) + }) + It("accepts valid criteria JSON", func() { + u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`} + Expect(validateScrobbleFilter(u)).To(Succeed()) + }) + It("rejects malformed JSON", func() { + u := &model.User{ScrobbleFilter: `{not json`} + var vErr *rest.ValidationError + err := validateScrobbleFilter(u) + Expect(errors.As(err, &vErr)).To(BeTrue()) + Expect(vErr.Errors).To(HaveKey("scrobbleFilter")) + }) + It("rejects criteria without rules", func() { + u := &model.User{ScrobbleFilter: `{"sort":"title"}`} + Expect(validateScrobbleFilter(u)).ToNot(Succeed()) + }) + It("rejects selection options that mean nothing for a single track", func() { + for _, f := range []string{ + `{"all":[{"lt":{"rating":4}}],"limit":100}`, + `{"all":[{"lt":{"rating":4}}],"limitPercent":10}`, + `{"all":[{"lt":{"rating":4}}],"offset":5}`, + `{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`, + } { + u := &model.User{ScrobbleFilter: f} + Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f) + } + }) + It("accepts a sort, which cannot change a single-track match", func() { + u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`} + Expect(validateScrobbleFilter(u)).To(Succeed()) + }) + It("rejects unknown fields", func() { + u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`} + Expect(validateScrobbleFilter(u)).ToNot(Succeed()) + }) + }) + Describe("filters", func() { It("qualifies id filter with table name", func() { r := repo.(*userRepository) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index b3b3bab2f..9a6338ae1 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -155,11 +155,13 @@ "newPassword": "Nova Senha", "token": "Token", "lastAccessAt": "Últ. Acesso", - "libraries": "Bibliotecas" + "libraries": "Bibliotecas", + "scrobbleFilter": "Filtro de scrobble" }, "helperTexts": { "name": "Alterações no seu nome só serão refletidas no próximo login", - "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão" + "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão", + "scrobbleFilter": "Músicas que correspondem a estas regras de playlist inteligente não são enviadas ao Last.fm, ListenBrainz ou plugins de scrobble. Usa a mesma sintaxe JSON e o mesmo comportamento das playlists inteligentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixe vazio para enviar scrobbles de todas as músicas. As contagens de reprodução locais não são afetadas." }, "notifications": { "created": "Novo usuário criado", @@ -173,7 +175,8 @@ "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas" }, "validation": { - "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores" + "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores", + "invalidScrobbleFilter": "Devem ser regras válidas de playlist inteligente. Limite, deslocamento e intervalo de atualização não são suportados." } }, "player": { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index d993af1a8..58e84785b 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -9,6 +9,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" ) @@ -31,6 +32,8 @@ type MockMediaFileRepo struct { // Add fields for cross-library move detection tests FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) + MatchesCriteriaValue bool + MatchesCriteriaErr error } func (m *MockMediaFileRepo) SetError(err bool) { @@ -369,5 +372,12 @@ func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, return result, nil } +func (m *MockMediaFileRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) { + if m.MatchesCriteriaErr != nil { + return false, m.MatchesCriteriaErr + } + return m.MatchesCriteriaValue, nil +} + var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index c0e226453..12968afbd 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -155,11 +155,13 @@ "currentPassword": "Current Password", "newPassword": "New Password", "token": "Token", - "libraries": "Libraries" + "libraries": "Libraries", + "scrobbleFilter": "Scrobble filter" }, "helperTexts": { "name": "Changes to your name will only be reflected on next login", - "libraries": "Select specific libraries for this user, or leave empty to use default libraries" + "libraries": "Select specific libraries for this user, or leave empty to use default libraries", + "scrobbleFilter": "Songs matching these smart playlist rules are not sent to Last.fm, ListenBrainz or scrobbler plugins. Uses the same JSON syntax and behavior as smart playlists. Example: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leave empty to scrobble everything. Local play counts are not affected." }, "notifications": { "created": "User created", @@ -167,7 +169,8 @@ "deleted": "User deleted" }, "validation": { - "librariesRequired": "At least one library must be selected for non-admin users" + "librariesRequired": "At least one library must be selected for non-admin users", + "invalidScrobbleFilter": "Must be valid smart playlist rules. Limit, offset and refresh delay are not supported." }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index d8302a9f9..c5d9c75a4 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -172,6 +172,17 @@ const UserEdit = (props) => { </FormDataConsumer> )} + <TextInput + source="scrobbleFilter" + multiline + minRows={2} + fullWidth + spellCheck={false} + style={{ maxWidth: '40em' }} + inputProps={{ style: { resize: 'vertical' } }} + helperText={translate('resources.user.helperTexts.scrobbleFilter')} + /> + <DateField variant="body1" source="lastLoginAt" showTime /> <DateField variant="body1" source="lastAccessAt" showTime /> <DateField variant="body1" source="updatedAt" showTime /> diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 1d8290569..74405cc13 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -127,6 +127,12 @@ describe('<UserEdit />', () => { expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument() }) + it('should render the scrobble filter input', () => { + render(<UserEdit id="user1" permissions="admin" />) + + expect(screen.getByTestId('text-input-scrobbleFilter')).toBeInTheDocument() + }) + it('should not render username input for non-admin users', () => { render(<UserEdit id="user1" permissions="user" />) diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js index e90fd2acb..9eb1b8f05 100644 --- a/ui/src/user/userValidation.js +++ b/ui/src/user/userValidation.js @@ -15,5 +15,15 @@ export const validateUserForm = (values, translate) => { } } + if (values.scrobbleFilter && values.scrobbleFilter.trim() !== '') { + try { + JSON.parse(values.scrobbleFilter) + } catch { + errors.scrobbleFilter = translate( + 'resources.user.validation.invalidScrobbleFilter', + ) + } + } + return errors } diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js index 2ee473910..de52c17df 100644 --- a/ui/src/user/userValidation.test.js +++ b/ui/src/user/userValidation.test.js @@ -67,4 +67,27 @@ describe('User Validation Utilities', () => { ) }) }) + + describe('scrobbleFilter validation', () => { + it('accepts an empty filter', () => { + const errors = validateUserForm({ isAdmin: true }, mockTranslate) + expect(errors.scrobbleFilter).toBeUndefined() + }) + it('accepts valid JSON', () => { + const errors = validateUserForm( + { isAdmin: true, scrobbleFilter: '{"all":[{"lt":{"rating":4}}]}' }, + mockTranslate, + ) + expect(errors.scrobbleFilter).toBeUndefined() + }) + it('rejects malformed JSON', () => { + const errors = validateUserForm( + { isAdmin: true, scrobbleFilter: '{broken' }, + mockTranslate, + ) + expect(errors.scrobbleFilter).toEqual( + 'resources.user.validation.invalidScrobbleFilter', + ) + }) + }) }) From 42dfdf49da7e75f48eda3463754694e2c2362799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 16 Aug 2026 10:18:10 -0400 Subject: [PATCH 440/596] feat(lyrics): support [bg:] tag and skip unknown tags in LRC files (#5966) Unknown [name:value] tag lines (e.g. [al:], [by:]) were being glued onto the previous lyric line, producing junk cues like "\n[bg: " and stretching the line's timing. Now any unrecognized tag line is skipped whole, and the non-standard [bg:] background-vocal tag is parsed into cues attached to the preceding line under a bg agent, using the same agents representation the TTML parser emits for Apple-style background vocals. --- model/lyrics_lrc.go | 103 +++++++++++++++++++++++++++++++-------- model/lyrics_lrc_test.go | 43 ++++++++++++++++ 2 files changed, 127 insertions(+), 19 deletions(-) diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go index 2cccb9d51..e5bd8d4f5 100644 --- a/model/lyrics_lrc.go +++ b/model/lyrics_lrc.go @@ -21,6 +21,12 @@ var ( timeRegex = regexp.MustCompile(timeRegexString) lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + // Non-standard background-vocal tag: [bg: <mm:ss.mm>word...] attaches to the preceding line + bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`) + + // Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole + unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`) + // Enhanced LRC: inline word-level timing markers like <00:12.34> enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) @@ -40,7 +46,26 @@ func parseLRC(language, text string) (*Lyrics, error) { priorLine := "" validLine := false repeated := false + hasBg := false var timestamps []int64 + var pendingBg []string + + flushLine := func() { + value, cues := parseEnhancedLine(priorLine) + var merged bool + value, cues, merged = mergeBgLayers(value, cues, pendingBg) + hasBg = hasBg || merged + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + pendingBg = nil + } for _, line := range lines { line := strings.TrimSpace(line) @@ -77,6 +102,17 @@ func parseLRC(language, text string) (*Lyrics, error) { continue } + if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil { + if validLine { + pendingBg = append(pendingBg, bgMatch[1]) + } + continue + } + + if unknownTagRegex.MatchString(line) { + continue + } + times := timeRegex.FindAllStringSubmatchIndex(line, -1) if len(times) > 1 { repeated = true @@ -92,16 +128,7 @@ func parseLRC(language, text string) (*Lyrics, error) { } if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } - timestamps = nil + flushLine() } end := 0 @@ -143,15 +170,11 @@ func parseLRC(language, text string) (*Lyrics, error) { } if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } + flushLine() + } + + if hasBg { + fillMainAgentID(structuredLines) } // If there are repeated values, there is no guarantee that they are in order @@ -170,9 +193,51 @@ func parseLRC(language, text string) (*Lyrics, error) { Offset: offset, Synced: synced, } + if hasBg { + lyrics.Agents = []Agent{ + {ID: "main", Role: "main"}, + {ID: backgroundAgentID("main"), Role: "bg"}, + } + } return &lyrics, nil } +// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a +// background agent layer, mirroring the TTML background-vocal representation. +func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) { + merged := false + for _, bg := range bgLines { + bgValue, bgCues := parseEnhancedLine(bg) + if len(bgCues) == 0 { + continue + } + offset := 0 + if value != "" { + offset = len(value) + 1 + value += " " + } + value += bgValue + for _, c := range bgCues { + c.ByteStart += offset + c.ByteEnd += offset + c.AgentID = backgroundAgentID("main") + cues = append(cues, c) + } + merged = true + } + return value, cues, merged +} + +func fillMainAgentID(lines []Line) { + for i := range lines { + for j := range lines[i].Cue { + if lines[i].Cue[j].AgentID == "" { + lines[i].Cue[j].AgentID = "main" + } + } + } +} + // parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers // and computes UTF-8 byte offsets against the final stripped line value. func parseEnhancedLine(text string) (string, []Cue) { diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go index 87514caf1..e8cd05352 100644 --- a/model/lyrics_lrc_test.go +++ b/model/lyrics_lrc_test.go @@ -147,6 +147,49 @@ var _ = Describe("parseLRC", func() { })) }) + It("should skip unknown tag lines instead of merging them into the prior line", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[1].Value).To(Equal("Next")) + }) + + It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() { + lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(lyrics.Line).To(HaveLen(2)) + + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Main line Okay")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"}, + {Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"}, + {Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"}, + })) + Expect(lyrics.Line[1].Value).To(Equal("Next")) + }) + + It("should skip a [bg:] line that has no inline timing", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Agents).To(BeNil()) + Expect(lyrics.Line).To(HaveLen(2)) + Expect(lyrics.Line[0].Value).To(Equal("Hi")) + Expect(lyrics.Line[1].Value).To(Equal("Next")) + }) + It("should handle mixed Enhanced and plain LRC lines", func() { lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") Expect(err).ToNot(HaveOccurred()) From 5b758fc20cc11e16f0fddab19e5bb0714007e1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 16 Aug 2026 13:24:07 -0400 Subject: [PATCH 441/596] fix(artwork): re-resolve artwork when image files change on disk (#5965) * fix(artwork): re-resolve artwork when image files change on disk An image-only folder change (replaced, added, or deleted cover/artist images, with no audio files touched) was detected by the scanner but never reached the artwork queue, so clients kept seeing the old coverArt hash until something else forced a re-resolution. Phase 1 now diffs each changed folder's image list and imagesUpdatedAt against the previously persisted folder row, and at the end of the phase bulk-enqueues re-resolution for the affected entities: albums with tracks in the folder or its direct children (covering disc subfolder layouts), and, when an artist-pattern image is involved, artists with albums under the folder's subtree, mirroring the artist resolver's upward search. The artist mapping mirrors the resolver's sole-album-artist album selection. New repository helpers keep the mapping set-based and light: folder GetAllIDs, media_file GetAlbumIDsByFolder (distinct, indexed by folder_id), and album GetSoleAlbumArtistIDs. * refactor: simplify the image-change artwork enqueue after review Load the previous folder image state through the existing GetFolderUpdateInfo bulk pre-pass instead of a per-folder SELECT inside the persist transaction, and skip the diff for new folders, whose artwork the scanner already enqueues inline. Move the artist-image classification into core/artwork (IsArtistImageFile) so the scanner shares the resolver's ArtistArtPriority token grammar instead of re-parsing it (the copy mistreated image-folder as a filename glob). Move the folder-subtree query into the folder repository (GetSubtreeIDs) with LIKE escaping and expression-tree batching, share the sole-album-artist predicate between the resolver and the album repository (model.SoleAlbumArtistFilter), extract a chunked single-column query helper, and deduplicate the ArtworkQueueItem literals behind scanArtworkItem. * fix(persistence): keep slash-form paths in GetSubtreeIDs subtree predicates The scanner hands GetSubtreeIDs io/fs slash-form paths, but filepath.Clean rewrites them with backslashes on Windows while folder.path is stored with forward slashes, so the descendant predicates matched nothing and nested artist folders were never re-enqueued there. Normalize with path.Clean, like HasAudioOutsideFolders does, and cover a nested path in the repo test. * refactor(persistence): move the sole-album-artist rule into the album repository SQLizer filters belong in the persistence package, not model. The rule becomes an unexported filter shared by GetSoleAlbumArtistIDs and a new GetBySoleAlbumArtist repository method, which the artist artwork resolver now calls instead of building the squirrel filter itself. * perf(scanner): resolve image-change artists in one query over album.folder_ids The artist half of the image-change enqueue walked folder subtree IDs, then media_file rows, then album rows, marshalling thousands of bound IDs through the driver on each hop. Matching albums by their own folder_ids instead is one statement, and folder_ids is the same source the artist resolver uses to compute an artist's folders. Benchmarked against a copy of the production DB (97k tracks, 10k folders, 7k albums): 87ms +/-196% -> 17.4ms +/-8%, 7.1MB -> 172KB, 103k -> 1.5k allocs. The subtree predicate becomes a shared folderSubtreeFilter, so Folder GetSubtreeIDs and Album GetSoleAlbumArtistIDs are no longer needed. * fix(scanner): persist ancestor folders discovered by a quick scan A quick scan skipped any new folder with no files of its own, so an artist folder holding only album subfolders never got a row. Adding artist.jpg to it later then produced no artwork enqueue: the entry was new, so the image diff was skipped, and it has no tracks, so nothing was enqueued inline either. Skip only genuinely empty new folders, matching what a full scan already persists. This also fixes artist artwork resolving as absent for artists first imported by a quick scan, since the resolver's folder climb needs that row. Also normalizes the selective-scan preload paths with path.Clean, so its descendant predicates match the stored slash-form paths on Windows. * fix(persistence): chunk subtree paths and match artist globs by basename Two regressions from earlier commits on this branch. Collapsing the subtree query into a single statement dropped the chunking the old GetSubtreeIDs had: each path expands into 3 OR terms and SQLite rejects an expression tree deeper than 1000, measured at 166 paths. A library with more artist-image folders than that (the prod copy has 158) would fail the whole collect, dropping the album items with it, so the scanner now keeps them when the artist query fails. The artist-image classifier compared whole tokens after stripping album/, so a directory-bearing glob like images/artist.* never matched the basenames the scanner has. Match on path.Base, which is what album/artist.* already reduced to; the resolver climbs parent folders, so an exact prefix is not knowable here and a conservative match is the right failure direction. * refactor(persistence): halve the repository surface this PR adds Research on the four new repository methods found two were avoidable. GetAlbumIDsByFolder now expands the changed folders to their direct children in its own subquery, so Folder.GetAllIDs has no callers and is deleted, one round trip per scan disappears, and the previously unchunked id/parent_id IN lists are covered by the existing chunking. GetBySoleAlbumArtist becomes an exported SoleAlbumArtistFilter, matching the ParticipantIDFilter precedent for sharing a Sqlizer with core/, so the rule still lives in persistence but AlbumRepository gains nothing and the mock shim that ignored the artist filter is gone. Also drops queryAllSliceChunked, now callerless, in favour of the file-local slices.Chunk convention used by the sibling folder queries. Rejected on measurement: matching the album path by album.folder_ids is exactly equivalent (13975 pairs, zero difference) but has no index, so it scans every album and runs 5-200x slower than the media_file route. * refactor(persistence): stop reading the deprecated album_artist_id column Both artist lookups this PR touches now go through participation, matching the precedent in core/archiver.go and share_repository.go. SoleAlbumArtistFilter uses ParticipantIDFilter, which is also faster: the album_artists unique constraint is a covering index for it, while the old column needed album_artist_album_id plus a row fetch. GetSoleAlbumArtistIDsInSubtrees reads the sole artist out of the participants JSON it already parses for the sole-artist check, rather than joining back to album_artists, which measured ~1.6x slower on a prod-sized copy. Verified equivalent on that copy: 6828 sole-artist albums and 1088 subtree artists resolve identically via the column, the join and the JSON. The tests now set a deliberately wrong album_artist_id so they fail if either query starts reading it again. * docs: trim comments that carry rationale belonging in commit messages Five comments had grown past the budget with benchmark numbers, rejected alternatives, and a duplicate of the constant's own explanation. * refactor(scanner): move the image-change enqueue into phase_1_folders The three functions were methods on phaseFolders, so they belong with the type; phase_1_image_changes.go also read like a fifth phase, which it wasn't. * refactor(scanner): extract the image-change collector into its own type phaseFolders no longer owns the per-library map and the mapping methods; it records into a collector and asks it to enqueue once. The collector keeps the library alongside the folders, so enqueue needs only ctx and the datastore. * refactor(scanner): simplify enqueue method by removing redundant datastore parameter Signed-off-by: Deluan <deluan@navidrome.org> * docs(scanner): drop the stale zero-value claim on imageChangeCollector The collector now takes its datastore at construction, so the zero value is no longer usable. * fix(scanner): pin the persist stage to concurrency 1 and guard the collector The stage relied on go-pipeline defaulting to one worker; stating it at the stage makes the constraint visible where someone would change it. The collector takes a mutex too, so the type is safe on its own terms rather than by configuration. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/artwork/resolve.go | 26 +++-- core/artwork/resolve_test.go | 25 +++++ model/album.go | 3 + model/folder.go | 6 +- model/mediafile.go | 3 + persistence/album_repository.go | 33 ++++++ persistence/album_repository_test.go | 75 +++++++++++++- persistence/folder_repository.go | 49 +++++++-- persistence/folder_repository_test.go | 37 +++++++ persistence/mediafile_repository.go | 20 ++++ persistence/mediafile_repository_test.go | 48 +++++++++ scanner/folder_entry.go | 51 ++++++--- scanner/folder_entry_test.go | 6 +- scanner/image_changes.go | 112 ++++++++++++++++++++ scanner/phase_1_folders.go | 34 +++--- scanner/scanner_test.go | 125 +++++++++++++++++++++++ 16 files changed, 602 insertions(+), 51 deletions(-) create mode 100644 scanner/image_changes.go diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 518b6e100..d25f76460 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -9,13 +9,14 @@ import ( "io/fs" "net/url" "os" + "path" "strings" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" ) // resolution is one attempted acquisition outcome for an entity. @@ -248,12 +249,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } // Only consider albums where the artist is the sole album artist. - als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"album_artist_id": artistID}, - squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, - }, - }) + als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)}) if err != nil { return resolution{}, err } @@ -525,6 +521,22 @@ func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern)) } +// IsArtistImageFile reports whether a file name matches any file-glob token of ArtistArtPriority. +// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed. +func IsArtistImageFile(name string) bool { + name = strings.ToLower(name) + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + if pattern == "" || pattern == externalCandidate || pattern == "image-folder" { + continue + } + if ok, _ := path.Match(path.Base(pattern), name); ok { + return true + } + } + return false +} + func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { folder := conf.Server.ArtistImageFolder if folder == "" { diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 25fb727de..8b4c11c8c 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -21,6 +21,31 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("IsArtistImageFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("matches bare and album/-prefixed glob tokens, case-insensitively", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external" + Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue()) + Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue()) + Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) + }) + + It("matches a directory-bearing glob by its basename", func() { + conf.Server.ArtistArtPriority = "images/artist.*, external" + Expect(IsArtistImageFile("artist.jpg")).To(BeTrue()) + Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) + }) + + It("does not treat non-file tokens as globs", func() { + conf.Server.ArtistArtPriority = "image-folder, external" + Expect(IsArtistImageFile("image-folder")).To(BeFalse()) + Expect(IsArtistImageFile("external")).To(BeFalse()) + }) +}) + var _ = Describe("resolveItem", func() { var ( ctx context.Context diff --git a/model/album.go b/model/album.go index f27ca12c0..5a436fec0 100644 --- a/model/album.go +++ b/model/album.go @@ -144,6 +144,9 @@ type AlbumRepository interface { Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetAllIDs(...QueryOptions) ([]string, error) + // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in + // any of the given library-relative subtrees. + GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error) GetCursor(...QueryOptions) (AlbumCursor, error) GetYears(libraryIDs ...int) ([]int, error) diff --git a/model/folder.go b/model/folder.go index 81800c072..5207a9db0 100644 --- a/model/folder.go +++ b/model/folder.go @@ -76,8 +76,10 @@ func NewFolder(lib Library, folderPath string) *Folder { type FolderCursor iter.Seq2[Folder, error] type FolderUpdateInfo struct { - UpdatedAt time.Time - Hash string + UpdatedAt time.Time + Hash string + ImageFiles []string + ImagesUpdatedAt time.Time } type FolderRepository interface { diff --git a/model/mediafile.go b/model/mediafile.go index 888425d07..f4e767272 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -555,6 +555,9 @@ type MediaFileRepository interface { GetCursor(options ...QueryOptions) (MediaFileCursor, error) // GetAllIDs returns just the media_file IDs for the same row set as GetAll. GetAllIDs(options ...QueryOptions) ([]string, error) + // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given + // folders or their direct children. + GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error) // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 486099fc5..2f0621b73 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -270,6 +270,39 @@ func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, er return ids, err } +// soleAlbumArtistFilter matches albums with exactly one album artist. The artist artwork +// resolver and the scanner's image-change enqueue must select the same albums. +var soleAlbumArtistFilter = Eq{"json_array_length(participants, '$.albumartist')": 1} + +// SoleAlbumArtistFilter matches the albums where the given artist is the only album artist. +// Matches by album-artist participation, not the deprecated album_artist_id column. +func SoleAlbumArtistFilter(artistID string) Sqlizer { + return And{ParticipantIDFilter("album", artistID, model.RoleAlbumArtist), soleAlbumArtistFilter} +} + +// GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's +// notion of an album's folders. +func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, paths ...string) ([]string, error) { + if len(paths) == 0 { + return nil, nil + } + ids := []string{} + // Repeated IDs across chunks are fine: the queue upserts by PK. + for chunk := range slices.Chunk(paths, subtreePathChunkSize) { + inSubtree := Exists("json_each(album.folder_ids) je join folder on folder.id = je.value", + folderSubtreeFilter(lib, chunk)) + // Sole album artist, so participants[0] is the only one. + sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album"). + Where(And{soleAlbumArtistFilter, inSubtree}) + var chunkIDs []string + if err := r.queryAllSlice(sq, &chunkIDs); err != nil { + return nil, err + } + ids = append(ids, chunkIDs...) + } + return ids, nil +} + func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { ids, err := r.GetAllIDs(options...) if err != nil { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 061083949..526642aa6 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "errors" "fmt" "sort" @@ -20,9 +21,10 @@ import ( var _ = Describe("AlbumRepository", func() { var albumRepo *albumRepository + var ctx context.Context BeforeEach(func() { - ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) }) @@ -96,6 +98,77 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetSoleAlbumArtistIDsInSubtrees", func() { + It("returns the sole album artists of albums with folders in the subtree", func() { + folderRepo := newFolderRepository(ctx, GetDBXBuilder()) + lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist") + outTree := model.NewFolder(*lib, "OtherTree/Artist") + Expect(folderRepo.Put(inTree)).To(Succeed()) + Expect(folderRepo.Put(outTree)).To(Succeed()) + + // album_artist_id is deliberately wrong: the artist must come from participants + inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID}, + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} + outAl := model.Album{ID: "subtree-out-al", Name: "Out", LibraryID: 1, AlbumArtistID: "3", FolderIDs: []string{outTree.ID}, + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistBeatles}}}} + duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID}, + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}} + for _, al := range []model.Album{inAl, outAl, duoAl} { + Expect(albumRepo.Put(&al)).To(Succeed()) + } + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute() + _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute() + }) + + ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, "SubtreeAlbums") + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded + }) + + It("stays under SQLite's expression tree depth limit with many paths", func() { + lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + paths := make([]string, 200) + for i := range paths { + paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i) + } + + _, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, paths...) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns nothing when given no paths", func() { + lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(BeEmpty()) + }) + }) + + Describe("SoleAlbumArtistFilter", func() { + It("matches only albums where the artist is the sole album artist", func() { + // album_artist_id is deliberately wrong: matching must come from participation + sole := model.Album{ID: "sole-artist-al", Name: "Sole", LibraryID: 1, AlbumArtistID: "999", + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} + duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999", + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}} + Expect(albumRepo.Put(&sole)).To(Succeed()) + Expect(albumRepo.Put(&duo)).To(Succeed()) + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute() + }) + + als, err := albumRepo.GetAll(model.QueryOptions{Filters: SoleAlbumArtistFilter("2")}) + Expect(err).ToNot(HaveOccurred()) + Expect(als).To(HaveLen(1)) + Expect(als[0].ID).To(Equal(sole.ID)) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 5da395a74..a4b73d9d6 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -6,9 +6,7 @@ import ( "fmt" "iter" "maps" - "os" "path" - "path/filepath" "slices" "strings" "time" @@ -147,9 +145,8 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath pathConditions := make(Or, 0, len(targetPaths)*2) for _, targetPath := range targetPaths { - // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes. - cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator)) - cleanPath = filepath.Clean(cleanPath) + // Slash-form like the stored paths; filepath.Clean would backslash them on Windows. + cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/")) // Include the target folder itself by ID folderIDs = append(folderIDs, model.FolderID(lib, cleanPath)) @@ -172,11 +169,13 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath // queryFolderUpdateInfo executes the query and returns the result map func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) { - sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where) + sq := r.newSelect().Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where) var res []struct { - ID string - UpdatedAt time.Time - Hash string + ID string + UpdatedAt time.Time + Hash string + ImageFiles string + ImagesUpdatedAt time.Time } err := r.queryAll(sq, &res) if err != nil { @@ -184,11 +183,41 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol } m := make(map[string]model.FolderUpdateInfo, len(res)) for _, f := range res { - m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash} + info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt} + if f.ImageFiles != "" { + if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil { + return nil, fmt.Errorf("parsing folder image_files: %w", err) + } + } + m[f.ID] = info } return m, nil } +// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds +// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths). +const subtreePathChunkSize = 100 + +// folderSubtreeFilter matches the folders at the given library-relative paths and all their +// descendants. A path of "" or "." selects the whole library, so it drops the path conditions. +func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer { + conds := make(Or, 0, len(paths)*3) + for _, p := range paths { + // Paths are io/fs slash-form; filepath.Clean would backslash them on Windows. + cleanPath := path.Clean(strings.TrimPrefix(p, "/")) + if cleanPath == "." { + return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}} + } + conds = append(conds, + Eq{"folder.id": model.FolderID(lib, cleanPath)}, + // Direct children have path = cleanPath; deeper descendants match the prefix + Eq{"folder.path": cleanPath}, + Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"), + ) + } + return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds} +} + // HasAudioOutsideFolders reports whether any folder in parent's subtree // (including parent itself) contains audio files and is not one of the given // folder IDs. LIKE wildcards in the parent path are escaped, so it is always diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 8cd45f16b..b429cecb9 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -43,6 +44,42 @@ var _ = Describe("FolderRepository", func() { _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute() }) + Describe("folderSubtreeFilter", func() { + var parent, child, grandchild, other *model.Folder + + matching := func(paths ...string) []string { + GinkgoHelper() + folders, err := repo.GetAll(model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(folders, func(f model.Folder) string { return f.ID }) + } + + BeforeEach(func() { + parent = model.NewFolder(testLib, "TestSubtree") + child = model.NewFolder(testLib, "TestSubtree/Child") + grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild") + other = model.NewFolder(testLib, "TestSubtreeOther") + for _, f := range []*model.Folder{parent, child, grandchild, other} { + Expect(repo.Put(f)).To(Succeed()) + } + DeferCleanup(func() { + _, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute() + }) + }) + + It("matches a folder and all its descendants", func() { + Expect(matching("TestSubtree")).To(ConsistOf(parent.ID, child.ID, grandchild.ID)) + }) + + It("matches the descendants of a nested slash-form path", func() { + Expect(matching("TestSubtree/Child")).To(ConsistOf(child.ID, grandchild.ID)) + }) + + It("matches the whole library for the root path", func() { + Expect(matching(".")).To(ContainElements(parent.ID, child.ID, grandchild.ID, other.ID)) + }) + }) + Describe("GetFolderUpdateInfo", func() { Context("with no target paths", func() { It("returns all folders in the library", func() { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 5f0addc5e..8146cba2f 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -318,6 +318,26 @@ func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string return ids, err } +func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs ...string) ([]string, error) { + ids := []string{} + for chunk := range slices.Chunk(folderIDs, 200) { + // A folder's own cover also covers albums whose tracks sit in its disc subfolders. + inFolders := Select("f.id").From("folder f").Where(And{ + Eq{"f.library_id": lib.ID}, + Eq{"f.missing": false}, + Or{Eq{"f.id": chunk}, Eq{"f.parent_id": chunk}}, + }) + sq := Select("distinct album_id").From("media_file"). + Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")}) + var chunkIDs []string + if err := r.queryAllSlice(sq, &chunkIDs); err != nil { + return nil, err + } + ids = append(ids, chunkIDs...) + } + return ids, nil +} + // GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { ids, err := r.GetAllIDs(options...) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index ba89b5def..c1a91c5a5 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -30,6 +30,54 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("GetAlbumIDsByFolder", func() { + var lib model.Library + var albumRoot, disc1, sibling *model.Folder + + BeforeEach(func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + libPtr, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + lib = *libPtr + + folderRepo := newFolderRepository(ctx, GetDBXBuilder()) + albumRoot = model.NewFolder(lib, "ByFolder/Album") + disc1 = model.NewFolder(lib, "ByFolder/Album/CD1") + sibling = model.NewFolder(lib, "ByFolder/Other") + for _, f := range []*model.Folder{albumRoot, disc1, sibling} { + Expect(folderRepo.Put(f)).To(Succeed()) + } + // Tracks live in the disc subfolder; the sibling album is the negative control. + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute() + _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute() + }) + }) + + It("returns the distinct album IDs of non-missing tracks in the folder", func() { + ids, err := mr.GetAlbumIDsByFolder(lib, disc1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf("fol-al-1")) + }) + + It("also matches albums whose tracks are in a direct child of the folder", func() { + // A cover in the album root must reach the album whose tracks sit in CD1 + ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf("fol-al-1")) + }) + + It("does not match albums outside the folder", func() { + ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).ToNot(ContainElement("fol-al-2")) + }) + }) + Describe("GetCursor", func() { It("yields the same media files as GetAll", func() { opts := model.QueryOptions{Sort: "title"} diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index c7cc88ee1..0e893d6e6 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -10,21 +10,24 @@ import ( "slices" "time" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/chrono" ) -func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry { +func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) *folderEntry { f := &folderEntry{ - id: id, - job: job, - path: path, - audioFiles: make(map[string]fs.DirEntry), - imageFiles: make(map[string]fs.DirEntry), - albumIDMap: make(map[string]string), - updTime: updTime, - prevHash: hash, + id: id, + job: job, + path: path, + audioFiles: make(map[string]fs.DirEntry), + imageFiles: make(map[string]fs.DirEntry), + albumIDMap: make(map[string]string), + updTime: info.UpdatedAt, + prevHash: info.Hash, + prevImageFiles: info.ImageFiles, + prevImagesUpdatedAt: info.ImagesUpdatedAt, } return f } @@ -42,12 +45,15 @@ type folderEntry struct { numSubFolders int imagesUpdatedAt time.Time prevHash string // Previous hash from DB - tracks model.MediaFiles - albums model.Albums - albumIDMap map[string]string - artists model.Artists - tags model.TagList - missingTracks []*model.MediaFile + // Previous image state from DB, to detect image-only changes + prevImageFiles []string + prevImagesUpdatedAt time.Time + tracks model.MediaFiles + albums model.Albums + albumIDMap map[string]string + artists model.Artists + tags model.TagList + missingTracks []*model.MediaFile } func (f *folderEntry) hasNoFiles() bool { @@ -69,6 +75,21 @@ func (f *folderEntry) isOutdated() bool { return f.prevHash != f.hash() } +// imagesChanged reports whether the folder's image files differ from the previously persisted +// state, and whether an artist image is involved (present in the old or the new list). +func (f *folderEntry) imagesChanged() (changed, artistImage bool) { + newNames := slices.Sorted(maps.Keys(f.imageFiles)) + prevNames := slices.Sorted(slices.Values(f.prevImageFiles)) + // Both empty also skips the timestamp check, which is noise for image-less folders. + if len(prevNames) == 0 && len(newNames) == 0 { + return false, false + } + if slices.Equal(prevNames, newNames) && f.prevImagesUpdatedAt.Equal(f.imagesUpdatedAt) { + return false, false + } + return true, slices.ContainsFunc(slices.Concat(prevNames, newNames), artwork.IsArtistImageFile) +} + func (f *folderEntry) toFolder() *model.Folder { folder := model.NewFolder(f.job.lib, f.path) folder.NumAudioFiles = len(f.audioFiles) diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index 0328c6653..e8e354b38 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -41,7 +41,7 @@ var _ = Describe("folder_entry", func() { Hash: "previous-hash", } - entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash) + entry := newFolderEntry(job, folderID, path, updateInfo) Expect(entry.id).To(Equal(folderID)) Expect(entry.job).To(Equal(job)) @@ -76,7 +76,7 @@ var _ = Describe("folder_entry", func() { BeforeEach(func() { folderID := model.FolderID(lib, path) - entry = newFolderEntry(job, folderID, path, time.Time{}, "") + entry = newFolderEntry(job, folderID, path, model.FolderUpdateInfo{}) }) Describe("hasNoFiles", func() { @@ -457,7 +457,7 @@ var _ = Describe("folder_entry", func() { // Create new folder entry folderPath := "music/rock/album" folderID := model.FolderID(lib, folderPath) - entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "") + entry := newFolderEntry(job, folderID, folderPath, model.FolderUpdateInfo{}) // Initially new and has no files Expect(entry.isNew()).To(BeTrue()) diff --git a/scanner/image_changes.go b/scanner/image_changes.go new file mode 100644 index 000000000..a9c4de365 --- /dev/null +++ b/scanner/image_changes.go @@ -0,0 +1,112 @@ +package scanner + +import ( + "context" + "sync" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// imageChangedFolder records a folder whose image files changed during the scan, so the +// affected albums/artists can be re-enqueued for artwork resolution at the end of phase 1. +type imageChangedFolder struct { + id string + path string + artistImage bool +} + +// imageChangeCollector gathers those folders per library while phase 1 persists them, then turns +// them into artwork queue items once. +type imageChangeCollector struct { + libs map[int]model.Library + folders map[int][]imageChangedFolder + ds model.DataStore + mutex sync.Mutex +} + +func (c *imageChangeCollector) record(lib model.Library, folder imageChangedFolder) { + c.mutex.Lock() + defer c.mutex.Unlock() + if c.folders == nil { + c.folders = map[int][]imageChangedFolder{} + c.libs = map[int]model.Library{} + } + c.libs[lib.ID] = lib + c.folders[lib.ID] = append(c.folders[lib.ID], folder) +} + +// enqueue is best-effort: failures are logged and never fail the scan. +func (c *imageChangeCollector) enqueue(ctx context.Context) { + c.mutex.Lock() + foldersMap, libsMap := c.folders, c.libs + c.folders, c.libs = nil, nil + c.mutex.Unlock() + + for libID, folders := range foldersMap { + lib := libsMap[libID] + items, err := c.queueItems(ctx, lib, folders) + if err != nil { + log.Warn(ctx, "Scanner: could not map image changes to artwork items", "lib", lib.Name, err) + continue + } + if len(items) == 0 { + continue + } + if err := c.ds.ArtworkQueue(ctx).Enqueue(items...); err != nil { + log.Warn(ctx, "Scanner: could not enqueue artwork for image changes", "lib", lib.Name, err) + continue + } + log.Debug(ctx, "Scanner: Enqueued artwork resolution for image changes", "lib", lib.Name, + "changedFolders", len(folders), "items", len(items)) + } +} + +func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library, + folders []imageChangedFolder, +) ([]model.ArtworkQueueItem, error) { + folderIDs := make([]string, len(folders)) + var artistFolderPaths []string + for i, f := range folders { + folderIDs[i] = f.id + if f.artistImage { + artistFolderPaths = append(artistFolderPaths, f.path) + } + } + + var items []model.ArtworkQueueItem + + albumIDs, err := c.ds.MediaFile(ctx).GetAlbumIDsByFolder(lib, folderIDs...) + if err != nil { + return nil, err + } + for _, id := range albumIDs { + items = append(items, scanArtworkItem(model.KindAlbumArtwork, id)) + } + + if len(artistFolderPaths) == 0 { + return items, nil + } + // The resolver climbs to the library root, so the subtree below the folder is the affected set. + // A failure here must not discard the album items already collected. + artistIDs, err := c.ds.Album(ctx).GetSoleAlbumArtistIDsInSubtrees(lib, artistFolderPaths...) + if err != nil { + log.Warn(ctx, "Scanner: could not map image changes to artists", "lib", lib.Name, err) + return items, nil + } + for _, id := range artistIDs { + if id == "" || id == consts.UnknownArtistID || id == consts.VariousArtistsID { + continue + } + items = append(items, scanArtworkItem(model.KindArtistArtwork, id)) + } + return items, nil +} + +func scanArtworkItem(kind model.Kind, id string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + } +} diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index e853948e6..82e91d5ad 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -45,7 +45,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor jobs = append(jobs, job) } - return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} + return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state, imageChanges: &imageChangeCollector{ds: ds}} } type scanJob struct { @@ -105,7 +105,7 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { func (j *scanJob) createFolderEntry(path string) *folderEntry { id := model.FolderID(j.lib, path) info := j.popLastUpdate(id) - return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash) + return newFolderEntry(j, id, path, info) } // phaseFolders represents the first phase of the scanning process, which is responsible @@ -125,6 +125,7 @@ type phaseFolders struct { ctx context.Context state *scanState prevAlbumPIDConf string + imageChanges *imageChangeCollector } func (p *phaseFolders) description() string { @@ -169,8 +170,10 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { // Check if folder is outdated if folder.isOutdated() { if !p.state.fullScan { - if folder.hasNoFiles() && folder.isNew() { - log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) + // Ancestor folders need a row even with no files of their own: artwork + // resolution climbs them, and an image added later needs a state to diff. + if folder.isEmpty() && folder.isNew() { + log.Trace(p.ctx, "Scanner: Skipping new empty folder", "folder", folder.path, "lib", job.lib.Name) continue } log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) @@ -197,7 +200,8 @@ func (p *phaseFolders) measure(entry *folderEntry) func() time.Duration { func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] { return []ppl.Stage[*folderEntry]{ ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads)), - ppl.NewStage(p.persistChanges, ppl.Name("persist changes")), + // persistChanges is not reentrant, so it always has to run with concurrency=1 + ppl.NewStage(p.persistChanges, ppl.Name("persist changes"), ppl.Concurrency(1)), ppl.NewStage(p.logFolder, ppl.Name("log results")), } } @@ -339,6 +343,15 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) albumRepo := tx.Album(p.ctx) mfRepo := tx.MediaFile(p.ctx) + // A new folder's albums/artists are enqueued below; only pre-existing folders need the diff. + if !entry.isNew() { + if changed, artistImage := entry.imagesChanged(); changed { + p.imageChanges.record(entry.job.lib, imageChangedFolder{ + id: entry.id, path: entry.path, artistImage: artistImage, + }) + } + } + // Save folder to DB folder := entry.toFolder() err := folderRepo.Put(folder) @@ -368,10 +381,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - queueItems = append(queueItems, model.ArtworkQueueItem{ - ItemKind: model.KindArtistArtwork.Prefix(), ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan, - }) + queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID)) } } @@ -383,10 +393,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - queueItems = append(queueItems, model.ArtworkQueueItem{ - ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan, - }) + queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID)) } } @@ -518,6 +525,7 @@ func (p *phaseFolders) finalize(err error) error { } return nil }, "scanner: finalize phaseFolders") + p.imageChanges.enqueue(p.ctx) return errors.Join(err, errF) } diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 6098cbd66..00f91699d 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -278,6 +278,131 @@ var _ = Describe("Scanner", Ordered, func() { }) }) + Context("Library with image files", func() { + var fsys storagetest.FakeFS + image := func(data string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(data)} } + + albumID := func(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1)) + return albums[0].ID + } + artistID := func(name string) string { + GinkgoHelper() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(1)) + return artists[0].ID + } + queuedItems := func() []model.ArtworkQueueItem { + GinkgoHelper() + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + return queued + } + queueItemFor := func(kind, id string) OmegaMatcher { + return ContainElement(SatisfyAll( + HaveField("ItemKind", kind), + HaveField("ItemID", id), + HaveField("Priority", model.ArtworkPriorityScan), + )) + } + + BeforeEach(func() { + revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) + wall := template(_t{"albumartist": "Pink Floyd", "album": "The Wall", "year": 1979}) + fsys = createFS(fstest.MapFS{ + "The Beatles/artist.jpg": image("beatles-artist-v1"), + "The Beatles/Revolver/cover.jpg": image("revolver-cover-v1"), + "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), + "Pink Floyd/The Wall/cover.jpg": image("wall-cover-v1"), + "Pink Floyd/The Wall/CD1/01 - In the Flesh.mp3": wall(track(1, "In the Flesh?")), + "Pink Floyd/The Wall/CD2/01 - Hey You.mp3": wall(track(1, "Hey You")), + }) + Expect(runScanner(ctx, true)).To(Succeed()) + resolveQueuedArtwork() + }) + + It("re-enqueues only the album whose cover was replaced in place", func() { + fsys.Add("The Beatles/Revolver/cover.jpg", image("revolver-cover-v2")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + queued := queuedItems() + Expect(queued).To(queueItemFor("al", albumID("Revolver"))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", albumID("The Wall")))) + Expect(queued).ToNot(ContainElement(HaveField("ItemKind", "ar"))) + }) + + It("re-enqueues the album when the cover above its disc folders changes", func() { + fsys.Add("Pink Floyd/The Wall/cover.jpg", image("wall-cover-v2")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("al", albumID("The Wall"))) + }) + + It("re-enqueues the album when its cover is removed", func() { + fsys.Remove("The Beatles/Revolver/cover.jpg") + + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("al", albumID("Revolver"))) + }) + + It("enqueues the artist when an artist image is added to their folder", func() { + fsys.Add("Pink Floyd/artist.jpg", image("floyd-artist-v1")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + queued := queuedItems() + Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", artistID("The Beatles")))) + }) + + It("re-enqueues the artist when their artist image is replaced in place", func() { + fsys.Add("The Beatles/artist.jpg", image("beatles-artist-v2")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("ar", artistID("The Beatles"))) + }) + + It("enqueues every artist under the folder when a shared artist image is added", func() { + fsys.Add("artist.png", image("shared-artist-v1")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + queued := queuedItems() + Expect(queued).To(queueItemFor("ar", artistID("The Beatles"))) + Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) + }) + + It("enqueues the artist when an image lands in a folder first seen by a quick scan", func() { + // A quick scan must persist an artist folder that holds only subfolders, or the + // artist.jpg added later has no previous state to diff against. + kraftwerk := template(_t{"albumartist": "Kraftwerk", "album": "Autobahn", "year": 1974}) + files := fsys.MapFS + files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn")) + fsys.SetFiles(files) + Expect(runScanner(ctx, false)).To(Succeed()) + resolveQueuedArtwork() + + fsys.Add("Kraftwerk/artist.jpg", image("kraftwerk-artist-v1")) + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("ar", artistID("Kraftwerk"))) + }) + + It("does not enqueue anything on a repeat full scan with no image changes", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + Expect(queuedItems()).To(BeEmpty()) + }) + }) + Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() { BeforeEach(func() { goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018}) From ea1e2b95a70074a8e4e98074736f13f0b5ea72a5 Mon Sep 17 00:00:00 2001 From: hotorcelexo <IgorPolyakov@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:14:41 +0300 Subject: [PATCH 442/596] fix(db): keep album created_at in the driver's timestamp format when copying (#5867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): keep album created_at in the driver's timestamp format when copying Signed-off-by: IgorPolyakov <igorpolyakov@protonmail.com> * fix(db): move created_at renormalize migration after merged migrations The migration was versioned 20260813140000, which is older than 20260815015320 (already merged). goose.UpContext runs without WithAllowMissing, so any database that already applied the newer migration would fail with "found 1 missing migrations" and db.Init would log.Fatal on startup. --------- Signed-off-by: IgorPolyakov <igorpolyakov@protonmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ...816180040_renormalize_album_created_at.sql | 11 ++++++++ persistence/album_repository.go | 5 +++- persistence/album_repository_test.go | 26 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20260816180040_renormalize_album_created_at.sql diff --git a/db/migrations/20260816180040_renormalize_album_created_at.sql b/db/migrations/20260816180040_renormalize_album_created_at.sql new file mode 100644 index 000000000..9af7b2e5a --- /dev/null +++ b/db/migrations/20260816180040_renormalize_album_created_at.sql @@ -0,0 +1,11 @@ +-- +goose Up + +-- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes. +-- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings. + +UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') +WHERE created_at LIKE '%T%'; + +-- +goose Down + +SELECT 1; diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 2f0621b73..5d7aad22e 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -329,8 +329,11 @@ func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { } func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { + // Cast values to text so go-sqlite3 does not decode datetime columns as time.Time + // and reformat them as RFC3339 when written back. + sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) }) var from dbx.NullStringMap - err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) + err := r.queryOne(Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from) if err != nil { return fmt.Errorf("getting album to copy fields from: %w", err) } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 526642aa6..f6768768d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -19,6 +19,16 @@ import ( . "github.com/onsi/gomega" ) +// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of +// `datetime` columns into time.Time. +func rawColumn(r sqlRepository, id, column string) string { + var res struct{ Value string } + sel := squirrel.Select("cast(" + column + " as text) as value"). + From(r.tableName).Where(squirrel.Eq{"id": id}) + ExpectWithOffset(1, r.queryOne(sel, &res)).To(Succeed()) + return res.Value +} + var _ = Describe("AlbumRepository", func() { var albumRepo *albumRepository var ctx context.Context @@ -69,6 +79,22 @@ var _ = Describe("AlbumRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) }) + It("returns not found and leaves destination untouched when source does not exist", func() { + err := albumRepo.CopyAttributes("copy-missing", "copy-dst", "created_at") + Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue()) + got, getErr := albumRepo.Get("copy-dst") + Expect(getErr).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) + }) + It("keeps the copied created_at in the driver's space-separated format", func() { + // Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"), + // which string-sorts above every space-format timestamp and pins the album to the + // top of "Recently Added". + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) + Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")). + To(Equal(rawColumn(albumRepo.sqlRepository, "copy-src", "created_at"))) + Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T")) + }) }) Describe("GetCursor", func() { From 65751d7665e3f1a85c96f661a10227a77bb655d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 17 Aug 2026 15:47:41 -0400 Subject: [PATCH 443/596] fix(playlists): chunk track deletes to stay under the SQLite variable limit (#5977) PlaylistTrackRepository.Delete built a single IN clause with one bind variable per track, so removing more tracks than SQLITE_MAX_VARIABLE_NUMBER (32766) failed with "too many SQL variables". Clients that sync a large playlist by adding the desired tracks and then removing the stale ones would get the add committed and the removal rejected, leaving the playlist with both sets of tracks and growing it on every sync. Delete now works in chunks of 200, the same size addTracks already uses, and renumbers once after the last chunk. Both callers already run inside a transaction, so the delete stays atomic. --- persistence/playlist_track_repository.go | 11 ++-- persistence/playlist_track_repository_test.go | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index c1f6fcf69..a5e1975fd 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -2,6 +2,7 @@ package persistence import ( "database/sql" + "slices" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -224,10 +225,14 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { return r.addMediaFileIds(clauses) } +// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks. +const deleteChunkSize = 200 + func (r *playlistTrackRepository) Delete(ids ...string) error { - err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}}) - if err != nil { - return err + for chunk := range slices.Chunk(ids, deleteChunkSize) { + if err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil { + return err + } } return r.playlistRepo.renumber(r.playlistId) diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index a5c67b92c..88ddbff48 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -1,6 +1,8 @@ package persistence import ( + "strconv" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -8,6 +10,9 @@ import ( . "github.com/onsi/gomega" ) +// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver +const sqliteMaxVariables = 32766 + var _ = Describe("PlaylistTrackRepository", func() { var repo model.PlaylistTrackRepository @@ -72,4 +77,49 @@ var _ = Describe("PlaylistTrackRepository", func() { To(Equal([]string{songRadioactivity.ID})) }) }) + + Describe("Delete", func() { + var tracks model.PlaylistTrackRepository + const numTracks = deleteChunkSize*2 + 1 + + positionsUpTo := func(n int) []string { + positions := make([]string, 0, n) + for i := 1; i <= n; i++ { + positions = append(positions, strconv.Itoa(i)) + } + return positions + } + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + + pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + songIds := make([]string, numTracks) + for i := range songIds { + songIds[i] = songDayInALife.ID + } + Expect(tracks.Add(songIds)).To(Equal(numTracks)) + }) + + It("removes positions spanning several chunks, and renumbers what is left", func() { + Expect(tracks.Delete(positionsUpTo(numTracks - 1)...)).To(Succeed()) + + Expect(tracks.CountAll()).To(Equal(int64(1))) + remaining, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1") + }) + + It("accepts more ids than SQLite allows as bind variables", func() { + Expect(tracks.Delete(positionsUpTo(sqliteMaxVariables + 100)...)).To(Succeed()) + + Expect(tracks.CountAll()).To(BeZero()) + }) + }) }) From 4c0ab074a36f0450a4ac618e8a74ae8d18eeb4e7 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 17 Aug 2026 20:36:13 -0400 Subject: [PATCH 444/596] chore(deps): update module dependencies in `go.mod` and `go.sum` to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 58 ++++++++++++------------ go.sum | 136 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 96 insertions(+), 98 deletions(-) diff --git a/go.mod b/go.mod index b72f4035c..65a01a9b5 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 - github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 + github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 github.com/djherbis/stream v1.4.0 @@ -26,7 +26,7 @@ require ( github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/gohugoio/hashstructure v0.6.0 + github.com/gohugoio/hashstructure v1.0.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 @@ -35,37 +35,37 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.1 github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.48 + github.com/lestrrat-go/jwx/v3 v3.2.0 + github.com/mattn/go-sqlite3 v1.14.50 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/ginkgo/v2 v2.32.1 github.com/onsi/gomega v1.42.1 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.2 - github.com/prometheus/client_golang v1.23.2 + github.com/pressly/goose/v3 v3.27.3 + github.com/prometheus/client_golang v1.24.1 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/sirupsen/logrus v1.9.4 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/sirupsen/logrus v1.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.0 github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.44.0 - golang.org/x/net v0.57.0 + golang.org/x/image v0.45.0 + golang.org/x/net v0.58.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -79,22 +79,21 @@ require ( github.com/cespare/reflex v0.3.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.10.1 // indirect + github.com/ebitengine/purego v0.10.2 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect + github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect @@ -114,13 +113,13 @@ require ( github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.20.1 // indirect - github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -129,15 +128,14 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect - golang.org/x/tools v0.48.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 29983a27d..c11f4bb20 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,6 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= @@ -37,8 +35,8 @@ github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcH 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= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= -github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= -github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= +github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA= +github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= @@ -53,8 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= -github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -79,8 +77,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= @@ -96,15 +94,15 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= -github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg= +github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -122,8 +120,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= -github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30= +github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= @@ -136,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -161,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= -github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA= +github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= -github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY= +github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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= @@ -187,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= -github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= +github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= @@ -201,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/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/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= -github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -218,8 +216,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= -github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -227,15 +225,15 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= -github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc= +github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -267,8 +265,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= @@ -293,27 +292,28 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -322,26 +322,26 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= @@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= -modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= +modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= -modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= From 4b1218eec0068ba8b119a970385aea5b0605660b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 18 Aug 2026 09:32:26 -0400 Subject: [PATCH 445/596] feat(ui): show translation completion percentage in the language selector (#5979) The language selector now shows how complete each translation is, so users can see at a glance which languages are lagging behind English. The native API's translation resource gained a termCount field holding the number of non-empty terms in each language file; the UI divides that by the term count of the bundled English file to get the percentage. The percentage is wrapped in a Unicode left-to-right isolate, otherwise it renders as "(%61)" beside right-to-left names such as Arabic and Persian. Sorting runs on the plain language name, before the percentage is appended. This also fixes prepareLanguage() mutating the bundled English translations: for the English locale it received the shared en object and aliased albumSong and playlistTrack onto it, growing en by 94 keys at runtime. That inflated the denominator and made every language read about 14 points low. The aliases now go on the merged copy instead. --- server/nativeapi/translations.go | 36 +++++++-- server/nativeapi/translations_test.go | 12 +++ ui/src/i18n/provider.js | 11 +-- ui/src/i18n/provider.test.js | 19 +++++ ui/src/i18n/useGetLanguageChoices.jsx | 31 +++++++- ui/src/i18n/useGetLanguageChoices.test.jsx | 92 ++++++++++++++++++++++ 6 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 ui/src/i18n/provider.test.js create mode 100644 ui/src/i18n/useGetLanguageChoices.test.jsx diff --git a/server/nativeapi/translations.go b/server/nativeapi/translations.go index 685713083..39d071279 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -17,9 +17,10 @@ import ( ) type translation struct { - ID string `json:"id"` - Name string `json:"name"` - Data string `json:"data"` + ID string `json:"id"` + Name string `json:"name"` + Data string `json:"data"` + TermCount int `json:"termCount"` } func newTranslationRepository(context.Context) rest.Repository { @@ -97,27 +98,46 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err // Load translation from json file file, err := fsys.Open(filePath) if err != nil { - return + return translation, err } data, err := io.ReadAll(file) if err != nil { - return + return translation, err } var out map[string]any if err = json.Unmarshal(data, &out); err != nil { - return + return translation, err } // Compress JSON buf := new(bytes.Buffer) if err = json.Compact(buf, data); err != nil { - return + return translation, err } translation.Data = buf.String() translation.Name = out["languageName"].(string) translation.ID = id - return + translation.TermCount = countTranslatedTerms(out) + return translation, nil +} + +// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term +func countTranslatedTerms(obj map[string]any) int { + count := 0 + for _, v := range obj { + switch v := v.(type) { + case map[string]any: + count += countTranslatedTerms(v) + case string: + if v != "" { + count++ + } + default: + count++ + } + } + return count } var _ rest.Repository = (*translationRepository)(nil) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 6c834070c..77c088f6f 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "testing/fstest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" @@ -45,5 +46,16 @@ var _ = Describe("Translations", func() { var out map[string]any Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil()) }) + + It("counts only non-empty leaf terms", func() { + fsys := fstest.MapFS{ + "i18n/test.json": &fstest.MapFile{ + Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`), + }, + } + tr, err := loadTranslation(fsys, "test.json") + Expect(err).To(BeNil()) + Expect(tr.TermCount).To(Equal(3)) + }) }) }) diff --git a/ui/src/i18n/provider.js b/ui/src/i18n/provider.js index f17a5b4ac..58f1782bb 100644 --- a/ui/src/i18n/provider.js +++ b/ui/src/i18n/provider.js @@ -42,13 +42,14 @@ const removeEmpty = (obj) => { const prepareLanguage = (lang) => { removeEmpty(lang) + // Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline + const merged = deepmerge(en, lang) // Make "albumSong" and "playlistTrack" resource use the same translations as "song" - lang.resources.albumSong = lang.resources.song - lang.resources.playlistTrack = lang.resources.song + merged.resources.albumSong = merged.resources.song + merged.resources.playlistTrack = merged.resources.song // ra.boolean.null should always be empty - lang.ra.boolean.null = '' - // Fallback to english translations - return deepmerge(en, lang) + merged.ra.boolean.null = '' + return merged } export default polyglotI18nProvider((locale) => { diff --git a/ui/src/i18n/provider.test.js b/ui/src/i18n/provider.test.js new file mode 100644 index 000000000..82b593788 --- /dev/null +++ b/ui/src/i18n/provider.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest' +import en from './en.json' + +vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } })) + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) + +describe('i18n provider', () => { + it('does not mutate the bundled English translations', async () => { + const before = countLeaves(en) + await import('./provider') + expect(countLeaves(en)).toEqual(before) + }) +}) diff --git a/ui/src/i18n/useGetLanguageChoices.jsx b/ui/src/i18n/useGetLanguageChoices.jsx index 0c708691f..38f95e422 100644 --- a/ui/src/i18n/useGetLanguageChoices.jsx +++ b/ui/src/i18n/useGetLanguageChoices.jsx @@ -1,5 +1,22 @@ // React Hook to get a list of all languages available. English is hardcoded import { useGetList } from 'react-admin' +import en from './en.json' + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) + +const enTermCount = countLeaves(en) + +const withPercentage = ({ id, name, termCount }) => { + if (!termCount) return { id, name } + const pct = Math.min(100, Math.round((100 * termCount) / enTermCount)) + // Isolate the percentage, or it renders as "(%61)" next to a right-to-left name + return { id, name: `${name} ⁦(${pct}%)⁩` } +} const useGetLanguageChoices = () => { const { ids, data, loaded, loading } = useGetList( @@ -9,13 +26,19 @@ const useGetLanguageChoices = () => { {}, ) - const choices = [{ id: 'en', name: 'English' }] + const languages = [{ id: 'en', name: 'English', termCount: enTermCount }] if (loaded) { - ids.forEach((id) => choices.push({ id: id, name: data[id].name })) + ids.forEach((id) => + languages.push({ + id, + name: data[id].name, + termCount: data[id].termCount, + }), + ) } - choices.sort((a, b) => a.name.localeCompare(b.name)) + languages.sort((a, b) => a.name.localeCompare(b.name)) - return { choices, loaded, loading } + return { choices: languages.map(withPercentage), loaded, loading } } export default useGetLanguageChoices diff --git a/ui/src/i18n/useGetLanguageChoices.test.jsx b/ui/src/i18n/useGetLanguageChoices.test.jsx new file mode 100644 index 000000000..b8214868e --- /dev/null +++ b/ui/src/i18n/useGetLanguageChoices.test.jsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react-hooks' +import { useGetList } from 'react-admin' +import en from './en.json' +import useGetLanguageChoices from './useGetLanguageChoices' + +vi.mock('react-admin', () => ({ + useGetList: vi.fn(), +})) + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) +const enTermCount = countLeaves(en) + +// The percentage is wrapped in a left-to-right isolate, so it reads the same +// next to right-to-left language names +const label = (pct) => `⁦(${pct}%)⁩` + +const mockLanguages = (languages) => { + const data = {} + languages.forEach((l) => (data[l.id] = l)) + useGetList.mockReturnValue({ + ids: languages.map((l) => l.id), + data, + loaded: true, + loading: false, + }) +} + +const choiceFor = (id) => { + const { result } = renderHook(() => useGetLanguageChoices()) + return result.current.choices.find((c) => c.id === id) +} + +describe('useGetLanguageChoices', () => { + it('appends the completion percentage to incomplete languages', () => { + const termCount = Math.round(enTermCount * 0.62) + mockLanguages([{ id: 'cs', name: 'Čeština', termCount }]) + + const pct = Math.round((100 * termCount) / enTermCount) + expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`) + }) + + it('shows 100% for a complete language', () => { + mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }]) + + expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`) + }) + + it('caps the percentage at 100 when a language has extra terms', () => { + mockLanguages([ + { id: 'pt', name: 'Português', termCount: enTermCount + 20 }, + ]) + + expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`) + }) + + it('isolates the percentage next to a right-to-left name', () => { + const termCount = Math.round(enTermCount * 0.61) + mockLanguages([{ id: 'ar', name: 'العربية', termCount }]) + + const pct = Math.round((100 * termCount) / enTermCount) + expect(choiceFor('ar').name).toEqual(`العربية ⁦(${pct}%)⁩`) + }) + + it('omits the percentage when the server does not send a term count', () => { + mockLanguages([{ id: 'fr', name: 'Français' }]) + + expect(choiceFor('fr').name).toEqual('Français') + }) + + it('shows 100% for the bundled English', () => { + mockLanguages([]) + + expect(choiceFor('en').name).toEqual(`English ${label(100)}`) + }) + + it('sorts by language name, ignoring the percentage', () => { + mockLanguages([ + { id: 'no', name: 'Norsk', termCount: enTermCount }, + { id: 'da', name: 'Dansk', termCount: 1 }, + ]) + + const { result } = renderHook(() => useGetLanguageChoices()) + + expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no']) + }) +}) From 2e03766a9df84cb24303bd2ebc808402ad7c1a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 18 Aug 2026 20:58:55 -0400 Subject: [PATCH 446/596] fix(playlist): preserve smart playlist song count on re-import (#5907) (#5908) * fix(playlist): preserve smart playlist counters on re-import (#5907) * perf(playlist): skip re-importing unchanged NSP files (#5907) * feat(playlist): also store content hash for M3U imports (unused for now) * fix(playlist): return stored record when skipping unchanged NSP import Skipping before copying the stored identity broke the ImportFile(sync=false) contract: callers received an ID-less playlist and the requested Sync change was silently dropped. * refactor(playlist): hash imports once at the caller; protect smart counters in Put Move content hashing out of both parsers into the code that owns the file (parsePlaylist and ImportFile), removing the NSP double-buffer and the duplicated hashing idiom. Put now drops song_count/duration/size for smart playlists (PostMapArgs), disarming the counter-zeroing trap for all callers. * fix(playlist): invalidate imported hash when rules are edited via API Without this, a rules edit through the REST API kept the stored file hash, so every scan skipped the unchanged file and never restored the file-backed rules while sync was on. * test(playlist): verify smart counters survive a re-import, end to end The existing Put test seeds the stored counters with a raw SQL update, so it pins the guard in PostMapArgs but not the pipeline around it. This test drives the counters through a real evaluation instead: it saves a smart playlist, reads it with GetWithTracks to populate song_count/duration/size, then saves the playlist the way the scanner rebuilds it after parsing the .nsp file, with the counters back at zero. Both routes fail without the guard, and the new one covers the exact sequence reported in #5907. Test taken from #5970, which diagnosed the same root cause independently. Co-authored-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> * test(playlist): build the service with artwork.NewUploader The artwork pipeline in #5847 replaced core.NewImageUploadService() with artwork.NewUploader(ds) and updated every call site it could see. The five call sites this branch adds were written against the old constructor, so the merge applied cleanly but left the package uncompilable. * fix(db): re-stamp the imported_hash migration after the master merge Master gained three migrations while this branch was open, the newest being 20260816180040. The original 20260808200333 stamp now sorts before them, so any database already upgraded past that point would skip this migration entirely and never get the imported_hash column. Same SQL, current timestamp. * refactor(playlist): hash imported playlists with xxh3 and the id encoding ImportedHash is a change detector, not a security boundary, so it does not need a cryptographic digest. xxh3 is already a direct dependency and is used the same way to fingerprint files in the artwork image store. Encoding the 128-bit digest with id.Encode stores it in the same 22-char base62 form as every other id in the schema, down from 64 hex chars. No migration is needed: the imported_hash column has not shipped in a release, so no database holds a value in the old format. * refactor(playlist): extract the imported-playlist fingerprint helper Both import paths encoded the hash inline, so how a playlist file is fingerprinted lived in two places. A third import path that encoded it differently would silently never match the stored value, turning the unchanged-file skip into a no-op. --------- Co-authored-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> --- core/playlists/import.go | 32 +++- core/playlists/import_test.go | 143 ++++++++++++++++++ core/playlists/rest_adapter.go | 1 + core/playlists/rest_adapter_test.go | 19 +++ ...60818002312_add_playlist_imported_hash.sql | 5 + model/playlist.go | 1 + persistence/playlist_repository.go | 4 + persistence/playlist_repository_test.go | 27 ++++ persistence/smart_playlist_repository_test.go | 35 +++++ 9 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 db/migrations/20260818002312_add_playlist_imported_hash.sql diff --git a/core/playlists/import.go b/core/playlists/import.go index bafb870cd..e41f61bd1 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -12,8 +12,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/ioutils" + "github.com/zeebo/xxh3" "golang.org/x/text/unicode/norm" ) @@ -57,10 +59,12 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo } defer file.Close() - reader := ioutils.UTF8Reader(file) + hasher := xxh3.New() + reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) if err := s.parseM3U(ctx, pls, nil, reader); err != nil { return nil, err } + pls.ImportedHash = fingerprint(hasher) if err := s.updatePlaylist(ctx, pls, sync); err != nil { return nil, err } @@ -138,7 +142,9 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold } defer file.Close() - reader := ioutils.UTF8Reader(file) + // Hash the bytes the parser consumes, giving every imported playlist a content fingerprint + hasher := xxh3.New() + reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) extension := strings.ToLower(filepath.Ext(playlistFile)) switch extension { case ".nsp": @@ -146,7 +152,15 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold default: err = s.parseM3U(ctx, pls, folder, reader) } - return pls, err + if err != nil { + return pls, err + } + pls.ImportedHash = fingerprint(hasher) + return pls, nil +} + +func fingerprint(h *xxh3.Hasher) string { + return id.Encode(h.Sum128().Bytes()) } // findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode @@ -179,6 +193,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, } if err == nil { + // Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve. + if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash { + log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path) + *newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync + return nil + } log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) newPls.ID = pls.ID newPls.Name = pls.Name @@ -187,6 +207,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.Public = pls.Public newPls.UploadedImage = pls.UploadedImage // Preserve manual upload newPls.EvaluatedAt = nil // force re-evaluation on next read + if newPls.IsSmartPlaylist() { + // Tracks aren't materialized at parse time; carry the stored counters so callers see real values + newPls.SongCount = pls.SongCount + newPls.Duration = pls.Duration + newPls.Size = pls.Size + } } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) newPls.OwnerID = owner.ID diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 2a84d56b2..445561266 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -15,10 +15,12 @@ import ( "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/zeebo/xxh3" "golang.org/x/text/unicode/norm" ) @@ -307,6 +309,33 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ID).To(BeEmpty()) }) + It("stores a content hash but re-imports unchanged M3U playlists", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + + first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(first.ImportedHash).ToNot(BeEmpty()) + + // Re-import with a matching stored hash: M3U must still be re-imported, not skipped. + existingPls := &model.Playlist{ + ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true, + OwnerID: "123", ImportedHash: first.ImportedHash, + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + mockPlsRepo.Last = nil + + _, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) + }) + It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) @@ -371,6 +400,85 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true }) + + It("preserves counters when re-importing an existing smart playlist", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", + Name: "My Smart", + Path: plsFile, + Sync: true, + OwnerID: "123", + SongCount: 42, + Duration: 123.4, + Size: 5000, + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") + + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) + Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue()) + Expect(mockPlsRepo.Last.SongCount).To(Equal(42)) + Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4))) + Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000))) + }) + + It("skips re-import when the smart playlist file content is unchanged", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, + OwnerID: "123", SongCount: 42, + ImportedHash: hashOf(nsp), + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") + + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written + }) + + It("re-imports when the smart playlist file content changed", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, + OwnerID: "123", SongCount: 42, + ImportedHash: hashOf("old content"), + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") + + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed + Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp))) + }) }) DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", @@ -760,6 +868,37 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ID).To(Equal("existing-id")) Expect(pls.Sync).To(BeTrue()) }) + + It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, + OwnerID: "123", SongCount: 42, + ImportedHash: hashOf(nsp), + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).To(Equal("smart-id")) + Expect(pls.Sync).To(BeFalse()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) }) Describe("ImportM3U", func() { @@ -1083,3 +1222,7 @@ func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.F } return nil, model.ErrNotFound } + +func hashOf(content string) string { + return id.Encode(xxh3.Hash128([]byte(content)).Bytes()) +} diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index f34524e27..ca7cfb0cc 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -136,6 +136,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod if rulesChanged { current.Rules = entity.Rules current.EvaluatedAt = nil // force re-evaluation on next read + current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it } if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 7ae376b07..fbfab350c 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -160,6 +160,25 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) + It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() { + mockPlsRepo.Data["smart-1"] = &model.Playlist{ + ID: "smart-1", + Name: "Smart Playlist", + OwnerID: "user-1", + Path: "/music/smart.nsp", + Sync: true, + ImportedHash: hashOf("file content"), + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} + pls := &model.Playlist{Rules: newRules} + err := repo.Update("smart-1", pls, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty()) + }) + It("allows toggling sync for file-backed playlists", func() { originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) mockPlsRepo.Data["file-pls"] = &model.Playlist{ diff --git a/db/migrations/20260818002312_add_playlist_imported_hash.sql b/db/migrations/20260818002312_add_playlist_imported_hash.sql new file mode 100644 index 000000000..aab729153 --- /dev/null +++ b/db/migrations/20260818002312_add_playlist_imported_hash.sql @@ -0,0 +1,5 @@ +-- +goose Up +alter table playlist add imported_hash varchar default '' not null; + +-- +goose Down +alter table playlist drop column imported_hash; diff --git a/model/playlist.go b/model/playlist.go index 9aa54bf13..55b94a640 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -31,6 +31,7 @@ type Playlist struct { ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ImportedHash string `structs:"imported_hash" json:"-"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 081dab3fa..cf54c6d5a 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -39,6 +39,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error { if err != nil { return fmt.Errorf("invalid criteria expression: %w", err) } + // Smart playlist counters are owned by refreshCounters (evaluation), never by callers + delete(args, "song_count") + delete(args, "duration") + delete(args, "size") return nil } delete(args, "rules") diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index fc2d4ae3f..9697e6fff 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -7,6 +7,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" @@ -230,6 +231,32 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Put", func() { + It("does not overwrite counters when saving a smart playlist", func() { + pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "love"}}, + }} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(repo.Delete(pls.ID)).To(Succeed()) }) + + // Simulate a previous evaluation having stored the counters + _, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}"). + Bind(dbx.Params{"id": pls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + pls.SongCount = 0 + pls.Duration = 0 + pls.Size = 0 + Expect(repo.Put(&pls)).To(Succeed()) + + saved, err := repo.Get(pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.SongCount).To(Equal(42)) + Expect(saved.Duration).To(Equal(float32(123))) + Expect(saved.Size).To(Equal(int64(456))) + }) + }) + It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index e62436890..ddc155fab 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -58,6 +58,41 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Context("re-imported from disk", func() { + // The scanner re-imports every playlist in a touched folder, and a freshly parsed + // .nsp carries no counters — saving it must not wipe the ones already evaluated. + It("keeps the stored counters when a freshly parsed playlist is saved over it", func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Antenna"}, + }, + } + pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + evaluated, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(evaluated.SongCount).To(BeNumerically(">", 0)) + + stored, err := repo.Get(pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.SongCount).To(Equal(evaluated.SongCount)) + + reimported := model.Playlist{ + ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules, + Path: pls.Path, Sync: true, + } + Expect(repo.Put(&reimported)).To(Succeed()) + + afterImport, err := repo.Get(pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(afterImport.SongCount).To(Equal(stored.SongCount)) + Expect(afterImport.Duration).To(Equal(stored.Duration)) + Expect(afterImport.Size).To(Equal(stored.Size)) + }) + }) + Context("child smart playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) From 7a11ca69bbfd3465ef4ce2af52966a97c51de77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 19 Aug 2026 08:36:44 -0400 Subject: [PATCH 447/596] fix(jellyfin): honor the Filters, SortBy and MaxHeight params clients actually send (#5981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): honor Filters=IsFavorite on /Artists and /Artists/AlbumArtists listArtistsByRole hand-built its itemsQuery and never set favOnly, so the favorites filter was silently dropped on both artist routes while /Items honored it. Finamp's home screen asks for favorite artists once per load and was served the entire artist list instead: 10,298 artists, 6.15 MB, 2.7s on a real library, and the wrong data on screen. Extract the favOnly parsing that parseItemsQuery already did into parseFavOnly and use it in both places. listArtists now adds the starred predicate to notMissing rather than replacing it, matching listAlbums and listSongs, so a favorite artist whose files are gone stays excluded. * fix(jellyfin): map SortBy=Runtime to duration for albums and songs sortColumnsByType had no runtime/runtimeticks key for any type, so Finamp's "Duration" sort silently misbehaved in two different ways. Albums: Finamp sends a bare SortBy=Runtime. Nothing matched, opts.Sort stayed empty, and applyOptions skips OrderBy entirely when Sort is empty — so the query ran with no ORDER BY at all and Ascending and Descending returned identical lists. Songs: Finamp sends SortBy=Runtime,AlbumArtist,Album,SortName. applySort takes the first *recognized* key, so Runtime was skipped and the list came back sorted by album artist while looking correct. Both repos already accept a duration sort (mediafile_repository maps it explicitly; album_repository falls through to the column name), so no migration is needed. Sorting 97k songs by duration costs a temp B-tree (~114ms on a prod-sized copy) — the same cost the Subsonic and UI duration sorts already pay, and correct where the previous behaviour was merely fast. * fix(jellyfin): apply the played/unplayed filters and MaxHeight image bound Filters was matched with a substring test for IsFavorite, so every other token Jellyfin defines was silently dropped and the response kept rows it should have excluded. Finamp sends Filters=IsUnplayed in normal use. Replace the bool with a parsed itemFilters carrying nullable favorite and played flags, so isFavorite=false and isPlayed=false are real filters rather than indistinguishable from an absent param. Standalone params are read first and the Filters list overrides them, the precedence real Jellyfin has. IsFavoriteOrLikes now maps to favorites deliberately instead of by substring accident; Likes, Dislikes, IsFolder, IsNotFolder and IsResumable have no Navidrome equivalent and are dropped rather than half-applied. The negative cases match NULL as well, since annotations are LEFT JOINed and an untouched item has no row. getItemImage read only maxwidth, so a client sending just MaxHeight got the full-size original: measured against a real cover, maxHeight=100 returned 82,570 bytes where maxWidth=100 returned 3,316. Use the tighter of the two bounds. * refactor(jellyfin): share the plain-param parser between /Items and /Artists listArtistsByRole hand-listed the itemsQuery fields it happened to need, which is exactly how the favorites filter went missing: the literal has been amended in four of the five commits that touched it. Extract listParams for the fields that come straight from query params so both paths read one parser, and the next supported param reaches every list path instead of only /Items. Also from the cleanup pass: collapse imageSize to a single clamped comparison and read its bounds through req.Params like the rest of the package, which drops the strconv import; build the artist and playlist filter lists with the flat append shape the album and song paths already use, instead of re-wrapping opts.Filters into a nested And per predicate; drop a nil guard in listPlaylists that no caller can reach, since both paths into queryItemsOfType build QueryOptions without Filters. applySort now logs when no SortBy key resolves at all — a miss inside a fallback list is normal, but none matching means a silently ignored sort, the failure mode that hid the Runtime bug. Its doc comment records why the remaining keys cannot simply be joined. Folds three duplicated test bodies into the tables that already parameterize them, and covers the artist-parent album branch, which reaches notMissing through filter.AlbumsByArtistID rather than the default branch. * docs(jellyfin): correct how applySort describes Jellyfin's SortBy semantics The comment claimed SortBy is a comma-separated fallback list. It is not: RequestHelpers.GetOrderBy (10.10) builds one (ItemSortBy, SortOrder) pair per key, so Jellyfin orders by every key in turn. Navidrome applies only the first recognized one, which is a real divergence — secondary keys never break ties — not the intended reading of the parameter. The assertion that the keys cannot be joined was also wrong. buildSortOrder does split its input on commas; what it maps is the whole string, so joining raw Jellyfin key names misses the mappings. Mapping each key first and joining the results would work, which makes multi-key sorting a real option rather than a blocked one. Documenting the current behaviour as a known divergence until then. * fix(jellyfin): order by every recognized SortBy key, not just the first Jellyfin orders by each SortBy key in turn, so "DatePlayed,SortName" means break ties by name. Navidrome applied only the first recognized key and dropped the rest, which is 28% of the sort traffic on a real server (23 of 82 requests in 12h carry 2-5 keys). Most were harmless because the primary key dominates, but PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName came back unordered within a year. The keys cannot simply be joined: sortMapping keyed on the whole Sort string, so a joined value missed every mapping and fell through to raw column names. Make it resolve a comma list per part, but only when every part is a known key — the four existing callers that pass raw column lists (core/matcher, core/lyrics, core/maintenance, subsonic/browsing) all carry a part that is not a mapping key, several with their own direction, so they keep falling through exactly as before. Verified each one. applySort now collects every recognized key, skipping duplicates so ParentIndexNumber,IndexNumber does not repeat a column. random stays alone: the repo matches it by exact string equality, so joining it would both break that path and emit a bare 'random' column into the ORDER BY. Verified against a prod-sized copy: every multi-key combination seen in real traffic returns 200, and a secondary key now changes the order within a tied year for songs. Albums are unchanged there, because their max_year mapping already ended in ", name". * fix(persistence): resolve sort mappings exactly once Making sortMapping resolve a comma list per part broke an invariant it had been relying on: idempotence. sanitizeSort mapped the sort key up front and applyOptions then ran buildSortOrder over the result, so sortMapping was already being handed its own output. That was harmless only while a mapped value could never look like a key list. media_file's rated_at maps to "rating, rated_at", and both parts are keys, so the second pass expanded it to "rating, rating, rated_at". Found by round-tripping every mapping in all four repositories; it was the only collision, and the duplicate sort key was benign in SQL, but any future mapping of that shape would silently change meaning. sanitizeSort now validates without resolving, leaving buildSortOrder as the single mapping point. The generated SQL is unchanged — the whole suite passes apart from the two specs that asserted the old return value, which are updated and joined by a round-trip guard covering exactly the rated_at shape. Also use the paren-aware splitFunc that buildSortOrder already uses, so an expression carrying commas inside its parentheses cannot be split apart. * refactor(jellyfin,persistence): flatten the sort resolution paths Cleanup pass over the branch, no behavior change. sortMapping loses the len(parts)>1 guard, which existed only to pick between two identical toSnakeCase exits; the single-key case now falls through the same loop. lookupSortMapping hands back the snake_case form it had to derive so the fallback stops recomputing it — toSnakeCase is two regexps, and on a miss it was running twice per call. sanitizeSort now asks lookupSortMapping instead of probing the map itself, so "is this a known sort key" has one answer; the two had already drifted, since sanitizeSort tried one casing where the resolver tries three. applySort folds the nested random branch into the skip condition and the two trailing length tests into one switch. setSortMappings documents the invariant the comma-list rule depends on, where someone adding a mapping will read it. The README line describing SortBy still said only the first key applied, which the commit before last made false. Tests: the twelve near-identical sorting specs become one DescribeTable of (itemType, SortBy, want) triples, 124 lines to 36, and the applyOptions round-trip assertion collapses to the buildSortOrder call its sibling uses. * fix(jellyfin): keep annotation filters out of search, resolve sorts per part Two findings from the Codex review on #5981. The played/unplayed filters turned working requests into 500s when combined with SearchTerm. Search runs a two-phase FTS query whose first phase selects rowids with no annotation join, so a starred or play_count predicate there is "no such column", not a filter. Measured against master: MusicAlbum with SearchTerm and Filters=IsUnplayed went 200 -> 500, likewise IsPlayed and the Audio equivalents. listAlbums and listSongs now skip those predicates on the search path, matching what listArtists already did. That also clears the same 500 master already had for Filters=IsFavorite with SearchTerm. sortMapping resolved a comma list only while every part was a known key, so a list mixing a plain column with a mapped key kept neither: MusicAlbum SortBy=Runtime,SortName arrives as "duration, name", and duration is a plain album column, so name stayed raw instead of expanding to order_album_name. Albums whose name differs from its sort form — 1,366 of 6,987 on a real library — then ordered by the wrong secondary key, and PreferSortTags was ignored. Each part is now resolved on its own, which is what setSortMappings already documents for a single field. Verified every in-tree caller that passes a raw column list still produces its original ORDER BY. Codex also asked for the artist search path to apply the same filters. It would 500 for the reason above, and wrapping the library scope in a compound filter makes requestedLibraryIDs stop recognizing it, silently widening the search past the requested ParentId. * fix(jellyfin): honor the first SortOrder value for a multi-key sort applySort compared the whole SortOrder string with "Descending", so a per-key list like SortOrder=Descending,Ascending failed the match and every key, including the primary, sorted ascending — the exact opposite of the request. Take the first comma-separated value, which Jellyfin also uses for any key past the end of the SortOrder list. True per-key directions can't be expressed through the single opts.Sort string and are left out; no observed client sends a SortOrder list. --- persistence/sql_base_repository.go | 36 ++++- persistence/sql_base_repository_test.go | 59 +++++++- persistence/sql_restful.go | 12 +- server/jellyfin/README.md | 6 +- server/jellyfin/browsing.go | 19 +-- server/jellyfin/browsing_test.go | 23 +++ server/jellyfin/images.go | 15 +- server/jellyfin/images_test.go | 33 +++- server/jellyfin/items.go | 149 +++++++++++++------ server/jellyfin/items_test.go | 190 +++++++++++++++--------- 10 files changed, 394 insertions(+), 148 deletions(-) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 428ba7a7b..c61bca1a6 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -97,6 +97,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun } // setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is. +// This applies per comma-separated part, so a key added here also defines that bare name wherever a +// caller uses it inside a sort list. // // If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression, // which gives precedence to sort tags. @@ -147,17 +149,37 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti // TODO Change all sortMappings to have a consistent case func (r sqlRepository) sortMapping(sort string) string { - if mapping, ok := r.sortMappings[sort]; ok { + if mapping, _, ok := r.lookupSortMapping(sort); ok { return mapping } - if mapping, ok := r.sortMappings[toCamelCase(sort)]; ok { - return mapping + // Each part of a comma list is resolved on its own, so a mix of mapped keys and plain columns + // keeps the mappings the recognized parts have. + parts := strings.FieldsFunc(sort, splitFunc(',')) + mapped := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if partMapping, _, ok := r.lookupSortMapping(part); ok { + part = partMapping + } else { + part = toSnakeCase(part) + } + mapped = append(mapped, part) } - sort = toSnakeCase(sort) - if mapping, ok := r.sortMappings[sort]; ok { - return mapping + return strings.Join(mapped, ", ") +} + +// lookupSortMapping also returns the snake_case form when it had to derive one, so a caller's +// fallback doesn't recompute it: toSnakeCase runs two regexps. +func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased string, ok bool) { + if mapping, ok = r.sortMappings[sort]; ok { + return mapping, sort, true } - return sort + if mapping, ok = r.sortMappings[toCamelCase(sort)]; ok { + return mapping, "", true + } + snakeCased = toSnakeCase(sort) + mapping, ok = r.sortMappings[snakeCased] + return mapping, snakeCased, ok } func (r sqlRepository) buildSortOrder(sort, order string) string { diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 9c6c6007f..0f76eb6ab 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -92,14 +92,28 @@ var _ = Describe("sqlRepository", func() { Expect(sort).To(BeEmpty()) }) - It("returns the mapped value when sort key exists", func() { + // Validation only: buildSortOrder resolves the mapping, so mapping here too would hand + // sortMapping its own output and re-map values whose parts are themselves keys. + It("accepts a known sort key without resolving it", func() { sort, _ := r.sanitizeSort("sort1", "") - Expect(sort).To(Equal("mappedSort1")) + Expect(sort).To(Equal("sort1")) }) It("is case insensitive", func() { sort, _ := r.sanitizeSort("Sort1", "") - Expect(sort).To(Equal("mappedSort1")) + Expect(sort).To(Equal("sort1")) + }) + + It("still resolves the mapping by the time the SQL is built", func() { + Expect(r.buildSortOrder("sort1", "asc")).To(Equal("mappedSort1 asc")) + }) + + // A mapping whose parts are themselves keys (media_file rated_at = "rating, rated_at") + // must survive the round trip through sanitizeSort and buildSortOrder unduplicated. + It("does not re-map a value whose parts are also keys", func() { + r.sortMappings = map[string]string{"rating": "rating", "rated_at": "rating, rated_at"} + sort, _ := r.sanitizeSort("rated_at", "") + Expect(r.buildSortOrder(sort, "asc")).To(Equal("rating asc, rated_at asc")) }) It("returns the field if it is a valid field", func() { @@ -135,6 +149,45 @@ var _ = Describe("sqlRepository", func() { }) }) + Describe("sortMapping", func() { + BeforeEach(func() { + r.sortMappings = map[string]string{ + "name": "order_album_name, order_album_artist_name", + "recently_added": "album.created_at, album.id", + } + }) + It("maps a single key", func() { + Expect(r.sortMapping("recently_added")).To(Equal("album.created_at, album.id")) + }) + It("maps every part of a comma list when all of them are known keys", func() { + Expect(r.sortMapping("recently_added, name")). + To(Equal("album.created_at, album.id, order_album_name, order_album_artist_name")) + }) + It("resolves the known parts of a mixed list and leaves the rest as columns", func() { + Expect(r.sortMapping("recently_added, play_count")). + To(Equal("album.created_at, album.id, play_count")) + }) + // Jellyfin's MusicAlbum SortBy=Runtime,SortName arrives as "duration, name"; duration is a + // plain album column while name is mapped, and the mapping must survive the mix. + It("keeps a mapping when an earlier part is a plain column", func() { + Expect(r.sortMapping("duration, name")). + To(Equal("duration, order_album_name, order_album_artist_name")) + }) + It("leaves a raw column list with directions untouched", func() { + Expect(r.sortMapping("starred desc, rating desc")).To(Equal("starred desc, rating desc")) + }) + It("does not split an expression on a comma inside its parentheses", func() { + Expect(r.sortMapping("coalesce(name, ''), title")).To(Equal("coalesce(name, ''), title")) + Expect(r.sortMapping("coalesce(nullif(a,''), b) desc, c")).To(Equal("coalesce(nullif(a,''), b) desc, c")) + }) + It("keeps a mapping whose value nests commas inside parentheses", func() { + r.sortMappings["max_year"] = "coalesce(nullif(original_date,''), cast(max_year as text)), release_date" + Expect(r.sortMapping("max_year, name")).To(Equal( + "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + + "order_album_name, order_album_artist_name")) + }) + }) + Describe("buildSortOrder", func() { BeforeEach(func() { r.sortMappings = map[string]string{} diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 1dcabcec6..b1cfd2379 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -69,13 +69,11 @@ func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.Qu func (r sqlRepository) sanitizeSort(sort, order string) (string, string) { if sort != "" { sort = toSnakeCase(sort) - if mapped, ok := r.sortMappings[sort]; ok { - sort = mapped - } else { - if !r.isFieldWhiteListed(sort) { - log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) - sort = "" - } + // Validate only: buildSortOrder resolves the mapping later, and mapping here as well would + // feed sortMapping its own output. + if _, _, known := r.lookupSortMapping(sort); !known && !r.isFieldWhiteListed(sort) { + log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) + sort = "" } } if order != "" { diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 5759e6cc0..dc3219dfa 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -104,7 +104,11 @@ album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreI genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); `SearchTerm`; -favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone +`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as +in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome +equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; +unrecognized keys are skipped, and `Random` always sorts alone); `StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` returns direct children only (no tracks — no track is a library's direct child). diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 9acd8d5a8..25c44e748 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -24,10 +24,6 @@ func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { // when accessible (like queryItems) or all accessible libraries otherwise. func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { ctx := r.Context() - p := req.Params(r) - opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} - applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _, ok := parentIDScope(ctx, r) if !ok { http.Error(w, "Not Found", http.StatusNotFound) @@ -38,14 +34,13 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol http.Error(w, "Not Found", http.StatusNotFound) return } - // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. - // Finamp's artist tab sends GenreIds when a genre filter is active. - q := itemsQuery{ - scopeIDs: scopeIDs, - genreIds: genreIds, - search: searchTerm(p), - fields: dto.ParseFields(p.Strings("fields")...), - } + // This route resolves its own scope, so it shares only the plain query params with /Items. + q := listParams(req.Params(r)) + q.scopeIDs = scopeIDs + q.genreIds = genreIds + + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, "MusicArtist", q.sortBy, q.sortOrder) if q.search != "" { opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) } diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 2825c03e8..657c7a42c 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -156,6 +156,29 @@ var _ = Describe("Browsing", func() { Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) }) + DescribeTable("restricts to favorites", + func(url string, handler func(*Router) http.HandlerFunc) { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", url, nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(handler(api), w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + // listArtists always ANDs notMissing, favorites filter or not. + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(true)) + }, + Entry("Filters=IsFavorite", "/Artists?Filters=IsFavorite", + func(a *Router) http.HandlerFunc { return a.getArtists }), + Entry("isFavorite=true", "/Artists?isFavorite=true", + func(a *Router) http.HandlerFunc { return a.getArtists }), + Entry("on /Artists/AlbumArtists", "/Artists/AlbumArtists?Filters=IsFavorite", + func(a *Router) http.HandlerFunc { return a.getAlbumArtists }), + ) + It("404s a malformed ParentId instead of listing every library's artists", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 65f02d7a0..fcf69c039 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -11,7 +11,6 @@ import ( _ "image/png" "io" "net/http" - "strconv" "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" @@ -20,9 +19,20 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/imghttp" + "github.com/navidrome/navidrome/utils/req" _ "golang.org/x/image/webp" ) +// imageSize picks the tighter of Jellyfin's two bounds, because Navidrome resizes on a single +// dimension: reading only MaxWidth serves the full-size original to a client that sent MaxHeight. +func imageSize(maxWidth, maxHeight int) int { + w, h := max(maxWidth, 0), max(maxHeight, 0) + if w == 0 || h == 0 { + return max(w, h) + } + return min(w, h) +} + func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. @@ -31,7 +41,8 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { if !ok { return } - size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + p := req.Params(r) + size := imageSize(p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0)) artID := api.resolveArtworkID(ctx, itemId) img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 0e0d6220d..2f32e6ec9 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -30,14 +30,16 @@ import ( type fakeArtwork struct { artwork.Artwork - recvId string - recvCtx context.Context - data []byte - hash string + recvId string + recvSize int + recvCtx context.Context + data []byte + hash string } func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id + f.recvSize = size f.recvCtx = ctx data := f.data if data == nil { @@ -61,6 +63,29 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) } var _ = Describe("Images", func() { + // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must + // still get a resized image rather than the full-size original. + DescribeTable("derives the requested size from MaxWidth or MaxHeight", + func(query string, wantSize int) { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID(testID("a1"))) + r.URL.RawQuery = query + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvSize).To(Equal(wantSize)) + }, + Entry("MaxWidth only", "maxwidth=300", 300), + Entry("MaxHeight only", "maxheight=300", 300), + Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), + Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), + Entry("neither", "", 0), + ) + It("streams album artwork", func() { ds := &tests.MockDataStore{} ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index de0da88eb..eb5cd2a1a 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -31,6 +31,51 @@ func searchTerm(p *req.Values) string { return strings.TrimSpace(p.StringOr("searchterm", "")) } +// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params +// clients may send instead. A nil field means the client asked for no filtering on that dimension. +type itemFilters struct { + favorite *bool + played *bool +} + +// parseItemFilters reads the standalone params first and lets the Filters list win, matching real +// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped. +func parseItemFilters(p *req.Values) itemFilters { + f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")} + for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") { + switch strings.TrimSpace(token) { + case "IsFavorite", "IsFavoriteOrLikes": + f.favorite = new(true) + case "IsPlayed": + f.played = new(true) + case "IsUnplayed": + f.played = new(false) + } + } + return f +} + +// predicates renders the filters as annotation-column conditions. The negative cases have to match +// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all. +func (f itemFilters) predicates() []squirrel.Sqlizer { + var out []squirrel.Sqlizer + if f.favorite != nil { + if *f.favorite { + out = append(out, squirrel.Eq{"starred": true}) + } else { + out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}}) + } + } + if f.played != nil { + if *f.played { + out = append(out, squirrel.Gt{"play_count": 0}) + } else { + out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}}) + } + } + return out +} + func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { @@ -214,7 +259,7 @@ type itemsQuery struct { sortOrder string offset int limit int - favOnly bool + filters itemFilters // parentId scopes the query. entityParent is the same id only when it names an entity (an artist // for MusicAlbum, an album for Audio) rather than a library. parentId string @@ -231,6 +276,19 @@ type itemsQuery struct { studioIds []string } +// listParams reads the itemsQuery fields that come straight from query params. +func listParams(p *req.Values) itemsQuery { + return itemsQuery{ + fields: dto.ParseFields(p.Strings("fields")...), + search: searchTerm(p), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + filters: parseItemFilters(p), + } +} + // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that @@ -261,24 +319,14 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ if !ok { return itemsQuery{}, model.ErrNotFound } - q := itemsQuery{ - fields: dto.ParseFields(p.Strings("fields")...), - ids: ids, - rawTypes: p.StringOr("includeitemtypes", ""), - search: searchTerm(p), - sortBy: p.StringOr("sortby", ""), - sortOrder: p.StringOr("sortorder", ""), - offset: p.IntOr("startindex", 0), - limit: p.IntOr("limit", 0), - // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone - // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), - parentId: parentId, - genreIds: genreIds, - albumIds: albumIds, - years: parseYears(r), - studioIds: studioIds, - } + q := listParams(p) + q.ids = ids + q.rawTypes = p.StringOr("includeitemtypes", "") + q.parentId = parentId + q.genreIds = genreIds + q.albumIds = albumIds + q.years = parseYears(r) + q.studioIds = studioIds // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) @@ -621,8 +669,10 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.studioIds) > 0 { filters = append(filters, filter.ByStudioID(q.studioIds)) } - if q.favOnly { - filters = append(filters, filter.ByStarred().Filters) + // Not on the search path: its first FTS phase selects rowids with no annotation join, so a + // starred/play_count predicate there is "no such column" rather than a filter. + if q.search == "" { + filters = append(filters, q.filters.predicates()...) } opts.Filters = filters opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) @@ -668,8 +718,10 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.studioIds) > 0 { filters = append(filters, filter.ByStudioID(q.studioIds)) } - if q.favOnly { - filters = append(filters, filter.ByStarred().Filters) + // Not on the search path: its first FTS phase selects rowids with no annotation join, so a + // starred/play_count predicate there is "no such column" rather than a filter. + if q.search == "" { + filters = append(filters, q.filters.predicates()...) } opts.Filters = filters opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) @@ -720,14 +772,12 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil } - if q.favOnly { - opts.Filters = filter.ArtistsByStarred().Filters - } else { - opts.Filters = notMissing - } + filters := squirrel.And{notMissing} + filters = append(filters, q.filters.predicates()...) if len(q.genreIds) > 0 { - opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} + filters = append(filters, filter.ArtistsByGenreID(q.genreIds)) } + opts.Filters = filters opts = filter.ArtistsByRole(opts, role) opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) @@ -752,13 +802,8 @@ func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (ite // listPlaylists lists playlists visible to the current user. Visibility (public or owned) is // enforced by playlistRepository, not scopeIDs. func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - if q.favOnly { - starred := squirrel.Eq{"starred": true} - if opts.Filters == nil { - opts.Filters = starred - } else { - opts.Filters = squirrel.And{opts.Filters, starred} - } + if preds := q.filters.predicates(); len(preds) > 0 { + opts.Filters = squirrel.And(preds) } repo := api.ds.Playlist(ctx) total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) @@ -908,18 +953,32 @@ func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} } -// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the -// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); -// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), -// not passed through raw where it could produce an invalid ORDER BY. +// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends. +// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY. func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + var cols []string for key := range strings.SplitSeq(sortBy, ",") { - if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { - opts.Sort = col + col, ok := sortColumn(itemType, strings.TrimSpace(key)) + // The repo matches random by exact string equality, so it can only ever sort alone. + if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) { + continue + } + cols = append(cols, col) + if col == "random" { break } } - if strings.EqualFold(order, "Descending") { + switch { + case len(cols) > 0: + opts.Sort = strings.Join(cols, ", ") + case sortBy != "": + log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order", + "itemType", itemType, "sortBy", sortBy) + } + // Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value + // for every key, as Jellyfin does for keys past the end of the list. + first, _, _ := strings.Cut(order, ",") + if strings.EqualFold(first, "Descending") { opts.Order = "desc" } } @@ -941,6 +1000,8 @@ var sortColumnsByType = map[string]map[string]string{ "dateplayed": "play_date", "communityrating": "rating", "random": "random", + "runtime": "duration", + "runtimeticks": "duration", // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. "premieredate": "year", "productionyear": "year", @@ -964,6 +1025,8 @@ var sortColumnsByType = map[string]map[string]string{ "playcount": "play_count", "dateplayed": "play_date", "communityrating": "rating", + "runtime": "duration", + "runtimeticks": "duration", "premieredate": "max_year", "productionyear": "max_year", }, "MusicGenre": { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index b227ab719..17180c36a 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -327,17 +327,75 @@ var _ = Describe("Items", func() { Expect(albumRepo.Options.Max).To(Equal(3)) }) - It("applies a starred filter when Filters=IsFavorite", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - }) + DescribeTable("translates the Filters list and its standalone equivalents", + func(query string, wantSQL, notWantSQL []string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + for _, want := range wantSQL { + Expect(sql).To(ContainSubstring(want)) + } + for _, not := range notWantSQL { + Expect(sql).NotTo(ContainSubstring(not)) + } + }, + Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil), + Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed", + []string{"starred", "play_count"}, nil), + Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}), + Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}), + Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil), + Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil), + Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil), + // Jellyfin builds the query from the standalone params, then applies Filters over the top. + Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite", + []string{"starred = "}, nil), + // No Navidrome equivalent: these must be dropped, not half-applied. + Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}), + Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}), + // The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default + // branch, so favorites must not be the only predicate left on it. + Entry("keeps missing excluded under an artist parent", + "Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")), + []string{"starred", "missing"}, nil), + ) + + // Search runs a two-phase FTS query whose first phase has no annotation join, so an + // annotation predicate there is "no such column: starred" -> 500. + DescribeTable("does not push annotation filters into a search", + func(itemType, filters string) { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + "/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var opts model.QueryOptions + if itemType == "MusicAlbum" { + opts = ds.Album(context.Background()).(*tests.MockAlbumRepo).Options + } else { + opts = ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).Options + } + if opts.Filters == nil { + return + } + sql, _, err := opts.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("starred")) + Expect(sql).NotTo(ContainSubstring("play_count")) + }, + Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"), + Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"), + Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"), + Entry("songs, IsFavorite", "Audio", "IsFavorite"), + Entry("songs, IsUnplayed", "Audio", "IsUnplayed"), + ) It("forwards SearchTerm to the repo's Search method", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) @@ -601,65 +659,59 @@ var _ = Describe("Items", func() { }) Describe("sorting", func() { - It("maps SortBy=PlayCount to the play_count column", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("play_count")) - }) + DescribeTable("translates SortBy into the repo's sort keys", + func(itemType, sortBy, want string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + got := mfRepo.Options.Sort + if itemType == "MusicAlbum" { + got = albumRepo.Options.Sort + } + Expect(got).To(Equal(want)) + }, + Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"), + Entry("DatePlayed", "Audio", "DatePlayed", "play_date"), + Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"), + Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"), + // Finamp leads its track sort with Runtime: unless that resolves, the first recognized + // key is AlbumArtist and the list looks sorted while being sorted by the wrong thing. + Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName", + "duration, album_artist, album, title"), + Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"), + Entry("a key repeating a column is dropped", "Audio", + "PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"), + // random is matched by exact string equality in the repo, so it can never share a sort. + Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"), + Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"), + Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"), + Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"), + Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""), + ) - It("maps SortBy=DatePlayed to the play_date column", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("play_date")) - }) - - It("uses the first recognized key in a comma-separated SortBy list", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("recently_added")) - }) - - It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("title")) - }) - - It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("album")) - }) - - It("leaves Sort at the repo default when no SortBy key is recognized", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("")) - }) + // Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so + // we honor the first value for all keys, matching Jellyfin's fallback for extra keys. + DescribeTable("reads the first SortOrder value for the whole sort", + func(sortOrder, want string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + "/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Order).To(Equal(want)) + }, + Entry("ascending", "Ascending", ""), + Entry("descending", "Descending", "desc"), + Entry("descending leading a list", "Descending,Ascending", "desc"), + Entry("ascending leading a list", "Ascending,Descending", ""), + ) }) Describe("library scoping", func() { From 1f3034f0229777c2023e861a4d9a92aaca1a1207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 19 Aug 2026 08:47:53 -0400 Subject: [PATCH 448/596] fix(playlist): block track edits on synced playlists across all APIs (#5984) * fix(playlist): block track edits on synced playlists across all APIs A synced playlist's tracks come from its source file, so any track edit made through the UI or an API was silently reverted on the next scan. Track mutations funnel through two service guards, checkTracksEditable (incremental edits) and Create (wholesale replace, used by Subsonic createPlaylist and Jellyfin's replace path), which each duplicated the smart-playlist check. Both now consult a shared model.Playlist.TracksEditable() predicate, so the native, Subsonic, and Jellyfin paths are all locked: track edits return ErrNotAuthorized (403, or Subsonic error 50) instead of being accepted and lost. Metadata-only edits (name, comment, public, the sync flag itself) still go through checkWritable and are unaffected. In the UI, a synced playlist's track list becomes read-only, mirroring how smart playlists already behave. * fix(playlist): return 409 Conflict for non-editable playlist track edits The previous commit rejected track edits on smart and synced playlists with ErrNotAuthorized (403). That conflates two different things: a 403 says the caller lacks permission, but a synced or smart playlist's tracks are immutable for everyone, including the owner and admins. It is a property of the resource, not the caller. Introduce ErrPlaylistNotEditable and return it from both track-edit guards. The Native and Jellyfin APIs now map it to 409 Conflict; Subsonic maps it to error 50, the closest code it has (it has no read-only concept). The Native track handlers previously mapped this rejection inconsistently (400 on add, 500 on remove, 403 on reorder) through a new shared writePlaylistError helper. Genuine authorization failures (non-owner, non-admin) still return ErrNotAuthorized. * fix(playlist): surface synced read-only state in picker, Jellyfin, and OpenSubsonic Follow-up to the track-edit lock: the read-only state was enforced but not advertised consistently, so clients still offered edits that the server rejects. - UI: the Add to Playlist picker filtered targets by isWritable only, offering synced playlists that then 409 on add. It now filters with canChangeTracks. - Jellyfin: addToPlaylist/removeFromPlaylist hard-coded every error to 404, so a locked playlist reported "not found" instead of 409. They now return 409 for ErrPlaylistNotEditable while keeping the deliberate anti-probing 404 for every other error (a non-owner never reaches ErrPlaylistNotEditable, so 409 leaks nothing). - OpenSubsonic: buildOSPlaylist marked only smart playlists readonly; owned synced playlists advertised readonly=false. Readonly now also covers !TracksEditable(), matching the existing smart-playlist treatment. * fix(jellyfin): report CanEdit from playlist editability in permission probes getPlaylistUsers and getPlaylistUser returned CanEdit: true unconditionally, so Finamp (which probes this before showing edit controls) offered track editing on synced/smart playlists whose add/remove requests now return 409. Both handlers now fetch the playlist and set CanEdit from TracksEditable(), keeping the deliberate non-owner looseness (CanEdit stays true for a normal playlist a non-owner views) and mapping any lookup error to 404 like the sibling probes. * fix(playlist): check ownership before editability when replacing tracks Create checked TracksEditable() before ownership, so a non-owner replacing another user's public smart/synced playlist (Jellyfin updatePlaylist with a non-empty Ids list) received a 409 read-only conflict instead of a 403 authorization failure. The incremental guards check ownership first via checkWritable; Create now matches that order. Subsonic is unaffected (both errors map to code 50). Owners of their own smart/synced playlists still get the read-only conflict. * fix(jellyfin): return 403 for locked playlists, matching Jellyfin Jellyfin itself refuses edits on its file-backed playlists with Forbid() (403): PlaylistsController gates every mutation on OwnerUserId == caller or a share with CanEdit, and playlists imported from .m3u files satisfy neither. Its CanEdit is an ACL field, not a read-only marker, and Jellyfin core has no server-managed playlist type at all. Our Jellyfin routes exist to imitate that API, so ErrPlaylistNotEditable now maps to 403 there instead of 409. The native API keeps 409 (a resource-state conflict is the accurate REST answer where we define the contract) and Subsonic keeps error 50, its closest code. * chore(playlist): trim comments added by this branch Several comments ran to three or four lines and carried rationale that belongs in the commit history rather than the code: what Jellyfin does with its own file-backed playlists, and restatements of the expressions directly below them. Each block is now one or two lines covering only the non-obvious why. --- core/playlists/playlists.go | 13 +++-- core/playlists/playlists_test.go | 46 +++++++++++++-- model/errors.go | 3 + model/playlist.go | 5 ++ model/playlist_test.go | 15 +++++ server/jellyfin/playlists.go | 49 ++++++++++++---- server/jellyfin/playlists_test.go | 63 ++++++++++++++++++++- server/nativeapi/playlists.go | 30 ++++++---- server/nativeapi/playlists_test.go | 14 +++++ server/subsonic/api.go | 3 +- server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 9 +++ ui/src/common/playlistUtils.js | 2 +- ui/src/common/playlistUtils.test.js | 6 ++ ui/src/dialogs/SelectPlaylistInput.jsx | 5 +- ui/src/dialogs/SelectPlaylistInput.test.jsx | 9 +++ 16 files changed, 232 insertions(+), 42 deletions(-) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 656bde05e..9ae8f09cb 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -130,12 +130,13 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, if err != nil { return err } - if pls.IsSmartPlaylist() { - return model.ErrNotAuthorized - } + // Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict. if !usr.IsAdmin && pls.OwnerID != usr.ID { return model.ErrNotAuthorized } + if !pls.TracksEditable() { + return model.ErrPlaylistNotEditable + } } else { pls = &model.Playlist{Name: name} pls.OwnerID = usr.ID @@ -230,14 +231,14 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli return pls, nil } -// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist). +// checkTracksEditable verifies the user owns the playlist and its tracks are editable. func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { pls, err := s.checkWritable(ctx, playlistID) if err != nil { return nil, err } - if pls.IsSmartPlaylist() { - return nil, model.ErrNotAuthorized + if !pls.TracksEditable() { + return nil, model.ErrPlaylistNotEditable } return pls, nil } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index e8134b2ef..dd182b3f1 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -102,6 +102,8 @@ var _ = Describe("Playlists", func() { "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, + "pls-synced-other": {ID: "pls-synced-other", Name: "Other's Synced", OwnerID: "other-user", Sync: true, Public: true}, } ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) @@ -145,6 +147,18 @@ var _ = Describe("Playlists", func() { It("denies replacing tracks on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies replacing tracks on a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-synced", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies a non-owner with authorization, not a conflict, on a public synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-synced-other", "", []string{"song-1"}) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) }) @@ -159,6 +173,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) @@ -191,13 +206,13 @@ var _ = Describe("Playlists", func() { It("denies adding tracks to a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("denies removing tracks from a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("allows metadata updates on a smart playlist", func() { @@ -205,6 +220,18 @@ var _ = Describe("Playlists", func() { err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) + + It("denies adding tracks to a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-synced", nil, nil, nil, []string{"song-1"}, nil) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("allows metadata updates on a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-synced", new("Renamed Synced"), nil, nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("AddTracks", func() { @@ -216,7 +243,8 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) @@ -246,7 +274,13 @@ var _ = Describe("Playlists", func() { It("denies editing smart playlists", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies editing synced playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "pls-synced", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("returns error when playlist not found", func() { @@ -280,7 +314,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("denies non-owner", func() { @@ -314,7 +348,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) }) diff --git a/model/errors.go b/model/errors.go index 41029d316..0e2543378 100644 --- a/model/errors.go +++ b/model/errors.go @@ -9,4 +9,7 @@ var ( ErrExpired = errors.New("access expired") ErrNotAvailable = errors.New("functionality not available") ErrValidation = errors.New("validation error") + + // ErrPlaylistNotEditable: tracks are server-managed, so nobody can edit them (not an ACL failure). + ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") ) diff --git a/model/playlist.go b/model/playlist.go index 55b94a640..306401271 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -42,6 +42,11 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +// TracksEditable reports whether the track list is user-owned rather than server-managed. +func (pls Playlist) TracksEditable() bool { + return !pls.IsSmartPlaylist() && !pls.Sync +} + // RefreshDelay returns the playlist's own refresh window when set, falling // back to the global SmartPlaylistRefreshDelay. func (pls Playlist) RefreshDelay() time.Duration { diff --git a/model/playlist_test.go b/model/playlist_test.go index d936129ce..d98c85716 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -73,4 +73,19 @@ var _ = Describe("Playlist", func() { Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) }) }) + + Describe("TracksEditable", func() { + It("is true for a plain playlist", func() { + Expect(model.Playlist{}.TracksEditable()).To(BeTrue()) + }) + + It("is false for a smart playlist", func() { + pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.Is{"loved": true}}} + Expect(pls.TracksEditable()).To(BeFalse()) + }) + + It("is false for a synced playlist", func() { + Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) + }) + }) }) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 805b369c8..ac790a821 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -29,11 +29,11 @@ func playlistsFolder() dto.BaseItemDto { } } -// playlistError maps core/playlists write errors to HTTP status: ownership -> 403, missing/invisible -// -> 404 (never revealing another user's private playlist), else -> 500. +// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403, +// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500. func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { switch { - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): http.Error(w, "Forbidden", http.StatusForbidden) case errors.Is(err, model.ErrNotFound): http.Error(w, "Not Found", http.StatusNotFound) @@ -262,7 +262,7 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin } // addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). -// AddTracks enforces ownership; any error maps to 404. +// AddTracks enforces ownership; a locked playlist maps to 403, any other error to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -276,6 +276,10 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { } ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + if errors.Is(err, model.ErrPlaylistNotEditable) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } http.Error(w, "Not Found", http.StatusNotFound) return } @@ -284,7 +288,7 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { // removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media // file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces -// ownership; any error maps to 404. +// ownership; a locked playlist maps to 403, any other error to 404. func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -302,21 +306,44 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ids = append(ids, entry) } if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { + if errors.Is(err, model.ErrPlaylistNotEditable) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } http.Error(w, "Not Found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } -// getPlaylistUsers and getPlaylistUser answer client probes (e.g. Finamp) made before allowing -// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still -// enforced by AddTracks/RemoveTracks. +// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries +// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { - u, _ := request.UserFrom(r.Context()) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + u, _ := request.UserFrom(ctx) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}}) } func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } userId := chi.URLParam(r, "userId") - api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true}) + api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()}) } diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 3599cc784..270f5fe08 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -381,6 +381,15 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { + fp.addErr = model.ErrPlaylistNotEditable + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) @@ -430,6 +439,15 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { + fp.removeErr = model.ErrPlaylistNotEditable + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) @@ -442,7 +460,8 @@ var _ = Describe("Playlists", func() { }) Describe("getPlaylistUsers", func() { - It("returns the current user with CanEdit true", func() { + It("returns the current user with CanEdit true for an editable playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1")} w := httptest.NewRecorder() ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) @@ -453,21 +472,59 @@ var _ = Describe("Playlists", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) }) + + It("reports CanEdit false for a synced playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res []dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res[0].CanEdit).To(BeFalse()) + }) + + It("returns 404 when the playlist is not visible", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("getPlaylistUser", func() { - It("returns CanEdit true for the requested user", func() { + requestUser := func() *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", testID("pl1")) + rctx.URLParams.Add("playlistId", dto.EncodeID(testID("pl1"))) rctx.URLParams.Add("userId", testID("u1")) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) api.getPlaylistUser(w, r) + return w + } + + It("returns CanEdit true for an editable playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1")} + w := requestUser() Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) }) + + It("reports CanEdit false for a synced playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} + w := requestUser() + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.CanEdit).To(BeFalse()) + }) }) }) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 90b2f9e94..d215eb9dd 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -20,6 +20,20 @@ import ( type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc +// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown. +func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, err.Error(), http.StatusNotFound) + case errors.Is(err, model.ErrNotAuthorized): + http.Error(w, err.Error(), http.StatusForbidden) + case errors.Is(err, model.ErrPlaylistNotEditable): + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, err.Error(), defaultStatus) + } +} + func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plsId := chi.URLParam(r, "playlistId") @@ -111,7 +125,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { } if err != nil { log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err) - http.Error(w, err.Error(), http.StatusInternalServerError) + writePlaylistError(w, err, http.StatusInternalServerError) return } writeDeleteManyResponse(w, r, ids) @@ -138,22 +152,22 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { } count, c := 0, 0 if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c @@ -192,12 +206,8 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc { return } err = pls.ReorderTrack(ctx, playlistId, id, newPos) - if errors.Is(err, model.ErrNotAuthorized) { - http.Error(w, err.Error(), http.StatusForbidden) - return - } if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9bf502687..74ef58cab 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -183,6 +183,20 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) }) +var _ = Describe("writePlaylistError", func() { + DescribeTable("maps a service error to an HTTP status", + func(err error, expected int) { + w := httptest.NewRecorder() + writePlaylistError(w, err, http.StatusBadRequest) + Expect(w.Code).To(Equal(expected)) + }, + Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound), + Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden), + Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict), + Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest), + ) +}) + type mockPlaylistTrackRepo struct { model.PlaylistTrackRepository tracks model.PlaylistTracks diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 82e404228..029046c39 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -304,7 +304,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorGeneric, err.Error()) case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied), + errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index bd53528d9..774a9c430 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -172,7 +172,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso } } else { user, ok := request.UserFrom(ctx) - pls.Readonly = !ok || p.OwnerID != user.ID + pls.Readonly = !ok || p.OwnerID != user.ID || !p.TracksEditable() } return &pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index a7c9e2ec7..f18f33b47 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -111,6 +111,15 @@ var _ = Describe("buildPlaylist", func() { Expect(result.Public).To(BeTrue()) Expect(result.Readonly).To(BeFalse()) }) + + It("is read-only for a synced playlist even as owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + playlist.Sync = true + + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Readonly).To(BeTrue()) + }) }) Context("when minimal clients list is empty", func() { diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js index 74a01d47a..2d0c745ff 100644 --- a/ui/src/common/playlistUtils.js +++ b/ui/src/common/playlistUtils.js @@ -12,4 +12,4 @@ export const isReadOnly = (ownerId) => { export const isSmartPlaylist = (pls) => !!pls.rules export const canChangeTracks = (pls) => - isWritable(pls.ownerId) && !isSmartPlaylist(pls) + isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !pls.sync diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js index 2c671ecf5..345b6045d 100644 --- a/ui/src/common/playlistUtils.test.js +++ b/ui/src/common/playlistUtils.test.js @@ -74,5 +74,11 @@ describe('playlistUtils', () => { const playlist = { ownerId: 'user1', rules: [] } expect(canChangeTracks(playlist)).toBe(false) }) + + it('returns false if playlist is synced', () => { + localStorage.setItem('userId', 'user1') + const playlist = { ownerId: 'user1', sync: true } + expect(canChangeTracks(playlist)).toBe(false) + }) }) }) diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 847107523..2f040a7f7 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -16,7 +16,7 @@ import { import AddIcon from '@material-ui/icons/Add' import { useGetList, useTranslate } from 'react-admin' import PropTypes from 'prop-types' -import { isWritable } from '../common' +import { canChangeTracks } from '../common' import { makeStyles } from '@material-ui/core' const useStyles = makeStyles((theme) => ({ @@ -268,8 +268,7 @@ export const SelectPlaylistInput = ({ onChange }) => { ) const options = - ids && - ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId)) + ids && ids.map((id) => data[id]).filter((option) => canChangeTracks(option)) // Filter playlists based on search text const filteredOptions = diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 4ffcdf0b6..753d12c03 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -16,6 +16,7 @@ const mockPlaylists = [ { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, { id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' }, { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin + { id: 'playlist-5', name: 'Synced List', ownerId: 'admin', sync: true }, ] const mockIndexedData = { @@ -27,6 +28,12 @@ const mockIndexedData = { ownerId: 'admin', }, 'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, + 'playlist-5': { + id: 'playlist-5', + name: 'Synced List', + ownerId: 'admin', + sync: true, + }, } const createTestComponent = ( @@ -89,6 +96,8 @@ describe('SelectPlaylistInput', () => { // Should not show playlists not owned by admin (not writable) expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument() + // Should not show synced playlists (their tracks are not editable) + expect(screen.queryByText('Synced List')).not.toBeInTheDocument() }) it('should filter playlists based on search input', async () => { From 2dab4b40483a6feb3be2007afa4a1ac83c2d0504 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 19 Aug 2026 08:49:15 -0400 Subject: [PATCH 449/596] chore: remove redundant comment Signed-off-by: Deluan <deluan@navidrome.org> --- model/errors.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/model/errors.go b/model/errors.go index 0e2543378..9e56f9501 100644 --- a/model/errors.go +++ b/model/errors.go @@ -3,13 +3,11 @@ package model import "errors" var ( - ErrNotFound = errors.New("data not found") - ErrInvalidAuth = errors.New("invalid authentication") - ErrNotAuthorized = errors.New("not authorized") - ErrExpired = errors.New("access expired") - ErrNotAvailable = errors.New("functionality not available") - ErrValidation = errors.New("validation error") - - // ErrPlaylistNotEditable: tracks are server-managed, so nobody can edit them (not an ACL failure). + ErrNotFound = errors.New("data not found") + ErrInvalidAuth = errors.New("invalid authentication") + ErrNotAuthorized = errors.New("not authorized") + ErrExpired = errors.New("access expired") + ErrNotAvailable = errors.New("functionality not available") + ErrValidation = errors.New("validation error") ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") ) From 6d8a3e48eeacf4dda8edd6841f714f3ee064a371 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 19 Aug 2026 09:19:47 -0400 Subject: [PATCH 450/596] refactor: replace `md5` with `xxh3` for faster and more efficient hashing Signed-off-by: Deluan <deluan@navidrome.org> --- .golangci.yml | 3 +++ core/artwork/housekeeping.go | 6 ++---- core/artwork/housekeeping_test.go | 2 +- model/mediafile.go | 4 ++-- model/mediafile_test.go | 12 ++++-------- model/participants.go | 4 ++-- model/tag.go | 4 ++-- persistence/sql_base_repository.go | 6 +++--- 8 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0804138dc..7df4f8ec3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,6 +27,9 @@ linters: disable: - staticcheck settings: + errcheck: + exclude-functions: + - (*github.com/zeebo/xxh3.Hasher).Write gocritic: disable-all: true enabled-checks: diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ae1fc0a0d..f2996d044 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -2,8 +2,6 @@ package artwork import ( "context" - "crypto/md5" - "encoding/hex" "fmt" "slices" "strconv" @@ -16,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) // StaleAbsentAge is how long an absent state is trusted before a recheck retries it. @@ -66,8 +65,7 @@ func FingerprintInputs() []FingerprintInput { func ConfigFingerprint() string { values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value }) raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch) - sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive - return hex.EncodeToString(sum[:]) + return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } // backfill enqueues artwork resolution for every entity when the config fingerprint changed. diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 32a7688b4..4ea15ab04 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -135,7 +135,7 @@ var _ = Describe("Housekeeping", func() { conf.Server.EnableExternalServices = true conf.Server.EnableM3UExternalAlbumArt = false - Expect(ConfigFingerprint()).To(Equal("7e537a22febc07d3d5ca40546e88da54")) + Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d")) }) It("reports the config inputs it hashes, so a change can be traced to a setting", func() { diff --git a/model/mediafile.go b/model/mediafile.go index f4e767272..99aee591e 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,7 +2,6 @@ package model import ( "cmp" - "crypto/md5" "encoding/json" "fmt" "iter" @@ -20,6 +19,7 @@ import ( "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) type MediaFile struct { @@ -232,7 +232,7 @@ func (mf MediaFile) Hash() string { ZeroNil: true, } hash, _ := hashstructure.Hash(mf, opts) - sum := md5.New() + sum := xxh3.New() sum.Write(fmt.Appendf(nil, "%d", hash)) sum.Write(mf.Tags.Hash()) sum.Write(mf.Participants.Hash()) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 3f306f1a7..097e3ca54 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -715,14 +715,10 @@ var _ = Describe("MediaFile.Movements", func() { }) var _ = Describe("MediaFile.Hash", func() { - // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, - // or every file would be spuriously re-imported on the next scan. - // Golden hashes were captured at 46221d516 when those fields were plain ints. - It("keeps hashes identical to the pre-pointer-conversion values", func() { - // Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning - // them guarantees the pointer conversion cannot trigger a full-library re-import. - Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622")) - Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689")) + // Pins the hash formula: an accidental change spuriously re-imports every file on the next scan. + It("hashes to a stable value", func() { + Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090")) + Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538")) }) It("changes the hash when a pointer field has a value", func() { base := MediaFile{Title: "Song"} diff --git a/model/participants.go b/model/participants.go index afbda10de..b5390d22c 100644 --- a/model/participants.go +++ b/model/participants.go @@ -2,12 +2,12 @@ package model import ( "cmp" - "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) var ( @@ -193,7 +193,7 @@ func (p Participants) Hash() []byte { flattened = append(flattened, role.String()+":"+strings.Join(ids, "/")) } slices.Sort(flattened) - sum := md5.New() + sum := xxh3.New() sum.Write([]byte(strings.Join(flattened, "|"))) return sum.Sum(nil) } diff --git a/model/tag.go b/model/tag.go index 1bc011495..bb4fce181 100644 --- a/model/tag.go +++ b/model/tag.go @@ -2,13 +2,13 @@ package model import ( "cmp" - "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) type Tag struct { @@ -117,7 +117,7 @@ func (t Tags) Hash() []byte { } ids := t.IDs() slices.Sort(ids) - sum := md5.New() + sum := xxh3.New() sum.Write([]byte(strings.Join(ids, "|"))) return sum.Sum(nil) } diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index c61bca1a6..d4cf9b456 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "crypto/md5" "database/sql" "errors" "fmt" @@ -23,6 +22,7 @@ import ( "github.com/navidrome/navidrome/utils/hasher" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" + "github.com/zeebo/xxh3" ) // sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB. @@ -298,8 +298,8 @@ func (r sqlRepository) visibleLibraryIDs() ([]int, error) { func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick - userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID)) - return fmt.Sprintf("%s|%x", r.tableName, userIDHash) + userIDHash := xxh3.Hash([]byte(loggedUser(r.ctx).ID)) + return fmt.Sprintf("%s|%016x", r.tableName, userIDHash) } func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { From bd6b7a66863ce4771cd8e3450e355cf512b31363 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 19 Aug 2026 10:35:15 -0400 Subject: [PATCH 451/596] test: increase timeout for cache availability checks to 10 seconds --- core/artwork/e2e/acquire_serve_test.go | 2 +- core/artwork/e2e/resolution_harness_test.go | 2 +- core/stream/media_streamer_test.go | 9 +++++---- server/subsonic/e2e/subsonic_artwork_test.go | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index 0a9363b5d..34dfb2cac 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -106,7 +106,7 @@ var _ = Describe("Acquisition → serve loop", func() { func(context.Context, cache.Item) (io.Reader, error) { return nil, errors.New("resize not exercised in e2e") }) - Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) svc = artwork.NewArtwork(ds, imgCache, store, ffm) worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go index 1168393df..fff62ce69 100644 --- a/core/artwork/e2e/resolution_harness_test.go +++ b/core/artwork/e2e/resolution_harness_test.go @@ -117,7 +117,7 @@ func setupResolutionHarness() { func(context.Context, cache.Item) (io.Reader, error) { return nil, fmt.Errorf("resize not exercised in e2e") }) - Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue()) + Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue()) rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index f5ca16d3f..fb1c59a60 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "os" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -33,7 +34,7 @@ var _ = Describe("MediaStreamer", func() { {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) testCache := stream.NewTranscodingCache() - Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { @@ -75,7 +76,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -92,7 +93,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -112,7 +113,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 619619810..9324ea9e3 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -313,7 +313,7 @@ func newDummyImageCache(ctx context.Context) cache.FileCache { func(context.Context, cache.Item) (io.Reader, error) { return nil, errors.New("resize not exercised in subsonic artwork e2e") }) - Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + Eventually(func() bool { return c.Available(ctx) }, 10*time.Second).Should(BeTrue()) return c } From c362519f76d1922a6c3e1345e5ec91dada6275ed Mon Sep 17 00:00:00 2001 From: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:50:32 +0200 Subject: [PATCH 452/596] test: unskip path-separator tests on Windows (#5381) (#5916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: unskip AbsolutePath and i18n path-separator tests on Windows (#5381) Signed-off-by: junkerderprovinz <jdp@braethoria.com> * test: unskip metadata folder-PID test on Windows via path.Dir (#5381) Signed-off-by: junkerderprovinz <jdp@braethoria.com> * test(storage): make relative-folder assertion cross-platform and unskip on Windows (#5381) Signed-off-by: junkerderprovinz <jdp@braethoria.com> * fix(persistence): normalize folder-update-info paths with forward slashes on Windows (#5381) Signed-off-by: junkerderprovinz <jdp@braethoria.com> * review: drop folder-PID change, trim storage_test comment (#5381) Revert model/metadata/persistent_ids.go to master: switching the `folder` PID attribute from filepath.Dir to path.Dir would change the persistent IDs of existing Windows libraries and needs a migration path, so it is out of scope for this PR. The matching test unskip is reverted with it, leaving #TBD-path-sep-metadata open in #5381. Trim the core/storage/storage_test.go comment to two lines. Signed-off-by: junkerderprovinz <jdp@braethoria.com> --------- Signed-off-by: junkerderprovinz <jdp@braethoria.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- core/common_test.go | 4 ++-- core/storage/storage_test.go | 10 ++++++---- persistence/folder_repository_test.go | 5 ----- server/nativeapi/translations_test.go | 5 ++--- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/core/common_test.go b/core/common_test.go index 0d6e3a299..f57ea8d38 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -41,10 +42,9 @@ var _ = Describe("common.go", func() { }) It("returns the absolute path when library exists", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)") ctx := context.Background() abs := AbsolutePath(ctx, ds, libId, path) - Expect(abs).To(Equal("/library/root/music/file.mp3")) + Expect(abs).To(Equal(filepath.FromSlash("/library/root/music/file.mp3"))) }) It("returns the original path if library not found", func() { diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 336b5a7a9..03904d086 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -7,7 +7,6 @@ import ( "runtime" "testing" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -56,13 +55,16 @@ var _ = Describe("Storage", func() { Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) }) It("should return a file implementation for a relative folder", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)") s, err := For("tmp") Expect(err).ToNot(HaveOccurred()) cwd, _ := os.Getwd() Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) - Expect(s.(*fakeLocalStorage).u.Path).To(Equal(filepath.Join(cwd, "tmp"))) + u := s.(*fakeLocalStorage).u + Expect(u.Scheme).To(Equal("file")) + // On Windows the drive letter lands in u.Host, so re-join it with + // u.Path (as newLocalStorage does) to keep the assertion OS-independent. + got := filepath.Join(u.Host, filepath.FromSlash(u.Path)) + Expect(got).To(Equal(filepath.Join(cwd, "tmp"))) }) It("should return error if schema is unregistered", func() { _, err := For("webdav:///tmp") diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index b429cecb9..545d8ad49 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -137,7 +136,6 @@ var _ = Describe("FolderRepository", func() { }) It("includes all child folders when querying parent", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create a parent folder with multiple children parent := model.NewFolder(testLib, "TestParent/Music") child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") @@ -159,7 +157,6 @@ var _ = Describe("FolderRepository", func() { }) It("excludes children from other libraries", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent in testLib parent := model.NewFolder(testLib, "TestIsolation/Parent") child := model.NewFolder(testLib, "TestIsolation/Parent/Child") @@ -185,7 +182,6 @@ var _ = Describe("FolderRepository", func() { }) It("excludes missing children when querying parent", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent and children, mark one as missing parent := model.NewFolder(testLib, "TestMissingChild/Parent") child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") @@ -206,7 +202,6 @@ var _ = Describe("FolderRepository", func() { }) It("handles mix of existing and non-existing target paths", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create folders for one path but not the other existingParent := model.NewFolder(testLib, "TestMixed/Exists") existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 77c088f6f..1c641da77 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -5,12 +5,12 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "testing/fstest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -18,13 +18,12 @@ import ( var _ = Describe("Translations", func() { Describe("I18n files", func() { It("contains only valid json language files", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)") fsys := resources.FS() dir, _ := fsys.Open(consts.I18nFolder) files, _ := dir.(fs.ReadDirFile).ReadDir(-1) for _, f := range files { name := filepath.Base(f.Name()) - filePath := filepath.Join(consts.I18nFolder, name) + filePath := path.Join(consts.I18nFolder, name) file, _ := fsys.Open(filePath) data, _ := io.ReadAll(file) var out map[string]any From dff9e47c2ea53e040ddac2fcc36f4563e53053be Mon Sep 17 00:00:00 2001 From: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:11:00 +0200 Subject: [PATCH 453/596] fix(scanner): detect in-place playlist edits via the folder content hash (#5914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scanner): detect in-place playlist edits via the folder content hash Signed-off-by: junkerderprovinz <jdp@braethoria.com> * docs(scanner): clarify the playlist entries in the folder hash Shorten the comment on the playlist loop, and record why the playlist count stays in the hash header: it is redundant with the loop for change detection, but removing it changes the hashed byte stream for every folder, including folders without playlists, which would mark every folder outdated on the first scan after upgrade. Signed-off-by: junkerderprovinz <jdp@braethoria.com> * test(scanner): pin filename and size into the playlist hash assertions The playlist size test called time.Now() twice, so the modtime differed too and carried the assertion — dropping info.Size() from the hash left the suite green. It now shares one baseTime. A new rename test swaps the map key with count, size and modtime held constant, so dropping the filename from the hash fails. Both mutations were verified to fail before this change and pass after. --------- Signed-off-by: junkerderprovinz <jdp@braethoria.com> Co-authored-by: Deluan <deluan@navidrome.org> --- scanner/folder_entry.go | 22 +++++++--- scanner/folder_entry_test.go | 80 ++++++++++++++++++++++++++++++++---- scanner/phase_1_folders.go | 4 +- scanner/walk_dir_tree.go | 4 +- 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index 0e893d6e6..e7eef223c 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -23,6 +23,7 @@ func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) path: path, audioFiles: make(map[string]fs.DirEntry), imageFiles: make(map[string]fs.DirEntry), + playlistFiles: make(map[string]fs.DirEntry), albumIDMap: make(map[string]string), updTime: info.UpdatedAt, prevHash: info.Hash, @@ -41,7 +42,7 @@ type folderEntry struct { updTime time.Time // from DB audioFiles map[string]fs.DirEntry imageFiles map[string]fs.DirEntry - numPlaylists int + playlistFiles map[string]fs.DirEntry numSubFolders int imagesUpdatedAt time.Time prevHash string // Previous hash from DB @@ -57,7 +58,7 @@ type folderEntry struct { } func (f *folderEntry) hasNoFiles() bool { - return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 + return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && len(f.playlistFiles) == 0 } func (f *folderEntry) isEmpty() bool { @@ -94,7 +95,7 @@ func (f *folderEntry) toFolder() *model.Folder { folder := model.NewFolder(f.job.lib, f.path) folder.NumAudioFiles = len(f.audioFiles) if playlists.InPath(*folder) { - folder.NumPlaylists = f.numPlaylists + folder.NumPlaylists = len(f.playlistFiles) } folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) folder.ImagesUpdatedAt = f.imagesUpdatedAt @@ -108,16 +109,18 @@ func (f *folderEntry) hash() string { h, "%s:%d:%d:%s", f.modTime.UTC(), - f.numPlaylists, + len(f.playlistFiles), // redundant with the loop below, but dropping it re-hashes every folder f.numSubFolders, f.imagesUpdatedAt.UTC(), ) - // Sort the keys of audio and image files to ensure consistent hashing + // Sort the keys of audio, image and playlist files to ensure consistent hashing audioKeys := slices.Collect(maps.Keys(f.audioFiles)) slices.Sort(audioKeys) imageKeys := slices.Collect(maps.Keys(f.imageFiles)) slices.Sort(imageKeys) + playlistKeys := slices.Collect(maps.Keys(f.playlistFiles)) + slices.Sort(playlistKeys) // Include audio files with their size and modtime for _, key := range audioKeys { @@ -135,5 +138,14 @@ func (f *folderEntry) hash() string { } } + // Include playlist files, so a content edit is detected even when the folder's + // mtime is preserved (rsync -a) or the playlist is not the newest file. + for _, key := range playlistKeys { + _, _ = io.WriteString(h, key) + if info, err := f.playlistFiles[key].Info(); err == nil { + _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) + } + } + return hex.EncodeToString(h.Sum(nil)) } diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index e8e354b38..4493f9309 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -48,6 +48,7 @@ var _ = Describe("folder_entry", func() { Expect(entry.path).To(Equal(path)) Expect(entry.audioFiles).To(BeEmpty()) Expect(entry.imageFiles).To(BeEmpty()) + Expect(entry.playlistFiles).To(BeEmpty()) Expect(entry.albumIDMap).To(BeEmpty()) Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) Expect(entry.prevHash).To(Equal(updateInfo.Hash)) @@ -95,7 +96,7 @@ var _ = Describe("folder_entry", func() { }) It("returns false when folder has playlists", func() { - entry.numPlaylists = 1 + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -107,7 +108,7 @@ var _ = Describe("folder_entry", func() { It("returns false when folder has multiple types of content", func() { entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - entry.numPlaylists = 2 + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} entry.numSubFolders = 3 Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -149,7 +150,11 @@ var _ = Describe("folder_entry", func() { "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, "folder.png": &fakeDirEntry{name: "folder.png"}, } - entry.numPlaylists = 3 + entry.playlistFiles = map[string]fs.DirEntry{ + "list1.m3u": &fakeDirEntry{name: "list1.m3u"}, + "list2.m3u": &fakeDirEntry{name: "list2.m3u"}, + "list3.m3u": &fakeDirEntry{name: "list3.m3u"}, + } entry.imagesUpdatedAt = time.Now() }) @@ -200,7 +205,10 @@ var _ = Describe("folder_entry", func() { "z.jpg": &fakeDirEntry{name: "z.jpg"}, "x.png": &fakeDirEntry{name: "x.png"}, } - entry.numPlaylists = 2 + entry.playlistFiles = map[string]fs.DirEntry{ + "q.m3u": &fakeDirEntry{name: "q.m3u"}, + "p.m3u": &fakeDirEntry{name: "p.m3u"}, + } entry.numSubFolders = 3 hash1 := entry.hash() @@ -214,6 +222,10 @@ var _ = Describe("folder_entry", func() { "x.png": &fakeDirEntry{name: "x.png"}, "z.jpg": &fakeDirEntry{name: "z.jpg"}, } + entry.playlistFiles = map[string]fs.DirEntry{ + "p.m3u": &fakeDirEntry{name: "p.m3u"}, + "q.m3u": &fakeDirEntry{name: "q.m3u"}, + } hash2 := entry.hash() Expect(hash1).To(Equal(hash2)) @@ -252,10 +264,10 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) - It("produces different hash when playlist count changes", func() { + It("produces different hash when playlist files change", func() { hash1 := entry.hash() - entry.numPlaylists = 5 + entry.playlistFiles["new.m3u"] = &fakeDirEntry{name: "new.m3u"} hash2 := entry.hash() Expect(hash1).ToNot(Equal(hash2)) @@ -377,6 +389,58 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) + It("produces different hash when playlist file size changes", func() { + baseTime := time.Now() + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 2000, modTime: baseTime}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when playlist file modification time changes", func() { + baseTime := time.Now() + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime.Add(1 * time.Hour)}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when a playlist is renamed", func() { + baseTime := time.Now() + entry.playlistFiles["old.m3u"] = &fakeDirEntry{ + name: "old.m3u", + fileInfo: &fakeFileInfo{name: "old.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + delete(entry.playlistFiles, "old.m3u") + entry.playlistFiles["new.m3u"] = &fakeDirEntry{ + name: "new.m3u", + fileInfo: &fakeFileInfo{name: "new.m3u", size: 1000, modTime: baseTime}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + It("produces valid hex-encoded hash", func() { hash := entry.hash() Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters @@ -421,7 +485,7 @@ var _ = Describe("folder_entry", func() { }) It("returns true when hash has changed", func() { - entry.numPlaylists = 10 // Change something to change the hash + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change something to change the hash Expect(entry.isOutdated()).To(BeTrue()) }) @@ -445,7 +509,7 @@ var _ = Describe("folder_entry", func() { It("returns true when full scan condition is not met but hash changed", func() { entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) - entry.numPlaylists = 10 // Change hash + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change hash Expect(entry.isOutdated()).To(BeTrue()) }) }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 82e91d5ad..6107b3316 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -165,7 +165,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime, "_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt, "numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles), - "numPlaylists", folder.numPlaylists, "numSubfolders", folder.numSubFolders) + "numPlaylists", len(folder.playlistFiles), "numSubfolders", folder.numSubFolders) // Check if folder is outdated if folder.isOutdated() { @@ -492,7 +492,7 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { logCall = log.Trace } logCall(p.ctx, "Scanner: Completed processing folder", - "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", entry.numPlaylists, + "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", len(entry.playlistFiles), "elapsed", entry.elapsed.Elapsed(), "tracksMissing", len(entry.missingTracks), "tracksImported", len(entry.tracks), "library", entry.job.lib.Name, consts.Zwsp+"folder", entry.path) return entry, nil diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 887344b1b..20f6d4213 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -82,7 +82,7 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker dir := path.Clean(currentFolder) log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles), - "images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt, + "images", maps.Keys(folder.imageFiles), "playlists", len(folder.playlistFiles), "imagesUpdatedAt", folder.imagesUpdatedAt, "updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children)) folder.path = dir folder.elapsed.Start() @@ -157,7 +157,7 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry case model.IsValidPlaylist(name): - folder.numPlaylists++ + folder.playlistFiles[entry.Name()] = entry case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) From fd4b3256e4c3e2c6eeeb90c2e3034149b536f873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 19 Aug 2026 16:20:38 -0400 Subject: [PATCH 454/596] perf(artwork): compute the blurhash DCT separably (#5989) The cosine basis factors into cosX[i][x] * cosY[j][y], so the pixel loop does not need to visit every (i,j) pair. Each row now collapses to xComp dot products, folded over yComp once per row: w*h*xComp + h*xComp*yComp multiply-accumulates instead of w*h*xComp*yComp. Encoding is ~60% faster at every input size, and ~80% faster at the 128px size the artwork pipeline actually feeds it (263us -> 53us). Hashes are byte-identical, so the existing golden-value specs cover the rewrite. --- core/artwork/blurhash/blurhash.go | 35 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index ca5bf7026..29e1ccfda 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -52,6 +52,12 @@ func Encode(img image.Image) (string, error) { lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) + linR := make([]float64, w) + linG := make([]float64, w) + linB := make([]float64, w) + rowR := make([]float64, xComp) + rowG := make([]float64, xComp) + rowB := make([]float64, xComp) for y := range h { row := src.pix[y*src.stride:] for x := range w { @@ -60,15 +66,26 @@ func Encode(img image.Image) (string, error) { if src.straight { r, g, b = premultiply(r, g, b, row[p+3]) } - lr, lg, lb := lin[r], lin[g], lin[b] - for j := range yComp { - for i := range xComp { - basis := cosX[i][x] * cosY[j][y] - f := &factors[j*xComp+i] - f[0] += basis * lr - f[1] += basis * lg - f[2] += basis * lb - } + linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b] + } + // The basis is separable, so a row costs xComp dot products plus one fold over yComp, + // rather than xComp*yComp multiply-accumulates per pixel. + for i := range xComp { + var sr, sg, sb float64 + for x, c := range cosX[i] { + sr += c * linR[x] + sg += c * linG[x] + sb += c * linB[x] + } + rowR[i], rowG[i], rowB[i] = sr, sg, sb + } + for j := range yComp { + cy := cosY[j][y] + for i := range xComp { + f := &factors[j*xComp+i] + f[0] += cy * rowR[i] + f[1] += cy * rowG[i] + f[2] += cy * rowB[i] } } } From 881073c1833e66c47839927dc3c1577e39471c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 19 Aug 2026 16:25:12 -0400 Subject: [PATCH 455/596] feat(cli): let artwork explain/refresh accept an id without its kind (#5988) * feat(cmd): make artwork explain/refresh accept an id without its kind The kind can now come from the id itself: a full artwork id (al-<id>) carries it in the prefix, and a bare id is resolved across tables via GetEntityByID. The explicit <kind> <id> leader still works. * fix(cmd): keep refreshing resolvable ids when others fail to resolve resolveArtworkTargets now collects a self-describing id it cannot resolve as a failure instead of aborting, so refresh reports and skips the bad ones and still queues the rest, matching refreshItems per-item behavior. explain stays strict and rejects any unresolved input. --- cmd/artwork.go | 110 +++++++++++++++++++++++++++++++-------- cmd/artwork_test.go | 83 ++++++++++++++++++++++++++--- model/get_entity.go | 37 +++++++++---- model/get_entity_test.go | 22 ++++++++ 4 files changed, 212 insertions(+), 40 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index e9c910f11..aeaec0e43 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -59,31 +59,27 @@ var artworkCmd = &cobra.Command{ } var artworkExplainCmd = &cobra.Command{ - Use: "explain <kind> <id>", + Use: "explain [<kind>] <id>", Short: "Explain why an item's artwork resolved the way it did", Long: "Explain why an item's artwork resolved the way it did.\n\n" + + "The item can be given as a bare id, a full artwork id (e.g. al-<id>), or a <kind> <id> pair.\n" + "<kind> is one of: " + kindPrefixes(explainKinds) + ".\n" + "A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2", - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { - kind, err := parseArtworkKind(args[0], explainKinds) - if err != nil { - log.Fatal(cmd.Context(), err) - } - runExplain(cmd.Context(), kind, args[1]) + runExplain(cmd.Context(), args) }, } var artworkRefreshCmd = &cobra.Command{ - Use: "refresh <kind> <id>...", + Use: "refresh [<kind>] <id>...", Short: "Clear an item's artwork state and re-resolve it", - Args: cobra.MinimumNArgs(2), + Long: "Clear an item's artwork state and re-resolve it.\n\n" + + "Each item can be given as a bare id, a full artwork id (e.g. al-<id>), or a shared\n" + + "<kind> <id>... leader. <kind> is one of: " + kindPrefixes(artwork.RefreshableKinds) + ".", + Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - kind, err := parseArtworkKind(args[0], artwork.RefreshableKinds) - if err != nil { - log.Fatal(cmd.Context(), err) - } - runRefresh(cmd.Context(), kind, args[1:]) + runRefresh(cmd.Context(), args) }, } @@ -499,19 +495,28 @@ func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, t } } -func runRefresh(ctx context.Context, kind model.Kind, ids []string) { +func runRefresh(ctx context.Context, args []string) { defer db.Init(ctx)() ds, ctx := getAdminContext(ctx) - if failed := refreshItems(ctx, ds, kind, ids, os.Stdout); failed > 0 { - log.Fatal(ctx, "Failed to refresh artwork", "kind", kind, "failed", failed, "total", len(ids)) + targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.RefreshableKinds) + if err != nil { + log.Fatal(ctx, err) + } + for _, f := range failures { + log.Error(ctx, "Skipping unresolved item", f) + } + failed := refreshItems(ctx, ds, targets, os.Stdout) + len(failures) + if failed > 0 { + log.Fatal(ctx, "Failed to refresh artwork", "failed", failed, "total", len(targets)+len(failures)) } } -// refreshItems keeps going after a failure — the ids are independent — and returns how many failed. -func refreshItems(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string, out io.Writer) int { +// refreshItems keeps going after a failure — the items are independent — and returns how many failed. +func refreshItems(ctx context.Context, ds model.DataStore, targets []model.ArtworkID, out io.Writer) int { var failed int - for _, id := range ids { + for _, t := range targets { + kind, id := t.Kind, t.ID // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. if _, err := artworkItemName(ctx, ds, kind, id); err != nil { log.Error(ctx, "Item not found", "kind", kind, "id", id, err) @@ -544,7 +549,56 @@ func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { if ok && slices.Contains(valid, kind) { return kind, nil } - return kind, fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) + return kind, invalidKindErr(s, valid) +} + +func invalidKindErr(s string, valid []model.Kind) error { + return fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) +} + +// resolveArtworkTargets resolves explain/refresh positional args into artwork ids, accepting a +// shared "<kind> <id>..." leader or self-describing args (a bare id, or a full artwork id). A +// self-describing arg that cannot be resolved is returned as a failure rather than aborting the +// batch, so refresh can process the resolvable ids; a malformed <kind> leader is a usage error. +func resolveArtworkTargets(ctx context.Context, ds model.DataStore, args []string, valid []model.Kind) ([]model.ArtworkID, []error, error) { + if kind, ok := model.ParseKind(args[0]); ok && len(args) > 1 { + if !slices.Contains(valid, kind) { + return nil, nil, invalidKindErr(args[0], valid) + } + return slice.Map(args[1:], func(id string) model.ArtworkID { + return model.ArtworkID{Kind: kind, ID: id} + }), nil, nil + } + var targets []model.ArtworkID + var failures []error + for _, arg := range args { + target, err := artworkKindAndID(ctx, ds, arg) + if err == nil && !slices.Contains(valid, target.Kind) { + err = invalidKindErr(target.Kind.Prefix(), valid) + } + if err != nil { + failures = append(failures, err) + continue + } + targets = append(targets, target) + } + return targets, failures, nil +} + +// artworkKindAndID resolves one self-describing argument: a full artwork id (al-<id>) takes its kind +// from the prefix, a bare id is looked up. Entity ids never start with "<kind>-", so no collision. +func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (model.ArtworkID, error) { + if artID, err := model.ParseArtworkID(arg); err == nil && artID.ID != "" { + return model.ArtworkID{Kind: artID.Kind, ID: artID.ID}, nil + } + kind, err := model.GetEntityKindByID(ctx, ds, arg) + if errors.Is(err, model.ErrNotFound) { + return model.ArtworkID{}, fmt.Errorf("could not determine kind for %q; pass an explicit <kind>", arg) + } + if err != nil { + return model.ArtworkID{}, err + } + return model.ArtworkID{Kind: kind, ID: arg}, nil } // explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a @@ -721,10 +775,22 @@ func formatTime(t time.Time) string { return t.Format(time.RFC3339) } -func runExplain(ctx context.Context, kind model.Kind, id string) { +func runExplain(ctx context.Context, args []string) { defer db.Init(ctx)() ds, ctx := getAdminContext(ctx) + targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds) + if err != nil { + log.Fatal(ctx, err) + } + if len(failures) > 0 { + log.Fatal(ctx, failures[0]) + } + if len(targets) != 1 { + log.Fatal(ctx, "explain takes a single item; pass one id or a <kind> <id> pair") + } + kind, id := targets[0].Kind, targets[0].ID + name, err := artworkItemName(ctx, ds, kind, id) if err != nil { log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index b28d8dac5..8b50ba775 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -54,6 +54,74 @@ var _ = Describe("parseArtworkKind", func() { }) }) +var _ = Describe("resolveArtworkTargets", func() { + var ds *tests.MockDataStore + ctx := context.Background() + + BeforeEach(func() { + artists := tests.CreateMockArtistRepo() + artists.SetData(model.Artists{{ID: "artist1"}}) + ds = &tests.MockDataStore{MockedArtist: artists} + }) + + It("accepts the explicit <kind> <id> leader shared by every id", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(failures).To(BeEmpty()) + Expect(targets).To(Equal([]model.ArtworkID{ + {Kind: model.KindAlbumArtwork, ID: "x"}, {Kind: model.KindAlbumArtwork, ID: "y"}})) + }) + + It("rejects an explicit kind the command does not accept as a usage error", func() { + _, _, err := resolveArtworkTargets(ctx, ds, []string{"dc", "x"}, artwork.RefreshableKinds) + Expect(err).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("resolves a bare id by looking it up across tables", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(failures).To(BeEmpty()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}})) + }) + + It("reads the kind from a full artwork id prefix without a database lookup", func() { + targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + }) + + It("strips the hash suffix from a full artwork id", func() { + targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + }) + + It("collects a self-describing arg whose kind the command does not accept", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"dc-realalbum:2"}, artwork.RefreshableKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(BeEmpty()) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("collects an id that matches nothing and has no kind prefix", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(BeEmpty()) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) + }) + + It("resolves the valid ids and collects the unresolvable ones", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{ + {Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) + }) +}) + var _ = Describe("explainResult", func() { It("reports the winning source", func() { steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} @@ -337,10 +405,10 @@ var _ = Describe("discArtworkName", func() { }) var _ = Describe("artwork refresh command", func() { - It("requires at least a kind and one id", func() { - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar"})).To(HaveOccurred()) + It("requires at least one argument", func() { + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"id1"})).ToNot(HaveOccurred()) Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1", "id2"})).ToNot(HaveOccurred()) }) }) @@ -873,7 +941,8 @@ var _ = Describe("refreshItems", func() { Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) - Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-1", "al-3"}, &out)).To(BeZero()) + Expect(refreshItems(ctx, ds, []model.ArtworkID{ + {Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero()) _, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) @@ -884,7 +953,7 @@ var _ = Describe("refreshItems", func() { }) It("skips an id that does not exist instead of queuing it", func() { - Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-2"}, &out)).To(Equal(1)) + Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1)) _, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") @@ -892,8 +961,8 @@ var _ = Describe("refreshItems", func() { }) It("continues past a failing id and counts the failures", func() { - Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, - []string{"al-1", "al-2", "al-3"}, &out)).To(Equal(1)) + Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-1"}, + {Kind: model.KindAlbumArtwork, ID: "al-2"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(Equal(1)) Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), "the ids after a failure are still refreshed") diff --git a/model/get_entity.go b/model/get_entity.go index 3e1a78d1d..e5d41f1be 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -7,21 +7,36 @@ import ( // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - getters := []func() (any, error){ - func() (any, error) { return ds.Artist(ctx).Get(id) }, - func() (any, error) { return ds.Album(ctx).Get(id) }, - func() (any, error) { return ds.Playlist(ctx).Get(id) }, - func() (any, error) { return ds.MediaFile(ctx).Get(id) }, - func() (any, error) { return ds.Radio(ctx).Get(id) }, + entity, _, err := getEntity(ctx, ds, id) + return entity, err +} + +// GetEntityKindByID resolves a bare entity id to its artwork Kind, searching the same tables as +// GetEntityByID. It reports ErrNotFound when no entity owns the id. +func GetEntityKindByID(ctx context.Context, ds DataStore, id string) (Kind, error) { + _, kind, err := getEntity(ctx, ds, id) + return kind, err +} + +func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) { + getters := []struct { + kind Kind + get func() (any, error) + }{ + {KindArtistArtwork, func() (any, error) { return ds.Artist(ctx).Get(id) }}, + {KindAlbumArtwork, func() (any, error) { return ds.Album(ctx).Get(id) }}, + {KindPlaylistArtwork, func() (any, error) { return ds.Playlist(ctx).Get(id) }}, + {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile(ctx).Get(id) }}, + {KindRadioArtwork, func() (any, error) { return ds.Radio(ctx).Get(id) }}, } - for _, get := range getters { - entity, err := get() + for _, g := range getters { + entity, err := g.get() if err == nil { - return entity, nil + return entity, g.kind, nil } if !errors.Is(err, ErrNotFound) { - return nil, err + return nil, Kind{}, err } } - return nil, ErrNotFound + return nil, Kind{}, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go index f8a4c9e8e..4e589406a 100644 --- a/model/get_entity_test.go +++ b/model/get_entity_test.go @@ -38,3 +38,25 @@ var _ = Describe("GetEntityByID", func() { Expect(err).ToNot(MatchError(model.ErrNotFound)) }) }) + +var _ = Describe("GetEntityKindByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the artwork kind for the matching id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) + kind, err := model.GetEntityKindByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(kind).To(Equal(model.KindAlbumArtwork)) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityKindByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) From 3d3c3ed60186deb95461cd80b052e564fb9d2055 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:53:11 -0600 Subject: [PATCH 456/596] ci: lint with the golangci-lint version the Makefile declares (#5994) * ci: lint with the golangci-lint version the Makefile declares The workflow asked for `version: latest` while the Makefile pins `GOLANGCI_LINT_VERSION ?= v2.12.0`, so `make lint` and CI ran different linters. golangci-lint v2.13.0 started reporting G404 on three existing `rand.Shuffle` calls, which turned every PR red without a line of Go changing. Read the version from the Makefile instead of resolving `latest`, so the two stay in step and a new release cannot break unchanged code. * chore: re-run CI --- .github/workflows/pipeline.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8e6e8126a..b91c19505 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -68,10 +68,16 @@ jobs: with: go-version-file: go.mod + # Keep CI on the same version `make lint` installs, so a clean local run + # cannot turn red in CI just because a new golangci-lint was released. + - name: Resolve golangci-lint version + id: golangci-version + run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" + - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: latest + version: ${{ steps.golangci-version.outputs.version }} problem-matchers: true args: --timeout 2m From fc1c1366dcfb18c6ac885fe271d04ec6fede8c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 20 Aug 2026 09:04:09 -0400 Subject: [PATCH 457/596] fix(cli): write `pls -p` playlist output to stdout (#5996) The export path used the `println` builtin, which writes to stderr, so `navidrome pls -p X > playlist.m3u8` produced an empty file while the M3U body was interleaved with the startup logs on stderr. `println` also appended a newline that `ToM3U8` already provides, so the piped output had a stray trailing blank line that `-o file` did not. Both destinations are now byte-identical. The stdout/file choice moved into a `writePlaylist` helper shared by `pls -p` and `pls export -p`, which both had the same bug. It takes the destination as an `io.Writer`, matching the existing convention in cmd/artwork.go. --- cmd/pls.go | 17 ++++++++++------- cmd/pls_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 cmd/pls_test.go diff --git a/cmd/pls.go b/cmd/pls.go index 184ca6fe7..93b411483 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strconv" @@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod func runExporter(ctx context.Context) { ds, ctx := getAdminContext(ctx) playlist := findPlaylist(ctx, ds, playlistID) - pls := playlist.ToM3U8() - if outputFile == "-" || outputFile == "" { - println(pls) + writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) +} + +func writePlaylist(m3u string, out io.Writer, file string) { + if file == "" || file == "-" { + fmt.Fprint(out, m3u) return } - err := os.WriteFile(outputFile, []byte(pls), 0600) - if err != nil { - log.Fatal("Error writing to the output file", "file", outputFile, err) + if err := os.WriteFile(file, []byte(m3u), 0600); err != nil { + log.Fatal("Error writing to the output file", "file", file, err) } } @@ -157,7 +160,7 @@ func runExport(ctx context.Context) { if playlistID != "" && outputFile == "" { playlist := findPlaylist(ctx, ds, playlistID) - println(playlist.ToM3U8()) + writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) return } diff --git a/cmd/pls_test.go b/cmd/pls_test.go new file mode 100644 index 000000000..f3e8c7edd --- /dev/null +++ b/cmd/pls_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writePlaylist", func() { + const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n" + plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid())) + + BeforeEach(func() { + DeferCleanup(func() { _ = os.Remove(plsFile) }) + }) + + DescribeTable("writes the playlist to exactly one destination", + func(file, wantStream, wantFile string) { + var out strings.Builder + + writePlaylist(m3u, &out, file) + + written, _ := os.ReadFile(plsFile) + Expect(out.String()).To(Equal(wantStream)) + Expect(string(written)).To(Equal(wantFile)) + }, + Entry("no file name writes to the stream", "", m3u, ""), + Entry("a dash writes to the stream", "-", m3u, ""), + Entry("a path writes to the file", plsFile, "", m3u), + ) +}) From 17db7d40770bb30edffc675323e4329a9303288a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 20 Aug 2026 10:02:04 -0400 Subject: [PATCH 458/596] ci: pull base images through mirror.gcr.io instead of ECR Public (#5997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI builds were failing at random with: buildx failed with: toomanyrequests: Rate exceeded The 429 comes from public.ecr.aws, not Docker Hub. AWS caps unauthenticated ECR Public pulls at 1 per second per source IP (authenticated: 10/s). The build matrix starts 11 jobs at once and each resolves 4 base images, so roughly 44 anonymous pulls land in a couple of seconds — from GitHub runner IPs that are shared with every other GitHub customer. Measured against public.ecr.aws with an anonymous token, 60 requests at concurrency 30 returned 31x 429 in 0.48s. The same probe against mirror.gcr.io (Google's Docker Hub pull-through cache) returned zero errors: 750 manifest requests up to ~141 req/s, plus 120 layer blob requests at concurrency 60. Google publishes no rate limit for it, so this is measured headroom, not a contract — but it is roughly 10x the pipeline's peak rate, and cached pulls do not count against Docker Hub's limits either. Authenticating to ECR Public was the alternative. It was rejected because 10 pulls/s is still under the ~44-pull burst, it needs an AWS account plus a secret, and secrets never reach fork pull requests — so forks would keep failing. The mirror fixes forks too. Verified buildkit honours the mirror block by routing a build through a local logging registry: all 6 requests (manifests and blobs) hit the mirror, none went to Docker Hub directly. Confirmed mirror.gcr.io answers 200 for buildkit's "?ns=docker.io" query form on all four images, for both GET and HEAD. Confirmed buildkit falls back to Docker Hub when the mirror is unreachable — the build still succeeds, but the resolve takes ~30s instead of ~0.3s, so a mirror outage means slow builds, not broken ones. The existing Docker Hub login covers that fallback on main-repo runs. msitools.dockerfile is only used by the local `make docker-msi` target, but is switched over too so no ECR Public reference is left behind. --- .github/actions/prepare-docker/action.yml | 5 +++++ Dockerfile | 10 +++++----- release/wix/msitools.dockerfile | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index b8cde4aaf..6cb54dbdb 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -68,6 +68,11 @@ runs: - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v4 + with: + # Runner IPs are shared, so anonymous base image pulls get rate-limited. + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["mirror.gcr.io"] - name: Extract metadata for Docker image id: meta diff --git a/Dockerfile b/Dockerfile index df5df52ab..847c19bf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build +FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/ ######################################################################################################################## ### Build Navidrome UI -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui +FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui WORKDIR /app # Install node dependencies @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -85,7 +85,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base +FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace @@ -154,7 +154,7 @@ COPY --from=build /out / ######################################################################################################################## ### Build Final Image -FROM public.ecr.aws/docker/library/alpine:3.20 AS final +FROM alpine:3.20 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" diff --git a/release/wix/msitools.dockerfile b/release/wix/msitools.dockerfile index 38364eb47..90249c1ce 100644 --- a/release/wix/msitools.dockerfile +++ b/release/wix/msitools.dockerfile @@ -1,3 +1,3 @@ -FROM public.ecr.aws/docker/library/alpine +FROM alpine RUN apk update && apk add jq msitools WORKDIR /workspace \ No newline at end of file From 0a55cc8cafdde9ab92aa0a207507dc2165ff1caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 20 Aug 2026 22:48:26 -0400 Subject: [PATCH 459/596] docs(plugins): document how a metadata agent signals "not found" (#6001) A MetadataAgent plugin reports "I have no data for this item" by returning an empty response with a nil error. Any error it returns instead is treated as a plugin fault and retried with backoff. That rule was not documented anywhere, so an author naturally returns an error for a missing item, and Navidrome then retries every item the plugin's source does not cover. This is not hypothetical: the artist-nfo-metadata plugin returned an error for every artist without an artist.nfo, which kept those artists in the artwork retry queue for hours and tripped the artwork circuit breaker for the plugin as a whole. Document the rule on the capability interface, which ndpgen copies into the Go PDK, and in the MetadataAgent section of the plugin README. --- plugins/README.md | 8 ++++++++ plugins/capabilities/metadata_agent.go | 3 +++ plugins/pdk/go/metadata/metadata.go | 3 +++ plugins/pdk/go/metadata/metadata_stub.go | 3 +++ 4 files changed, 17 insertions(+) diff --git a/plugins/README.md b/plugins/README.md index b04e12bd9..7042b8c45 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -174,6 +174,14 @@ Capabilities define what your plugin can do. They're automatically detected base Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. +> **Returning "not found".** When you have no data for an item, return an empty response and no +> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found" +> and stops asking. +> +> Return an error only when the plugin itself failed, such as an unreachable API or a broken host +> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes +> Navidrome retry every item it has no data for. + | Function | Input | Output | Description | |-----------------------------------|----------------------------|----------------------------------|--------------------------| | `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index f856562c6..72cb1622f 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -9,6 +9,9 @@ import "github.com/navidrome/navidrome/plugins/types" // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. // +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. +// //nd:capability name=metadata type MetadataAgent interface { // GetArtistMBID retrieves the MusicBrainz ID for an artist. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index c561c2893..bb0ae9620 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -186,6 +186,9 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. +// +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index e72cca103..572eba4da 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -184,6 +184,9 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. +// +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. From c26f6f9e981dcdbf14a86120bcda5e3d0a3e74a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 21 Aug 2026 10:24:01 -0400 Subject: [PATCH 460/596] feat(artwork): store the resolution trace so `artwork explain` works offline (#5980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): record the resolution trace so explain works without --live The worker never attached a ChainTrace, so `artwork explain` had to re-walk the priority chain at CLI time. That reconstruction could disagree with what actually happened, and without --live it could not report the external tier at all. The worker now traces every acquisition and stores it. `explain` reads the stored trace by default and reports when it was recorded; --live re-walks and calls the agents. Disc artwork keeps no row, so it always walks live. A chain trace alone would have explained almost nothing about failures: six of the seven ways an item can fail happen after the chain has already picked a winner. The trace now covers those stages too, and has somewhere to live when they fail: the retrying queue row carries the last failure, and the state row keeps it in last_failure once the retry budget is spent and the queue row is deleted. Measured on a copy of a 682MB / 43.6k-item library: +9.7MB (+1.4%). No row crosses the WITHOUT ROWID overflow threshold, so list hydration is unchanged; only full scans of item_artwork, which no request performs, read more pages. * test(artwork): pin the give-up ordering that keeps a failure for unresolved items recordGiveUp updates an existing row, and for a kind with a recheck path that row is only created moments earlier by the absent settle. Recording before the settle would lose the failure for every item that never resolved, with nothing to catch it. * refactor(artwork): tighten the trace code after review Four fixes worth taking: The doc comments on ChainTrace and chainState.trace still said the worker never attaches a trace and resolution stays allocation-free — the exact invariant this branch reverses. explain's report field meant both "the chain shown was walked just now" and "go out for real", and was being passed to loadPluginAgents, which --live documents as the only thing that may open external connections. Renamed to `walked` and restored explainLive as the sole input to that decision. A stored Detail is an error string on the failure paths, with no bound. The measured "no row reaches the WITHOUT ROWID overflow limit" only holds while it is bounded, so cap it at 200 runes. offlineGate was a factory returning a constant closure; make it a plain gateFunc like its sibling passthroughGate. Collapse five copies of the age-a-queue-row loop in the worker tests into one helper. * refactor(artwork): drop the offline explain walk, now that traces are stored `artwork explain` reported the external tier without calling it, so a diagnostic could not add load to a provider already rate-limiting us. Reading the stored trace answers that better: it reports what the agents actually returned, not what would be tried. Nothing could reach the offline gate any more. It was installed only for a walk with --live unset, which now happens for disc artwork alone, and disc rejects the external candidate before any gate call. That made the gate, its sentinel error, the would-try outcome and two of explain's verdicts unreachable. Removes offlineGate, errOfflineSkipped, OutcomeWouldTry, the NewTracingResolver live parameter and the CreateArtworkResolver argument threaded through wire. Verified against a copy of a real library: disc artwork with "external" first in DiscArtPriority and external services enabled still records the skip and issues no agent call. * fix(artwork): make explain's no-network guarantee structural, not incidental Serving falls back disc -> album and track -> disc -> album. The resolver layer explain uses has no such fallback today, so dropping the offline gate did not leak. But the guarantee rested on which chains happen to lack an external tier, and the serving layer already shows the fallback shape someone could mirror. Without --live the tracing resolver is now built with no agents at all, so no chain and no fallback added later can reach a provider. That is stronger than the gate it replaces, which only intercepted the call. The test pins it against exactly that regression: with the guard removed and the serving fallback mirrored into resolveDisc, it fails. * refactor(artwork): trim the trace plumbing EncodeTrace was exported for nobody: only this package writes traces, and cmd reads them. It becomes a ChainTrace method, which also drops the copy Steps made for a caller that only wanted to serialize. explain's report carried queuedSteps and failureSteps, both pure functions of the queue and state rows already in the struct, which let a test set the two out of step with each other. formatExplain derives them, as it already does for every other display value. The trace row format and its tabwriter empty-cell rule lived in two places, and the "nothing was ever recorded" predicate in three. * fix(artwork): clear the queue trace on a fresh re-enqueue Enqueue's conflict clause reset attempts to 0 but left the new trace column, so after a scan or refresh re-enqueued a previously-failed item artwork explain showed "Attempts: 0" next to the prior lifecycle's "Last attempt failed" trace. Clear trace in Enqueue (a fresh lifecycle has no last attempt); EnqueuePreservingBackoff still keeps it. * fix(artwork): treat a processing-stage error as indeterminate in explain A read/hash/decode/store failure records an OutcomeError step and writes an absent row, but explainResult only mapped external errors and unreadable candidates to indeterminate, so the default verdict read "not resolved" — presenting a processing failure as a definitive miss. The worker retries these exactly as it retries an unreadable candidate, so classify any OutcomeError as indeterminate too. * fix(artwork): record a trace step when a chainless resolver faults Playlist and radio resolvers walk no priority chain, so a fault (unreadable upload/sidecar, or an m3u fetch error with no grid) returned localError/extError without recording any trace step. The attempt then encoded [], leaving artwork explain with an empty "Last attempt failed" and "Gave up after". Record a fallback step in the faulted-no-image branch when nothing else did, and carry the source label through resolveLocalFile so the step can name it. * fix(artwork): trace the m3u failure at its source, not via the empty guard A playlist's grid sampling records album-chain steps into the shared trace, so the processor's empty-trace fallback no longer fires when the m3u remote image fetch failed — the error that forced the retry was omitted from explain. Record it where it happens, in resolvePlaylist's external step, as external:m3u. * test(artwork): skip the chainless-fault spec on Windows The spec provokes an open fault with a non-directory parent, but Windows maps that to a not-exist error, so localError is never set and the item resolves absent instead of failed. The sibling failed-on-unreadable-upload spec skips Windows for the same class of reason. * fix(artwork): don't label an absent empty-chain row as pre-tracing explain reported "resolved before traces were recorded" for any stored row with an empty chain, but an empty CoverArtPriority records a real, empty [] chain and resolves absent. A recorded resolution that finds an image always records its winning candidate, so only a row with a hash and no chain predates tracing; split on the hash and report an absent empty chain plainly instead. * fix(db): retimestamp the artwork trace migration after rebase master merged a 2026-08-18 migration, so the original 2026-08-16 timestamp is now older than the newest on the base branch and Goose would silently skip it on an already-upgraded database. Bumped past it; the SQL is unchanged. * fix(artwork): keep the m3u error detail in the trace The m3u trace step recorded OutcomeError with no detail because resolveExternalStep collapsed the gate's error to a bool, so explain showed only "external:m3u error -" and could not tell a timeout from an HTTP error or an open breaker. Return the error (normalizing not-found to nil so it stays a definitive miss, not a failure) and store its message as the step detail; encodeSteps already bounds it. * docs(artwork): note the give-up write relies on serial draining recordGiveUp writes last_failure unconditionally; that is only correct because the drain resolves each item serially, so no concurrent success can store artwork between the write and the queue delete. Record the invariant at the call site. --- cmd/artwork.go | 108 ++++++++--- cmd/artwork_test.go | 108 +++++++---- core/artwork/agent_images.go | 6 +- core/artwork/artwork.go | 13 +- core/artwork/processor.go | 21 ++- core/artwork/processor_test.go | 29 +++ core/artwork/resolve.go | 26 ++- core/artwork/resolve_test.go | 31 ++++ core/artwork/trace.go | 107 ++++++++--- core/artwork/trace_test.go | 170 +++++++++++------- core/artwork/worker.go | 20 ++- core/artwork/worker_test.go | 95 ++++++++-- ...260819204637_add_artwork_trace_columns.sql | 9 + model/artwork.go | 10 +- persistence/artwork_queue_repository.go | 11 +- persistence/artwork_queue_repository_test.go | 27 ++- persistence/artwork_repository.go | 10 ++ persistence/artwork_repository_test.go | 45 +++++ tests/mock_artwork_queue_repo.go | 3 +- tests/mock_artwork_repo.go | 14 ++ 20 files changed, 662 insertions(+), 201 deletions(-) create mode 100644 db/migrations/20260819204637_add_artwork_trace_columns.sql diff --git a/cmd/artwork.go b/cmd/artwork.go index aeaec0e43..63f0d0917 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -36,8 +36,9 @@ var ( func init() { artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, - "perform real external lookups instead of reporting what would be tried; "+ - "also initializes plugin agents, which may open external connections") + "walk the chain again now, performing real external lookups, instead of reporting the "+ + "stored trace of the last resolution; also initializes plugin agents, which may open "+ + "external connections") artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil, "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil, @@ -640,10 +641,6 @@ func explainResult(source string, steps []artwork.TraceStep) string { if s.Outcome == artwork.OutcomeHit { break } - if s.Outcome == artwork.OutcomeWouldTry { - return "resolved from " + source + - " (offline: a higher-priority external candidate was not tried; re-run with --live)" - } // An external winner discards the earlier error, so the resolver settles it with no retry. if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) && !strings.HasPrefix(source, artwork.ExternalPrefix) { @@ -655,14 +652,12 @@ func explainResult(source string, steps []artwork.TraceStep) string { } for _, s := range steps { switch { - case s.Outcome == artwork.OutcomeWouldTry: - return "indeterminate (external agents not called; re-run with --live)" case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix): return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" - // The worker treats an unreadable local candidate exactly as it treats a failed external one: - // it retries instead of settling absent, so the verdict must not read as a clean miss. - case s.Outcome == artwork.OutcomeUnreadable: - return "indeterminate (a candidate exists but could not be read; the worker retries rather than settling absent)" + // A stage error or an unreadable candidate means a source was found but not processed; the + // worker retries rather than settling absent, so neither reads as a clean miss. + case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable: + return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)" } } return "not resolved" @@ -684,22 +679,55 @@ func explainConfig(kind model.Kind) (name, value string) { } type explainReport struct { - kind model.Kind - id string - name string - stored *model.ItemArtwork - queued *model.ArtworkQueueItem - agents string + kind model.Kind + id string + name string + stored *model.ItemArtwork + queued *model.ArtworkQueueItem + agents string + // steps is the chain walk: recorded when the item was resolved, or performed just now when walked. steps []artwork.TraceStep source string + walked bool resolveErr error } +// explainChainOrigin says whether the operator is reading history or a walk performed just now, +// since the two can disagree after a config change. +func explainChainOrigin(rep explainReport) string { + if rep.walked { + return "walked now" + } + if rep.stored != nil { + return "recorded " + formatTime(rep.stored.AttemptedAt) + } + return "not recorded" +} + +// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and +// break the alignment, so a missing detail is rendered as a dash. +func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) { + for _, s := range steps { + fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) + } +} + +// writeStepTable prints a secondary trace, and nothing at all when there is none to show. +func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) { + if len(steps) == 0 { + return + } + // No tab on the title: it closes the preceding column block, so these rows align among themselves. + fmt.Fprintf(w, " %s:\n", title) + writeSteps(w, " ", steps) +} + func formatExplain(rep explainReport) string { var sb strings.Builder w := newTabWriter(&sb) explainable := artwork.Explainable(rep.kind) stateful := artwork.KeepsState(rep.kind) + unrecorded := !rep.walked && rep.stored == nil fmt.Fprintln(w, "Item") fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix()) @@ -732,6 +760,12 @@ func formatExplain(rep explainReport) string { fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts) fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt)) } + if rep.queued != nil { + writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, "")) + } + if rep.stored != nil { + writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, "")) + } fmt.Fprintln(w, "\nConfig") if setting, value := explainConfig(rep.kind); setting == "" { @@ -743,15 +777,22 @@ func formatExplain(rep explainReport) string { } } - fmt.Fprintln(w, "\nChain") - if !explainable { + fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep)) + switch { + case !explainable: fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind) - } else { + case unrecorded: + fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)") + case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "": + // A stored image with no chain can only predate trace recording: a recorded resolution that + // found an image always records its winning candidate. + fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)") + case !rep.walked && len(rep.steps) == 0: + // Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row. + fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)") + default: fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL") - for _, s := range rep.steps { - // A row with an empty last cell would end tabwriter's column block, breaking alignment. - fmt.Fprintf(w, " %s\t%s\t%s\n", s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) - } + writeSteps(w, " ", rep.steps) } fmt.Fprintln(w, "\nResult") @@ -760,6 +801,8 @@ func formatExplain(rep explainReport) string { fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr) case !explainable: fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)") + case unrecorded: + fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)") default: fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps)) } @@ -807,6 +850,8 @@ func runExplain(ctx context.Context, args []string) { } } + // Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now. + rep.walked = explainLive || !artwork.KeepsState(kind) if artwork.Explainable(kind) { // Only artist and album reach an agent, and the load must precede the resolver, which reads // the same manager. @@ -815,11 +860,16 @@ func runExplain(ctx context.Context, args []string) { defer func() { _ = mgr.Stop() }() rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind)) } - trace := &artwork.ChainTrace{} - rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) - rep.steps = trace.Steps() + switch { + case rep.walked: + trace := &artwork.ChainTrace{} + rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) + rep.steps = trace.Steps() + case rep.stored != nil: + rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath) + rep.source = rep.stored.Source + } } - fmt.Print(formatExplain(rep)) // The steps taken before a failed walk are the diagnosis, so report them before exiting. if rep.resolveErr != nil { diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 8b50ba775..38a1b79cb 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -145,6 +145,15 @@ var _ = Describe("explainResult", func() { "the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss") }) + It("reports indeterminate when a processing stage errored after a candidate was found", func() { + steps := []artwork.TraceStep{ + {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, + {Candidate: "store", Outcome: "error", Detail: "disk full"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "a stage error is a processing failure the worker retries, not a definitive miss") + }) + It("does not qualify a hit that an earlier unreadable candidate preceded", func() { // chainState.try stamps only the external error onto a hit and drops the local one, so the // worker settles this as found; warning about it would be a false alarm. @@ -164,34 +173,6 @@ var _ = Describe("explainResult", func() { "a failed network call is not evidence that the item has no artwork") }) - It("qualifies a win a skipped higher-priority external candidate could have taken", func() { - steps := []artwork.TraceStep{ - {Candidate: "external:deezer", Outcome: "would-try"}, - {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, - } - res := explainResult("artist.*", steps) - Expect(res).To(ContainSubstring("resolved from artist.*")) - Expect(res).To(ContainSubstring("--live"), - "offline, the winner is only the winner because the external tier was skipped") - }) - - It("does not qualify a win that no skipped candidate outranked", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "hit"}, - {Candidate: "external:deezer", Outcome: "would-try"}, - } - Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) - }) - - It("reports indeterminate when external agents were never called", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "miss"}, - {Candidate: "external:deezer", Outcome: "would-try"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "an offline run must not claim an item is unresolvable when external agents were skipped") - }) - It("qualifies a win a failed higher-priority external lookup could have taken", func() { steps := []artwork.TraceStep{ {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, @@ -252,9 +233,10 @@ var _ = Describe("formatExplain", func() { id: "ar-1", name: "Radiohead", agents: "lastfm,spotify", + walked: true, steps: []artwork.TraceStep{ {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, - {Candidate: "external:deezer", Outcome: "would-try"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, }, source: "", } @@ -267,7 +249,6 @@ var _ = Describe("formatExplain", func() { Expect(out).To(ContainSubstring("ArtistArtPriority")) Expect(out).To(ContainSubstring("lastfm,spotify")) Expect(out).To(ContainSubstring("external:deezer")) - Expect(out).To(ContainSubstring("would-try")) Expect(out).To(ContainSubstring("indeterminate")) }) @@ -304,7 +285,7 @@ var _ = Describe("formatExplain", func() { out := formatExplain(rep) Expect(out).To(ContainSubstring("resolution failed: no such directory")) Expect(out).ToNot(ContainSubstring("indeterminate")) - Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print") + Expect(out).To(ContainSubstring("external:deezer"), "the steps taken before the failure still print") }) It("says a kind that does not walk a chain has no chain, without an empty table", func() { @@ -329,6 +310,7 @@ var _ = Describe("formatExplain", func() { kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)", steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}}, source: "folder", + walked: true, } out := formatExplain(rep) @@ -341,10 +323,74 @@ var _ = Describe("formatExplain", func() { Expect(out).To(ContainSubstring("resolved from folder")) }) + Context("stored traces", func() { + BeforeEach(func() { + rep.walked = false + rep.steps = nil + }) + + It("labels a recorded chain with when it was recorded, not as a walk done now", func() { + attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted} + rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}} + rep.source = "folder" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)")) + Expect(out).To(ContainSubstring("/music/artist.jpg")) + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + It("says so when the item has never been resolved", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("no resolution recorded yet")) + Expect(out).To(ContainSubstring("--live")) + Expect(out).ToNot(ContainSubstring("not resolved"), + "nothing was recorded, which is not the same as resolving to nothing") + }) + + It("distinguishes a row written before traces existed from one with an empty chain", func() { + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()} + + Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded")) + }) + + It("does not call an absent row with an empty recorded chain a pre-tracing row", func() { + // An empty priority list records a real but empty chain and resolves absent; that is not a + // legacy row, so it must not be reported as resolved before tracing existed. + rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()} + + out := formatExplain(rep) + Expect(out).ToNot(ContainSubstring("resolved before traces were recorded")) + Expect(out).To(ContainSubstring("no candidates were recorded")) + Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly") + }) + + It("prints why the last attempt failed and why it gave up", func() { + rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3, + Trace: `[{"c":"decode","o":"error","d":"bad header"}]`} + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(), + LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`} + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Last attempt failed")) + Expect(out).To(ContainSubstring("bad header")) + Expect(out).To(ContainSubstring("Gave up after")) + Expect(out).To(ContainSubstring("i/o timeout")) + }) + + It("omits the failure tables when there is no failure to report", func() { + out := formatExplain(rep) + Expect(out).ToNot(ContainSubstring("Last attempt failed")) + Expect(out).ToNot(ContainSubstring("Gave up after")) + }) + }) + It("reports the setting that governs media file artwork", func() { conf.Server.EnableMediaFileCoverArt = false rep = explainReport{ kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag", + walked: true, steps: []artwork.TraceStep{ {Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"}, }, diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index a6f746959..95596dabc 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -58,7 +58,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar return nil, "", false } for _, a := range imageAgents { - reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) if err != nil { return nil, "", err @@ -69,6 +69,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } return fromURL(ctx, u) }) + recordAgent(ctx, a.Name, reader, path, err) if reader != nil { return reader, a.Name, false } @@ -90,7 +91,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m return nil, "", false } for _, a := range imageAgents { - reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) if err != nil { return nil, "", err @@ -101,6 +102,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m } return fromURL(ctx, u) }) + recordAgent(ctx, a.Name, reader, path, err) if reader != nil { return reader, a.Name, false } diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 7edc80e99..e8458a0f9 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -393,16 +393,15 @@ type TracingResolver struct { trace *ChainTrace } -// NewTracingResolver builds a TracingResolver that records its priority-chain walk. With live -// false the external tier is reported but never called. +// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live +// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider; +// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed. func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver { - gate := offlineGate(t) + inner := newLocalResolver(ds, ffm) if live { - // A diagnostic must show the provider's real answer, and one item is at most one call - // per agent, so --live deliberately bypasses the rate limiter and circuit breaker. - gate = tracingGate(t, passthroughGate) + inner = newResolver(ds, ag, ffm, passthroughGate) } - return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), trace: t} + return &TracingResolver{inner: inner, trace: t} } // Resolve walks kind's sources for id, recording the walk, and reports the winning source diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 4d38ced95..fdb28189a 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -2,6 +2,7 @@ package artwork import ( "bytes" + "cmp" "context" "encoding/base64" "errors" @@ -89,12 +90,21 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o res, err := p.resolver.resolve(ctx, item) if err != nil { + traceStage(ctx, "resolve", err) log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } if res.reader == nil { if res.extError || res.localError { // A fault is not a definitive "no image": never settle absent, keep serving old state. + // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. + if t := traceFrom(ctx); len(t.Steps()) == 0 { + outcome := OutcomeError + if res.localError { + outcome = OutcomeUnreadable + } + t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) + } log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", "kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError) return outcomeFailed, nil @@ -106,6 +116,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o readStart := time.Now() data, err := readCapped(res.reader) if err != nil { + traceStage(ctx, "read", err) log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) return outcomeFailed, nil } @@ -115,6 +126,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o hashStart := time.Now() hash, err := hashImage(bytes.NewReader(data)) if err != nil { + traceStage(ctx, "hash", err) log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } @@ -138,19 +150,22 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o art, err = undecodedArtwork(hash), nil } if err != nil { + traceStage(ctx, "decode", err) log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) default: + traceStage(ctx, "lookup", err) log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } art.SizeBytes = int64(len(data)) - ia, err := p.persist(repo, item, art, res, data) + ia, err := p.persist(ctx, repo, item, art, res, data) if err != nil { + traceStage(ctx, "store", err) log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } @@ -165,7 +180,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o // persist places the bytes and commits the rows referencing them, excluding Prune for that // window only so a slow resolution can never hold it off. -func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem, +func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem, art *model.Artwork, res resolution, data []byte, ) (*model.ItemArtwork, error) { if p.pruneLock != nil { @@ -188,6 +203,7 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu SourcePath: sourcePath, RefMtime: refMtime, AttemptedAt: time.Now(), + Trace: traceFrom(ctx).encode(sourcePath), } // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. if err := repo.PutItemArtwork(ia); err != nil { @@ -203,6 +219,7 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A ItemID: item.ItemID, ImageType: item.ImageType, AttemptedAt: time.Now(), + Trace: traceFrom(ctx).encode(""), }) if err != nil { log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 1ada8415d..0ca5a308e 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -229,6 +229,35 @@ var _ = Describe("processor.acquire", func() { Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") }) + // Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback, + // explain would show a give-up with an empty "Gave up after" table. + It("chainless fault: records a fallback trace step naming the faulted source", func() { + if runtime.GOOS == "windows" { + // os.Open under a non-directory maps to a not-exist error on Windows, so no localError. + Skip("cannot provoke an open fault via a non-directory parent on Windows") + } + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds.MockedRadio = radioRepo + dir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(dir) + upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg") + // A plain file where the upload's parent should be makes os.Open fault with ENOTDIR, + // deterministically and regardless of the test user's privileges. + Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed()) + radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} + + trace := &ChainTrace{} + out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) + Expect(out).To(Equal(outcomeFailed)) + + steps := trace.Steps() + Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank") + Expect(steps[0].Candidate).To(Equal("upload")) + Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable)) + }) + It("failed-on-extError: leaves the item's state untouched", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index d25f76460..f42beb9f1 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -37,7 +37,7 @@ type resolution struct { // transient external failure still retries; localErr is dropped, as the scanner re-lists changes. type chainState struct { extErr, localErr bool - trace *ChainTrace // nil unless the CLI asked for a trace + trace *ChainTrace // nil only where no caller attached one } // try stamps the accumulated external failure onto a hit, and records the miss otherwise. @@ -354,10 +354,13 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } - if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok { + if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { return res, nil - } else if isErr { + } else if err != nil { extErr = true + // Record it here with its detail: once album sampling adds its own steps, the processor's + // empty-trace fallback no longer fires, and the error that forced the retry would be lost. + traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) } } @@ -461,14 +464,17 @@ func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, erro return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain) } -// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes -// a not-found, which is a definitive "no" rather than a failure. -func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) { +// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a +// definitive "no", returned as (_, false, nil); any other error is a failure the caller records. +func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) { r, path, err := gate(name, sf) if r != nil { - return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false + return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil } - return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) + if errors.Is(err, model.ErrNotFound) { + return resolution{}, false, nil + } + return resolution{}, false, err } // classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a @@ -561,7 +567,9 @@ func resolveLocalFile(path, source string) (resolution, bool) { } f, err := os.Open(path) if err != nil { - return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false + // Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can + // still name what faulted in the trace. + return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false } return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 8b4c11c8c..236e76b9b 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -520,6 +520,37 @@ var _ = Describe("resolveItem", func() { Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) + It("records the m3u failure in the trace even when album sampling adds its own steps", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return nil, "", errors.New("network down") + } + + trace := &ChainTrace{} + res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), + model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + + steps := trace.Steps() + var m3u *TraceStep + for i := range steps { + if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError { + m3u = &steps[i] + } + } + Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback") + Expect(m3u.Detail).To(Equal("network down"), + "the trace must carry the underlying error so explain can tell a timeout from an HTTP error") + }) + It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces diff --git a/core/artwork/trace.go b/core/artwork/trace.go index 5d02fa4ff..bca2f2c7d 100644 --- a/core/artwork/trace.go +++ b/core/artwork/trace.go @@ -2,10 +2,12 @@ package artwork import ( "context" - "errors" + "encoding/json" "io" "slices" "sync" + + "github.com/navidrome/navidrome/utils/str" ) // Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these. @@ -16,7 +18,6 @@ const ( OutcomeMiss Outcome = "miss" OutcomeUnreadable Outcome = "unreadable" OutcomeSkipped Outcome = "skipped" - OutcomeWouldTry Outcome = "would-try" OutcomeError Outcome = "error" ) @@ -34,8 +35,8 @@ type TraceStep struct { Detail string } -// ChainTrace collects the walk of a single resolution. The artwork worker never attaches -// one; only the CLI does, so resolution stays allocation-free in the hot path. +// ChainTrace collects the walk of a single resolution: the worker attaches one per queue +// item so it can be stored, and the CLI attaches one per explain. type ChainTrace struct { mu sync.Mutex steps []TraceStep @@ -59,6 +60,65 @@ func (t *ChainTrace) Steps() []TraceStep { return slices.Clone(t.steps) } +// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of +// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table. +const maxTraceDetail = 200 + +// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace +// is written for every item, and the encoded length is repeated across the whole library. +type storedStep struct { + C string `json:"c"` + O Outcome `json:"o"` + D string `json:"d,omitempty"` +} + +// encode serializes the trace for storage, without the copy Steps would make for a caller +// that only wants to write it. +func (t *ChainTrace) encode(sourcePath string) string { + if t == nil { + return encodeSteps(nil, sourcePath) + } + t.mu.Lock() + defer t.mu.Unlock() + return encodeSteps(t.steps, sourcePath) +} + +// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the +// same row already stores as source_path, so it is dropped and DecodeTrace puts it back. +func encodeSteps(steps []TraceStep, sourcePath string) string { + out := make([]storedStep, 0, len(steps)) + for _, s := range steps { + d := s.Detail + if s.Outcome == OutcomeHit && d == sourcePath { + d = "" + } + out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")}) + } + b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail + return string(b) +} + +// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all, +// since a diagnostic command must not fail on a bad row. +func DecodeTrace(encoded, sourcePath string) []TraceStep { + if encoded == "" { + return nil + } + var stored []storedStep + if err := json.Unmarshal([]byte(encoded), &stored); err != nil { + return nil + } + steps := make([]TraceStep, 0, len(stored)) + for _, s := range stored { + d := s.D + if d == "" && s.O == OutcomeHit { + d = sourcePath + } + steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d}) + } + return steps +} + type traceCtxKey struct{} func withTrace(ctx context.Context, t *ChainTrace) context.Context { @@ -70,30 +130,23 @@ func traceFrom(ctx context.Context) *ChainTrace { return t } -var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)") - -// tracingGate records each external agent's outcome without changing what the gate returns. -func tracingGate(t *ChainTrace, inner gateFunc) gateFunc { - return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - r, path, err := inner(name, f) - candidate := ExternalPrefix + name - switch { - case r != nil: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) - case isTransientExternal(err): - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) - default: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) - } - return r, path, err +// recordAgent files what one external agent answered. The agent loops call this rather than a +// gate wrapper, because only they hold the context that carries the trace. +func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) { + t := traceFrom(ctx) + candidate := ExternalPrefix + name + switch { + case r != nil: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) + case isTransientExternal(err): + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) + default: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) } } -// offlineGate reports which agents would be asked without asking them, so a diagnostic -// command cannot add load to a provider that is already rate-limiting us. -func offlineGate(t *ChainTrace) gateFunc { - return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry}) - return nil, "", errOfflineSkipped - } +// traceStage records a failure from the stages that run after the priority chain has already +// picked a winner: most ways an item can fail are here, not in the chain walk. +func traceStage(ctx context.Context, stage string, err error) { + traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()}) } diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go index 5a54c9e91..a16347457 100644 --- a/core/artwork/trace_test.go +++ b/core/artwork/trace_test.go @@ -24,13 +24,63 @@ var _ = Describe("trace vocabulary", func() { // what `artwork explain` tells an operator, so it must be made deliberately. It("pins the wire values the CLI reads", func() { Expect([]Outcome{ - OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError, - }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"})) + OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError, + }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"})) Expect(externalCandidate).To(Equal("external")) Expect(ExternalPrefix).To(Equal("external:")) }) }) +var _ = Describe("encodeSteps/DecodeTrace", func() { + It("round-trips a trace", func() { + steps := []TraceStep{ + {Candidate: "cover.png", Outcome: OutcomeMiss}, + {Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"}, + } + Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps)) + }) + + It("encodes an empty trace as an empty JSON array", func() { + Expect(encodeSteps(nil, "")).To(Equal("[]")) + Expect(DecodeTrace("[]", "")).To(BeEmpty()) + }) + + It("tolerates a row written before the column existed", func() { + Expect(DecodeTrace("", "")).To(BeEmpty()) + }) + + // The hit detail repeats source_path byte for byte, and that column is on the same row. + It("drops a hit detail that repeats sourcePath, and restores it on read", func() { + path := "/music/artist/album/cover.jpg" + steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}} + encoded := encodeSteps(steps, path) + Expect(encoded).NotTo(ContainSubstring(path)) + Expect(DecodeTrace(encoded, path)).To(Equal(steps)) + }) + + It("keeps a detail that differs from sourcePath", func() { + steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}} + Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) + }) + + // A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would + // slow every scan; Detail is an error string on the failure paths, so it needs a bound. + It("bounds a detail so one long error cannot inflate the row", func() { + steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}} + + got := DecodeTrace(encodeSteps(steps, ""), "") + + Expect(len(got[0].Detail)).To(BeNumerically("<=", 210)) + Expect(got[0].Detail).To(HaveSuffix("...")) + Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step") + }) + + It("only restores sourcePath onto a detail-less hit", func() { + steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}} + Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) + }) +}) + var _ = Describe("chainTrace", func() { It("returns nil when no trace is attached", func() { Expect(traceFrom(context.Background())).To(BeNil()) @@ -113,63 +163,41 @@ var _ = Describe("chainState tracing", func() { }) }) -var _ = Describe("external gate tracing", func() { - hit := func() (io.ReadCloser, string, error) { - return io.NopCloser(strings.NewReader("x")), "http://img", nil - } - miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } - boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") } +var _ = Describe("external agent tracing", func() { + var ( + t *ChainTrace + ctx context.Context + body io.ReadCloser + ) + BeforeEach(func() { + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + body = io.NopCloser(strings.NewReader("x")) + }) It("records a hit with the image path", func() { - t := &ChainTrace{} - g := tracingGate(t, passthroughGate) - - r, _, err := g("deezer", hit) - - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) + recordAgent(ctx, "deezer", body, "http://img", nil) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, })) }) It("records a miss for a not-found", func() { - t := &ChainTrace{} - _, _, _ = tracingGate(t, passthroughGate)("deezer", miss) + recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) }) It("records a miss for a model not-found", func() { - t := &ChainTrace{} - notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound } - _, _, _ = tracingGate(t, passthroughGate)("deezer", notFound) + recordAgent(ctx, "deezer", nil, "", model.ErrNotFound) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), "both not-found flavours are definitive answers, not faults") }) It("records an error with its reason", func() { - t := &ChainTrace{} - _, _, _ = tracingGate(t, passthroughGate)("apple-music", boom) + recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429")) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) }) - - It("never calls the agent in offline mode", func() { - t := &ChainTrace{} - called := false - counting := func() (io.ReadCloser, string, error) { - called = true - return hit() - } - - _, _, err := offlineGate(t)("deezer", counting) - - Expect(called).To(BeFalse(), "offline mode must not perform external requests") - Expect(err).To(MatchError(errOfflineSkipped)) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "external:deezer", Outcome: OutcomeWouldTry}, - })) - }) }) var _ = Describe("resolveAlbum tracing", func() { @@ -409,51 +437,51 @@ var _ = Describe("NewTracingResolver", func() { t = &ChainTrace{} }) - Context("offline", func() { + Context("resolving", func() { var fake *fakeImageAgent BeforeEach(func() { - fake = &fakeImageAgent{name: "offline-probe"} + // Misses, so the chain falls through to the local tier and both are traced. + fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound} albumRepo.SetData(model.Albums{{ ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, }}) artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) }) - It("reports the external tier without asking any agent", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + It("asks the agents and records what each answered", func() { + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) - Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider") - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) + Expect(fake.albumCalls).To(Equal(1)) + Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss})) }) It("records the local chain steps too", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) last := t.Steps()[len(t.Steps())-1] - Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate") + Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier") Expect(last.Outcome).To(Equal(OutcomeHit)) }) It("never persists artwork state", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(artworkRepo.ItemData).To(BeEmpty(), - "an offline resolution carries extError, which must never be recorded as a real provider failure") + "explain is read-only; a diagnostic walk must never become the stored answer") Expect(queueRepo.Data).To(BeEmpty()) }) It("resolves an artist without persisting anything", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1") + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(BeEmpty()) - Expect(fake.artistCalls).To(BeZero()) - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) + Expect(fake.artistCalls).To(Equal(1)) Expect(artworkRepo.ItemData).To(BeEmpty()) Expect(queueRepo.Data).To(BeEmpty()) }) @@ -465,31 +493,39 @@ var _ = Describe("NewTracingResolver", func() { ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, }}) - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2") + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") }) + // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but + // if it ever did, an explain without --live would start calling providers uninvited. + It("cannot reach a provider without live, whatever the chain does", func() { + conf.Server.DiscArtPriority = "external, cover.*" + conf.Server.CoverArtPriority = "external, cover.*" + conf.Server.EnableMediaFileCoverArt = true + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/test.mp3"}}) + ds.MockedMediaFile = mfRepo + offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false) + + _, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1") + Expect(err).ToNot(HaveOccurred()) + _, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1") + Expect(err).ToNot(HaveOccurred()) + + Expect(fake.albumCalls).To(BeZero()) + Expect(fake.artistCalls).To(BeZero()) + }) + It("propagates a lookup error", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope") + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope") Expect(err).To(MatchError(model.ErrNotFound)) }) }) - - It("asks the agents when live is true", func() { - fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound} - albumRepo.SetData(model.Albums{{ - ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, - }}) - - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.albumCalls).To(Equal(1)) - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss})) - }) }) var _ = Describe("resolveDisc tracing", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index be8495305..6271e54bf 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -235,6 +235,8 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) + trace := &ChainTrace{} + ctx = withTrace(ctx, trace) out, got := w.proc.acquire(ctx, item) queue := w.proc.ds.ArtworkQueue(ctx) @@ -247,10 +249,11 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc } case outcomeFoundStale, outcomeFailed: retryAt := time.Now().Add(backoff(item.Attempts)) + encoded := trace.encode("") if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its // fresh, immediate eligibility. - if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil { + if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil { log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, @@ -265,6 +268,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) settled = "recorded absent" } + // The queue row is about to go, taking the only record of the failure with it. This write is + // unconditional (not CAS-guarded) — safe only because the drain resolves each item serially. + w.recordGiveUp(ctx, item, encoded) log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { @@ -274,6 +280,18 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc return out, got } +// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item +// that never resolved has no row to update, and creating one would settle it absent. +func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { + kind, ok := model.ParseKind(item.ItemKind) + if !ok { + return + } + if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil { + log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err) + } +} + func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { kind, ok := model.ParseKind(item.ItemKind) if !ok { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 53b6a43b2..248e400e1 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -95,6 +95,17 @@ func (f *fakeEventBroker) getEvents() []events.Event { var _ events.Broker = (*fakeEventBroker)(nil) +// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying. +func expireQueued(q *tests.MockArtworkQueueRepo, id string) { + GinkgoHelper() + for k, v := range q.Data { + if v.ItemID == id { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + q.Data[k] = v + } + } +} + func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { for _, it := range q.Data { if it.ItemKind == kind && it.ItemID == id { @@ -318,12 +329,7 @@ var _ = Describe("Worker", func() { w = NewWorker(ds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) // Age the row past the retry budget. - for k, v := range queueRepo.Data { - if v.ItemID == "al9" { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - queueRepo.Data[k] = v - } - } + expireQueued(queueRepo, "al9") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -345,12 +351,7 @@ var _ = Describe("Worker", func() { imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) - for k, v := range queueRepo.Data { - if v.ItemID == "al10" { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - queueRepo.Data[k] = v - } - } + expireQueued(queueRepo, "al10") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -362,6 +363,67 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") }) + It("records on the queue row why the last attempt failed", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed()) + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + it := findQueued(queueRepo, "al", "al11") + Expect(it).ToNot(BeNil()) + Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll( + HaveField("Candidate", "external:failAgent"), + HaveField("Outcome", OutcomeError), + HaveField("Detail", ContainSubstring("agent timed out")), + )), "a retrying row must say why it is retrying") + }) + + // The give-up path settles absent before recording, so the row exists by the time the + // failure is written. Recording first would silently lose it for every unresolved item. + It("keeps the failure for an item that never resolved at all", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed()) + expireQueued(queueRepo, "al13") + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to") + Expect(ia.Hash).To(BeEmpty()) + Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty()) + }) + + It("keeps the failure on the state row after the queue row is deleted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary, + Hash: "cafebabe", Source: "external:lastfm", + })).To(Succeed()) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed()) + expireQueued(queueRepo, "al12") + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + Expect(findQueued(queueRepo, "al", "al12")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(), + "the queue row is gone, so this is the only remaining record of the failure") + Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art") + }) + // Media files are excluded from RecheckKinds, so an absent row here would never be // revisited: a transient read error would look permanent. It("does not settle absent on exhaustion for a kind with no recheck path", func() { @@ -371,12 +433,7 @@ var _ = Describe("Worker", func() { {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, }) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) - for k, v := range queueRepo.Data { - if v.ItemID == "mfX" { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - queueRepo.Data[k] = v - } - } + expireQueued(queueRepo, "mfX") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -386,6 +443,8 @@ var _ = Describe("Worker", func() { _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "no row leaves the track unresolved, so a later view can still recover it") + // Known gap: with no row and no absent settle, there is nowhere to keep the failure. + // Creating one here would write an empty hash, which every reader treats as absent. }) It("resolves a private playlist under an admin context instead of failing forever", func() { diff --git a/db/migrations/20260819204637_add_artwork_trace_columns.sql b/db/migrations/20260819204637_add_artwork_trace_columns.sql new file mode 100644 index 000000000..90fbf9725 --- /dev/null +++ b/db/migrations/20260819204637_add_artwork_trace_columns.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; + +-- +goose Down +ALTER TABLE artwork_queue DROP COLUMN trace; +ALTER TABLE item_artwork DROP COLUMN last_failure; +ALTER TABLE item_artwork DROP COLUMN trace; diff --git a/model/artwork.go b/model/artwork.go index ea724265a..6107e9ffa 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -51,6 +51,10 @@ type ItemArtwork struct { SourcePath string `structs:"source_path"` // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. RefMtime int64 `structs:"ref_mtime"` + // Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt + // that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace. + Trace string `structs:"trace"` + LastFailure string `structs:"last_failure"` // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL. AttemptedAt time.Time `structs:"attempted_at"` UpdatedAt time.Time `structs:"updated_at"` @@ -91,6 +95,8 @@ type ArtworkQueueItem struct { Attempts int `structs:"attempts"` RetryAt time.Time `structs:"retry_at"` EnqueuedAt time.Time `structs:"enqueued_at"` + // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. + Trace string `structs:"trace"` } // Queue priorities: higher drains first. @@ -109,6 +115,8 @@ type ArtworkRepository interface { PurgeOrphans(createdBefore time.Time) (int64, error) GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error) PutItemArtwork(ia *ItemArtwork) error + // PutLastFailure records the trace of the attempt that exhausted the retry budget. + PutLastFailure(kind Kind, id, imageType, trace string) error DeleteForItems(kind Kind, ids []string) error // GetInfoForItems hydrates a page in one batched query. GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error) @@ -145,7 +153,7 @@ type ArtworkQueueRepository interface { DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error) // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. - MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error Count() (int64, error) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 1ff754dc3..ba9fb6f1a 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -18,6 +18,7 @@ import ( const enqueueChunkSize = 100 // Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. +// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads. var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} type artworkQueueRepository struct { @@ -42,11 +43,12 @@ func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*mo return &res, nil } -// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget. +// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old +// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0). func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, - attempts = 0, enqueued_at = excluded.enqueued_at`, items) + attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items) } func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { @@ -159,7 +161,7 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ } func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - sel := Select("*").From(r.tableName). + sel := Select(enqueueColumns...).From(r.tableName). Where(LtOrEq{"retry_at": time.Now()}). OrderBy("priority DESC", "enqueued_at ASC"). Limit(uint64(n)) @@ -171,10 +173,11 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A return res, err } -func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { upd := Update(r.tableName). Set("attempts", Expr("attempts + 1")). Set("retry_at", retryAt). + Set("trace", trace). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) _, err := r.executeSQL(upd) return err diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index d11d89a1f..84f3e2986 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -127,19 +127,42 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed()) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed()) got, _ = repo.DequeueBatch(10) Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") current := got[0].RetryAt - Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed()) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) got, _ = repo.DequeueBatch(10) Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") all, _ := repo.Count() Expect(all).To(Equal(int64(1))) }) + It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() { + // Fail an attempt so the queue row carries a failure trace. + Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "t1", time.Now().Add(-time.Hour)) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) + + // A continuation of the same lifecycle must retain the trace. + Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) + kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`)) + + // A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it. + Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(fresh.Attempts).To(BeZero()) + Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace") + }) + It("Enqueue restarts the retry budget an existing row had spent", func() { Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) backOff("al", "e1", time.Now().Add(-time.Hour)) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 22662b575..89eb1d415 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -134,11 +134,21 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { } ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, + trace=excluded.trace, last_failure=excluded.last_failure, attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) _, err = r.items.executeSQL(ins) return err } +// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row: +// inserting one would write an empty hash, which the rest of the system reads as a settled absent. +func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error { + upd := Update(itemArtworkTable).Set("last_failure", trace). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) + _, err := r.items.executeSQL(upd) + return err +} + func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error { for chunk := range slices.Chunk(ids, artworkBatchSize) { if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 683dc2d0f..a9687f76b 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -28,6 +28,51 @@ var _ = Describe("ArtworkRepository", func() { repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) }) + Context("resolution traces", func() { + const traceJSON = `[{"c":"cover.*","o":"hit"}]` + + It("round-trips the trace with the state row", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1", + ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed()) + + got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Trace).To(Equal(traceJSON)) + Expect(got.LastFailure).To(BeEmpty()) + }) + + It("replaces the trace when the item is resolved again", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed()) + + got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary) + Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`)) + }) + + It("records a last failure on an existing row", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3", + ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed()) + + Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary, + `[{"c":"decode","o":"error"}]`)).To(Succeed()) + + got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary) + Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`)) + Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork") + }) + + // Inserting here would write hash='', which every reader treats as a settled absent. + It("never creates a row for an item that has no state", func() { + Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, + `[{"c":"decode","o":"error"}]`)).To(Succeed()) + + _, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("image identity", func() { It("stores and retrieves an artwork by hash", func() { a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index c8e915daa..f8f57e8d9 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -118,7 +118,7 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.Art return res, nil } -func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -128,6 +128,7 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { it.Attempts++ it.RetryAt = retryAt + it.Trace = trace m.Data[k] = it } return nil diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 2ace0daba..5d76a0169 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -122,6 +122,20 @@ func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) return nil, model.ErrNotFound } +func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + key := iaKey(kind.Prefix(), id, imageType) + if ia, ok := m.ItemData[key]; ok { + ia.LastFailure = trace + m.ItemData[key] = ia + } + return nil +} + func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { m.mu.Lock() defer m.mu.Unlock() From ffc68e29dbf53baf11ea6ae06d225a2ddea64e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 21 Aug 2026 14:03:59 -0400 Subject: [PATCH 461/596] feat(cli): add `artwork cancel` to call off queued artwork work (#6006) * feat(cli): add `artwork cancel` to call off queued artwork work A bulk backfill had no off switch. Changing an artwork setting bumps the config fingerprint, which enqueues every entity in the library, and the only way to stop it was to turn agents off -- which changes the fingerprint again and enqueues a second full backfill. The escape hatch was the trap. `artwork cancel` deletes pending queue rows selected by --kind and/or --priority, with the --dry-run/confirm/-y flow `reprocess` already uses. Cancelling by priority is the point: it drops a runaway backfill while leaving the bump-priority rows an operator queued by hand. It only touches the queue. Resolved artwork and the item_artwork state behind `artwork explain` are left alone, and the trace of why a cancelled item last failed goes with its row. Preserving that trace would mean writing it to last_failure, which `explain` prints under "Gave up after" -- reporting a cancellation as an exhausted retry budget. The help text says the trace is discarded instead. Two limits the help text states, because neither is guessable: work already dequeued is not interrupted, and an item with no artwork state yet can be queued again by the hourly missing-artwork recheck. Cancel calls off queued work; it does not stop the worker. --kind validates against RefreshableKinds, not the RecheckKinds `reprocess` uses: the queue holds media file rows, so --all has to reach them. PurgeQueued follows the repository's naming rule -- it finds its own rows and reports how many went -- and ignores retry_at, since a row still backing off is pending work. The preview reuses CountByKindAndPriority rather than adding a counter. reprocessConfirm became confirmUnlessYes(yes, in, verb) now that two commands prompt. * refactor(cli): share the artwork queue filter between the preview and the delete Follow-up cleanup on the previous commit; no change to what the command does, apart from --all, noted below. The "which rows does cancel touch" predicate was written three times: once as SQL in PurgeQueued, once in Go in cmd's matchingQueueStats, and once more in the mock. The preview and the delete could therefore drift, and the mock would keep the tests green while they did. persistence now has one artworkQueueFilter, shared by PurgeQueued and a new CountQueued, and cmd does no filtering at all. That also makes the preview cheaper. It counted the whole queue and filtered in Go, so `artwork cancel --kind al` scanned every row of every kind to print a handful. CountQueued pushes the filter into SQL, which the drain index serves as a range seek. CountByKindAndPriority is gone: it is CountQueued(nil, nil). --all now selects with an empty filter instead of enumerating RefreshableKinds. It is what the flag help already claimed, and the enumeration was narrower than its own documentation -- a queue row whose item_kind this build does not know survived `--all` with no flag combination able to remove it. It also restores SQLite's truncate path: measured with EXPLAIN QUERY PLAN, a bare DELETE plans to nothing, while `WHERE (1=1)` -- which an empty squirrel And renders -- plans to a full index scan. A test pins the filter's emptiness so that cannot regress silently. Also folded together three copies of the parse-and-dedup loop (parseAll), two copies of the queue-stats table (printQueueStats, now shared with `artwork status`), two copies of the stat sum (queueTotal), and four copies of the kind-to-prefix mapping (model.KindPrefixes). The PurgeQueued specs became one DescribeTable that asserts count and delete agree on every selection. * docs(cli): say when `artwork cancel` evaluates its selection The help text covered the two limits that surprise an operator after the fact, but not the one that bites during the prompt: the count is a preview, and the filters run again on confirm. A scan or a manual refresh landing in between is cancelled without ever appearing in the table the operator agreed to. Deleting only the previewed rows was considered and rejected. The exposure is one item re-resolving on next view instead of immediately: clearing an item's artwork state is what every recovery path selects on, so a lost Bump row from artwork.Refresh comes back at the same priority via provisional() on the next request, and otherwise within the hour via EnqueueAllMissing. Buying a guarantee against that costs the truncate path on --all, the flag that exists for a 29k-item backfill. * refactor(cli): share one set of flag targets across the artwork subcommands reprocess and cancel each declared their own kinds/all/dry-run/yes variables, but cobra only ever parses the one subcommand being run, so the two sets could never hold values at the same time. backup.go already binds one backupDir across two subcommands and one force across two more; this follows that. Ten package-level variables become six. Each command keeps its own help string and its own valid-kind list, so --kind still reports RecheckKinds for reprocess and RefreshableKinds for cancel, and --source and --priority stay registered only on the command that has them. The priority lookup table is now knownPriorities, freeing the artworkPriorities name for the flag. The new name also reads better against priorityName's fallback for a value it does not know. --- cmd/artwork.go | 223 +++++++++++++++---- cmd/artwork_test.go | 168 +++++++++++++- model/artwork.go | 7 +- model/artwork_id.go | 7 + persistence/artwork_queue_repository.go | 40 +++- persistence/artwork_queue_repository_test.go | 71 +++++- tests/mock_artwork_queue_repo.go | 29 ++- 7 files changed, 479 insertions(+), 66 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index 63f0d0917..b193abeaf 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -26,12 +26,14 @@ import ( var explainLive bool +// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets. var ( - reprocessKinds []string - reprocessSources []string - reprocessAll bool - reprocessDryRun bool - reprocessYes bool + artworkKinds []string + artworkSources []string + artworkPriorities []string + artworkAll bool + artworkDryRun bool + artworkYes bool ) func init() { @@ -39,17 +41,26 @@ func init() { "walk the chain again now, performing real external lookups, instead of reporting the "+ "stored trace of the last resolution; also initializes plugin agents, which may open "+ "external connections") - artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil, + artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") - artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil, + artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, "only items currently resolved from these sources (e.g. folder, external:deezer, absent)") - artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind") - artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false, + artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") + artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, "report what would be queued and exit without queueing") - artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt") + artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") + artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, + "kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable") + artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil, + "only rows queued at these priorities ("+priorityNames()+"); repeatable") + artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority") + artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, + "report what would be cancelled and exit without cancelling") + artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") artworkCmd.AddCommand(artworkExplainCmd) artworkCmd.AddCommand(artworkRefreshCmd) artworkCmd.AddCommand(artworkReprocessCmd) + artworkCmd.AddCommand(artworkCancelCmd) artworkCmd.AddCommand(artworkStatusCmd) rootCmd.AddCommand(artworkCmd) } @@ -93,6 +104,22 @@ var artworkReprocessCmd = &cobra.Command{ }, } +var artworkCancelCmd = &cobra.Command{ + Use: "cancel", + Short: "Cancel pending artwork work in bulk, by kind and/or queue priority", + Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" + + "Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" + + "left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" + + "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + + "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + + "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + + "backfill, not to stop the worker.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runCancel(cmd.Context()) + }, +} + var artworkStatusCmd = &cobra.Command{ Use: "status", Short: "Report the artwork queue, where artwork resolves from, and the backfill state", @@ -133,9 +160,11 @@ type statusReport struct { current string } -func (r statusReport) queueTotal() int64 { +func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) } + +func queueTotal(stats []model.ArtworkQueueStat) int64 { var n int64 - for _, s := range r.queue { + for _, s := range stats { n += s.Count } return n @@ -155,7 +184,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error q := ds.ArtworkQueue(ctx) var rep statusReport var err error - if rep.queue, err = q.CountByKindAndPriority(); err != nil { + if rep.queue, err = q.CountQueued(nil, nil); err != nil { return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) } @@ -195,11 +224,7 @@ func formatStatus(rep statusReport) string { if len(rep.queue) == 0 { fmt.Fprintln(w, " (empty)") } else { - fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS") - for _, s := range rep.queue { - fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count) - } - fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal()) + printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ") } fmt.Fprintln(w, "\nSources") @@ -246,6 +271,15 @@ func backfillState(rep statusReport) string { return "up to date" } +// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it. +func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) { + fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader) + for _, s := range stats { + fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count) + } + fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total) +} + func kindName(prefix string) string { if k, ok := model.ParseKind(prefix); ok { return k.String() @@ -253,22 +287,44 @@ func kindName(prefix string) string { return prefix } +type artworkPriority struct { + name string + value int +} + +// knownPriorities is the one listing behind both the name and the parse, so they cannot drift. +var knownPriorities = []artworkPriority{ + {"bump", model.ArtworkPriorityBump}, + {"scan", model.ArtworkPriorityScan}, + {"backfill", model.ArtworkPriorityBackfill}, + {"recheck", model.ArtworkPriorityRecheck}, +} + +// priorityName falls back to the number: a row written by a newer version still has to print. func priorityName(p int) string { - switch p { - case model.ArtworkPriorityRecheck: - return "recheck" - case model.ArtworkPriorityBackfill: - return "backfill" - case model.ArtworkPriorityScan: - return "scan" - case model.ArtworkPriorityBump: - return "bump" + for _, ap := range knownPriorities { + if ap.value == p { + return ap.name + } } return strconv.Itoa(p) } +func priorityNames() string { + return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ") +} + +func parseArtworkPriority(s string) (int, error) { + for _, ap := range knownPriorities { + if ap.name == s { + return ap.value, nil + } + } + return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames()) +} + func runReprocess(ctx context.Context) { - kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll) + kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll) if err != nil { log.Fatal(ctx, err) } @@ -285,8 +341,8 @@ func runReprocess(ctx context.Context) { imageAgents = imageAgentCount(ds, mgr) } - if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents, - reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil { + if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, + artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil { log.Fatal(ctx, err) } } @@ -299,16 +355,9 @@ func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { if len(kinds) == 0 { return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") } - out := make([]model.Kind, 0, len(kinds)) - for _, k := range kinds { - kind, err := parseArtworkKind(k, artwork.RecheckKinds) - if err != nil { - return nil, err - } - out = append(out, kind) - } - // A repeated kind would be counted twice, overstating the cost the operator confirms. - return slice.Unique(out), nil + return parseAll(kinds, func(s string) (model.Kind, error) { + return parseArtworkKind(s, artwork.RecheckKinds) + }) } // absentSource is how the stored empty source — resolved, no image — is spelled on the CLI. @@ -327,11 +376,11 @@ func displaySource(s string) string { return cmp.Or(s, absentSource) } type confirmFunc func(out io.Writer, total, external int64) bool -func reprocessConfirm(yes bool, in io.Reader) confirmFunc { +func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc { if yes { return func(io.Writer, int64, int64) bool { return true } } - return promptConfirm(in) + return promptConfirm(in, verb) } // externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the @@ -379,13 +428,13 @@ func configuredAgents() []string { return names } -func promptConfirm(in io.Reader) confirmFunc { +func promptConfirm(in io.Reader, verb string) confirmFunc { return func(out io.Writer, total, external int64) bool { var cost string if external > 0 { cost = fmt.Sprintf(" %s", externalLookupLine(external)) } - fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost) + fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost) var answer string if _, err := fmt.Fscanln(in, &answer); err != nil { return false @@ -477,6 +526,90 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin return nil } +func runCancel(ctx context.Context) { + kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll) + if err != nil { + log.Fatal(ctx, err) + } + + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun, + confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil { + log.Fatal(ctx, err) + } +} + +// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row +// whose kind this build does not know still gets cancelled. +func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) { + if all { + return nil, nil, nil + } + if len(kinds) == 0 && len(priorities) == 0 { + return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") + } + // RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them. + outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { + return parseArtworkKind(s, artwork.RefreshableKinds) + }) + if err != nil { + return nil, nil, err + } + outPriorities, err := parseAll(priorities, parseArtworkPriority) + if err != nil { + return nil, nil, err + } + return outKinds, outPriorities, nil +} + +// parseAll drops repeats: a doubled selector would overstate the total the operator confirms. +func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) { + out := make([]T, 0, len(values)) + for _, v := range values { + parsed, err := parse(v) + if err != nil { + return nil, err + } + out = append(out, parsed) + } + return slice.Unique(out), nil +} + +func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int, + dryRun bool, confirm confirmFunc, out io.Writer) error { + q := ds.ArtworkQueue(ctx) + matched, err := q.CountQueued(kinds, priorities) + if err != nil { + return fmt.Errorf("counting queued artwork: %w", err) + } + total := queueTotal(matched) + w := newTabWriter(out) + printQueueStats(w, matched, total, "MATCHED", "") + w.Flush() + + switch { + case total == 0: + fmt.Fprintln(out, "\nNothing matches this selection.") + return nil + case dryRun: + fmt.Fprintln(out, "\nDry run: nothing was cancelled.") + return nil + case !confirm(out, total, 0): + fmt.Fprintln(out, "Aborted: nothing was cancelled.") + return nil + } + + cancelled, err := q.PurgeQueued(kinds, priorities) + if err != nil { + return fmt.Errorf("cancelling queued artwork: %w", err) + } + // Count and delete are separate statements, so a drain in between makes these two differ. + fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total) + return nil +} + // printReprocessPreview also states the external estimate, which --dry-run must show because it // skips the prompt that would otherwise carry it. func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) { @@ -542,7 +675,7 @@ var explainKinds = []model.Kind{ } func kindPrefixes(kinds []model.Kind) string { - return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ") + return strings.Join(model.KindPrefixes(kinds), ", ") } func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 38a1b79cb..1d2bdae73 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -549,36 +549,36 @@ var _ = Describe("promptConfirm", func() { BeforeEach(func() { out.Reset() }) It("states the external cost and accepts an explicit yes", func() { - Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue()) + Expect(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&out, 42, 7)).To(BeTrue()) Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) }) It("defaults to no on anything else", func() { - Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&out, 1, 1)).To(BeFalse()) }) It("drops the external clause when no lookup will be made", func() { - Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("re-resolve 3 items.")) + Expect(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("cancel 3 items.")) Expect(out.String()).ToNot(ContainSubstring("External lookups")) }) }) -var _ = Describe("reprocessConfirm", func() { +var _ = Describe("confirmUnlessYes", func() { var out strings.Builder BeforeEach(func() { out.Reset() }) It("prompts when --yes was not given", func() { - Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse()) + Expect(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&out, 5, 5)).To(BeFalse()) Expect(out.String()).To(ContainSubstring("Continue?")) }) It("bypasses the prompt only for --yes", func() { - Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue()) + Expect(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue()) Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") }) }) @@ -1059,3 +1059,153 @@ var _ = Describe("configuredAgents", func() { Expect(configuredAgents()).To(BeEmpty()) }) }) + +var _ = Describe("parseArtworkPriority", func() { + It("accepts every name status prints", func() { + for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill, + model.ArtworkPriorityScan, model.ArtworkPriorityBump} { + Expect(parseArtworkPriority(priorityName(p))).To(Equal(p)) + } + }) + + It("rejects an unknown name and lists the valid ones", func() { + _, err := parseArtworkPriority("urgent") + Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`))) + Expect(err).To(MatchError(ContainSubstring("backfill"))) + }) + + // Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing. + It("rejects the numeric form", func() { + _, err := parseArtworkPriority("10") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("artwork cancel selection", func() { + It("errors when no selector is given", func() { + _, _, err := cancelSelection(nil, nil, false) + Expect(err).To(MatchError(ContainSubstring("no selector given"))) + }) + + // Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind + // this build does not recognise. + It("selects with no filter at all for --all", func() { + kinds, priorities, err := cancelSelection(nil, nil, true) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty()) + Expect(priorities).To(BeEmpty()) + }) + + // The queue holds media file rows, so --all must reach them. + It("accepts media file artwork, which reprocess does not", func() { + kinds, _, err := cancelSelection([]string{"mf"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork})) + }) + + It("treats a priority filter on its own as a complete selection", func() { + kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty(), "no kind filter means every kind") + Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill})) + }) + + It("returns only the named kinds and priorities", func() { + kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork})) + Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan})) + }) + + It("counts a repeated kind and a repeated priority once", func() { + kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(HaveLen(1)) + Expect(priorities).To(HaveLen(1)) + }) + + It("rejects an unknown kind", func() { + _, _, err := cancelSelection([]string{"zz"}, nil, false) + Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`))) + }) + + It("rejects a kind that is never queued", func() { + _, _, err := cancelSelection([]string{"dc"}, nil, false) + Expect(err).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("rejects an unknown priority", func() { + _, _, err := cancelSelection(nil, []string{"urgent"}, false) + Expect(err).To(MatchError(ContainSubstring("invalid priority"))) + }) +}) + +var _ = Describe("cancelArtwork", func() { + var ds *tests.MockDataStore + var queue *tests.MockArtworkQueueRepo + var out strings.Builder + ctx := context.Background() + accept := func(io.Writer, int64, int64) bool { return true } + decline := func(io.Writer, int64, int64) bool { return false } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + Expect(queue.Enqueue( + model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBackfill}, + model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump}, + model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBackfill}, + )).To(Succeed()) + }) + + It("previews the per-kind breakdown and cancels nothing on a dry run", func() { + Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("artist")) + Expect(out.String()).To(ContainSubstring("backfill")) + Expect(out.String()).To(ContainSubstring("TOTAL")) + Expect(out.String()).To(ContainSubstring("Dry run")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("cancels nothing when the operator declines", func() { + Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Aborted")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("deletes the selected rows and leaves the rest queued", func() { + Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(BeNumerically("==", 1)) + _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued") + Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items.")) + }) + + It("cancels every kind and priority when neither filter is given", func() { + Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed()) + Expect(queue.Count()).To(BeZero()) + }) + + It("stops at a selection that matches nothing instead of prompting", func() { + refuse := func(io.Writer, int64, int64) bool { + Fail("must not prompt when nothing matches") + return false + } + Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches this selection.")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("reports a queue read failure instead of reporting nothing to cancel", func() { + queue.Err = errors.New("read failed") + Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed"))) + }) +}) diff --git a/model/artwork.go b/model/artwork.go index 6107e9ffa..b484b0500 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -157,13 +157,16 @@ type ArtworkQueueRepository interface { // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error Count() (int64, error) - // CountByKindAndPriority reports the pending queue rows grouped by kind and priority. - CountByKindAndPriority() ([]ArtworkQueueStat, error) + // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; + // an empty filter means every one. + CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) // CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent // would pick up at the given cutoff. CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) + // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. + PurgeQueued(kinds []Kind, priorities []int) (int64, error) } type ArtworkQueueStat struct { diff --git a/model/artwork_id.go b/model/artwork_id.go index 634a6442f..e827e935a 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "github.com/navidrome/navidrome/utils/slice" ) type Kind struct { @@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{ KindRadioArtwork.prefix: KindRadioArtwork, } +// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string. +func KindPrefixes(kinds []Kind) []string { + return slice.Map(kinds, func(k Kind) string { return k.prefix }) +} + // ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known. // Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain. func ParseKind(prefix string) (Kind, bool) { diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index ba9fb6f1a..5db122a8b 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -191,19 +191,45 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) { return purgeDangling(r.sqlRepository) } +// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps +// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too. +func artworkQueueFilter(kinds []model.Kind, priorities []int) And { + var f And + if len(kinds) > 0 { + f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)}) + } + if len(priorities) > 0 { + f = append(f, Eq{"priority": priorities}) + } + return f +} + +// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses. +func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { + sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName). + GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc") + if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { + sel = sel.Where(f) + } + var res []model.ArtworkQueueStat + err := r.queryAll(sel, &res) + return res, err +} + +func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { + del := Delete(r.tableName) + if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { + del = del.Where(f) + } + return r.executeSQL(del) +} + func (r *artworkQueueRepository) Count() (int64, error) { var res struct{ Count int64 } err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) return res.Count, err } -func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { - var res []model.ArtworkQueueStat - err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName). - GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res) - return res, err -} - // CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue. func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { var res model.ArtworkAbsentStat diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 84f3e2986..35eebdd6d 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -411,7 +411,7 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - Expect(repo.CountByKindAndPriority()).To(ConsistOf( + Expect(repo.CountQueued(nil, nil)).To(ConsistOf( model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, @@ -419,7 +419,7 @@ var _ = Describe("ArtworkQueueRepository", func() { }) It("reports an empty queue as no rows", func() { - Expect(repo.CountByKindAndPriority()).To(BeEmpty()) + Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) }) It("counts absent states and how many are due for recheck", func() { @@ -442,4 +442,71 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{})) }) }) + + Describe("PurgeQueued", func() { + queuedIDs := func() []string { + GinkgoHelper() + got, err := repo.DequeueBatch(100) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + } + + BeforeEach(func() { + Expect(repo.Enqueue( + item("ar", "ar-backfill", model.ArtworkPriorityBackfill), + item("ar", "ar-bump", model.ArtworkPriorityBump), + item("al", "al-backfill", model.ArtworkPriorityBackfill), + item("mf", "mf-scan", model.ArtworkPriorityScan), + )).To(Succeed()) + }) + + // CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so + // every selection must count exactly what it deletes. + DescribeTable("selects the same rows to count and to delete", + func(kinds []model.Kind, priorities []int, deleted int, remaining []string) { + counted, err := repo.CountQueued(kinds, priorities) + Expect(err).ToNot(HaveOccurred()) + var total int64 + for _, s := range counted { + total += s.Count + } + Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete") + + Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted)) + Expect(queuedIDs()).To(ConsistOf(remaining)) + }, + Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil, + 2, []string{"al-backfill", "mf-scan"}), + Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill}, + 2, []string{"ar-bump", "mf-scan"}), + Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill}, + 1, []string{"ar-bump", "al-backfill", "mf-scan"}), + Entry("everything, when neither filter is given", nil, nil, + 4, []string{}), + Entry("several kinds and priorities at once", + []model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork}, + []int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}, + 2, []string{"ar-bump", "al-backfill"}), + Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil, + 0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}), + ) + + It("deletes a row that is still backing off", func() { + backOff("ar", "ar-bump", time.Now().Add(time.Hour)) + + Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) + Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). + Error().To(MatchError(model.ErrNotFound)) + }) + + // A WHERE clause, even one that matches everything, costs SQLite its truncate optimization + // and turns `artwork cancel --all` into a full scan of the queue. + It("adds no conditions at all for an empty filter", func() { + Expect(artworkQueueFilter(nil, nil)).To(BeEmpty()) + Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1)) + Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1)) + Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})). + To(HaveLen(2)) + }) + }) }) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index f8f57e8d9..70f7084ff 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -167,6 +167,30 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { return purged, nil } +// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree. +func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool { + prefixes := model.KindPrefixes(kinds) + return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) && + (len(priorities) == 0 || slices.Contains(priorities, it.Priority)) +} + +func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, it := range m.Data { + if !queueFilterMatches(it, kinds, priorities) { + continue + } + delete(m.Data, k) + purged++ + } + return purged, nil +} + func (m *MockArtworkQueueRepo) Count() (int64, error) { m.mu.Lock() defer m.mu.Unlock() @@ -176,7 +200,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } -func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { +func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -184,6 +208,9 @@ func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueSta } var res []model.ArtworkQueueStat for _, it := range m.Data { + if !queueFilterMatches(it, kinds, priorities) { + continue + } i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { return s.ItemKind == it.ItemKind && s.Priority == it.Priority }) From 07b6411c0bc3ff0c2aafe8c8cfac4d00f2a6ddcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 21 Aug 2026 15:23:07 -0400 Subject: [PATCH 462/596] perf(artwork): cap the stale-absent recheck at 100 items per kind per hour (#6007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): drip the stale-absent recheck instead of bursting it daily Each hourly housekeeping tick now re-queues at most 100 absent states per kind, oldest attempts first, instead of everything older than 24h at once. External agents see a flat ~100 requests/hour per agent instead of hourly bursts of ~2,000, and the effective recheck interval self-scales with the size of the absent pool (~4 days at 10k absent artists) while small libraries keep the 24h floor. * feat(artwork): trust an absent artwork state for a week before rechecking With the recheck now dripped at 100 items per kind per hour, the 24h floor only governed small libraries, where the drip cap never binds; they still re-asked every agent daily. A 7-day floor cuts that cost 7x and, for large libraries, becomes the binding limit over the drip cycle (~5.7k calls/day instead of ~9.6k at 10k absent artists). Among comparable servers, this is still the second-most-eager recheck: gonic retries misses every 30 days, Jellyfin and Funkwhale never do. * refactor(artwork): state the drip's backpressure contract where it bites Review follow-ups: the recheck limit deliberately caps the *selection*, not the insertions — already-queued rows use up budget, so a stalled drain admits no new work instead of building a recovery burst. Say so in the interface doc, mirror it in the mock by truncating the sorted candidates (matching the SQL's LIMIT-before-ON CONFLICT), and teach `artwork status` and the worker doc the post-drip wording. Also pin the one cmd fixture that still assumed a 24h recheck window. --- cmd/artwork.go | 3 ++- cmd/artwork_test.go | 7 ++++--- core/artwork/housekeeping.go | 8 ++++++-- core/artwork/housekeeping_test.go | 19 +++++++++++++++++-- core/artwork/worker.go | 3 ++- model/artwork.go | 9 +++++---- persistence/artwork_queue_repository.go | 9 +++++---- persistence/artwork_queue_repository_test.go | 19 ++++++++++++++++++- tests/mock_artwork_queue_repo.go | 16 +++++++++++----- 9 files changed, 70 insertions(+), 23 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index b193abeaf..01de071bb 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -238,7 +238,8 @@ func formatStatus(rep statusReport) string { for _, a := range rep.absent { fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale) } - fmt.Fprintf(w, " (rechecked once the last attempt is older than %gh)\n", artwork.StaleAbsentAge.Hours()) + fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n", + artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch) fmt.Fprintln(w, "\nBackfill") fmt.Fprintf(w, " State:\t%s\n", backfillState(rep)) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 1d2bdae73..cc63ed86f 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -818,7 +818,7 @@ var _ = Describe("collectStatus", func() { ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour)) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour)) put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", @@ -909,8 +909,9 @@ var _ = Describe("formatStatus", func() { Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) }) - It("states the recheck window the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring("24h")) + It("states the recheck window and the drip rate the absent counts are bucketed against", func() { + Expect(formatStatus(rep)).To(ContainSubstring("168h")) + Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) }) It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index f2996d044..fed5757c8 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -18,7 +18,11 @@ import ( ) // StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 24 * time.Hour +const StaleAbsentAge = 7 * 24 * time.Hour + +// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, +// oldest first, so external agents see a flat drip instead of a daily burst. +const StaleAbsentRecheckBatch = 100 // RecheckKinds omits media files: they resolve embedded-only, at scan or on view. var RecheckKinds = []model.Kind{ @@ -125,7 +129,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { cutoff := time.Now().Add(-StaleAbsentAge) queue := ds.ArtworkQueue(ctx) for _, kind := range RecheckKinds { - if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { + if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil { return err } } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 4ea15ab04..4f229bff8 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "fmt" "slices" "time" @@ -235,8 +236,8 @@ var _ = Describe("Housekeeping", func() { }) It("enqueues only absent entries older than the recheck window, across all kinds", func() { - old := time.Now().Add(-48 * time.Hour) - recent := time.Now().Add(-time.Hour) + old := time.Now().Add(-StaleAbsentAge - time.Hour) + recent := time.Now().Add(-StaleAbsentAge + time.Hour) artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} @@ -259,6 +260,20 @@ var _ = Describe("Housekeeping", func() { Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) }) + + It("caps each tick at the recheck batch, oldest attempts first", func() { + for i := range StaleAbsentRecheckBatch + 1 { + id := fmt.Sprintf("ar%d", i) + artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary, + Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)} + } + + Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed()) + + Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch)) + // ar0 has the newest attempted_at of the cohort, so it is the one left out. + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil()) + }) }) Describe("EnqueueMissingAll", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 6271e54bf..0f947f6d7 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -137,7 +137,8 @@ func (w *Worker) Backfill(ctx context.Context) (bool, error) { return backfill(ctx, w.proc.ds) } -// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge. +// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most +// StaleAbsentRecheckBatch per kind, oldest first. func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { return enqueueStaleAbsentAll(ctx, w.proc.ds) } diff --git a/model/artwork.go b/model/artwork.go index b484b0500..3c0df209b 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -134,8 +134,9 @@ type ArtworkQueueRepository interface { // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a // request-triggered read-through never resets a failed resolution's backoff. EnqueuePreservingBackoff(items ...ArtworkQueueItem) error - // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. - EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error) + // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest + // first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls). + EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (int64, error) // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. EnqueueAllMissing(kind Kind, priority int) (int64, error) // EnqueueIfMissing inserts only for items with no item_artwork row yet. @@ -160,8 +161,8 @@ type ArtworkQueueRepository interface { // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; // an empty filter means every one. CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent - // would pick up at the given cutoff. + // CountAbsent reports the absent states of a kind, and how many are past the given cutoff, + // eligible for EnqueueStaleAbsent (which drains them limit rows per call). CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 5db122a8b..1c0077fc7 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -56,11 +56,12 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { +func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { now := time.Now() return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ? - FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`, - model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore) + FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ? + ORDER BY attempted_at LIMIT ?`, + model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit) } func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { @@ -230,7 +231,7 @@ func (r *artworkQueueRepository) Count() (int64, error) { return res.Count, err } -// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue. +// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from. func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { var res model.ArtworkAbsentStat err := r.queryOne(Select("count(*) as total"). diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 35eebdd6d..1673a5b0e 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -244,7 +244,7 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour)) + n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(int64(1))) @@ -255,6 +255,23 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) }) + It("enqueues only the oldest stale absent states up to the limit", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + now := time.Now() + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed()) + + n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(2))) + + items, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID }) + Expect(ids).To(ConsistOf("oldest", "older")) + }) + It("enqueues entities that have no item_artwork row at all", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 70f7084ff..51ddf4b61 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -272,18 +272,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu return nil } -func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { +func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil || m.ItemArtworkSource == nil { return 0, m.Err } + var stale []model.ItemArtwork + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) { + stale = append(stale, ia) + } + } + slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) }) + // The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget. + stale = stale[:min(limit, len(stale))] now := time.Now() var inserted int64 - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { - continue - } + for _, ia := range stale { k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows continue From 295886cb9a432d375afa796ed8a32db0775464af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 21 Aug 2026 20:27:42 -0400 Subject: [PATCH 463/596] feat(artwork): report what a config-fingerprint backfill enqueued (#6010) * feat(artwork): report what a config-fingerprint backfill enqueued A backfill re-resolves every entity, and on a large library that is tens of thousands of external agent calls. It announced itself with a single line carrying nothing but an elapsed time, so the size of the job was invisible until the request volume showed up hours later. Log the item count, the per-kind breakdown, and a ceiling on the external lookups the queued work can cost. The ceiling reuses ExternalLookupsPerItem, the same estimator behind the `artwork reprocess` preview, so the two agree on what an item can cost. backfill now returns a summary instead of a bare bool, which keeps the counts assertable without capturing log output. Worker.Backfill keeps its (bool, error) signature, so its caller is unchanged, and it reads the agent count off its own resolver. * refactor(artwork): share the image-agent count and take it lazily Counting image agents was written twice, once in the CLI for the `artwork reprocess` preview and again as a resolver method for the backfill log. Two copies of "which agents count as image agents" can drift, and the CLI estimate and the server log would then disagree silently. Move the derivation next to the type it builds, as NewImageAgentCount, and call it from both. The resolver method goes away with it: hanging the census on the resolver forced two nil guards that its only caller could never trigger, because Worker always builds a resolver with agents. The Worker keeps the *agents.Agents it is already handed instead of reaching through the processor and resolver to find it. Pass the count as a func. Building the agent list constructs every enabled agent (each one an HTTP client and a cache goroutine) only to take its length, and a backfill returns early on an unchanged fingerprint, which is what happens on nearly every restart. * docs(artwork): say what the backfill lookup estimate does not bound The comment called the number a ceiling, which the CLI comment on the same estimate already contradicts: externalEstimate "claims no bound". Both are right about the local-source case and only one of them mentions that a retried item asks its agents again. --- cmd/artwork.go | 7 +----- core/artwork/housekeeping.go | 35 ++++++++++++++++++++------- core/artwork/housekeeping_test.go | 39 ++++++++++++++++++++++++------- core/artwork/resolve.go | 9 +++++++ core/artwork/worker.go | 7 ++++-- 5 files changed, 73 insertions(+), 24 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index 01de071bb..8b9e28f0e 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -339,7 +339,7 @@ func runReprocess(ctx context.Context) { if needsImageAgents(kinds) { mgr := loadPluginAgents(ctx, false) defer func() { _ = mgr.Stop() }() - imageAgents = imageAgentCount(ds, mgr) + imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr)) } if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, @@ -397,11 +397,6 @@ func externalLookupLine(n int64) string { return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) } -func imageAgentCount(ds model.DataStore, mgr *plugins.Manager) artwork.ImageAgentCount { - ag := agents.GetAgents(ds, mgr) - return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} -} - // loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a // running server would. A load failure is reported, not fatal: the built-in agents still answer. func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index fed5757c8..9456a5584 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -72,18 +72,27 @@ func ConfigFingerprint() string { return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } +// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one +// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again. +type backfillSummary struct { + Ran bool + PerKind map[string]int64 + Items int64 + MaxExternalLookups int64 +} + // backfill enqueues artwork resolution for every entity when the config fingerprint changed. -func backfill(ctx context.Context, ds model.DataStore) (bool, error) { +func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) { start := time.Now() ctx = auth.WithAdminUser(ctx, ds) current := ConfigFingerprint() props := ds.Property(ctx) stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { - return false, err + return backfillSummary{}, err } if stored == current { - return false, nil + return backfillSummary{}, nil } // Artists first: few entities, most external-dependent, so they get a queue headstart. @@ -96,21 +105,31 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) { {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, } + // Counted here, not by the caller: building the agent list constructs every enabled agent, and + // an unchanged fingerprint returns above without ever needing the number. + agents := agentCount() + summary := backfillSummary{Ran: true, PerKind: map[string]int64{}} for _, k := range kinds { ids, err := k.fetch() if err != nil { - return false, err + return backfillSummary{}, err } if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { - return false, err + return backfillSummary{}, err } + n := int64(len(ids)) + summary.PerKind[k.kind.Prefix()] = n + summary.Items += n + summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents) } if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { - return false, err + return backfillSummary{}, err } - log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start)) - return true, nil + log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items, + "byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups, + "elapsed", time.Since(start)) + return summary, nil } func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 4f229bff8..7aecd2760 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -39,6 +39,8 @@ func adminUserRepo() *tests.MockedUserRepo { return repo } +func noAgents() ImageAgentCount { return ImageAgentCount{} } + // orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can // assert phase ordering (artists-first) that same-priority timestamps can't guarantee. type orderTrackingQueueRepo struct { @@ -164,9 +166,14 @@ var _ = Describe("Housekeeping", func() { seedEntities() Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed()) - did, err := backfill(ctx, ds) + counted := false + s, err := backfill(ctx, ds, func() ImageAgentCount { + counted = true + return ImageAgentCount{Artist: 3, Album: 2} + }) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeFalse()) + Expect(s).To(Equal(backfillSummary{})) + Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it") count, err := queueRepo.Count() Expect(err).ToNot(HaveOccurred()) @@ -176,9 +183,9 @@ var _ = Describe("Housekeeping", func() { It("runs the backfill when no fingerprint was ever stored", func() { seedEntities() - did, err := backfill(ctx, ds) + s, err := backfill(ctx, ds, noAgents) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(s.Ran).To(BeTrue()) count, err := queueRepo.Count() Expect(err).ToNot(HaveOccurred()) @@ -197,9 +204,9 @@ var _ = Describe("Housekeeping", func() { tracks: &tests.MockPlaylistTrackRepo{}, } - did, err := backfill(ctx, vds) + s, err := backfill(ctx, vds, noAgents) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(s.Ran).To(BeTrue()) Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) }) @@ -207,9 +214,9 @@ var _ = Describe("Housekeeping", func() { seedEntities() Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - did, err := backfill(ctx, ds) + s, err := backfill(ctx, ds, noAgents) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(s.Ran).To(BeTrue()) Expect(queueRepo.callKinds).ToNot(BeEmpty()) firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) @@ -224,6 +231,22 @@ var _ = Describe("Housekeeping", func() { Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) } }) + + It("reports what it enqueued, per kind and as an external-lookup ceiling", func() { + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + seedEntities() + + s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} }) + Expect(err).ToNot(HaveOccurred()) + Expect(s.Ran).To(BeTrue()) + + Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1})) + Expect(s.Items).To(Equal(int64(5))) + // 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch. + Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2))) + }) }) Describe("EnqueueStaleAbsentAll", func() { diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index f42beb9f1..6663679fa 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -137,6 +137,15 @@ func MayFetchExternal(kind model.Kind) bool { // ImageAgentCount is how many enabled agents provide artist and album images. type ImageAgentCount struct{ Artist, Album int } +// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that +// guards it cannot disagree about which agents exist. +func NewImageAgentCount(ag *agents.Agents) ImageAgentCount { + if ag == nil { + return ImageAgentCount{} + } + return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} +} + // ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is // tried, and a zero count still bills one, so agents the caller cannot see never read as free. func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 0f947f6d7..0358708c0 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -40,6 +40,7 @@ type drainPool struct { // independently, and pruneMu serializes prune against the store-write window. type Worker struct { proc *processor + agents *agents.Agents cache cache.FileCache ffmpeg ffmpeg.FFmpeg broker events.Broker @@ -54,6 +55,7 @@ type Worker struct { func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { w := &Worker{ proc: &processor{ds: ds, store: store}, + agents: ag, cache: imgCache, ffmpeg: ffmpeg, broker: broker, @@ -132,9 +134,10 @@ func (w *Worker) RunPrune(ctx context.Context) error { } // Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, -// artists first. It reports whether anything was enqueued. +// artists first. It reports whether the backfill ran. func (w *Worker) Backfill(ctx context.Context) (bool, error) { - return backfill(ctx, w.proc.ds) + s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) }) + return s.Ran, err } // EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most From 59810c3d59af724f46c73fd8add72ec5cde1776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 22 Aug 2026 20:36:24 -0400 Subject: [PATCH 464/596] feat(jellyfin): non-expiring, audience-scoped tokens revocable by password change (#6013) * feat(auth): add per-user token_epoch column and bump method * feat(auth): add aud and ep claims, omitted when zero * feat(auth): add CreateAPIToken for non-expiring, audience-scoped tokens * feat(auth): add CheckClaims for epoch and audience validation * feat(jellyfin): issue non-expiring, jellyfin-scoped access tokens * fix(subsonic): reject API-scoped and revoked tokens on the jwt path * fix(server): reject API-scoped and revoked tokens on the native API * fix(server): pin the token-subject guard and stop leaking test config Adds a regression spec for the DevAutoLogin/ExtAuth guard in tokenAllowed, switches its comparison to case-insensitive to match the user lookup's own COLLATE NOCASE semantics, and restores Subsonic JWT test config after each spec instead of leaking SessionTimeout. * feat(request): add a token epoch holder for handler-to-middleware signalling * refactor(server): write the refreshed JWT header after the handler runs * feat(auth): revoke all tokens for a user when their password changes * fix(server): restore Unwrap on the JWT refresh writer so SSE write deadlines apply * test(auth): pin that non-session tokens reject API access tokens * test(jellyfin): pin token scoping and epoch revocation end to end Exercises auth.CreateAPIToken and CheckClaims against the real Jellyfin router and SQLite DB: the minted token has no exp and is aud-scoped to jellyfin, and bumping token_epoch through the real UserRepository revokes an already-issued token on the next protected request. * test(nativeapi): pin the token-epoch handoff through a real password-change request Drive a self password change through the real Authenticator/JWTRefresher chain and a real SQLite-backed userRepository, so the epoch handoff between Put and the refreshed-token writer is verified end to end, not as two separately-tested halves. Also fix tokenAllowed to read the enriched ctx it was given instead of r.Context(), so its warning log carries the username. * refactor(server): drop tokenAllowed's now-unused request parameter Finding-2 already moved every use to ctx; r was dead weight. Also note in the new nativeapi test why it must stay the package's only real-DB spec: db.Db() is a process-wide singleton its cleanup closes for good. * refactor(auth): remove duplication in claim decoding and token minting * refactor(auth): group aud with the standard JWT claims * refactor(auth): read aud with the standard-claim accessor pattern * fix(log): redact every api_key spelling the Jellyfin API accepts * fix(auth): bind session tokens to the user id, not just the username * fix(auth): return the token epoch from the same atomic increment * fix(auth): bump the token epoch in the same statement as the password write * chore(auth): trim comments to the why-only budget --- adapters/lastfm/auth_router_test.go | 9 + core/auth/auth.go | 59 ++++++- core/auth/auth_test.go | 109 ++++++++++++ core/auth/claims.go | 32 +++- core/auth/claims_test.go | 40 +++++ core/stream/token_test.go | 10 ++ .../20260822062750_add_user_token_epoch.sql | 7 + log/log.go | 5 +- log/log_test.go | 11 ++ model/request/request.go | 49 +++++- model/request/request_suite_test.go | 17 ++ model/request/request_test.go | 40 +++++ model/user.go | 2 + persistence/user_repository.go | 35 +++- persistence/user_repository_test.go | 157 ++++++++++++++++++ server/auth.go | 94 +++++++++-- server/auth_test.go | 135 +++++++++++++++ server/jellyfin/README.md | 2 + server/jellyfin/auth.go | 2 +- server/jellyfin/e2e/auth_test.go | 37 +++++ server/jellyfin/middlewares.go | 4 + server/jellyfin/middlewares_test.go | 46 +++++ .../user_password_token_refresh_test.go | 80 +++++++++ server/subsonic/middlewares.go | 4 +- server/subsonic/middlewares_test.go | 31 ++++ 25 files changed, 971 insertions(+), 46 deletions(-) create mode 100644 db/migrations/20260822062750_add_user_token_epoch.sql create mode 100644 model/request/request_suite_test.go create mode 100644 model/request/request_test.go create mode 100644 server/nativeapi/user_password_token_refresh_test.go diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go index 4cbbd4298..1f65c059e 100644 --- a/adapters/lastfm/auth_router_test.go +++ b/adapters/lastfm/auth_router_test.go @@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() { _, err = verifyLinkToken(nonExpiringToken) Expect(err).To(MatchError("link token missing expiration")) }) + + It("rejects a Jellyfin access token", func() { + usr := &model.User{ID: "u1", UserName: "johndoe"} + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(tokenStr) + Expect(err).To(HaveOccurred()) + }) }) }) diff --git a/core/auth/auth.go b/core/auth/auth.go index b1e2667bd..b36bb2696 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,6 +4,8 @@ import ( "cmp" "context" "crypto/sha256" + "errors" + "slices" "sync" "time" @@ -26,6 +28,13 @@ var ( PublicTokenAuth *jwtauth.JWTAuth ) +// Audiences a session token can be scoped to. A token with no audience is accepted anywhere. +const ( + AudienceJellyfin = "jellyfin" + AudienceSubsonic = "subsonic" + AudienceNative = "native" +) + // Init creates the JWTAuth objects from the secrets stored in the DB. // Missing or undecryptable secrets are regenerated and stored. func Init(ds model.DataStore) { @@ -66,15 +75,20 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { return token, err } -func CreateToken(u *model.User) (string, error) { - claims := Claims{ +func userClaims(u *model.User, audience []string) Claims { + return Claims{ Issuer: consts.JWTIssuer, Subject: u.UserName, IssuedAt: time.Now(), UserID: u.ID, IsAdmin: u.IsAdmin, + Epoch: u.TokenEpoch, + Audience: audience, } - token, _, err := TokenAuth.Encode(claims.ToMap()) +} + +func CreateToken(u *model.User) (string, error) { + token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap()) if err != nil { return "", err } @@ -82,10 +96,20 @@ func CreateToken(u *model.User) (string, error) { return TouchToken(token) } +// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin +// clients expect tokens to behave. Revocation is by token epoch, not expiry. +func CreateAPIToken(u *model.User, audience string) (string, error) { + _, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap()) + return token, err +} + func TouchToken(token jwt.Token) (string, error) { - claims := ClaimsFromToken(token). - WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) - _, newToken, err := TokenAuth.Encode(claims.ToMap()) + return TouchClaims(ClaimsFromToken(token)) +} + +func TouchClaims(c Claims) (string, error) { + c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) + _, newToken, err := TokenAuth.Encode(c.ToMap()) return newToken, err } @@ -106,6 +130,29 @@ func ValidatePublic(tokenStr string) (Claims, error) { return ClaimsFromToken(token), nil } +var ( + ErrTokenRevoked = errors.New("token revoked") + ErrWrongAudience = errors.New("token not valid for this API") + ErrWrongUser = errors.New("token issued for a different user") +) + +// CheckClaims gates a session token against the user it names. Callers must have already +// verified the signature; this adds revocation and API scoping on top. +func CheckClaims(c Claims, usr model.User, audience string) error { + // Usernames can be reused: deleting a user and recreating the name yields a new random id + // at epoch 0, which an old token would otherwise match. + if c.UserID != "" && c.UserID != usr.ID { + return ErrWrongUser + } + if c.Epoch != usr.TokenEpoch { + return ErrTokenRevoked + } + if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) { + return ErrWrongAudience + } + return nil +} + func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { u, err := ds.User(ctx).FindFirstAdmin() if err != nil { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index e5cbb2352..c86dcd08c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -151,4 +151,113 @@ var _ = Describe("Auth", func() { Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) + + Describe("CreateAPIToken", func() { + var usr *model.User + + BeforeEach(func() { + usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4} + }) + + It("does not expire", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) + }) + + It("carries the audience and the user's epoch", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + Expect(claims.Epoch).To(Equal(4)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.UserID).To(Equal("123")) + }) + }) + + Describe("CreateToken with an epoch", func() { + It("carries the epoch and still expires", func() { + usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9} + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(9)) + Expect(claims.Audience).To(BeEmpty()) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) + }) + }) + + Describe("TouchClaims", func() { + It("preserves custom claims and refreshes the expiry", func() { + tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5}) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(5)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) + }) + }) + + Describe("CheckClaims", func() { + usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2} + + It("accepts a matching epoch and audience", func() { + c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) + }) + + It("accepts a token with no audience on any API", func() { + c := auth.Claims{Epoch: 2} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) + Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed()) + }) + + It("rejects a stale epoch", func() { + c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked)) + }) + + It("rejects a token minted for another API", func() { + c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience)) + Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience)) + }) + + It("accepts a multi-audience token that includes this API", func() { + c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + }) + + It("accepts a pre-upgrade token against a never-bumped user", func() { + fresh := model.User{ID: "456", UserName: "newbie"} + Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + }) + + It("accepts a token whose user id matches", func() { + c := auth.Claims{UserID: "123", Epoch: 2} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + }) + + It("rejects a token for a deleted user recreated under the same name", func() { + recreated := model.User{ID: "new-random-id", UserName: "johndoe"} + c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser)) + }) + + It("accepts a token that carries no user id", func() { + fresh := model.User{ID: "456", UserName: "newbie"} + Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + }) + }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go index c7e6f02fe..42f7e4f2f 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -11,7 +11,8 @@ import ( type Claims struct { // Standard JWT claims Issuer string - Subject string // username for session tokens + Subject string // username for session tokens + Audience []string // which API may accept this token; empty means any IssuedAt time.Time ExpiresAt time.Time @@ -22,6 +23,7 @@ type Claims struct { Format string // "f" - audio format BitRate int // "b" - audio bitrate ShareID string // "sid" - share ID for share stream tokens + Epoch int // "ep" - the user's token_epoch at mint time } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -34,6 +36,9 @@ func (c Claims) ToMap() map[string]any { if c.Subject != "" { m[jwt.SubjectKey] = c.Subject } + if len(c.Audience) > 0 { + m[jwt.AudienceKey] = c.Audience + } if !c.IssuedAt.IsZero() { m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() } @@ -58,6 +63,9 @@ func (c Claims) ToMap() map[string]any { if c.ShareID != "" { m["sid"] = c.ShareID } + if c.Epoch != 0 { + m["ep"] = c.Epoch + } return m } @@ -73,6 +81,7 @@ func ClaimsFromToken(token jwt.Token) Claims { c.Subject, _ = token.Subject() c.IssuedAt, _ = token.IssuedAt() c.ExpiresAt, _ = token.Expiration() + c.Audience, _ = token.Audience() var uid string if err := token.Get("uid", &uid); err == nil { @@ -90,15 +99,24 @@ func ClaimsFromToken(token jwt.Token) Claims { if err := token.Get("f", &f); err == nil { c.Format = f } - if err := token.Get("b", &c.BitRate); err != nil { - var bf float64 - if err := token.Get("b", &bf); err == nil { - c.BitRate = int(bf) - } - } + c.BitRate = intClaim(token, "b") var sid string if err := token.Get("sid", &sid); err == nil { c.ShareID = sid } + c.Epoch = intClaim(token, "ep") return c } + +// intClaim reads a numeric claim, which a parsed token may decode as either int or float64. +func intClaim(token jwt.Token, key string) int { + var i int + if err := token.Get(key, &i); err == nil { + return i + } + var f float64 + if err := token.Get(key, &f); err == nil { + return int(f) + } + return 0 +} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index 8820fd295..69d054031 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -105,4 +105,44 @@ var _ = Describe("Claims", func() { }) }) + Describe("Audience and Epoch claims", func() { + It("omits both when zero", func() { + m := auth.Claims{ID: "artwork-id"}.ToMap() + Expect(m).ToNot(HaveKey("aud")) + Expect(m).ToNot(HaveKey("ep")) + }) + + It("includes them when set", func() { + m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap() + Expect(m).To(HaveKeyWithValue("ep", 3)) + Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"})) + }) + + It("round-trips through a signed token", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + _, tokenStr, err := tokenAuth.Encode(auth.Claims{ + Subject: "u", Epoch: 7, Audience: []string{"jellyfin"}, + }.ToMap()) + Expect(err).ToNot(HaveOccurred()) + + token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) + Expect(err).ToNot(HaveOccurred()) + claims := auth.ClaimsFromToken(token) + Expect(claims.Epoch).To(Equal(7)) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + }) + + It("reads a token that has neither claim", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + _, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap()) + Expect(err).ToNot(HaveOccurred()) + + token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) + Expect(err).ToNot(HaveOccurred()) + claims := auth.ClaimsFromToken(token) + Expect(claims.Epoch).To(BeZero()) + Expect(claims.Audience).To(BeEmpty()) + }) + }) + }) diff --git a/core/stream/token_test.go b/core/stream/token_test.go index 7409a7532..4f0d8066c 100644 --- a/core/stream/token_test.go +++ b/core/stream/token_test.go @@ -232,6 +232,16 @@ var _ = Describe("Token", func() { _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ErrTokenStale)) }) + + It("rejects a Jellyfin access token", func() { + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} + usr := &model.User{ID: "u1", UserName: "johndoe"} + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + _, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0) + Expect(err).To(MatchError(ErrTokenInvalid)) + }) }) Describe("paramsFromToken", func() { diff --git a/db/migrations/20260822062750_add_user_token_epoch.sql b/db/migrations/20260822062750_add_user_token_epoch.sql new file mode 100644 index 000000000..bd37ddeb4 --- /dev/null +++ b/db/migrations/20260822062750_add_user_token_epoch.sql @@ -0,0 +1,7 @@ +-- +goose Up + +ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0; + +-- +goose Down + +ALTER TABLE user DROP COLUMN token_epoch; diff --git a/log/log.go b/log/log.go index 1c4ee3b4b..f7977c0fa 100644 --- a/log/log.go +++ b/log/log.go @@ -47,8 +47,9 @@ var redacted = &Hook{ // External services query params. Values can be JWTs (dots, dashes), so match everything up // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop - // at a JWT's first '.' and leak its payload and signature. - "([^\\w]api_key=)[^&\\s]+", + // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an + // optional underscore: the API accepts api_key, apikey and ApiKey alike. + "(?i)([^\\w]api_?key=)[^&\\s]+", }, } diff --git a/log/log_test.go b/log/log_test.go index 7b6ecfc32..f0e2e0eae 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -264,5 +264,16 @@ var _ = Describe("Logger", func() { msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) }) + + DescribeTable("redacts every api_key spelling the Jellyfin API accepts", + func(param string) { + msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1")) + }, + Entry("api_key", "api_key"), + Entry("apikey", "apikey"), + Entry("ApiKey", "ApiKey"), + Entry("APIKEY", "APIKEY"), + ) }) }) diff --git a/model/request/request.go b/model/request/request.go index 8d7919298..2b1cfb9ef 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -2,6 +2,7 @@ package request import ( "context" + "sync/atomic" "github.com/navidrome/navidrome/model" ) @@ -9,15 +10,16 @@ import ( type contextKey string const ( - User = contextKey("user") - Username = contextKey("username") - Client = contextKey("client") - Version = contextKey("version") - Player = contextKey("player") - Transcoding = contextKey("transcoding") - ClientUniqueId = contextKey("clientUniqueId") - ReverseProxyIp = contextKey("reverseProxyIp") - InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins + User = contextKey("user") + Username = contextKey("username") + Client = contextKey("client") + Version = contextKey("version") + Player = contextKey("player") + Transcoding = contextKey("transcoding") + ClientUniqueId = contextKey("clientUniqueId") + ReverseProxyIp = contextKey("reverseProxyIp") + InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins + TokenEpochHolder = contextKey("tokenEpochHolder") ) var allKeys = []contextKey{ @@ -125,3 +127,32 @@ func AddValues(ctx, requestCtx context.Context) context.Context { } return ctx } + +type tokenEpochHolder struct { + value atomic.Int64 +} + +// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch +// back to middleware that has already returned from the handler's perspective. +func WithTokenEpochHolder(ctx context.Context) context.Context { + h := &tokenEpochHolder{} + h.value.Store(-1) + return context.WithValue(ctx, TokenEpochHolder, h) +} + +func SetTokenEpoch(ctx context.Context, epoch int) { + if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok { + h.value.Store(int64(epoch)) + } +} + +func TokenEpochFrom(ctx context.Context) (int, bool) { + h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder) + if !ok { + return 0, false + } + if v := h.value.Load(); v >= 0 { + return int(v), true + } + return 0, false +} diff --git a/model/request/request_suite_test.go b/model/request/request_suite_test.go new file mode 100644 index 000000000..643ca76d7 --- /dev/null +++ b/model/request/request_suite_test.go @@ -0,0 +1,17 @@ +package request + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// tests.Init is not used here: the tests package imports model/request, so importing it +// back would create an import cycle. +func TestRequest(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Request Suite") +} diff --git a/model/request/request_test.go b/model/request/request_test.go new file mode 100644 index 000000000..ef9af8231 --- /dev/null +++ b/model/request/request_test.go @@ -0,0 +1,40 @@ +package request + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Token epoch holder", func() { + It("reports nothing when unset", func() { + ctx := WithTokenEpochHolder(context.TODO()) + _, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("round-trips a value set by the handler", func() { + ctx := WithTokenEpochHolder(context.TODO()) + SetTokenEpoch(ctx, 7) + + epoch, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(7)) + }) + + It("survives being wrapped in a derived context", func() { + ctx := WithTokenEpochHolder(context.TODO()) + SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3) + + epoch, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(3)) + }) + + It("is a no-op with no holder installed", func() { + Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic()) + _, ok := TokenEpochFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) +}) diff --git a/model/user.go b/model/user.go index b6f792c9a..37bdca33d 100644 --- a/model/user.go +++ b/model/user.go @@ -22,6 +22,8 @@ type User struct { // This is only available on the backend, and it is never sent over the wire Password string `structs:"-" json:"-"` + // Bumped on password change to invalidate every issued token for this user. + TokenEpoch int `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 diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 3c030a640..9de37876b 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" @@ -126,14 +127,30 @@ func (r *userRepository) Put(u *model.User) error { } delete(values, "current_password") - // Save/update the user + // The epoch bump rides the password UPDATE: as two statements they can interleave with a + // concurrent change and leave a session valid that the other change should have revoked. update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) - count, err := r.executeSQL(update) - if err != nil { - return err + var isNewUser bool + var epoch int + if u.NewPassword != "" { + var res struct{ TokenEpoch int } + err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")). + Suffix("RETURNING token_epoch"), &res) + switch { + case errors.Is(err, model.ErrNotFound): + isNewUser = true + case err != nil: + return err + default: + epoch = res.TokenEpoch + } + } else { + count, err := r.executeSQL(update) + if err != nil { + return err + } + isNewUser = count == 0 } - - isNewUser := count == 0 if isNewUser { values["created_at"] = time.Now() insert := Insert(r.tableName).SetMap(values) @@ -163,6 +180,12 @@ func (r *userRepository) Put(u *model.User) error { } } + // Only the caller's own token can be refreshed in-flight; an admin resetting another + // user must keep their own epoch. + if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID { + request.SetTokenEpoch(r.ctx, epoch) + } + return nil } diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index ec417c193..dc519d0a1 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "slices" + "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -13,6 +14,7 @@ import ( "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -683,4 +685,159 @@ var _ = Describe("UserRepository", func() { Expect(query).To(ContainSubstring("user.id = {:p0}")) }) }) + + Describe("token epoch", func() { + var repo model.UserRepository + var usr model.User + + newUser := func() model.User { + uid := id.NewRandom() + // user_name is unique; suffix it so each It gets its own row in the shared suite DB. + return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"} + } + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) + repo = NewUserRepository(ctx, GetDBXBuilder()) + usr = newUser() + Expect(repo.Put(&usr)).To(Succeed()) + }) + + It("starts at zero for a new user", func() { + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("increments once per password change", func() { + usr.NewPassword = "second" + Expect(repo.Put(&usr)).To(Succeed()) + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(1)) + + usr.NewPassword = "third" + Expect(repo.Put(&usr)).To(Succeed()) + got, err = repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(2)) + }) + + It("leaves the epoch alone when the password is untouched", func() { + usr.NewPassword = "" + usr.Name = "Renamed" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + Expect(got.Name).To(Equal("Renamed")) + }) + + It("never signals the same epoch to two concurrent password changes", func() { + // Each writer's epoch must be the one its own UPDATE produced. + const callers = 4 + var mu sync.Mutex + var signalled []int + var wg sync.WaitGroup + for range callers { + wg.Go(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: usr.ID}) + ctx = request.WithTokenEpochHolder(ctx) + own := NewUserRepository(ctx, GetDBXBuilder()) + + u := usr + u.NewPassword = "concurrent" + if err := own.Put(&u); err != nil { + return // the shared in-memory test DB can raise SQLITE_LOCKED + } + epoch, ok := request.TokenEpochFrom(ctx) + if !ok { + return + } + mu.Lock() + defer mu.Unlock() + signalled = append(signalled, epoch) + }) + } + wg.Wait() + + Expect(signalled).To(HaveLen(len(slice.Unique(signalled))), + "an epoch was signalled to more than one writer: %v", signalled) + }) + }) + + Describe("Put and the token epoch", func() { + newRepo := func(actingUserID string) model.UserRepository { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) + ctx = request.WithTokenEpochHolder(ctx) + return NewUserRepository(ctx, GetDBXBuilder()) + } + + It("does not bump when creating a user", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("bumps when the password changes", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(1)) + }) + + It("does not bump on an edit that leaves the password alone", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "" + usr.Name = "New Display Name" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("signals the new epoch when a user changes their own password", func() { + userID := id.NewRandom() + repo := newRepo(userID) + usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(1)) + }) + + It("does not signal when an admin changes someone else's password", func() { + repo := newRepo("some-admin") + usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + _, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + Expect(ok).To(BeFalse()) + }) + }) }) diff --git a/server/auth.go b/server/auth.go index 6a25f1406..37a318a83 100644 --- a/server/auth.go +++ b/server/auth.go @@ -12,10 +12,12 @@ import ( "net/http" "slices" "strings" + "sync" "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -260,7 +262,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader) - if err != nil { + if err != nil || !tokenAllowed(ctx) { _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") return } @@ -270,24 +272,88 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { } } -// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header +// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and +// config auth carry no token, so they short-circuit to true. +func tokenAllowed(ctx context.Context) bool { + token, _, err := jwtauth.FromContext(ctx) + if err != nil || token == nil { + return true + } + usr, ok := request.UserFrom(ctx) + if !ok { + return true + } + claims := auth.ClaimsFromToken(token) + if !strings.EqualFold(claims.Subject, usr.UserName) { + return true + } + if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil { + log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err) + return false + } + return true +} + +// refreshingWriter defers the refreshed-token header until the handler's first write, so an +// epoch the handler bumped reaches the token the client stores. +type refreshingWriter struct { + http.ResponseWriter + ctx context.Context + token jwt.Token + once sync.Once +} + +func (w *refreshingWriter) setToken() { + w.once.Do(func() { + claims := auth.ClaimsFromToken(w.token) + if epoch, ok := request.TokenEpochFrom(w.ctx); ok { + claims.Epoch = epoch + } + newToken, err := auth.TouchClaims(claims) + if err != nil { + log.Error(w.ctx, "Could not sign new token", err) + return + } + w.Header().Set(consts.UIAuthorizationHeader, newToken) + }) +} + +func (w *refreshingWriter) WriteHeader(code int) { + w.setToken() + w.ResponseWriter.WriteHeader(code) +} + +func (w *refreshingWriter) Write(b []byte) (int, error) { + w.setToken() + return w.ResponseWriter.Write(b) +} + +// Flush keeps the SSE events route working through the wrap. +func (w *refreshingWriter) Flush() { + w.setToken() + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap. +func (w *refreshingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to +// the Authorization Header. func JWTRefresher(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - token, _, err := jwtauth.FromContext(ctx) - if err != nil { + token, _, err := jwtauth.FromContext(r.Context()) + if err != nil || token == nil { next.ServeHTTP(w, r) return } - newTokenString, err := auth.TouchToken(token) - if err != nil { - log.Error(r, "Could not sign new token", err) - _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") - return - } - - w.Header().Set(consts.UIAuthorizationHeader, newTokenString) - next.ServeHTTP(w, r) + ctx := request.WithTokenEpochHolder(r.Context()) + rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token} + next.ServeHTTP(rw, r.WithContext(ctx)) + rw.setToken() }) } diff --git a/server/auth_test.go b/server/auth_test.go index f6af6f0d6..e021c82a8 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -12,6 +12,7 @@ import ( "time" "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" @@ -342,4 +343,138 @@ var _ = Describe("Auth", func() { Expect(u.IsAdmin).To(BeFalse()) }) }) + + Describe("Authenticator token gating", func() { + var ds *tests.MockDataStore + var usr *model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Hour + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.TODO()).(*tests.MockedUserRepo) + usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2} + Expect(ur.Put(usr)).To(Succeed()) + }) + + serve := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/api/song", nil) + r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + w := httptest.NewRecorder() + handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }, + ))) + handler.ServeHTTP(w, r) + return w + } + + It("accepts a current session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a jellyfin-scoped token", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a token with a stale epoch", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 3 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("ignores a stray token for someone else when config auto-login resolves the user", func() { + conf.Server.DevAutoLoginUsername = usr.UserName + tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"}) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() { + tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch}) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 5 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("JWTRefresher", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // TouchClaims reads this; left at zero every refreshed token is born expired. + conf.Server.SessionTimeout = time.Hour + auth.Init(&tests.MockDataStore{}) + }) + + serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder { + usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} + tokenStr, err := auth.CreateToken(&usr) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest("GET", "/api/song", nil) + r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr) + w := httptest.NewRecorder() + JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r) + return w + } + + It("writes a refreshed token when the handler writes a body", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) + }) + + It("writes a refreshed token when the handler writes no body", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) + }) + + It("picks up an epoch the handler reported", func() { + w := serveWith(func(w http.ResponseWriter, r *http.Request) { + request.SetTokenEpoch(r.Context(), 42) + w.WriteHeader(http.StatusOK) + }) + + claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(42)) + }) + + It("keeps the original epoch when the handler reports nothing", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(1)) + }) + + It("propagates Flush to the underlying ResponseWriter", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + }) + Expect(w.Flushed).To(BeTrue()) + }) + + It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() { + var unwrapped http.ResponseWriter + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + Expect(ok).To(BeTrue()) + unwrapped = u.Unwrap() + w.WriteHeader(http.StatusOK) + }) + Expect(unwrapped).To(BeIdenticalTo(w)) + }) + }) }) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index dc3219dfa..15b56a499 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -58,6 +58,8 @@ query param — all forms are accepted, matching what different clients do). `/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force surface. +Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. + ### Public user list (login picker) `GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index 062ac6458..e7070d341 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -36,7 +36,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) } - token, err := auth.CreateToken(usr) + token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) if err != nil { api.internalError(w, r, err) return diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 806b0e5e7..156e79ce9 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +62,42 @@ var _ = Describe("Authentication", func() { It("rejects a malformed body", func() { Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) }) + + It("mints a non-expiring token scoped to the Jellyfin audience", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + Expect(claims.Subject).To(Equal("admin")) + }) + + It("revokes an already-issued token when the user's epoch is bumped", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + + // A real password change through the repository, which is what revokes in production. + admin, err := ds.User(ctx).Get(testID("admin-1")) + Expect(err).ToNot(HaveOccurred()) + admin.NewPassword = "rotated" + Expect(ds.User(ctx).Put(admin)).To(Succeed()) + + r = httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw = httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusUnauthorized)) + }) }) Describe("GET /Users/Public", func() { diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index c90f9c088..0ae4f6071 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -167,6 +167,10 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) { log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) return model.User{}, false } + if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil { + log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err) + return model.User{}, false + } return *usr, true } diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index b17b9a4ec..a3b88799b 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -95,6 +95,52 @@ var _ = Describe("authenticate middleware", func() { api.authenticate(next).ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) + + Context("token scoping and revocation", func() { + var usr *model.User + + BeforeEach(func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3} + Expect(ur.Put(usr)).To(Succeed()) + }) + + serve := func(token string) *httptest.ResponseRecorder { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", token) + api.authenticate(next).ServeHTTP(w, r) + return w + } + + It("accepts a jellyfin-scoped token with the current epoch", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a token whose epoch is stale", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 4 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a token minted for another API", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("still accepts an unscoped session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + }) }) var _ = Describe("withPlayer middleware", func() { diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go new file mode 100644 index 000000000..32f4b13cb --- /dev/null +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -0,0 +1,80 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type noopPluginUnloader struct{} + +func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {} + +// Pins that the token-epoch handoff survives a real request through the real middleware chain. +var _ = Describe("PUT /user/{id}: token refresh on self password change", func() { + var ds model.DataStore + var router http.Handler + + BeforeEach(func() { + // db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package. + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableUserEditing = true + conf.Server.EnableSharing = false + conf.Server.SessionTimeout = time.Hour + conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL" + DeferCleanup(db.Init(GinkgoT().Context())) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + userService := core.NewUser(ds, noopPluginUnloader{}) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + }) + + It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() { + usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"} + Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + + token, err := auth.CreateToken(&usr) + Expect(err).ToNot(HaveOccurred()) + + body, _ := json.Marshal(map[string]any{ + "userName": usr.UserName, + "name": usr.Name, + "currentPassword": "old-password", + "password": "new-password", + }) + req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + + refreshed := w.Header().Get(consts.UIAuthorizationHeader) + Expect(refreshed).ToNot(BeEmpty()) + claims, err := auth.Validate(refreshed) + Expect(err).ToNot(HaveOccurred()) + + reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.TokenEpoch).To(Equal(1)) + Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch)) + }) +}) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 837852d18..6dfa2263f 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -178,7 +178,9 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && claims.Subject == user.UserName + valid = err == nil && + claims.Subject == user.UserName && + auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil case pass != "": if strings.HasPrefix(pass, "enc:") { if dec, err := hex.DecodeString(pass[4:]); err == nil { diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 3f8c07a56..cb34b92e7 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -470,6 +470,7 @@ var _ = Describe("Middlewares", func() { var validToken string BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) @@ -499,6 +500,36 @@ var _ = Describe("Middlewares", func() { Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) + + Context("JWT credentials", func() { + var usr *model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + auth.Init(ds) + usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} + }) + + It("accepts an unscoped session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed()) + }) + + It("rejects a jellyfin-scoped token", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a token with a stale epoch", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 2 + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) + }) + }) }) }) From 3cb9850872f45a2cee0ca540b7c8d141c631f222 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 22 Aug 2026 21:38:54 -0400 Subject: [PATCH 465/596] feat(artwork): extend stale absent age to 30 days and update test formatting Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/artwork_test.go | 3 ++- core/artwork/housekeeping.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index cc63ed86f..f17206aaa 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "fmt" "io" "strings" "time" @@ -910,7 +911,7 @@ var _ = Describe("formatStatus", func() { }) It("states the recheck window and the drip rate the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring("168h")) + Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours()))) Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) }) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 9456a5584..ce98e2a03 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -18,7 +18,7 @@ import ( ) // StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 7 * 24 * time.Hour +const StaleAbsentAge = 30 * 24 * time.Hour // StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, // oldest first, so external agents see a flat drip instead of a daily burst. From fc9d93d22ac7e175223a48e0a84a6e5fe7c24c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 22 Aug 2026 22:02:25 -0400 Subject: [PATCH 466/596] fix(plugins): read the loaded plugin from a local, not the shared map (#6014) Plugins load concurrently through an errgroup. loadPluginWithConfig wrote m.plugins under m.mu but read it back unlocked to pass to callPluginInit, so one goroutine's write raced another's read. Caught by -race on master (run 32608293134): all 640 specs passed, the job failed only on the race. Capture the pointer while holding the lock and use the local. Holding m.mu across callPluginInit would be wrong, since that runs arbitrary plugin code. --- plugins/manager_loader.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index e5e3dbfc0..46da56396 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -434,8 +434,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manifest validation: %w", err) } - m.mu.Lock() - m.plugins[p.ID] = &plugin{ + loadedPlugin := &plugin{ name: p.ID, path: p.Path, manifest: pkg.Manifest, @@ -449,13 +448,16 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { fsConfig: fsConfig, lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } + m.mu.Lock() + m.plugins[p.ID] = loadedPlugin m.mu.Unlock() loaded = true // Init is the plugin's first chance to run arbitrary code: open sockets, create task queues, // schedule work. Only a caller that already intends to reach the network asks for it. + // Use the local: loads run concurrently, so reading the map back here would race the writes. if m.transient == nil || m.transient.runInit { - callPluginInit(ctx, m.plugins[p.ID]) + callPluginInit(ctx, loadedPlugin) } return nil From 3e55886195bb125f5c1fda1a1ded39f319e96662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 23 Aug 2026 14:31:37 -0400 Subject: [PATCH 467/596] feat: add optional natural sort order for names and titles (#6015) * feat: add optional natural sort order for names and titles Album, artist, song and playlist lists sort with a plain text comparison, so names containing numbers come out as "Foo 1, Foo 10, Foo 2" instead of "Foo 1, Foo 2, Foo 10" (issue #4554). Adds an EnableNaturalSorting option, default off, that switches those sorts to a NATSORT collation registered on every connection and backed by natural.CompareFold. natural.Compare gained an ASCII case-folding variant because it replaces 'collate nocase': sort_* columns hold raw tag values, so without folding they would order uppercase before lowercase. Applying the collation only inside mapSortOrder would have missed the default configuration entirely, since that mapper runs only when PreferSortTags is on. setSortMappings now also rewrites the order_* columns when natural sorting is enabled on its own. Sorts over plain text columns that are not order_* columns (playlist.name, album.name, media_file.title, playlist_tracks title) are wrapped explicitly, and qualified with their table because 'user' is joined and also has a 'name' column. The option defaults to off because the collation cannot use the existing indexes: measured on a synthetic 110k album library, the first page of an album-by-name listing goes from 0.03ms to 14ms. Indexing the expression was rejected outright - an index declared with a custom collation makes the whole database unreadable to any tool that does not register it, including the sqlite3 CLI, which fails even on 'select count(*)' and 'pragma integrity_check'. * refactor: fold the two sort-order mappers into one mapSortOrder and mapNaturalOrder shared the same regex and loop, differing only in the expression they substituted, and setSortMappings picked between them with a two-case switch. mapSortOrder now selects the column shape itself and defers to collatedSort for the collation, so the 'collate' clause is emitted in one place and the caller only has to decide whether any mapping is needed at all. The mapper tests were three near-identical cases that each hard-coded one flag combination; they are now a DescribeTable covering all four combinations of PreferSortTags and EnableNaturalSorting, which the previous set did not. The album sorting specs collapse the same way. Behavior is unchanged. * fix: leave plain sort columns alone when natural sorting is off collatedSort wrapped its column unconditionally, so the tiebreakers added for plain text columns picked up 'collate nocase' even with EnableNaturalSorting off. media_file.title, the playlist_tracks alias of it, and user.user_name are all declared without a collation, so a default install would have silently switched those tiebreaks from binary to case-insensitive ordering. Only playlist.name was already NOCASE and genuinely unaffected. The helper is now naturalSort and returns the column untouched unless the option is on, so the default path keeps the collation each column was declared with. sortCollation had a single remaining caller and folded into mapSortOrder. Tests: the CompareFold table body was a verbatim copy of the Compare one, so both now go through one expectOrder helper, and the album sorting specs inline two single-use closures. * fix(natural): defer the leading-zero tie-break to keep ordering transitive Compare applied the padding difference between numerically equal digit runs only when one side ended at the digit boundary, and ignored it mid-string. That made the relation intransitive: CompareFold("1","1a") < 0 and CompareFold("1a","01a") == 0, yet CompareFold("1","01a") > 0. SQLite requires a collating function to be transitive and leaves ORDER BY undefined otherwise, so registering this as NATSORT was not safe. Reproduced with the real driver on three artist names that occur in practice - "3", "3 doors down" and "03 greedo" - where paging one row at a time returned "03 greedo" twice and dropped "3" entirely. The padding difference is now carried as a tie-break that is applied only when the strings are otherwise equal, which restores transitivity while keeping the documented intent (a01 < a1, a0 < a00). Three existing entries changed: each asserted that two distinct strings compare equal, which was the same defect seen from the other side. Found by the Codex review on #6015. --- conf/configuration.go | 2 + db/db.go | 10 ++- persistence/album_repository.go | 2 +- persistence/album_repository_test.go | 39 +++++++++ persistence/helpers.go | 29 +++++-- persistence/helpers_test.go | 55 ++++++++++--- persistence/mediafile_repository.go | 2 +- persistence/playlist_repository.go | 3 +- persistence/playlist_repository_test.go | 35 ++++++++ persistence/playlist_track_repository.go | 2 +- persistence/sql_base_repository.go | 5 +- utils/natural/natural.go | 51 ++++++++---- utils/natural/natural_test.go | 100 ++++++++++++++++++++--- 13 files changed, 284 insertions(+), 51 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index fbbaaf252..df22e4ae2 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -73,6 +73,7 @@ type configOptions struct { Matcher matcherOptions `json:",omitzero"` RecentlyAddedByModTime bool PreferSortTags bool + EnableNaturalSorting bool IgnoredArticles string IndexGroups string FFmpegPath string @@ -973,6 +974,7 @@ func setViperDefaults() { viper.SetDefault("matcher.fuzzythreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) + viper.SetDefault("enablenaturalsorting", false) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") diff --git a/db/db.go b/db/db.go index 11a05b456..a325dd3f5 100644 --- a/db/db.go +++ b/db/db.go @@ -13,10 +13,15 @@ import ( _ "github.com/navidrome/navidrome/db/migrations" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/hasher" + "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/singleton" "github.com/pressly/goose/v3" ) +// NaturalCollation sorts embedded numbers by value. It is registered on every +// connection, but only referenced when conf.Server.EnableNaturalSorting is on. +const NaturalCollation = "NATSORT" + var ( Dialect = "sqlite3" Driver = Dialect + "_custom" @@ -32,7 +37,10 @@ func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { sql.Register(Driver, &sqlite3.SQLiteDriver{ ConnectHook: func(conn *sqlite3.SQLiteConn) error { - return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) + if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { + return err + } + return conn.RegisterCollation(NaturalCollation, natural.CompareFold) }, }) Path = conf.Server.DbPath diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 5d7aad22e..7ac875a51 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -113,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito "artist": "compilation, order_album_artist_name, order_album_name", "album_artist": "compilation, order_album_artist_name, order_album_name", // TODO Rename this to just year (or date) - "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name", + "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"), "random": "random", "recently_added": recentlyAddedSort(), "starred_at": "starred, starred_at", diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index f6768768d..0fb680cff 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -10,6 +10,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -38,6 +39,44 @@ var _ = Describe("AlbumRepository", func() { albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) }) + Describe("natural sorting", func() { + var ids []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ids = nil + for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} { + aid := "nat-" + n + ids = append(ids, aid) + Expect(albumRepo.Put(&model.Album{ + ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n, + })).To(Succeed()) + } + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) + }) + }) + + DescribeTable("sorts albums by name", + func(naturalSorting, preferSortTags bool, expected []string) { + conf.Server.EnableNaturalSorting = naturalSorting + conf.Server.PreferSortTags = preferSortTags + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) + albums, err := albumRepo.GetAll(model.QueryOptions{ + Sort: "name", Filters: squirrel.Eq{"album.id": ids}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected)) + }, + Entry("lexicographically by default", false, false, + []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}), + Entry("by number value when natural sorting is enabled", true, false, + []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), + Entry("by number value with sort tags preferred too", true, true, + []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), + ) + }) + Describe("Get", func() { var Get = func(id string) (*model.Album, error) { album, err := albumRepo.Get(id) diff --git a/persistence/helpers.go b/persistence/helpers.go index fd6a9a4cd..1da31cf02 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -9,6 +9,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/fatih/structs" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/db" ) type PostMapper interface { @@ -82,11 +84,28 @@ func (e existsCond) ToSql() (string, []any, error) { var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) -// Convert the order_* columns to an expression using sort_* columns. Example: -// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) +// naturalSort makes a plain text column sort numbers by value, leaving it alone +// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split. +func naturalSort(col string) string { + if !conf.Server.EnableNaturalSorting { + return col + } + return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation) +} + +// Convert the order_* columns to a collated sort expression, falling back to the +// sort_* column when those are preferred. Example: +// order_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 nocase)", tableName) - return sortOrderRegex.ReplaceAllString(order, repl) + col := tableName + ".order_$1" + if conf.Server.PreferSortTags { + col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName) + } + collation := "nocase" + if conf.Server.EnableNaturalSorting { + collation = db.NaturalCollation + } + repl := fmt.Sprintf("(%s collate %s)", col, collation) + return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl) } diff --git a/persistence/helpers_test.go b/persistence/helpers_test.go index 85893ef55..3019609f3 100644 --- a/persistence/helpers_test.go +++ b/persistence/helpers_test.go @@ -4,6 +4,8 @@ import ( "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -85,22 +87,51 @@ var _ = Describe("Helpers", func() { }) Describe("mapSortOrder", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + It("does not change the sort string if there are no order columns", func() { - sort := "album_name asc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(sort)) - }) - It("changes order columns to sort expression", 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 nocase) asc`)) + Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc")) }) + + DescribeTable("maps order columns to a collated expression", + func(preferSortTags, naturalSorting bool, expected string) { + conf.Server.PreferSortTags = preferSortTags + conf.Server.EnableNaturalSorting = naturalSorting + Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected)) + }, + Entry("qualified column", false, false, + "(album.order_album_name collate nocase) asc"), + Entry("natural collation", false, true, + "(album.order_album_name collate NATSORT) asc"), + Entry("sort tags preferred", true, false, + `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`), + Entry("sort tags preferred, natural collation", true, true, + `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`), + ) + It("changes multiple order columns to sort expressions", func() { + conf.Server.PreferSortTags = true 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 nocase) asc,` + - ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) + Expect(mapSortOrder("album", sort)).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`)) + }) + }) + + Describe("naturalSort", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("leaves the column alone by default, keeping its declared collation", func() { + Expect(naturalSort("media_file.title")).To(Equal("media_file.title")) + }) + + It("applies the natural collation when enabled", func() { + conf.Server.EnableNaturalSorting = true + Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)")) }) }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 8146cba2f..320b95ef2 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -86,7 +86,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile "title": "order_title", "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"), "random": "random", "created_at": "media_file.created_at", "recently_added": mediaFileRecentlyAddedSort(), diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index cf54c6d5a..505f23440 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -60,7 +60,8 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe "starred": annotationBoolFilter("starred"), }) r.setSortMappings(map[string]string{ - "owner_name": "owner_name", + "name": naturalSort("playlist.name"), + "owner_name": naturalSort("owner_name"), }) return r } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 9697e6fff..f60b4e7ca 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -5,6 +5,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -24,6 +26,39 @@ var _ = Describe("PlaylistRepository", func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + Describe("natural sorting", func() { + var ids []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableNaturalSorting = true + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + + ids = nil + for _, n := range []string{"mix 1", "mix 10", "mix 2"} { + pls := model.Playlist{Name: n, OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + ids = append(ids, pls.ID) + } + DeferCleanup(func() { + for _, id := range ids { + _ = repo.Delete(id) + } + }) + }) + + It("sorts playlist names by number value", func() { + all, err := repo.GetAll(model.QueryOptions{ + Sort: "name", Filters: squirrel.Eq{"playlist.id": ids}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To( + Equal([]string{"mix 1", "mix 2", "mix 10"})) + }) + }) + Describe("Count", func() { It("returns the number of playlists in the DB", func() { Expect(repo.CountAll()).To(Equal(int64(2))) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index a5e1975fd..cf1b8f3fa 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -56,7 +56,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "id": "playlist_tracks.id", "artist": "order_artist_name", "album_artist": "order_album_artist_name", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"), "title": "order_title", "random": "random()", // To make sure these fields will be whitelisted diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index d4cf9b456..f49e1bc4f 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -113,10 +113,9 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName .. if len(tableName) > 0 { tn = tableName[0] } - if conf.Server.PreferSortTags { + if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting { for k, v := range mappings { - v = mapSortOrder(tn, v) - mappings[k] = v + mappings[k] = mapSortOrder(tn, v) } } r.sortMappings = mappings diff --git a/utils/natural/natural.go b/utils/natural/natural.go index fa0800e1d..d8ddcc405 100644 --- a/utils/natural/natural.go +++ b/utils/natural/natural.go @@ -10,15 +10,32 @@ import "strings" // or a positive value if a > b using natural sort ordering. // // When two numeric segments are numerically equal (e.g. "01" vs "1"), -// comparison continues with the remaining suffixes. If one or both -// strings end at the digit boundary, the raw strings are compared -// lexically, which makes leading zeros significant as a tie-breaker -// (e.g. "a01" < "a1", "a0" < "a00"). +// comparison continues with the remaining suffixes, and the padding +// difference is kept as a final tie-breaker that only decides strings +// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring +// it that way is what keeps the ordering transitive, which SQLite +// requires of a collating function. func Compare(a, b string) int { + return compare(a, b, false) +} + +// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE +// collation: only A-Z fold, bytes >= 0x80 are compared as-is. +func CompareFold(a, b string) int { + return compare(a, b, true) +} + +func compare(a, b string, fold bool) int { ia, ib := 0, 0 + // Set when two runs are numerically equal but differently padded. Applying it + // immediately would break transitivity, so it only decides otherwise-equal strings. + padTie := 0 for ia < len(a) && ib < len(b) { ca, cb := a[ia], b[ib] da, db := isDigit(ca), isDigit(cb) + if fold { + ca, cb = lower(ca), lower(cb) + } switch { case da && db: @@ -35,17 +52,11 @@ func Compare(a, b string) int { if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 { return c } - - // Numerically equal. If both sides have trailing data, continue - // comparing after the digit runs. Otherwise fall through to - // lexical comparison of the full remaining strings (which makes - // leading-zero differences significant as a tie-breaker). - if endA < len(a) && endB < len(b) { - ia = endA - ib = endB - continue + if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 { + padTie = t } - return strings.Compare(a[ia:], b[ib:]) + ia = endA + ib = endB case da != db: return int(ca) - int(cb) default: @@ -56,7 +67,10 @@ func Compare(a, b string) int { ib++ } } - return (len(a) - ia) - (len(b) - ib) + if c := (len(a) - ia) - (len(b) - ib); c != 0 { + return c + } + return padTie } // compareNumbers compares two digit strings numerically. @@ -96,3 +110,10 @@ func stripZeros(s string) string { func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func lower(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + 'a' - 'A' + } + return c +} diff --git a/utils/natural/natural_test.go b/utils/natural/natural_test.go index 825a944c0..534885d40 100644 --- a/utils/natural/natural_test.go +++ b/utils/natural/natural_test.go @@ -13,17 +13,23 @@ func TestNatural(t *testing.T) { RunSpecs(t, "Natural Suite") } +// expectOrder asserts the sign of cmp(a, b) matches expected. +func expectOrder(cmp func(string, string) int, a, b string, expected int) { + result := cmp(a, b) + switch { + case expected < 0: + ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b) + case expected > 0: + ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b) + default: + ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b) + } +} + var _ = Describe("Compare", func() { DescribeTable("returns correct ordering", func(a, b string, expected int) { - result := natural.Compare(a, b) - if expected < 0 { - Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b) - } else if expected > 0 { - Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b) - } else { - Expect(result).To(Equal(0), "expected %q == %q", a, b) - } + expectOrder(natural.Compare, a, b, expected) }, // Basic string ordering Entry("a < b", "a", "b", -1), @@ -67,7 +73,9 @@ var _ = Describe("Compare", func() { Entry("a00b00 < a0b1", "a00b00", "a0b1", -1), Entry("a00b00 > a0b0", "a00b00", "a0b0", 1), Entry("a00b01 > a0b00", "a00b01", "a0b00", 1), - Entry("a00b00 == a0b00", "a00b00", "a0b00", 0), + // Distinct strings must not compare equal: the padding difference in the first + // run decides once everything else matches. + Entry("a00b00 > a0b00", "a00b00", "a0b00", 1), // Leading zeros at end of string — lexical tie-break Entry("file01 < file1", "file01", "file1", -1), @@ -109,8 +117,78 @@ var _ = Describe("Compare", func() { Entry("large: equal", "a100000000000000000000", "a100000000000000000000", 0), Entry("large: leading zeros with trailing data", - "a00000000000000000000001x", "a1x", 0), + "a00000000000000000000001x", "a1x", -1), Entry("large: leading zeros with trailing data (2)", - "a099999999999999999999x", "a99999999999999999999x", 0), + "a099999999999999999999x", "a99999999999999999999x", -1), ) }) + +var _ = Describe("CompareFold", func() { + DescribeTable("orders case-insensitively", + func(a, b string, expected int) { + expectOrder(natural.CompareFold, a, b, expected) + }, + Entry("numbers compare numerically", "foo 2", "foo 10", -1), + Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1), + Entry("case is ignored", "apple 2", "Banana 10", -1), + Entry("case is ignored, reversed", "Banana 10", "apple 2", 1), + Entry("same word, different case, is equal", "ABC", "abc", 0), + Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1), + Entry("uppercase digits boundary", "Track9", "track10", -1), + Entry("empty vs empty", "", "", 0), + Entry("empty sorts first", "", "a", -1), + Entry("non-ASCII is left untouched", "café 2", "café 10", -1), + ) + + // SQLite requires a collating function to be transitive; if it is not, the behavior of + // ORDER BY is undefined and paginated queries can drop or duplicate rows. + It("is transitive, as a SQLite collation requires", func() { + var corpus []string + var build func(prefix string, depth int) + build = func(prefix string, depth int) { + if prefix != "" { + corpus = append(corpus, prefix) + } + if depth == 0 { + return + } + for _, c := range []string{"0", "1", "a"} { + build(prefix+c, depth-1) + } + } + build("", 3) + + sign := func(n int) int { + switch { + case n < 0: + return -1 + case n > 0: + return 1 + } + return 0 + } + for _, a := range corpus { + for _, b := range corpus { + ab := sign(natural.CompareFold(a, b)) + for _, c := range corpus { + bc := sign(natural.CompareFold(b, c)) + ac := sign(natural.CompareFold(a, c)) + if ab == 0 && bc == 0 { + Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac) + } + if ab < 0 && bc < 0 { + Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac) + } + } + } + } + }) + + It("matches Compare when both sides are already lowercase", func() { + pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}} + for _, p := range pairs { + Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])), + "CompareFold(%q,%q) should match Compare", p[0], p[1]) + } + }) +}) From 82b9a44a1f0ee94682f759086579df3a9ec4330e Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 23 Aug 2026 15:24:44 -0400 Subject: [PATCH 468/596] fix(log): redact sensitive auth headers from request logs The trace-level request log dumps all headers as a JSON blob, but the redaction hook only had query-param patterns, so Authorization, X-Emby-Token, X-MediaBrowser-Token and X-Nd-Authorization leaked their tokens in plaintext. Add one pattern that blanks those header value arrays at the log sink. --- log/log.go | 4 ++++ log/log_test.go | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/log/log.go b/log/log.go index f7977c0fa..10cfb17b5 100644 --- a/log/log.go +++ b/log/log.go @@ -50,6 +50,10 @@ var redacted = &Hook{ // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an // optional underscore: the API accepts api_key, apikey and ApiKey alike. "(?i)([^\\w]api_?key=)[^&\\s]+", + + // Sensitive request headers, logged as a JSON blob at trace level and never matched by the + // query-param patterns above. Blank the whole value array; values may hold escaped quotes. + `(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`, }, } diff --git a/log/log_test.go b/log/log_test.go index f0e2e0eae..82207c672 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -2,7 +2,9 @@ package log import ( "context" + "encoding/json" "errors" + "net/http" "net/http/httptest" "testing" "time" @@ -92,7 +94,7 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) @@ -275,5 +277,19 @@ var _ = Describe("Logger", func() { Entry("ApiKey", "ApiKey"), Entry("APIKEY", "APIKEY"), ) + + It("redacts sensitive request headers in a logged header blob", func() { + h := http.Header{ + "Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`}, + "X-Emby-Token": {"emby-secret"}, + "X-Mediabrowser-Token": {"mb-secret"}, + "X-Nd-Authorization": {"Bearer nd-secret"}, + "User-Agent": {"Finamp/1.0"}, + } + blob, _ := json.Marshal(h) + got := Redact(string(blob)) + Expect(got).ToNot(ContainSubstring("secret")) + Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) + }) }) }) From 3da2b590e780722df6c2a35e10fd38dfaf83098a Mon Sep 17 00:00:00 2001 From: Rob Emery <mintsoft@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:22:32 +0100 Subject: [PATCH 469/596] fix: add Navidrome UserAgent in all outgoing requests (#6020) * There has been a report about navidrome hitting listenbrainz hard and the listenbrainz guys wanting to be able to distinguish navidrome * feat: apply Navidrome User-Agent to all outgoing HTTP requests Add utils/httpclient, a shared http.Client factory whose transport sets the User-Agent header (Navidrome/{version} - https://github.com/navidrome) on any request that does not already have one, and use it at every place the server builds an HTTP client: Last.fm, ListenBrainz and Deezer agents and auth routers, insights collector, backgrounds handler, and the plugin host HTTP service. Plugin-set User-Agent values are preserved. The per-request header lines from the previous commit are superseded by the transport. --------- Co-authored-by: Deluan <deluan@navidrome.org> --- adapters/deezer/deezer.go | 6 +- adapters/lastfm/agent.go | 5 +- adapters/lastfm/auth_router.go | 5 +- adapters/listenbrainz/agent.go | 6 +- adapters/listenbrainz/auth_router.go | 5 +- consts/consts.go | 2 +- core/metrics/insights.go | 5 +- plugins/host_httpclient.go | 3 +- server/backgrounds/handler.go | 5 +- utils/httpclient/httpclient.go | 35 +++++++++++ utils/httpclient/httpclient_suite_test.go | 17 +++++ utils/httpclient/httpclient_test.go | 76 +++++++++++++++++++++++ 12 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 utils/httpclient/httpclient.go create mode 100644 utils/httpclient/httpclient_suite_test.go create mode 100644 utils/httpclient/httpclient_test.go diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 742b8b1a5..1fa10e25c 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "net/http" "slices" "strings" @@ -15,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -36,9 +36,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface { dataStore: dataStore, languages: conf.Server.Deezer.Languages, } - httpClient := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + httpClient := httpclient.New(consts.DefaultHttpClientTimeOut) cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) agent.client = newClient(cachedHttpClient) return agent diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index f967595e3..863868b5a 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "golang.org/x/net/html" ) @@ -59,9 +60,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { secret: conf.Server.LastFM.Secret, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.httpClient = chc l.client = newClient(l.apiKey, l.secret, chc) diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 499863e28..411bf069a 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -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/utils/httpclient" "github.com/navidrome/navidrome/utils/req" ) @@ -41,9 +42,7 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) r.client = newClient(r.apiKey, r.secret, hc) return r } diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 76beed921..a59a5393f 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -3,7 +3,6 @@ package listenbrainz import ( "context" "errors" - "net/http" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -12,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -33,9 +33,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, baseURL: conf.Server.ListenBrainz.BaseURL, } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.client = newClient(l.baseURL, chc) return l diff --git a/adapters/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go index 7cb9eb16a..1ff1a1495 100644 --- a/adapters/listenbrainz/auth_router.go +++ b/adapters/listenbrainz/auth_router.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils/httpclient" ) type sessionKeysRepo interface { @@ -37,9 +38,7 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc) return r } diff --git a/consts/consts.go b/consts/consts.go index aed8ecf66..2934cd968 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -201,7 +201,7 @@ var ( } ) -var HTTPUserAgent = "Navidrome" + "/" + Version +var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome" var ( VariousArtists = "Various Artists" diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 66d0b89bd..706df6559 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -26,6 +26,7 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/singleton" ) @@ -95,9 +96,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) { log.Trace(ctx, "No users found, skipping Insights data collection") return } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) data := c.collect(ctx) if data == nil { return diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index f1d64deb7..4c8f85acd 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/utils/httpclient" ) const ( @@ -46,7 +47,7 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI requiredHosts: requiredHosts, } svc.client = &http.Client{ - Transport: http.DefaultTransport, + Transport: httpclient.NewTransport(nil), // Timeout is set per-request via context deadline, not here. // CheckRedirect validates hosts and enforces redirect limits. CheckRedirect: func(req *http.Request, via []*http.Request) error { diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index b00a51696..f6e159b4b 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/random" "gopkg.in/yaml.v3" ) @@ -35,7 +36,7 @@ type Handler struct { func NewHandler() *Handler { h := &Handler{} - h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL) + h.httpClient = cache.NewHTTPClient(httpclient.New(5*time.Second), imageListTTL) h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage) go func() { _, _ = h.getImageList(log.NewContext(context.Background())) @@ -78,7 +79,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e if image == "" { return nil, errors.New("empty image name") } - c := http.Client{Timeout: imageRequestTimeout} + c := httpclient.New(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 if errors.Is(err, context.DeadlineExceeded) { diff --git a/utils/httpclient/httpclient.go b/utils/httpclient/httpclient.go new file mode 100644 index 000000000..7fb48f36d --- /dev/null +++ b/utils/httpclient/httpclient.go @@ -0,0 +1,35 @@ +// Package httpclient provides a shared http.Client factory that identifies +// Navidrome via the User-Agent header on all outgoing requests. +package httpclient + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/consts" +) + +type uaTransport struct { + base http.RoundTripper +} + +func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if _, ok := req.Header["User-Agent"]; !ok { + req = req.Clone(req.Context()) + req.Header.Set("User-Agent", consts.HTTPUserAgent) + } + return t.base.RoundTrip(req) +} + +// NewTransport wraps base (or http.DefaultTransport if nil) to set the +// Navidrome User-Agent on requests that don't have one. +func NewTransport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &uaTransport{base: base} +} + +func New(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, Transport: NewTransport(nil)} +} diff --git a/utils/httpclient/httpclient_suite_test.go b/utils/httpclient/httpclient_suite_test.go new file mode 100644 index 000000000..e18a9d0ad --- /dev/null +++ b/utils/httpclient/httpclient_suite_test.go @@ -0,0 +1,17 @@ +package httpclient_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHTTPClient(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "HTTPClient Suite") +} diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go new file mode 100644 index 000000000..c86b51165 --- /dev/null +++ b/utils/httpclient/httpclient_test.go @@ -0,0 +1,76 @@ +package httpclient_test + +import ( + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/utils/httpclient" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("httpclient", func() { + var server *httptest.Server + var receivedUA string + + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + })) + DeferCleanup(server.Close) + }) + + Describe("New", func() { + It("sets the Navidrome User-Agent when the request has none", func() { + c := httpclient.New(time.Second) + resp, err := c.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) + }) + + It("keeps a User-Agent already set by the caller", func() { + c := httpclient.New(time.Second) + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set("User-Agent", "CustomAgent/1.0") + resp, err := c.Do(req) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal("CustomAgent/1.0")) + }) + + It("applies the given timeout", func() { + c := httpclient.New(5 * time.Second) + Expect(c.Timeout).To(Equal(5 * time.Second)) + }) + }) + + Describe("NewTransport", func() { + It("uses the default transport when base is nil", func() { + c := &http.Client{Transport: httpclient.NewTransport(nil)} + resp, err := c.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) + }) + + It("does not modify the original request", func() { + c := &http.Client{Transport: httpclient.NewTransport(nil)} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + Expect(err).ToNot(HaveOccurred()) + resp, err := c.Do(req) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(req.Header).ToNot(HaveKey("User-Agent")) + }) + }) + + Describe("HTTPUserAgent", func() { + It("identifies Navidrome with version and project URL", func() { + Expect(consts.HTTPUserAgent).To(Equal("Navidrome/" + consts.Version + " - https://github.com/navidrome")) + }) + }) +}) From cb0a6cedd6c445d8040df3ac01fe69d06ca27b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 25 Aug 2026 10:58:58 -0400 Subject: [PATCH 470/596] fix(scanner): keep album tag order from the files instead of alphabetical (#5872) Album-level tags were ordered by frequency and then alphabetically by value. Album.Genre is just the first genre in that list, so any album whose genres tie on frequency, which is the normal case, displayed the alphabetically first genre rather than the first one in the file. A file tagged "Native American New Age; Indigenous American Traditional Music; Ambient" showed up as "Ambient". Break frequency ties on order of appearance instead. This affects all album-level tags, so mood tagged "Happy; Chill" now keeps that order too. MediaFiles.ToAlbum already sorts the files by path before flattening their tags, so the aggregated order stays deterministic across scans. Only album genre was affected; media_file tags already preserved file order. --- model/mediafile_test.go | 6 +++--- model/tag.go | 8 ++++++-- model/tag_test.go | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 097e3ca54..9ca3489bb 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -218,11 +218,11 @@ var _ = Describe("MediaFiles", func() { {Tags: Tags{"genre": []string{"Alternative", "Rock"}}}, } }) - It("sets the correct Genre, sorted by frequency, then alphabetically", func() { + It("sets the correct Genre, sorted by frequency, then by order of appearance", func() { album := mfs.ToAlbum() Expect(album.Tags).To(HaveLen(2)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"})) - Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"})) + Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Punk", "Alternative"})) + Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Happy", "Chill"})) }) }) When("we have tags with mismatching case", func() { diff --git a/model/tag.go b/model/tag.go index bb4fce181..234cfb359 100644 --- a/model/tag.go +++ b/model/tag.go @@ -24,13 +24,17 @@ type TagList []Tag func (l TagList) GroupByFrequency() Tags { grouped := map[string]map[string]int{} values := map[string]string{} - for _, t := range l { + firstSeen := map[string]int{} + for i, t := range l { if m, ok := grouped[string(t.TagName)]; !ok { grouped[string(t.TagName)] = map[string]int{t.ID: 1} } else { m[t.ID]++ } values[t.ID] = t.TagValue + if _, ok := firstSeen[t.ID]; !ok { + firstSeen[t.ID] = i + } } tags := Tags{} @@ -42,7 +46,7 @@ func (l TagList) GroupByFrequency() Tags { slices.SortFunc(idList, func(a, b string) int { return cmp.Or( cmp.Compare(counts[b], counts[a]), - cmp.Compare(values[a], values[b]), + cmp.Compare(firstSeen[a], firstSeen[b]), ) }) tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] }) diff --git a/model/tag_test.go b/model/tag_test.go index c01aa0b4c..4dc99019b 100644 --- a/model/tag_test.go +++ b/model/tag_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tag", func() { Expect(groupedTags).To(HaveKeyWithValue(TagName("artist"), []string{"The Beatles", "The Rolling Stones"})) }) - It("should sort tags by name when frequency is the same", func() { + It("should keep the order the values appeared in when frequency is the same", func() { tagList := TagList{ NewTag("genre", "Jazz"), NewTag("genre", "Rock"), @@ -103,7 +103,7 @@ var _ = Describe("Tag", func() { groupedTags := tagList.GroupByFrequency() - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Alternative", "Jazz", "Pop", "Rock"})) + Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Jazz", "Rock", "Alternative", "Pop"})) }) It("should normalize casing", func() { tagList := TagList{ From 97da9993d76126cadc45f54159ed6e5ce0932230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 25 Aug 2026 18:48:43 -0400 Subject: [PATCH 471/596] fix(stream): abort the response when a transcoded stream is truncated (#6035) * fix(stream): abort the response when a transcoded stream is truncated When a transcode failed after some audio had already been sent, Serve logged the error and returned nil, so Go finished the chunked body normally and the client received an apparently complete, silently short file. Symfonium users hit this on large offline syncs, and the worst path, ffmpeg dying mid-write behind the transcoding cache, produced no error and nothing in the log above Debug: the cache writer was closed plainly, so readers drained the truncated entry to a clean EOF. The root cause of that silence is an fscache limitation: Close is the only way to end a cache write, and Close always means "complete". This adopts the deluan/fscache fork, which adds CloseWithError: on failure copyAndClose now cancels the entry with the cause, so every attached reader fails mid-read with the real error instead of EOF, a late Get for the entry is refused, and the entry never reports a final size. The error travels inside the entry each reader holds, which makes per-generation delivery automatic and needs no bookkeeping on our side. With the failure arriving in-band, one change in Serve covers every mode: an io.Copy error after bytes are on the wire panics with http.ErrAbortHandler. Go aborts the response without the terminating chunk (RST_STREAM on HTTP/2), chi's Recoverer re-panics that value, and the deferred stream.Close() still runs, so the transcode limiter slot is released as before. Two behaviors improve as side effects. A transcoder that dies before its first byte now yields a Subsonic error response instead of a 200 with an empty body, since the failure reaches Serve as an error while the status is still unsent; genuinely empty output (clean EOF, exit 0) keeps the 200. And a failed entry's invalidation no longer defers its unlink past a replacement entry re-creating the same file, because canceling already closed its readers. * fix(cache): warn when the cache writer cannot report failures to readers The CloseWithError capability comes from the fscache fork via a go.mod replace directive, and a type assertion picks it up. If that directive is ever lost, the assertion fails silently, readers of a dead writer go back to draining a truncated entry to a clean EOF, and nothing says so. Two layers against that: a warning on the failure path when the writer lacks the capability, and a test that asserts the writer fscache returns carries it, so losing the fork fails CI instead of a listener's download. * build: point the fscache replace at the fork's master deluan/fscache#1 is merged; pin the merge commit instead of the review branch. Pinned by sha because the module proxy still resolves the fork's master ref to its pre-merge commit. * build: reference the upstream fscache PR in the replace comment The replace itself must keep pointing at the fork: the commit only exists in djherbis/fscache under refs/pull/22/head, which the Go module fetcher cannot resolve (verified: unknown revision for both short and full sha). The same commit is advertised on the fork's master, so that is the fetchable source. --- core/stream/media_streamer.go | 6 ++- core/stream/media_streamer_test.go | 51 ++++++++++++++++++++++++ go.mod | 3 ++ go.sum | 4 +- utils/cache/file_caches.go | 11 ++++++ utils/cache/file_caches_test.go | 62 ++++++++++++++++++++++++++---- 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index b09d9bab8..aaa3126b4 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -152,8 +152,9 @@ func (s *Stream) EstimatedContentLength() int { // Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent // (supporting range requests). For non-seekable streams it writes directly and logs any errors. -// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written +// Returns the number of bytes written and an error only when it fails with 0 bytes written // (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). +// Once bytes are on the wire it panics with http.ErrAbortHandler instead, aborting the response. // Empty output (0 bytes, no error) is logged but not treated as an error. func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { if s.Seekable() { @@ -183,7 +184,8 @@ func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Reque w.Header().Del("Content-Length") return 0, fmt.Errorf("sending transcoded file: %w", err) } - return c, nil + // The 200 is already sent, so dropping the connection is the only way to say "truncated". + panic(http.ErrAbortHandler) } if c == 0 { log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index fb1c59a60..e06599208 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -1,12 +1,18 @@ package stream_test import ( + "bytes" "context" "errors" "io" + "net/http" + "net/http/httptest" "os" + "testing/iotest" "time" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/stream" @@ -140,4 +146,49 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeTrue()) }) }) + + Context("Serve", func() { + var mf *model.MediaFile + BeforeEach(func() { + var err error + mf, err = ds.MediaFile(ctx).Get("123") + Expect(err).ToNot(HaveOccurred()) + }) + + It("keeps empty output a non-error, so callers still reply 200 with an empty body", func() { + s := stream.NewStream(mf, "mp3", 128, io.NopCloser(bytes.NewReader(nil))) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + n, err := s.Serve(ctx, w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("aborts the response when the source fails after sending data", func() { + src := io.NopCloser(io.MultiReader( + bytes.NewReader(bytes.Repeat([]byte("a"), 64*1024)), + iotest.ErrReader(errors.New("transcoder died")), + )) + server := httptest.NewServer(serveHandler(stream.NewStream(mf, "mp3", 128, src))) + DeferCleanup(server.Close) + + resp, err := http.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + defer resp.Body.Close() + + // A client-side read failure is the only observable proof the response was aborted. + _, err = io.ReadAll(resp.Body) + Expect(err).To(HaveOccurred()) + }) + }) }) + +// Serve runs behind the real server's Recoverer, which must let ErrAbortHandler through. +func serveHandler(s *stream.Stream) http.Handler { + return middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = s.Serve(r.Context(), w, r) + })) +} diff --git a/go.mod b/go.mod index 65a01a9b5..c0809f7b5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 +// Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 +replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 + require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.4 diff --git a/go.sum b/go.sum index c11f4bb20..8be5a910c 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 h1:s254V2hsrrCJXYtAn9WPG/5p4QHenfL9E+j6Tiq5MW4= +github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2/go.mod h1:eNFa48vJrse+8ysT4IJnnUeXwLZNcR0JQumU/W/QoUI= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -39,8 +41,6 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= -github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU= github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index dff9e4e7a..48cd135cb 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -255,6 +255,17 @@ func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteClo } if err == nil { fc.markComplete(ctx, key) + } else if cw, ok := w.(interface{ CloseWithError(error) error }); ok { + // Cancel instead of close, so readers fail with the cause rather than + // draining a truncated entry to a clean EOF. + if cErr := cw.CloseWithError(err); cErr != nil { + // Join, not Append: err is now shared with readers and must not be mutated. + return errors.Join(err, fmt.Errorf("closing cache writer: %w", cErr)) + } + return err + } else { + log.Warn(ctx, "Cache writer cannot report failures; readers will see a truncated entry as a clean EOF", + "cache", fc.name, "key", key, err) } if cErr := w.Close(); cErr != nil { err = multierror.Append(err, fmt.Errorf("closing cache writer: %w", cErr)) diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 974200656..3189de6b2 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -259,6 +259,54 @@ var _ = Describe("File Caches", func() { }).Should(BeTrue()) }) + It("gets a writer that can report failures to readers", func() { + // Guards the fork adoption: if the fscache replace directive is ever lost, + // this fails in CI instead of silently reviving the truncation bug. + fc := callNewFileCache("test", "10MB", "test", 0, nil) + _, w, err := fc.cache.Get("capability") + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = w.Close() }) + + _, ok := w.(interface{ CloseWithError(error) error }) + Expect(ok).To(BeTrue(), "fscache writer lost CloseWithError; check the go.mod replace directive") + }) + + It("fails the reader with the cause instead of a clean EOF", func() { + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("transcoder died")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"inband"}) + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = s.Close() }) + + _, err = io.ReadAll(s) + Expect(err).To(MatchError(ContainSubstring("transcoder died"))) + }) + + It("fails a reader that joined mid-write with the same cause", func() { + pr, pw := io.Pipe() + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return pr, nil + }) + s1, err := fc.Get(context.Background(), &testArg{"joined"}) + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = s1.Close() }) + + // The blocking pipe write gives a happens-before: the entry is in flight. + _, err = pw.Write([]byte("PARTIAL")) + Expect(err).To(BeNil()) + + s2, err := fc.Get(context.Background(), &testArg{"joined"}) + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = s2.Close() }) + Expect(s2.Cached).To(BeTrue()) + + Expect(pw.CloseWithError(errors.New("transcoder died"))).To(Succeed()) + + _, err = io.ReadAll(s2) + Expect(err).To(MatchError(ContainSubstring("transcoder died"))) + }) + It("does not write a completion marker when the write fails after partial bytes", func() { // Mimics a transcode that produces real output and then dies: // the bytes land on disk, but the entry must NOT be marked complete. @@ -304,9 +352,9 @@ var _ = Describe("File Caches", func() { Expect(calls.Load()).To(BeNumerically("==", 2)) }) - It("survives an invalidated entry's deferred file removal", func() { - // invalidate() drops the map entry but defers the unlink until readers close; - // a Get in that window re-creates the file, which the deferred unlink then eats. + It("removes a failed entry promptly, without eating its replacement", func() { + // Cancel closes the failed entry's readers, so its removal no longer defers + // past the point where a new entry re-creates the same file. var n atomic.Int32 fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { if n.Add(1) == 1 { @@ -319,7 +367,6 @@ var _ = Describe("File Caches", func() { s1, err := fc.Get(context.Background(), &testArg{"deferred"}) Expect(err).To(BeNil()) - // The failed write invalidates the entry; the removal now waits on s1. Eventually(func() bool { return fc.cache.Exists(key) }).Should(BeFalse()) s2, err := fc.Get(context.Background(), &testArg{"deferred"}) @@ -330,15 +377,16 @@ var _ = Describe("File Caches", func() { Expect(s1.Close()).To(Succeed()) dataPath := fcSpreadFS(fc).KeyMapper(key) - Eventually(func() bool { + Consistently(func() error { _, e := os.Stat(dataPath) - return os.IsNotExist(e) - }).Should(BeTrue(), "expected the deferred removal to take the re-created file") + return e + }).Should(Succeed(), "the replacement entry's file must survive the failed entry's cleanup") s3, err := fc.Get(context.Background(), &testArg{"deferred"}) Expect(err).ToNot(HaveOccurred()) Expect(io.ReadAll(s3)).To(Equal([]byte("GOOD"))) _ = s3.Close() + Expect(n.Load()).To(Equal(int32(2)), "the third Get must be served from cache") }) It("re-fetches when an adopted entry's data file vanished", func() { From f08b5297ee01e1c1195344f32204a8ffcd107750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 25 Aug 2026 23:59:40 -0400 Subject: [PATCH 472/596] feat(ui): add Refresh Metadata to the album and artist context menus (#6036) * refactor(artwork): move artworkItemName into core/artwork as ItemName * feat(external): add RefreshInfo to force an external info refresh RefreshInfo re-fetches and re-saves external info for one artist or album, bypassing the TTL check that UpdateArtistInfo/UpdateAlbumInfo use. It is synchronous; callers that must not block detach it themselves. Also makes MockArtistRepo/MockAlbumRepo.UpdateExternalInfo persist to Data (previously a no-op) and adds the new method to the e2e noopProvider, both required so the interface addition compiles and is observable in tests. * feat(external): broadcast RefreshResource after external info is saved populateArtistInfo and populateAlbumInfo now emit the same RefreshResource event the artwork worker uses, so the UI learns about both foreground and background metadata refreshes. * feat(nativeapi): replace artwork refresh endpoint with metadata refresh * feat(ui): add refreshMetadata to the data provider * feat(ui): add a Refresh Metadata item to the album and artist context menus * fix(ui): re-fetch artist info when the record is refreshed * test: fix mislabeled spec, add kind-gate negative case, guard nil mock maps - Rename the RefreshInfo spec that claimed to cover the save-failure/broadcast path: SetError(true) fails Get too, so it only proves RefreshInfo bails out early at getArtist. - Add a spec proving playlist refreshes skip the external-info step, since that asymmetry (al/ar only) was documented but unasserted. - Add lazy nil-map init to MockAlbumRepo/MockArtistRepo.UpdateExternalInfo so a composite-literal-constructed mock doesn't panic on first save. * test: relocate discArtworkName specs from cmd to core/artwork artworkItemName moved into core/artwork as ItemName in an earlier commit, but its disc-name specs stayed behind in cmd/artwork_test.go, reaching across packages. Move them to core/artwork/item_name_test.go where the code now lives. * fix(ui): shape refreshMetadata like a react-admin response react-admin validates custom dataProvider methods and rejects any response without a `data` key, so the raw httpClient promise made every click surface an error toast instead of the success message. The unit test mocked useDataProvider, which skips that validation. Also folds "which kinds have external info" into external.HasInfo so the handler stops restating it, drops the nil-broker guard that only existed for tests, and delegates the mocks' UpdateExternalInfo to Put. * refactor(external): unexport infoKinds Only HasInfo is used outside the package, so the slice itself does not need to be exported. * refactor(artwork): fold ItemName into housekeeping.go next to Refresh ItemName exists to guard Refresh from ids that would orphan a queue row, and both callers invoke them back to back. A separate file hid that pairing; it was only split out to keep the move out of cmd/ legible in review. * fix(nativeapi): return 500 when the refresh lookup fails for a non-ErrNotFound reason A transient repository error told the admin the id did not exist, and the error was dropped without a log line, so nothing pointed at the real cause. Also drops the inherited claim that clearing artwork state shows a placeholder. Reads fall back to local resolution, so that only holds when there is no local art. * fix(ui): move Refresh Metadata above Get Info in the context menu Menu order follows key insertion order in the options object, so the new spec pins the position rather than leaving it to be shuffled by the next addition. --- cmd/artwork.go | 61 +----- cmd/artwork_test.go | 27 --- cmd/wire_gen.go | 9 +- core/artwork/housekeeping.go | 57 ++++++ core/artwork/housekeeping_test.go | 53 ++++++ core/external/provider.go | 47 ++++- core/external/provider_refreshinfo_test.go | 156 +++++++++++++++ core/external/provider_similarsongs_test.go | 2 +- core/external/provider_topsongs_test.go | 2 +- .../external/provider_updatealbuminfo_test.go | 2 +- .../provider_updateartistinfo_test.go | 2 +- resources/i18n/pt-br.json | 4 +- server/nativeapi/artwork.go | 34 ---- server/nativeapi/artwork_test.go | 95 ---------- server/nativeapi/config_test.go | 2 +- server/nativeapi/library_test.go | 2 +- server/nativeapi/metadata.go | 56 ++++++ server/nativeapi/metadata_test.go | 177 ++++++++++++++++++ server/nativeapi/native_api.go | 8 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- .../user_password_token_refresh_test.go | 2 +- server/subsonic/e2e/e2e_suite_test.go | 4 + tests/mock_album_repo.go | 8 +- tests/mock_artist_repo.go | 8 +- ui/src/artist/ArtistShow.jsx | 6 +- ui/src/artist/ArtistShow.test.jsx | 63 +++++++ ui/src/common/ContextMenus.jsx | 18 +- ui/src/common/ContextMenus.test.jsx | 57 +++++- ui/src/dataProvider/wrapperDataProvider.js | 8 + .../dataProvider/wrapperDataProvider.test.js | 33 ++++ ui/src/i18n/en.json | 4 +- 33 files changed, 766 insertions(+), 247 deletions(-) create mode 100644 core/external/provider_refreshinfo_test.go delete mode 100644 server/nativeapi/artwork.go delete mode 100644 server/nativeapi/artwork_test.go create mode 100644 server/nativeapi/metadata.go create mode 100644 server/nativeapi/metadata_test.go create mode 100644 ui/src/artist/ArtistShow.test.jsx diff --git a/cmd/artwork.go b/cmd/artwork.go index 8b9e28f0e..5cd4fc146 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -648,7 +648,7 @@ func refreshItems(ctx context.Context, ds model.DataStore, targets []model.Artwo for _, t := range targets { kind, id := t.Kind, t.ID // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. - if _, err := artworkItemName(ctx, ds, kind, id); err != nil { + if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil { log.Error(ctx, "Item not found", "kind", kind, "id", id, err) failed++ continue @@ -963,7 +963,7 @@ func runExplain(ctx context.Context, args []string) { } kind, id := targets[0].Kind, targets[0].ID - name, err := artworkItemName(ctx, ds, kind, id) + name, err := artwork.ItemName(ctx, ds, kind, id) if err != nil { log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) } @@ -1005,60 +1005,3 @@ func runExplain(ctx context.Context, args []string) { log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr) } } - -// artworkItemName looks the entity up under its own kind, so a mismatched kind/id pair is -// reported as not found instead of silently explaining another entity's artwork. -func artworkItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { - switch kind { - case model.KindArtistArtwork: - ar, err := ds.Artist(ctx).Get(id) - if err != nil { - return "", err - } - return ar.Name, nil - case model.KindAlbumArtwork: - al, err := ds.Album(ctx).Get(id) - if err != nil { - return "", err - } - return al.Name, nil - case model.KindPlaylistArtwork: - pls, err := ds.Playlist(ctx).Get(id) - if err != nil { - return "", err - } - return pls.Name, nil - case model.KindRadioArtwork: - rd, err := ds.Radio(ctx).Get(id) - if err != nil { - return "", err - } - return rd.Name, nil - case model.KindMediaFileArtwork: - mf, err := ds.MediaFile(ctx).Get(id) - if err != nil { - return "", err - } - return mf.Title, nil - case model.KindDiscArtwork: - return discArtworkName(ctx, ds, id) - } - return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) -} - -func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { - albumID, discNumber, err := model.ParseDiscArtworkID(id) - if err != nil { - return "", err - } - al, err := ds.Album(ctx).Get(albumID) - if err != nil { - return "", err - } - name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) - // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. - if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { - name += ": " + subtitle - } - return name, nil -} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index f17206aaa..a7220d2d5 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -424,33 +424,6 @@ var _ = Describe("explainConfig", func() { ) }) -var _ = Describe("discArtworkName", func() { - var ds *tests.MockDataStore - - BeforeEach(func() { - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}}) - ds = &tests.MockDataStore{MockedAlbum: albumRepo} - }) - - It("names the album, the disc and its subtitle", func() { - name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2") - Expect(err).ToNot(HaveOccurred()) - Expect(name).To(Equal("Sandinista! (disc 2): Side Three")) - }) - - It("omits the subtitle when the disc has none", func() { - name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1") - Expect(err).ToNot(HaveOccurred()) - Expect(name).To(Equal("Sandinista! (disc 1)")) - }) - - It("rejects an id that is not <albumID>:<disc>", func() { - _, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1") - Expect(err).To(HaveOccurred()) - }) -}) - var _ = Describe("artwork refresh command", func() { It("requires at least one argument", func() { Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 49a99f8ca..de4c55a1e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -76,7 +76,10 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider) return router } @@ -97,7 +100,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) @@ -129,7 +132,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ce98e2a03..a3330d7cf 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -166,6 +166,63 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { return nil } +// ItemName resolves a kind+id to the entity's display name, and errors when the item +// does not exist. Callers use it to reject ids that would otherwise orphan a queue row. +func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { + switch kind { + case model.KindArtistArtwork: + ar, err := ds.Artist(ctx).Get(id) + if err != nil { + return "", err + } + return ar.Name, nil + case model.KindAlbumArtwork: + al, err := ds.Album(ctx).Get(id) + if err != nil { + return "", err + } + return al.Name, nil + case model.KindPlaylistArtwork: + pls, err := ds.Playlist(ctx).Get(id) + if err != nil { + return "", err + } + return pls.Name, nil + case model.KindRadioArtwork: + rd, err := ds.Radio(ctx).Get(id) + if err != nil { + return "", err + } + return rd.Name, nil + case model.KindMediaFileArtwork: + mf, err := ds.MediaFile(ctx).Get(id) + if err != nil { + return "", err + } + return mf.Title, nil + case model.KindDiscArtwork: + return discArtworkName(ctx, ds, id) + } + return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) +} + +func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { + albumID, discNumber, err := model.ParseDiscArtworkID(id) + if err != nil { + return "", err + } + al, err := ds.Album(ctx).Get(albumID) + if err != nil { + return "", err + } + name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) + // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. + if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { + name += ": " + subtitle + } + return name, nil +} + // Refresh drops an item's resolved artwork state and re-queues it at Bump priority. func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil { diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 7aecd2760..c9809203a 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -332,3 +332,56 @@ var _ = Describe("Housekeeping", func() { }) }) }) + +var _ = Describe("ItemName", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-1", Name: "Kid A"}, + {ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}, + }) + ds = &tests.MockDataStore{MockedAlbum: albumRepo} + Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + }) + + It("returns the album name", func() { + Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A")) + }) + + It("returns the artist name", func() { + Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead")) + }) + + It("errors for an unknown album", func() { + _, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("errors for an unsupported kind", func() { + // model.Kind is a struct with unexported fields, so the zero value is the only + // unsupported Kind constructible from outside package model. + _, err := ItemName(ctx, ds, model.Kind{}, "al-1") + Expect(err).To(HaveOccurred()) + }) + + Context("disc artwork", func() { + It("names the album, the disc and its subtitle", func() { + Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")). + To(Equal("Sandinista! (disc 2): Side Three")) + }) + + It("omits the subtitle when the disc has none", func() { + Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")). + To(Equal("Sandinista! (disc 1)")) + }) + + It("rejects an id that is not <albumID>:<disc>", func() { + _, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2") + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/external/provider.go b/core/external/provider.go index 782c7c3aa..5c46dc644 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" @@ -33,12 +35,14 @@ type Provider interface { UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) + RefreshInfo(ctx context.Context, kind model.Kind, id string) error } type provider struct { ds model.DataStore ag Agents matcher *matcher.Matcher + broker events.Broker artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -83,13 +87,17 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { - e := &provider{ds: ds, ag: agents, matcher: m} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher, broker events.Broker) Provider { + e := &provider{ds: ds, ag: agents, matcher: m, broker: broker} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e } +func (e *provider) broadcastRefresh(ctx context.Context, resource, id string) { + e.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(resource, id)) +} + func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { var entity any entity, err := model.GetEntityByID(ctx, e.ds, id) @@ -179,6 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl "elapsed", time.Since(start), err) } else { log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) + e.broadcastRefresh(ctx, "album", album.ID) } return album, nil @@ -272,10 +281,44 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au "elapsed", time.Since(start), err) } else { log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) + e.broadcastRefresh(ctx, "artist", artist.ID) } return artist, nil } +// infoKinds are the kinds RefreshInfo can act on. Callers check this instead of restating +// the set, so the switch below stays the only place that has to know how each kind loads. +var infoKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} + +// HasInfo reports whether a kind has external info to refresh. +func HasInfo(kind model.Kind) bool { return slices.Contains(infoKinds, kind) } + +// RefreshInfo re-fetches external info for one item, ignoring the TTL. It is synchronous: +// callers that must not block are responsible for detaching it. +func (e *provider) RefreshInfo(ctx context.Context, kind model.Kind, id string) error { + ctx, cancel := context.WithTimeout(ctx, refreshTimeout) + defer cancel() + + switch kind { + case model.KindArtistArtwork: + artist, err := e.getArtist(ctx, id) + if err != nil { + return err + } + _, err = e.populateArtistInfo(ctx, artist) + return err + case model.KindAlbumArtwork: + album, err := e.getAlbum(ctx, id) + if err != nil { + return err + } + _, err = e.populateAlbumInfo(ctx, album) + return err + default: + return model.ErrNotFound + } +} + func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { artist, err := e.findArtist(ctx, artistName, id) if err != nil { diff --git a/core/external/provider_refreshinfo_test.go b/core/external/provider_refreshinfo_test.go new file mode 100644 index 000000000..e7910a734 --- /dev/null +++ b/core/external/provider_refreshinfo_test.go @@ -0,0 +1,156 @@ +package external_test + +import ( + "context" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +type fakeBroker struct { + events.Broker + mu sync.Mutex + events []events.Event +} + +func (f *fakeBroker) SendBroadcastMessage(_ context.Context, e events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, e) +} + +func (f *fakeBroker) sent() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.events) +} + +var _ = Describe("Provider - RefreshInfo", func() { + var ( + ctx context.Context + p external.Provider + ds *tests.MockDataStore + ag *mockAgents + broker *fakeBroker + mockArtistRepo *tests.MockArtistRepo + mockAlbumRepo *tests.MockAlbumRepo + ) + + expectArtistAgents := func() { + ag.On("GetArtistMBID", mock.Anything, mock.Anything, mock.Anything).Return("mbid-1", nil) + ag.On("GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.ExternalImage{}, nil) + ag.On("GetArtistBiography", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return("Fresh Bio", nil) + ag.On("GetArtistURL", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return("http://artist.url", nil) + ag.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Artist{}, nil) + } + + expectAlbumAgents := func() { + ag.On("GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&agents.AlbumInfo{URL: "http://album.url", Description: "Fresh Notes"}, nil) + ag.On("GetAlbumImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.ExternalImage{}, nil) + } + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = new(tests.MockDataStore) + ag = new(mockAgents) + broker = &fakeBroker{} + p = external.NewProvider(ds, ag, matcher.New(ds), broker) + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + }) + + It("repopulates an artist even when its info is fresh", func() { + fresh := time.Now() + mockArtistRepo.SetData(model.Artists{{ + ID: "ar-1", Name: "Test Artist", Biography: "stale", ExternalInfoUpdatedAt: &fresh, + }}) + expectArtistAgents() + + Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) + + saved, err := mockArtistRepo.Get("ar-1") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Biography).To(Equal("Fresh Bio")) + }) + + It("repopulates an album even when its info is fresh", func() { + fresh := time.Now() + mockAlbumRepo.SetData(model.Albums{{ + ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist", + Description: "stale", ExternalInfoUpdatedAt: &fresh, + }}) + expectAlbumAgents() + + Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) + + saved, err := mockAlbumRepo.Get("al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Description).To(Equal("Fresh Notes")) + }) + + It("returns ErrNotFound for an unknown id", func() { + Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "nope")).To(MatchError(model.ErrNotFound)) + }) + + It("returns ErrNotFound for a kind with no external info", func() { + Expect(p.RefreshInfo(ctx, model.KindPlaylistArtwork, "pl-1")).To(MatchError(model.ErrNotFound)) + }) + + It("broadcasts a RefreshResource naming the artist", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) + expectArtistAgents() + + Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) + + sent := broker.sent() + Expect(sent).To(HaveLen(1)) + rr, ok := sent[0].(*events.RefreshResource) + Expect(ok).To(BeTrue()) + Expect(rr.Data(rr)).To(ContainSubstring("ar-1")) + Expect(rr.Data(rr)).To(ContainSubstring("artist")) + }) + + It("broadcasts a RefreshResource naming the album", func() { + mockAlbumRepo.SetData(model.Albums{{ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist"}}) + expectAlbumAgents() + + Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) + + sent := broker.sent() + Expect(sent).To(HaveLen(1)) + Expect(sent[0].Data(sent[0])).To(ContainSubstring("album")) + Expect(sent[0].Data(sent[0])).To(ContainSubstring("al-1")) + }) + + It("does not broadcast when the artist cannot be loaded", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) + expectArtistAgents() + mockArtistRepo.SetError(true) + + _ = p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1") + + Expect(broker.sent()).To(BeEmpty()) + }) + + It("reports which kinds have external info", func() { + Expect(external.HasInfo(model.KindArtistArtwork)).To(BeTrue()) + Expect(external.HasInfo(model.KindAlbumArtwork)).To(BeTrue()) + Expect(external.HasInfo(model.KindPlaylistArtwork)).To(BeFalse()) + }) +}) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index ac54495f4..3944edb68 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -61,7 +61,7 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) + provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{}) }) // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 795a7ff74..16e8ad840 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -45,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag, matcher.New(ds)) + p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) }) It("returns top songs for a known artist", func() { diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 21824c93f..a54ef922c 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -34,7 +34,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds)) + p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index d783128fb..853d56bbc 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -37,7 +37,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds)) + p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 9a6338ae1..a4ad6bc8c 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -93,6 +93,7 @@ "addToPlaylist": "Adicionar à playlist", "download": "Baixar", "info": "Detalhes", + "refresh": "Atualizar Metadados", "share": "Compartilhar" }, "lists": { @@ -602,7 +603,8 @@ "coverUploaded": "Capa atualizada", "coverRemoved": "Capa removida", "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa" + "coverRemoveError": "Erro ao remover capa", + "metadataRefreshStarted": "Atualizando metadados em segundo plano" }, "menu": { "library": "Biblioteca", diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go deleted file mode 100644 index cfd943b1f..000000000 --- a/server/nativeapi/artwork.go +++ /dev/null @@ -1,34 +0,0 @@ -package nativeapi - -import ( - "net/http" - "slices" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -func (api *Router) addArtworkRoute(r chi.Router) { - r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) -} - -// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved). -func (api *Router) refreshArtwork() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - kind, _ := model.ParseKind(chi.URLParam(r, "kind")) - id := chi.URLParam(r, "id") - if !slices.Contains(artwork.RefreshableKinds, kind) { - http.Error(w, "invalid artwork kind", http.StatusBadRequest) - return - } - if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { - log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/server/nativeapi/artwork_test.go b/server/nativeapi/artwork_test.go deleted file mode 100644 index b26a94e6a..000000000 --- a/server/nativeapi/artwork_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package nativeapi - -import ( - "context" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "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" -) - -var _ = Describe("Artwork API", func() { - var ds *tests.MockDataStore - var artRepo *tests.MockArtworkRepo - var queueRepo *tests.MockArtworkQueueRepo - var router http.Handler - var adminToken, userToken string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} - auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - - adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} - regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} - Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) - Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) - - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("POST /api/artwork/{kind}/{id}/refresh", func() { - It("clears state and enqueues a Bump for admins", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - - queued, err := queueRepo.DequeueBatch(1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", "al-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("returns 400 for an invalid kind", func() { - req := createAuthenticatedRequest("POST", "/artwork/xx/id-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("denies access to regular users", func() { - req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, userToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access without authentication", func() { - req := createUnauthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 107b01e01..d1007f457 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 9b7061845..13b33c238 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/metadata.go b/server/nativeapi/metadata.go new file mode 100644 index 000000000..913ab0471 --- /dev/null +++ b/server/nativeapi/metadata.go @@ -0,0 +1,56 @@ +package nativeapi + +import ( + "context" + "errors" + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (api *Router) addMetadataRoute(r chi.Router) { + r.Post("/metadata/{kind}/{id}/refresh", api.refreshMetadata()) +} + +// refreshMetadata clears the artwork state deliberately, so a wrong pick cannot be served from +// cache again; reads fall back to local resolution while the worker re-runs the chain at Bump. +func (api *Router) refreshMetadata() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind, _ := model.ParseKind(chi.URLParam(r, "kind")) + id := chi.URLParam(r, "id") + if !slices.Contains(artwork.RefreshableKinds, kind) { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if _, err := artwork.ItemName(ctx, api.ds, kind, id); err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error(ctx, "Error looking up item to refresh", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if external.HasInfo(kind) { + // Detached: the request context is cancelled the moment this handler returns 204. + bg := context.WithoutCancel(ctx) + go func() { + if err := api.provider.RefreshInfo(bg, kind, id); err != nil { + log.Error(bg, "Error refreshing external info", "kind", kind, "id", id, err) + } + }() + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go new file mode 100644 index 000000000..ebc9aeb28 --- /dev/null +++ b/server/nativeapi/metadata_test.go @@ -0,0 +1,177 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "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 fakeProvider struct { + external.Provider + mu sync.Mutex + called []string +} + +func (f *fakeProvider) RefreshInfo(_ context.Context, kind model.Kind, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.called = append(f.called, kind.Prefix()+"/"+id) + return nil +} + +func (f *fakeProvider) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.called) +} + +var _ = Describe("Metadata API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var albumRepo *tests.MockAlbumRepo + var provider *fakeProvider + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo := tests.CreateMockArtistRepo() + playlistRepo := tests.CreateMockPlaylistRepo() + Expect(albumRepo.Put(&model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) + Expect(artistRepo.Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + Expect(playlistRepo.Put(&model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedPlaylist: playlistRepo, + } + auth.Init(ds) + provider = &fakeProvider{} + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("POST /api/metadata/{kind}/{id}/refresh", func() { + It("clears state and enqueues a Bump for admins", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", "al-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("returns 400 for an invalid kind", func() { + req := createAuthenticatedRequest("POST", "/metadata/xx/id-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("denies access to regular users", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("denies access without authentication", func() { + req := createUnauthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("triggers an external info refresh for albums", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Eventually(provider.calls).Should(ContainElement("al/al-1")) + }) + + It("triggers an external info refresh for artists", func() { + req := createAuthenticatedRequest("POST", "/metadata/ar/ar-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Eventually(provider.calls).Should(ContainElement("ar/ar-1")) + }) + + It("skips the external info refresh for kinds without external info", func() { + req := createAuthenticatedRequest("POST", "/metadata/pl/pl-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Consistently(provider.calls).ShouldNot(ContainElement("pl/pl-1")) + }) + + It("returns 404 for an unknown id", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/nope/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 when the lookup fails for a reason other than not-found", func() { + albumRepo.SetError(true) + + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f97bd26b2..57a712a20 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -46,10 +47,11 @@ type Router struct { maintenance core.Maintenance pluginManager PluginManager imgUpload artwork.Uploader + provider external.Provider } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider} r.Handler = r.routes() return r } @@ -92,7 +94,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) - api.addArtworkRoute(r) + api.addMetadataRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b1ed09d65..203fcd4cf 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 74ef58cab..9abcc477f 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index aa91a7951..1683885e7 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go index 32f4b13cb..2a363980f 100644 --- a/server/nativeapi/user_password_token_refresh_test.go +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() auth.Init(ds) userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) }) diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 8998731a4..58e877b0d 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -357,6 +357,10 @@ func (n noopProvider) TopSongs(context.Context, string, string, int) (model.Medi return nil, nil } +func (n noopProvider) RefreshInfo(context.Context, model.Kind, string) error { + return nil +} + // Compile-time interface checks var ( _ artwork.Artwork = noopArtwork{} diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index ff6f9cff6..c63f7c425 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -65,6 +65,9 @@ func (m *MockAlbumRepo) Put(al *model.Album) error { if al.ID == "" { al.ID = id.NewRandom() } + if m.Data == nil { + m.Data = make(map[string]*model.Album) + } m.Data[al.ID] = al return nil } @@ -142,10 +145,7 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { } func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { - if m.Err { - return errors.New("unexpected error") - } - return nil + return m.Put(album) } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 9691a6584..b71942208 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -58,6 +58,9 @@ func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { if ar.ID == "" { ar.ID = id.NewRandom() } + if m.Data == nil { + m.Data = make(map[string]*model.Artist) + } m.Data[ar.ID] = ar return nil } @@ -137,10 +140,7 @@ func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistC } func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { - if m.Err { - return errors.New("mock repo error") - } - return nil + return m.Put(artist) } func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index eef2989d5..515c1ab08 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -53,7 +53,7 @@ const useStyles = makeStyles( }, ) -const ArtistDetails = (props) => { +export const ArtistDetails = (props) => { const record = useRecordContext(props) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'), { noSsr: true, @@ -75,7 +75,9 @@ const ArtistDetails = (props) => { // eslint-disable-next-line no-console console.error('error on artist page', e) }) - }, [record.id]) + // Keyed on the record, not its id: a refreshed record must re-fetch, or the stale + // artistInfo state keeps winning the `||` above. + }, [record]) const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails return ( diff --git a/ui/src/artist/ArtistShow.test.jsx b/ui/src/artist/ArtistShow.test.jsx new file mode 100644 index 000000000..f51cde5ed --- /dev/null +++ b/ui/src/artist/ArtistShow.test.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { RecordContextProvider } from 'react-admin' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ArtistDetails } from './ArtistShow' +import subsonic from '../subsonic' + +vi.mock('../subsonic', () => ({ + default: { getArtistInfo: vi.fn(), getCoverArtUrl: vi.fn() }, +})) + +// Not under test here: isolate ArtistDetails from the leaf presentational views. +vi.mock('./DesktopArtistDetails', () => ({ default: () => null })) +vi.mock('./MobileArtistDetails', () => ({ default: () => null })) + +const mockGetArtistInfo = subsonic.getArtistInfo + +describe('ArtistDetails', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetArtistInfo.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + artistInfo: { biography: 'fetched' }, + }, + }, + }) + }) + + const theme = createTheme() + + const wrap = (record) => ( + <ThemeProvider theme={theme}> + <RecordContextProvider value={record}> + <ArtistDetails /> + </RecordContextProvider> + </ThemeProvider> + ) + + const renderDetails = (record) => render(wrap(record)) + + it('re-fetches the artist info when the record object changes', async () => { + const record = { id: 'ar1', name: 'Artist', biography: 'old' } + const { rerender } = renderDetails(record) + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) + + rerender(wrap({ ...record, biography: 'new' })) + + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(2)) + }) + + it('does not re-fetch when the same record object is passed again', async () => { + const record = { id: 'ar1', name: 'Artist', biography: 'old' } + const { rerender } = renderDetails(record) + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) + + rerender(wrap(record)) + + expect(mockGetArtistInfo).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 7ad8c735c..606506459 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -7,7 +7,12 @@ import MenuItem from '@material-ui/core/MenuItem' import MoreVertIcon from '@material-ui/icons/MoreVert' import { MdQuestionMark } from 'react-icons/md' import { makeStyles } from '@material-ui/core/styles' -import { useDataProvider, useNotify, useTranslate } from 'react-admin' +import { + useDataProvider, + useNotify, + usePermissions, + useTranslate, +} from 'react-admin' import clsx from 'clsx' import { playNext, @@ -69,6 +74,7 @@ const ContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() + const { permissions } = usePermissions() const [anchorEl, setAnchorEl] = useState(null) const isArtist = resource === 'artist' @@ -129,6 +135,16 @@ const ContextMenu = ({ ) }, }, + refresh: { + enabled: permissions === 'admin', + needData: false, + label: translate('resources.album.actions.refresh'), + action: (record) => + dataProvider + .refreshMetadata(resource, record.id) + .then(() => notify('message.metadataRefreshStarted')) + .catch(() => notify('ra.page.error', 'warning')), + }, ...(!hideInfo && { info: { enabled: true, diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx index 72a98b64b..59a7de966 100644 --- a/ui/src/common/ContextMenus.test.jsx +++ b/ui/src/common/ContextMenus.test.jsx @@ -17,12 +17,21 @@ const { mockConfig } = vi.hoisted(() => ({ })) vi.mock('../config', () => ({ default: mockConfig })) +const { mockPermissions, mockRefreshMetadata } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, + mockRefreshMetadata: vi.fn(), +})) + vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => vi.fn(), - useDataProvider: () => ({ getList: vi.fn() }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useDataProvider: () => ({ + getList: vi.fn(), + refreshMetadata: mockRefreshMetadata, + }), useTranslate: () => (x) => x, } }) @@ -43,6 +52,7 @@ describe('ContextMenus', () => { vi.clearAllMocks() mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' }) describe('ArtistContextMenu', () => { @@ -75,4 +85,49 @@ describe('ContextMenus', () => { expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() }) }) + + describe('refresh metadata', () => { + it('shows the item for admins on the album menu', () => { + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + expect( + screen.getByText('resources.album.actions.refresh'), + ).toBeInTheDocument() + }) + + // Menu order comes from key insertion order in the options object, so it is easy to + // change by accident when adding an entry. + it('places the item directly above Get Info', () => { + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + const labels = screen + .getAllByRole('menuitem') + .map((item) => item.textContent) + const refreshAt = labels.indexOf('resources.album.actions.refresh') + const infoAt = labels.indexOf('resources.album.actions.info') + + expect(refreshAt).toBeGreaterThanOrEqual(0) + expect(infoAt).toEqual(refreshAt + 1) + }) + + it('shows the item for admins on the artist menu', () => { + renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) + expect( + screen.getByText('resources.album.actions.refresh'), + ).toBeInTheDocument() + }) + + it('hides the item for regular users', () => { + mockPermissions.value = 'regular' + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + expect( + screen.queryByText('resources.album.actions.refresh'), + ).not.toBeInTheDocument() + }) + + it('calls refreshMetadata with the resource and id', () => { + mockRefreshMetadata.mockResolvedValue({}) + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + fireEvent.click(screen.getByText('resources.album.actions.refresh')) + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1') + }) + }) }) diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index f5004308b..e79beb787 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -4,6 +4,8 @@ import { REST_URL } from '../consts' const dataProvider = jsonServerProvider(REST_URL, httpClient) +const REFRESH_KIND = { album: 'al', artist: 'ar' } + const isAdmin = () => { const role = localStorage.getItem('role') return role === 'admin' @@ -221,6 +223,12 @@ const wrapperDataProvider = { data: json, })) }, + // The endpoint answers 204 with no body, but react-admin rejects any response without a + // `data` key, so the id stands in for one. + refreshMetadata: (resource, id) => + httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, { + method: 'POST', + }).then(() => ({ data: { id } })), } export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js index fbc82f969..4225a5a54 100644 --- a/ui/src/dataProvider/wrapperDataProvider.test.js +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -87,4 +87,37 @@ describe('wrapperDataProvider', () => { ) }) }) + + describe('refreshMetadata', () => { + it('posts to the album metadata refresh endpoint', () => { + mockHttpClient.mockResolvedValue({ json: {} }) + wrapperDataProvider.refreshMetadata('album', 'al-1') + expect(mockHttpClient).toHaveBeenCalledWith( + expect.stringContaining('/metadata/al/al-1/refresh'), + { method: 'POST' }, + ) + }) + + it('posts to the artist metadata refresh endpoint', () => { + mockHttpClient.mockResolvedValue({ json: {} }) + wrapperDataProvider.refreshMetadata('artist', 'ar-1') + expect(mockHttpClient).toHaveBeenCalledWith( + expect.stringContaining('/metadata/ar/ar-1/refresh'), + { method: 'POST' }, + ) + }) + + // react-admin rejects a custom method whose response has no `data` key, and the + // endpoint answers 204 with no body. + it('resolves to a react-admin shaped response', async () => { + mockHttpClient.mockResolvedValue({ + status: 204, + body: '', + json: undefined, + }) + await expect( + wrapperDataProvider.refreshMetadata('album', 'al-1'), + ).resolves.toEqual({ data: { id: 'al-1' } }) + }) + }) }) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 12968afbd..8823a6749 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -93,7 +93,8 @@ "shuffle": "Shuffle", "addToPlaylist": "Add to Playlist", "download": "Download", - "info": "Get Info" + "info": "Get Info", + "refresh": "Refresh Metadata" }, "lists": { "all": "All", @@ -569,6 +570,7 @@ "coverRemoved": "Cover art removed", "coverUploadError": "Error uploading cover art", "coverRemoveError": "Error removing cover art", + "metadataRefreshStarted": "Refreshing metadata in the background", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", From a9962ebe5de7d1b077eb57852a096897eb3ae8e1 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 26 Aug 2026 10:53:07 -0400 Subject: [PATCH 473/596] refactor(artwork): use the shared httpclient for image downloads Same behavior: httpclient.New sets the Navidrome User-Agent via its transport, so the manual header is no longer needed. --- core/artwork/sources.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 78b7dd68d..069b7bf5a 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -16,10 +16,10 @@ import ( "strings" "time" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/httpclient" "go.senan.xyz/taglib" ) @@ -163,9 +163,8 @@ type readCloser struct { } func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { - hc := http.Client{Timeout: 5 * time.Second} + hc := httpclient.New(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 if err != nil { return nil, "", err From 23e4c8f580365e642d1e0f24c30484057423f4a1 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 26 Aug 2026 10:57:01 -0400 Subject: [PATCH 474/596] refactor(plugins): build the host HTTP client with httpclient.New CheckRedirect is set on the returned client, so the plugin service no longer hand-builds an http.Client just to attach the shared transport. --- plugins/host_httpclient.go | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 4c8f85acd..d52898bdd 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -46,24 +46,21 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI pluginName: pluginName, requiredHosts: requiredHosts, } - svc.client = &http.Client{ - Transport: httpclient.NewTransport(nil), - // Timeout is set per-request via context deadline, not here. - // CheckRedirect validates hosts and enforces redirect limits. - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.Context().Value(noFollowRedirectsKey) != nil { - return http.ErrUseLastResponse - } - if len(via) >= httpClientMaxRedirects { - log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) - return http.ErrUseLastResponse - } - if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { - log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) - return err - } - return nil - }, + // No client timeout: it is set per-request via context deadline. + svc.client = httpclient.New(0) + svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.Context().Value(noFollowRedirectsKey) != nil { + return http.ErrUseLastResponse + } + if len(via) >= httpClientMaxRedirects { + log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { + log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) + return err + } + return nil } return svc } From b0e1943d8ba8ba8f7abd92d0666e332c096a9b3c Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 26 Aug 2026 18:03:59 -0400 Subject: [PATCH 475/596] fix(ui): always show the Last.fm link on the artist details page The button only rendered when an agent supplied a real last.fm URL, either embedded in the biography or as artistInfo.lastFmUrl. Neither source is reliable anymore: cleanContent strips the "Read more on Last.fm" anchor out of the biography, and the Last.fm agent does not register at all unless LastFM.ApiKey and LastFM.Secret are set, in which case GetArtistURL falls through to ListenBrainz, which returns the artist's official homepage. The isLastFmURL guard then correctly rejects it and the button disappears. Build the URL from the artist name when no canonical one is available, the same way AlbumExternalLinks already does for albums. A real last.fm URL is still preferred when one is present, and the button stays hidden when Last.fm is disabled or the artist has no name. --- ui/src/artist/ArtistExternalLink.jsx | 16 +++--- ui/src/artist/ArtistExternalLink.test.jsx | 60 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 ui/src/artist/ArtistExternalLink.test.jsx diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index a83972f17..6595d1fbc 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -38,15 +38,15 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - if (lastFMlink && isLastFmURL(lastFMlink[2])) { + // No agent may be enabled to supply a canonical URL, so fall back to a name-based one. + const lastFMUrl = + (lastFMlink && isLastFmURL(lastFMlink[2]) && lastFMlink[2]) || + (isLastFmURL(artistInfo?.lastFmUrl) && artistInfo.lastFmUrl) || + (record.name && + `https://last.fm/music/${encodeURIComponent(record.name)}`) + if (lastFMUrl) { addLink( - lastFMlink[2], - 'message.openIn.lastfm', - <ImLastfm2 className="lastfm-icon" />, - ) - } else if (isLastFmURL(artistInfo?.lastFmUrl)) { - addLink( - artistInfo?.lastFmUrl, + lastFMUrl, 'message.openIn.lastfm', <ImLastfm2 className="lastfm-icon" />, ) diff --git a/ui/src/artist/ArtistExternalLink.test.jsx b/ui/src/artist/ArtistExternalLink.test.jsx new file mode 100644 index 000000000..4214c21ea --- /dev/null +++ b/ui/src/artist/ArtistExternalLink.test.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { TestContext } from 'ra-test' +import ArtistExternalLinks from './ArtistExternalLink' + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { lastFMEnabled: true }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +describe('ArtistExternalLinks', () => { + beforeEach(() => { + mockConfig.lastFMEnabled = true + }) + + const renderLinks = (artistInfo, record = { id: 'ar-1', name: 'Björk' }) => + render( + <TestContext> + <ArtistExternalLinks artistInfo={artistInfo} record={record} /> + </TestContext>, + ) + + const lastFmHref = () => + screen.getByLabelText('message.openIn.lastfm').closest('a').href + + it('uses the URL returned by the server', () => { + renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) + expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') + }) + + it('uses the URL found in the biography', () => { + renderLinks({ + biography: 'Read more on <a href="https://www.last.fm/music/Bjork">', + lastFmUrl: 'https://bjork.com', + }) + expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') + }) + + it('builds the URL from the artist name when the server has none', () => { + renderLinks({ lastFmUrl: 'https://bjork.com' }) + expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') + }) + + it('builds the URL when there is no artist info at all', () => { + renderLinks(undefined) + expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') + }) + + it('shows no Last.fm link when Last.fm is disabled', () => { + mockConfig.lastFMEnabled = false + renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) + expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() + }) + + it('shows no Last.fm link when the artist has no name', () => { + renderLinks({}, { id: 'ar-1', name: '' }) + expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() + }) +}) From b5f530e90cc57c798e3b8277f96dbcda557a4df3 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 27 Aug 2026 20:19:16 -0400 Subject: [PATCH 476/596] chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index c0809f7b5..624cf9a01 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.6.4 - github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -43,7 +43,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.1 - github.com/onsi/gomega v1.42.1 + github.com/onsi/gomega v1.43.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -53,10 +53,10 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 - github.com/sirupsen/logrus v1.10.0 + github.com/sirupsen/logrus v1.10.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 @@ -92,7 +92,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect + github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -104,7 +104,7 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.3.0 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect diff --git a/go.sum b/go.sum index 8be5a910c..0fcd0a4c7 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= -github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= @@ -101,8 +101,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 h1:dl4UZiszMU+NKHirOiCKTC+hRuNAQ0moHPxSg6WcU1o= +github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -151,8 +151,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= -github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= @@ -187,8 +187,8 @@ github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= -github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -232,8 +232,8 @@ github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc= -github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -266,8 +266,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= From 4b60b21316f9e8467479c1f389e2a12a112bcbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 29 Aug 2026 16:36:08 -0400 Subject: [PATCH 477/596] fix(scanner): read file birth time via statx on Linux (#6046) * fix(scanner): read file birth time via statx on Linux On Linux the file birth time is only reachable through statx(2). We were reading it with times.Get(), which looks only at the plain stat() result, where the field does not exist: djherbis/times declares HasBirthTime=false for Linux, so the check was always false and every file fell back to time.Now(). This has been the case since #2553 introduced the feature, which means that PR was a no-op on Linux from day one. macOS and Windows were never affected, as there the birth time does come back from plain stat. BirthTime() now tries times.Get() first, which costs no syscall and is already correct on macOS, Windows and BSD, and only falls back to times.Stat() on the path when that comes back empty. Ordering matters: on Windows times.Stat() opens the file asking for FILE_WRITE_ATTRIBUTES, which fails on a read-only share before falling back. Not every filesystem stores a birth time. Measured with a probe over real mounts: ext4, SMB/CIFS and mergerfs report one, while NFS and rclone/FUSE never do. Asking those on every file is pure overhead, so a miss is remembered per device on the localFS and skipped from then on. The memo is keyed by device rather than by library, so a library spanning two mounts does not lose birth times on the mount that does support them. Cost of the extra call is ~2us per file against ~52us just to open a file for tag reading, so 0.23s across a 97k-file library, and only for files whose tags are actually read. Existing rows keep their current birth_time: the repository drops that column on update, so only newly added files get the real value. * fix(scanner): return the device id opaquely to satisfy unconvert st.Dev is uint64 on Linux and int32 on darwin, so a uint64() cast is redundant on one and required on the other. Returning it as an opaque value drops the cast entirely, which also removes the gosec suppression that came with it. The value is only ever used as a sync.Map key. --- core/storage/local/deviceid_unix.go | 18 ++++++++++++ core/storage/local/deviceid_windows.go | 8 ++++++ core/storage/local/local.go | 40 +++++++++++++++++++++++++- core/storage/local/local_test.go | 33 +++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 core/storage/local/deviceid_unix.go create mode 100644 core/storage/local/deviceid_windows.go diff --git a/core/storage/local/deviceid_unix.go b/core/storage/local/deviceid_unix.go new file mode 100644 index 000000000..42e7dac70 --- /dev/null +++ b/core/storage/local/deviceid_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package local + +import ( + "io/fs" + "syscall" +) + +// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. +// It is returned opaquely because its width varies by platform, and it is only used as a map key. +func deviceID(fi fs.FileInfo) (any, bool) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, false + } + return st.Dev, true +} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go new file mode 100644 index 000000000..f1b44be4d --- /dev/null +++ b/core/storage/local/deviceid_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package local + +import "io/fs" + +// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. +func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 32aff0955..686838565 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "sync" "sync/atomic" "time" @@ -61,6 +62,8 @@ type localFS struct { fs.FS extractor Extractor root string + // devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once + noBirthTime sync.Map } // ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the @@ -84,7 +87,11 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { if err != nil { return nil, err } - v.FileInfo = localFileInfo{info} + v.FileInfo = localFileInfo{ + FileInfo: info, + path: filepath.Join(lfs.root, filepath.FromSlash(path)), + noBirthTime: &lfs.noBirthTime, + } res[path] = v } } @@ -95,15 +102,46 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { // with metadata.FileInfo type localFileInfo struct { fs.FileInfo + path string + noBirthTime *sync.Map } func (lfi localFileInfo) BirthTime() time.Time { if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { return ts.BirthTime() } + if bt, ok := lfi.statxBirthTime(); ok { + return bt + } return time.Now() } +// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it. +// Filesystems that never report one are remembered per device, so a scan asks each only once. +func (lfi localFileInfo) statxBirthTime() (time.Time, bool) { + if lfi.path == "" { + return time.Time{}, false + } + dev, hasDev := deviceID(lfi.FileInfo) + memo := lfi.noBirthTime + if hasDev && memo != nil { + if _, skip := memo.Load(dev); skip { + return time.Time{}, false + } + } + ts, err := times.Stat(lfi.path) + if err != nil { + return time.Time{}, false + } + if ts.HasBirthTime() { + return ts.BirthTime(), true + } + if hasDev && memo != nil { + memo.Store(dev, struct{}{}) + } + return time.Time{}, false +} + func init() { storage.Register(storage.LocalSchemaID, newLocalStorage) } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 90bdd4b5b..3ed6d6fd9 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" + "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -440,6 +442,37 @@ var _ = Describe("LocalStorage", func() { // Should be around the current time (within last few minutes) Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) }) + + It("reads the birth time from the path, not the time of the call", func() { + // On Linux, birth time is only available via statx(2) on the path. + lfi := localFileInfo{FileInfo: fileInfo, path: testFile} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) + }) + + It("does not remember filesystems that do report a birth time", func() { + memo := &sync.Map{} + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + lfi.BirthTime() + + count := 0 + memo.Range(func(_, _ any) bool { count++; return true }) + Expect(count).To(BeZero()) + }) + + It("skips statx on filesystems already known to have none", func() { + if times.Get(fileInfo).HasBirthTime() { + Skip("this platform reports birth time from FileInfo, so statx is never called") + } + dev, ok := deviceID(fileInfo) + Expect(ok).To(BeTrue()) + + memo := &sync.Map{} + memo.Store(dev, struct{}{}) + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + }) }) It("should delegate all other FileInfo methods", func() { From d7ca00d0189210f5f00c110d7a35b74a98b5a71a Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 29 Aug 2026 17:07:17 -0400 Subject: [PATCH 478/596] chore(deps): update fscache fork to the CancelWithErr simplification stream v1.5.0 added CancelWithErr, which delivers a cancellation cause to blocked reads, future reads, and NextReader. The fscache fork now delegates CloseWithError to it, dropping its own cause recording and reader wrappers. Behavior is unchanged on the Navidrome side. --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 624cf9a01..93e84b052 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ go 1.26 replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 // Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 -replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 +replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 require ( github.com/Masterminds/squirrel v1.5.4 @@ -17,7 +17,7 @@ require ( github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.4.0 + github.com/djherbis/stream v1.5.0 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 diff --git a/go.sum b/go.sum index 0fcd0a4c7..c75b72dd0 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 h1:s254V2hsrrCJXYtAn9WPG/5p4QHenfL9E+j6Tiq5MW4= -github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2/go.mod h1:eNFa48vJrse+8ysT4IJnnUeXwLZNcR0JQumU/W/QoUI= +github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 h1:u3oDvM7pOIouwDGeIoMu9SYgSzVMtiSkOc+xywtKiJk= +github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -41,8 +41,8 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= -github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/stream v1.5.0 h1:+ewqpS/ndTmEiJRH142JyOpmmjnkTxdbJtqoAG5sg0Y= +github.com/djherbis/stream v1.5.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= From 59448e928351f5afd08ee3ce86fa46b081128c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 29 Aug 2026 17:28:29 -0400 Subject: [PATCH 479/596] fix(scrobbler): back off when a provider asks us to, instead of retrying per play (#6028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): retry-later error type with optional server delay Add agents.ErrRetryLater and agents.RetryLaterError, which carries the delay requested by an external service (e.g. ListenBrainz's X-RateLimit-Reset-In). scrobbler.ErrRetryLater becomes an alias of the new sentinel, so existing errors.Is checks and the plugin error-string protocol keep working unchanged. Groundwork for honoring server-requested retry delays across scrobbling, metadata agents and artwork. Song.Equals tests moved to song_test.go to enable external test package. * fix(scrobbler): honor backoff window and server-requested retry delay ListenBrainz 429s were decoded into a typed error that classified as unrecoverable, silently discarding the scrobble (a JSON-bodied 429 was measured live). The client now maps any 429 to agents.RetryLaterError, carrying X-RateLimit-Reset-In when present (capped at 1h). Last.fm error 29 (rate limit) is now retryable like 11/16. The buffer's drain loop no longer lets wake signals bypass an active backoff window - new plays enqueue but drain only when the window closes - and the wait honors the server delay via max(backoff, retryIn). * feat(agents): skip cooling-down agents in aggregate calls When an agent reports retry-later, remember a per-agent cooldown deadline (the server-requested delay, or 1 minute when unspecified) and skip that agent in all aggregate metadata calls until it passes. A round that found no data but skipped or saw a throttled agent returns ErrRetryLater instead of ErrNotFound, so callers cannot mistake rate limiting for a definitive 'no data' answer. * feat(artwork): honor server-requested retry delay when rescheduling When an external image lookup fails with a retry-later error carrying a delay (e.g. a 429 with X-RateLimit-Reset-In), the chain trace carries the largest such hint back to the worker, which reschedules the item at max(exponential backoff, server delay) instead of backoff alone. * feat(plugins): retry-later with optional delay for scrobbler and agent plugins Scrobbler plugins can now return scrobbler(retry_later:N) to request a retry in N seconds (capped at 1h); the bare token keeps its old meaning. Metadata-agent plugins, which had no error vocabulary at all, gain the parallel agent(retry_later[:N]) token, mapped to agents.RetryLaterError so the aggregate's cooldown and the artwork worker honor plugin throttling the same way as built-in agents. * fix: address whole-branch review findings for retry-later handling Narrow the aggregate's throttled rule to the spec sentence: core.Agents returns ErrRetryLater only when no agent answered at all (all skipped-cooling or retry-later). An agent that does not implement the called method now returns an internal errUnsupported instead of ErrNotFound, so it counts as "did not run" — without that, the always-appended local agent would answer for biography, URL and images and make ErrRetryLater unreachable. Wire the consequence in core/external: a throttled round no longer stamps ExternalInfoUpdatedAt (artist and album), so the empty result is not cached for the TTL, and TopSongs maps ErrRetryLater to the same empty-200 the not-found path already produced instead of a new client-facing error. Move the Last.fm code-29 mapping into the client's central error construction so every metadata path produces RetryLaterError, and map ListenBrainz's body-level code 429 (sent with a non-429 HTTP status) the same way. Clamp server- and plugin-requested delays in seconds before scaling to a Duration, in all three parse sites: a header of 18446744074 wrapped past 2^64 and came out as a 0.29s delay. Also: extract the artwork worker's reschedule computation into retryDelay() and cover both it and the trace RetryIn wiring with tests; collapse the double regex call in mapScrobblerError; drop capabilities.ScrobblerErrorRetryLaterIn (ndpgen never emits funcs, so plugin authors could not reach it); regenerate the PDKs so MetadataAgentError reaches the Go and Rust SDKs; de-flake the cooldown tests (long RetryIn for the skip case, separate expiry spec); and cover the max() retry-delay aggregation across users in the scrobble buffer. * refactor: dedupe retry-later parsing and simplify error collection - Add agents.NewRetryLater and agents.RetryLaterFromSeconds, with a single 1h cap, replacing the parse+clamp+multiply logic and the maxRetryInSeconds constant duplicated across listenbrainz, plugins and the agent adapter. - Move HTTP header parsing to httpclient.RetryAfter, so the transport layer owns it and stays domain-agnostic; drop retryInFromHeaders from the ListenBrainz client. Covered by a new Ginkgo table in that package. - Collapse the two near-identical plugin retry_later regexes into one parseRetryLater(prefix, msg) shared by the agent and scrobbler adapters. - Fold the duplicated noteRetryIn snippet from fetchArtistImage and fetchAlbumImage into recordAgent, which already branched on the same isTransientExternal condition. - Replace the atomic.Bool + note() closure in populateArtistInfo with errgroup's own error collection; the group carries no context, so a returned error does not cancel its siblings. - Reuse recoveringScrobbler for the per-user delay test instead of a third double, and switch fakeScrobbler's mutex-guarded error to the atomic.Pointer idiom already used in the same package. * refactor(listenbrainz): keep rate-limit header parsing in the adapter The X-RateLimit-Reset-In header is ListenBrainz's own convention, not a shared one: Last.fm sends no rate-limit headers at all and reports its limit as a body code, and no other integration in tree sends Retry-After. A parser in utils/httpclient implied a uniformity across services that does not exist, so it moves back next to the only client that can know which header its service sends. * refactor(agents): collapse the retry-later sentinel and error into one type ErrRetryLater is now the zero-delay RetryLaterError rather than a separate errors.New value, so errors.Is and errors.AsType both match the sentinel and every delay-carrying variant. That removes the trap where a bare sentinel silently skipped the AsType path, and lets every consumer read the delay off the error directly: the RetryIn accessor and the two constructors are gone, with the policy cap applied where untrusted input is parsed. * refactor(agents): split the cooldown store from the per-dispatch tally The cooldown map and mutex become a cooldowns value with active/park, holding no knowledge of errors; agentAttempts records one dispatch's outcomes and owns the classification that noteAgentError used to hide behind a bool. The three dispatch loops now touch a single object: skip folds the cooldown check and the throttled flag into one call, so the store never appears in the loops. * refactor(agents): share one dispatch loop between the agent call helpers callAgentMethod and callAgentSliceMethod ran identical loops, differing only in how they test a result for emptiness: a slice cannot be compared against its zero value, so the two could not share a constraint. Both now delegate to callAgent, which takes that test as a parameter. Keeping the loop in one place matters more than the lines saved: it holds the cooldown skip, the attempt recording and the empty-dispatch verdict, and a fix applied to one copy but not the other would be silent. * test: cover the two retry-later paths a mutation could break silently Both gaps were proven, not guessed: making the artwork worker pass 0 instead of the collected hint left all 386 specs green, and replacing the default agent cooldown with 0 left the agents suite green. The worker test drives a throttled image agent through drain and asserts the persisted retry_at, and the cooldown test parks an agent that asked to be retried without naming a delay, which is what Last.fm does on every rate limit. * refactor(artwork): carry the external failure as an error, not a flag plus a trace field The retry delay was riding on ChainTrace, a diagnostic that gets persisted, while the very same signal — an external source faulted — already travelled by value as resolution.extError. That was two mechanisms for one idea, and it put control-flow state inside a serializable trace. resolution.extError and chainState.extErr become the error itself, so a caller checks err != nil for the fault and errors.AsType for the delay the provider asked for. The agent loops return that error last, per convention, and longerRetry keeps whichever failure wants the longer wait. ChainTrace goes back to holding only steps and no longer imports core/agents. * fix(artwork): check the resolve error before reading its resolution Reading res.extError before the err check was safe only because every error path in resolve returns a bare resolution{}; a future path returning a partly-filled one would have been read silently. The failure path now returns no delay explicitly. * test(artwork): assert the delay acquire reports, not just its downstream effect acquire's retry delay was only covered through the worker's persisted retry_at, one layer away from where the value is computed. Both outcomes are now pinned at the processor: a plain failure asks for nothing, a throttled provider's delay is passed through. * refactor: share the retry-seconds parse and drop the backoff deadline arithmetic The clamp-before-scaling invariant lived in two parsers and was independently re-tested in three files with the same magic number; a fix applied to one copy would have left the others wrapping a huge value down to a fraction of a second. It moves to agents.ParseRetryIn. The buffer tracked an absolute retryDeadline only to re-arm a timer that was already armed for the same instant; a backingOff flag says the same thing without the arithmetic. The plugin token regex now carries its capability in the pattern instead of capturing and comparing, so another capability's token in the same message cannot mask it. resolution.extError becomes extErr, matching its chainState counterpart. * fix(agents): keep the longer cooldown when parks overlap Calls to one agent overlap, so a short cooldown could land after a long one started and cut it short. park now keeps whichever deadline is later, matching the rule longerRetry already applies on the artwork side. No in-tree provider can currently produce two different delays for the same agent, so this is hardening rather than a fix for observed behaviour. * fix(agents): parse the retry delay at a fixed width strconv.Atoi parses into the native int, so on the 32-bit targets we ship (linux/386, windows/386, three ARM variants) a delay above MaxInt32 seconds overflowed and became unspecified instead of being capped. No provider sends a 68-year delay, so this is not user-visible, but the overflow tests asserted the cap and would have failed on those architectures, where tests never run. * fix(plugins): anchor the retry_later regex to a word boundary Prevents a superstring like useragent(retry_later) from matching the agent capability token. --- adapters/lastfm/agent.go | 3 +- adapters/lastfm/agent_test.go | 19 +++ adapters/lastfm/client.go | 11 +- adapters/listenbrainz/agent_test.go | 13 ++ adapters/listenbrainz/client.go | 17 +++ adapters/listenbrainz/client_test.go | 73 +++++++++ core/agents/agents.go | 144 +++++++++++++----- core/agents/agents_test.go | 126 ++++++++++++++- core/agents/interfaces.go | 51 ++++++- core/agents/interfaces_test.go | 47 ++++-- core/agents/song_test.go | 27 ++++ core/artwork/agent_images.go | 36 +++-- core/artwork/agent_images_test.go | 65 +++++--- core/artwork/processor.go | 32 ++-- core/artwork/processor_test.go | 57 ++++--- core/artwork/resolve.go | 49 +++--- core/artwork/resolve_test.go | 46 +++--- core/artwork/worker.go | 9 +- core/artwork/worker_soak_test.go | 2 +- core/artwork/worker_test.go | 33 ++++ core/external/provider.go | 63 +++++--- core/external/provider_similarsongs.go | 2 +- core/external/provider_topsongs_test.go | 15 ++ .../external/provider_updatealbuminfo_test.go | 22 +++ .../provider_updateartistinfo_test.go | 19 +++ core/scrobbler/buffered_scrobbler.go | 47 +++--- core/scrobbler/buffered_scrobbler_test.go | 122 ++++++++++++++- core/scrobbler/interfaces.go | 4 +- core/scrobbler/play_tracker_test.go | 37 +++-- plugins/capabilities.go | 29 +++- plugins/capabilities/metadata_agent.go | 12 ++ plugins/metadata_agent.go | 3 + plugins/metadata_agent_test.go | 26 ++++ plugins/pdk/go/metadata/metadata.go | 12 ++ plugins/pdk/go/metadata/metadata_stub.go | 12 ++ .../rust/nd-pdk-capabilities/src/metadata.rs | 5 + plugins/scrobbler_adapter.go | 5 +- plugins/scrobbler_adapter_test.go | 22 +++ 38 files changed, 1085 insertions(+), 232 deletions(-) create mode 100644 core/agents/song_test.go diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 863868b5a..7f005db1a 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -405,7 +405,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) return errors.Join(err, scrobbler.ErrRetryLater) } - if lfErr.Code == 11 || lfErr.Code == 16 { + // 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client. + if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) { return errors.Join(err, scrobbler.ErrRetryLater) } return errors.Join(err, scrobbler.ErrUnrecoverable) diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94024b9ab..ce81e0916 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -100,6 +100,15 @@ var _ = Describe("lastfmAgent", func() { Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) + + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + _, err := agent.GetArtistBiography(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) }) Describe("Language Fallback", func() { @@ -497,6 +506,16 @@ var _ = Describe("lastfmAgent", func() { Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrRetryLater on http errors", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 726df1360..e468aa638 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -14,11 +15,15 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" + // errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200 + // and no rate-limit headers, so the body code is the only signal. + errCodeRateLimit = 29 ) type lastFMError struct { @@ -225,7 +230,11 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return nil, jsonErr } if response.Error != 0 { - return &response, &lastFMError{Code: response.Error, Message: response.Message} + var err error = &lastFMError{Code: response.Error, Message: response.Message} + if response.Error == errCodeRateLimit { + err = errors.Join(err, &agents.RetryLaterError{}) + } + return &response, err } return &response, nil diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index 2c4668296..a201b7c3a 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -164,6 +164,19 @@ var _ = Describe("listenBrainzAgent", func() { err := agent.Scrobble(ctx, "user-1", sc) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + It("keeps a 429 scrobble for retry and carries the delay", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)), + } + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) }) Describe("GetArtistUrl", func() { diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 708f02f28..aae4fb51d 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -13,6 +13,7 @@ import ( "slices" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) @@ -21,6 +22,12 @@ const ( labsBase = "https://labs.api.listenbrainz.org/" ) +// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In +// (delta-seconds) on every response, including the 429, and never Retry-After. +func retryLaterErr(h http.Header) *agents.RetryLaterError { + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))} +} + var ( ErrorNotFound = errors.New("listenbrainz: not found") ) @@ -174,6 +181,9 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en } defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var response listenBrainzResponse @@ -185,6 +195,10 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en return nil, jsonErr } if response.Code != 0 && response.Code != 200 { + // LB also reports rate limiting as a body code, not only as an HTTP status. + if response.Code == http.StatusTooManyRequests { + return &response, retryLaterErr(resp.Header) + } return &response, &listenBrainzError{Code: response.Code, Message: response.Error} } @@ -211,6 +225,9 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint // On a 200 code, there is no code. Decode using using error message if it exists if resp.StatusCode != 200 { defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var lbzError lbzHttpError diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 319cf01ab..ec0b0ac11 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" + "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -461,4 +465,73 @@ var _ = Describe("client", func() { })) }) }) + + Describe("rate limiting", func() { + It("returns RetryLaterError with the header delay on 429", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(3 * time.Second)) + }) + + It("returns RetryLaterError with zero delay when no header is present", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(BeZero()) + }) + + DescribeTable("caps absurd header values at one hour", + func(header string) { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{header}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(Equal(time.Hour)) + }, + Entry("a large value", "999999"), + Entry("a huge value", "99999999999"), + // Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("a value that overflows int64 nanoseconds", "18446744074"), + ) + + It("maps a body-level 429 sent with a non-429 status", func() { + httpClient.Res = http.Response{ + StatusCode: 200, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) + + It("returns RetryLaterError on a 429 from makeGenericRequest", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/core/agents/agents.go b/core/agents/agents.go index 348f7d4e7..ac623951c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -1,9 +1,12 @@ package agents import ( + "cmp" "context" + "errors" "slices" "strings" + "sync" "time" "github.com/navidrome/navidrome/conf" @@ -22,11 +25,43 @@ type PluginLoader interface { LoadMediaAgent(name string) (Interface, bool) } +// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific +// RetryIn duration. +const agentCooldown = time.Minute + +// errUnsupported marks an agent that does not implement the requested method: it never ran, +// so it neither answered nor throttled. +var errUnsupported = errors.New("agent does not support this method") + // Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order // until one returns valid data. type Agents struct { ds model.DataStore pluginLoader PluginLoader + cooldowns cooldowns +} + +// cooldowns remembers, across dispatches, which agents asked to be left alone and until when. +type cooldowns struct { + mu sync.RWMutex + until map[string]time.Time +} + +func (c *cooldowns) active(name string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return time.Now().Before(c.until[name]) +} + +// park keeps whichever deadline is later, so a call still in flight when a longer cooldown +// starts cannot cut it short when it finally answers. +func (c *cooldowns) park(name string, d time.Duration) { + until := time.Now().Add(d) + c.mu.Lock() + defer c.mu.Unlock() + if until.After(c.until[name]) { + c.until[name] = until + } } // GetAgents returns the singleton instance of Agents @@ -41,6 +76,7 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { return &Agents{ ds: ds, pluginLoader: pluginLoader, + cooldowns: cooldowns{until: map[string]time.Time{}}, } } @@ -171,7 +207,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistMBIDRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistMBID(ctx, id, name) }) @@ -188,7 +224,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistURLRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistURL(ctx, id, name, mbid) }) @@ -205,7 +241,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistBiographyRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistBiography(ctx, id, name, mbid) }) @@ -224,7 +260,11 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) start := time.Now() + attempts := newAttempts(&a.cooldowns) for _, enabledAgent := range a.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := a.getAgent(enabledAgent) if ag == nil { continue @@ -237,6 +277,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l continue } similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) + attempts.record(enabledAgent.name, err) if len(similar) > 0 && err == nil { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) @@ -246,7 +287,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l return similar, err } } - return nil, ErrNotFound + return nil, attempts.noResultErr() } func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { @@ -260,7 +301,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(ArtistImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistImages(ctx, id, name, mbid) }) @@ -281,7 +322,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) }) @@ -295,7 +336,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { retriever, ok := ag.(AlbumInfoRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumInfo(ctx, name, artist, mbid) }) @@ -309,7 +350,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(AlbumImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumImages(ctx, name, artist, mbid) }) @@ -320,7 +361,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByTrackRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) }) @@ -331,7 +372,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByAlbumRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) }) @@ -349,16 +390,61 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByArtistRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count) }) } -func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { +// agentAttempts tallies what the enabled agents did in one dispatch. +type agentAttempts struct { + cooldowns *cooldowns + throttled bool + answered bool +} + +func newAttempts(c *cooldowns) agentAttempts { + return agentAttempts{cooldowns: c} +} + +// skip reports whether name is still cooling down, counting it as throttled for this dispatch. +func (t *agentAttempts) skip(name string) bool { + if !t.cooldowns.active(name) { + return false + } + t.throttled = true + return true +} + +// record files one agent's outcome, parking it when it asked to be retried later. +func (t *agentAttempts) record(name string, err error) { + switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); { + case errors.Is(err, errUnsupported): + case isRetryLater: + t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown)) + t.throttled = true + default: + t.answered = true + } +} + +// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss. +func (t *agentAttempts) noResultErr() error { + if t.throttled && !t.answered { + return ErrRetryLater + } + return ErrNotFound +} + +// callAgent tries each enabled agent in order until found reports a usable result. +func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) { var zero T start := time.Now() + attempts := newAttempts(&agents.cooldowns) for _, enabledAgent := range agents.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := agents.getAgent(enabledAgent) if ag == nil { continue @@ -367,41 +453,29 @@ func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodNa break } result, err := fn(ag) + attempts.record(enabledAgent.name, err) if err != nil { log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) continue } - if result != zero { + if found(result) { log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) return result, nil } } - return zero, ErrNotFound + return zero, attempts.noResultErr() +} + +func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { + return callAgent(ctx, agents, methodName, fn, func(result T) bool { + var zero T + return result != zero + }) } func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) { - start := time.Now() - for _, enabledAgent := range agents.getEnabledAgentNames() { - ag := agents.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } - results, err := fn(ag) - if err != nil { - log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) - continue - } - - if len(results) > 0 { - log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start)) - return results, nil - } - } - return nil, ErrNotFound + return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 }) } var _ Interface = (*Agents)(nil) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e79b2b3c8..35ebf18d8 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "context" "errors" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -14,6 +15,29 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("cooldowns", func() { + // Calls to one agent overlap, so a short cooldown can land after a long one started. + It("keeps the longer deadline when a shorter park lands after it", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Hour) + c.park("fake", time.Millisecond) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) + + It("extends the deadline when the later park is longer", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Millisecond) + c.park("fake", time.Hour) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) +}) + var _ = Describe("Agents", func() { var ctx context.Context var cancel context.CancelFunc @@ -160,6 +184,102 @@ var _ = Describe("Agents", func() { }) }) + Describe("cooldown", func() { + It("skips an agent that returned RetryLaterError until the deadline", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + // Immediately after: agent is skipped, not called + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + // Providers that throttle without saying for how long (Last.fm sends no delay at all) + // must still be parked, or the aggregate keeps calling them on every request. + It("parks an agent that asked to be retried without a delay", func() { + mock.Err = ErrRetryLater + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("calls the agent again once the cooldown expires", func() { + mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + Eventually(func() (string, error) { + return ag.GetArtistBiography(ctx, "id", "name", "mbid") + }, 5*time.Second, 10*time.Millisecond).Should(Equal("bio")) + }) + + It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() { + mock.Err = errors.New("boom") + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + // ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive + // answer from any other agent is an answer, throttled peer or not. + It("returns ErrNotFound when another agent answered with a definitive miss", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + + // The cooldown was still recorded for the throttled agent + calls := mock.Calls + _, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + }) + + It("returns ErrNotFound when another agent answered with an empty slice", func() { + empty := &testImageAgent{Name: "emptyImages"} + Register("emptyImages", func(model.DataStore) Interface { return empty }) + conf.Server.Agents = "fake,emptyImages" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + }) + Describe("GetArtistImages", func() { It("returns on first match", func() { Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{ @@ -423,8 +543,9 @@ var _ = Describe("Agents", func() { }) type mockAgent struct { - Args []any - Err error + Args []any + Err error + Calls int } func (a *mockAgent) AgentName() string { @@ -449,6 +570,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { a.Args = []any{id, name, mbid} + a.Calls++ if a.Err != nil { return "", a.Err } diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 7fc5de361..9225a0442 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -3,6 +3,9 @@ package agents import ( "context" "errors" + "fmt" + "strconv" + "time" "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" @@ -52,11 +55,49 @@ func (s Song) Equals(other Song) bool { return h1 == h2 } -var ( - // ErrNotFound means the provider answered and had nothing. Return the underlying error - // for a fault instead, or callers that back off on faults will treat it as definitive. - ErrNotFound = errors.New("not found") -) +// ErrNotFound means the provider answered and had nothing. Return the underlying error +// for a fault instead, or callers that back off on faults will treat it as definitive. +var ErrNotFound = errors.New("not found") + +// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable +// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and +// errors.AsType[*RetryLaterError] match it and every delay-carrying variant. +// Treat it as immutable; build a new RetryLaterError to name a delay. +var ErrRetryLater = &RetryLaterError{} + +// RetryLaterError asks callers to back off, optionally for the delay the provider requested. +type RetryLaterError struct { + RetryIn time.Duration +} + +func (e *RetryLaterError) Error() string { + if e.RetryIn > 0 { + return fmt.Sprintf("retry later (in %s)", e.RetryIn) + } + return "retry later" +} + +func (e *RetryLaterError) Is(target error) bool { + _, ok := target.(*RetryLaterError) + return ok +} + +// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely. +const MaxRetryIn = time.Hour +const maxRetryInSeconds = int(MaxRetryIn / time.Second) + +// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token. +// Anything unparseable or non-positive means unspecified. +func ParseRetryIn(seconds string) time.Duration { + // Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds, + // turning "wait an age" into a fraction of a second. Parse at a fixed width so the + // cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first. + secs, err := strconv.ParseInt(seconds, 10, 64) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second +} // AlbumInfoRetriever provides album info (no images) type AlbumInfoRetriever interface { diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go index c13710a38..6acbc545d 100644 --- a/core/agents/interfaces_test.go +++ b/core/agents/interfaces_test.go @@ -1,27 +1,42 @@ -package agents +package agents_test import ( + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} - It("true for identical songs incl Artists", func() { - Expect(base.Equals(base)).To(BeTrue()) +var _ = Describe("RetryLaterError", func() { + It("matches the ErrRetryLater sentinel via errors.Is", func() { + err := &agents.RetryLaterError{RetryIn: 30 * time.Second} + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when Artists differ", func() { - other := base - other.Artists = []Artist{{ID: "y", Name: "B"}} - Expect(base.Equals(other)).To(BeFalse()) + + It("matches through errors.Join and wrapping", func() { + err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{})) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when a scalar differs", func() { - other := base - other.Name = "T" - Expect(base.Equals(other)).To(BeFalse()) + + It("exposes the delay through the wrapped error", func() { + err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second}) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(42 * time.Second)) }) - It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S"} - Expect(a.Equals(a)).To(BeTrue()) + + It("matches the sentinel too, reporting no delay", func() { + retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(BeZero()) + }) + + It("is the same sentinel as scrobbler.ErrRetryLater", func() { + Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue()) + Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue()) }) }) diff --git a/core/agents/song_test.go b/core/agents/song_test.go new file mode 100644 index 000000000..c13710a38 --- /dev/null +++ b/core/agents/song_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 95596dabc..985abacd7 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "io" "net/url" @@ -41,22 +42,36 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL { return best } -// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no +// longerRetry keeps whichever external failure asks for the longer wait, so one provider's +// short delay cannot shorten another's. +func longerRetry(a, b error) error { + if a == nil { + return b + } + var ra, rb *agents.RetryLaterError + if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) { + return b + } + return a +} + +// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no // agent succeeded and at least one failed transiently. -func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) { // Synthetic artists would otherwise get an unrelated agent result assigned to them. switch ar.ID { case consts.UnknownArtistID, consts.VariousArtistsID: traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) - return nil, "", false + return nil, "", nil } name := externalName(ar.Name) imageAgents := ag.ArtistImageAgents() if len(imageAgents) == 0 { traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "no enabled agent provides artist images"}) - return nil, "", false + return nil, "", nil } + var extErr error for _, a := range imageAgents { reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) @@ -71,10 +86,10 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar }) recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) } } @@ -82,14 +97,15 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } // fetchAlbumImage is the album counterpart of fetchArtistImage. -func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) { name, artist := externalName(al.Name), externalName(al.AlbumArtist) imageAgents := ag.AlbumImageAgents() if len(imageAgents) == 0 { traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "no enabled agent provides album images"}) - return nil, "", false + return nil, "", nil } + var extErr error for _, a := range imageAgents { reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) @@ -104,10 +120,10 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m }) recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) } } diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 60a34352d..d0c2429b0 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -2,11 +2,13 @@ package artwork import ( "context" + "errors" "io" "net/http" "net/http/httptest" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -153,11 +155,11 @@ var _ = Describe("agent images", func() { a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} ag := imageAgents(a) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentA")) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) }) It("skips the external lookup for synthetic artists", func() { @@ -165,10 +167,10 @@ var _ = Describe("agent images", func() { ag := imageAgents(a) for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) } Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") }) @@ -177,9 +179,9 @@ var _ = Describe("agent images", func() { ag := imageAgents() t := &ChainTrace{} - r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) + r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, Detail: "no enabled agent provides artist images"}}), "a configured external token must never be silently absent from the chain") @@ -211,11 +213,11 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentB")) - Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error") + Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error") Expect(a.artistCalls).To(Equal(1)) Expect(b.artistCalls).To(Equal(1)) }) @@ -225,20 +227,43 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure") + Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure") }) - It("reports extErr when one agent fails transiently and the rest find nothing", func() { + It("reports an error when one agent fails transiently and the rest find nothing", func() { a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} ag := imageAgents(a, b) - r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) + }) + + // The worker reschedules on this delay, so it is only honored if the agent loop + // returns it. Two throttled agents: the longest wait is the one that must survive. + It("returns the longest retry delay the providers asked for", func() { + a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}} + b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}} + ag := imageAgents(a, b) + + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(10 * time.Second)) + }) + + It("returns no delay when the provider did not ask for one", func() { + ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")}) + + _, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(err).To(HaveOccurred()) + _, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle") }) }) @@ -247,11 +272,11 @@ var _ = Describe("agent images", func() { a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} ag := imageAgents(a) - r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) + r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentA")) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(a.albumCalls).To(Equal(1)) }) @@ -259,21 +284,21 @@ var _ = Describe("agent images", func() { ag := imageAgents() t := &ChainTrace{} - r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) + r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, Detail: "no enabled agent provides album images"}}), "a configured external token must never be silently absent from the chain") }) - It("reports extErr when the only agent fails transiently", func() { + It("reports an error when the only agent fails transiently", func() { a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} ag := imageAgents(a) - r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) + r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) }) }) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index fdb28189a..cf2176775 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/core/artwork/dominant" "github.com/navidrome/navidrome/core/artwork/thumbhash" @@ -80,7 +81,7 @@ type processor struct { // acquire resolves one queue item end to end: find an image, hash/decode/ // blurhash it, place its bytes, and persist the resulting state. -func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) { +func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { repo := p.ds.Artwork(ctx) start := time.Now() defer func() { @@ -92,10 +93,13 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "resolve", err) log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, 0 + } + if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok { + retryIn = retry.RetryIn } if res.reader == nil { - if res.extError || res.localError { + if res.extErr != nil || res.localError { // A fault is not a definitive "no image": never settle absent, keep serving old state. // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. if t := traceFrom(ctx); len(t.Steps()) == 0 { @@ -106,10 +110,10 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) } log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", - "kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError) - return outcomeFailed, nil + "kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError) + return outcomeFailed, nil, retryIn } - return writeAbsent(ctx, repo, item), nil + return writeAbsent(ctx, repo, item), nil, 0 } defer res.reader.Close() @@ -118,7 +122,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "read", err) log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) @@ -128,7 +132,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "hash", err) log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) @@ -152,14 +156,14 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "decode", err) log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) default: traceStage(ctx, "lookup", err) log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } art.SizeBytes = int64(len(data)) @@ -167,15 +171,15 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "store", err) log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } got = &acquired{ia: ia, mime: art.Mime, data: data} - if res.extError { + if res.extErr != nil { log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", "kind", item.ItemKind, "id", item.ItemID, "source", res.source) - return outcomeFoundStale, got + return outcomeFoundStale, got, retryIn } - return outcomeFound, got + return outcomeFound, got, retryIn } // persist places the bytes and commits the rows referencing them, excluding Prune for that diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 0ca5a308e..554ca08dc 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -90,7 +90,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) @@ -127,7 +127,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) Expect(out).To(Equal(outcomeFound)) Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") @@ -141,7 +141,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) Expect(out).To(Equal(outcomeAbsent)) Expect(lock.locks).To(BeZero()) }) @@ -153,7 +153,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = nil - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) @@ -176,7 +176,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al3", Name: "Album"}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(out).To(Equal(outcomeAbsent)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) @@ -197,7 +197,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) @@ -222,7 +222,7 @@ var _ = Describe("processor.acquire", func() { DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) @@ -249,7 +249,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} trace := &ChainTrace{} - out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) + out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) Expect(out).To(Equal(outcomeFailed)) steps := trace.Steps() @@ -265,13 +265,26 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("failed-on-extError: reports the delay a throttled provider asked for", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4r", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}}) + + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"}) + Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(Equal(42 * time.Second)) + }) + It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -283,7 +296,7 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) @@ -300,7 +313,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary) @@ -320,7 +333,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary) @@ -338,7 +351,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary) @@ -357,7 +370,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) @@ -382,7 +395,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, }) - out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(out1).To(Equal(outcomeFound)) ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -392,7 +405,7 @@ var _ = Describe("processor.acquire", func() { poisoned.BlurHash = "SENTINEL" artRepo.Data[ia1.Hash] = poisoned - out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) Expect(out2).To(Equal(outcomeFound)) ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -422,7 +435,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} - outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) + outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) Expect(outN).To(Equal(outcomeFound)) iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -436,7 +449,7 @@ var _ = Describe("processor.acquire", func() { artRepo.Data[iaA.Hash] = poisoned folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} - outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) + outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) Expect(outN).To(Equal(outcomeFound)) iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -467,7 +480,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} ds.MockedRadio = radioRepo - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) @@ -488,7 +501,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} ds.MockedRadio = radioRepo - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) Expect(out).To(Equal(outcomeFailed)) _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) @@ -554,7 +567,7 @@ var _ = Describe("processor.acquire", func() { Expect(err).ToNot(HaveOccurred()) Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) Expect(out).To(Equal(outcomeFound)) upgraded, err := artRepo.GetImage(hash) @@ -574,7 +587,7 @@ var _ = Describe("processor.acquire", func() { Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) proc.store = NewImageStore(blockedRoot) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 6663679fa..e7a2d3765 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -25,9 +25,9 @@ type resolution struct { source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath - // external source errored/timed out. With no reader it forces failed (never absent); - // on a hit a higher-priority external step failed—serve this, but retry later. - extError bool + // a faulted external source, carrying the provider's requested delay when it named one. + // With no reader it forces failed (never absent); on a hit, serve this but retry later. + extErr error // a local source that should have been readable wasn't. With no reader it forces failed, // so a transient I/O fault never records absent. localError bool @@ -36,14 +36,15 @@ type resolution struct { // chainState carries what a priority walk has seen so far. A hit takes extErr with it so a // transient external failure still retries; localErr is dropped, as the scanner re-lists changes. type chainState struct { - extErr, localErr bool - trace *ChainTrace // nil only where no caller attached one + extErr error + localErr bool + trace *ChainTrace // nil only where no caller attached one } // try stamps the accumulated external failure onto a hit, and records the miss otherwise. func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { if ok { - res.extError = c.extErr + res.extErr = c.extErr c.record(candidate, OutcomeHit, res.sourcePath) return res, true } @@ -62,7 +63,7 @@ func (c *chainState) record(candidate string, out Outcome, detail string) { // exhausted is the outcome when no source in the chain yielded an image. func (c *chainState) exhausted() resolution { - return resolution{extError: c.extErr, localError: c.localErr} + return resolution{extErr: c.extErr, localError: c.localErr} } // externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. @@ -181,16 +182,16 @@ func chainFetchesExternal(priority string) bool { // Album and artist fetches stop here when the resolver is local-only, rather than at each point in // the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. -func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) } -func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) } @@ -223,10 +224,10 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution return res, nil } case pattern == externalCandidate: - if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { + if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil { return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if isErr { - chain.extErr = true + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case len(imgFiles) > 0: res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) @@ -285,10 +286,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } switch { case pattern == externalCandidate: - if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { + if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil { return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if isErr { - chain.extErr = true + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case pattern == "image-folder": res, ok := resolveArtistImageFolder(ar) @@ -332,7 +333,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso return resolution{}, err } - var extErr bool + var extErr error for _, src := range []struct{ path, source string }{ {pl.UploadedImagePath(), "upload"}, {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, @@ -366,7 +367,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { return res, nil } else if err != nil { - extErr = true + extErr = longerRetry(extErr, err) // Record it here with its detail: once album sampling adds its own steps, the processor's // empty-trace fallback no longer fires, and the error that forced the retry would be lost. traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) @@ -389,8 +390,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } continue } - if res.extError { - extErr = true + if res.extErr != nil { + extErr = longerRetry(extErr, res.extErr) } if res.reader == nil { continue @@ -409,7 +410,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if tileErr != nil { return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) } - return resolution{extError: extErr}, nil + return resolution{extErr: extErr}, nil } // Grow to 4 tiles by repeating what we have. switch len(tiles) { @@ -420,9 +421,9 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } grid, err := assembleTiles(tiles) if err != nil { - return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error + return resolution{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error } - return resolution{reader: grid, source: "generated", extError: extErr}, nil + return resolution{reader: grid, source: "generated", extErr: extErr}, nil } // resolveRadio serves only an uploaded image; there is no fallback. diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 236e76b9b..402a11363 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -100,7 +100,7 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("embedded")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when the track has no cover art", func() { @@ -111,7 +111,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when media file cover art is disabled", func() { @@ -154,7 +154,7 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("folder")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("falls back to embedded art when no folder image matches", func() { @@ -172,7 +172,7 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr when the external source errors without being not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al3", Name: "Album"}, @@ -182,10 +182,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, @@ -195,10 +195,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) - It("carries extError onto a fallback folder hit after a transient external failure", func() { + It("carries extErr onto a fallback folder hit after a transient external failure", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", @@ -214,10 +214,10 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", @@ -233,7 +233,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -250,7 +250,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -298,7 +298,7 @@ var _ = Describe("resolveItem", func() { Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr when the external source errors without being not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) @@ -308,10 +308,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) @@ -321,7 +321,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -338,7 +338,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -516,7 +516,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) @@ -537,7 +537,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) steps := trace.Steps() var m3u *TraceStep @@ -562,7 +562,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { @@ -582,7 +582,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("generated")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) // A local resolver holds no agents: reaching the external branch would panic, not degrade. @@ -594,7 +594,7 @@ var _ = Describe("resolveItem", func() { res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one") + Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one") }) // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. @@ -642,7 +642,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) It("yields an empty resolution when no album has art", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 0358708c0..bea478aa5 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -241,7 +241,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) trace := &ChainTrace{} ctx = withTrace(ctx, trace) - out, got := w.proc.acquire(ctx, item) + out, got, retryIn := w.proc.acquire(ctx, item) queue := w.proc.ds.ArtworkQueue(ctx) switch out { @@ -252,7 +252,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } case outcomeFoundStale, outcomeFailed: - retryAt := time.Now().Add(backoff(item.Attempts)) + retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn)) encoded := trace.encode("") if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its @@ -341,3 +341,8 @@ func backoffFor(attempts int, jitter float64) time.Duration { func backoff(attempts int) time.Duration { return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive } + +// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer. +func retryDelay(attempts int, hint time.Duration) time.Duration { + return max(backoff(attempts), hint) +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index eb7346102..803cc2dfe 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -95,7 +95,7 @@ var _ = Describe("Worker soak", func() { start := time.Now() for i := range soakCycles { it := items[i%len(items)] - out, _ := proc.acquire(context.Background(), it) + out, _, _ := proc.acquire(context.Background(), it) // Read-back exercises the surfaces a caller would use after acquisition. if out == outcomeFound { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 248e400e1..b0ef665fc 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -245,6 +245,23 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) + // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. + const askedFor = 90 * time.Minute + imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al9") + Expect(it).ToNot(BeNil()) + Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute)) + }) + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -911,3 +928,19 @@ var _ = Describe("backoff", func() { } }) }) + +var _ = Describe("retryDelay", func() { + It("uses the backoff schedule when the provider asked for nothing", func() { + d := retryDelay(0, 0) + Expect(d).To(BeNumerically(">=", 3*time.Second)) + Expect(d).To(BeNumerically("<=", 7*time.Second)) + }) + + It("waits the provider's delay when it is longer than the backoff", func() { + Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour)) + }) + + It("keeps the backoff when it is longer than the provider's delay", func() { + Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second)) + }) +}) diff --git a/core/external/provider.go b/core/external/provider.go index 5c46dc644..3a3f4bd46 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -148,7 +148,8 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl start := time.Now() albumName := album.Name() info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if errors.Is(err, agents.ErrNotFound) { + // Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call. + if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) { return album, nil } if err != nil { @@ -253,28 +254,37 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au start := time.Now() // Get MBID first, if it is not yet available artistName := artist.Name() + var mbidErr error if artist.MbzArtistID == "" { mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) + mbidErr = err if mbid != "" && err == nil { artist.MbzArtistID = mbid } } - // Call all registered agents and collect information + // Call all registered agents and collect information. The group carries no context, so a + // returned error does not cancel the siblings; only throttling is reported back. g := errgroup.Group{} g.SetLimit(2) - g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) - _ = g.Wait() + g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) }) + g.Go(func() error { + return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true)) + }) + throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater) if utils.IsCtxDone(ctx) { log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = new(time.Now()) + // A throttled round keeps the previous timestamp, so the next call retries instead of + // serving an empty cache entry for the whole TTL. + if !throttled { + artist.ExternalInfoUpdatedAt = new(time.Now()) + } err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, @@ -334,8 +344,9 @@ func (e *provider) TopSongs(ctx context.Context, artistName, id string, count in songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) if err != nil { switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "TopSongs not found", "name", artistName) + // Throttled is not an answer, but the caller keeps the empty 200 it got before. + case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater): + log.Trace(ctx, "TopSongs not found", "name", artistName, err) return nil, model.ErrNotFound case errors.Is(err, context.Canceled): log.Debug(ctx, "TopSongs call canceled", err) @@ -385,22 +396,33 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { - artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) - if err != nil { - return +// retryLaterOnly discards every failure the caller does not act on, so errgroup's +// first-error slot is reserved for the throttling signal. +func retryLaterOnly(err error) error { + if errors.Is(err, agents.ErrRetryLater) { + return err } - artist.ExternalUrl = artisURL + return nil } -func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error { + artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) + if err != nil { + return err + } + artist.ExternalUrl = artisURL + return nil +} + +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error { bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return + return err } bio = str.SanitizeText(bio) bio = strings.ReplaceAll(bio, "\n", " ") artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ") + return nil } // callGetImage populates artist's image URLs. A transient agent failure is @@ -428,19 +450,20 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet } func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, - limit int, includeNotPresent bool) { + limit int, includeNotPresent bool) error { artistName := artist.Name() similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit) if len(similar) == 0 || err != nil { - return + return err } start := time.Now() sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent) log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start)) if err != nil { - return + return err } artist.SimilarArtists = sa + return nil } func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) { diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go index 22b20565b..7720c9349 100644 --- a/core/external/provider_similarsongs.go +++ b/core/external/provider_similarsongs.go @@ -239,7 +239,7 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in return nil, err } - e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) + _ = e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) if utils.IsCtxDone(ctx) { log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) return nil, ctx.Err() diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 16e8ad840..5d35ba914 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -232,6 +232,21 @@ var _ = Describe("Provider - TopSongs", func() { ag.AssertExpectations(GinkgoT()) }) + // This endpoint answered with an empty list before retry-later existed; it must keep doing so. + It("returns an empty list, not a client error, when the agents are throttled", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5). + Return(nil, agents.ErrRetryLater).Once() + + songs, err := p.TopSongs(ctx, "Artist One", "", 5) + + Expect(songs).To(BeEmpty()) + Expect(err).To(MatchError(model.ErrNotFound), "the handler renders this as an empty 200") + Expect(err).ToNot(MatchError(agents.ErrRetryLater)) + ag.AssertExpectations(GinkgoT()) + }) + It("returns fewer songs if count is less than available top songs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index a54ef922c..e168aa026 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -164,4 +164,26 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ag.AssertExpectations(GinkgoT()) }) + + It("returns the original album, unstamped, when the agents are throttled", func() { + originalAlbum := &model.Album{ + ID: "al-throttled", + Name: "Throttled Album", + AlbumArtist: "Throttled Artist", + MbzAlbumID: "mbid-throttled", + } + mockAlbumRepo.SetData(model.Albums{*originalAlbum}) + + ag.On("GetAlbumInfo", ctx, "Throttled Album", "Throttled Artist", "mbid-throttled"). + Return(nil, agents.ErrRetryLater) + + updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-throttled") + + Expect(err).NotTo(HaveOccurred()) + Expect(updatedAlbum).NotTo(BeNil()) + Expect(*updatedAlbum).To(Equal(*originalAlbum)) + Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil()) + + ag.AssertExpectations(GinkgoT()) + }) }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index 853d56bbc..c722aaee8 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -104,6 +104,25 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) + // Stamping a throttled round would cache the empty result for the whole TTL. + It("does not stamp ExternalInfoUpdatedAt when the agents are throttled", func() { + originalArtist := &model.Artist{ID: "ar-throttled", Name: "Throttled Artist"} + mockArtistRepo.SetData(model.Artists{*originalArtist}) + + ag.On("GetArtistMBID", ctx, "ar-throttled", "Throttled Artist").Return("", agents.ErrRetryLater).Once() + ag.On("GetArtistImages", ctx, "ar-throttled", "Throttled Artist", "").Return(nil, agents.ErrRetryLater).Once() + ag.On("GetArtistBiography", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() + ag.On("GetArtistURL", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() + ag.On("GetSimilarArtists", ctx, "ar-throttled", "Throttled Artist", "", 100).Return(nil, agents.ErrRetryLater).Once() + + updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-throttled", 10, false) + + Expect(err).ToNot(HaveOccurred()) + Expect(updatedArtist).NotTo(BeNil()) + Expect(updatedArtist.ExternalInfoUpdatedAt).To(BeNil()) + ag.AssertExpectations(GinkgoT()) + }) + It("preserves decoded plain text in biography storage", func() { originalArtist := &model.Artist{ ID: "ar-encoded-bio", diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 38ea83228..c5c096a0b 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -126,42 +127,52 @@ func (b *bufferedScrobbler) run(ctx context.Context) { timer.Stop() defer timer.Stop() failures := 0 + backingOff := false for { - if b.processQueue(ctx) { - failures = 0 - timer.Stop() - } else { - timer.Reset(backoffDelay(failures)) - if failures < maxRetryShift { - failures++ + // While a backoff window is open the timer is already armed for the rest of it, so a + // wake (a new play enqueued) must not drain: that is the hammering this avoids. + if !backingOff { + if ok, retryIn := b.processQueue(ctx); ok { + failures = 0 + timer.Stop() + } else { + timer.Reset(max(backoffDelay(failures), retryIn)) + backingOff = true + if failures < maxRetryShift { + failures++ + } } } select { case <-b.wakeSignal: case <-timer.C: + backingOff = false case <-ctx.Done(): return } } } -func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { +func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) { buffer := b.ds.ScrobbleBuffer(ctx) userIds, err := buffer.UserIDs(b.service) if err != nil { log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err) - return false + return false, 0 } result := true + var retryIn time.Duration for _, userId := range userIds { - if !b.processUserQueue(ctx, userId) { + ok, d := b.processUserQueue(ctx, userId) + if !ok { result = false + retryIn = max(retryIn, d) } } - return result + return result, retryIn } -func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { +func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) (bool, time.Duration) { // Scrobbles are drained on a background context that no longer carries the // request's authenticated user. Restore it from the buffered userId so that // scrobblers relying on the user in the context (e.g. plugins) still get it. @@ -175,25 +186,25 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) entry, err := buffer.Next(b.service, userId) if err != nil { log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err) - return false + return false, 0 } if entry == nil { - return true + return true, 0 } s, ok := b.loader() if !ok { log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service) - return false + return false, 0 } log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist) err = s.Scrobble(ctx, entry.UserID, Scrobble{ MediaFile: entry.MediaFile, TimeStamp: entry.PlayTime, }) - if errors.Is(err, ErrRetryLater) { + if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok { log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false + return false, retry.RetryIn } if err != nil { log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service, @@ -203,7 +214,7 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) if err != nil { log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false + return false, 0 } } } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index c250085ef..fd972e87d 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,11 +2,13 @@ package scrobbler import ( "context" + "errors" "sync/atomic" "testing" "testing/synctest" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -158,19 +160,128 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) { g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) } - // Once the service recovers, waking the loop drains the buffered entry. + // Once the service recovers, the buffered entry drains when the open + // backoff window closes (a wake alone must not drain it early). flaky.succeed() bs.sendWakeSignal() synctest.Wait() + g.Expect(buffer.Length()).To(Equal(int64(1)), "wake during backoff drained early") + time.Sleep(80 * time.Second) + synctest.Wait() g.Expect(buffer.Length()).To(Equal(int64(0))) }) } +func TestBufferedScrobblerBackoffWindow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + scr := &fakeScrobbler{Authorized: true} + scr.SetError(errors.Join(errors.New("boom"), ErrRetryLater)) + bs := newBufferedScrobbler(ds, scr, "test") + defer bs.Stop() + + // First enqueue: one immediate attempt, then a 5s window opens. + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("expected 1 attempt after first enqueue, got %d", got) + } + + // A wake inside the window must NOT trigger an early attempt. + time.Sleep(1 * time.Second) + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "2"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("wake during backoff drained early: %d attempts", got) + } + + // When the 5s window closes, the retry happens. + time.Sleep(4100 * time.Millisecond) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 2 { + t.Fatalf("expected retry after window, got %d attempts", got) + } + }) +} + +func TestBufferedScrobblerHonorsServerDelay(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + scr := &fakeScrobbler{Authorized: true} + scr.SetError(errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: 30 * time.Second})) + bs := newBufferedScrobbler(ds, scr, "test") + defer bs.Stop() + + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("expected 1 attempt, got %d", got) + } + + // The 5s exponential floor is overridden by the 30s server delay. + time.Sleep(20 * time.Second) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("retried before server delay elapsed: %d attempts", got) + } + time.Sleep(10100 * time.Millisecond) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 2 { + t.Fatalf("expected retry after server delay, got %d attempts", got) + } + }) +} + +// The drain visits users in an arbitrary order, so the longest delay must win regardless +// of which user was seen last. +func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + _ = userRepo.Put(&model.User{ID: "user2", UserName: "bob"}) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + scr := &recoveringScrobbler{delays: map[string]time.Duration{ + "user1": 10 * time.Second, + "user2": 45 * time.Second, + }} + bs := newBufferedScrobbler(ds, scr, "test") + defer bs.Stop() + + // user2 is enqueued directly so both are buffered before the first drain wakes. + _ = buffer.Enqueue("test", "user2", "2", time.Now()) + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.count.Load(); got != 2 { + t.Fatalf("expected both users drained, got %d attempts", got) + } + + time.Sleep(30 * time.Second) + synctest.Wait() + if got := scr.count.Load(); got != 2 { + t.Fatalf("retried on the shorter delay: %d attempts", got) + } + time.Sleep(15100 * time.Millisecond) + synctest.Wait() + if got := scr.count.Load(); got != 4 { + t.Fatalf("expected a retry after the longest delay, got %d attempts", got) + } + }) +} + // recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while // the buffered scrobbler's goroutine is draining, to exercise retry then recovery. +// With delays set, it instead fails every scrobble asking for that user's delay. type recoveringScrobbler struct { - err atomic.Pointer[error] - count atomic.Int32 + err atomic.Pointer[error] + count atomic.Int32 + delays map[string]time.Duration } func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } @@ -182,8 +293,11 @@ func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFi return nil } -func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error { +func (f *recoveringScrobbler) Scrobble(_ context.Context, userId string, _ Scrobble) error { f.count.Add(1) + if f.delays != nil { + return errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: f.delays[userId]}) + } if e := f.err.Load(); e != nil { return *e } diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index 8a18bb37e..218b7a919 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" ) @@ -15,7 +16,8 @@ type Scrobble struct { var ( ErrNotAuthorized = errors.New("not authorized") - ErrRetryLater = errors.New("retry later") + // ErrRetryLater is an alias of agents.ErrRetryLater so adapters and plugins share one identity. + ErrRetryLater = agents.ErrRetryLater ErrUnrecoverable = errors.New("unrecoverable") ) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 72f802c2e..0e768c3f4 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -293,7 +293,7 @@ var _ = Describe("PlayTracker", func() { }) It("increments play counts even if it cannot scrobble", func() { - fake.Error = errors.New("error") + fake.SetError(errors.New("error")) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) @@ -1414,7 +1414,25 @@ type fakeScrobbler struct { position atomic.Int32 LastScrobble atomic.Pointer[Scrobble] LastPlaybackReport atomic.Pointer[PlaybackSession] - Error error + err atomic.Pointer[error] + scrobbleAttempts atomic.Int32 +} + +// SetError sets the error returned by IsAuthorized/NowPlaying/Scrobble/PlaybackReport. +func (f *fakeScrobbler) SetError(err error) { + f.err.Store(&err) +} + +func (f *fakeScrobbler) getError() error { + if e := f.err.Load(); e != nil { + return *e + } + return nil +} + +// ScrobbleAttempts returns how many times Scrobble was called. +func (f *fakeScrobbler) ScrobbleAttempts() int32 { + return f.scrobbleAttempts.Load() } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -1440,13 +1458,13 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { } func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { - return f.Error == nil && f.Authorized + return f.getError() == nil && f.Authorized } func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { f.nowPlayingCalled.Store(true) - if f.Error != nil { - return f.Error + if err := f.getError(); err != nil { + return err } f.userID.Store(&userId) // Capture username from context (this is what plugin scrobblers do) @@ -1478,16 +1496,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) } f.LastScrobble.Store(&s) f.ScrobbleCalled.Store(true) - if f.Error != nil { - return f.Error + f.scrobbleAttempts.Add(1) + if err := f.getError(); err != nil { + return err } return nil } func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { f.PlaybackReportCalled.Store(true) - if f.Error != nil { - return f.Error + if err := f.getError(); err != nil { + return err } f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) diff --git a/plugins/capabilities.go b/plugins/capabilities.go index 81e683b6b..b86475028 100644 --- a/plugins/capabilities.go +++ b/plugins/capabilities.go @@ -1,6 +1,11 @@ package plugins -import "slices" +import ( + "regexp" + "slices" + + "github.com/navidrome/navidrome/core/agents" +) // Capability represents a plugin capability type. // Capabilities are detected by checking which functions a plugin exports. @@ -39,3 +44,25 @@ func detectCapabilities(plugin functionExistsChecker) []Capability { func hasCapability(capabilities []Capability, cap Capability) bool { return slices.Contains(capabilities, cap) } + +// retryLaterRe matches one capability's `<capability>(retry_later[:seconds])` token, which is +// all a plugin fault carries back across the WASM boundary. The capability is part of the +// pattern, so another capability's token in the same message cannot mask this one. The leading +// \b keeps a superstring like `useragent(retry_later)` from matching `agent`. +var ( + agentRetryLaterRe = retryLaterRe("agent") + scrobblerRetryLaterRe = retryLaterRe("scrobbler") +) + +func retryLaterRe(capability string) *regexp.Regexp { + return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`) +} + +// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay. +func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) { + m := re.FindStringSubmatch(msg) + if m == nil { + return nil, false + } + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true +} diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 72cb1622f..8fec7f5a8 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -224,3 +224,15 @@ type SimilarSongsResponse struct { // Songs is the list of similar songs. Songs []types.SongRef `json:"songs"` } + +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":<seconds>" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 607926438..17062ba6b 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -56,6 +56,9 @@ func agentErr(err error) error { if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { return errors.Join(agents.ErrNotFound, err) } + if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok { + return errors.Join(retryLater, err) + } return err } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 2dc67d41c..a7a0aa8b8 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -5,6 +5,7 @@ package plugins import ( "errors" "fmt" + "time" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" @@ -31,6 +32,31 @@ var _ = Describe("agentErr", func() { Entry("a non-zero exit is a fault", errors.New("plugin call exited with code 1"), false), ) + + DescribeTable("agentErr retry-later", + func(msg string, wantDelay time.Duration) { + err := agentErr(errors.New(msg)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "agent(retry_later)", time.Duration(0)), + Entry("with seconds", "agent(retry_later:120)", 120*time.Second), + Entry("capped at 1h", "agent(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour), + ) + + It("leaves other plugin errors untouched", func() { + orig := errors.New("some plugin failure") + Expect(agentErr(orig)).To(Equal(orig)) + }) + + It("does not treat a superstring token as a throttle", func() { + orig := errors.New("useragent(retry_later)") + Expect(agentErr(orig)).To(Equal(orig)) + }) }) var _ = Describe("MetadataAgent", Ordered, func() { diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index bb0ae9620..57546352e 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -18,6 +18,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":<seconds>" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index 572eba4da..f979419a9 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -16,6 +16,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":<seconds>" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 38fcae9da..890e16954 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -24,6 +24,11 @@ pub type ArtistRef = nd_pdk_types::ArtistRef; #[deprecated(note = "use nd_pdk::types::SongRef")] pub type SongRef = nd_pdk_types::SongRef; +/// MetadataAgentError represents an error type for metadata agent operations. +pub type MetadataAgentError = &'static str; +/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. +/// Append ":<seconds>" inside the parentheses to request a specific delay. +pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)"; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index b3203a352..721f0d3fa 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -174,11 +174,12 @@ func mapScrobblerError(err error) error { return nil } errMsg := err.Error() + retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg) switch { case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): return scrobbler.ErrNotAuthorized - case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()): - return scrobbler.ErrRetryLater + case isRetryLater: + return retryLater case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): return scrobbler.ErrUnrecoverable default: diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 56a452742..3efd5d1b1 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -7,6 +7,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -362,4 +363,25 @@ var _ = Describe("mapScrobblerError", func() { err := mapScrobblerError(errors.New("some unknown error")) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + DescribeTable("mapScrobblerError retry-later", + func(msg string, wantDelay time.Duration) { + err := mapScrobblerError(errors.New(msg)) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "scrobbler(retry_later)", time.Duration(0)), + Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second), + Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour), + Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second), + ) + + It("still maps unknown errors to unrecoverable", func() { + err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage")) + Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue()) + }) }) From b134f16fd5f95308700b1e66940fc4f495f80ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 11:11:35 -0400 Subject: [PATCH 480/596] feat(plugins): surface the valid agent names in logs and the Plugins UI (#5910) The agent name used in the `Agents` config option comes from the .ndp file name, not from the manifest. The Plugins UI showed the ID but never said what it was for, so renaming a plugin file silently breaks the config with only a Debug-level "Unknown agent ignored" line to go on. Add a caption under the ID in the Plugins UI, and list the accepted names alongside the rejected one in that log line. Related to navidrome/apple-music-plugin#14 --- core/agents/agents.go | 10 +++++++- core/agents/agents_test.go | 17 ++++++++++++++ resources/i18n/pt-br.json | 1 + ui/src/i18n/en.json | 1 + ui/src/plugin/InfoCard.jsx | 9 +++++++- ui/src/plugin/InfoCard.test.jsx | 41 +++++++++++++++++++++++++++++++++ ui/src/plugin/styles.js | 4 ++++ 7 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 ui/src/plugin/InfoCard.test.jsx diff --git a/core/agents/agents.go b/core/agents/agents.go index ac623951c..8ae3124ef 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "errors" + "maps" "slices" "strings" "sync" @@ -126,12 +127,19 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Debug("Unknown agent ignored", "name", name) + log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins)) } } return validAgents } +// availableAgentNames returns every name accepted by the Agents config option. +func availableAgentNames(plugins []string) []string { + names := append(slices.Collect(maps.Keys(Map)), plugins...) + slices.Sort(names) + return names +} + func (a *Agents) getAgent(ea enabledAgent) Interface { if ea.isPlugin { // Try to load WASM plugin agent (if plugin loader is available) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 35ebf18d8..6163c7f3c 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "context" "errors" + "slices" "time" "github.com/navidrome/navidrome/conf/configtest" @@ -91,6 +92,22 @@ var _ = Describe("Agents", func() { Expect(ags).ToNot(ContainElement("disabled")) }) + Describe("availableAgentNames", func() { + It("combines built-in agents with the given plugins", func() { + names := availableAgentNames([]string{"apple-music"}) + Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty")) + }) + + It("returns the names sorted", func() { + names := availableAgentNames([]string{"zz-plugin", "aa-plugin"}) + Expect(slices.IsSorted(names)).To(BeTrue()) + }) + + It("works when there are no plugins", func() { + Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName)) + }) + }) + Describe("GetArtistMBID", func() { It("returns on first match", func() { Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index a4ad6bc8c..ccc5f872b 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -394,6 +394,7 @@ "invalidJson": "A configuração deve ser um JSON válido" }, "messages": { + "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.", "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.", "clickPermissions": "Clique em uma permissão para ver detalhes", "noConfig": "Nenhuma configuração definida", diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 8823a6749..de96d47c0 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -397,6 +397,7 @@ "invalidJson": "Configuration must be valid JSON" }, "messages": { + "idHelp": "The plugin ID, derived from its file name. Use it when referencing this plugin in configuration options, such as Agents.", "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", "configValidationError": "Configuration validation failed:", "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", diff --git a/ui/src/plugin/InfoCard.jsx b/ui/src/plugin/InfoCard.jsx index 8fb6853fe..3a7bb24c7 100644 --- a/ui/src/plugin/InfoCard.jsx +++ b/ui/src/plugin/InfoCard.jsx @@ -123,6 +123,13 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( isSmall={isSmall} > {record.id} + <Typography + variant="caption" + color="textSecondary" + className={classes.fieldHelp} + > + {translate('resources.plugin.messages.idHelp')} + </Typography> </InfoRow> {manifest?.name && ( @@ -201,7 +208,7 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( <Typography variant="caption" color="textSecondary" - style={{ marginTop: 4, display: 'block' }} + className={classes.fieldHelp} > {translate('resources.plugin.messages.clickPermissions')} </Typography> diff --git a/ui/src/plugin/InfoCard.test.jsx b/ui/src/plugin/InfoCard.test.jsx new file mode 100644 index 000000000..24fa48caa --- /dev/null +++ b/ui/src/plugin/InfoCard.test.jsx @@ -0,0 +1,41 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../common', () => ({ + DateField: ({ source }) => <span data-testid={`date-${source}`} />, +})) + +const { InfoCard } = await import('./InfoCard') + +const record = { + id: 'apple-music', + path: '/data/plugins/apple-music.ndp', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', +} + +const renderCard = () => + render( + <InfoCard + record={record} + manifest={{ name: 'Apple Music Metadata Agent' }} + classes={{}} + translate={(key) => key} + isSmall={false} + />, + ) + +describe('InfoCard', () => { + it('shows the plugin ID', () => { + renderCard() + expect(screen.getByText('apple-music')).toBeInTheDocument() + }) + + it('explains that the ID is the name used in config options', () => { + renderCard() + expect( + screen.getByText('resources.plugin.messages.idHelp'), + ).toBeInTheDocument() + }) +}) diff --git a/ui/src/plugin/styles.js b/ui/src/plugin/styles.js index 104d8bc0f..68c0be929 100644 --- a/ui/src/plugin/styles.js +++ b/ui/src/plugin/styles.js @@ -45,6 +45,10 @@ export const usePluginShowStyles = makeStyles( fontSize: '0.85rem', wordBreak: 'break-all', }, + fieldHelp: { + marginTop: theme.spacing(0.5), + display: 'block', + }, permissionsContainer: { display: 'flex', flexWrap: 'wrap', From 3867fab4da6ea7142bfc6374c94b34a708e03b4e Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh <github@adityaraj.com> Date: Sun, 30 Aug 2026 20:51:14 +0530 Subject: [PATCH 481/596] fix(transcoding): report AAC streams as audio/aac instead of audio/mp4 (#5998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default AAC transcode emits raw ADTS (`ffmpeg ... -f adts -`), but the MIME table mapped `.aac` to `audio/mp4`. Clients that dispatch strictly on Content-Type could reject the stream because the declared container did not match the payload. `.m4a` and `.alac` stay on `audio/mp4`, since those really are MP4. Fixes #5958 Signed-off-by: Aditya Raj Singh <aditya@bncw.in> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/mime_types.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index 83abf2e5c..18a2c22b5 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -10,7 +10,7 @@ types: .ogg: audio/ogg .oga: audio/ogg .opus: audio/ogg - .aac: audio/mp4 + .aac: audio/aac .alac: audio/mp4 .m4a: audio/mp4 .m4b: audio/mp4 From ff033d8db62c9b045ae3dea54fc794a8114dd5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 12:43:15 -0400 Subject: [PATCH 482/596] chore(deps): upgrade to Go 1.27 (#5990) * build: upgrade to Go 1.27 Bumps the toolchain in go.mod, both golang base images in the Dockerfile, and the devcontainer VARIANT. CI needs no change, as the workflows resolve the version through go-version-file: go.mod. Tests, race tests, build and vet all pass on go1.27.0. * build: upgrade golangci-lint to v2.13.0 v2.13.0 is the first release built with Go 1.27, so it can lint a module whose go directive is 1.27. It also enables gosec's G404 on math/rand/v2, which flags the three rand.Shuffle call sites. Shuffle order is not a security decision, and the crypto-backed alternative in utils/random costs 25x and allocates per swap, so the call sites are annotated rather than the rule excluded, keeping G404 active for the cases where it would matter. * chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> * build: bump golangci-lint to v2.13.2 --------- Signed-off-by: Deluan <deluan@navidrome.org> --- .devcontainer/devcontainer.json | 2 +- Dockerfile | 4 ++-- Makefile | 2 +- core/external/provider_similarsongs.go | 1 + core/playback/queue.go | 1 + db/backup_test.go | 1 + go.mod | 2 +- 7 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c9e4ba2bf..efe965428 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.26", + "VARIANT": "1.27", // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24" diff --git a/Dockerfile b/Dockerfile index 847c19bf7..3d7a03f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -85,7 +85,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base +FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace diff --git a/Makefile b/Makefile index fa0d10475..81a609422 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.12.0 +GOLANGCI_LINT_VERSION ?= v2.13.2 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go index 7720c9349..4ab465b03 100644 --- a/core/external/provider_similarsongs.go +++ b/core/external/provider_similarsongs.go @@ -167,6 +167,7 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model. if len(matched) == 0 { matched = seeds } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) if len(matched) > count { matched = matched[:count] diff --git a/core/playback/queue.go b/core/playback/queue.go index d15eaad96..116db5fe6 100644 --- a/core/playback/queue.go +++ b/core/playback/queue.go @@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() { backupID = current.ID } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] }) var err error diff --git a/db/backup_test.go b/db/backup_test.go index 5e8f877e6..5d1bfc6e3 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -68,6 +68,7 @@ var _ = Describe("database backups", func() { timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) copy(timesShuffled, timesDecreasingChronologically) + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(timesShuffled), func(i, j int) { timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] }) diff --git a/go.mod b/go.mod index 93e84b052..a7b005c25 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.26 +go 1.27 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 From b3ecaddd9c49534f6612a12715a83bd457fe0a67 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 30 Aug 2026 11:28:32 -0400 Subject: [PATCH 483/596] chore(deps): update fscache and stream dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 7 ++----- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a7b005c25..cdf8fc699 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,6 @@ go 1.27 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 -// Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 -replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 - require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.4 @@ -16,8 +13,8 @@ require ( github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 - github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.5.0 + github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 + github.com/djherbis/stream v1.5.1 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 diff --git a/go.sum b/go.sum index c75b72dd0..d852285f1 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 h1:u3oDvM7pOIouwDGeIoMu9SYgSzVMtiSkOc+xywtKiJk= -github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -41,8 +39,10 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/stream v1.5.0 h1:+ewqpS/ndTmEiJRH142JyOpmmjnkTxdbJtqoAG5sg0Y= -github.com/djherbis/stream v1.5.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 h1:GNKxzBirvK9arfVRGVebhFYBp3tnOZG3nlIog6N5/6I= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= +github.com/djherbis/stream v1.5.1 h1:9AoCl0bnm7imWT2uUORqT8aLuTh+YllyynYpolpjIqY= +github.com/djherbis/stream v1.5.1/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= From b7ea48057682f564e6e8955de95a5115e99f959b Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 30 Aug 2026 12:44:04 -0400 Subject: [PATCH 484/596] refactor: simplify return statements Signed-off-by: Deluan <deluan@navidrome.org> --- core/share.go | 3 +-- tests/mock_artist_repo.go | 3 +-- tests/mock_mediafile_repo.go | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/core/share.go b/core/share.go index 5a611c7f0..b2f32ba39 100644 --- a/core/share.go +++ b/core/share.go @@ -123,8 +123,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.Contents = str.TruncateRunes(s.Contents, 30, "...") - id, err = r.Persistable.Save(s) - return id, err + return r.Persistable.Save(s) } func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b71942208..af393129e 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -205,8 +205,7 @@ func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model. return nil, errors.New("unexpected error") } // Simple mock implementation - just return all artists for testing - allArtists, err := m.GetAll() - return allArtists, err + return m.GetAll() } var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 58e84785b..f18280fd5 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -319,8 +319,7 @@ func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (mod return nil, errors.New("unexpected error") } // Simple mock implementation - just return all media files for testing - allFiles, err := m.GetAll() - return allFiles, err + return m.GetAll() } // Cross-library move detection mock methods From aee8a705b1a579e328b88672826ee0d68da80a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 14:25:36 -0400 Subject: [PATCH 485/596] build(docker): upgrade Alpine base image to 3.22 (#6048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves both the xx-build toolchain stage and the final runtime image from Alpine 3.20 (past end of active support) to 3.22. 3.22 is the last release where ffmpeg is still 6.1.x — it jumps to 8.0 in 3.23 — so transcoding behavior is unchanged by this bump. Alpine 3.21 repackaged mesa, and from that release on `mpv` requires so:libEGL.so.1 and so:libgbm.so.1. Those pull mesa -> llvm20-libs (156MB) plus the gallium drivers (62MB), which took the image from 231MB/62MB compressed to 578MB/147MB. mesa-egl is the only provider of libEGL.so.1, and newer Alpine releases do not improve on this. Navidrome runs mpv headless for jukebox audio and never enters a video path, so this replaces libEGL/libgbm with generated no-op stubs and drops the mesa/LLVM stack. The stub symbol list is read from real mesa at build time and cross-compiled with the existing xx toolchain, so it adapts per architecture rather than being hardcoded. Verified with logging stubs across mp3/flac/ogg/opus/m4a/wav driving the default MPVCmdTemplate (pause, volume, time-pos seek, quit): zero calls into the stubbed libraries. The final stage now also runs mpv once at build time, so a broken stub fails the build instead of shipping. Image size: 323MB -> 325MB (84MB -> 86MB compressed). --- Dockerfile | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d7a03f5d..3427d6806 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build +FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -152,19 +152,52 @@ RUN xx-verify --static /out/navidrome* FROM scratch AS binary COPY --from=build /out / +######################################################################################################################## +### Build no-op stubs for mpv's video-output libraries +# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio. +# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls. +FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs +COPY --from=xx / / +RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm +ARG TARGETPLATFORM +RUN xx-apk add --no-cache musl-dev +RUN <<EOT + set -e + mkdir -p /out + for so in libEGL.so.1 libgbm.so.1; do + readelf -sW /usr/lib/$so \ + | awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \ + | sed 's/@.*//' \ + | grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \ + | sort -u \ + | awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c + test -s /tmp/stub.c + xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c + xx-verify /out/$so + done +EOT + ######################################################################################################################## ### Build Final Image -FROM alpine:3.20 AS final +FROM alpine:3.22 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" # Install runtime dependencies # - libwebp + symlinks: enables native WebP encoding via purego/dlopen +# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, +# otherwise the deleted bytes still ship in the image. RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ for lib in libwebp libwebpdemux libwebpmux; do \ target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ - done + done && \ + rm -rf /usr/lib/gallium-pipe /usr/lib/dri \ + /usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \ + /usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so* + +COPY --from=mpv-stubs /out/ /usr/lib/ +RUN mpv --no-video --ao=null --version > /dev/null # Copy navidrome binary (musl build for Docker, enables native libwebp) COPY --from=build-alpine /out/navidrome /app/ From 46041bb90884b818c8dbcaf851855cb1ee1aeccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 16:06:08 -0400 Subject: [PATCH 486/596] ci: cache the plugins test suite WASM compilation across runs (#6049) * ci: cache the plugins test suite WASM compilation across runs The 'Test Go code' job was dominated by a single package: 'plugins' took 541s of the 699s test step. The suite builds 25 test plugins as full-Go wasip1 modules of ~4.5MB each, and wazero must compile every one to machine code. Under -race that compiler work is instrumented, so each module costs around 11 seconds. The suite already shared a wazero compilation cache, but three things kept it from paying off. It lived in a fresh temp dir, so nothing survived the run. The default plugins.cachesize of 200MB was smaller than the 334MB the cache actually needs, so the purge evicted entries mid-run. And the wasm binaries embedded VCS stamps, so every commit produced different bytes and missed the content-addressed cache anyway. Point CacheFolder at plugins/testdata/.wazero-cache, raise the test cache limit past what the suite needs, build the test plugins with -buildvcs=false, and restore the directory in CI. Locally the package goes from 256s to 74s with the cache warm and the wasm rebuilt from scratch. * ci: key the WASM cache on what actually changes the modules The test plugins are separate Go modules with their own go.mod and go.sum; they reach the PDK through a replace directive and never read the root module. So the root go.sum has no bearing on the wasm bytes, and the wazero version it pins is already namespaced by wazero itself, which stores entries under wazero-<version>-<goarch>-<goos>. Keying on it only rotated the cache on every unrelated dependency bump. Drop it, and add the go.mod files that were missing: the test plugins' own and the PDK's. The root go.mod stays, since it selects the toolchain that builds the modules. * ci: key the WASM cache on the toolchain version, not go.mod Only the Go toolchain in the root go.mod affects the built wasm, but the file also changes on every direct dependency bump, which would rotate the cache for no reason. Take setup-go's go-version output instead: it is the version that actually built the modules. --- .github/workflows/pipeline.yml | 10 ++++++++++ .gitignore | 5 ++++- plugins/plugins_suite_test.go | 21 ++++++++------------- plugins/testdata/Makefile | 6 ++++-- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index b91c19505..012e3a9c3 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -131,12 +131,22 @@ jobs: uses: actions/checkout@v7 - uses: actions/setup-go@v6 + id: setup-go with: go-version-file: go.mod - name: Download dependencies run: go mod download + # Without this, the plugins suite recompiles every test plugin WASM module, + # which dominates the job runtime under -race. + - name: Cache the plugins test suite WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + - name: Test run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v diff --git a/.gitignore b/.gitignore index 6459ded9b..810074e72 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ go.work* .playwright-mcp/ # Temp benchmark files -zz_*_test.go \ No newline at end of file +zz_*_test.go + +# wazero compilation cache for the plugins test suite +/plugins/testdata/.wazero-cache/ diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 77956e2f9..c8585f2c1 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -25,7 +25,10 @@ import ( . "github.com/onsi/gomega" ) -const testDataDir = "plugins/testdata" +const ( + testDataDir = "plugins/testdata" + wazeroCacheDir = ".wazero-cache" +) // Shared test state initialized in BeforeSuite var ( @@ -38,18 +41,10 @@ func TestPlugins(t *testing.T) { tests.Init(t, false) buildTestPlugins(t, testDataDir) - // Create a shared wazero compilation cache directory. - // All test managers will point CacheFolder here so that WASM compilation - // is done once per binary and then reused from disk cache. - sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*") - if err != nil { - t.Fatalf("Failed to create shared cache dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(sharedCacheDir) }) - - // Set CacheFolder globally so all tests (including those using - // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) + // Set globally so tests using configtest.SetupConfig inherit it. The cache + // persists between runs; entries are content-addressed, so a stale one only misses. + conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir)) + conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index d53f2aaee..758497095 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,6 +10,7 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) + rm -rf .wazero-cache # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) @@ -22,10 +23,11 @@ PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) @rm -f plugin.wasm @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp -# Build the wasm binary +# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so +# the test suite's wazero compilation cache still hits after a rebuild. %.wasm: %/*.go %/go.mod $(PDK_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ . + cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ . endif \ No newline at end of file From a2de8e61efe82ec1d81d67d985026c083ad541fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 16:57:40 -0400 Subject: [PATCH 487/596] ci: run the plugins test suite in parallel processes (#6051) * test(plugins): make the suite safe to run in parallel processes Two things broke when the suite ran across several Ginkgo processes. buildTestPlugins ran in every process, so N copies of make raced in the same directory. The packaging rule made that worse by staging every plugin through one shared plugin.wasm, so concurrent targets clobbered each other and left orphaned temp files behind. That also ruled out make -j. Stage each package under its own per-target directory, and move the build into SynchronizedBeforeSuite so process 1 does it once while the others wait. * ci: run the plugins suite in parallel processes With the compilation cache warm the suite is bound by spec execution, which splits cleanly across processes. Run it as its own step with the ginkgo CLI, already declared as a tool in go.mod, and drop the package from the main go test invocation so it is not run twice. Locally, with -race: 69s to 19s warm, and 256s to 96s cold. * ci: give the plugins suite its own job so it runs concurrently Running it as a second step in the go job serialised it against the other 90 packages, which cancelled out the parallel win: the job went from 5m41s to only 5m29s even though the suite itself dropped from ~175s to 82s. Move it to its own job so the two run at the same time. The WASM compilation cache moves with it, since the go job no longer runs the suite. --- .github/workflows/pipeline.yml | 38 +++++++++++++++++++++++----------- .gitignore | 1 + plugins/plugins_suite_test.go | 20 ++++++++---------- plugins/testdata/Makefile | 10 +++++---- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 012e3a9c3..9da99e864 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -131,24 +131,14 @@ jobs: uses: actions/checkout@v7 - uses: actions/setup-go@v6 - id: setup-go with: go-version-file: go.mod - name: Download dependencies run: go mod download - # Without this, the plugins suite recompiles every test plugin WASM module, - # which dominates the job runtime under -race. - - name: Cache the plugins test suite WASM compilation cache - uses: actions/cache@v6 - with: - path: plugins/testdata/.wazero-cache - key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} - restore-keys: wazero-${{ runner.os }}- - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -157,6 +147,30 @@ jobs: go build -o ndpgen . ./ndpgen --help + go-plugins: + name: Test Go plugins + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + id: setup-go + with: + go-version-file: go.mod + + # Without this, the suite recompiles every test plugin WASM module, + # which dominates its runtime under -race. + - name: Cache the WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + + - name: Test plugins + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/ + go-windows: name: Test Go code (Windows) runs-on: windows-2022 @@ -294,7 +308,7 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] diff --git a/.gitignore b/.gitignore index 810074e72..4ed971491 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ zz_*_test.go # wazero compilation cache for the plugins test suite /plugins/testdata/.wazero-cache/ +/plugins/testdata/*.stage/ diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index c8585f2c1..2dae65d00 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "net/http" "os" "os/exec" @@ -39,7 +40,6 @@ var ( func TestPlugins(t *testing.T) { tests.Init(t, false) - buildTestPlugins(t, testDataDir) // Set globally so tests using configtest.SetupConfig inherit it. The cache // persists between runs; entries are content-addressed, so a stale one only misses. @@ -51,16 +51,11 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(t *testing.T, path string) { - t.Helper() +func buildTestPlugins(path string) { start := time.Now() - t.Logf("[BeforeSuite] Current working directory: %s", path) - cmd := exec.Command("make", "-C", path) - out, err := cmd.CombinedOutput() - t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start)) - if err != nil { - t.Fatalf("Failed to build test plugins: %v", err) - } + out, err := exec.Command("make", "-C", path).CombinedOutput() + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s:\n%s", time.Since(start), out) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") } // createTestManager creates a new plugin Manager with the given plugin config. @@ -146,7 +141,10 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s return manager, tmpDir } -var _ = BeforeSuite(func() { +var _ = SynchronizedBeforeSuite(func() { + // Build once: the testdata Makefile is not safe to run concurrently. + buildTestPlugins(testDataDir) +}, func() { // Get testdata directory (where test plugin .ndp packages live) _, currentFile, _, ok := runtime.Caller(0) Expect(ok).To(BeTrue()) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index 758497095..de948a2bb 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,17 +10,19 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) - rm -rf .wazero-cache + rm -rf .wazero-cache $(PLUGINS:%=%.stage) # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) # Build the .ndp package (zip containing manifest.json + plugin.wasm) +# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds. %.ndp: %.wasm %/manifest.json @rm -f $@ - @cp $< plugin.wasm - zip -j $@ $*/manifest.json plugin.wasm - @rm -f plugin.wasm + @rm -rf $*.stage && mkdir -p $*.stage + @cp $< $*.stage/plugin.wasm + zip -j $@ $*/manifest.json $*.stage/plugin.wasm + @rm -rf $*.stage @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp # Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so From 9ff005862071ee16f60f1ca103397b993821201d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 21:24:50 -0400 Subject: [PATCH 488/596] fix: assorted scanner, plugin, and server fixes from the Go 1.27 work (#6050) * fix(plugins): stop the cache janitor when a plugin cache is dropped newCacheService started a ttlcache janitor goroutine that only stopped via the explicit Close() path, so a cache service that was discarded without being closed leaked its janitor for the process lifetime. It now registers the same runtime.AddCleanup safety net that utils/cache.simpleCache already uses. * fix(scanner): stop splitting multi-byte characters when truncating tags sanitize() capped tag values with a byte slice, so a value whose limit falls in the middle of a multi-byte character was stored as invalid UTF-8. defaultMaxTagLength is 1024, which is not a multiple of 3, so any sufficiently long CJK title hit this. Only trailing invalid bytes are trimmed, leaving bad bytes elsewhere in the value untouched. * fix(scanner): store MusicBrainz ids in their canonical form uuid.Parse accepts a UUID wrapped in any two bytes, as well as braced and urn: forms, but sanitize() returned the raw string. A tag like {<mbid>} or a quoted value was therefore persisted with its wrapper into the mbz_* columns, where the exact-match MBID search can never find it. The parsed value is now stored, which also lowercases uppercase ids and adds the dashes to unhyphenated ones. * fix(plugins): parse IPv6 hosts correctly in the websocket allowlist isHostAllowed cut the host at the last colon, which mangles an IPv6 literal: "[::1]:8080" became "[::1]" and "[::1]" became "[:". A plugin manifest could therefore never allow an IPv6 host. It now uses net.SplitHostPort, falling back to unwrapping the brackets when there is no port. * fix(server): serve pprof profiles when a BaseURL is configured net/http/pprof's Index resolves the profile name by trimming "/debug/pprof/" from the raw request path, which never matches once MountRouter prepends the BasePath. Requests for any profile without an explicit chi route fell through to the index page, returning HTML with a 200 instead of the profile. The handler now strips the BasePath first. * test(scanner): run the goroutine leak check unconditionally The scanner suite's goleak check only ran when the GOLEAK env var was set, so it never ran in CI and could not catch a regression. It passes with the existing ignore list, verified over repeated runs, so the gate is removed. * fix(server): close the background image body on a non-200 response serveImage returned early on an unexpected status code without closing the response body, pinning the connection until the 5s client timeout. The nolint:bodyclose above the request suppressed the linter that would have caught it, and its justification only holds on the success path, where the body is handed to the CachedStream wrapper. * test(scanner): repair BenchmarkScan so it can actually run The benchmark failed three ways before reaching its first iteration: it reused a shared temp DB and tried to repoint the default library, it never loaded the config defaults so the scanner got a concurrency of 0, and it lacked the notify ignore that the suite already carries. tests.Init now takes a testing.TB so a benchmark can load the test config the same way the suites do. * refactor(artwork): drop the unused sourceFunc Stringer sourceFunc.String derived a label from the closure's symbol name via reflection, but nothing called it: the trace output builds its candidate labels from explicit strings. Whole-program analysis confirms it is unreachable, and dropping it removes a reflection-based dependency on compiler closure-naming details. * refactor(plugins): reuse extractHostname in the websocket allowlist The IPv6 host parsing added for isHostAllowed duplicated extractHostname, which already lives in the same package and backs the HTTP client's identical allowlist check. Two copies of a security-relevant parser can drift, so the websocket service now calls the existing helper. The port-stripping specs move into the URL Validation block that already covered them. * perf(scanner): bound the tag truncation trim to a partial rune The trim loop dropped every trailing byte that failed to decode, so a value ending in a long run of invalid bytes was walked one byte at a time: a 1 MiB lyrics tag measured 2.58ms against 45ns for a normal cut. A partial rune is at most 3 trailing bytes, so the loop is capped there, which also stops it consuming a pre-existing invalid run. * test: tighten the tests added with the Go 1.27 bugfixes Drop the testItem stub in favour of the package's own cacheKey, register the pprof test profile once at package scope, and replace the hand-rolled goroutine settle loop with Eventually. Also corrects a comment that credited a TestMain the scanner suite does not have. * test(scanner): ignore notify's nonrecursive-tree goroutines on Linux The goroutine leak check only ignored the recursive tree (macOS/FSEvents). Linux CI uses inotify, whose nonrecursive tree leaks dispatch and internal goroutines after Stop(), failing the check. * fix(scanner): avoid a truncation panic when MaxLength is 1 or 2 A value of only UTF-8 continuation bytes drained the partial-rune loop to empty, then sliced value[:-1] and panicked. Break when DecodeLastRune returns size 0 (empty string) by testing size != 1 instead of size > 1. * fix: address Codex review on the pprof base path and scan benchmark - profilerHandler: treat a root BasePath ("/") as no prefix, so http.StripPrefix keeps the leading slash chi needs; without this the profiler 404s when BaseURL is "/". Cover the root case in the test. - BenchmarkScan: make it run regardless of test/benchmark ordering. Add singleton.DeleteInstance so a fresh DB is opened after TestScanner closes the shared one, guard driver registration with sync.Once so the rebuild does not re-Register, and ignore the Ginkgo interrupt-handler and Linux notify goroutines the preceding suite leaves behind. * fix: address Codex round 2 on BasePath trailing slash and benchmark DB cleanup - profilerHandler: trim all trailing slashes (TrimRight), not just a bare "/", so a BaseURL like "/music/" strips correctly instead of 404ing. Cover it in the test. - BenchmarkScan: keep and defer db.Init's closer so the DB is closed before b.TempDir cleanup, which otherwise cannot delete the open SQLite/WAL files on Windows. --- cmd/root.go | 11 +++- cmd/root_test.go | 46 +++++++++++++ core/artwork/sources.go | 12 ---- db/db.go | 21 ++++-- model/metadata/metadata.go | 14 +++- model/metadata/metadata_internal_test.go | 20 ++++++ model/metadata/metadata_test.go | 38 +++++++++++ plugins/host_cache.go | 10 ++- plugins/host_cache_test.go | 34 ++++++++++ plugins/host_websocket.go | 6 +- plugins/host_websocket_test.go | 26 ++++---- scanner/scanner_benchmark_test.go | 22 ++++++- scanner/scanner_suite_test.go | 20 +++--- server/backgrounds/backgrounds_suite_test.go | 17 +++++ server/backgrounds/handler.go | 3 +- server/backgrounds/handler_test.go | 68 ++++++++++++++++++++ tests/init_tests.go | 2 +- utils/singleton/singleton.go | 10 +++ 18 files changed, 323 insertions(+), 57 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 model/metadata/metadata_internal_test.go create mode 100644 server/backgrounds/backgrounds_suite_test.go create mode 100644 server/backgrounds/handler_test.go diff --git a/cmd/root.go b/cmd/root.go index ff1641bd3..94f861f40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "net/http" "os" "os/signal" "strings" @@ -138,7 +139,7 @@ func startServer(ctx context.Context) func() error { a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler()) } if conf.Server.DevEnableProfiler { - a.MountRouter("Profiling", "/debug", middleware.Profiler()) + a.MountRouter("Profiling", "/debug", profilerHandler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) @@ -147,6 +148,14 @@ func startServer(ctx context.Context) func() error { } } +// profilerHandler returns the pprof handler. net/http/pprof resolves the profile +// name from the raw request path, so the BasePath has to come off first. +func profilerHandler() http.Handler { + // A trailing or root slash would make StripPrefix drop the leading slash chi needs. + basePath := strings.TrimRight(conf.Server.BasePath, "/") + return http.StripPrefix(basePath, middleware.Profiler()) +} + // schedulePeriodicScan schedules a periodic scan of the music library, if configured. func schedulePeriodicScan(ctx context.Context) func() error { return func() error { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 000000000..af8d44e7e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "path" + "runtime/pprof" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = pprof.NewProfile("nd-profiler-test") + +var _ = Describe("profilerHandler", func() { + // Mirrors how server.MountRouter mounts the handler. + mount := func() http.Handler { + router := chi.NewRouter() + router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler()) + return router + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("serves a named profile", + func(basePath string) { + conf.Server.BasePath = basePath + + w := httptest.NewRecorder() + target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1" + mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0")) + }, + Entry("without a BasePath", ""), + Entry("with a BasePath", "/music"), + Entry("with a root BasePath", "/"), + Entry("with a trailing-slash BasePath", "/music/"), + ) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 069b7bf5a..f2abf9da5 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -10,9 +10,7 @@ import ( "net/http" "net/url" "path/filepath" - "reflect" "regexp" - "runtime" "strings" "time" @@ -29,16 +27,6 @@ var errSourceUnreadable = errors.New("artwork source unreadable") type sourceFunc func() (r io.ReadCloser, path string, err error) -func (f sourceFunc) String() string { - name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() - name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") - if _, after, found := strings.Cut(name, ")."); found { - name = after - } - name = strings.TrimSuffix(name, ".func1") - return name -} - func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { var openErr error diff --git a/db/db.go b/db/db.go index a325dd3f5..c53aa364a 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "errors" "fmt" + "sync" "time" "github.com/mattn/go-sqlite3" @@ -33,15 +34,21 @@ var embedMigrations embed.FS const migrationsFolder = "migrations" +// sql.Register panics if called twice, so guard it: the singleton instance can be reset +// (tests/benchmarks) and rebuilt, but the driver is process-global and registers only once. +var registerDriverOnce sync.Once + 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(NaturalCollation, natural.CompareFold) - }, + registerDriverOnce.Do(func() { + 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(NaturalCollation, natural.CompareFold) + }, + }) }) Path = conf.Server.DbPath if Path == ":memory:" { diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 729e83564..0efbe94ec 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/navidrome/navidrome/consts" @@ -366,6 +367,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s if len(value) > maxLength { log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength) value = value[:maxLength] + // Drop the partial rune the cut may have left: at most 3 trailing bytes, + // so a pre-existing invalid run elsewhere is never consumed. + for range 3 { + if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 { + break + } + value = value[:len(value)-1] + } } switch tag.Type { @@ -387,11 +396,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s return "" } case model.TagTypeUUID: - _, err := uuid.Parse(value) + u, err := uuid.Parse(value) if err != nil { log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) return "" } + // Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any + // two-byte wrapper, and a wrapped value would never match an exact-match query + value = u.String() } return value } diff --git a/model/metadata/metadata_internal_test.go b/model/metadata/metadata_internal_test.go new file mode 100644 index 000000000..99fbba328 --- /dev/null +++ b/model/metadata/metadata_internal_test.go @@ -0,0 +1,20 @@ +package metadata + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("sanitize truncation with a tiny MaxLength", + // A value of only continuation bytes drains the partial-rune loop to empty; the loop + // must stop there instead of slicing value[:-1] and panicking. + func(maxLength int, value string) { + Expect(func() { + Expect(sanitize("file.mp3", "title", model.TagConf{MaxLength: maxLength}, value)).To(Equal("")) + }).NotTo(Panic()) + }, + Entry("maxLength 1", 1, "\x80\x80"), + Entry("maxLength 2", 2, "\x80\x80\x80"), + Entry("maxLength 3", 3, "\x80\x80\x80\x80"), +) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 7ebe9fa4a..09a2dfde0 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" @@ -122,6 +123,43 @@ var _ = Describe("Metadata", func() { Expect(pair[0].Value()).To(HaveLen(1048570)) }) + It("should not split a multi-byte character when truncating", func() { + // 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune. + props.Tags = model.RawTags{ + "Title": {strings.Repeat("日", 2048)}, + } + md = metadata.New(filePath, props) + + title := md.String(model.TagTitle) + Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8") + Expect(len(title)).To(BeNumerically("<=", 1024)) + }) + + It("should keep invalid bytes that are not at the truncation point", func() { + props.Tags = model.RawTags{ + "Title": {"a\xffb" + strings.Repeat("c", 2048)}, + } + md = metadata.New(filePath, props) + + Expect(md.String(model.TagTitle)).To(HaveLen(1024)) + }) + + DescribeTable("should normalize UUID tags to their canonical form", + func(raw, expected string) { + props.Tags = model.RawTags{"musicbrainz_artistid": {raw}} + md = metadata.New(filePath, props) + + Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected)) + }, + Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("not a uuid", "the beatles", ""), + ) + It("should split multiple values", func() { props.Tags = model.RawTags{ "Genre": {"Rock/Pop;;Punk"}, diff --git a/plugins/host_cache.go b/plugins/host_cache.go index b90d790cf..f410dfd3d 100644 --- a/plugins/host_cache.go +++ b/plugins/host_cache.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "runtime" "time" "github.com/jellydator/ttlcache/v3" @@ -29,11 +30,18 @@ func newCacheService(pluginName string) *cacheServiceImpl { // Start the janitor goroutine to clean up expired entries go cache.Start() - return &cacheServiceImpl{ + svc := &cacheServiceImpl{ pluginName: pluginName, cache: cache, defaultTTL: defaultCacheTTL, } + + // Automatic cleanup to prevent goroutine leak when the service is garbage collected + runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) { + ttlCache.Stop() + }, cache) + + return svc } // getTTL converts seconds to a duration, using default if 0 or negative diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index cf3973fc4..5e91b04ec 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -8,9 +8,11 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "os" "path/filepath" + "runtime" "time" "github.com/navidrome/navidrome/conf" @@ -599,3 +601,35 @@ var _ = Describe("CacheService Integration", Ordered, func() { }) }) }) + +var _ = Describe("newCacheService", func() { + // The suite above leaves goroutines winding down, so settle before sampling. + settledBaseline := func() int { + var n int + Eventually(func() int { + runtime.GC() + prev := n + n = runtime.NumGoroutine() + return n - prev + }).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero()) + return n + } + + It("stops the janitor goroutine once the service is unreachable", func() { + const numServices = 5 + baseline := settledBaseline() + + func() { + services := make([]*cacheServiceImpl, 0, numServices) + for i := range numServices { + services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i))) + } + Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices), + "expected one janitor goroutine per cache service") + }() + + Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }). + WithTimeout(10*time.Second).WithPolling(20*time.Millisecond). + Should(BeNumerically("<=", baseline), "janitor goroutines leaked") + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 90403f4c0..82aded0cb 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -243,11 +243,7 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection } func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - // Strip port from host if present - hostWithoutPort := host - if idx := strings.LastIndex(host, ":"); idx != -1 { - hostWithoutPort = host[:idx] - } + hostWithoutPort := extractHostname(host) for _, pattern := range s.requiredHosts { if matchHostPattern(pattern, hostWithoutPort) { diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index e41cfbb82..2aa85cd21 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -144,20 +144,18 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(allowed).To(BeFalse()) }) - It("should strip port before checking host", func() { - // Implementation strips port before matching against patterns - // test-websocket manifest has "localhost:*" which matches "localhost" - // after port stripping - // Note: The port wildcard pattern isn't actually implemented, but - // since port is stripped, "localhost:*" is compared against "localhost" - // which won't match. To make localhost work, we'd need exact "localhost" - // in the allowed hosts list. - - // Testing that port is properly stripped - // The pattern "localhost:*" won't match "localhost" due to exact match - allowed := testService.isHostAllowed("localhost:8080") - Expect(allowed).To(BeFalse()) - }) + DescribeTable("should match against the host with its port stripped", + func(allowed []string, host string, expected bool) { + svc := &webSocketServiceImpl{requiredHosts: allowed} + Expect(svc.isHostAllowed(host)).To(Equal(expected)) + }, + Entry("hostname with port", []string{"example.com"}, "example.com:8080", true), + Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true), + Entry("IPv6 without port", []string{"::1"}, "[::1]", true), + Entry("host not in the list", []string{"::2"}, "[::1]:8080", false), + // "localhost:*" is matched against the stripped "localhost", so it never hits + Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false), + ) }) Describe("Connection Management", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index ca1064631..65410d500 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -2,8 +2,8 @@ package scanner_test import ( "context" + "database/sql" "fmt" - "os" "path/filepath" "runtime" "testing" @@ -21,6 +21,8 @@ import ( "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/singleton" "go.uber.org/goleak" ) @@ -31,11 +33,25 @@ func BenchmarkScan(b *testing.B) { goleak.IgnoreAnyFunction("testing.(*B).doBench"), // Ignore database/sql.(*DB).connectionOpener, as we are not closing the database connection goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"), + // A preceding TestScanner leaves Ginkgo's interrupt handler running. + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps watcher goroutines alive after Stop(); recursive on macOS, nonrecursive on Linux. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), ) - tmpDir := os.TempDir() + tests.Init(b, false) + + tmpDir := b.TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - db.Init(context.Background()) + // The default library is seeded from MusicFolder, and its path cannot be changed afterwards + conf.Server.MusicFolder = "fake:///music" + // TestScanner may run first and close the shared DB singleton; drop it so db.Init + // opens a fresh one whether or not the test suite ran before this benchmark. + singleton.DeleteInstance[*sql.DB]() + // Close before b.TempDir cleanup runs, or Windows cannot delete the open DB/WAL files. + defer db.Init(context.Background())() ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 10be0401f..07ffd6e3a 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -3,7 +3,6 @@ package scanner_test import ( "context" "io/fs" - "os" "testing" "github.com/navidrome/navidrome/consts" @@ -31,16 +30,15 @@ func init() { } func TestScanner(t *testing.T) { - // Only run goleak checks when the GOLEAK env var is set - if os.Getenv("GOLEAK") != "" { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library creates internal goroutines for file watching that persist after Stop() is called. - // These are created by the plugins package tests and are expected behavior. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - ) - } + // Detect any goroutine leaks in the scanner code under test + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps internal goroutines alive after Stop(). The backend picks the tree per + // platform: recursive on macOS (FSEvents), nonrecursive on Linux (inotify), so ignore both. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), + ) tests.Init(t, true) defer db.Close(context.Background()) diff --git a/server/backgrounds/backgrounds_suite_test.go b/server/backgrounds/backgrounds_suite_test.go new file mode 100644 index 000000000..5ad81907b --- /dev/null +++ b/server/backgrounds/backgrounds_suite_test.go @@ -0,0 +1,17 @@ +package backgrounds + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBackgrounds(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Backgrounds Suite") +} diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index f6e159b4b..dcaaa9c66 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -81,7 +81,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e } c := httpclient.New(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,gosec // On success the body is closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) return strings.NewReader(string(defaultImage)), nil @@ -90,6 +90,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e return nil, fmt.Errorf("could not get background image from hosting service: %w", err) } if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode) } log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start)) diff --git a/server/backgrounds/handler_test.go b/server/backgrounds/handler_test.go new file mode 100644 index 000000000..e8c77380f --- /dev/null +++ b/server/backgrounds/handler_test.go @@ -0,0 +1,68 @@ +package backgrounds + +import ( + "context" + "io" + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type recordingBody struct { + io.Reader + closed *bool +} + +func (b recordingBody) Close() error { + *b.closed = true + return nil +} + +type stubTransport struct { + statusCode int + closed *bool +} + +func (t stubTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: t.statusCode, + Header: make(http.Header), + Body: recordingBody{Reader: strings.NewReader("image-bytes"), closed: t.closed}, + }, nil +} + +var _ = Describe("serveImage", func() { + var closed bool + + BeforeEach(func() { + closed = false + }) + + stubStatus := func(statusCode int) { + original := http.DefaultTransport + http.DefaultTransport = stubTransport{statusCode: statusCode, closed: &closed} + DeferCleanup(func() { http.DefaultTransport = original }) + } + + It("closes the response body when the hosting service returns an error", func() { + stubStatus(http.StatusNotFound) + + _, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).To(MatchError(ContainSubstring("unexpected status code"))) + Expect(closed).To(BeTrue(), "response body was left open") + }) + + It("hands the still-open body to the caller on success", func() { + stubStatus(http.StatusOK) + + reader, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).ToNot(HaveOccurred()) + Expect(closed).To(BeFalse(), "response body must stay open for the CachedStream wrapper") + body, _ := io.ReadAll(reader) + Expect(string(body)).To(Equal("image-bytes")) + }) +}) diff --git a/tests/init_tests.go b/tests/init_tests.go index 582ad95fc..902cf196d 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -13,7 +13,7 @@ import ( var once sync.Once -func Init(t *testing.T, skipOnShort bool) { +func Init(t testing.TB, skipOnShort bool) { if skipOnShort && testing.Short() { t.Skip("skipping test in short mode.") } diff --git a/utils/singleton/singleton.go b/utils/singleton/singleton.go index 83f8c53ab..8271034ad 100644 --- a/utils/singleton/singleton.go +++ b/utils/singleton/singleton.go @@ -67,3 +67,13 @@ func GetInstance[T any](constructor func() T) T { return newInstance } + +// DeleteInstance drops the cached instance of type T so the next GetInstance rebuilds it. +// Intended for tests and benchmarks that need a fresh instance regardless of run order. +func DeleteInstance[T any]() { + var v T + name := reflect.TypeOf(v).String() + lock.Lock() + delete(instances, name) + lock.Unlock() +} From dbd26ba2e71d0a5b79dba873a2beeff59f1cd8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 30 Aug 2026 22:17:25 -0400 Subject: [PATCH 489/596] perf(scanner): improve playlist importing on large libraries (#6055) * perf(persistence): avoid a full media_file scan when resolving playlist paths FindByPaths built one OR-ed equality term per path. On the real media_file schema SQLite abandons the path index at just two OR-ed terms and falls back to SCAN media_file, re-testing every term against every row, so the cost grows with (rows x terms). Group the candidates by library and emit one IN list per library instead, which plans as SEARCH media_file USING INDEX media_file_path_nocase. The NOCASE collation is kept so ASCII case-insensitive matching still works. This is the dominant cost of M3U playlist import, which resolves every track on every scan. Measured with a 1000-track playlist against a migrated DB: 100k media_file rows: 397 -> 51,414 tracks/sec 500k media_file rows: 78.5 -> 47,174 tracks/sec The rate no longer degrades as the table grows, which is the expected shape for an index lookup. Reported in #6043, where an 8 hour scan of a 2M-song library spent 7h52m in the playlist phase. * docs(playlists): correct the stale reason for the M3U lookup chunk size The expression-tree depth ceiling applied to the old OR-per-path query, which capped a batch at roughly 500 terms. The IN form is bound by SQLite's 32766 variable limit instead, which the 400 candidates per chunk sit far below. --- core/playlists/parse_m3u.go | 4 ++-- persistence/mediafile_repository.go | 25 +++++++++++++++++------- persistence/mediafile_repository_test.go | 22 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index a64c337c9..286f2e420 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -25,8 +25,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m return err } var mfs model.MediaFiles - // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates - // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000. + // Chunked so a huge playlist is not held in memory at once. Each line yields up to + // 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables. for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { filteredLines := make([]string, 0, len(lines)) for _, line := range lines { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 320b95ef2..ed18333de 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "iter" + "maps" "slices" "strconv" "strings" @@ -355,7 +356,10 @@ func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions // Library-qualified paths search within the specified library, while unqualified paths // search across all libraries for backward compatibility. func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { - query := Or{} + // One IN list per library instead of one OR term per path: SQLite abandons the + // path index at just two OR-ed equality terms and scans the whole table. + byLibrary := map[int][]string{} + var unqualified []string for _, path := range paths { parts := strings.SplitN(path, ":", 2) @@ -366,17 +370,24 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err // Invalid format, skip continue } - relativePath := parts[1] - query = append(query, And{ - Eq{"path collate nocase": relativePath}, - Eq{"library_id": libraryID}, - }) + byLibrary[libraryID] = append(byLibrary[libraryID], parts[1]) } else { // Unqualified path: search across all libraries - query = append(query, Eq{"path collate nocase": path}) + unqualified = append(unqualified, path) } } + query := Or{} + for _, libraryID := range slices.Sorted(maps.Keys(byLibrary)) { + query = append(query, And{ + Eq{"path collate nocase": byLibrary[libraryID]}, + Eq{"library_id": libraryID}, + }) + } + if len(unqualified) > 0 { + query = append(query, Eq{"path collate nocase": unqualified}) + } + if len(query) == 0 { return model.MediaFiles{}, nil } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index c1a91c5a5..8a492a813 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1055,6 +1055,28 @@ var _ = Describe("MediaRepository", func() { Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("otherlib-track")) }) + + It("resolves paths from multiple libraries in a single call", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + results, err := adminMr.FindByPaths([]string{ + "1:artist/Album/track.mp3", + fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) + }) + + It("keeps each path scoped to its own library when several are queried", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + // Each path exists, but under the other library's ID, so neither must match. + results, err := adminMr.FindByPaths([]string{ + fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), + "1:hidden/test.mp3", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) }) }) From 96b051ffa774eab2e0d877f5fbabe3215bc4d4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 31 Aug 2026 11:21:32 -0400 Subject: [PATCH 490/596] fix(nativeapi): stop partial PUTs from clearing untouched columns (#6058) * fix(nativeapi): stop partial PUTs from clearing untouched columns The REST layer parses the request body's top-level JSON keys and passes them to Repository.Update as colsToUpdate. The radio and library repositories discarded that list and issued a full-row UPDATE, so any field absent from the body was written as its zero value. For radio this wiped uploaded_image, deleting the station's cover on every partial update (the Web UI is unaffected because its form submits the whole record). For library it silently cleared remote_path and default_new_users. Thread the column list through to Put in both repositories, and extract the column-selection half of filterUpdateValues into selectUpdateColumns so library, which hand-builds its update map, shares the same rule instead of copying it. Fixes #6057 * refactor(persistence): drop pluginRepository's dead rest.Persistable methods Save and Update had no callers: PUT /api/plugin/{id} is served by the hand-written updatePlugin handler over a typed request struct, and the route only wires rest.GetAll and rest.Get. Both methods delegated to Put, which upserts all twelve columns, so wiring rest.Put to this repository would have reintroduced the partial-update clobbering fixed in the previous commit. Removing them, along with the rest.Persistable assertion, makes that a compile error instead of a silent data loss. Put itself is unchanged and still backs plugin discovery. --- core/library.go | 4 ++-- core/library_test.go | 9 +++++++++ model/library.go | 2 +- persistence/library_repository.go | 10 +++++----- persistence/library_repository_test.go | 20 ++++++++++++++++++++ persistence/plugin_repository.go | 26 -------------------------- persistence/radio_repository.go | 2 +- persistence/radio_repository_test.go | 18 ++++++++++++++++++ persistence/sql_base_repository.go | 15 +++++++++------ tests/mock_library_repo.go | 10 ++++++---- 10 files changed, 71 insertions(+), 45 deletions(-) diff --git a/core/library.go b/core/library.go index 365dcbd4c..d905e00cb 100644 --- a/core/library.go +++ b/core/library.go @@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error { +func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error { lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { @@ -211,7 +211,7 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) er pathChanged := originalLib.Path != lib.Path - err = r.LibraryRepository.Put(lib) + err = r.LibraryRepository.Put(lib, cols...) if err != nil { return r.mapError(err) } diff --git a/core/library_test.go b/core/library_test.go index 175d9c37d..43097414d 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -188,6 +188,15 @@ var _ = Describe("Library Service", func() { Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) }) + It("forwards the columns sent by the client to the repository", func() { + library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} + + err := repo.Update("1", library, "name", "path") + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) + }) + It("fails when library doesn't exist", func() { // Create a unique temporary directory to avoid path conflicts uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") diff --git a/model/library.go b/model/library.go index bcb2864c8..aceab533a 100644 --- a/model/library.go +++ b/model/library.go @@ -45,7 +45,7 @@ type LibraryRepository interface { GetPath(id int) (string, error) GetAll(...QueryOptions) (Libraries, error) CountAll(...QueryOptions) (int64, error) - Put(*Library) error + Put(l *Library, colsToUpdate ...string) error Delete(id int) error StoreMusicFolder() error AddArtist(id int, artistID string) error diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 5a0142423..df5c9a066 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -70,7 +70,7 @@ func (r *libraryRepository) GetPath(id int) (string, error) { } } -func (r *libraryRepository) Put(l *model.Library) error { +func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error { if l.ID == model.DefaultLibraryID { currentLib, err := r.Get(1) // if we are creating it, it's ok. @@ -89,13 +89,13 @@ func (r *libraryRepository) Put(l *model.Library) error { err = r.db.Model(l).Insert() } else { // Try to update first - cols := map[string]any{ + cols := selectUpdateColumns(map[string]any{ "name": l.Name, "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, - "updated_at": l.UpdatedAt, - } + }, colsToUpdate...) + cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) rowsAffected, updateErr := r.executeSQL(sq) if updateErr != nil { @@ -340,7 +340,7 @@ func (r *libraryRepository) Update(id string, entity any, cols ...string) error } lib.ID = idInt - return r.Put(lib) + return r.Put(lib, cols...) } var _ model.LibraryRepository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 1743df209..949dd93c5 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -52,6 +52,26 @@ var _ = Describe("LibraryRepository", func() { }) }) + Context("when colsToUpdate is specified", func() { + It("only writes the requested columns", func() { + lib := &model.Library{ + Name: "Original Library", + Path: "/music/original", + RemotePath: "/remote/original", + DefaultNewUsers: true, + } + Expect(repo.Put(lib)).To(Succeed()) + + Expect(repo.Put(&model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) + + saved, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.RemotePath).To(Equal("/remote/original")) + Expect(saved.DefaultNewUsers).To(BeTrue()) + }) + }) + Context("when ID is non-zero and record exists", func() { It("updates the existing record", func() { // First create a library diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 35c32de91..c1e36f0b1 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -141,31 +141,5 @@ func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *pluginRepository) Save(entity any) (string, error) { - p := entity.(*model.Plugin) - if !r.isPermitted() { - return "", rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return p.ID, err -} - -func (r *pluginRepository) Update(id string, entity any, cols ...string) error { - p := entity.(*model.Plugin) - p.ID = id - if !r.isPermitted() { - return rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err -} - var _ model.PluginRepository = (*pluginRepository)(nil) var _ rest.Repository = (*pluginRepository)(nil) -var _ rest.Persistable = (*pluginRepository)(nil) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index b73487e40..915859559 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -152,7 +152,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - err := r.Put(t) + err := r.Put(t, cols...) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound } diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index e2564455d..c35c85ad7 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -141,6 +141,24 @@ var _ = Describe("RadioRepository", func() { ))) }) }) + + Describe("Update", func() { + It("only writes the columns sent by the client", func() { + radio := radioWithHomePage + radio.UploadedImage = "cover.png" + Expect(repo.Put(&radio)).To(Succeed()) + + persistable := repo.(rest.Persistable) + Expect(persistable.Update(radio.ID, &model.Radio{Name: "Renamed"}, "name")).To(Succeed()) + + item, err := repo.Get(radio.ID) + Expect(err).To(BeNil()) + Expect(item.Name).To(Equal("Renamed")) + Expect(item.UploadedImage).To(Equal("cover.png")) + Expect(item.StreamUrl).To(Equal(radio.StreamUrl)) + Expect(item.HomePageUrl).To(Equal(radio.HomePageUrl)) + }) + }) }) Describe("Regular User", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index f49e1bc4f..5530d2568 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -560,13 +560,11 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } -// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the -// row identified by id: only the requested colsToUpdate (or all columns when none are specified), -// dropping columns that must never be overwritten on update (created_at, birth_time). -func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { +// selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are +// specified), dropping columns that must never be overwritten on update (created_at, birth_time). +func selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any { updateValues := map[string]any{} - // This is a map of the columns that need to be updated, if specified c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { return toSnakeCase(s), struct{}{} }) @@ -576,7 +574,6 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string } } - updateValues["id"] = id delete(updateValues, "created_at") // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now // TODO move to mediafile_repository when each repo has its own upsert method @@ -584,6 +581,12 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string return updateValues } +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := selectUpdateColumns(values, colsToUpdate...) + updateValues["id"] = id + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 3f0e576e9..1a16a7e0b 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -14,9 +14,10 @@ import ( type MockLibraryRepo struct { model.LibraryRepository - Data map[int]model.Library - Err error - PutFn func(*model.Library) error // Allow custom Put behavior for testing + Data map[int]model.Library + Err error + PutFn func(*model.Library) error // Allow custom Put behavior for testing + PutCols []string } func (m *MockLibraryRepo) SetData(data model.Libraries) { @@ -90,7 +91,8 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } -func (m *MockLibraryRepo) Put(library *model.Library) error { +func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) error { + m.PutCols = colsToUpdate if m.PutFn != nil { return m.PutFn(library) } From 1f861d27efd97fa7df2b7a6c3e83e665e43270a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 31 Aug 2026 21:27:43 -0400 Subject: [PATCH 491/596] fix(plugins): build public URLs on the caller's address instead of localhost (#6059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugins): build public URLs on the caller's address instead of localhost The artwork host service had no `*http.Request`, so it passed `nil` to `publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that produced `http://localhost/share/img/...`, which is useless to anything outside the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so it fell back to the Navidrome logo instead of the real cover art. `serverAddressMiddleware` already works out the client-facing scheme and host from the `X-Forwarded-*` headers. It now also records them in the request context, and `publicurl` takes a `context.Context` instead of an `*http.Request` so any caller can reach them. Extism passes the caller's context through to host functions, so plugins invoked during a request now get a reachable URL with no configuration. Switching the parameter also removes the need for a second, parallel entry point: the package previously wanted only a scheme, a host, and a context, and took a whole request to get them. `AbsoluteURL` no longer dereferences a possibly-nil request on its parse-error path. Plugin calls that start from `context.Background()` (scheduler and websocket callbacks, the buffered scrobble drain) still fall back to localhost, since they have no request to learn from. A debug log now points at `ShareURL` when that happens. * fix(publicurl): include the configured port in the localhost fallback The last-resort fallback built `http://localhost/...`, which points at port 80 and so is unreachable for a server listening anywhere else — the default 4533 included. Use `conf.Server.Port` so a consumer on the same machine can actually fetch the URL. * fix(publicurl): use https in the localhost fallback when TLS is configured The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL advertised a URL it does not answer on. Mirror the server's own switch, which requires both a certificate and a key. * refactor(publicurl): tidy the localhost fallback and its tests Use gg.If for the fallback scheme so it reads as an expression, like the BaseScheme branch above it, instead of assigning http and overwriting it. Drop two tests the ctx refactor left redundant: one asserted PublicURL "works without a request" but became a byte-identical copy of the ShareURL spec once the *http.Request parameter went away, and the two port specs differed only in the integer, where the non-default port is the stronger assertion. * refactor(conf): add TLSEnabled and use it instead of repeating the predicate Whether the server speaks HTTPS was decided inline in three unconnected places. This PR added the third, in a URL-building package that has no business inferring the transport config. Move the rule to conf, next to the fields it derives from, and call it from publicurl and the insights collector. server.Run keeps its own expression: it takes the certificate and key as parameters, and its test passes values that do not come from the config. --- conf/configuration.go | 6 ++ conf/configuration_test.go | 27 ++++++++ core/metrics/insights.go | 2 +- core/publicurl/publicurl.go | 41 +++++------ core/publicurl/publicurl_test.go | 113 ++++++++++++++++++++----------- model/request/request.go | 21 ++++++ model/request/request_test.go | 31 +++++++++ plugins/host_artwork.go | 16 ++--- plugins/host_artwork_test.go | 11 ++- server/middlewares.go | 2 + server/middlewares_test.go | 29 ++++++++ server/public/handle_shares.go | 6 +- server/public/public.go | 5 +- server/subsonic/browsing.go | 12 ++-- server/subsonic/helpers.go | 4 +- server/subsonic/searching.go | 2 +- server/subsonic/sharing.go | 2 +- 17 files changed, 245 insertions(+), 85 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index df22e4ae2..b9c9f027e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -315,6 +315,12 @@ var currentGOOS = func() string { return runtime.GOOS } +// TLSEnabled reports whether the server serves HTTPS. Both halves are required, +// so callers cannot infer it from the certificate alone. +func (c *configOptions) TLSEnabled() bool { + return c.TLSCert != "" && c.TLSKey != "" +} + var ( Server = &configOptions{} hooks []func() diff --git a/conf/configuration_test.go b/conf/configuration_test.go index ac6477572..344ab3595 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -453,3 +454,29 @@ var _ = Describe("Configuration", func() { Entry("JSON format", "json"), ) }) + +var _ = Describe("TLSEnabled", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("is false when neither the certificate nor the key is set", func() { + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is true when both the certificate and the key are set", func() { + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeTrue()) + }) + + It("is false when only the certificate is set", func() { + conf.Server.TLSCert = "cert.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is false when only the key is set", func() { + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) +}) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 706df6559..d952f517a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -198,7 +198,7 @@ var staticData = sync.OnceValue(func() insights.Data { // Config info data.Config.LogLevel = conf.Server.LogLevel data.Config.LogFileConfigured = conf.Server.LogFile != "" - data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" + data.Config.TLSConfigured = conf.Server.TLSEnabled() data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index b0865e78b..345606324 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -2,7 +2,7 @@ package publicurl import ( "cmp" - "net/http" + "context" "net/url" "path" "strconv" @@ -13,35 +13,36 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" ) // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. -func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { +func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string { token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { params.Add("size", strconv.Itoa(size)) } - return PublicURL(req, uri, params) + return PublicURL(ctx, uri, params) } // PublicURL builds a full URL for public-facing resources. -// It uses ShareURL from config if available, otherwise falls back to extracting -// the scheme and host from the provided http.Request. -// If req is nil and ShareURL is not set, it defaults to http://localhost. -func PublicURL(req *http.Request, u string, params url.Values) string { +// It uses ShareURL from config if available, otherwise falls back to the address the +// client used to reach the server, recorded in the context. +func PublicURL(ctx context.Context, u string, params url.Values) string { if conf.Server.ShareURL == "" { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } shareUrl, err := url.Parse(conf.Server.ShareURL) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl, err := url.Parse(u) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host @@ -55,13 +56,12 @@ func PublicURL(req *http.Request, u string, params url.Values) string { } // AbsoluteURL builds an absolute URL from a relative path. -// It uses BaseHost/BaseScheme from config if available, otherwise extracts -// the scheme and host from the http.Request. -// If req is nil and BaseHost is not set, it defaults to http://localhost. -func AbsoluteURL(req *http.Request, u string, params url.Values) string { +// It uses BaseHost/BaseScheme from config if available, otherwise the address the client +// used to reach the server, recorded in the context by the server's address middleware. +func AbsoluteURL(ctx context.Context, u string, params url.Values) string { buildUrl, err := url.Parse(u) if err != nil { - log.Error(req.Context(), "Failed to parse URL path", "url", u, err) + log.Error(ctx, "Failed to parse URL path", "url", u, err) return "" } if strings.HasPrefix(u, "/") { @@ -69,12 +69,13 @@ func AbsoluteURL(req *http.Request, u string, params url.Values) string { if conf.Server.BaseHost != "" { buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") buildUrl.Host = conf.Server.BaseHost - } else if req != nil { - buildUrl.Scheme = req.URL.Scheme - buildUrl.Host = req.Host + } else if scheme, host, ok := request.ServerAddressFrom(ctx); ok { + buildUrl.Scheme = scheme + buildUrl.Host = host } else { - buildUrl.Scheme = "http" - buildUrl.Host = "localhost" + log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u) + buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http") + buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port) } } if len(params) > 0 { diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 7e9ee8b8e..0f8ea806c 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -1,7 +1,7 @@ package publicurl_test import ( - "net/http" + "context" "net/url" "testing" @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,24 +37,17 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses ShareURL as the base", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/path/to/resource", nil) + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://share.example.com/path/to/resource")) }) It("includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"300"}, "format": []string{"png"}} - result := publicurl.PublicURL(r, "/image/123", params) + result := publicurl.PublicURL(context.Background(), "/image/123", params) Expect(result).To(ContainSubstring("https://share.example.com/image/123")) Expect(result).To(ContainSubstring("size=300")) Expect(result).To(ContainSubstring("format=png")) }) - - It("works without a request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("https://share.example.com/path/to/resource")) - }) }) When("ShareURL includes a path", func() { @@ -62,21 +56,19 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends the ShareURL path to the resource", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) It("prepends the ShareURL path and includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"600"}} - result := publicurl.PublicURL(r, "/share/img/hash", params) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", params) Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) }) It("handles trailing slash in ShareURL path", func() { conf.Server.ShareURL = "https://example.com/navi/" - result := publicurl.PublicURL(nil, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) }) @@ -87,15 +79,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("falls back to AbsoluteURL with request", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/test", nil) - r.Host = "myserver.com" - result := publicurl.PublicURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "myserver.com") + result := publicurl.PublicURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://myserver.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 4533 + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) }) @@ -109,15 +101,13 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses BaseHost and BaseScheme", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://configured.example.com/path/to/resource")) }) It("defaults to http scheme if BaseScheme is empty", func() { conf.Server.BaseScheme = "" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("http://configured.example.com/path/to/resource")) }) }) @@ -129,15 +119,30 @@ var _ = Describe("Public URL Utilities", func() { }) It("extracts host from request", func() { - r, _ := http.NewRequest("GET", "https://request.example.com/test", nil) - r.Host = "request.example.com" - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "request.example.com") + result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://request.example.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 8080 + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:8080/path/to/resource")) + }) + + It("uses https in the fallback when TLS is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("https://localhost:4533/path/to/resource")) + }) + + It("stays on http when only the certificate is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) @@ -149,24 +154,21 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends BasePath to the URL", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) }) }) It("passes through absolute URLs unchanged", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil) + result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil) Expect(result).To(Equal("https://other.example.com/path")) }) It("includes query parameters", func() { conf.Server.BaseHost = "example.com" conf.Server.BaseScheme = "https" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"key": []string{"value"}} - result := publicurl.AbsoluteURL(r, "/path", params) + result := publicurl.AbsoluteURL(context.Background(), "/path", params) Expect(result).To(Equal("https://example.com/path?key=value")) }) }) @@ -180,20 +182,51 @@ var _ = Describe("Public URL Utilities", func() { It("generates a URL with the artwork token", func() { artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).To(HavePrefix("https://share.example.com/share/img/")) }) It("includes size parameter when provided", func() { artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) - result := publicurl.ImageURL(nil, artID, 300) + result := publicurl.ImageURL(context.Background(), artID, 300) Expect(result).To(ContainSubstring("size=300")) }) It("omits size parameter when zero", func() { artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).ToNot(ContainSubstring("size=")) }) }) + + Describe("ImageURL address precedence", func() { + var artID model.ArtworkID + + BeforeEach(func() { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) + }) + + It("uses the address of the request that triggered the call", func() { + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 300) + Expect(result).To(HavePrefix("https://music.example.com/share/img/")) + Expect(result).To(ContainSubstring("size=300")) + }) + + It("prefers ShareURL over the address in the context", func() { + conf.Server.ShareURL = "https://share.example.com" + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 0) + Expect(result).To(HavePrefix("https://share.example.com/share/img/")) + }) + + It("falls back to localhost on the configured port when no address is available", func() { + conf.Server.Port = 4533 + result := publicurl.ImageURL(context.Background(), artID, 0) + Expect(result).To(HavePrefix("http://localhost:4533/share/img/")) + }) + }) }) diff --git a/model/request/request.go b/model/request/request.go index 2b1cfb9ef..33c49f797 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -20,6 +20,7 @@ const ( ReverseProxyIp = contextKey("reverseProxyIp") InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins TokenEpochHolder = contextKey("tokenEpochHolder") + ServerAddress = contextKey("serverAddress") ) var allKeys = []contextKey{ @@ -32,6 +33,7 @@ var allKeys = []contextKey{ ClientUniqueId, ReverseProxyIp, InternalAuth, + ServerAddress, } func WithUser(ctx context.Context, u model.User) context.Context { @@ -70,6 +72,25 @@ func WithInternalAuth(ctx context.Context, username string) context.Context { return context.WithValue(ctx, InternalAuth, username) } +// serverAddress is the public scheme and host the client used to reach this server, +// so code running without an http.Request can still build absolute URLs. +type serverAddress struct { + scheme string + host string +} + +func WithServerAddress(ctx context.Context, scheme, host string) context.Context { + return context.WithValue(ctx, ServerAddress, serverAddress{scheme: scheme, host: host}) +} + +func ServerAddressFrom(ctx context.Context) (scheme, host string, ok bool) { + a, ok := ctx.Value(ServerAddress).(serverAddress) + if !ok || a.host == "" { + return "", "", false + } + return a.scheme, a.host, true +} + func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok diff --git a/model/request/request_test.go b/model/request/request_test.go index ef9af8231..da819f9c0 100644 --- a/model/request/request_test.go +++ b/model/request/request_test.go @@ -38,3 +38,34 @@ var _ = Describe("Token epoch holder", func() { Expect(ok).To(BeFalse()) }) }) + +var _ = Describe("Server address", func() { + It("reports nothing when unset", func() { + _, _, ok := ServerAddressFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) + + It("round-trips the scheme and host", func() { + ctx := WithServerAddress(context.TODO(), "https", "music.example.com") + + scheme, host, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(scheme).To(Equal("https")) + Expect(host).To(Equal("music.example.com")) + }) + + It("reports nothing when the host is empty", func() { + ctx := WithServerAddress(context.TODO(), "https", "") + + _, _, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("is carried over to a background context by AddValues", func() { + reqCtx := WithServerAddress(context.TODO(), "https", "music.example.com") + + _, host, ok := ServerAddressFrom(AddValues(context.Background(), reqCtx)) + Expect(ok).To(BeTrue()) + Expect(host).To(Equal("music.example.com")) + }) +}) diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go index 49b9a285d..7a19eddb9 100644 --- a/plugins/host_artwork.go +++ b/plugins/host_artwork.go @@ -14,24 +14,24 @@ func newArtworkService() host.ArtworkService { return &artworkServiceImpl{} } -func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 218d3d892..50a1a0335 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -122,7 +123,7 @@ var _ = Describe("ArtworkService", Ordered, func() { Size: size, } inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_artwork", inputBytes) + _, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes) if err != nil { return "", err } @@ -189,6 +190,14 @@ var _ = Describe("ArtworkService", Ordered, func() { Expect(artID.ID).To(Equal("al-456")) }) + It("uses the address of the request that triggered the plugin", func() { + ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com") + + url, err := callTestArtwork(ctx, "track", "mf-789", 300) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(HavePrefix("https://music.example.com/share/img/")) + }) + It("should handle unknown artwork type", func() { _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) Expect(err).To(HaveOccurred()) diff --git a/server/middlewares.go b/server/middlewares.go index 23e11eaa6..b710b4068 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -208,6 +208,8 @@ func serverAddressMiddleware(h http.Handler) http.Handler { if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme + // Recorded so code running without the request (e.g. plugins) can build public URLs. + r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost)) } // Call the next handler in the chain with the modified request and response. diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 5cecba7d5..4ba9a853b 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -69,10 +70,15 @@ var _ = Describe("middlewares", func() { middleware http.Handler recorder *httptest.ResponseRecorder req *http.Request + gotScheme string + gotHost string + gotOK bool ) BeforeEach(func() { + gotScheme, gotHost, gotOK = "", "", false nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context()) w.WriteHeader(http.StatusOK) }) middleware = serverAddressMiddleware(nextHandler) @@ -89,6 +95,13 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("example.com")) Expect(req.URL.Scheme).To(Equal("http")) }) + + It("should record the address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("http")) + Expect(gotHost).To(Equal("example.com")) + }) }) Context("with X-Forwarded-Host header", func() { @@ -143,6 +156,22 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("forwarded.example.com")) Expect(req.URL.Scheme).To(Equal("https")) }) + + It("should record the forwarded address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("https")) + Expect(gotHost).To(Equal("forwarded.example.com")) + }) + + It("lets a handler build a public URL on the forwarded address", func() { + var got string + serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil) + })).ServeHTTP(recorder, req) + + Expect(got).To(Equal("https://forwarded.example.com/share/img/token")) + }) }) Context("with multiple values in X-Forwarded-Host header", func() { diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 13a7e4c32..d67cfe456 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -81,8 +81,8 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s } func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { - s.URL = ShareURL(r, s.ID) - s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) + s.URL = ShareURL(r.Context(), s.ID) + s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -92,7 +92,7 @@ func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { for i := range s.Tracks { id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) + s.Tracks[i].Path = publicurl.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil) } return &s } diff --git a/server/public/public.go b/server/public/public.go index 18867e1c4..142c474bd 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -1,6 +1,7 @@ package public import ( + "context" "net/http" "path" @@ -59,7 +60,7 @@ func (pub *Router) routes() http.Handler { return r } -func ShareURL(r *http.Request, id string) string { +func ShareURL(ctx context.Context, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicurl.PublicURL(r, uri, nil) + return publicurl.PublicURL(ctx, uri, nil) } diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d32a5c857..ac0edb69f 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -231,9 +231,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description if !album.ImageAbsent { - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 1200) } response.AlbumInfo.LastFmUrl = album.ExternalUrl @@ -298,9 +298,9 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography if !artist.ImageAbsent { - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + base.SmallImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 1200) } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e367cdee5..2d9d53b18 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -112,7 +112,7 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -132,7 +132,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { UserRating: int32(a.Rating), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index cc787f041..35233a98f 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -116,7 +116,7 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), } if !artist.ImageAbsent { - a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + a.ArtistImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 540ae79d7..36124c40b 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -31,7 +31,7 @@ func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { resp := responses.Share{ ID: share.ID, - Url: public.ShareURL(r, share.ID), + Url: public.ShareURL(r.Context(), share.ID), Description: share.Description, Username: share.Username, Created: share.CreatedAt, From c9385fbb6b2e985d6c03034cf44eb05237c7e417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 31 Aug 2026 21:42:10 -0400 Subject: [PATCH 492/596] test(plugins): build test plugins in Go instead of shelling out to make (#6060) * test(plugins): build test plugins in Go instead of shelling out to make The plugins suite built its .ndp test packages by running `make -C plugins/testdata`, which needs make and zip on the PATH. That is the reason the 26 WASM-dependent spec files are tagged //go:build !windows. buildTestPlugins now does the same work in Go: the same mtime check make performed, `GOOS=wasip1 GOARCH=wasm go build` per plugin, and archive/zip for the package. TinyGo was already optional and unused in CI, so nothing is lost there. The first plugin builds on its own so the shared wasip1 stdlib and PDK objects land in the build cache before the rest fan out: on a cold cache that is 2.2s against 3.4s for the sequential make and 7.3s for an unrestrained fan-out. Packaging moved into a writeNdp helper shared with createTestPackage, which was already writing the same two-entry archive. Entries are written in a fixed order, so the .ndp bytes are now reproducible; the loader hashes those bytes, and `zip` also stored file mtimes, so the previous packages differed on every rebuild. The Makefile is unchanged and still works for building the plugins by hand. Removing the !windows tags is a separate step, once CI is green here. * test(plugins): run the WASM plugin specs on Windows With the test plugins now built in Go, nothing in the suite needs a Unix toolchain, so the //go:build !windows tags come off all 25 spec files. The Windows CI job runs `go test ./...`, so it picks the suite up with no workflow change. plugins_suite_windows_test.go existed only to bootstrap the handful of specs that compiled on Windows; plugins_suite_test.go now serves both. * test(plugins): skip the planted-symlink spec where symlinks need privileges os.Symlink needs an elevated token or Developer Mode on Windows, so the unconditional Expect(...).To(Succeed()) would fail for contributors running the suite on an ordinary Windows box. The elevated GitHub runner hides this. The equivalent spec in sandbox_fs_internal_test.go already attempts the symlink and skips on error; this does the same, keeping the pin live everywhere it can run, including Windows CI. * fix(ci): stop the Windows ndpgen test failing silently The ndpgen suite builds its helper binary to %TEMP%\ndpgen-test, and Windows will not exec a file without an executable extension, so the "supports verbose mode" spec has been failing there. Nobody noticed because the Test ndpgen step ran under pwsh, which carries on after a non-zero exit and takes the step's status from the last command, so the job stayed green with a FAIL line in its log. Add the .exe suffix, and run the step under bash like the Linux job does, so a failure in any of its three commands fails the job. --- .github/workflows/pipeline.yml | 6 +- plugins/cmd/ndpgen/integration_test.go | 4 + plugins/host_artwork_test.go | 2 - plugins/host_cache_test.go | 2 - plugins/host_config_test.go | 2 - plugins/host_httpclient_test.go | 2 - plugins/host_kvstore_test.go | 2 - plugins/host_library_test.go | 2 - plugins/host_matcher_test.go | 2 - plugins/host_scheduler_test.go | 2 - plugins/host_scrobbleretriever_test.go | 2 - plugins/host_storage_test.go | 2 - plugins/host_subsonicapi_test.go | 2 - plugins/host_taskqueue_test.go | 2 - plugins/host_users_test.go | 2 - plugins/host_websocket_test.go | 2 - plugins/lyrics_adapter_test.go | 2 - plugins/manager_call_test.go | 2 - plugins/manager_loader_load_test.go | 2 - plugins/manager_readonly_test.go | 2 - plugins/manager_test.go | 2 - plugins/manager_watcher_test.go | 2 - plugins/metadata_agent_test.go | 2 - plugins/package_test.go | 50 +++++----- plugins/plugins_suite_test.go | 12 --- plugins/plugins_suite_windows_test.go | 23 ----- plugins/sandbox_fs_test.go | 6 +- plugins/scrobbler_adapter_test.go | 2 - plugins/sonic_similarity_adapter_test.go | 2 - plugins/testdata_builder_test.go | 119 +++++++++++++++++++++++ 30 files changed, 156 insertions(+), 110 deletions(-) delete mode 100644 plugins/plugins_suite_windows_test.go create mode 100644 plugins/testdata_builder_test.go diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 9da99e864..37b532eb0 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -237,12 +237,12 @@ jobs: run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v - name: Test ndpgen - shell: pwsh + shell: bash run: | - cd plugins\cmd\ndpgen + cd plugins/cmd/ndpgen go test -shuffle=on -v go build -o ndpgen.exe . - .\ndpgen.exe --help + ./ndpgen.exe --help js: name: Test JS code diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index d8bc5859d..bbe886f8d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" . "github.com/onsi/ginkgo/v2" @@ -32,6 +33,9 @@ var _ = Describe("ndpgen CLI", Ordered, func() { // Build the ndpgen binary ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") + if runtime.GOOS == "windows" { + ndpgenBin += ".exe" // Windows refuses to exec a file without it + } cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") cmd.Dir = mustGetWd(GinkgoT()) output, err := cmd.CombinedOutput() diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 50a1a0335..311bf21d5 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 5e91b04ec..925856701 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index b296d29fb..80b06efe1 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 27e92d59d..4eb34a247 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 997409146..9af3fcbcd 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index eb5b17a02..00a953b24 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go index 9d44ee581..f0967628c 100644 --- a/plugins/host_matcher_test.go +++ b/plugins/host_matcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index ca53aed56..6940d8820 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index ab817c282..aa92c9eb5 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go index d6f895bff..9fc58c396 100644 --- a/plugins/host_storage_test.go +++ b/plugins/host_storage_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index c3d9ffe8a..4b941bc43 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index d459fd69b..72fb4cccd 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 42f6a3032..1721d3ee2 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 2aa85cd21..9f2d20bef 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..fe15e40b2 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 3e64f1cee..5e2c6385e 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go index 8f35548af..eb004276e 100644 --- a/plugins/manager_loader_load_test.go +++ b/plugins/manager_loader_load_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go index 9d16b76e9..019b14fbf 100644 --- a/plugins/manager_readonly_test.go +++ b/plugins/manager_readonly_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 9b6f7ea39..6cf90994a 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 5b5ffca02..99326bde1 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index a7a0aa8b8..0a7b6c1e2 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/package_test.go b/plugins/package_test.go index 953797750..1539d8df7 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -2,8 +2,11 @@ package plugins import ( "archive/zip" + "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" @@ -266,6 +269,14 @@ func (h *testZipHelper) close() error { // createTestPackage creates an .ndp package file from a manifest and wasm bytes. // This is primarily used for testing. func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error { + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes)) +} + +func writeNdp(ndpPath string, manifest, wasm io.Reader) error { f, err := os.Create(ndpPath) if err != nil { return fmt.Errorf("creating package file: %w", err) @@ -273,30 +284,23 @@ func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) err defer f.Close() zw := zip.NewWriter(f) - defer zw.Close() - - // Write manifest.json - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("marshaling manifest: %w", err) + add := func(name string, r io.Reader) error { + w, err := zw.Create(name) + if err != nil { + return fmt.Errorf("creating %s in package: %w", name, err) + } + if _, err := io.Copy(w, r); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + return nil } - - mw, err := zw.Create(manifestFileName) - if err != nil { - return fmt.Errorf("creating manifest in zip: %w", err) + // Entry order is fixed: the loader hashes the package bytes, so they must + // be reproducible across rebuilds. + if err := add(manifestFileName, manifest); err != nil { + return err } - if _, err := mw.Write(manifestBytes); err != nil { - return fmt.Errorf("writing manifest: %w", err) + if err := add(wasmFileName, wasm); err != nil { + return err } - - // Write plugin.wasm - ww, err := zw.Create(wasmFileName) - if err != nil { - return fmt.Errorf("creating wasm in zip: %w", err) - } - if _, err := ww.Write(wasmBytes); err != nil { - return fmt.Errorf("writing wasm: %w", err) - } - - return nil + return errors.Join(zw.Close(), f.Close()) } diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 2dae65d00..cc1b45f97 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -7,15 +5,12 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strings" "testing" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -51,13 +46,6 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(path string) { - start := time.Now() - out, err := exec.Command("make", "-C", path).CombinedOutput() - fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s:\n%s", time.Since(start), out) - Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") -} - // createTestManager creates a new plugin Manager with the given plugin config. // It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager. // Returns the manager, temp directory path, and a cleanup function. diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go deleted file mode 100644 index ed43bdcc3..000000000 --- a/plugins/plugins_suite_windows_test.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows - -package plugins - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Runs the subset of plugin specs compiled on Windows (files without the -// //go:build !windows tag): capabilities, manager_cache, manager_plugin, -// manifest, package. WASM-runtime-dependent specs live in !windows-tagged -// files and aren't reached here. -func TestPlugins(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Plugins Suite") -} diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go index ea033283f..0dd9d15ab 100644 --- a/plugins/sandbox_fs_test.go +++ b/plugins/sandbox_fs_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -134,7 +132,9 @@ var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() // Accepted residual, pinned so a future tightening can't happen silently It("still follows a symlink planted in the mount by something else", func() { - Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed()) + if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil { + Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges + } out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 3efd5d1b1..00c9045a4 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index cb08b9acd..e08fc124f 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/testdata_builder_test.go b/plugins/testdata_builder_test.go new file mode 100644 index 000000000..746a8af22 --- /dev/null +++ b/plugins/testdata_builder_test.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildTestPlugins packages every test plugin under dir, replicating +// `make -C plugins/testdata` without needing make or zip on the PATH. +func buildTestPlugins(dir string) { + start := time.Now() + built, err := buildPackages(dir) + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") +} + +func buildPackages(dir string) ([]string, error) { + mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod")) + if err != nil || len(mods) == 0 { + return nil, err + } + pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go")) + if err != nil { + return nil, err + } + + built := make([]string, len(mods)) + errs := make([]error, len(mods)) + build := func(i int) { + pluginDir := filepath.Dir(mods[i]) + var rebuilt bool + if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt { + built[i] = filepath.Base(pluginDir) + } + } + + // The first build populates the wasip1 stdlib and PDK objects every plugin + // shares; fanning out before it lands makes each one compile them again. + build(0) + var wg sync.WaitGroup + for i := range mods[1:] { + wg.Go(func() { build(i + 1) }) + } + wg.Wait() + return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...) +} + +func buildPackage(dir string, pdkTime time.Time) (bool, error) { + sourceTime, err := newestModTime(dir) + if err != nil { + return false, err + } + pkg := dir + PackageExtension + if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) { + return false, nil + } + + wasm, err := filepath.Abs(pkg + ".build.wasm") + if err != nil { + return false, err + } + defer os.Remove(wasm) + // -buildvcs=false keeps the bytes stable across commits, so the suite's + // wazero compilation cache still hits after a rebuild. + cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + if out, err := cmd.CombinedOutput(); err != nil { + return false, fmt.Errorf("building %s: %w\n%s", dir, err, out) + } + + tmp := pkg + ".build.ndp" + defer os.Remove(tmp) + if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil { + return false, fmt.Errorf("packaging %s: %w", dir, err) + } + return true, os.Rename(tmp, pkg) +} + +func packageFiles(pkg, manifest, wasm string) error { + m, err := os.Open(manifest) + if err != nil { + return err + } + defer m.Close() + w, err := os.Open(wasm) + if err != nil { + return err + } + defer w.Close() + return writeNdp(pkg, m, w) +} + +func newestModTime(root string) (time.Time, error) { + var newest time.Time + err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + newest = utils.TimeNewest(newest, info.ModTime()) + return nil + }) + return newest, err +} From 4ed7494a3293a9e9e647897ebfb9be327efd981b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 31 Aug 2026 23:03:09 -0400 Subject: [PATCH 493/596] ci: report Go test coverage on pull requests (#6061) * ci: report Go test coverage on pull requests Adds octocov to the existing 'Test Go code' job. It reads the coverage profile, posts a PR comment with the coverage percentage and the delta against master, and writes the same report to the job summary. The master-branch report is stored as a GitHub Actions artifact, so no external service or secret is needed. * ci: merge the plugins job coverage into the same report The plugins suite runs in its own job, so its coverage was missing from the report. Both jobs now upload their profile as an artifact and a new 'Report coverage' job merges them into a single PR comment. * ci: update the coverage comment in place instead of reposting octocov's default is to collapse the previous comment and create a new one. updatePrevious edits the existing comment instead, so a PR keeps a single coverage comment across pushes. * ci: fix octocov timeout and step-time lookup Storing the report hit the 30s default timeout: scanning this repo's artifacts for the baseline consumed it first. Raise it to 5m. The step-time lookup also matched the Windows job's 'Test' step and waited for a job that was still running, so execution time was dropped from the report. Rename the step to make it unique. * ci: only store the coverage baseline from the default branch * ci: report statement coverage instead of line coverage octocov reports statement coverage for a single profile but switches to line counting when it merges several itself, which made the number disagree with 'go tool cover -func'. Merge the two job profiles into one file first, so the reported number matches what developers see locally. * ci: stop the download-link comment from clobbering the coverage report Both comments are posted by github-actions[bot], and the download-link job updated the first bot comment it found. On a new PR the coverage comment is created first, so it would be overwritten. Match on the body as well, and keep the coverage profiles out of the download list. --- .github/workflows/download-link-on-pr.yml | 11 ++++-- .github/workflows/pipeline.yml | 47 +++++++++++++++++++++-- .octocov.yml | 33 ++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 .octocov.yml diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 5b421331b..80ec18e90 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -34,16 +34,19 @@ jobs: } const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); - if (!artifacts.length) { + const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); + if (!downloadable.length) { return core.error(`No artifacts found`); } - let body = `Download the artifacts for this pull request:\n`; - for (const art of artifacts) { + const header = `Download the artifacts for this pull request:`; + let body = `${header}\n`; + for (const art of downloadable) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); + // Match on the body too: octocov also comments as github-actions[bot]. + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 37b532eb0..7625cf410 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -137,8 +137,10 @@ jobs: - name: Download dependencies run: go mod download - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$') + # Name must stay unique across the workflow: octocov matches step names + # by name across every job, and waits for each match to finish. + - name: Test with coverage + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -147,6 +149,13 @@ jobs: go build -o ndpgen . ./ndpgen --help + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-go + path: coverage.out + if-no-files-found: error + go-plugins: name: Test Go plugins runs-on: ubuntu-latest @@ -169,7 +178,39 @@ jobs: restore-keys: wazero-${{ runner.os }}- - name: Test plugins - run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/ + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ + + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-plugins + path: coverage.out + if-no-files-found: error + + coverage: + name: Report coverage + runs-on: ubuntu-latest + needs: [go, go-plugins] + permissions: + contents: read + pull-requests: write + actions: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + pattern: octocov-* + + # Merge here rather than letting octocov do it: octocov reports statement + # coverage for a single profile, but switches to line counting for several. + - name: Merge coverage profiles + run: | + echo "mode: atomic" > coverage.out + awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ + octocov-*/coverage.out | sort >> coverage.out + + - uses: k1LoW/octocov-action@v1 go-windows: name: Test Go code (Windows) diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 000000000..397b5b364 --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,33 @@ +# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov +# The 30s default is not enough: scanning this repo's artifacts for the baseline +# eats most of it, leaving none for the report upload. +timeout: 5m +coverage: + # A single pre-merged profile: octocov reports statements for one path, but + # switches to line counting when it merges several itself. + paths: + - coverage.out +codeToTestRatio: + code: + - '**/*.go' + - '!**/*_test.go' + - '!**/*_gen.go' + test: + - '**/*_test.go' +testExecutionTime: + if: true + steps: + - Test with coverage + - Test plugins +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} +comment: + if: is_pull_request + updatePrevious: true +summary: + if: true +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} From 09867e5cc18678cb9ae9579c912dc54a597bf651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 1 Sep 2026 07:32:28 -0400 Subject: [PATCH 494/596] ci: comment coverage on pull requests from forks (#6065) * ci: comment coverage on pull requests from forks A pull_request run from a fork gets a read-only GITHUB_TOKEN, so octocov could not post its comment: it logged a 403 and exited 0, leaving the job green and the PR silent. The 'permissions:' block cannot grant what the token does not have. The comment now comes from a workflow_run workflow, which runs on the base repository and does get a write token. The pipeline job keeps the job summary and the default-branch baseline, and hands the merged profile and the PR number to it as an artifact. A workflow_run job otherwise looks like a push to the default branch, so octocov is pointed back at the pull request and at the run that produced the profile via its OCTOCOV_ environment overrides. Without the run id override the test execution time would be read from the wrong run; without the ref override a fork's coverage would be stored as the master baseline. The job holds a write token, so it reads .octocov.yml from the base branch rather than from the fork. * ci: stop checking out the fork in the coverage comment workflow CodeQL flagged the pull request checkout as untrusted code in a privileged context (actions/untrusted-checkout/high): the job holds a write token. The checkout existed only so the code-to-test ratio would reflect the pull request, which does not justify the alert. The workflow now checks out just .octocov.yml from the base branch, and the ratio is skipped when reporting from there. Coverage and its delta against master, the metrics that motivated the report, are unaffected: they come from the profile the pipeline uploads. * ci: treat the coverage artifact as untrusted input A pull_request run executes the fork's own copy of pipeline.yml, so every file in the octocov-pr artifact is attacker-controlled. The artifact was extracted into the workspace root, on top of the base-branch checkout, and download-artifact truncates existing files. A fork could therefore replace .octocov.yml before octocov loaded it. That is not only a config swap. config.Load expands ${VAR} from the job environment and the action sets OCTOCOV_GITHUB_TOKEN, so a crafted comment.message posts the privileged job's token into a public comment; a body: section rewrites a pull request description, which pull-requests: write allows. The artifact now lands in a subdirectory and only coverage.out is copied out, after pr_number is checked to be digits and the named pull request's head is confirmed to be the sha that triggered this run. Without that check the artifact could aim the comment at any open pull request, and unvalidated content reached GITHUB_OUTPUT. --- .github/workflows/coverage-on-pr.yml | 60 ++++++++++++++++++++++++++++ .github/workflows/pipeline.yml | 17 +++++++- .octocov.yml | 7 +++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/coverage-on-pr.yml diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml new file mode 100644 index 000000000..03260cacc --- /dev/null +++ b/.github/workflows/coverage-on-pr.yml @@ -0,0 +1,60 @@ +name: Report coverage on PR +on: + workflow_run: + workflows: ['Pipeline: Test, Lint, Build'] + types: [completed] +jobs: + comment: + name: Comment coverage report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + env: + COVERAGE_COMMENT: 'true' + steps: + # Only the config, from the base branch: this job holds a write token, so + # it must never check out the fork. + - name: Check out the octocov config + uses: actions/checkout@v7 + with: + sparse-checkout: .octocov.yml + sparse-checkout-cone-mode: false + persist-credentials: false + + # Into a subdirectory. A pull_request run executes the fork's own copy of + # pipeline.yml, so every file in here is attacker-controlled. + - uses: actions/download-artifact@v8 + with: + name: octocov-pr + path: untrusted + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify the artifact and take the coverage profile + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') + case "$number" in ''|*[!0-9]*) + echo "::error::artifact pr_number is not a number"; exit 1;; + esac + sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) + if [ "$sha" != "$HEAD_SHA" ]; then + echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 + fi + cp untrusted/coverage.out coverage.out + echo "number=$number" >> "$GITHUB_OUTPUT" + + - uses: k1LoW/octocov-action@v1 + env: + # A workflow_run job looks like a push to the default branch. Point + # octocov back at the pull request and at the run that produced it. + GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} + OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge + OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 7625cf410..aa8e29e49 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -193,8 +193,9 @@ jobs: needs: [go, go-plugins] permissions: contents: read - pull-requests: write actions: write + env: + COVERAGE_COMMENT: 'false' steps: - uses: actions/checkout@v7 @@ -212,6 +213,20 @@ jobs: - uses: k1LoW/octocov-action@v1 + - name: Save the PR number for the comment workflow + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > pr_number + + - name: Upload the merged profile for the comment workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: octocov-pr + path: | + coverage.out + pr_number + if-no-files-found: error + go-windows: name: Test Go code (Windows) runs-on: windows-2022 diff --git a/.octocov.yml b/.octocov.yml index 397b5b364..52d4ff9bf 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,6 +8,9 @@ coverage: paths: - coverage.out codeToTestRatio: + # Needs the pull request's own source, which the comment workflow must not + # check out: it holds a write token. + if: env.COVERAGE_COMMENT != 'true' code: - '**/*.go' - '!**/*_test.go' @@ -23,7 +26,9 @@ diff: datastores: - artifact://${GITHUB_REPOSITORY} comment: - if: is_pull_request + # Only the 'Report coverage on PR' workflow sets this: a pull_request run from + # a fork gets a read-only token, so commenting from here 403s. + if: env.COVERAGE_COMMENT == 'true' updatePrevious: true summary: if: true From 3784fd0ea7c0d902a513777bef9f2c8f015ad42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 1 Sep 2026 11:07:58 -0400 Subject: [PATCH 495/596] fix(artwork): honor a provider's explicit retry-later delay in the circuit breaker (#6056) An explicit RetryLaterError now opens the agent's breaker immediately for the provider's own delay, instead of counting it as one generic failure that needs five to open and then always probes after a fixed minute. --- core/artwork/gate.go | 19 ++++++++++++++++++- core/artwork/gate_test.go | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index ebb65bcbe..aa2ad8f2f 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -1,6 +1,7 @@ package artwork import ( + "cmp" "context" "errors" "io" @@ -95,6 +96,9 @@ type breaker struct { // generation identifies the current open episode, so an answer from a call admitted before // the breaker opened cannot be mistaken for evidence that it has recovered. generation int + // probeAfter overrides the probe delay for the current episode when a provider named its own + // back-off; zero falls back to breakerProbeAfter. + probeAfter time.Duration } func newBreaker() *breaker { return &breaker{} } @@ -107,7 +111,7 @@ func (b *breaker) allow() (bool, int) { if b.failures < breakerThreshold { return true, 0 } - if time.Since(b.openedAt) >= breakerProbeAfter { + if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) { b.openedAt = time.Now() // start a fresh probe window so only one caller passes return true, b.generation } @@ -121,11 +125,24 @@ func (b *breaker) record(name string, gen int, err error) { } b.mu.Lock() defer b.mu.Unlock() + // An explicit back-off is a definitive "stop for this long", so it opens the breaker at once + // with the provider's own delay instead of waiting for the failure threshold. + if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 { + b.recoveries = 0 + b.failures = breakerThreshold + b.openedAt = time.Now() + b.probeAfter = retry.RetryIn + b.generation++ + log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name, + "probeAfter", retry.RetryIn) + return + } if isTransientExternal(err) { b.recoveries = 0 b.failures++ if b.failures == breakerThreshold { b.openedAt = time.Now() + b.probeAfter = 0 b.generation++ log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go index abe723508..a954d4cac 100644 --- a/core/artwork/gate_test.go +++ b/core/artwork/gate_test.go @@ -2,7 +2,9 @@ package artwork import ( "errors" + "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -40,4 +42,15 @@ var _ = Describe("breaker", func() { Expect(allowed(b)).To(BeFalse(), "answers from calls admitted before the breaker opened must not close it") }) + + It("opens at once when a provider asks to retry later, honoring its delay", func() { + b := newBreaker() + Expect(allowed(b)).To(BeTrue(), "starts closed") + + // A single explicit back-off opens the breaker without reaching the failure threshold. + b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second}) + + Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately") + Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval") + }) }) From 88cd1c39374783e30ac7b5bcd9401789243a6cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 1 Sep 2026 17:17:52 -0400 Subject: [PATCH 496/596] fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes. --- adapters/deezer/client.go | 44 +++++++++++++++++------ adapters/deezer/client_test.go | 34 +++++++++++++++++- adapters/deezer/deezer.go | 3 -- adapters/deezer/deezer_test.go | 17 +++++++++ adapters/deezer/responses.go | 8 ++--- adapters/deezer/responses_test.go | 2 +- core/scrobbler/buffered_scrobbler_test.go | 7 ++-- 7 files changed, 90 insertions(+), 25 deletions(-) diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index d51f65dd9..03f37af19 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -13,15 +13,26 @@ import ( "strings" "github.com/microcosm-cc/bluemonday" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" const authBaseURL = "https://auth.deezer.com" -var ( - ErrNotFound = errors.New("deezer: not found") -) +// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200 +// and no rate-limit headers, so the body code is the only signal. +const errCodeQuota = 4 + +type deezerError struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` +} + +func (e *deezerError) Error() string { + return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message) +} type httpDoer interface { Do(req *http.Request) (*http.Response, error) @@ -56,7 +67,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A } if len(results.Data) == 0 { - return nil, ErrNotFound + return nil, agents.ErrNotFound } return results.Data, nil } @@ -74,20 +85,31 @@ func (c *client) makeRequest(req *http.Request, response any) error { return err } + // Checked before the status: a throttled request still answers 200, and decoding its body + // into a result type yields an empty one, which reads as "nothing found". + if err := parseBodyError(data); err != nil { + return err + } if resp.StatusCode != 200 { - return c.parseError(data) + return fmt.Errorf("deezer http status: (%d)", resp.StatusCode) } return json.Unmarshal(data, response) } -func (c *client) parseError(data []byte) error { - var deezerError Error - err := json.Unmarshal(data, &deezerError) - if err != nil { - return err +// parseBodyError returns the error Deezer reported in the body, or nil when it reported none. +func parseBodyError(data []byte) error { + var body errorResponse + // Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer. + _ = json.Unmarshal(data, &body) + switch { + case body.Error == nil: + return nil + case body.Error.Code == errCodeQuota: + return errors.Join(body.Error, agents.ErrRetryLater) + default: + return body.Error } - return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go index 9fa7afdd9..84d981a76 100644 --- a/adapters/deezer/client_test.go +++ b/adapters/deezer/client_test.go @@ -2,12 +2,14 @@ package deezer import ( "bytes" + "errors" "fmt" "io" "net/http" "os" "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -41,7 +43,37 @@ var _ = Describe("client", func() { }) _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(MatchError(ErrNotFound)) + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + + // Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal. + It("reports an exhausted quota as a retryable error, not as a missing artist", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound), + "a throttled lookup would otherwise settle the artist as having no image") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("Quota limit exceeded")) + }) + + It("reports a non-quota body error as a plain error", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Invalid query","code":100}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(), + "only a throttle asks the caller to come back later") }) }) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 1fa10e25c..d3570a29f 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -91,9 +91,6 @@ func isPlaceholderPicture(url string) bool { func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) - if errors.Is(err, ErrNotFound) || len(artists) == 0 { - return nil, agents.ErrNotFound - } if err != nil { return nil, err } diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 360db1f13..82d02c244 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -3,6 +3,7 @@ package deezer import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -80,6 +81,22 @@ var _ = Describe("deezerAgent", func() { Expect(artist.ID).To(Equal(2)) }) + // The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled + // lookup reaching that here would record a permanent absence. + It("surfaces an exhausted quota instead of reporting the artist as not found", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrNotFound when no result matches the name exactly", func() { httpClient.mock("https://api.deezer.com/search/artist", http.Response{ StatusCode: 200, diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go index 266c44c62..6cc95dd4b 100644 --- a/adapters/deezer/responses.go +++ b/adapters/deezer/responses.go @@ -22,12 +22,8 @@ type Artist struct { Type string `json:"type"` } -type Error struct { - Error struct { - Type string `json:"type"` - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` +type errorResponse struct { + Error *deezerError `json:"error"` } type RelatedArtists struct { diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go index a9de5c5fb..5a3fc7798 100644 --- a/adapters/deezer/responses_test.go +++ b/adapters/deezer/responses_test.go @@ -26,7 +26,7 @@ var _ = Describe("Responses", func() { Describe("Error", func() { It("parses the error response correctly", func() { - var errorResp Error + var errorResp errorResponse body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) err := json.Unmarshal(body, &errorResp) Expect(err).To(BeNil()) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index fd972e87d..16172194b 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -251,12 +251,13 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { "user1": 10 * time.Second, "user2": 45 * time.Second, }} + // Both are buffered before the drain goroutine exists: it drains once on startup, and + // seeing only one user there would park it on that user's delay, ignoring the other. + _ = buffer.Enqueue("test", "user1", "1", time.Now()) + _ = buffer.Enqueue("test", "user2", "2", time.Now()) bs := newBufferedScrobbler(ds, scr, "test") defer bs.Stop() - // user2 is enqueued directly so both are buffered before the first drain wakes. - _ = buffer.Enqueue("test", "user2", "2", time.Now()) - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) synctest.Wait() if got := scr.count.Load(); got != 2 { t.Fatalf("expected both users drained, got %d attempts", got) From b77fb45088e90c9530a17acfc34d30f1670c0314 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 1 Sep 2026 20:32:25 -0400 Subject: [PATCH 497/596] ci: exclude test helpers and generated code from the coverage report The coverage profile counted the tests/ package and the *_gen.go files, none of which are code under test: tests/ is the mock and helper package, and generated code is never hand-tested. Together they added 2926 uncounted statements at 0%, pulling the reported number down by almost 6 points (70.13% -> 75.98% on the current master profile). octocov's coverage.exclude takes doublestar globs matched against git-root-relative paths. All 26 mock_*.go files live under tests/, so the single 'tests/**' pattern covers them. --- .octocov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.octocov.yml b/.octocov.yml index 52d4ff9bf..467998b51 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -7,6 +7,10 @@ coverage: # switches to line counting when it merges several itself. paths: - coverage.out + # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + exclude: + - 'tests/**' + - '**/*_gen.go' codeToTestRatio: # Needs the pull request's own source, which the comment workflow must not # check out: it holds a write token. From 47bc3c00f3a526ba4879307a6026004c241c83d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 1 Sep 2026 20:48:41 -0400 Subject: [PATCH 498/596] fix(artwork): never retry absent artwork on its own (#6054) An absent artwork state was revisited by an hourly job, by viewing the entity, and by the startup backfill on any artwork config change. On a large library the last one queued tens of thousands of external lookups at once and got the provider to rate-limit us for hours. Nothing revisits an absent state now. Retrying is explicit: `artwork reprocess` on the CLI, or the refresh button in the UI. The config fingerprint survives only as an advisory, warning at startup and naming the command that clears it. Since absent is terminal, `artwork status` splits it into two disjoint columns, and `--source failed` targets only the ones that gave up rather than being answered. Both read through the filter CountBySource and EnqueueBySource already share, so the reported number is the set the command acts on. Also fixes the last_failure default left by 20260819204637, which marked every pre-existing absent row as failed, and removes the code the deleted retry paths orphaned. --- cmd/artwork.go | 123 +++++---- cmd/artwork_test.go | 102 ++++--- cmd/root.go | 30 +-- consts/consts.go | 9 +- core/artwork/artwork.go | 9 +- core/artwork/artwork_test.go | 17 +- core/artwork/housekeeping.go | 125 +++------ core/artwork/housekeeping_test.go | 248 ++---------------- core/artwork/worker.go | 25 +- core/artwork/worker_test.go | 30 ++- ...1225726_normalize_artwork_last_failure.sql | 9 + model/album.go | 1 - model/artist.go | 1 - model/artwork.go | 33 ++- model/mediafile.go | 2 - model/playlist.go | 1 - model/radio.go | 1 - persistence/album_repository.go | 6 +- persistence/album_repository_test.go | 4 +- persistence/artist_repository.go | 6 +- persistence/artist_repository_test.go | 4 +- persistence/artwork_hydration_test.go | 2 +- persistence/artwork_queue_repository.go | 35 +-- persistence/artwork_queue_repository_test.go | 83 +++--- persistence/mediafile_repository.go | 6 +- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 4 +- persistence/radio_repository.go | 8 - persistence/radio_repository_test.go | 12 - tests/mock_album_repo.go | 9 - tests/mock_artist_repo.go | 9 - tests/mock_artwork_queue_repo.go | 73 +----- tests/mock_mediafile_repo.go | 8 - tests/mock_playlist_repo.go | 9 - tests/mock_radio_repository.go | 9 - 35 files changed, 341 insertions(+), 718 deletions(-) create mode 100644 db/migrations/20260901225726_normalize_artwork_last_failure.sql diff --git a/cmd/artwork.go b/cmd/artwork.go index 5cd4fc146..9a64cd8c3 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -42,9 +42,10 @@ func init() { "stored trace of the last resolution; also initializes plugin agents, which may open "+ "external connections") artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") + "kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable") artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, - "only items currently resolved from these sources (e.g. folder, external:deezer, absent)") + "only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+ + "or failed for the absent ones that gave up)") artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, "report what would be queued and exit without queueing") @@ -113,7 +114,7 @@ var artworkCancelCmd = &cobra.Command{ "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + - "backfill, not to stop the worker.", + "reprocess, not to stop the worker.", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { runCancel(cmd.Context()) @@ -122,7 +123,7 @@ var artworkCancelCmd = &cobra.Command{ var artworkStatusCmd = &cobra.Command{ Use: "status", - Short: "Report the artwork queue, where artwork resolves from, and the backfill state", + Short: "Report the artwork queue, where artwork resolves from, and the config state", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { runStatus(cmd.Context()) @@ -146,9 +147,11 @@ type sourceCount struct { count int64 } +// absentCount partitions a kind's absent states: noImage was answered, failed gave up. type absentCount struct { - kind model.Kind - model.ArtworkAbsentStat + kind model.Kind + noImage int64 + failed int64 } type statusReport struct { @@ -170,16 +173,6 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 { return n } -func (r statusReport) backfillQueued() int64 { - var n int64 - for _, s := range r.queue { - if s.Priority == model.ArtworkPriorityBackfill { - n += s.Count - } - } - return n -} - func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { q := ds.ArtworkQueue(ctx) var rep statusReport @@ -188,8 +181,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) } - cutoff := time.Now().Add(-artwork.StaleAbsentAge) - for _, k := range artwork.RecheckKinds { + for _, k := range artwork.ReprocessKinds { sources, err := q.SourcesInUse(k) if err != nil { return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) @@ -201,12 +193,15 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) } rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) + // An absent state is exactly a row with no source, so it needs no second query. + if s == "" { + failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed}) + if err != nil { + return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) + } + rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed}) + } } - stat, err := q.CountAbsent(k, cutoff) - if err != nil { - return rep, fmt.Errorf("counting absent %s artwork: %w", k, err) - } - rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat}) } rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() @@ -234,19 +229,20 @@ func formatStatus(rep statusReport) string { } fmt.Fprintln(w, "\nAbsent (resolved, no image found)") - fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK") + fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED") for _, a := range rep.absent { - fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale) + fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed) } - fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n", - artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch) + fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)") + fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+ + " 'artwork reprocess --source failed' retries just those)") - fmt.Fprintln(w, "\nBackfill") - fmt.Fprintf(w, " State:\t%s\n", backfillState(rep)) + fmt.Fprintln(w, "\nConfig") + fmt.Fprintf(w, " State:\t%s\n", configState(rep)) fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) if len(rep.inputs) > 0 { - fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):") + fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):") for _, in := range rep.inputs { fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) } @@ -256,18 +252,10 @@ func formatStatus(rep statusReport) string { return sb.String() } -// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually -// already stored the new fingerprint, and "up to date" would bury the flood it is still working through. -func backfillState(rep statusReport) string { - pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup" - if n := rep.backfillQueued(); n > 0 { - if rep.stored != rep.current { - return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending) - } - return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n) - } +func configState(rep statusReport) string { if rep.stored != rep.current { - return pending + return "fingerprint changed — stored artwork keeps the old resolution; " + + "run 'artwork reprocess --all' to apply it" } return "up to date" } @@ -297,8 +285,8 @@ type artworkPriority struct { var knownPriorities = []artworkPriority{ {"bump", model.ArtworkPriorityBump}, {"scan", model.ArtworkPriorityScan}, - {"backfill", model.ArtworkPriorityBackfill}, {"recheck", model.ArtworkPriorityRecheck}, + {"backfill", model.ArtworkPriorityBackfill}, } // priorityName falls back to the number: a row written by a newer version still has to print. @@ -351,29 +339,41 @@ func runReprocess(ctx context.Context) { func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { // A source filter on its own is already a complete selection, so it does not also need a kind. if all || (len(kinds) == 0 && len(sources) > 0) { - return artwork.RecheckKinds, nil + return artwork.ReprocessKinds, nil } if len(kinds) == 0 { return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") } return parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.RecheckKinds) + return parseArtworkKind(s, artwork.ReprocessKinds) }) } -// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI. -const absentSource = "absent" +// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and +// failedSource the subset of it that gave up rather than being answered. +const ( + absentSource = "absent" + failedSource = "failed" +) func repositorySources(sources []string) []string { return slice.Map(sources, func(s string) string { - if s == absentSource { + switch s { + case absentSource: return "" + case failedSource: + return model.ArtworkSourceFailed } return s }) } -func displaySource(s string) string { return cmp.Or(s, absentSource) } +func displaySource(s string) string { + if s == model.ArtworkSourceFailed { + return failedSource + } + return cmp.Or(s, absentSource) +} type confirmFunc func(out io.Writer, total, external int64) bool @@ -447,7 +447,7 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { return nil } var inUse []string - for _, k := range artwork.RecheckKinds { + for _, k := range artwork.ReprocessKinds { found, err := q.SourcesInUse(k) if err != nil { return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) @@ -456,14 +456,16 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { } var unknown []string for _, s := range sources { - if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent + // The reserved absent and failed sources are valid even when nothing currently matches them. + if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) { unknown = append(unknown, displaySource(s)) } } if len(unknown) == 0 { return nil } - valid := slice.Map(inUse, displaySource) + // failed is accepted but never stored, so listing only what is in use would hide it. + valid := append(slice.Map(inUse, displaySource), failedSource) slices.Sort(valid) return fmt.Errorf("no artwork resolves from %s; sources in use: %s", strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) @@ -478,6 +480,18 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin return err } + // Derived from what actually drives the queries, so a filter added to this signature cannot + // silently keep stamping the fingerprint for a partial run. + markApplied := func() error { + if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) { + return nil + } + if err := artwork.MarkConfigApplied(ctx, ds); err != nil { + return fmt.Errorf("recording the applied artwork config: %w", err) + } + return nil + } + matched := make([]int64, len(kinds)) var total, external int64 for i, k := range kinds { @@ -496,8 +510,9 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin fmt.Fprintln(out, "\nDry run: nothing was queued.") return nil case total == 0: + // An empty match set still leaves nothing resolved under the old config. fmt.Fprintln(out, "Nothing was queued.") - return nil + return markApplied() case !confirm(out, total, external): fmt.Fprintln(out, "Aborted: nothing was queued.") return nil @@ -519,7 +534,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin if skipped := total - queued; skipped > 0 { fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) } - return nil + return markApplied() } func runCancel(ctx context.Context) { @@ -546,7 +561,7 @@ func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, if len(kinds) == 0 && len(priorities) == 0 { return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") } - // RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them. + // RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them. outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { return parseArtworkKind(s, artwork.RefreshableKinds) }) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index a7220d2d5..71b450914 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -3,7 +3,6 @@ package cmd import ( "context" "errors" - "fmt" "io" "strings" "time" @@ -20,20 +19,20 @@ import ( var _ = Describe("parseArtworkKind", func() { It("accepts a supported kind", func() { - k, err := parseArtworkKind("ar", artwork.RecheckKinds) + k, err := parseArtworkKind("ar", artwork.ReprocessKinds) Expect(err).ToNot(HaveOccurred()) Expect(k).To(Equal(model.KindArtistArtwork)) }) It("rejects an unknown kind and lists the valid ones", func() { - _, err := parseArtworkKind("zz", artwork.RecheckKinds) + _, err := parseArtworkKind("zz", artwork.ReprocessKinds) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ar")) Expect(err.Error()).To(ContainSubstring("al")) }) It("rejects a known kind the command does not accept", func() { - _, err := parseArtworkKind("mf", artwork.RecheckKinds) + _, err := parseArtworkKind("mf", artwork.ReprocessKinds) Expect(err).To(HaveOccurred()) }) @@ -442,13 +441,13 @@ var _ = Describe("artwork reprocess selection", func() { It("returns every kind for --all", func() { ks, err := selectedKinds(nil, nil, true) Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.RecheckKinds)) + Expect(ks).To(ConsistOf(artwork.ReprocessKinds)) }) It("returns every kind for a source filter given without a kind", func() { ks, err := selectedKinds(nil, []string{"folder"}, false) Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection") + Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection") }) It("returns only the named kinds", func() { @@ -512,6 +511,11 @@ var _ = Describe("repositorySources", func() { Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) }) + It("maps the failed name onto the pseudo-source, and back for display", func() { + Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed})) + Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource)) + }) + It("keeps an empty selection empty, meaning every source", func() { Expect(repositorySources(nil)).To(BeEmpty()) }) @@ -607,6 +611,25 @@ var _ = Describe("reprocessArtwork", func() { Expect(queue.Count()).To(BeZero()) }) + DescribeTable("records the applied config only for a run that leaves nothing on the old one", + func(selected []model.Kind, sources []string, dryRun, applied bool) { + Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) + + want := "stale-fingerprint" + if applied { + want = artwork.ConfigFingerprint() + } + Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) + }, + Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), + Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), + Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false), + Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false), + Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false), + ) + It("queues the matching items at recheck priority, leaving their artwork state alone", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) @@ -760,6 +783,14 @@ var _ = Describe("reprocessArtwork", func() { imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") }) + It("names failed among the valid sources when rejecting a typo", func() { + err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed"), + "failed is accepted but never stored, so it has to be named explicitly") + }) + It("accepts a source another kind uses, letting the empty selection report itself", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, imageAgents, false, decline, &out)).To(Succeed()) @@ -792,14 +823,16 @@ var _ = Describe("collectStatus", func() { ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour)) - put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) + // ar-3 is absent because it gave up, so the two absent artists split across the columns. + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", + ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) }) - It("reports the queue, the source distribution and the absent ages", func() { + It("reports the queue, the source distribution and the absent totals", func() { rep, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -810,8 +843,8 @@ var _ = Describe("collectStatus", func() { sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, )) - Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, - ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}})) + Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}), + "two absent artists, one answered and one that gave up") }) It("compares the stored fingerprint against the current one", func() { @@ -845,7 +878,7 @@ var _ = Describe("formatStatus", func() { {kind: model.KindArtistArtwork, source: "", count: 2}, }, absent: []absentCount{ - {kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}, + {kind: model.KindArtistArtwork, noImage: 1, failed: 1}, }, inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, stored: "abc123", @@ -878,50 +911,37 @@ var _ = Describe("formatStatus", func() { Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) }) - It("prints the absent total and how many are due for recheck", func() { - absent := block(formatStatus(rep), "Absent (resolved, no image found)") - Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) + It("partitions the absent states into answered and gave up", func() { + out := block(formatStatus(rep), "Absent (resolved, no image found)") + Expect(out).To(ContainSubstring("NO IMAGE")) + Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent") + Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed")) }) - It("states the recheck window and the drip rate the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours()))) - Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) + It("says absent states are never retried on their own, and names both commands that do", func() { + out := formatStatus(rep) + Expect(out).To(ContainSubstring("nothing retries these")) + Expect(out).To(ContainSubstring("artwork reprocess --source absent")) + Expect(out).To(ContainSubstring("artwork reprocess --source failed")) }) - It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { - out := block(formatStatus(rep), "Backfill") - Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`), - "an operator scanning for trouble must not read 'up to date' while 2 items churn") - Expect(out).To(ContainSubstring("fingerprint up to date")) - }) - - It("keeps the re-enqueue warning while a backfill is already running", func() { - rep.stored = "older" - - out := block(formatStatus(rep), "Backfill") - Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`)) - Expect(out).To(ContainSubstring("re-enqueued"), - "the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one") - }) - - It("reports up to date only once the backfill has drained", func() { - rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}} - - Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`)) + It("reports a matching fingerprint as up to date, whatever else is queued", func() { + Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`)) }) It("echoes the config inputs a fingerprint change would have come from", func() { - out := block(formatStatus(rep), "Backfill") + out := block(formatStatus(rep), "Config") Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") }) - It("reports a changed fingerprint as a pending re-resolve of everything", func() { + It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() { rep.stored = "older" rep.queue = nil out := formatStatus(rep) Expect(out).To(ContainSubstring("fingerprint changed")) + Expect(out).To(ContainSubstring("artwork reprocess --all")) Expect(out).ToNot(ContainSubstring("up to date")) }) @@ -1015,7 +1035,7 @@ var _ = Describe("needsImageAgents", func() { It("is false once the chains no longer reach an agent", func() { conf.Server.CoverArtPriority = "cover.*" conf.Server.ArtistArtPriority = "artist.*" - Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse()) + Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse()) }) }) diff --git a/cmd/root.go b/cmd/root.go index 94f861f40..c4e360010 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -366,21 +366,18 @@ func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() erro } } -// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the -// recurring stale-absent recheck and prune jobs. +// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and +// reports an artwork config change without acting on it. func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { return func() error { schedulerInstance := scheduler.GetInstance() - if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { - if err := worker.EnqueueStaleAbsentAll(ctx); err != nil { - log.Error(ctx, "Error enqueueing stale artwork rechecks", err) - } + if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { if err := worker.EnqueueMissingAll(ctx); err != nil { log.Error(ctx, "Error enqueueing missing artwork rechecks", err) } }); err != nil { - log.Error(ctx, "Error scheduling artwork stale-absent recheck", err) + log.Error(ctx, "Error scheduling artwork missing-state recheck", err) } if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { @@ -397,23 +394,8 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu log.Error(ctx, "Error enqueueing missing artwork rechecks", err) } - backfilled, err := worker.Backfill(ctx) - if err != nil { - log.Error(ctx, "Error running artwork backfill", err) - return nil - } - if !backfilled { - return nil - } - log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune") - timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay) - defer timer.Stop() - select { - case <-timer.C: - if err := worker.RunPrune(ctx); err != nil { - log.Error(ctx, "Error running post-backfill artwork prune", err) - } - case <-ctx.Done(): + if err := worker.ReconcileConfig(ctx); err != nil { + log.Error(ctx, "Error checking the artwork config fingerprint", err) } return nil } diff --git a/consts/consts.go b/consts/consts.go index 2934cd968..0f950ef10 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -24,8 +24,8 @@ const ( LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" DBAnalyzePendingKey = "DBAnalyzePending" DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" - // ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against - // to detect artwork-affecting config changes across restarts. + // ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check + // compares against to detect artwork-affecting config changes across restarts. ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" @@ -39,9 +39,8 @@ const ( DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour - ArtworkStaleAbsentRecheckSchedule = "@every 1h" - ArtworkPruneSchedule = "@daily" - ArtworkPostBackfillPruneDelay = 10 * time.Minute + ArtworkEnqueueMissingSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index e8458a0f9..663d06d25 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -118,10 +118,6 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa } } -// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't -// hammer external services; below StaleAbsentAge to catch younger absences. -const requestRecheckAge = time.Hour - func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary) switch { @@ -130,10 +126,7 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i case err != nil: return nil, err case ia.Hash == "": - // Inserts an immediately-eligible recheck for a settled absent row. - if time.Since(ia.AttemptedAt) > requestRecheckAge { - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - } + // Settled absent: only an explicit reprocess or refresh retries it. return nil, ErrUnavailable default: return s.serveHash(ctx, artID, ia, size, square) diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 7de5475d6..907b300de 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -204,28 +204,15 @@ var _ = Describe("Artwork", func() { Expect(err).To(MatchError(ErrUnavailable)) }) - It("does not re-enqueue a recently-attempted absent state", func() { + It("never re-enqueues an absent state on view, however old", func() { Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(), + ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), })).To(Succeed()) _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) Expect(err).To(MatchError(ErrUnavailable)) Expect(queueRepo.Data).To(BeEmpty()) }) - - It("promotes a stale absent state at Bump priority on view", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) }) Describe("provisional read-through", func() { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index a3330d7cf..3ca452bdc 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -6,26 +6,18 @@ import ( "slices" "strconv" "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" "github.com/zeebo/xxh3" ) -// StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 30 * 24 * time.Hour - -// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, -// oldest first, so external agents see a flat drip instead of a daily burst. -const StaleAbsentRecheckBatch = 100 - -// RecheckKinds omits media files: they resolve embedded-only, at scan or on view. -var RecheckKinds = []model.Kind{ +// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead +// so bulk enqueues give the most external-dependent kind a queue headstart. +var ReprocessKinds = []model.Kind{ model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, } @@ -34,14 +26,16 @@ var RecheckKinds = []model.Kind{ func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } // RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds -// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker -// resolves them, it just never revisits them on its own. -var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork) +// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker +// resolves them, it just never enumerates them in bulk. +var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork) -// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. -func hasRecheckPath(prefix string) bool { +// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media +// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local +// read, and only a view ever enqueues them. +func settlesAbsentOnGiveUp(prefix string) bool { kind, ok := model.ParseKind(prefix) - return ok && slices.Contains(RecheckKinds, kind) + return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork } // artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. @@ -72,93 +66,36 @@ func ConfigFingerprint() string { return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } -// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one -// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again. -type backfillSummary struct { - Ran bool - PerKind map[string]int64 - Items int64 - MaxExternalLookups int64 -} - -// backfill enqueues artwork resolution for every entity when the config fingerprint changed. -func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) { - start := time.Now() - ctx = auth.WithAdminUser(ctx, ds) +// ReconcileConfigFingerprint warns when the artwork config changed since the library was last +// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. +func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { current := ConfigFingerprint() - props := ds.Property(ctx) - stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") + stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { - return backfillSummary{}, err + return err } - if stored == current { - return backfillSummary{}, nil - } - - // Artists first: few entities, most external-dependent, so they get a queue headstart. - kinds := []struct { - kind model.Kind - fetch func() ([]string, error) - }{ - {model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, - {model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, - {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, - {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, - } - // Counted here, not by the caller: building the agent list constructs every enabled agent, and - // an unchanged fingerprint returns above without ever needing the number. - agents := agentCount() - summary := backfillSummary{Ran: true, PerKind: map[string]int64{}} - for _, k := range kinds { - ids, err := k.fetch() - if err != nil { - return backfillSummary{}, err - } - if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { - return backfillSummary{}, err - } - n := int64(len(ids)) - summary.PerKind[k.kind.Prefix()] = n - summary.Items += n - summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents) - } - - if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { - return backfillSummary{}, err - } - log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items, - "byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups, - "elapsed", time.Since(start)) - return summary, nil -} - -func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { - if len(ids) == 0 { - return nil - } - items := slice.Map(ids, func(id string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, - } - }) - return ds.ArtworkQueue(ctx).Enqueue(items...) -} - -func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { - cutoff := time.Now().Add(-StaleAbsentAge) - queue := ds.ArtworkQueue(ctx) - for _, kind := range RecheckKinds { - if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil { - return err - } + switch stored { + case current: + case "": + // An unset fingerprint counts as current; the alternative warns every upgrading install once. + return MarkConfigApplied(ctx, ds) + default: + log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+ + "the old resolution; run 'navidrome artwork reprocess --all' to apply the change", + "stored", stored, "current", current, "inputs", FingerprintInputs()) } return nil } +// MarkConfigApplied records the current fingerprint as the one the library is resolved under. +func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { + return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) +} + // enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { queue := ds.ArtworkQueue(ctx) - for _, kind := range RecheckKinds { + for _, kind := range ReprocessKinds { if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { return err } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index c9809203a..2027cba9a 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -2,59 +2,17 @@ package artwork import ( "context" - "fmt" "slices" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only -// visible when the ctx carries an admin, so headless work must wrap ctx with one first. -type visibilityPlaylistDS struct { - *tests.MockDataStore - private model.Playlist - tracks model.PlaylistTrackRepository -} - -func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { - repo := tests.CreateMockPlaylistRepo() - repo.TracksRepo = v.tracks - if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { - repo.SetData(model.Playlists{v.private}) - } - return repo -} - -func adminUserRepo() *tests.MockedUserRepo { - repo := tests.CreateMockUserRepo() - Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) - return repo -} - -func noAgents() ImageAgentCount { return ImageAgentCount{} } - -// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can -// assert phase ordering (artists-first) that same-priority timestamps can't guarantee. -type orderTrackingQueueRepo struct { - *tests.MockArtworkQueueRepo - callKinds []string -} - -func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { - if len(items) > 0 { - o.callKinds = append(o.callKinds, items[0].ItemKind) - } - return o.MockArtworkQueueRepo.Enqueue(items...) -} - var _ = Describe("RefreshableKinds", func() { // The two are meant to describe the same fact. Nothing but this test stops them from drifting, // and a drift would have `artwork explain` report state for a kind that keeps none. @@ -72,7 +30,7 @@ var _ = Describe("Housekeeping", func() { var ( ctx context.Context ds *tests.MockDataStore - queueRepo *orderTrackingQueueRepo + queueRepo *tests.MockArtworkQueueRepo propRepo *tests.MockedPropertyRepo ) @@ -84,52 +42,24 @@ var _ = Describe("Housekeeping", func() { conf.Server.Agents = "spotify" conf.Server.EnableExternalServices = true - queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + queueRepo = tests.CreateMockArtworkQueueRepo() propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} }) - seedEntities := func() { - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}}) - ds.MockedArtist = artistRepo - - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1"}}) - ds.MockedAlbum = albumRepo - - playlistRepo := tests.CreateMockPlaylistRepo() - playlistRepo.SetData(model.Playlists{{ID: "pl1"}}) - ds.MockedPlaylist = playlistRepo - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.All = model.Radios{{ID: "ra1"}} - ds.MockedRadio = radioRepo - } - Describe("Fingerprint", func() { - It("changes when a fingerprint-affecting config value changes", func() { - f1 := ConfigFingerprint() - conf.Server.CoverArtPriority = "folder, embedded" - f2 := ConfigFingerprint() - Expect(f1).NotTo(Equal(f2)) - }) + DescribeTable("changes when a fingerprint-affecting config value changes", + func(change func()) { + before := ConfigFingerprint() + change() + Expect(ConfigFingerprint()).NotTo(Equal(before)) + }, + Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }), + Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }), + Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }), + ) - It("changes when ArtistImageFolder changes", func() { - conf.Server.ArtistImageFolder = "/before" - f1 := ConfigFingerprint() - conf.Server.ArtistImageFolder = "/after" - Expect(ConfigFingerprint()).NotTo(Equal(f1)) - }) - - It("changes when EnableM3UExternalAlbumArt is toggled", func() { - conf.Server.EnableM3UExternalAlbumArt = false - f1 := ConfigFingerprint() - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ConfigFingerprint()).NotTo(Equal(f1)) - }) - - // Pinned: a changed formula re-resolves every library on upgrade, flooding external providers. + // Pinned: a changed formula tells every existing install its artwork config went stale. It("hashes a given config to a stable value", func() { conf.Server.CoverArtPriority = "cover.*, embedded" conf.Server.ArtistArtPriority = "artist.*, external" @@ -157,145 +87,23 @@ var _ = Describe("Housekeeping", func() { f1 := ConfigFingerprint() consts.Version = original + "-next" Expect(ConfigFingerprint()).To(Equal(f1), - "the version must not invalidate artwork state: it would re-resolve every entity on every build") + "the version must not invalidate artwork state: every build would report a stale config") }) }) - Describe("Backfill", func() { - It("enqueues nothing and returns false when the stored fingerprint matches", func() { - seedEntities() - Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed()) + Describe("ReconcileConfigFingerprint", func() { + It("records the current fingerprint when none was ever stored", func() { + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - counted := false - s, err := backfill(ctx, ds, func() ImageAgentCount { - counted = true - return ImageAgentCount{Artist: 3, Album: 2} - }) - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(Equal(backfillSummary{})) - Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it") - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) }) - It("runs the backfill when no fingerprint was ever stored", func() { - seedEntities() - - s, err := backfill(ctx, ds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio - - stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey) - Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal(ConfigFingerprint())) - }) - - It("enqueues a private playlist by resolving it under an admin context", func() { - ds.MockedUser = adminUserRepo() - vds := &visibilityPlaylistDS{ - MockDataStore: ds, - private: model.Playlist{ID: "plPrivate", OwnerID: "admin"}, - tracks: &tests.MockPlaylistTrackRepo{}, - } - - s, err := backfill(ctx, vds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) - }) - - It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { - seedEntities() + It("leaves a stale fingerprint stored, so the warning survives a restart", func() { Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - s, err := backfill(ctx, ds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - Expect(queueRepo.callKinds).ToNot(BeEmpty()) - firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) - Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call") - if firstOther >= 0 { - Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"), - "no artist Enqueue may follow another kind") - } - - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill)) - Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) - } - }) - - It("reports what it enqueued, per kind and as an external-lookup ceiling", func() { - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - seedEntities() - - s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} }) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - - Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1})) - Expect(s.Items).To(Equal(int64(5))) - // 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch. - Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2))) - }) - }) - - Describe("EnqueueStaleAbsentAll", func() { - var artRepo *tests.MockArtworkRepo - - BeforeEach(func() { - artRepo = tests.CreateMockArtworkRepo() - ds.MockedArtwork = artRepo - queueRepo.ItemArtworkSource = artRepo - }) - - It("enqueues only absent entries older than the recheck window, across all kinds", func() { - old := time.Now().Add(-StaleAbsentAge - time.Hour) - recent := time.Now().Add(-StaleAbsentAge + time.Hour) - - artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old} - - err := enqueueStaleAbsentAll(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - Expect(queueRepo.Data).To(HaveLen(4)) - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - } - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) - }) - - It("caps each tick at the recheck batch, oldest attempts first", func() { - for i := range StaleAbsentRecheckBatch + 1 { - id := fmt.Sprintf("ar%d", i) - artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary, - Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)} - } - - Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed()) - - Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch)) - // ar0 has the newest attempted_at of the cohort, so it is the one left out. - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) }) }) @@ -315,8 +123,8 @@ var _ = Describe("Housekeeping", func() { }) It("enqueues only entities that have no item_artwork row, across all kinds", func() { - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()} - artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()} + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"} + artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""} err := enqueueMissingAll(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -324,11 +132,11 @@ var _ = Describe("Housekeeping", func() { for _, it := range queueRepo.Data { Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) } - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil()) + Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "al", "al1")).To(BeNil()) + Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil()) }) }) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index bea478aa5..bb09be55e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -23,8 +23,8 @@ import ( const ( workerPollInterval = 5 * time.Second backoffBase = 5 * time.Second - // giveUpAfter bounds the retry budget from enqueue; past it the item falls to the - // periodic stale-absent recheck. + // giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an + // explicit reprocess retries it. giveUpAfter = 12 * time.Hour ) @@ -40,7 +40,6 @@ type drainPool struct { // independently, and pruneMu serializes prune against the store-write window. type Worker struct { proc *processor - agents *agents.Agents cache cache.FileCache ffmpeg ffmpeg.FFmpeg broker events.Broker @@ -55,7 +54,6 @@ type Worker struct { func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { w := &Worker{ proc: &processor{ds: ds, store: store}, - agents: ag, cache: imgCache, ffmpeg: ffmpeg, broker: broker, @@ -133,17 +131,9 @@ func (w *Worker) RunPrune(ctx context.Context) error { return prune(ctx, w.proc.ds, w.proc.store) } -// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, -// artists first. It reports whether the backfill ran. -func (w *Worker) Backfill(ctx context.Context) (bool, error) { - s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) }) - return s.Ran, err -} - -// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most -// StaleAbsentRecheckBatch per kind, oldest first. -func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { - return enqueueStaleAbsentAll(ctx, w.proc.ds) +// ReconcileConfig records the artwork config fingerprint, or warns when it changed. +func (w *Worker) ReconcileConfig(ctx context.Context) error { + return ReconcileConfigFingerprint(ctx, w.proc.ds) } // EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything @@ -265,10 +255,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) break } - // Absent is only recoverable where a periodic recheck revisits it, so other kinds keep - // no row; art already being served is kept, as exhaustion means unreachable, not removed. + // Art already being served is kept: exhaustion means unreachable, not removed. settled := "kept previous state" - if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { + if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) settled = "recorded absent" } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index b0ef665fc..ebb8de251 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/cache" @@ -115,6 +116,29 @@ func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQu return nil } +// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only +// visible when the ctx carries an admin, so headless work must wrap ctx with one first. +type visibilityPlaylistDS struct { + *tests.MockDataStore + private model.Playlist + tracks model.PlaylistTrackRepository +} + +func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { + repo := tests.CreateMockPlaylistRepo() + repo.TracksRepo = v.tracks + if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { + repo.SetData(model.Playlists{v.private}) + } + return repo +} + +func adminUserRepo() *tests.MockedUserRepo { + repo := tests.CreateMockUserRepo() + Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + return repo +} + var _ = Describe("Worker", func() { var ( ctx context.Context @@ -441,9 +465,9 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art") }) - // Media files are excluded from RecheckKinds, so an absent row here would never be - // revisited: a transient read error would look permanent. - It("does not settle absent on exhaustion for a kind with no recheck path", func() { + // Only a view enqueues a media file, and an absent row is exactly what stops a view from + // doing so: a transient read error would look permanent. + It("does not settle absent on exhaustion for a media file", func() { conf.Server.EnableMediaFileCoverArt = true ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ diff --git a/db/migrations/20260901225726_normalize_artwork_last_failure.sql b/db/migrations/20260901225726_normalize_artwork_last_failure.sql new file mode 100644 index 000000000..6de12e757 --- /dev/null +++ b/db/migrations/20260901225726_normalize_artwork_last_failure.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- 20260819204637 added last_failure with DEFAULT '[]', so every row already in the table got a +-- non-empty value. That is how a give-up is now told apart from a definitive "no image", which +-- would report every pre-existing absent row as failed. +UPDATE item_artwork SET last_failure = '' WHERE last_failure = '[]'; + +-- +goose Down +-- Irreversible: a genuine give-up and a backfilled default are indistinguishable once normalized. +SELECT 1; diff --git a/model/album.go b/model/album.go index 5a436fec0..ee24bfa96 100644 --- a/model/album.go +++ b/model/album.go @@ -143,7 +143,6 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) - GetAllIDs(...QueryOptions) ([]string, error) // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in // any of the given library-relative subtrees. GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error) diff --git a/model/artist.go b/model/artist.go index f3704b669..f88b3a974 100644 --- a/model/artist.go +++ b/model/artist.go @@ -90,7 +90,6 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) - GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/artwork.go b/model/artwork.go index 3c0df209b..8856a35fa 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -18,6 +18,10 @@ type Artwork struct { const ImageTypePrimary = "primary" +// ArtworkSourceFailed is a pseudo-source selecting absent states that exhausted the retry budget +// rather than being answered. The "!" keeps it from colliding with a stored source value. +const ArtworkSourceFailed = "!failed" + // ItemImage is per-entity artwork state hydrated at query time; never persisted. type ItemImage struct { ImageHash string `structs:"-" json:"imageHash,omitempty"` @@ -88,11 +92,12 @@ func (i ItemArtworkInfo) Image() ItemImage { } type ArtworkQueueItem struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Priority int `structs:"priority"` - Attempts int `structs:"attempts"` + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + // RetryAt is the earliest time the drain may take this row, not when it will run. RetryAt time.Time `structs:"retry_at"` EnqueuedAt time.Time `structs:"enqueued_at"` // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. @@ -101,7 +106,9 @@ type ArtworkQueueItem struct { // Queue priorities: higher drains first. const ( - ArtworkPriorityRecheck = 0 + ArtworkPriorityRecheck = 0 + // ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues + // it today; it stays named so a row still carrying it can be reported and cancelled. ArtworkPriorityBackfill = 10 ArtworkPriorityScan = 50 ArtworkPriorityBump = 100 @@ -134,15 +141,13 @@ type ArtworkQueueRepository interface { // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a // request-triggered read-through never resets a failed resolution's backoff. EnqueuePreservingBackoff(items ...ArtworkQueueItem) error - // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest - // first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls). - EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (int64, error) // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. EnqueueAllMissing(kind Kind, priority int) (int64, error) // EnqueueIfMissing inserts only for items with no item_artwork row yet. EnqueueIfMissing(items ...ArtworkQueueItem) error // CountBySource reports how many items of a kind currently resolve from the given sources. - // An empty sources slice means every source; "" matches absent state. + // An empty sources slice means every source; "" matches absent state, and the pseudo-source + // ArtworkSourceFailed matches the absent states that gave up. CountBySource(kind Kind, sources []string) (int64, error) // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. SourcesInUse(kind Kind) ([]string, error) @@ -161,9 +166,6 @@ type ArtworkQueueRepository interface { // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; // an empty filter means every one. CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // CountAbsent reports the absent states of a kind, and how many are past the given cutoff, - // eligible for EnqueueStaleAbsent (which drains them limit rows per call). - CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. @@ -175,8 +177,3 @@ type ArtworkQueueStat struct { Priority int Count int64 } - -type ArtworkAbsentStat struct { - Total int64 - Stale int64 -} diff --git a/model/mediafile.go b/model/mediafile.go index 99aee591e..2669018f3 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -553,8 +553,6 @@ type MediaFileRepository interface { // expression, using the logged user's annotations. Limit and offset are ignored. MatchesCriteria(id string, c criteria.Criteria) (bool, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) - // GetAllIDs returns just the media_file IDs for the same row set as GetAll. - GetAllIDs(options ...QueryOptions) ([]string, error) // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given // folders or their direct children. GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error) diff --git a/model/playlist.go b/model/playlist.go index 306401271..abd11b8c4 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -150,7 +150,6 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) - GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error diff --git a/model/radio.go b/model/radio.go index 466ff48b0..013a24beb 100644 --- a/model/radio.go +++ b/model/radio.go @@ -35,6 +35,5 @@ type RadioRepository interface { Exists(id string) (bool, error) Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) - GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 7ac875a51..808c880fb 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -259,8 +259,8 @@ func (r *albumRepository) hydrateArtwork(albums model.Albums) { func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) } -// GetAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. -func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. +func (r *albumRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "album.id") @@ -304,7 +304,7 @@ func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, pat } func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 0fb680cff..c3d7f018f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -152,12 +152,12 @@ var _ = Describe("AlbumRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := albumRepo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := albumRepo.GetAllIDs() + ids, err := albumRepo.getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 67d0df448..1ff291a0f 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -265,9 +265,9 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } -// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the // heavy stats columns and JSON post-processing. -func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +func (r *artistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "artist.id") @@ -284,7 +284,7 @@ func (r *artistRepository) hydrateArtwork(artists model.Artists) { } func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 25472ffe1..d337b4c22 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -285,12 +285,12 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() + ids, err := repo.(*artistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) }) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 2b52d7d82..bb9cda04d 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -578,7 +578,7 @@ var _ = Describe("Artwork hydration", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} // Both phases must filter on their own: the id pre-pass and the chunk fetch. - Expect(repo.GetAllIDs(opts)).To(ConsistOf(plsBest.ID)) + Expect(repo.(*playlistRepository).getAllIDs(opts)).To(ConsistOf(plsBest.ID)) all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists}) Expect(err).ToNot(HaveOccurred()) Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 1c0077fc7..88b6f6f80 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -56,14 +56,6 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { - now := time.Now() - return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ? - FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ? - ORDER BY attempted_at LIMIT ?`, - model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit) -} - func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { entityTable, ok := artworkOwnerTables[kind] if !ok { @@ -110,13 +102,23 @@ func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) } -// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state. +// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the +// absent state, and ArtworkSourceFailed the absent states that gave up. Several are a union, so +// asking for both absent and failed is just absent. func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { f := And{Eq{"item_kind": kind.Prefix()}} - if len(sources) > 0 { - f = append(f, Eq{"source": sources}) + if len(sources) == 0 { + return f } - return f + stored := slices.DeleteFunc(slices.Clone(sources), func(s string) bool { return s == model.ArtworkSourceFailed }) + var match Or + if len(stored) > 0 { + match = append(match, Eq{"source": stored}) + } + if len(stored) != len(sources) { + match = append(match, And{Eq{"hash": ""}, NotEq{"last_failure": ""}}) + } + return append(f, match) } func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) { @@ -231,13 +233,4 @@ func (r *artworkQueueRepository) Count() (int64, error) { return res.Count, err } -// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from. -func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { - var res model.ArtworkAbsentStat - err := r.queryOne(Select("count(*) as total"). - Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)). - From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res) - return res, err -} - var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 1673a5b0e..0481d4193 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -237,41 +237,6 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) }) - It("enqueues stale absent states for recheck", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - old := time.Now().Add(-48 * time.Hour) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) - - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - items, err := repo.DequeueBatch(10) - Expect(err).ToNot(HaveOccurred()) - Expect(items).To(HaveLen(1)) - Expect(items[0].ItemID).To(Equal("stale1")) - Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) - }) - - It("enqueues only the oldest stale absent states up to the limit", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - now := time.Now() - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed()) - - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(2))) - - items, err := repo.DequeueBatch(10) - Expect(err).ToNot(HaveOccurred()) - ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf("oldest", "older")) - }) - It("enqueues entities that have no item_artwork row at all", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) @@ -435,29 +400,49 @@ var _ = Describe("ArtworkQueueRepository", func() { )) }) - It("reports an empty queue as no rows", func() { - Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) - }) - - It("counts absent states and how many are due for recheck", func() { + It("selects only the absent states that gave up, not those a source answered", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - old := time.Now().Add(-48 * time.Hour) for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, - {ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}, - {ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old}, - {ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, } { Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) } - Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))). - To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1})) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), + "an item still serving art is not absent, however its last attempt went") + + // A later success rewrites the row, clearing the record. + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", + ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) }) - It("reports a kind with no absent state as zero, not as an error", func() { - Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{})) + It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, + } { + Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + } + failedAndAbsent := []string{model.ArtworkSourceFailed, ""} + Expect(repo.CountBySource(model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), + "failed is a subset of absent, so asking for both is asking for absent") + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). + To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") }) + + It("reports a kind with nothing failed as zero", func() { + Expect(repo.CountBySource(model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + }) + + It("reports an empty queue as no rows", func() { + Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) + }) + }) Describe("PurgeQueued", func() { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ed18333de..da167b1a8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -308,8 +308,8 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me return wrapMediaFileCursor(cursor), nil } -// GetAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. -func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. +func (r *mediaFileRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "media_file.id") @@ -341,7 +341,7 @@ func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs . // GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 505f23440..bf8d6d5a8 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -208,8 +208,8 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } -// GetAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. -func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. +func (r *playlistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY // on the playlist's own ambiguous. sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name"). @@ -224,7 +224,7 @@ func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index f60b4e7ca..60263807a 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -74,12 +74,12 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() + ids, err := repo.(*playlistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) }) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 915859559..e042ee6bf 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -79,14 +79,6 @@ func (r *radioRepository) hydrateArtwork(radios model.Radios) { func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) } -// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). -func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - sel := r.newSelect(options...).Columns("id") - ids := []string{} - err := r.queryAllSlice(sel, &ids) - return ids, err -} - func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index c35c85ad7..a958f715d 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -79,17 +78,6 @@ var _ = Describe("RadioRepository", func() { }) }) - Describe("GetAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll() - Expect(err).To(BeNil()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() - Expect(err).To(BeNil()) - Expect(ids).To(ConsistOf(slice.Map(want, func(r model.Radio) string { return r.ID }))) - }) - }) - Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index c63f7c425..1b14f225b 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockAlbumRepo() *MockAlbumRepo { @@ -85,14 +84,6 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } -func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(a model.Album) string { return a.ID }), nil -} - func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index af393129e..db7d54d5d 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockArtistRepo() *MockArtistRepo { @@ -117,14 +116,6 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } -func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(options...) - if err != nil { - return nil, err - } - return slice.Map(all, func(a model.Artist) string { return a.ID }), nil -} - func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 51ddf4b61..c482e2150 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -16,7 +16,7 @@ type MockArtworkQueueRepo struct { mu sync.Mutex Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) Err error - // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + // ItemArtworkSource, when set, backs the set-difference insert with real item_artwork state. ItemArtworkSource *MockArtworkRepo // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. ExistingIDs map[string]map[string]bool @@ -226,26 +226,6 @@ func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) return res, nil } -// CountAbsent mirrors the SQL predicate: an absent state is one with no hash. -func (m *MockArtworkQueueRepo) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { - m.mu.Lock() - defer m.mu.Unlock() - var res model.ArtworkAbsentStat - if m.Err != nil || m.ItemArtworkSource == nil { - return res, m.Err - } - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind != kind.Prefix() || ia.Hash != "" { - continue - } - res.Total++ - if ia.AttemptedAt.Before(attemptedBefore) { - res.Stale++ - } - } - return res, nil -} - func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -272,49 +252,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu return nil } -func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil || m.ItemArtworkSource == nil { - return 0, m.Err - } - var stale []model.ItemArtwork - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) { - stale = append(stale, ia) - } - } - slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) }) - // The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget. - stale = stale[:min(limit, len(stale))] - now := time.Now() - var inserted int64 - for _, ia := range stale { - k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) - if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows - continue - } - m.Data[k] = model.ArtworkQueueItem{ - ItemKind: ia.ItemKind, - ItemID: ia.ItemID, - ImageType: ia.ImageType, - Priority: model.ArtworkPriorityRecheck, - RetryAt: now, - EnqueuedAt: now, - } - inserted++ - } - return inserted, nil -} - -// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state. +// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and +// ArtworkSourceFailed the absent states that gave up. func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { if m.ItemArtworkSource == nil { return nil } + matches := func(ia model.ItemArtwork) bool { + if len(sources) == 0 { + return true + } + if slices.Contains(sources, ia.Source) { + return true + } + return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != "" + } var res []model.ItemArtwork for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && (len(sources) == 0 || slices.Contains(sources, ia.Source)) { + if ia.ItemKind == kind.Prefix() && matches(ia) { res = append(res, ia) } } @@ -366,7 +321,7 @@ func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string return inserted, nil } -// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. +// EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f18280fd5..7a1a8f926 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -130,14 +130,6 @@ func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (mode return m.GetCursor(qo...) } -func (m *MockMediaFileRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(mf model.MediaFile) string { return mf.ID }), nil -} - func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index f04ed98c6..0fa9618ae 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -7,7 +7,6 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockPlaylistRepo() *MockPlaylistRepo { @@ -54,14 +53,6 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } -func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(options...) - if err != nil { - return nil, err - } - return slice.Map(all, func(p model.Playlist) string { return p.ID }), nil -} - func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 2baeadc5c..20f81ec45 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -5,7 +5,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) type MockedRadioRepo struct { @@ -74,14 +73,6 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(r model.Radio) string { return r.ID }), nil -} - func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") From bd46284087ad0ceed420e1aaf7f54e380637648b Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:16:14 -0700 Subject: [PATCH 499/596] feat: validate all configuration durations (#6002) * chore: ensure that all durations are nonnegative * make sure you actually include the test file * test(conf): use non-zero durations in the valid_duration fixture Zero is the boundary between the accepted and rejected ranges, so it passes even if the guard is off by one. 1s exercises an ordinary value. --------- Co-authored-by: Deluan <deluan@navidrome.org> --- conf/configuration.go | 27 +++++++++++++++-- conf/configuration_test.go | 45 +++++++++++++++++++++++++++++ conf/testdata/invalid_duration.toml | 12 ++++++++ conf/testdata/valid_duration.toml | 12 ++++++++ consts/consts.go | 6 ++++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 conf/testdata/invalid_duration.toml create mode 100644 conf/testdata/valid_duration.toml diff --git a/conf/configuration.go b/conf/configuration.go index b9c9f027e..ff119417a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -351,6 +351,13 @@ func LoadFromFile(confFile string) { Load(true) } +func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) { + if val.Nanoseconds() < 0 { + log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original) + *val = original + } +} + func Load(noConfigDump bool) { parseIniFileConfiguration() remapEnvVarKeysFromConfig() @@ -418,6 +425,20 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) + durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout) + durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh) + durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration) + durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval) + durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength) + durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait) + + durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate) + durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout) + durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay) + durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout) + // Log deprecated, removed and unknown options for _, o := range deprecatedOptions { logDeprecatedOptions(o.name, o.replacement) @@ -967,7 +988,7 @@ func setViperDefaults() { viper.SetDefault("autoimportplaylists", true) viper.SetDefault("defaultplaylistpublicvisibility", false) viper.SetDefault("playlistspath", "") - viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second) + viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) viper.SetDefault("enablem3uexternalalbumart", false) @@ -1011,14 +1032,14 @@ func setViperDefaults() { viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") - viper.SetDefault("defaultshareexpiration", 8760*time.Hour) + viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration) viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) - viper.SetDefault("authwindowlength", 20*time.Second) + viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength) viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 344ab3595..2c7f8edaa 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -6,9 +6,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -453,6 +455,49 @@ var _ = Describe("Configuration", func() { Entry("INI format", "ini"), Entry("JSON format", "json"), ) + + It("should use default values for negative duration fields", func() { + filename := filepath.Join("testdata", "invalid_duration.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh)) + Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration)) + Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval)) + Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength)) + Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive)) + Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay)) + Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout)) + }) + + It("should use parsed values for duration fields", func() { + conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false) + conf.Load(true) + + configured := 1 * time.Second + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(configured)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured)) + Expect(server.DefaultShareExpiration).To(Equal(configured)) + Expect(server.UIPlaybackReportInterval).To(Equal(configured)) + Expect(server.AuthWindowLength).To(Equal(configured)) + Expect(server.Scanner.WatcherWait).To(Equal(configured)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(configured)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(configured)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured)) + Expect(server.DevInsightsInitialDelay).To(Equal(configured)) + Expect(server.DevPluginCompilationTimeout).To(Equal(configured)) + }) }) var _ = Describe("TLSEnabled", func() { diff --git a/conf/testdata/invalid_duration.toml b/conf/testdata/invalid_duration.toml new file mode 100644 index 000000000..6540fef60 --- /dev/null +++ b/conf/testdata/invalid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "-10s" +SmartPlaylistRefreshDelay = "-10s" +UIPlaybackReportInterval = "-10s" +AuthWindowLength = "-10s" +DefaultShareExpiration = "-10s" +Scanner.WatcherWait = "-10s" +DevActivityPanelUpdateRate = "-10s" +DevArtworkThrottleBacklogTimeout = "-10s" +DevArtistInfoTimeToLive = "-10s" +DevAlbumInfoTimeToLive = "-10s" +DevInsightsInitialDelay = "-10s" +DevPluginCompilationTimeout = "-10s" diff --git a/conf/testdata/valid_duration.toml b/conf/testdata/valid_duration.toml new file mode 100644 index 000000000..6ec4af480 --- /dev/null +++ b/conf/testdata/valid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "1s" +SmartPlaylistRefreshDelay = "1s" +UIPlaybackReportInterval = "1s" +AuthWindowLength = "1s" +DefaultShareExpiration = "1s" +Scanner.WatcherWait = "1s" +DevActivityPanelUpdateRate = "1s" +DevArtworkThrottleBacklogTimeout = "1s" +DevArtistInfoTimeToLive = "1s" +DevAlbumInfoTimeToLive = "1s" +DevInsightsInitialDelay = "1s" +DevPluginCompilationTimeout = "1s" diff --git a/consts/consts.go b/consts/consts.go index 0f950ef10..486ea66bc 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -34,6 +34,8 @@ const ( JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour + DefaultSmartRefresh = 5 * time.Second + DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year DBAnalyzeCheckSchedule = "@every 30m" @@ -71,6 +73,7 @@ const ( DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline DefaultMaxSidebarPlaylists = 100 + DefaultAuthWindowLength = 20 * time.Second RequestThrottleBacklogLimit = 100 RequestThrottleBacklogTimeout = time.Minute @@ -106,6 +109,9 @@ const ( DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') + + DefaultActivityPanelUpdateRate = 300 * time.Millisecond + DefaultPluginCompilationTimeout = time.Minute ) const ( From cb045b8ef3959f0ee225d65857b665dfc1508df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 1 Sep 2026 23:02:15 -0400 Subject: [PATCH 500/596] ci: exclude tests/ from the coverage report on pull requests too (#6070) The exclusion only worked on master. Coverage profiles name files by import path; octocov shortens those to repo-relative paths using the checked-out source, but coverage-on-pr.yml sparse-checks-out only .octocov.yml, so the paths stay as github.com/navidrome/navidrome/tests/mock_*.go and 'tests/**' never matched. '**/*_gen.go' matched either way, which is why only the 30 tests/ files leaked. Every pull request since b77fb45 therefore reported ~-3.7% against master: 447 files on the base side, 477 on the pull request side (#6002, #6069). --- .octocov.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.octocov.yml b/.octocov.yml index 467998b51..c4ad10712 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,8 +8,10 @@ coverage: paths: - coverage.out # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + # Both patterns need the '**/' prefix: the comment workflow has no source tree, + # so octocov cannot shorten the profile's import paths to repo-relative ones. exclude: - - 'tests/**' + - '**/tests/**' - '**/*_gen.go' codeToTestRatio: # Needs the pull request's own source, which the comment workflow must not From 8407fe6dda7e2c24a618127fd5cade2608e9f1c0 Mon Sep 17 00:00:00 2001 From: polybjorn <service@polybjorn.com> Date: Thu, 3 Sep 2026 00:49:50 +0000 Subject: [PATCH 501/596] fix(ui): reload the playlist after rating or loving a track (#6009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playlistTrack id is a position in the playlist, not a stable key, so refetching a row by id after the annotation is saved can return a different song: in a smart playlist filtered on that annotation the track is gone and every later row has shifted up. The stale-keyed record then renders as a duplicate of its neighbour. Signed-off-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com> Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/common/useRating.jsx | 22 ++++++--------- ui/src/common/useRating.test.js | 43 +++++++++++++++-------------- ui/src/common/useToggleLove.jsx | 31 ++++++++++----------- ui/src/common/useToggleLove.test.js | 39 +++++++++++++------------- 4 files changed, 65 insertions(+), 70 deletions(-) diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index 2eb5d9eca..82e9bfc06 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -1,11 +1,12 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useRating = (resource, record) => { const [loading, setLoading] = useState(false) const notify = useNotify() const dataProvider = useDataProvider() + const refresh = useRefresh() const mountedRef = useRef(false) const rating = record.rating @@ -17,23 +18,18 @@ export const useRating = (resource, record) => { }, []) const refreshRating = useCallback(() => { - // For playlist tracks, refresh both resources to keep data in sync if (record.mediaFileId) { - // This is a playlist track - refresh both the playlist track and the song - const promises = [ - dataProvider.getOne('song', { id: record.mediaFileId }), - dataProvider.getOne('playlistTrack', { - id: record.id, - filter: { playlist_id: record.playlistId }, - }), - ] - - Promise.all(promises) + // A playlistTrack id is a position, not a stable key: rating a song can drop it out + // of a smart playlist, and that position then holds a different track. Refetching + // the row by id would write the neighbour's data under this row, so reload the list. + dataProvider + .getOne('song', { id: record.mediaFileId }) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + refresh() if (mountedRef.current) { setLoading(false) } @@ -52,7 +48,7 @@ export const useRating = (resource, record) => { } }) } - }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource]) + }, [dataProvider, record.id, record.mediaFileId, refresh, resource]) const rate = (val, id) => { setLoading(true) diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js index b1353512e..ffe9abffd 100644 --- a/ui/src/common/useRating.test.js +++ b/ui/src/common/useRating.test.js @@ -4,6 +4,8 @@ import { useRating } from './useRating' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { setRating: vi.fn(() => Promise.resolve()), @@ -16,13 +18,16 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) describe('useRating', () => { let getOne beforeEach(() => { - getOne = vi.fn(() => Promise.resolve()) + getOne = vi.fn((resource, params) => + Promise.resolve({ data: { id: params.id } }), + ) useDataProvider.mockReturnValue({ getOne }) vi.clearAllMocks() }) @@ -56,9 +61,9 @@ describe('useRating', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', rating: 2, @@ -71,18 +76,21 @@ describe('useRating', () => { // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // rating can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', rating: 1, @@ -92,16 +100,8 @@ describe('useRating', () => { await result.current[0](3, 'sg-10') }) - // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -111,9 +111,10 @@ describe('useRating', () => { await result.current[0](2, 'sg-1') }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx index 3f98a2e21..22468cbe1 100644 --- a/ui/src/common/useToggleLove.jsx +++ b/ui/src/common/useToggleLove.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useToggleLove = (resource, record = {}) => { @@ -15,33 +15,32 @@ export const useToggleLove = (resource, record = {}) => { }, []) const dataProvider = useDataProvider() + const refresh = useRefresh() const refreshRecord = useCallback(() => { - const promises = [] + // A playlistTrack id is a position, not a stable key: loving a song can drop it out of + // a smart playlist, and that position then holds a different track. Refetching the row + // by id would write the neighbour's data under this row, so reload the list instead. + const isPlaylistTrack = !!record.mediaFileId + const target = isPlaylistTrack + ? { resource: 'song', params: { id: record.mediaFileId } } + : { resource, params: { id: record.id } } - // Always refresh the original resource - const params = { id: record.id } - if (record.playlistId) { - params.filter = { playlist_id: record.playlistId } - } - promises.push(dataProvider.getOne(resource, params)) - - // If we have a mediaFileId, also refresh the song - if (record.mediaFileId) { - promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) - } - - Promise.all(promises) + dataProvider + .getOne(target.resource, target.params) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + if (isPlaylistTrack) { + refresh() + } if (mountedRef.current) { setLoading(false) } }) - }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource]) + }, [dataProvider, record.mediaFileId, record.id, refresh, resource]) const toggleLove = () => { const toggle = record.starred ? subsonic.unstar : subsonic.star diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js index 640e9ff89..10db1b3c3 100644 --- a/ui/src/common/useToggleLove.test.js +++ b/ui/src/common/useToggleLove.test.js @@ -4,6 +4,8 @@ import { useToggleLove } from './useToggleLove' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { star: vi.fn(() => Promise.resolve()), @@ -17,6 +19,7 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) @@ -58,9 +61,9 @@ describe('useToggleLove', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', starred: false, @@ -75,18 +78,21 @@ describe('useToggleLove', () => { // Should star using the media file ID expect(subsonic.star).toHaveBeenCalledWith('sg-1') - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // loving can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', starred: true, @@ -98,16 +104,8 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should unstar using the media file ID expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -117,9 +115,10 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { From afb3a2f881a245c28cc7b65888f51ea1a2cbbb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 2 Sep 2026 23:29:31 -0400 Subject: [PATCH 502/596] feat(ui): add Refresh Metadata action to the album and artist pages (#6078) The Refresh Metadata action was only reachable from the Album and Artist context menus, so it could not be triggered from AlbumShow or ArtistShow. This adds an icon-only button, with a tooltip, to the action toolbar on both detail pages. Like the menu entry, it is only rendered for admins. The button is built on react-admin's Button rather than a plain IconButton: the surrounding toolbars use the former, so the theme colour and the icon-only swap at the xs breakpoint are inherited instead of restated. The dataProvider call and its two notifications move into a new useRefreshMetadata hook, which ContextMenus now shares, keeping a single copy of that logic. --- ui/src/album/AlbumActions.jsx | 7 +- ui/src/album/AlbumActions.test.jsx | 68 ++++++++++++ ui/src/artist/ArtistActions.jsx | 7 ++ ui/src/artist/ArtistActions.test.jsx | 32 +++++- ui/src/common/ContextMenus.jsx | 8 +- ui/src/common/RefreshMetadataButton.jsx | 62 +++++++++++ ui/src/common/RefreshMetadataButton.test.jsx | 104 +++++++++++++++++++ ui/src/common/index.js | 2 + ui/src/common/useRefreshMetadata.js | 16 +++ 9 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 ui/src/album/AlbumActions.test.jsx create mode 100644 ui/src/common/RefreshMetadataButton.jsx create mode 100644 ui/src/common/RefreshMetadataButton.test.jsx create mode 100644 ui/src/common/useRefreshMetadata.js diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx index 96cfab09a..c3b3a4bc2 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.jsx @@ -27,7 +27,7 @@ import { } from '../actions' import { formatBytes } from '../utils' import config from '../config' -import { ToggleFieldsMenu } from '../common' +import { RefreshMetadataButton, ToggleFieldsMenu } from '../common' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -138,6 +138,11 @@ const AlbumActions = ({ <CloudDownloadOutlinedIcon /> </AlbumButton> )} + <RefreshMetadataButton + resource="album" + record={record} + size="small" + /> </div> <div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div> </div> diff --git a/ui/src/album/AlbumActions.test.jsx b/ui/src/album/AlbumActions.test.jsx new file mode 100644 index 000000000..246a15558 --- /dev/null +++ b/ui/src/album/AlbumActions.test.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import AlbumActions from './AlbumActions' + +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: false, + enableDownloads: false, + losslessFormats: 'FLAC,WAV', + }, + mockPermissions: { value: 'admin' }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-redux', () => ({ + useDispatch: () => vi.fn(), + useSelector: () => ({}), +})) + +const mockRefreshMetadata = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('AlbumActions', () => { + const record = { id: 'al1', name: 'Album', size: 1024 } + const refreshLabel = 'resources.album.actions.refresh' + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } }) + }) + + const renderAlbumActions = () => + render( + <ThemeProvider theme={createTheme()}> + <AlbumActions record={record} ids={[]} data={{}} /> + </ThemeProvider>, + ) + + it('refreshes the album metadata for admins', async () => { + renderAlbumActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderAlbumActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) +}) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 2f329a034..c2614c05a 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -26,6 +26,7 @@ import { import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from '../common/artist' +import { RefreshMetadataButton } from '../common/RefreshMetadataButton' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -33,6 +34,7 @@ const useStyles = makeStyles((theme) => ({ padding: '0 !important', background: 'transparent', boxShadow: 'none', + alignItems: 'center', '& .MuiToolbar-root': { minHeight: 'auto', padding: '0 !important', @@ -174,6 +176,11 @@ const ArtistActions = ({ className, record, ...rest }) => { icon={<CloudDownloadOutlinedIcon />} /> )} + <RefreshMetadataButton + resource="artist" + record={record} + size={isMobile ? 'small' : 'medium'} + /> </TopToolbar> ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index ad25177b3..05f53f835 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -18,20 +18,26 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) -const { mockConfig } = vi.hoisted(() => ({ +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ mockConfig: { enableSharing: true, enableDownloads: true }, + mockPermissions: { value: 'admin' }, })) vi.mock('../config', () => ({ default: mockConfig })) const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) +const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } }) vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => mockNotify, - useDataProvider: () => ({ getList: mockGetList }), + useDataProvider: () => ({ + getList: mockGetList, + refreshMetadata: mockRefreshMetadata, + }), + usePermissions: () => ({ permissions: mockPermissions.value }), useTranslate: () => (x) => x, } }) @@ -64,6 +70,7 @@ describe('ArtistActions', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' const songWithReplayGain = { id: 'rec1', @@ -290,4 +297,25 @@ describe('ArtistActions', () => { expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() }) }) + + describe('Refresh metadata action', () => { + const refreshLabel = 'resources.album.actions.refresh' + + it('refreshes the artist metadata for admins', async () => { + renderArtistActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderArtistActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 606506459..4407392c1 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -30,6 +30,7 @@ import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from './artist' +import { useRefreshMetadata } from './useRefreshMetadata' const useStyles = makeStyles({ noWrap: { @@ -75,6 +76,7 @@ const ContextMenu = ({ const translate = useTranslate() const notify = useNotify() const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() const [anchorEl, setAnchorEl] = useState(null) const isArtist = resource === 'artist' @@ -139,11 +141,7 @@ const ContextMenu = ({ enabled: permissions === 'admin', needData: false, label: translate('resources.album.actions.refresh'), - action: (record) => - dataProvider - .refreshMetadata(resource, record.id) - .then(() => notify('message.metadataRefreshStarted')) - .catch(() => notify('ra.page.error', 'warning')), + action: (record) => refreshMetadata(resource, record.id), }, ...(!hideInfo && { info: { diff --git a/ui/src/common/RefreshMetadataButton.jsx b/ui/src/common/RefreshMetadataButton.jsx new file mode 100644 index 000000000..aa0533ade --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.jsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import Tooltip from '@material-ui/core/Tooltip' +import { makeStyles } from '@material-ui/core/styles' +import { Button, usePermissions, useTranslate } from 'react-admin' +import { MdRefresh } from 'react-icons/md' +import { useRefreshMetadata } from './useRefreshMetadata' + +const useStyles = makeStyles({ + // Tooltip needs a ref-holding child, and react-admin's Button does not forward one. + wrapper: { display: 'inline-flex', verticalAlign: 'middle' }, + button: { minWidth: 'auto' }, +}) + +// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap +// at xs match without restating either rule. +export const RefreshMetadataButton = ({ + resource, + record, + className, + size, +}) => { + const translate = useTranslate() + const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() + const classes = useStyles() + + const handleClick = useCallback( + () => refreshMetadata(resource, record?.id), + [refreshMetadata, resource, record], + ) + + if (permissions !== 'admin' || !record?.id) return null + + const label = translate('resources.album.actions.refresh') + return ( + <Tooltip title={label}> + <span className={classes.wrapper}> + <Button + aria-label={label} + className={className} + classes={{ button: classes.button }} + size={size} + onClick={handleClick} + > + <MdRefresh /> + </Button> + </span> + </Tooltip> + ) +} + +RefreshMetadataButton.propTypes = { + resource: PropTypes.oneOf(['album', 'artist']).isRequired, + record: PropTypes.object, + className: PropTypes.string, + size: PropTypes.oneOf(['small', 'medium']), +} + +RefreshMetadataButton.defaultProps = { + size: 'small', +} diff --git a/ui/src/common/RefreshMetadataButton.test.jsx b/ui/src/common/RefreshMetadataButton.test.jsx new file mode 100644 index 000000000..7bda0497c --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.test.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { RefreshMetadataButton } from './RefreshMetadataButton' + +const mockNotify = vi.fn() +const mockRefreshMetadata = vi.fn() +const { mockPermissions } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('RefreshMetadataButton', () => { + const record = { id: 'al-1', name: 'Album' } + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } }) + }) + + const renderButton = (props = {}) => + render( + <ThemeProvider theme={createTheme()}> + <RefreshMetadataButton resource="album" record={record} {...props} /> + </ThemeProvider>, + ) + + it('renders an icon-only button labelled by the refresh action', () => { + renderButton() + const button = screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }) + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent('') + }) + + it('shows the label as a tooltip on hover', async () => { + renderButton() + fireEvent.mouseOver( + screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }), + ) + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'resources.album.actions.refresh', + ), + ) + }) + + it('renders nothing for non-admin users', () => { + mockPermissions.value = 'regular' + const { container } = renderButton() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing without a record', () => { + const { container } = renderButton({ record: undefined }) + expect(container).toBeEmptyDOMElement() + }) + + it('requests a refresh for the record and notifies success', async () => { + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'), + ) + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'), + ) + }) + + it('passes the artist resource through', async () => { + renderButton({ resource: 'artist', record: { id: 'ar-1' } }) + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'), + ) + }) + + it('notifies a warning when the request fails', async () => { + mockRefreshMetadata.mockRejectedValue(new Error('boom')) + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'), + ) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 047f5b3cf..0177df326 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -36,6 +36,8 @@ export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' export * from './RatingField' +export * from './RefreshMetadataButton' +export * from './useRefreshMetadata' export * from './useRating' export * from './useSelectedFields' export * from './ToggleFieldsMenu' diff --git a/ui/src/common/useRefreshMetadata.js b/ui/src/common/useRefreshMetadata.js new file mode 100644 index 000000000..d34042919 --- /dev/null +++ b/ui/src/common/useRefreshMetadata.js @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import { useDataProvider, useNotify } from 'react-admin' + +export const useRefreshMetadata = () => { + const dataProvider = useDataProvider() + const notify = useNotify() + + return useCallback( + (resource, id) => + dataProvider + .refreshMetadata(resource, id) + .then(() => notify('message.metadataRefreshStarted')) + .catch(() => notify('ra.page.error', 'warning')), + [dataProvider, notify], + ) +} From a7365e119b4b2fdc3debbff7d8d801c3417f2824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 3 Sep 2026 16:07:53 -0400 Subject: [PATCH 503/596] fix(subsonic): update the playlist `changed` timestamp when renaming a smart playlist (#6082) `buildPlaylist` reported `evaluated_at` as `changed` for smart playlists, so a rename or comment edit was invisible to clients until the next evaluation. A never-evaluated smart playlist also reported the current time on every call, which never settled. Report `updated_at` for every playlist. `refreshCounters` now syncs the stamp it writes back onto the model, and `refreshSmartPlaylist` reuses it for `evaluated_at`. `changed` therefore still equals the evaluation time for a just-evaluated playlist, and `validUntil` stays anchored to it. Original Subsonic always bumps `changed` on any playlist update, so this also aligns the behavior with upstream. --- persistence/playlist_repository.go | 4 +++- persistence/smart_playlist_repository.go | 3 ++- persistence/smart_playlist_repository_test.go | 17 +++++++++++++++++ server/subsonic/playlists.go | 11 +---------- server/subsonic/playlists_test.go | 19 +++++++++++++++++-- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index bf8d6d5a8..bc6bffd25 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -316,11 +316,12 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { } // Update playlist's total duration, size and count + now := time.Now() upd := Update("playlist"). Set("duration", res.Duration). Set("size", res.Size). Set("song_count", res.Count). - Set("updated_at", time.Now()). + Set("updated_at", now). Where(Eq{"id": pls.ID}) _, err = r.executeSQL(upd) if err != nil { @@ -329,6 +330,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) + pls.UpdatedAt = now return nil } diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 65ae4656b..9d2ac9590 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -58,7 +58,8 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - now := time.Now() + // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree + now := pls.UpdatedAt updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) if _, err = r.executeSQL(updSql); err != nil { log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index ddc155fab..6f8684d5c 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -45,6 +45,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Context("after an evaluation", func() { + It("stamps updated_at and evaluated_at with the same instant", func() { + newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + refreshed, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + stored, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.EvaluatedAt).ToNot(BeNil()) + Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) + Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt)) + }) + }) + Context("invalid rules", func() { It("fails to Put it in the DB", func() { rules = &criteria.Criteria{ diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 774a9c430..e64fc9e82 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -133,15 +132,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.SongCount = int32(p.SongCount) pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt - if p.IsSmartPlaylist() { - if p.EvaluatedAt != nil { - pls.Changed = *p.EvaluatedAt - } else { - pls.Changed = time.Now() - } - } else { - pls.Changed = p.UpdatedAt - } + pls.Changed = p.UpdatedAt player, ok := request.PlayerFrom(ctx) if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f18f33b47..c7775c0fa 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -220,7 +220,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(evaluatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) // These should not be set Expect(result.Comment).To(BeEmpty()) @@ -245,7 +245,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(*playlist.EvaluatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) @@ -271,6 +271,21 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("when it was never evaluated", func() { + BeforeEach(func() { + playlist.EvaluatedAt = nil + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("omits validUntil but still reports changed", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.ValidUntil).To(BeNil()) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + }) + }) + Context("with a per-playlist refreshDelay", func() { BeforeEach(func() { playlist.Rules.RefreshDelay = 24 * time.Hour From 330da83efff42e67d5f065ae052a31adcb6bb15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 5 Sep 2026 13:52:07 -0400 Subject: [PATCH 504/596] chore(deps): bump TagLib to 2.3.2 (#6088) See https://github.com/taglib/taglib/releases/tag/v2.3.2 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cdf8fc699..4339b9c55 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.27 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index d852285f1..71d9facfd 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= -github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70= +github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= 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= From 546302576ab7d19109bc651fd136f595d4e1e92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 5 Sep 2026 21:44:03 -0400 Subject: [PATCH 505/596] fix(jellyfin): emit TranscodingUrl without the /jellyfin base path (#6089) PlaybackInfo returned a TranscodingUrl prefixed with the /jellyfin mount path. Clients concatenate that value onto a server base URL that already carries the prefix, producing /jellyfin/jellyfin/Audio/{id}/universal and a 404, so playback never started. Jellify, jellyfin-web, jellyfin-vue and Streamyfin all consume the field this way; real Jellyfin emits it server-relative (StreamInfo.ToUrl is called with a nil baseUrl). Emit the path server-relative to match. Finamp is unaffected: it builds its own stream URLs and never reads the field. --- server/jellyfin/e2e/streaming_test.go | 12 +++++------- server/jellyfin/stream.go | 9 +++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index 954981910..67208aed3 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -113,14 +113,12 @@ var _ = Describe("Streaming", func() { var info dto.PlaybackInfoResponse parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) streamURL := info.MediaSources[0].TranscodingUrl - // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute - // host path hits the mounted router. - Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + // Server-relative: clients append it to a base URL already carrying /jellyfin. + Expect(streamURL).To(HavePrefix("/Audio/" + enc(id) + "/universal")) + Expect(streamURL).ToNot(HavePrefix(consts.URLPathJellyfinAPI)) Expect(streamURL).To(ContainSubstring("api_key=")) - // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e - // router is mounted at the root, so strip the /jellyfin prefix before replaying. - replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) - w := rawReq("GET", replayURL, "") + // The embedded api_key alone must authenticate the stream — no auth header sent. + w := rawReq("GET", streamURL, "") Expect(w.Code).To(Equal(http.StatusOK)) Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) }) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 86746f89a..87660056c 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -57,13 +56,11 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { }) } } - // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl - // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients - // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so - // a client resolving it as an absolute host path still hits the mounted router. + // Self-authenticating: native players fetch this without an auth header. Server-relative: + // clients append it to a base URL already carrying /jellyfin. if token := tokenFromRequest(r); token != "" { src.TranscodingSubProtocol = "http" - src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) } api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } From c534aedd0c3e00579fd90c7adabed4a5fb742935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 5 Sep 2026 22:57:36 -0400 Subject: [PATCH 506/596] fix(jellyfin): add the /Items/Latest route, and scope it by ParentId (#6090) Jellify's Discover tab calls GET /Items/Latest and got a 404: we only routed the /Users/{userId}/Items/Latest form, which real Jellyfin marks [Obsolete] and hides from its OpenAPI spec, so SDK-generated clients never see it. Add the current route alongside the legacy one, both served by the same handler. getLatest also ignored ParentId, so browsing a library or an artist returned the newest albums across everything the user can see. It now scopes to the library when ParentId names one, and filters to that artist's albums otherwise, which also makes a stale id return nothing instead of silently widening back to the full library set. A malformed ParentId 404s, matching /Items and the contract decodeFilterParam documents. --- server/jellyfin/api.go | 1 + server/jellyfin/e2e/browsing_test.go | 37 ++++++++++++++++++++++++++++ server/jellyfin/items.go | 13 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d64366a63..6484a3bb4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -118,6 +118,7 @@ func (api *Router) routes() http.Handler { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) r.Get("/items", api.getItems) r.Get("/users/{userId}/items", api.getItems) + r.Get("/items/latest", api.getLatest) r.Get("/users/{userId}/items/latest", api.getLatest) r.Get("/artists", api.getArtists) r.Get("/artists/albumartists", api.getAlbumArtists) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 004090de6..832b69f25 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -563,6 +563,43 @@ var _ = Describe("Browsing", func() { }) }) + Describe("GET /Items/Latest", func() { + // Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so + // SDK-generated clients (Jellify) only ever call this one. + It("serves the same response as the legacy /Users/{userId} route", func() { + Expect(get("/Items/Latest?Limit=3").Body.String()). + To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String())) + }) + + It("scopes to ParentId when it names a library", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("scopes to ParentId when it names an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("returns nothing for a library the user cannot access", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items) + Expect(items).To(BeEmpty()) + }) + + It("returns nothing for an id that is neither a library nor an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items) + Expect(items).To(BeEmpty()) + }) + + It("404s a malformed ParentId, like every other filtered endpoint", func() { + Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) + Describe("GET /Artists and /Genres", func() { It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { names := names(queryResult(get("/Artists/AlbumArtists")).Items) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index eb5cd2a1a..d16c5e57e 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -938,7 +938,18 @@ func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() opts.Max = p.IntOr("limit", 20) - opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + parentID, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing + // rather than widening back to every library. + scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID) + if parentID != "" && !isLibrary { + opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} + } + opts = filter.ApplyLibraryFilter(opts, scopeIDs) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) From 072331078d7ac999b443ebd66eed2c61820b75d9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 6 Sep 2026 12:39:53 -0400 Subject: [PATCH 507/596] fix(server): update StoreMusicFolder to skip updates when path is unchanged Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/library_repository.go | 7 +++-- persistence/library_repository_test.go | 42 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index df5c9a066..2e8feea7a 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -137,9 +137,10 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`, func (r *libraryRepository) StoreMusicFolder() error { sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). Set("updated_at", time.Now()). - Where(Eq{"id": model.DefaultLibraryID}) - _, err := r.executeSQL(sq) - if err != nil { + Where(Eq{"id": model.DefaultLibraryID}). + Where(NotEq{"path": conf.Server.MusicFolder}) + rowsAffected, err := r.executeSQL(sq) + if err == nil && rowsAffected > 0 { libLock.Lock() defer libLock.Unlock() libCache[model.DefaultLibraryID] = conf.Server.MusicFolder diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 949dd93c5..6aede8c4e 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -139,6 +141,46 @@ var _ = Describe("LibraryRepository", func() { }) }) + Describe("StoreMusicFolder", func() { + var libBefore *model.Library + + BeforeEach(func() { + var err error + libBefore, err = repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { + _, _ = conn.NewQuery("update library set path = {:path}, updated_at = {:updated_at} where id = {:id}"). + Bind(dbx.Params{"path": libBefore.Path, "updated_at": libBefore.UpdatedAt, "id": model.DefaultLibraryID}). + Execute() + libLock.Lock() + defer libLock.Unlock() + delete(libCache, model.DefaultLibraryID) + }) + }) + + It("skips updating the default library when the configured path is unchanged", func() { + conf.Server.MusicFolder = libBefore.Path + Expect(repo.StoreMusicFolder()).To(Succeed()) + + libAfter, err := repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.Path).To(Equal(libBefore.Path)) + Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) + }) + + It("updates the default library only when the configured path changes", func() { + conf.Server.MusicFolder = libBefore.Path + "-updated" + Expect(repo.StoreMusicFolder()).To(Succeed()) + + libAfter, err := repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder)) + Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt)) + }) + }) + It("refreshes stats", func() { libBefore, err := repo.Get(1) Expect(err).ToNot(HaveOccurred()) From 8568010524f4b44207d8c8c28f55fc079867641f Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 6 Sep 2026 13:08:05 -0400 Subject: [PATCH 508/596] refactor(log): replace sort with slices.SortFunc and use atomic for currentLevel Signed-off-by: Deluan <deluan@navidrome.org> --- log/log.go | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/log/log.go b/log/log.go index 10cfb17b5..de2f171b2 100644 --- a/log/log.go +++ b/log/log.go @@ -1,6 +1,7 @@ package log import ( + "cmp" "context" "errors" "fmt" @@ -9,9 +10,10 @@ import ( "net/http" "os" "runtime" - "sort" + "slices" "strings" "sync" + "sync/atomic" "time" "github.com/sirupsen/logrus" @@ -76,18 +78,19 @@ type levelPath struct { } var ( - currentLevel Level - loggerMu sync.RWMutex - defaultLogger = logrus.New() - logSourceLine = false - rootPath string - logLevels []levelPath + currentLevel atomic.Uint32 + hasLogLevelOverrides atomic.Bool + loggerMu sync.RWMutex + defaultLogger = logrus.New() + logSourceLine = false + rootPath string + logLevels []levelPath ) // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { loggerMu.Lock() - currentLevel = l + currentLevel.Store(uint32(l)) defaultLogger.Level = logrus.TraceLevel loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) @@ -126,9 +129,10 @@ func SetLogLevels(levels map[string]string) { for k, v := range levels { logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)}) } - sort.Slice(logLevels, func(i, j int) bool { - return logLevels[i].path > logLevels[j].path + slices.SortFunc(logLevels, func(a, b levelPath) int { + return cmp.Compare(b.path, a.path) }) + hasLogLevelOverrides.Store(len(logLevels) != 0) } func SetLogSourceLine(enabled bool) { @@ -193,9 +197,7 @@ func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { } func CurrentLevel() Level { - loggerMu.RLock() - defer loggerMu.RUnlock() - return currentLevel + return Level(currentLevel.Load()) } // IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level. @@ -248,18 +250,18 @@ func Writer() io.Writer { } func shouldLog(requiredLevel Level, skip int) bool { - loggerMu.RLock() - level := currentLevel - levels := logLevels - loggerMu.RUnlock() - + level := Level(currentLevel.Load()) if level >= requiredLevel { return true } - if len(levels) == 0 { + if !hasLogLevelOverrides.Load() { return false } + loggerMu.RLock() + levels := logLevels + loggerMu.RUnlock() + _, file, _, ok := runtime.Caller(skip) if !ok { return false From 8a2135f076dcabe1399147c6c8dfe64a5b383047 Mon Sep 17 00:00:00 2001 From: karigane <169052233+karigane-cha@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:30:25 +0900 Subject: [PATCH 509/596] fix(i18n): Update Japanese translation (#6080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): Update Japanese translation * fix(i18n): fix Japanese translation * fix(i18n): fix Japanese translation Update Japanese translations for `recentlyAdded`, `recentlyPlayed`, and `mostPlayed` in album lists --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/ja.json | 681 +++++++++++++++++++++++------------------ 1 file changed, 390 insertions(+), 291 deletions(-) diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index 29975b92b..83cd7822b 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -5,48 +5,53 @@ "name": "曲", "fields": { "albumArtist": "アルバムアーティスト", - "duration": "長さ", + "duration": "再生時間", "trackNumber": "#", - "playCount": "再生数", + "playCount": "再生回数", "title": "タイトル", "artist": "アーティスト", + "composer": "作曲", "album": "アルバム", "path": "ファイルパス", + "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "Compilation", + "compilation": "コンピレーション", "year": "年", "size": "ファイルサイズ", - "updatedAt": "更新日", + "updatedAt": "更新日時", "bitRate": "ビットレート", + "bitDepth": "ビット深度", + "sampleRate": "サンプリングレート", + "albumGain": "アルバムゲイン", + "trackGain": "トラックゲイン", + "channels": "チャンネル", + "disc": "ディスク %{discNumber}", "discSubtitle": "ディスクサブタイトル", "starred": "お気に入り", "comment": "コメント", - "rating": "レート", - "quality": "品質", + "rating": "評価", + "quality": "形式", "bpm": "BPM", - "playDate": "最後の再生", - "channels": "チャンネル", - "createdAt": "追加日", - "grouping": "グループ分け", + "playDate": "最終再生日時", + "createdAt": "追加日時", + "grouping": "グループ化", "mood": "ムード", - "participants": "追加参加者", + "participants": "参加者", "tags": "追加タグ", - "mappedTags": "マッピング済みタグ", - "rawTags": "未処理タグ", - "bitDepth": "ビット深度", - "sampleRate": "サンプリングレート", - "missing": "不明", - "libraryName": "ライブラリ" + "mappedTags": "マッピングされたタグ", + "rawTags": "未加工タグ", + "missing": "不明" }, "actions": { "addToQueue": "最後に再生", - "playNow": "すぐに再生", + "playNow": "今すぐ再生", "addToPlaylist": "プレイリストに追加", - "shuffleAll": "全曲シャッフル", + "showInPlaylist": "プレイリストで表示", + "shuffleAll": "すべてシャッフル", "download": "ダウンロード", "playNext": "次に再生", "info": "詳細", - "showInPlaylist": "含まれるプレイリスト" + "instantMix": "インスタントミックス" } }, "album": { @@ -54,48 +59,49 @@ "fields": { "albumArtist": "アルバムアーティスト", "artist": "アーティスト", - "duration": "長さ", - "songCount": "曲", - "playCount": "再生数", - "name": "名前", + "duration": "再生時間", + "songCount": "曲数", + "playCount": "再生回数", + "size": "ファイルサイズ", + "name": "アルバム名", + "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "Compilation", + "compilation": "コンピレーション", "year": "年", - "updatedAt": "更新日", - "comment": "コメント", - "rating": "レート", - "createdAt": "追加日", - "size": "サイズ", - "originalDate": "オリジナルの日付", - "releaseDate": "リリース日", + "date": "録音日", + "originalDate": "オリジナル発売日", + "releaseDate": "発売日", "releases": "リリース", - "released": "リリース", - "recordLabel": "ラベル", + "released": "リリース済み", + "updatedAt": "更新日時", + "comment": "コメント", + "rating": "評価", + "createdAt": "追加日時", + "recordLabel": "レーベル", "catalogNum": "カタログ番号", - "releaseType": "タイプ", - "grouping": "グループ分け", + "releaseType": "リリースタイプ", + "grouping": "グループ化", "media": "メディア", "mood": "ムード", - "date": "録音日", - "missing": "不明", - "libraryName": "ライブラリ" + "missing": "不明" }, "actions": { - "playAll": "再生", + "playAll": "すべて再生", "playNext": "次に再生", "addToQueue": "最後に再生", + "share": "共有", "shuffle": "シャッフル", - "addToPlaylist": "プレイリストへ追加", + "addToPlaylist": "プレイリストに追加", "download": "ダウンロード", "info": "詳細", - "share": "共有" + "refresh": "メタデータを更新" }, "lists": { - "all": "全て", + "all": "すべて", "random": "ランダム", "recentlyAdded": "最近の追加", "recentlyPlayed": "最近の再生", - "mostPlayed": "最も再生", + "mostPlayed": "最多再生", "starred": "お気に入り", "topRated": "高評価" } @@ -106,33 +112,33 @@ "name": "名前", "albumCount": "アルバム枚数", "songCount": "曲数", - "playCount": "再生数", - "rating": "レート", - "genre": "ジャンル", "size": "サイズ", + "playCount": "再生回数", + "rating": "評価", + "genre": "ジャンル", "role": "役割", "missing": "不明" }, "roles": { "albumartist": "アルバムアーティスト", "artist": "アーティスト", - "composer": "作曲家", - "conductor": "指揮者", - "lyricist": "作詞家", - "arranger": "編曲者", + "composer": "作曲", + "conductor": "指揮", + "lyricist": "作詞", + "arranger": "編曲", "producer": "プロデューサー", - "director": "ディレクター", + "director": "監督", "engineer": "エンジニア", "mixer": "ミキサー", "remixer": "リミキサー", "djmixer": "DJ ミキサー", - "performer": "演奏者", - "maincredit": "アルバムアーティストもしくはアーティスト" + "performer": "演奏", + "maincredit": "アルバムアーティストまたはアーティスト" }, "actions": { + "topSongs": "人気曲", "shuffle": "シャッフル", - "radio": "ラジオ", - "topSongs": "トップソング" + "radio": "ラジオ" } }, "user": { @@ -140,55 +146,58 @@ "fields": { "userName": "ユーザー名", "isAdmin": "管理者", - "lastLoginAt": "最終ログイン", - "updatedAt": "更新日", + "lastLoginAt": "最終ログイン日時", + "lastAccessAt": "最終アクセス日時", + "updatedAt": "更新日時", "name": "名前", "password": "パスワード", - "createdAt": "作成日", + "createdAt": "作成日時", "changePassword": "パスワードを変更しますか?", "currentPassword": "現在のパスワード", "newPassword": "新しいパスワード", "token": "トークン", - "lastAccessAt": "最終アクセス", - "libraries": "ライブラリ" + "libraries": "ライブラリ", + "scrobbleFilter": "Scrobble フィルター" }, "helperTexts": { - "name": "名前の変更は次回ログイン以降反映されます", - "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします" + "name": "ユーザー名の変更は、次回ログイン時に反映されます", + "libraries": "このユーザー用に特定のライブラリを選択するか、デフォルトのライブラリを使用する場合は空のままにしてください", + "scrobbleFilter": "これらのスマートプレイリストのルールに一致する曲は、Last.fm、ListenBrainz、または Scrobble プラグインに送信されません。スマートプレイリストと同じ JSON 構文と動作を使用します。例: {\"all\":[{\"lt\":{\"rating\":4}}]}。すべてを Scrobble する場合は空欄のままにしてください。ローカルでの再生回数には影響しません。" }, "notifications": { "created": "ユーザーが作成されました", "updated": "ユーザーが更新されました", "deleted": "ユーザーが削除されました" }, - "message": { - "listenBrainzToken": "ListenBrainzユーザートークンを入力", - "clickHereForToken": "ここをクリックしトークンを入手", - "selectAllLibraries": "全てのライブラリを選択", - "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます" - }, "validation": { - "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります" + "librariesRequired": "管理者以外のユーザーについては、少なくとも 1 つのライブラリを選択する必要があります", + "invalidScrobbleFilter": "有効なスマートプレイリストのルールである必要があります。上限、オフセット、更新遅延はサポートされていません。" + }, + "message": { + "listenBrainzToken": "ListenBrainz のユーザートークンを入力してください。", + "clickHereForToken": "トークンを取得するには、ここをクリックしてください", + "selectAllLibraries": "すべてのライブラリを選択", + "adminAutoLibraries": "管理者ユーザーは、すべてのライブラリに自動的にアクセスできます" } }, "player": { - "name": "プレイヤー", + "name": "プレーヤー", "fields": { "name": "名前", "transcodingId": "トランスコード", "maxBitRate": "最大ビットレート", "client": "クライアント", - "userName": "ユーザ名", - "lastSeen": "最後の利用", + "userName": "ユーザー名", + "lastSeen": "最終アクセス日時", "reportRealPath": "実際のファイルパスを返す", - "scrobbleEnabled": "他のサービスへscrobbleする" + "scrobbleEnabled": "外部サービスに Scrobble する" } }, "transcoding": { "name": "トランスコード", "fields": { "name": "名前", - "targetFormat": "対象フォーマット", + "targetFormat": "ターゲット形式", "defaultBitRate": "デフォルトビットレート", "command": "コマンド" } @@ -197,45 +206,46 @@ "name": "プレイリスト", "fields": { "name": "名前", - "duration": "時間", + "duration": "再生時間", "ownerName": "所有者", "public": "公開", - "updatedAt": "更新日", - "createdAt": "作成日", - "songCount": "曲", + "updatedAt": "更新日時", + "createdAt": "作成日時", + "songCount": "曲数", "comment": "コメント", - "sync": "自動インポート", - "path": "インポート元" + "sync": "同期", + "path": "インポートパス", + "starred": "お気に入り" }, "actions": { - "selectPlaylist": "プレイリストを選択", - "addNewPlaylist": "'%{name}' を作成", + "selectPlaylist": "プレイリストを選択:", + "addNewPlaylist": "\"%{name}\" を作成", "export": "エクスポート", - "makePublic": "公開する", - "makePrivate": "非公開にする", "saveQueue": "キューをプレイリストに保存", - "searchOrCreate": "プレイリストを検索または入力して新規作成...", - "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成", - "removeFromSelection": "選択から削除" + "makePublic": "公開にする", + "makePrivate": "非公開にする", + "searchOrCreate": "プレイリストを検索するか、入力して新規作成します...", + "pressEnterToCreate": "Enter キーを押して新しいプレイリストを作成します", + "removeFromSelection": "選択から除外" }, "message": { - "duplicate_song": "重複する曲を追加", - "song_exist": "既にプレイリストに存在する曲です。追加しますか?", - "noPlaylistsFound": "プレイリストが見つかりません", - "noPlaylists": "利用可能なプレイリストはありません" + "duplicate_song": "重複している曲を追加", + "song_exist": "プレイリストに重複する曲が追加されようとしています。重複する曲を追加しますか、それともスキップしますか?", + "noPlaylistsFound": "プレイリストが見つかりませんでした", + "noPlaylists": "利用可能なプレイリストがありません" } }, "radio": { "name": "ラジオ", "fields": { "name": "名前", - "streamUrl": "配信URL", - "homePageUrl": "ホームページURL", - "updatedAt": "更新日", - "createdAt": "作成日" + "streamUrl": "ストリーム URL", + "homePageUrl": "ホームページ URL", + "updatedAt": "更新日時", + "createdAt": "作成日時" }, "actions": { - "playNow": "すぐに再生" + "playNow": "今すぐ再生" } }, "share": { @@ -244,55 +254,57 @@ "username": "共有者", "url": "URL", "description": "説明", + "downloadable": "ダウンロードを許可しますか?", "contents": "コンテンツ", - "expiresAt": "期限切れ", - "lastVisitedAt": "最後の訪問", - "visitCount": "訪問回数", - "format": "フォーマット", + "expiresAt": "有効期限", + "lastVisitedAt": "最終閲覧日時", + "visitCount": "閲覧回数", + "format": "形式", "maxBitRate": "最大ビットレート", - "updatedAt": "更新日", - "createdAt": "作成日", - "downloadable": "ダウンロードを許可しますか?" - } + "updatedAt": "更新日時", + "createdAt": "作成日時" + }, + "notifications": {}, + "actions": {} }, "missing": { - "name": "欠落したファイル", + "name": "欠落ファイル", + "empty": "欠落しているファイルはありません", "fields": { - "path": "パス", - "size": "サイズ", - "updatedAt": "欠落日", - "libraryName": "ライブラリ" + "path": "ファイルパス", + "size": "ファイルサイズ", + "libraryName": "ライブラリ名", + "updatedAt": "更新日時" }, "actions": { "remove": "削除", - "remove_all": "全て削除" + "remove_all": "すべて削除" }, "notifications": { "removed": "欠落ファイルが削除されました" - }, - "empty": "ファイルの欠落はありません" + } }, "library": { "name": "ライブラリ", "fields": { "name": "名前", "path": "パス", - "remotePath": "リモートパス", - "lastScanAt": "最終スキャン", + "remotePath": "リモートスキャン", + "lastScanAt": "最終スキャン日時", "songCount": "曲数", - "albumCount": "アルバム数", + "albumCount": "アルバム枚数", "artistCount": "アーティスト数", - "totalSongs": "曲数", - "totalAlbums": "アルバム数", - "totalArtists": "アーティスト数", - "totalFolders": "フォルダー数", - "totalFiles": "ファイル数", - "totalMissingFiles": "欠落したファイル", + "totalSongs": "総曲数", + "totalAlbums": "総アルバム枚数", + "totalArtists": "総アーティスト数", + "totalFolders": "フォルダー総数", + "totalFiles": "ファイル総数", + "totalMissingFiles": "欠落ファイル数", "totalSize": "合計サイズ", - "totalDuration": "合計時間", - "defaultNewUsers": "新規ユーザーに対するデフォルト", - "createdAt": "作成日", - "updatedAt": "更新日" + "totalDuration": "総再生時間", + "defaultNewUsers": "新規ユーザーのデフォルト設定", + "createdAt": "作成日時", + "updatedAt": "更新日時" }, "sections": { "basic": "基本情報", @@ -300,95 +312,172 @@ }, "actions": { "scan": "ライブラリをスキャン", - "manageUsers": "ユーザーアクセス管理", - "viewDetails": "詳細を表示", "quickScan": "クイックスキャン", - "fullScan": "フルスキャン" + "fullScan": "フルスキャン", + "manageUsers": "ユーザーを管理", + "viewDetails": "詳細を表示" }, "notifications": { "created": "ライブラリが正常に作成されました", "updated": "ライブラリが正常に更新されました", "deleted": "ライブラリが正常に削除されました", - "scanStarted": "スキャンを開始しました", - "scanCompleted": "スキャンが完了しました", - "quickScanStarted": "クイックスキャンを開始しました", - "fullScanStarted": "フルスキャンを開始しました", - "scanError": "スキャン開始中にエラーが発生。ログを確認してください" + "scanStarted": "ライブラリのスキャンが開始されました", + "quickScanStarted": "クイックスキャンが開始されました", + "fullScanStarted": "フルスキャンが開始されました", + "scanError": "スキャンを開始できませんでした。ログを確認してください", + "scanCompleted": "ライブラリのスキャンが完了しました" }, "validation": { - "nameRequired": "ライブラリの名前が必要です", - "pathRequired": "ライブラリのパスが必要です", + "nameRequired": "ライブラリ名は必須です", + "pathRequired": "ライブラリパスは必須です", "pathNotDirectory": "ライブラリパスはディレクトリである必要があります", - "pathNotFound": "ライブラリのパスが見つかりません", - "pathNotAccessible": "ライブラリパスへアクセスできません", + "pathNotFound": "ライブラリパスが見つかりません", + "pathNotAccessible": "ライブラリパスにアクセスできません", "pathInvalid": "無効なライブラリパス" }, "messages": { - "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。", + "deleteConfirm": "このライブラリを削除してもよろしいですか?これにより、関連するすべてのデータおよびユーザーのアクセス権が削除されます。", "scanInProgress": "スキャン中...", - "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません" + "noLibrariesAssigned": "このユーザーに割り当てられたライブラリはありません" + } + }, + "plugin": { + "name": "プラグイン", + "fields": { + "id": "ID", + "name": "名前", + "description": "説明", + "version": "バージョン", + "author": "作者", + "website": "ウェブサイト", + "permissions": "権限", + "enabled": "有効", + "status": "ステータス", + "path": "パス", + "lastError": "エラー", + "hasError": "エラー", + "updatedAt": "更新日時", + "createdAt": "インストール日時", + "configKey": "キー", + "configValue": "値", + "allUsers": "すべてのユーザーを許可", + "selectedUsers": "選択されたユーザー", + "allLibraries": "すべてのライブラリを許可", + "selectedLibraries": "選択されたライブラリ", + "allowWriteAccess": "書き込み権限を許可" + }, + "sections": { + "status": "ステータス", + "info": "プラグイン情報", + "configuration": "設定", + "manifest": "マニフェスト", + "usersPermission": "ユーザー権限", + "libraryPermission": "ライブラリ権限" + }, + "status": { + "enabled": "有効", + "disabled": "無効" + }, + "actions": { + "enable": "有効化", + "disable": "無効化", + "disabledDueToError": "有効化する前にエラーを修正してください", + "disabledUsersRequired": "有効化する前にユーザーを選択してください", + "disabledLibrariesRequired": "有効化する前にライブラリを選択してください", + "addConfig": "設定を追加", + "rescan": "再スキャン" + }, + "notifications": { + "enabled": "プラグインが有効化されました", + "disabled": "プラグインが無効化されました", + "updated": "プラグインが更新されました", + "error": "プラグインの更新中にエラーが発生しました" + }, + "validation": { + "invalidJson": "設定は有効な JSON である必要があります" + }, + "messages": { + "idHelp": "ファイル名から生成されるプラグイン ID です。エージェントなどの設定オプションでこのプラグインを参照する際に使用します。", + "configHelp": "キーと値のペアを使用してプラグインを設定します。設定が不要な場合は、空のままにしてください。", + "configValidationError": "設定の検証に失敗しました:", + "schemaRenderError": "設定フォームを表示できません。プラグインのスキーマが無効である可能性があります。", + "clickPermissions": "詳細を表示するには、「権限」をクリックしてください", + "noConfig": "設定がありません", + "allUsersHelp": "有効にすると、このプラグインは、今後作成されるユーザーも含め、すべてのユーザーにアクセスできるようになります。", + "noUsers": "ユーザーが選択されていません", + "permissionReason": "理由", + "usersRequired": "このプラグインは、ユーザー情報へのアクセスを必要とします。プラグインがアクセスできるユーザーを選択するか、「すべてのユーザーを許可」を有効にしてください。", + "allLibrariesHelp": "有効にすると、このプラグインは、今後作成されるライブラリも含め、すべてのライブラリにアクセスできるようになります。", + "noLibraries": "ライブラリが選択されていません", + "librariesRequired": "このプラグインは、ライブラリ情報へのアクセスを必要とします。プラグインがアクセスできるライブラリを選択するか、「すべてのライブラリを許可」を有効にしてください。", + "allowWriteAccessHelp": "有効にすると、プラグインはライブラリディレクトリ内のファイルを変更できるようになります。デフォルトでは、プラグインのアクセス権限は読み取り専用に設定されています。", + "requiredHosts": "必須ホスト" + }, + "placeholders": { + "configKey": "キー", + "configValue": "値" } } }, "ra": { "auth": { - "welcome1": "Navidromeをインストールいただきありがとうございます!", - "welcome2": "管理ユーザーを作成して始めましょう", + "welcome1": "Navidrome をインストールしていただき、ありがとうございます!", + "welcome2": "まずは、管理者ユーザーを作成してください", "confirmPassword": "パスワードの確認", - "buttonCreateAdmin": "管理者の作成", - "auth_check_error": "認証に失敗しました。再度ログインしてください", - "user_menu": "プロフィール", + "buttonCreateAdmin": "管理者を作成", + "auth_check_error": "続行するにはログインしてください", + "user_menu": "プロファイル", "username": "ユーザー名", "password": "パスワード", "sign_in": "ログイン", - "sign_in_error": "認証に失敗しました。入力を確認してください", + "sign_in_error": "認証に失敗しました。もう一度お試しください", "logout": "ログアウト", - "insightsCollectionNote": "Navidromeでは、プロジェクトの改善に役立てるため、匿名の利用データを収集しています。詳しくは [here] をクリックしてください。" + "insightsCollectionNote": "Navidrome では、プロジェクトの改善に役立てるため、匿名化された利用データを収集しています。詳細の確認やオプトアウト(収集の停止)を行うには、[こちら]をクリックしてください" }, "validation": { - "invalidChars": "文字と数字のみを使用してください", + "invalidChars": "英数字のみを使用してください", "passwordDoesNotMatch": "パスワードが一致しません", "required": "必須", - "minLength": "%{min}文字以上である必要があります", - "maxLength": "%{max}文字以下である必要があります", - "minValue": "%{min}以上である必要があります", - "maxValue": "%{max}以下である必要があります", + "minLength": "%{min} 文字以上である必要があります", + "maxLength": "%{max} 文字以下である必要があります", + "minValue": "%{min} 桁以上である必要があります", + "maxValue": "%{max} 桁以下である必要があります", "number": "数字である必要があります", - "email": "メールアドレスである必要があります", + "email": "有効なメールアドレスである必要があります", "oneOf": "次のいずれかである必要があります: %{options}", - "regex": "次の正規表現形式にする必要があります: %{pattern}", + "regex": "特定の形式(正規表現)に一致する必要があります: %{pattern}", "unique": "一意である必要があります", - "url": "有効なURLを入力してください" + "url": "有効な URL である必要があります" }, "action": { - "add_filter": "検索条件", + "add_filter": "フィルターを追加", "add": "追加", "back": "戻る", - "bulk_actions": "%{smart_count}件選択", + "bulk_actions": "1 件選択中 |||| %{smart_count} 件選択中", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "キャンセル", - "clear_input_value": "空にする", + "clear_input_value": "値を消去", "clone": "複製", "confirm": "確認", "create": "作成", "delete": "削除", "edit": "編集", "export": "エクスポート", - "list": "一覧", + "list": "リスト", "refresh": "更新", - "remove_filter": "検索条件を削除", + "remove_filter": "このフィルターを削除", "remove": "削除", "save": "保存", "search": "検索", - "show": "詳細", - "sort": "並び替え", + "show": "表示", + "sort": "並べ替え", "undo": "元に戻す", - "expand": "開く", + "expand": "展開", "close": "閉じる", - "open_menu": "開く", - "close_menu": "閉じる", + "open_menu": "メニューを開く", + "close_menu": "メニューを閉じる", "unselect": "選択解除", "skip": "スキップ", - "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "共有", "download": "ダウンロード" }, @@ -402,71 +491,71 @@ "edit": "%{name} #%{id}", "error": "問題が発生しました", "list": "%{name}", - "loading": "読込中", + "loading": "読み込み中", "not_found": "見つかりませんでした", "show": "%{name} #%{id}", - "empty": "%{name}はありません", - "invite": "作成しますか?" + "empty": "%{name} はまだありません。", + "invite": "追加してもよろしいですか?" }, "input": { "file": { - "upload_several": "アップロードするファイルをドロップ、または選択してください", - "upload_single": "アップロードするファイルをドロップ、または選択してください" + "upload_several": "ファイルをドロップしてアップロードするか、クリックしてファイルを選択してください。", + "upload_single": "ファイルをドロップしてアップロードするか、クリックして選択してください。" }, "image": { - "upload_several": "アップロードする画像をドロップ、または選択してください", - "upload_single": "アップロードする画像をドロップ、または選択してください" + "upload_several": "画像をドロップしてアップロードするか、クリックして画像を選択してください。", + "upload_single": "画像をドロップしてアップロードするか、クリックして画像を選択してください。" }, "references": { - "all_missing": "データが利用できなくなりました", - "many_missing": "選択したデータが利用できなくなりました", - "single_missing": "選択したデータが利用できなくなりました" + "all_missing": "参照データが見つかりませんでした。", + "many_missing": "参照データのうち、少なくとも 1 件が利用できなくなっています。", + "single_missing": "参照データは現在利用できません。" }, "password": { - "toggle_visible": "非表示", - "toggle_hidden": "表示" + "toggle_visible": "パスワードを非表示", + "toggle_hidden": "パスワードを表示" } }, "message": { - "about": "詳細", - "are_you_sure": "本当によろしいですか?", - "bulk_delete_content": "%{name} を削除してよろしいですか? |||| 選択した %{smart_count}件のアイテムを削除してよろしいですか?", - "bulk_delete_title": "%{name} を削除 |||| %{name} %{smart_count}件を削除", - "delete_content": "削除してよろしいですか?", + "about": "概要", + "are_you_sure": "よろしいですか?", + "bulk_delete_content": "%{name} を削除してもよろしいですか? |||| %{smart_count} 件の項目を削除してもよろしいですか?", + "bulk_delete_title": "%{name} を削除 |||| %{smart_count} 件の %{name} を削除", + "delete_content": "この項目を削除してもよろしいですか?", "delete_title": "%{name} #%{id} を削除", "details": "詳細", - "error": "クライアントエラーが発生し、処理を完了できませんでした", - "invalid_form": "入力値に誤りがあります。エラーメッセージを確認してください", - "loading": "読み込み中です。しばらくお待ちください", + "error": "クライアント側でエラーが発生したため、リクエストを完了できませんでした。", + "invalid_form": "フォームが無効です。エラーを確認してください", + "loading": "ページを読み込んでいます。しばらくお待ちください", "no": "いいえ", - "not_found": "間違ったURLを入力したか、間違ったリンクを辿りました", + "not_found": "URL を間違えて入力したか、無効なリンクをクリックした可能性があります。", "yes": "はい", - "unsaved_changes": "行った変更が保存されていません。このページから移動してよろしいですか?" + "unsaved_changes": "変更の一部が保存されませんでした。変更を破棄してもよろしいですか?" }, "navigation": { - "no_results": "結果が見つかりませんでした", - "no_more_results": "ページ番号 %{page} は最大のページ数を超えています。前のページに戻ってください", - "page_out_of_boundaries": "ページ番号 %{page} は最大のページ数を超えています", - "page_out_from_end": "最大のページ数より後に移動できません", - "page_out_from_begin": "1 ページより前に移動できません", + "no_results": "検索結果はありません", + "no_more_results": "ページ番号 %{page} は範囲外です。前のページに戻ってください。", + "page_out_of_boundaries": "ページ番号 %{page} は範囲外です", + "page_out_from_end": "最後のページより先には進めません", + "page_out_from_begin": "ページ 1 より前には戻れません", "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "表示件数:", + "page_rows_per_page": "1 ページあたりの表示件数:", "next": "次", "prev": "前", - "skip_nav": "スキップ" + "skip_nav": "コンテンツへスキップ" }, "notification": { - "updated": "更新しました |||| %{smart_count} 件更新しました", - "created": "作成しました", - "deleted": "削除しました |||| %{smart_count} 件削除しました", - "bad_item": "データが不正です", - "item_doesnt_exist": "データが存在しませんでした", - "http_error": "通信エラーが発生しました", - "data_provider_error": "dataProviderエラー。詳細はコンソールを確認してください", - "i18n_error": "翻訳ファイルが読み込めませんでした", - "canceled": "元に戻しました", - "logged_out": "認証に失敗しました。再度ログインしてください", - "new_version": "新しいバージョンが利用可能です!ページを更新してください。" + "updated": "更新されました |||| %{smart_count} 件の項目が更新されました", + "created": "作成されました", + "deleted": "削除されました |||| %{smart_count} 件の項目が削除されました", + "bad_item": "項目が無効です", + "item_doesnt_exist": "項目が存在しません", + "http_error": "サーバー通信エラーが発生しました", + "data_provider_error": "dataProvider のエラーが発生しました。詳細についてはコンソールを確認してください。", + "i18n_error": "指定された言語の翻訳を読み込めません", + "canceled": "操作がキャンセルされました", + "logged_out": "セッションが終了しました。再接続してください。", + "new_version": "新しいバージョンが利用可能になりました!このウィンドウを更新してください。" }, "toggleFieldsMenu": { "columnsToDisplay": "表示列", @@ -476,45 +565,59 @@ } }, "message": { - "note": "注意", - "transcodingDisabled": "セキュリティ上の理由から、Web インターフェイスからのトランスコード設定は無効になっています。\nこれを設定したい場合、環境変数 %{config} を設定しサーバーを再起動してください。", - "transcodingEnabled": "Navidromeは現在 %{config} の設定で実行されており、WebUIのトランスコード設定からコマンドを実行できます。\nセキュリティ上の問題から、この設定はトランスコード設定を変更する時のみ有効にすることを推奨します。", - "songsAddedToPlaylist": "プレイリストへ1曲追加しました |||| プレイリストへ%{smart_count}曲追加しました", - "noPlaylistsAvailable": "利用不可", - "delete_user_title": "'%{name}' を削除", - "delete_user_content": "このユーザーとその全てのデータ(プレイリストや設定)を削除してもよろしいですか?", - "notifications_blocked": "ブラウザの設定でこのサイトの通知がブロックされています", - "notifications_not_available": "このブラウザはデスクトップ通知をサポートしていません", - "lastfmLinkSuccess": "Last.fmとリンクしscrobbleが有効になりました", - "lastfmLinkFailure": "Last.fmとリンクできませんでした", - "lastfmUnlinkSuccess": "設定が解除され、Last.fmへのscrobbleは無効になっています", - "lastfmUnlinkFailure": "Last.fmとリンクできませんでした", + "uploadCover": "カバーをアップロード", + "removeCover": "カバーを削除", + "coverUploaded": "カバーアートが更新されました", + "coverRemoved": "カバーアートが削除されました", + "coverUploadError": "カバーアートのアップロード中にエラーが発生しました", + "coverRemoveError": "カバーアートの削除中にエラーが発生しました", + "metadataRefreshStarted": "メタデータの更新が開始されました", + "note": "注記", + "transcodingDisabled": "セキュリティ上の理由により、ウェブインターフェース経由でのトランスコード設定の変更は無効になっています。トランスコードのオプションを変更(編集または追加)する場合は、%{config} 設定オプションを指定してサーバーを再起動してください。", + "transcodingEnabled": "Navidrome は現在 %{config} で動作しており、ウェブインターフェースのトランスコード設定からシステムコマンドを実行できる状態になっています。セキュリティ上の理由から、この機能を無効にしておき、トランスコードのオプションを設定する際のみ有効にすることをお勧めします。", + "songsAddedToPlaylist": "1 曲をプレイリストに追加しました |||| %{smart_count} 曲をプレイリストに追加しました", + "noSimilarSongsFound": "類似の曲は見つかりませんでした", + "startingInstantMix": "インスタントミックスを読み込み中...", + "noTopSongsFound": "人気曲は見つかりませんでした", + "noPlaylistsAvailable": "利用可能なプレイリストがありません", + "delete_user_title": "ユーザー「%{name}」を削除", + "delete_user_content": "このユーザーと、そのすべてのデータ(プレイリストや設定を含む)を削除してもよろしいですか?", + "remove_missing_title": "欠落ファイルを削除", + "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生回数や評価を含む、これらのファイルに関するすべての情報が完全に削除されます。", + "remove_all_missing_title": "すべての欠落ファイルを削除", + "remove_all_missing_content": "すべての欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生回数や評価を含む、これらのファイルに関するすべての情報が完全に削除されます。", + "notifications_blocked": "お使いのブラウザーの設定で、このサイトからの通知をブロックしています", + "notifications_not_available": "このブラウザーはデスクトップ通知に対応していないか、または Navidrome に HTTPS 経由でアクセスしていない可能性があります", + "lastfmLinkSuccess": "Last.fm との連携に成功し、Scrobbling 機能が有効になりました", + "lastfmLinkFailure": "Last.fm との連携に失敗しました", + "lastfmUnlinkSuccess": "Last.fm との連携が解除され、Scrobbling 機能が無効になりました", + "lastfmUnlinkFailure": "Last.fm との連携解除に失敗しました", + "listenBrainzLinkSuccess": "ListenBrainz との連携に成功し、ユーザー %{user} として Scrobbling 機能が有効になりました", + "listenBrainzLinkFailure": "ListenBrainz との連携に失敗しました: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz との連携が解除され、Scrobbling 機能が無効になりました", + "listenBrainzUnlinkFailure": "ListenBrainz との連携解除に失敗しました", "openIn": { - "lastfm": "Last.fmで開く", - "musicbrainz": "MusicBrainzで開く" + "lastfm": "Last.fm で開く", + "musicbrainz": "MusicBrainz で開く" }, "lastfmLink": "続きを読む", - "listenBrainzLinkSuccess": "%{user} へのscrobbling設定に成功しました", - "listenBrainzLinkFailure": "ListenBrainzとのリンクに失敗しました: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainzとのリンクとscrobblingを無効化しました。", - "listenBrainzUnlinkFailure": "ListenBrainzとのリンクを解除できませんでした", - "downloadOriginalFormat": "元のフォーマットでダウンロード", - "shareOriginalFormat": "元のフォーマットで共有", - "shareDialogTitle": "%{resource} '%{name}' を共有", + "shareOriginalFormat": "オリジナルの形式で共有", + "shareDialogTitle": "%{resource}「%{name}」を共有", "shareBatchDialogTitle": "1 %{resource} を共有 |||| %{smart_count} %{resource} を共有", - "shareSuccess": "コピーしました: %{url}", - "shareFailure": "コピーに失敗しました %{url}", - "downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter", - "remove_missing_title": "欠落ファイルを削除", - "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。", - "remove_all_missing_title": "全ての欠落ファイルを削除", - "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。", - "noSimilarSongsFound": "類似の曲が見つかりませんでした", - "noTopSongsFound": "トップソングが見つかりません" + "shareCopyToClipboard": "クリップボードにコピー: Ctrl+C、Enter", + "shareSuccess": "URL をクリップボードにコピーしました: %{url}", + "shareFailure": "URL %{url} をクリップボードにコピーできませんでした", + "downloadDialogTitle": "%{resource}「%{name}」(%{size}) をダウンロード", + "downloadOriginalFormat": "オリジナルの形式でダウンロード" }, "menu": { "library": "ライブラリ", + "librarySelector": { + "allLibraries": "すべてのライブラリ (%{count})", + "multipleLibraries": "%{total} 件のライブラリのうち %{selected} 件", + "selectLibraries": "ライブラリを選択", + "none": "無し" + }, "settings": "設定", "version": "バージョン", "theme": "テーマ", @@ -523,53 +626,48 @@ "options": { "theme": "テーマ", "language": "言語", - "defaultView": "デフォルト画面", + "defaultView": "デフォルトの画面", "desktop_notifications": "デスクトップ通知", - "lastfmScrobbling": "Last.fmへscrobbleする", - "listenBrainzScrobbling": "ListenBrainzへscrobble", - "replaygain": "ReplayGainモード", - "preAmp": "プリアンプ", + "lastfmNotConfigured": "Last.fm API キーが設定されていません。", + "lastfmScrobbling": "Last.fm に Scrobble", + "listenBrainzScrobbling": "ListenBrainz に Scrobble", + "replaygain": "リプレイゲインモード", + "preAmp": "リプレイゲインプリアンプ (dB)", "gain": { "none": "無効", - "album": "アルバムゲインを使う", - "track": "トラックゲインを使う" - }, - "lastfmNotConfigured": "Last.fmのAPIキーが設定されていません" + "album": "アルバムゲインを使用", + "track": "トラックゲインを使用" + } } }, "albumList": "アルバム", - "about": "詳細", "playlists": "プレイリスト", + "onlyFavourites": "お気に入りのみ表示", "sharedPlaylists": "共有プレイリスト", - "librarySelector": { - "allLibraries": "全てのライブラリ( %{count} )", - "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ", - "selectLibraries": "ライブラリを選択", - "none": "無し" - } + "about": "概要" }, "player": { - "playListsText": "再生リスト", + "playListsText": "再生キュー", "openText": "開く", "closeText": "閉じる", "notContentText": "音楽がありません", "clickToPlayText": "クリックして再生", - "clickToPauseText": "一時停止", + "clickToPauseText": "クリックして一時停止", "nextTrackText": "次の曲", "previousTrackText": "前の曲", - "reloadText": "更新", + "reloadText": "再読み込み", "volumeText": "音量", - "toggleLyricText": "歌詞を切り替え", + "toggleLyricText": "歌詞の表示 / 非表示", "toggleMiniModeText": "最小化", - "destroyText": "削除", + "destroyText": "破棄", "downloadText": "ダウンロード", - "removeAudioListsText": "リストを空にする", - "clickToDeleteText": "クリックして%{name}を削除", + "removeAudioListsText": "オーディオリストを削除", + "clickToDeleteText": "クリックして %{name} を削除", "emptyLyricText": "歌詞がありません", "playModeText": { - "order": "順番に", - "orderLoop": "リピート", - "singleLoop": "一曲リピート", + "order": "順番に再生", + "orderLoop": "繰り返し再生", + "singleLoop": "1 曲のみ繰り返し再生", "shufflePlay": "シャッフル" } }, @@ -578,14 +676,14 @@ "homepage": "ホームページ", "source": "ソースコード", "featureRequests": "機能リクエスト", - "lastInsightsCollection": "最後のデータ収集", + "lastInsightsCollection": "最終データ収集日時", "insights": { "disabled": "無効", "waiting": "待機中" } }, "tabs": { - "about": "詳細", + "about": "概要", "config": "設定" }, "config": { @@ -593,42 +691,43 @@ "environmentVariable": "環境変数", "currentValue": "現在値", "configurationFile": "設定ファイル", - "exportToml": "設定をエクスポート(TOML)", - "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました", + "exportToml": "設定をエクスポート (TOML)", + "downloadToml": "設定をダウンロード (TOML)", + "exportSuccess": "設定が TOML 形式でクリップボードにエクスポートされました", "exportFailed": "設定のコピーに失敗しました", - "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)", + "devFlagsHeader": "開発用フラグ(変更または削除される可能性があります)", "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります" } }, "activity": { - "title": "活動", - "totalScanned": "スキャン済みフォルダー", + "title": "アクティビティ", + "totalScanned": "スキャンされたフォルダーの総数", "quickScan": "クイック", "fullScan": "フル", + "selectiveScan": "選択的", "serverUptime": "サーバー稼働時間", - "serverDown": "サーバーオフライン", + "serverDown": "オフライン", "scanType": "最終スキャン", "status": "スキャンエラー", - "elapsedTime": "経過時間", - "selectiveScan": "選択的スキャン" - }, - "help": { - "title": "ホットキー", - "hotkeys": { - "show_help": "このヘルプを表示", - "toggle_menu": "サイドバーの表示/非表示", - "toggle_play": "再生/停止", - "prev_song": "前の曲", - "next_song": "次の曲", - "vol_up": "音量を上げる", - "vol_down": "音量を下げる", - "toggle_love": "星の付け外し", - "current_song": "現在の曲へ移動" - } + "elapsedTime": "経過時間" }, "nowPlaying": { "title": "再生中", - "empty": "何も再生されていません", - "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前" + "empty": "再生中の曲はありません", + "minutesAgo": "%{smart_count} 分前" + }, + "help": { + "title": "Navidrome ホットキー", + "hotkeys": { + "show_help": "ヘルプを表示", + "toggle_menu": "サイドバーの表示 / 非表示", + "toggle_play": "再生 / 一時停止", + "prev_song": "前の曲", + "next_song": "次の曲", + "current_song": "再生中の曲へ移動", + "vol_up": "音量を上げる", + "vol_down": "音量を下げる", + "toggle_love": "この曲をお気に入り登録 / 登録解除" + } } -} \ No newline at end of file +} From 9198bde34ad1b75060773de32ff3bb111b9d6ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 6 Sep 2026 13:40:43 -0400 Subject: [PATCH 510/596] test(scanner): fix Windows flake in the quick-scan artist image spec (#6093) The spec asserted on artistID("Kraftwerk") and intermittently found zero artists on Windows. The scan did import the artist; it was then made invisible. RefreshStats selects touched artists with a strict artist.updated_at > library.last_scan_at (persistence/artist_repository.go:466). Windows' wall clock has ~15ms granularity, so a new artist written by a quick scan can land in the same tick as the previous scan's last_scan_at and be excluded. Its library_artist.stats then stays at the '{}' default and the unscoped cleanup DELETE removes the row, after which selectArtist's INNER JOIN on library_artist hides the artist from GetAll. Backdate last_scan_at before the scan so the comparison is unambiguous, matching the fix already applied to the search_normalized spec below it. --- scanner/scanner_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 00f91699d..8542b3ac6 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -387,6 +387,11 @@ var _ = Describe("Scanner", Ordered, func() { files := fsys.MapFS files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn")) fsys.SetFiles(files) + // Backdate the previous scan so this one's new artists are unambiguously newer: + // RefreshStats picks touched artists with a strict artist.updated_at > + // library.last_scan_at, and Windows' coarse clock can put both in one tick. + _, err := db.Db().ExecContext(ctx, "UPDATE library SET last_scan_at = ?", time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) Expect(runScanner(ctx, false)).To(Succeed()) resolveQueuedArtwork() From 97e1f73cc85bc35eb1d998e9e6a987b239552537 Mon Sep 17 00:00:00 2001 From: Shxiao <shxiao1115@163.com> Date: Mon, 7 Sep 2026 12:08:49 +0900 Subject: [PATCH 511/596] docs: fix broken links in Jellyfin and plugin documentation (#6097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: point jftui client link to canonical repository * docs: fix relative path to webhook-rs example in nd-pdk-host README * docs: fix capability schema paths in plugin examples README --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- plugins/examples/README.md | 4 ++-- plugins/pdk/rust/nd-pdk-host/README.md | 2 +- server/jellyfin/README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/examples/README.md b/plugins/examples/README.md index bce2b6762..8e2dc5a85 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -108,7 +108,7 @@ Generate boilerplate from a schema: # Install XTP: https://docs.xtp.dylibso.com/docs/cli xtp plugin init \ - --schema-file ../schemas/metadata_agent.yaml \ + --schema-file ../capabilities/metadata_agent.yaml \ --template go \ --path ./my-plugin \ --name my-plugin @@ -119,7 +119,7 @@ xtp plugin build zip -j my-plugin.ndp manifest.json dist/plugin.wasm ``` -Available schemas in [../schemas/](../schemas/): +Available schemas in [../capabilities/](../capabilities/): - `metadata_agent.yaml` – Artist/album metadata - `scrobbler.yaml` – Scrobbling integration - `lifecycle.yaml` – Init callbacks diff --git a/plugins/pdk/rust/nd-pdk-host/README.md b/plugins/pdk/rust/nd-pdk-host/README.md index f722b2e5a..00bcdfc2f 100644 --- a/plugins/pdk/rust/nd-pdk-host/README.md +++ b/plugins/pdk/rust/nd-pdk-host/README.md @@ -84,4 +84,4 @@ Rust plugins must be compiled to WebAssembly: cargo build --target wasm32-wasip1 --release ``` -See the [webhook-rs](../../examples/webhook-rs/) example for a complete plugin implementation. +See the [webhook-rs](../../../examples/webhook-rs/) example for a complete plugin implementation. diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 15b56a499..5b9a2dba4 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -3,7 +3,7 @@ This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), -[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +[jftui](https://github.com/Aanok/jftui)) browse and stream a Navidrome library without requiring a real Jellyfin server. It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music From 1ceb25c6c1cc0698efcf164010cf8a25092d0024 Mon Sep 17 00:00:00 2001 From: jaxi <jaxi@cmyksoda.cc> Date: Sun, 6 Sep 2026 23:28:25 -0400 Subject: [PATCH 512/596] =?UTF-8?q?feat(ui):=20added=20Catppuccin=20Mocha?= =?UTF-8?q?=20and=20Frapp=C3=A9=20themes,=20updated=20Macchiato=20theme=20?= =?UTF-8?q?to=20better=20reflect=20the=20official=20palette=20(#5835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added catppuccin mocha theme * added catppuccin mocha theme to index.js * fix syntax * add catppuccin frappé theme * made frappe and mocha themes more consistant with official color palette and added comments for easy verification * same for macchiato, seperate commit in case original is preferred * added comments to .js files --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/themes/catppuccinFrappe.css.js | 158 ++++++++++++++++++++ ui/src/themes/catppuccinFrappe.js | 106 +++++++++++++ ui/src/themes/catppuccinLatte.css.js | 54 +++---- ui/src/themes/catppuccinMacchiato.css.js | 180 ++++++++++++----------- ui/src/themes/catppuccinMacchiato.js | 50 +++---- ui/src/themes/catppuccinMocha.css.js | 158 ++++++++++++++++++++ ui/src/themes/catppuccinMocha.js | 106 +++++++++++++ ui/src/themes/index.js | 6 +- 8 files changed, 677 insertions(+), 141 deletions(-) create mode 100644 ui/src/themes/catppuccinFrappe.css.js create mode 100644 ui/src/themes/catppuccinFrappe.js create mode 100644 ui/src/themes/catppuccinMocha.css.js create mode 100644 ui/src/themes/catppuccinMocha.js diff --git a/ui/src/themes/catppuccinFrappe.css.js b/ui/src/themes/catppuccinFrappe.css.js new file mode 100644 index 000000000..f927d3ba1 --- /dev/null +++ b/ui/src/themes/catppuccinFrappe.css.js @@ -0,0 +1,158 @@ +const stylesheet = ` + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #949cbb; /* overlay2 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #a5adce !important; + } + + .react-jinke-music-player-main .loading svg { + color: #a5adce !important; + } + + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + + .rc-slider-rail, .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #292c3c; /* mantle */ + color: #c6d0f5; /* text */ + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); + } + + .audio-lists-panel { + background-color: #292c3c; /* mantle */ + bottom: 6.25rem; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color:rgba(255, 255, 255, 0.08); + } + + .audio-lists-panel-header { + border-bottom:1px solid #414559; /* surface0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #a5adce; /* subtext0 */ + -webkit-text-stroke: 0.5px #303446; /* base */ + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #a5adce !important; + } + + .audio-lists-panel-header { + border-bottom:1px solid rgba(0, 0, 0, 0.25); + box-shadow:none; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #a5adce; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #a5adce; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #51576d; /* surface1 */ + } + +/* Mobile */ + + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122,160,236,.3); + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinFrappe.js b/ui/src/themes/catppuccinFrappe.js new file mode 100644 index 000000000..87ff6258f --- /dev/null +++ b/ui/src/themes/catppuccinFrappe.js @@ -0,0 +1,106 @@ +import stylesheet from './catppuccinFrappe.css.js' + +export default { + themeName: 'Catppuccin Frappé', + palette: { + primary: { + main: '#ca9ee6', // Mauve + }, + secondary: { + main: '#292c3c', //Mantle + contrastText: '#c6d0f5', // Text + }, + type: 'dark', + background: { + default: '#303446', // Base + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#c6d0f5', // Text + backgroundColor: '#292c3c', // Mantle + MuiSnackbarContent: { + root: { + color: '#c6d0f5', // Text + backgroundColor: '#e78284', // Red + }, + message: { + color: '#c6d0f5', // Text + backgroundColor: '#e78284', // Red + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#8caaee', // Blue + }, + textSecondary: { + color: '#c6d0f5', // Text + }, + }, + MuiChip: { + clickable: { + background: '#292c3c', //Mantle + }, + }, + MuiFormGroup: { + root: { + color: '#c6d0f5', // Text + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#e78284', // Red + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#c6d0f5', // Text + background: '#292c3c', //Mantle + }, + }, + MuiTableCell: { + root: { + color: '#c6d0f5', // Text + background: '#292c3c !important', //Mantle + }, + head: { + color: '#c6d0f5', // Text + background: '#292c3c !important', //Mantle + }, + }, + NDLogin: { + systemNameLink: { + color: '#ca9ee6', // Mauve + }, + icon: {}, + welcome: { + color: '#c6d0f5', // Text + }, + card: { + minWidth: 300, + background: '#303446', // Base + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #303446', // Base + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js index 84c8d2d7f..006601cd9 100644 --- a/ui/src/themes/catppuccinLatte.css.js +++ b/ui/src/themes/catppuccinLatte.css.js @@ -2,41 +2,41 @@ const stylesheet = ` .react-jinke-music-player-main.light-theme svg, .react-jinke-music-player .music-player-controller, .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { - color: #6c6f85; - stroke: #6c6f85; + color: #6c6f85; /* subtext0 */ + stroke: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #7c7f93; + color: #7c7f93; /* overlay2 */ } .react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { - color: #7c7f93; + color: #7c7f93; /* overlay2 */ } .react-jinke-music-player-mobile-play-model-tip, .react-jinke-music-player-main.light-theme .play-mode-title { - background-color: #6c6f85; - color: #eff1f5; + background-color: #6c6f85; /* subtext0 */ + color: #eff1f5; /* base */ } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #6c6f85; + background-color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #6c6f85; + background-color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #6c6f85; + box-shadow: 0 0 2px #6c6f85; /* subtext0 */ } .react-jinke-music-player-main .audio-item.playing svg { - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main .audio-item.playing .player-singer { @@ -65,7 +65,7 @@ const stylesheet = ` .react-jinke-music-player-main.light-theme .rc-switch-checked { background-color: #6c6f85 !important; - border: 1px solid #6c6f85; + border: 1px solid #6c6f85; /* subtext0 */ } .sound-operation > div:nth-child(4) { @@ -77,17 +77,17 @@ const stylesheet = ` } .react-jinke-music-player-main .music-player-panel { - background-color: #e6e9ef; - color: #4c4f69; + background-color: #e6e9ef; /* mantle */ + color: #4c4f69; /* text */ box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); } .react-jinke-music-player-main.light-theme .music-player-panel { - color: #4c4f69; + color: #4c4f69; /* text */ } .audio-lists-panel { - background-color: #e6e9ef; + background-color: #e6e9ef; /* mantle */ bottom: 6.25rem; box-shadow: rgba(76, 79, 105, 0.12) 0px 4px 6px, @@ -108,7 +108,7 @@ const stylesheet = ` } .audio-lists-panel-header { - border-bottom: 1px solid #ccd0da; + border-bottom: 1px solid #ccd0da; /* surface0 */ box-shadow: none; } @@ -118,13 +118,13 @@ const stylesheet = ` } .react-jinke-music-player-main.light-theme .audio-lists-panel-header { - background-color: #e6e9ef; - color: #4c4f69; + background-color: #e6e9ef; /* mantle */ + color: #4c4f69; /* text */ } .audio-lists-panel-content .audio-item { line-height: 32px; - color: #4c4f69; + color: #4c4f69; /* text */ } .react-jinke-music-player-main .music-player-panel .panel-content .img-content { @@ -135,7 +135,7 @@ const stylesheet = ` .react-jinke-music-player-main .music-player-lyric { color: #6c6f85; /* subtext0 */ - -webkit-text-stroke: 0.35px #eff1f5; + -webkit-text-stroke: 0.35px #eff1f5; /* base */ font-weight: bolder; } @@ -146,12 +146,12 @@ const stylesheet = ` .audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .audio-lists-panel-content .audio-item .player-icons { @@ -173,12 +173,12 @@ const stylesheet = ` .react-jinke-music-player .music-player-controller { border: none; - background-color: #e6e9ef; - border-color: #e6e9ef; + background-color: #e6e9ef; /* mantle */ + border-color: #e6e9ef; /* mantle */ box-shadow: rgba(76, 79, 105, 0.12) 0px 4px 6px, rgba(76, 79, 105, 0.08) 0px 5px 7px; - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .react-jinke-music-player .music-player-controller.music-player-playing:before { @@ -187,12 +187,12 @@ const stylesheet = ` .react-jinke-music-player .music-player-controller .music-player-controller-setting { background: rgba(108, 111, 133, 0.2); - color: #eff1f5; + color: #eff1f5; /* base */ } .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #6c6f85; + background-color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-mobile-progress .rc-slider-handle { diff --git a/ui/src/themes/catppuccinMacchiato.css.js b/ui/src/themes/catppuccinMacchiato.css.js index d303a0364..e415a990b 100644 --- a/ui/src/themes/catppuccinMacchiato.css.js +++ b/ui/src/themes/catppuccinMacchiato.css.js @@ -1,154 +1,158 @@ const stylesheet = ` + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #939ab7; /* overlay2 */ + } -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #00a4dc -} + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #00a4dc -} + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #00a4dc; -} + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #00a4dc -} + .react-jinke-music-player-main .audio-item.playing svg { + color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main .audio-item.playing svg { - color: #00a4dc -} + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #a5adcb !important; + } -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #00a4dc !important -} - -.react-jinke-music-player-main .loading svg { - color: #00a4dc !important -} + .react-jinke-music-player-main .loading svg { + color: #a5adcb !important; + } -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { border: hidden; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.rc-slider-rail, .rc-slider-track { + .rc-slider-rail, .rc-slider-track { height: 6px; -} + } -.rc-slider { + .rc-slider { padding: 3px 0; -} + } -.sound-operation > div:nth-child(4) { + .sound-operation > div:nth-child(4) { transform: translateX(-50%) translateY(5%) !important; -} + } -.sound-operation { + .sound-operation { padding: 4px 0; -} + } -.react-jinke-music-player-main .music-player-panel { - background-color: #1e2030; - color: #24273a + .react-jinke-music-player-main .music-player-panel { + background-color: #1e2030; /* mantle */ + color: #cad3f5; /* text */ box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); -} + } -.audio-lists-panel { - background-color: #1e2030; + .audio-lists-panel { + background-color: #1e2030; /* mantle */ bottom: 6.25rem; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.audio-lists-panel-content .audio-item.playing { + .audio-lists-panel-content .audio-item.playing { background-color: rgba(0, 0, 0, 0); -} + } -.audio-lists-panel-content .audio-item:nth-child(2n+1) { + .audio-lists-panel-content .audio-item:nth-child(2n+1) { background-color: rgba(0, 0, 0, 0); -} + } -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { background-color:rgba(255, 255, 255, 0.08); -} + } -.audio-lists-panel-header { - border-bottom:1px solid #242936; -} + .audio-lists-panel-header { + border-bottom:1px solid #363a4f; /* surface0 */ + } -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { background-color:rgba(0,0,0,0); box-shadow:0 0 0 0; -} + } -.audio-lists-panel-content .audio-item { + .audio-lists-panel-content .audio-item { line-height: 32px; -} + } -.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.react-jinke-music-player-main .music-player-lyric { - color: #acb3d2; - -webkit-text-stroke: 0.5px #2E3440; + .react-jinke-music-player-main .music-player-lyric { + color: #a5adcb; /* subtext0 */ + -webkit-text-stroke: 0.5px #1e1e2e; /* base */ font-weight: bolder; -} + } -.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { - color: #acb3d2 !important; -} + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #a5adcb !important; + } -.audio-lists-panel-header { + .audio-lists-panel-header { border-bottom:1px solid rgba(0, 0, 0, 0.25); box-shadow:none; -} + } -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #00a4dc -} + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #a5adcb; /* subtext0 */ + } -.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #00a4dc -} + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #a5adcb; /* subtext0 */ + } -.audio-lists-panel-content .audio-item .player-icons { + .audio-lists-panel-content .audio-item .player-icons { scale: 75%; -} + } -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { - background-color: #363a4f; -} + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #494d64; /* surface1 */ + } /* Mobile */ -.react-jinke-music-player-mobile-cover { + .react-jinke-music-player-mobile-cover { border: none; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.react-jinke-music-player .music-player-controller { + .react-jinke-music-player .music-player-controller { border: none; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - color: #00a4dc; -} + color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player .music-player-controller .music-player-controller-setting { + .react-jinke-music-player .music-player-controller .music-player-controller-setting { color: rgba(122,160,236,.3); -} + } -.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #00a4dc; -} + .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-mobile-progress .rc-slider-handle { + .react-jinke-music-player-mobile-progress .rc-slider-handle { border: none; -} + } ` export default stylesheet diff --git a/ui/src/themes/catppuccinMacchiato.js b/ui/src/themes/catppuccinMacchiato.js index 63c93ff66..b41a7b2ea 100644 --- a/ui/src/themes/catppuccinMacchiato.js +++ b/ui/src/themes/catppuccinMacchiato.js @@ -4,92 +4,92 @@ export default { themeName: 'Catppuccin Macchiato', palette: { primary: { - main: '#c6a0f6', + main: '#c6a0f6', // Mauve }, secondary: { - main: '#1e2030', - contrastText: '#cad3f5', + main: '#1e2030', // Mantle + contrastText: '#cad3f5', // Text }, type: 'dark', background: { - default: '#24273a', + default: '#24273a', // Base }, }, overrides: { MuiPaper: { root: { - color: '#cad3f5', - backgroundColor: '#1e2030', + color: '#cad3f5', // Text + backgroundColor: '#1e2030', // Mantle MuiSnackbarContent: { root: { - color: '#cad3f5', - backgroundColor: '#ed8796', + color: '#cad3f5', // Text + backgroundColor: '#ed8796', // Red }, message: { - color: '#cad3f5', - backgroundColor: '#ed8796', + color: '#cad3f5', // Text + backgroundColor: '#ed8796', // Red }, }, }, }, MuiButton: { textPrimary: { - color: '#8aadf4', + color: '#8aadf4', // Blue }, textSecondary: { - color: '#cad3f5', + color: '#cad3f5', // Text }, }, MuiChip: { clickable: { - background: '#1e2030', + background: '#1e2030', // Mantle }, }, MuiFormGroup: { root: { - color: '#cad3f5', + color: '#cad3f5', // Text }, }, MuiFormHelperText: { root: { Mui: { error: { - color: '#ed8796', + color: '#ed8796', // Red }, }, }, }, MuiTableHead: { root: { - color: '#cad3f5', - background: '#1e2030', + color: '#cad3f5', // Text + background: '#1e2030', // Mantle }, }, MuiTableCell: { root: { - color: '#cad3f5', - background: '#1e2030 !important', + color: '#cad3f5', // Text + background: '#1e2030 !important', // Mantle }, head: { - color: '#cad3f5', - background: '#1e2030 !important', + color: '#cad3f5', // Text + background: '#1e2030 !important', // Mantle }, }, NDLogin: { systemNameLink: { - color: '#c6a0f6', + color: '#c6a0f6', // Mauve }, icon: {}, welcome: { - color: '#cad3f5', + color: '#cad3f5', // Text }, card: { minWidth: 300, - background: '#24273a', + background: '#24273a', // Base }, avatar: {}, button: { - boxShadow: '3px 3px 5px #24273a', + boxShadow: '3px 3px 5px #24273a', // Base }, }, NDMobileArtistDetails: { diff --git a/ui/src/themes/catppuccinMocha.css.js b/ui/src/themes/catppuccinMocha.css.js new file mode 100644 index 000000000..7a238468a --- /dev/null +++ b/ui/src/themes/catppuccinMocha.css.js @@ -0,0 +1,158 @@ +const stylesheet = ` + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #9399b2; /* overlay2 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #a6adc8 !important; + } + + .react-jinke-music-player-main .loading svg { + color: #a6adc8 !important; + } + + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + + .rc-slider-rail, .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #181825; /* mantle */ + color: #cdd6f4; /* text */ + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); + } + + .audio-lists-panel { + background-color: #181825; /* mantle */ + bottom: 6.25rem; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color:rgba(255, 255, 255, 0.08); + } + + .audio-lists-panel-header { + border-bottom:1px solid #313244; /* surface0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #a6adc8; /* subtext0 */ + -webkit-text-stroke: 0.5px #1e1e2e; /* base */ + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #a6adc8 !important; + } + + .audio-lists-panel-header { + border-bottom:1px solid rgba(0, 0, 0, 0.25); + box-shadow:none; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #a6adc8; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #a6adc8; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #45475a; /* surface1 */ + } + +/* Mobile */ + + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122,160,236,.3); + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinMocha.js b/ui/src/themes/catppuccinMocha.js new file mode 100644 index 000000000..bdfb33520 --- /dev/null +++ b/ui/src/themes/catppuccinMocha.js @@ -0,0 +1,106 @@ +import stylesheet from './catppuccinMocha.css.js' + +export default { + themeName: 'Catppuccin Mocha', + palette: { + primary: { + main: '#cba6f7', // Mauve + }, + secondary: { + main: '#181825', // Mantle + contrastText: '#cdd6f4', // Text + }, + type: 'dark', + background: { + default: '#1e1e2e', // Base + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#cdd6f4', // Text + backgroundColor: '#181825', // Mantle + MuiSnackbarContent: { + root: { + color: '#cdd6f4', // Text + backgroundColor: '#f38ba8', // Red + }, + message: { + color: '#cdd6f4', // Text + backgroundColor: '#f38ba8', // Red + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#89b4fa', // Blue + }, + textSecondary: { + color: '#cdd6f4', // Text + }, + }, + MuiChip: { + clickable: { + background: '#181825', // Mantle + }, + }, + MuiFormGroup: { + root: { + color: '#cdd6f4', // Text + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#f38ba8', // Red + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#cdd6f4', // Text + background: '#181825', // Mantle + }, + }, + MuiTableCell: { + root: { + color: '#cdd6f4', // Text + background: '#181825 !important', // Mantle + }, + head: { + color: '#cdd6f4', // Text + background: '#181825 !important', // Mantle + }, + }, + NDLogin: { + systemNameLink: { + color: '#cba6f7', // Mauve + }, + icon: {}, + welcome: { + color: '#cdd6f4', // Text + }, + card: { + minWidth: 300, + background: '#1e1e2e', // Base + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #1e1e2e', // Base + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 98705da30..391a97f2c 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -8,7 +8,9 @@ import MonokaiTheme from './monokai' import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' +import CatppuccinFrappeTheme from './catppuccinFrappe' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import CatppuccinMochaTheme from './catppuccinMocha' import CatppuccinLatteTheme from './catppuccinLatte' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' @@ -31,8 +33,10 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, - CatppuccinMacchiatoTheme, + CatppuccinFrappeTheme, CatppuccinLatteTheme, + CatppuccinMacchiatoTheme, + CatppuccinMochaTheme, DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, From 48af781b82524fcb1dd9e4b1a0a3d35c7405428b Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 7 Sep 2026 13:47:45 -0400 Subject: [PATCH 513/596] fix(reflex): exclude .worktrees from the reflex configuration regex --- reflex.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex.conf b/reflex.conf index 47dd775ab..1cbaa7bb7 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 . +-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 . From 404837799b9e7fc378625a1e8e1834df5c3533a3 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 7 Sep 2026 13:43:26 -0400 Subject: [PATCH 514/596] fix(subsonic): don't re-encode a source already in the player's forced format When a player has a forced transcoding format, ClientInfo.ForceFormat cleared DirectPlayProfiles unconditionally. A FLAC source on a player configured to transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the resulting FLAC has total_samples=0 and no seek table -- an offline copy of it can never be seeked. Reported against getTranscodeDecision by the Symfonium author. ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding profiles instead of dropping them: a client declaring a transcoding profile for a format is proof it can consume that format, so a source already in it is served as-is. Container and codec come from resolveTargetFormat, so a legacy "oga" target_format yields an ogg/opus profile, and the profile's MaxAudioChannels is carried across. DirectPlayProfile has no bitrate field, so restoring direct play needs a ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now seeds that ceiling from the transcoding row's DefaultBitRate when a format was successfully forced, with the player's own MaxBitRate still taking precedence. This also closes a gap where the new endpoint ignored DefaultBitRate entirely: an mp3 320 source on a player forced to mp3@192 was served at 320, while the legacy /rest/stream path correctly gave 192. Applied via CapBitrate, which only ever lowers, so a client declaring a stricter limit keeps it. The legacy path (applyServerOverride) is untouched -- ForceFormat has no other callers. --- core/stream/decider_test.go | 76 +++++++++++++++++++++++++++++++ core/stream/types.go | 26 +++++++---- core/stream/types_test.go | 32 ++++++++++++- server/subsonic/transcode.go | 25 ++++++---- server/subsonic/transcode_test.go | 45 +++++++++++++++++- 5 files changed, 183 insertions(+), 21 deletions(-) diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 577207636..01fef1249 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() { }) }) + Context("Player-forced format", func() { + symfonium := func() *ClientInfo { + return &ClientInfo{ + Name: "Symfonium", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + } + + It("direct plays a flac source forced to flac", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := symfonium() + Expect(ci.ForceFormat("flac")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("still transcodes a 24-bit flac when the client caps bit depth", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := symfonium() + ci.CodecProfiles = []CodecProfile{{ + Type: CodecProfileTypeAudio, Name: "flac", + Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}}, + }} + Expect(ci.ForceFormat("flac")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + }) + + It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + ci.CapBitrate(192) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + ci.CapBitrate(192) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("transcodes a flac source forced to mp3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + }) + }) }) Describe("ensureProbed", func() { diff --git a/core/stream/types.go b/core/stream/types.go index 19474dd91..300017c01 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool { return changed } -// ForceFormat narrows the client to transcoding to targetFormat and suppresses -// direct play, but only if the client already declares a profile for that -// format. All matching profiles are kept so negotiation can still pick among -// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or -// unsupported. +// ForceFormat narrows the client to transcoding to targetFormat, but only if the +// client already declares a profile for it. All matching profiles are kept so +// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt +// from those profiles rather than dropped, since declaring a transcoding profile +// for a format is proof the client can play it. Returns false when unsupported. func (ci *ClientInfo) ForceFormat(targetFormat string) bool { if targetFormat == "" { return false } var matched []Profile + var directPlay []DirectPlayProfile for i := range ci.TranscodingProfiles { + p := &ci.TranscodingProfiles[i] // matchesContainer is alias-aware, so a forced "oga" (legacy Opus // target_format) still matches a resolved "opus" profile. - if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) { - matched = append(matched, ci.TranscodingProfiles[i]) + container, format := resolveTargetFormat(p) + if !matchesContainer(format, []string{targetFormat}) { + continue } + matched = append(matched, *p) + directPlay = append(directPlay, DirectPlayProfile{ + Containers: []string{container}, + AudioCodecs: []string{format}, + Protocols: []string{ProtocolHTTP}, + MaxAudioChannels: p.MaxAudioChannels, + }) } if len(matched) == 0 { return false } ci.TranscodingProfiles = matched - ci.DirectPlayProfiles = nil + ci.DirectPlayProfiles = directPlay return true } diff --git a/core/stream/types_test.go b/core/stream/types_test.go index eff408362..88ad904a7 100644 --- a/core/stream/types_test.go +++ b/core/stream/types_test.go @@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() { }) Describe("ForceFormat", func() { - It("restricts to the forced format and clears direct play when supported", func() { + It("restricts direct play to the forced format when supported", func() { ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, TranscodingProfiles: []Profile{ @@ -71,7 +71,35 @@ var _ = Describe("ClientInfo", func() { Expect(ok).To(BeTrue()) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(ci.DirectPlayProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ + Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}, + })) + }) + + It("keeps direct play for a source already in the forced format", func() { + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("flac") + Expect(ok).To(BeTrue()) + Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ + Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP}, + })) + }) + + It("carries the channel limit of the forced profile into direct play", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + Expect(ci.ForceFormat("flac")).To(BeTrue()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2)) }) It("matches a container-only forced format (mp3)", func() { diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 9eb2af160..d64bce605 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -280,12 +280,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + player, hasPlayer := request.PlayerFrom(ctx) + // Honor the player's forced transcoding format, falling back to normal // negotiation when the client can't play it (issue #5583). + maxBitRate := 0 if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - if !clientInfo.ForceFormat(trc.TargetFormat) { + if clientInfo.ForceFormat(trc.TargetFormat) { + // DirectPlayProfile carries no bitrate, so this ceiling is the only + // thing keeping an over-bitrate source out of direct play. + maxBitRate = trc.DefaultBitRate + } else { clientName := clientInfo.Name - if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" { + if hasPlayer && player.Client != "" { clientName = player.Client } log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", @@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } } - // Apply the player's MaxBitRate as a ceiling on the client's declared - // limits (issue #5583). Both fields are capped because the client sends - // them independently here; capping only MaxAudioBitrate would let an - // independent MaxTranscodingAudioBitrate slip through computeBitrate. - if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) { - log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision", - "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + // The player's own MaxBitRate outranks the forced-format default (issue #5583). + if hasPlayer && player.MaxBitRate > 0 { + maxBitRate = player.MaxBitRate + } + if clientInfo.CapBitrate(maxBitRate) { + log.Debug(ctx, "Applied bitrate ceiling to transcode decision", + "maxBitRate", maxBitRate, "client", clientInfo.Name) } // Get media file diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8d5cbb974..0f3c24832 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() { mockTD.token = "token" }) - It("forces a supported format and clears direct play", func() { + It("forces a supported format and narrows direct play to it", func() { body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` @@ -380,7 +380,11 @@ var _ = Describe("Transcode endpoints", func() { Expect(err).ToNot(HaveOccurred()) Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty()) + Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{ + Containers: []string{"ogg"}, + AudioCodecs: []string{"opus"}, + Protocols: []string{"http"}, + })) }) It("falls back to negotiation when the forced format is unsupported", func() { @@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() { Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) }) + + withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request { + ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate}) + ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate}) + return r.WithContext(ctx) + } + + It("applies the transcoding default bitrate when the player sets no maxBitRate", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("prefers the player maxBitRate over the transcoding default bitrate", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + + It("ignores the transcoding default bitrate when the forced format is unsupported", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero()) + }) }) }) From 89026012ab3d9aeddcd7bbdf965fa9d6e0806b62 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 7 Sep 2026 14:19:45 -0400 Subject: [PATCH 515/596] fix(transcoding): make piped FLAC transcodes seekable The FLAC muxer writes STREAMINFO before it knows the stream length, then rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout (-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC header" and the field stays 0. A decoder needs total_samples to turn a timestamp into a byte offset, so it reports an unknown duration and refuses to seek. Online playback hides this because the client re-requests with a new offset each time, but an offline copy is permanently unseekable, the symptom reported against Symfonium where seeking a downloaded track jumps back to the start. Transcode now wraps its own output and rewrites total_samples as the first bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream only learns a target format and hands back an io.ReadCloser, so compensating there leaked a transcoder implementation detail one layer up. TranscodeOptions grows a Duration field alongside the existing Offset, which also puts the duration-minus-offset arithmetic in the same function that emits -ss. The wrapper runs on every transcode rather than only FLAC targets: the format on a transcoding row is a declared target that nothing validates against the command's actual -f, so a custom command can emit FLAC under any target_format. The magic-byte check inside the wrapper is the authoritative test and costs a 26-byte peek. The output sample rate is read back out of the header ffmpeg just wrote rather than taken from the transcode options, so a resampled (-ar) output still gets the right count. Anything that is not a FLAC stream with an unset total_samples passes through byte for byte. Measured on a 177s source: before, total_samples=0 and ffprobe reported duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio payload byte-identical. This affects every piped FLAC regardless of the source format; only FLAC stores an authoritative "unknown", which is why mp3, opus and aac survive the same pipe. No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and decoders binary-search using total_samples alone. --- core/ffmpeg/ffmpeg.go | 17 ++-- core/ffmpeg/ffmpeg_test.go | 35 +++++++ core/ffmpeg/flac_streaminfo.go | 66 +++++++++++++ core/ffmpeg/flac_streaminfo_test.go | 142 ++++++++++++++++++++++++++++ core/stream/media_streamer.go | 1 + 5 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 core/ffmpeg/flac_streaminfo.go create mode 100644 core/ffmpeg/flac_streaminfo_test.go diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index af2dab647..cc38dd9de 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -27,11 +27,12 @@ type TranscodeOptions struct { Command string // DB command template (used to detect custom vs default) Format string // Target format (mp3, opus, aac, flac) FilePath string - BitRate int // kbps, 0 = codec default - SampleRate int // 0 = no constraint - Channels int // 0 = no constraint - BitDepth int // 0 = no constraint; valid values: 16, 24, 32 - Offset int // seconds + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 + Offset int // seconds + Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header. } // AudioProbeResult contains authoritative audio stream properties from ffprobe. @@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC } else { args = buildTemplateArgs(opts) } - return e.start(ctx, args) + out, err := e.start(ctx, args) + if err != nil { + return nil, err + } + return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil } func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 0fa3de111..dbc8fa3c8 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,6 +3,7 @@ package ffmpeg import ( "context" "errors" + "io" "os" "os/exec" "path/filepath" @@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() { }) Expect(err).To(MatchError(context.Canceled)) }) + + It("fills in total_samples on a piped FLAC transcode", func() { + stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ + Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "tests/fixtures/test.flac", + Duration: 1, // the fixture is exactly 1s at 44100Hz + }) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + out, err := io.ReadAll(stream) + Expect(err).ToNot(HaveOccurred()) + Expect(string(out[:4])).To(Equal("fLaC")) + Expect(readTotalSamples(out)).To(Equal(uint64(44100))) + }) + + It("patches the duration net of the requested offset", func() { + // The command has no %t, so ffmpeg still emits the whole fixture. + // What is under test is the header arithmetic, not the audio. + stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ + Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "tests/fixtures/test.flac", + Duration: 3, + Offset: 1, + }) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + out, err := io.ReadAll(stream) + Expect(err).ToNot(HaveOccurred()) + Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) + }) }) Context("stderr capture", func() { diff --git a/core/ffmpeg/flac_streaminfo.go b/core/ffmpeg/flac_streaminfo.go new file mode 100644 index 000000000..878c28718 --- /dev/null +++ b/core/ffmpeg/flac_streaminfo.go @@ -0,0 +1,66 @@ +package ffmpeg + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" +) + +const ( + flacPrefixLen = 26 // through the last total_samples byte + flacMaxTotalSamples = 1<<36 - 1 +) + +// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0 +// when writing to a pipe, since a decoder cannot seek a cached FLAC without it. +func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser { + if duration <= 0 { + return r + } + return &flacPatcher{ReadCloser: r, duration: duration} +} + +type flacPatcher struct { + io.ReadCloser + duration float32 + // Peeking here rather than in the constructor keeps Transcode from blocking + // until ffmpeg has emitted its first bytes. + stream io.Reader +} + +func (f *flacPatcher) Read(p []byte) (int, error) { + if f.stream == nil { + prefix := make([]byte, flacPrefixLen) + n, err := io.ReadFull(f.ReadCloser, prefix) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return 0, err + } + prefix = prefix[:n] + if err == nil { + setFLACTotalSamples(prefix, f.duration) + } + f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser) + } + return f.stream.Read(p) +} + +// setFLACTotalSamples takes the rate from the header rather than the transcode +// options, so a resampled (-ar) output still gets the right count. +func setFLACTotalSamples(prefix []byte, duration float32) { + if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 { + return + } + // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples + info := binary.BigEndian.Uint64(prefix[18:]) + rate := info >> 44 + if rate == 0 || info&flacMaxTotalSamples != 0 { + return + } + total := math.Round(float64(duration) * float64(rate)) + if total > flacMaxTotalSamples { + return + } + binary.BigEndian.PutUint64(prefix[18:], info|uint64(total)) +} diff --git a/core/ffmpeg/flac_streaminfo_test.go b/core/ffmpeg/flac_streaminfo_test.go new file mode 100644 index 000000000..6bf3503d7 --- /dev/null +++ b/core/ffmpeg/flac_streaminfo_test.go @@ -0,0 +1,142 @@ +package ffmpeg + +import ( + "bytes" + "errors" + "io" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Decoded independently so the specs do not mirror the production bit-twiddling. +func readSampleRate(b []byte) int { + return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4 +} + +func readTotalSamples(b []byte) uint64 { + return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25]) +} + +var _ = Describe("patchFLACDuration", func() { + var fileFLAC []byte + + // Zeroing total_samples reproduces what a piped transcode emits. + pipedFLAC := func() []byte { + b := bytes.Clone(fileFLAC) + b[21] &= 0xF0 + clear(b[22:26]) + return b + } + + readAll := func(in []byte, duration float32) []byte { + out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration)) + Expect(err).ToNot(HaveOccurred()) + return out + } + + BeforeEach(func() { + var err error + fileFLAC, err = os.ReadFile("tests/fixtures/test.flac") + Expect(err).ToNot(HaveOccurred()) + Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate + }) + + It("fills in total_samples from the duration", func() { + out := readAll(pipedFLAC(), 1.0) + Expect(readTotalSamples(out)).To(Equal(uint64(44100))) + }) + + It("takes the sample rate from the header, not from the source file", func() { + in := pipedFLAC() + // Rewrite the header's rate to 48000, as -ar would. + in[18], in[19] = 0x0B, 0xB8 + in[20] &= 0x0F + + out := readAll(in, 2.0) + + Expect(readSampleRate(out)).To(Equal(48000)) + Expect(readTotalSamples(out)).To(Equal(uint64(96000))) + }) + + It("rounds to the nearest sample rather than truncating", func() { + // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample. + out := readAll(pipedFLAC(), 0.7) + Expect(readTotalSamples(out)).To(Equal(uint64(30870))) + }) + + It("passes through when the duration overflows the 36-bit field", func() { + in := pipedFLAC() + Expect(readAll(in, 2e6)).To(Equal(in)) + }) + + It("leaves everything after the header untouched", func() { + in := pipedFLAC() + out := readAll(in, 1.0) + Expect(out).To(HaveLen(len(in))) + Expect(out[26:]).To(Equal(in[26:])) + Expect(out[:18]).To(Equal(in[:18])) + }) + + It("leaves an already-populated total_samples alone", func() { + out := readAll(fileFLAC, 99.0) + Expect(out).To(Equal(fileFLAC)) + }) + + It("passes through a stream that is not FLAC", func() { + in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes") + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through when the first metadata block is not STREAMINFO", func() { + in := pipedFLAC() + in[4] = 0x04 // VORBIS_COMMENT + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through a stream shorter than the STREAMINFO fields it patches", func() { + in := pipedFLAC()[:20] + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through an empty stream", func() { + Expect(readAll(nil, 1.0)).To(BeEmpty()) + }) + + It("passes through when the duration is zero or negative", func() { + in := pipedFLAC() + Expect(readAll(in, 0)).To(Equal(in)) + Expect(readAll(in, -5)).To(Equal(in)) + }) + + It("passes through when the header declares no sample rate", func() { + in := pipedFLAC() + in[18], in[19] = 0, 0 + in[20] &= 0x0F + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("propagates a read error from the underlying stream", func() { + _, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader( + bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0)) + Expect(err).To(MatchError("boom")) + }) + + It("closes the underlying stream", func() { + c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())} + Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed()) + Expect(c.closed).To(BeTrue()) + }) +}) + +type errReader struct{} + +func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") } + +type closeSpy struct { + io.Reader + closed bool +} + +func (c *closeSpy) Close() error { c.closed = true; return nil } diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index aaa3126b4..6db2f6338 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache { BitDepth: job.bitDepth, Channels: job.channels, Offset: job.offset, + Duration: job.mf.Duration, }) if err != nil { release() From bea9715001abc956c712f76bf87cd93fd41bd6f1 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 8 Sep 2026 18:51:49 -0400 Subject: [PATCH 516/596] refactor(ui): replace icons in LibraryScanButton with react-icons --- ui/src/library/LibraryScanButton.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx index 50d90e615..b793844fd 100644 --- a/ui/src/library/LibraryScanButton.jsx +++ b/ui/src/library/LibraryScanButton.jsx @@ -8,8 +8,8 @@ import { useUnselectAll, } from 'react-admin' import { useSelector } from 'react-redux' -import SyncIcon from '@material-ui/icons/Sync' -import CachedIcon from '@material-ui/icons/Cached' +import { GiMagnifyingGlass } from 'react-icons/gi' +import { VscSync } from 'react-icons/vsc' import subsonic from '../subsonic' const LibraryScanButton = ({ fullScan, selectedIds, className }) => { @@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => { ? translate('resources.library.actions.fullScan') : translate('resources.library.actions.quickScan') - const icon = fullScan ? <CachedIcon /> : <SyncIcon /> + const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync /> return ( <Button From 043de7a86c0afa0317a926c8a08c6038bb2c5e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 9 Sep 2026 10:42:54 -0400 Subject: [PATCH 517/596] docs(jellyfin): correct the rationale for the public image endpoint (#6114) The comment justified anonymous access with "item ids are unguessable". That is not true: an artist id is a deterministic, unsalted hash of the artist name, id.NewHash(id.NewHash(str.Clear(lower(name)))), so it is computable offline by anyone who knows the name. The real reason the route is public is that upstream Jellyfin's is too. ImageController.GetItemImage carries no [Authorize] attribute (verified on v12.0, master/13.0.0, v10.11.9 and v10.10.7), and an anonymous request reaches LibraryManager.ItemIsVisible with a null user, which returns true unconditionally. Clients build cover URLs with no credentials at all, so requiring auth here would break them. No behavior change. --- server/jellyfin/images.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index fcf69c039..9f1b9ce1a 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -34,8 +34,8 @@ func imageSize(maxWidth, maxHeight int) int { } func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials - // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. + // Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and + // upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user). ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) itemId, ok := itemIDParam(w, r, "itemId") if !ok { From fe1c87c190eff85271b16525327599adb204d356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 9 Sep 2026 10:52:15 -0400 Subject: [PATCH 518/596] fix(ui): round the album grid hover overlay in the Nautiline theme (#6115) The theme rounded the cover image directly and set a border radius on albumContainer, which has no background or clipping, so it rounded nothing. The hover overlay is a sibling of the image inside the same link, so it kept square corners that poked out over the rounded cover. Move the radius to that link and clip it, so both the image and the overlay follow the same rounded box. This also covers the mobile bar, which is always visible. Fixes #6110 --- ui/src/themes/nautiline.js | 6 ++---- ui/src/themes/theme.test.js | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js index 0c13dc0ec..9194ad7a0 100644 --- a/ui/src/themes/nautiline.js +++ b/ui/src/themes/nautiline.js @@ -598,11 +598,9 @@ const NautilineTheme = { }, }, NDAlbumGridView: { - albumContainer: { + link: { borderRadius: radii.md, - '& img': { - borderRadius: radii.md, - }, + overflow: 'hidden', }, albumTitle: { fontWeight: 600, diff --git a/ui/src/themes/theme.test.js b/ui/src/themes/theme.test.js index b65c3a5fe..a015ae696 100644 --- a/ui/src/themes/theme.test.js +++ b/ui/src/themes/theme.test.js @@ -12,3 +12,25 @@ describe('NDPlaylistDetails styles', () => { }, ) }) + +describe('NDAlbumGridView styles', () => { + const themeEntries = Object.entries(themes) + + // The hover overlay is a sibling of the image, so it keeps square corners. + it.each(themeEntries)( + '%s should not round the grid cover image on its own', + (themeName, theme) => { + const container = theme.overrides?.NDAlbumGridView?.albumContainer + expect(container?.['& img']?.borderRadius).toBeUndefined() + }, + ) + + it.each(themeEntries)( + '%s should clip the grid cover link when it is rounded', + (themeName, theme) => { + const link = theme.overrides?.NDAlbumGridView?.link + if (!link?.borderRadius) return + expect(link.overflow).toBe('hidden') + }, + ) +}) From 02c9816aec605a8403263a3cc1a0e4af69e22782 Mon Sep 17 00:00:00 2001 From: MIguel Lopes <miguel.lopes@miguelallopes.dev> Date: Wed, 9 Sep 2026 16:38:59 +0100 Subject: [PATCH 519/596] build(docker): add curl to container image (#6111) (#6116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miguel Lopes <miguel.lopes@miguelallopes.dev> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3427d6806..5fc0529f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -187,7 +187,7 @@ LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" # - libwebp + symlinks: enables native WebP encoding via purego/dlopen # The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, # otherwise the deleted bytes still ship in the image. -RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ +RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ for lib in libwebp libwebpdemux libwebpmux; do \ target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ From 8d77a49b31d97e3c1554c8cc811b53a8a23e40a2 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 9 Sep 2026 16:09:58 -0400 Subject: [PATCH 520/596] fix(ui): allow setting a transcoding Default Bit Rate of 0 The Default Bit Rate dropdown on the Transcoding create/edit forms was fed BITRATE_CHOICES, which starts at 32. There was no way to pick 0, and the SelectInput was not resettable, so an admin could neither create nor restore a transcoding with no default bit rate, such as the default FLAC one (seeded with 0 in consts.DefaultTranscodings). Editing that row also rendered a blank dropdown, since its stored value matched no choice. Adds TRANSCODING_BITRATE_CHOICES, which prepends a 0 entry labelled 'None' to the shared list. The forms use it as SelectInput choices, and the list and read-only show view render it through SelectField, so all four screens resolve the label from the same array and cannot drift. The shared BITRATE_CHOICES is left untouched, because 0 is not a meaningful option for the player Max. Bit Rate or the share dialog. Reported in discussion #6107, where a user had deleted the default transcodings and could not recreate the FLAC one. --- ui/src/consts.js | 6 ++++++ ui/src/i18n/en.json | 3 +++ ui/src/transcoding/TranscodingCreate.jsx | 4 ++-- ui/src/transcoding/TranscodingEdit.jsx | 7 +++++-- ui/src/transcoding/TranscodingList.jsx | 16 +++++++++++++--- ui/src/transcoding/TranscodingShow.jsx | 8 ++++++-- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/ui/src/consts.js b/ui/src/consts.js index 472cd4940..46c76e194 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ 32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320, ].map((b) => ({ id: b, name: b.toString() })) + +// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express. +export const TRANSCODING_BITRATE_CHOICES = [ + { id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' }, + ...BITRATE_CHOICES, +] diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index de96d47c0..fd200fa60 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -200,6 +200,9 @@ "targetFormat": "Target Format", "defaultBitRate": "Default Bit Rate", "command": "Command" + }, + "choices": { + "noDefaultBitRate": "None" } }, "playlist": { diff --git a/ui/src/transcoding/TranscodingCreate.jsx b/ui/src/transcoding/TranscodingCreate.jsx index aaf122665..014a94e48 100644 --- a/ui/src/transcoding/TranscodingCreate.jsx +++ b/ui/src/transcoding/TranscodingCreate.jsx @@ -8,7 +8,7 @@ import { useTranslate, } from 'react-admin' import { Title } from '../common' -import { BITRATE_CHOICES } from '../consts' +import { TRANSCODING_BITRATE_CHOICES } from '../consts' const TranscodingTitle = () => { const translate = useTranslate() @@ -28,7 +28,7 @@ const TranscodingCreate = (props) => ( <TextInput source="targetFormat" validate={[required()]} /> <SelectInput source="defaultBitRate" - choices={BITRATE_CHOICES} + choices={TRANSCODING_BITRATE_CHOICES} defaultValue={192} /> <TextInput diff --git a/ui/src/transcoding/TranscodingEdit.jsx b/ui/src/transcoding/TranscodingEdit.jsx index 5aba9a4e3..bf500920f 100644 --- a/ui/src/transcoding/TranscodingEdit.jsx +++ b/ui/src/transcoding/TranscodingEdit.jsx @@ -9,7 +9,7 @@ import { } from 'react-admin' import { Title } from '../common' import { TranscodingNote } from './TranscodingNote' -import { BITRATE_CHOICES } from '../consts' +import { TRANSCODING_BITRATE_CHOICES } from '../consts' const TranscodingTitle = ({ record }) => { const translate = useTranslate() @@ -28,7 +28,10 @@ const TranscodingEdit = (props) => { <SimpleForm variant={'outlined'}> <TextInput source="name" validate={[required()]} /> <TextInput source="targetFormat" validate={[required()]} /> - <SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} /> + <SelectInput + source="defaultBitRate" + choices={TRANSCODING_BITRATE_CHOICES} + /> <TextInput source="command" fullWidth validate={[required()]} /> </SimpleForm> </Edit> diff --git a/ui/src/transcoding/TranscodingList.jsx b/ui/src/transcoding/TranscodingList.jsx index bca8b49df..d1371c04a 100644 --- a/ui/src/transcoding/TranscodingList.jsx +++ b/ui/src/transcoding/TranscodingList.jsx @@ -1,7 +1,8 @@ import React from 'react' -import { Datagrid, TextField } from 'react-admin' +import { Datagrid, SelectField, TextField } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { SimpleList, List } from '../common' +import { TRANSCODING_BITRATE_CHOICES } from '../consts' import config from '../config' const TranscodingList = (props) => { @@ -16,13 +17,22 @@ const TranscodingList = (props) => { <SimpleList primaryText={(r) => r.name} secondaryText={(r) => `format: ${r.targetFormat}`} - tertiaryText={(r) => r.defaultBitRate} + tertiaryText={(r) => ( + <SelectField + record={r} + source="defaultBitRate" + choices={TRANSCODING_BITRATE_CHOICES} + /> + )} /> ) : ( <Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}> <TextField source="name" /> <TextField source="targetFormat" /> - <TextField source="defaultBitRate" /> + <SelectField + source="defaultBitRate" + choices={TRANSCODING_BITRATE_CHOICES} + /> <TextField source="command" /> </Datagrid> )} diff --git a/ui/src/transcoding/TranscodingShow.jsx b/ui/src/transcoding/TranscodingShow.jsx index e132afee5..b7ec2f595 100644 --- a/ui/src/transcoding/TranscodingShow.jsx +++ b/ui/src/transcoding/TranscodingShow.jsx @@ -1,7 +1,8 @@ import React from 'react' -import { Show, SimpleShowLayout, TextField } from 'react-admin' +import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin' import { Title } from '../common' import { TranscodingNote } from './TranscodingNote' +import { TRANSCODING_BITRATE_CHOICES } from '../consts' const TranscodingTitle = ({ record }) => { return <Title subTitle={`Transcoding ${record ? record.name : ''}`} /> @@ -16,7 +17,10 @@ const TranscodingShow = (props) => { <SimpleShowLayout> <TextField source="name" /> <TextField source="targetFormat" /> - <TextField source="defaultBitRate" /> + <SelectField + source="defaultBitRate" + choices={TRANSCODING_BITRATE_CHOICES} + /> <TextField source="command" /> </SimpleShowLayout> </Show> From 72975a95fbe123a4684bdeb8361b58b0b245cc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 9 Sep 2026 20:29:00 -0400 Subject: [PATCH 521/596] fix(subsonic): honor DefaultDownloadableShare in createShare (#6121) * fix(subsonic): honor DefaultDownloadableShare in createShare The DefaultDownloadableShare option was only sent to the web UI, which used it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare handler built the model.Share without touching Downloadable, so it fell back to the Go zero value and every share created through the API was stored as non-downloadable, regardless of the configured default. createShare now reads an optional downloadable parameter and falls back to conf.Server.DefaultDownloadableShare when the client omits it, matching the web UI. Fixes #6119. updateShare had a related problem: core's share repository wrapper always writes the downloadable column, but the handler never set the field, so any updateShare call silently reset the share to non-downloadable. It now loads the current share and uses its value as the fallback. * refactor(subsonic): trim the share downloadable lookup and align with the UI updateShare fetched the share with Get to recover the stored downloadable flag, which also runs loadMedia and materializes every album and track the share points at, just to read one boolean. It now uses Read, which skips loadMedia, and only queries at all when the client omitted the parameter. createShare now ANDs the default with EnableDownloads, matching what the web UI already computes, so both paths apply the same rule. The specs collapse the create-path matrix into a DescribeTable, reuse the existing albumIDByName helper, and set the request-time config after setupTestDB so it does not leak into the config snapshot. * fix(subsonic): keep the share description on a downloadable-only update updateShare read the description straight from the request, so a client that sent only id and downloadable got an empty string written over the stored description. shareRepositoryWrapper.Update always writes that column, so the description was silently erased. This predates the downloadable parameter added earlier in this branch: any updateShare that omitted description already cleared it. Adding the parameter just made it easy to hit, since toggling downloads is a natural reason to call updateShare without touching the description. Both fields now use the presence-aware accessors and fall back to the stored share, which still costs at most one read and none when the client sends both. An explicitly empty description still clears the field. --- server/subsonic/e2e/subsonic_sharing_test.go | 73 ++++++++++++++++++++ server/subsonic/sharing.go | 32 +++++++-- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 0421d96ea..1ae68dc1a 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -205,3 +205,76 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { Expect(check.Shares.Share[0].ID).To(Equal(shareID)) }) }) + +var _ = Describe("Sharing Downloadable Default", func() { + var albumID string + + BeforeEach(func() { + conf.Server.EnableSharing = true + setupTestDB() + conf.Server.EnableDownloads = true + albumID = albumIDByName("Abbey Road") + }) + + createShare := func(params ...string) *model.Share { + GinkgoHelper() + resp := doReq("createShare", append([]string{"id", albumID}, params...)...) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID) + Expect(err).ToNot(HaveOccurred()) + return share + } + + DescribeTable("createShare resolves downloadable", + func(defaultDownloadable, enableDownloads bool, params []string, expected bool) { + conf.Server.DefaultDownloadableShare = defaultDownloadable + conf.Server.EnableDownloads = enableDownloads + + Expect(createShare(params...).Downloadable).To(Equal(expected)) + }, + Entry("applies the default when the param is absent", true, true, nil, true), + Entry("stays off when the default is off", false, true, nil, false), + Entry("ignores the default when downloads are disabled", true, false, nil, false), + Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false), + Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true), + ) + + It("updateShare keeps the current downloadable when the param is absent", func() { + conf.Server.DefaultDownloadableShare = true + share := createShare() + Expect(share.Downloadable).To(BeTrue()) + + resp := doReq("updateShare", "id", share.ID, "description", "Updated") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Description).To(Equal("Updated")) + Expect(updated.Downloadable).To(BeTrue()) + }) + + It("updateShare applies an explicit downloadable and keeps the description", func() { + conf.Server.DefaultDownloadableShare = true + share := createShare("description", "Keep me") + + resp := doReq("updateShare", "id", share.ID, "downloadable", "false") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Downloadable).To(BeFalse()) + Expect(updated.Description).To(Equal("Keep me")) + }) + + It("updateShare clears the description when it is sent empty", func() { + share := createShare("description", "Clear me") + + resp := doReq("updateShare", "id", share.ID, "description", "") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Description).To(BeEmpty()) + }) +}) diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 36124c40b..c4b735832 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -1,11 +1,13 @@ package subsonic import ( + "cmp" "net/http" "strings" "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -60,9 +62,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { description, _ := p.String("description") repo := api.share.NewRepository(r.Context()) share := &model.Share{ - Description: description, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), - ResourceIDs: strings.Join(ids, ","), + Description: description, + Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads), + ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ResourceIDs: strings.Join(ids, ","), } id, err := repo.(rest.Persistable).Save(share) @@ -87,12 +90,27 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - description, _ := p.String("description") repo := api.share.NewRepository(r.Context()) + + // The update always writes description and downloadable, so read back the + // stored value for whichever one the client omitted. + description := p.StringPtr("description") + downloadable := p.BoolPtr("downloadable") + if description == nil || downloadable == nil { + current, err := repo.Read(id) + if err != nil { + return nil, err + } + cur := current.(*model.Share) + description = cmp.Or(description, &cur.Description) + downloadable = cmp.Or(downloadable, &cur.Downloadable) + } + share := &model.Share{ - ID: id, - Description: description, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ID: id, + Description: *description, + Downloadable: *downloadable, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share) From 055fbde3cf042f48caf96ff38cdb1e6cef648dbf Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 9 Sep 2026 12:03:36 -0400 Subject: [PATCH 522/596] fix(server): key the login rate limit on a trust-aware client IP The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP and X-Forwarded-For headers on every request, including when no trusted reverse proxy was configured. The login rate limiters on /auth/login and the Jellyfin /Users/AuthenticateByName derived their bucket from that value, so an unauthenticated client could rotate a forwarding header and get a fresh bucket for every password attempt, defeating the brute-force protection. Resolve the client IP with chi's ClientIPFrom* middlewares instead. The forwarding headers are only honoured when ExtAuth.TrustedSources is set and the connecting peer is in that list, reusing the trust check that external authentication already applies; otherwise the peer address is used. The X-Forwarded-For chain is now walked against the trusted CIDRs rather than taking its leftmost entry, so a spoofed value prepended by the client is skipped. Both limiters now key on the resolved address. The resolved address is still mirrored into RemoteAddr, so request logging, player registration and the Jellyfin local-network check keep reporting the client rather than the proxy. Reported by gehan-psbc. --- server/jellyfin/api.go | 5 +- server/jellyfin/api_test.go | 23 +++++++++ server/jellyfin/system.go | 2 +- server/middlewares.go | 82 ++++++++++++++++++++++++++----- server/middlewares_test.go | 97 +++++++++++++++++++++++++++++++++++++ server/server.go | 3 +- 6 files changed, 195 insertions(+), 17 deletions(-) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 6484a3bb4..b0a5350b3 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -7,7 +7,6 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/httprate" "golang.org/x/sync/singleflight" "github.com/navidrome/navidrome/conf" @@ -78,9 +77,9 @@ func (api *Router) routes() http.Handler { inner.Post("/system/ping", api.ping) inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated - // brute-force surface, so it must share the same per-IP throttle when one is configured. + // brute-force surface, so it must share the same per-client throttle when one is configured. if conf.Server.AuthRequestLimit > 0 { - limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) } else { inner.Post("/users/authenticatebyname", api.authenticateByName) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index fe69c8321..605303793 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" @@ -84,4 +85,26 @@ var _ = Describe("Router", func() { Expect(login()).To(Equal(http.StatusUnauthorized)) Expect(login()).To(Equal(http.StatusTooManyRequests)) }) + + It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 1 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + // Every request arrives on the same proxy connection, so only the resolved client IP + // can separate the buckets. + handler := middleware.ClientIPFromHeader("X-Real-IP")(api) + + login := func(clientIP string) int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Real-IP", clientIP) + handler.ServeHTTP(w, r) + return w.Code + } + Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized)) + Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests)) + Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized)) + }) }) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 8b847987f..ccc1277df 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool { return parseIP(local.String()) == remote } -// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP. +// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP. func remoteIP(r *http.Request) netip.Addr { return parseIP(r.RemoteAddr) } diff --git a/server/middlewares.go b/server/middlewares.go index b710b4068..c54e947f3 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "io/fs" + "net" "net/http" + "net/netip" "net/url" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -165,20 +168,77 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler { }) } -// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's -// context if navidrome is behind a trusted reverse proxy. +// realIPMiddleware resolves the request's client IP into the context, where it can be read with +// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration. +// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that +// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept +// in the context as request.ReverseProxyIp. func realIPMiddleware(next http.Handler) http.Handler { - if conf.Server.ExtAuth.TrustedSources != "" { - return chi.Chain( - reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), - middleware.RealIP, - ).Handler(next) + trusted := conf.Server.ExtAuth.TrustedSources + fromPeer := middleware.ClientIPFromRemoteAddr(next) + if trusted == "" { + return fromPeer } - // The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients - // behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security - // impact of reading the headers from untrusted sources is limited. - return middleware.RealIP(next) + // Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP, + // X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list. + fromProxy := chi.Chain( + middleware.ClientIPFromRemoteAddr, + middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...), + middleware.ClientIPFromHeader("X-Real-IP"), + middleware.ClientIPFromHeader("True-Client-IP"), + ).Handler(mirrorClientIP(next)) + + dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if validateIPAgainstList(r.RemoteAddr, trusted) { + fromProxy.ServeHTTP(w, r) + return + } + log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr) + fromPeer.ServeHTTP(w, r) + }) + return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch) +} + +// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer. +func mirrorClientIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) { + r.RemoteAddr = ip + } + next.ServeHTTP(w, r) + }) +} + +// peerHost returns the host part of RemoteAddr, which may already be a bare IP. +func peerHost(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR +// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust +// exactly one hop. +func trustedProxyPrefixes(list string) []string { + var prefixes []string + for _, entry := range strings.Split(list, ",") { + entry = strings.TrimSpace(entry) + if _, err := netip.ParsePrefix(entry); err == nil { + prefixes = append(prefixes, entry) + } + } + return prefixes +} + +// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware, +// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer +// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket. +func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { + return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { + return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil + }) } // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 4ba9a853b..a9b0bc99e 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -435,4 +436,100 @@ var _ = Describe("middlewares", func() { }) }) }) + Describe("realIPMiddleware", func() { + var resolved, remoteAddr string + var proxyIP any + next := func(w http.ResponseWriter, r *http.Request) { + resolved = middleware.GetClientIP(r.Context()) + remoteAddr = r.RemoteAddr + proxyIP = r.Context().Value(request.ReverseProxyIp) + } + call := func(peer string, headers map[string]string) { + resolved, remoteAddr, proxyIP = "", "", nil + r := httptest.NewRequest("POST", "/auth/login", nil) + r.RemoteAddr = peer + for k, v := range headers { + r.Header.Set(k, v) + } + realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r) + } + + Context("without a trusted proxy", func() { + It("ignores client-supplied forwarding headers", func() { + call("10.0.0.1:1234", map[string]string{ + "X-Forwarded-For": "203.0.113.5", + "X-Real-IP": "203.0.113.6", + "True-Client-IP": "203.0.113.7", + }) + Expect(resolved).To(Equal("10.0.0.1")) + }) + It("leaves RemoteAddr untouched", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(remoteAddr).To(Equal("10.0.0.1:1234")) + }) + }) + + Context("with a trusted proxy", func() { + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + It("uses the forwarded client IP when the peer is a trusted proxy", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"}) + Expect(resolved).To(Equal("203.0.113.5")) + Expect(remoteAddr).To(Equal("203.0.113.5")) + }) + It("honours X-Real-IP from a trusted proxy", func() { + call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"}) + Expect(resolved).To(Equal("203.0.113.6")) + }) + It("ignores forwarding headers when the peer is not a trusted proxy", func() { + call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(resolved).To(Equal("198.51.100.9")) + Expect(remoteAddr).To(Equal("198.51.100.9:1234")) + }) + It("keeps the peer address in the context for external auth", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(proxyIP).To(Equal("10.0.0.1:1234")) + }) + }) + }) + + Describe("ClientIPRateLimiter", func() { + var handler http.Handler + JustBeforeEach(func() { + handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))) + }) + attempt := func(peer string, header, value string) int { + r := httptest.NewRequest("POST", "/auth/login", nil) + r.RemoteAddr = peer + r.Header.Set(header, value) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w.Code + } + + DescribeTable("keeps one bucket per peer when the forwarding header is rotated", + func(header string) { + Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK)) + Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests)) + }, + Entry("X-Forwarded-For", "X-Forwarded-For"), + Entry("X-Real-IP", "X-Real-IP"), + Entry("True-Client-IP", "True-Client-IP"), + ) + + Context("behind a trusted proxy", func() { + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + It("gives each real client its own bucket", func() { + Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests)) + Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK)) + }) + }) + }) }) diff --git a/server/server.go b/server/server.go index b05c20cc5..7e7cfa3ea 100644 --- a/server/server.go +++ b/server/server.go @@ -17,7 +17,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -209,7 +208,7 @@ func (s *Server) mountAuthenticationRoutes() chi.Router { log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, "windowLength", conf.Server.AuthWindowLength) - rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) r.With(rateLimiter).Post("/login", login(s.ds)) } else { log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks") From 964d3c778bdbddf781cfbea3e64dcf802d1856e8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 10 Sep 2026 15:00:01 -0400 Subject: [PATCH 523/596] build(deps): update direct Go dependencies and taglib fork Bumps all 13 direct dependencies that had newer releases, plus the go-taglib fork pin. No source changes were needed. The jwx bump to v3.3.0 carries a security fix (GHSA-4cf7-xm37-g63h): custom claim, header and JWK names were written unescaped, so a name containing a quote could inject extra members. Navidrome is not affected - every claim name we emit is a hardcoded literal - but the fix is worth taking. cascadia v1.3.5 similarly limits selector nesting to avoid a stack overflow, and our only selector is a constant. go-sqlite3 v1.14.52 is the only bump with real behavior change: it flushes the statement cache on schema changes, steps cached statements eagerly, and drops the per-row goroutine used for query cancellation. goose v3.28.0 raises its minimum to Go 1.26 and otherwise only touches MySQL, ClickHouse and Azure SQL, which we do not use. The golang.org/x bumps are routine. govulncheck reports no reachable vulnerabilities. The taglib fork pin picks up two fixes. Audio properties are now clamped with std::max(0, ...) before the unsigned conversion, so a malformed file no longer reports a duration of ~49 days; this ports upstream sentriz/go-taglib 0524e91 and additionally covers bitsPerSample, which is specific to this fork. Bit depth is also now reported for DSDIFF, TrueAudio and Shorten, which previously returned 0. Both values reach media_file only on re-extraction, so existing libraries need a full scan to pick them up. --- go.mod | 34 +++++++++++------------ go.sum | 88 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/go.mod b/go.mod index 4339b9c55..1a73328f3 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/navidrome/navidrome go 1.27 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.4 + github.com/andybalholm/cascadia v1.3.5 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -26,7 +26,7 @@ require ( github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/gohugoio/hashstructure v1.0.0 + github.com/gohugoio/hashstructure v1.1.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 @@ -35,16 +35,16 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.1 github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.2.0 - github.com/mattn/go-sqlite3 v1.14.50 + github.com/lestrrat-go/jwx/v3 v3.3.0 + github.com/mattn/go-sqlite3 v1.14.52 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.32.1 + github.com/onsi/ginkgo/v2 v2.32.2 github.com/onsi/gomega v1.43.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.3 + github.com/pressly/goose/v3 v3.28.0 github.com/prometheus/client_golang v1.24.1 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -60,13 +60,13 @@ require ( github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.45.0 - golang.org/x/net v0.58.0 - golang.org/x/sync v0.22.0 - golang.org/x/sys v0.47.0 - golang.org/x/term v0.45.0 - golang.org/x/text v0.41.0 - golang.org/x/time v0.15.0 + golang.org/x/image v0.46.0 + golang.org/x/net v0.59.0 + golang.org/x/sync v0.23.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 + golang.org/x/time v0.16.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -114,7 +114,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect @@ -131,8 +131,8 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/mod v0.40.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect golang.org/x/tools v0.49.0 // indirect google.golang.org/protobuf v1.36.12 // indirect diff --git a/go.sum b/go.sum index 71d9facfd..42e1505e9 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= -github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= +github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70= -github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec h1:3VyOFsbsRtCQqdq/+fcmD3D6zlRvKSG7RCixgrdWfEo= +github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= 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= @@ -94,8 +94,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg= -github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE= +github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4= +github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -134,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -159,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA= -github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw= +github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= +github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= -github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY= -github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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= @@ -185,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= -github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg= +github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= @@ -199,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/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/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= -github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc= +github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -304,34 +304,34 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= -golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= @@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= -modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= -modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= From 2aa7a5c46696a8ba09d73915b203b309c38ee93d Mon Sep 17 00:00:00 2001 From: York <goog10216922@gmail.com> Date: Fri, 11 Sep 2026 08:48:33 +0800 Subject: [PATCH 524/596] fix(ui): prevent Safari album grid resize when top menus open (#6125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): prevent Safari album grid resize when top menus open * fix(ui): disable scroll lock for all popovers --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/themes/useCurrentTheme.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 2b5d13d13..4ccefe820 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -59,7 +59,11 @@ const useCurrentTheme = () => { return useMemo( () => ({ ...theme, - props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } }, + props: { + ...theme.props, + MuiUseMediaQuery: { noSsr: true }, + MuiPopover: { disableScrollLock: true }, + }, }), [theme], ) From 2dc09836291a1eadaced27cbeb938d875635d9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 11 Sep 2026 15:03:54 -0400 Subject: [PATCH 525/596] fix: miscellaneous fixes for shares, artwork resize, auth limits, and watcher start (#6098) * fix(artwork): cap declared image dimensions before resizing resizeStaticImage decoded the image with a raw image.Decode, so a small file declaring huge dimensions (e.g. a PNG header claiming 50k x 50k) forced a multi-gigabyte allocation on the serve-time resize path. The processor already guards its own decodes with decodeCapped; use it here too so the same 64M pixel cap applies to uploaded and sidecar images served through the cache. * fix(share): validate every resource ID and reject mixed types when saving Save only resolved the first ID in ResourceIDs to pick the resource type; the remaining IDs were never checked. A non-existent or hidden entity could ride along behind a valid first ID, and IDs of different kinds were accepted as one share. Resolve every ID as the current user and require all of them to be the same kind, returning ErrNotFound or ErrValidation otherwise. * fix(share): scope album and media file shares to the owner's libraries loadMedia already loaded artist and playlist shares as the share owner, but album and media_file shares used the repository context. Public share rendering carries no user, so the library filter was skipped and the share listed albums and tracks from libraries the owner cannot access. Streaming was already blocked, so only metadata leaked. Use ownerContext for all resource types. * fix(server): limit login payload size and surface first-admin creation errors The unauthenticated /login and /createAdmin handlers decoded the request body with no size limit. Add a body-limit middleware to the /auth route group that caps the payload at 8KiB, which is plenty for a username and password. Also make createAdminUser return the datastore error instead of logging it and returning nil, which previously let createAdmin proceed to a login attempt for a user that was never saved. * fix(conf): create the log file readable only by the owner The log file was created with mode 0644, so other local users could read it. Logs can contain usernames, paths and, at trace level, request details, so create it with 0600 instead. Existing files keep their current mode. * fix(lastfm): stop logging the auth token when fetching the session key fails The Last.fm callback token was written to the log as a structured field on failure. The redaction hook only matches value patterns, so it was not masked. Drop the field; the request ID is enough to correlate the failure. * fix(db): allow a music folder path containing a single quote on fresh databases The library table migration interpolated conf.Server.MusicFolder into the SQL with fmt.Sprintf, so a path such as /music/Rock 'n' Roll produced invalid SQL and the migration failed on a brand new database. Bind the path as a parameter instead. * fix(scanner): return an error when the folder watcher cannot start When notify.Watch failed, the watcher goroutine logged the error and exited, but never signalled the started channel, so Start blocked until its context was cancelled and left the watching flag set. Call notify.Watch before spawning the event loop, so Start returns the error right away, the started/failed signalling goes away, and the storage can be watched again later. * fix(jellyfin): limit the login request body size The Jellyfin AuthenticateByName endpoint decoded its JSON body with no size limit, the same gap the native /auth routes had. Export the login body-limit middleware from the server package and apply it to the Jellyfin login route, before the optional per-IP rate limiter, so both unauthenticated login surfaces share the same 8KiB cap. * fix(scanner): share one scanner instance across all injectors Each wire injector built its own scanner controller, so the Subsonic and native API routers held a different instance from the ones used by the startup scan, the periodic scan, the folder watcher and the SIGUSR1 handler. Status reads the in-progress file and folder counters from its own instance, so getScanStatus reported scanning=true with count=0 for every scan not started through the API. Verified live with a startup scan: master reports count 0 while scanning, this branch reports the real counts. Expose the controller through a singleton, as the watcher, broker and play tracker already are, and wire everything to it. New stays available for tests that need isolated controllers. * fix(share): do not panic when a media file share has no visible tracks Share.CoverArtID picked a random track for media file shares without checking that any track was loaded. The tracks are empty when the files went missing, were deleted, or the owner lost access to their library, and the public share page then panicked inside the random pick and returned a 500. Return an empty artwork ID instead, so the page renders with the placeholder cover. The old guard on the split resource IDs was dead code, since SplitN always returns at least one element. --- adapters/lastfm/auth_router.go | 2 +- cmd/wire_gen.go | 10 ++--- cmd/wire_injectors.go | 2 +- conf/configuration.go | 2 +- conf/configuration_test.go | 16 +++++++ core/artwork/resize.go | 2 +- core/artwork/resize_test.go | 13 ++++++ core/share.go | 45 +++++++++++++------ core/share_test.go | 13 ++++++ core/storage/local/watcher.go | 23 ++++------ core/storage/local/watcher_test.go | 15 +++++++ .../20240511220020_add_library_table.go | 9 ++-- model/share.go | 7 +-- model/share_test.go | 19 ++++++++ persistence/share_repository.go | 22 ++++----- persistence/share_repository_test.go | 43 +++++++++++++----- scanner/controller.go | 10 +++++ scanner/controller_test.go | 10 +++++ server/auth.go | 11 +++++ server/auth_test.go | 16 +++++++ server/jellyfin/api.go | 7 ++- server/jellyfin/auth_test.go | 13 ++++++ server/server.go | 1 + 23 files changed, 237 insertions(+), 74 deletions(-) create mode 100644 core/artwork/resize_test.go create mode 100644 model/share_test.go diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 411bf069a..ec1bb9290 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -132,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { sessionKey, err := s.client.getSession(ctx, token) if err != nil { - log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err) return err } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index de4c55a1e..fd04c44c5 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -70,7 +70,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { insights := metrics.GetInstance(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) manager := plugins.GetManager(dataStore, broker, metricsMetrics) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) @@ -103,7 +103,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) @@ -189,7 +189,7 @@ func CreateScanner(ctx context.Context) model.Scanner { uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -200,7 +200,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } @@ -249,7 +249,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 8bc404fb1..a54d4ae1b 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -42,7 +42,7 @@ var allProviders = wire.NewSet( lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, - scanner.New, + scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, diff --git a/conf/configuration.go b/conf/configuration.go index ff119417a..e549e2a61 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -407,7 +407,7 @@ func Load(noConfigDump bool) { if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) } - out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 2c7f8edaa..9404f01bc 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "testing" "time" @@ -329,6 +330,21 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error creating log file directory"))) }) + It("creates the log file readable only by the owner", func() { + if runtime.GOOS == "windows" { + Skip("file modes are not enforced on Windows") + } + logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log") + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("logfile", logFile) + DeferCleanup(log.SetOutput, os.Stderr) + conf.Load(true) + + info, err := os.Stat(logFile) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) + It("is called when BaseURL is invalid", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("baseurl", "://invalid") diff --git a/core/artwork/resize.go b/core/artwork/resize.go index 9536c3bb1..9b3ff46e5 100644 --- a/core/artwork/resize.go +++ b/core/artwork/resize.go @@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image { } func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(bytes.NewReader(data)) + original, format, err := decodeCapped(data) if err != nil { return nil, 0, err } diff --git a/core/artwork/resize_test.go b/core/artwork/resize_test.go new file mode 100644 index 000000000..76b95974d --- /dev/null +++ b/core/artwork/resize_test.go @@ -0,0 +1,13 @@ +package artwork + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizeStaticImage", func() { + It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() { + _, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false) + Expect(err).To(MatchError(ContainSubstring("exceed pixel cap"))) + }) +}) diff --git a/core/share.go b/core/share.go index b2f32ba39..5ee1a5667 100644 --- a/core/share.go +++ b/core/share.go @@ -2,6 +2,8 @@ package core import ( "context" + "fmt" + "slices" "strings" "time" @@ -98,27 +100,19 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - firstId, _, _ := strings.Cut(s.ResourceIDs, ",") - v, err := model.GetEntityByID(r.ctx, r.ds, firstId) + s.ResourceType, err = r.resourceType(s.ResourceIDs) if err != nil { return "", err } - switch v.(type) { - case *model.Artist: - s.ResourceType = "artist" + switch s.ResourceType { + case "artist": s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs) - case *model.Album: - s.ResourceType = "album" + case "album": s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs) - case *model.Playlist: - s.ResourceType = "playlist" + case "playlist": s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs) - case *model.MediaFile: - s.ResourceType = "media_file" + case "media_file": s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs) - default: - log.Error(r.ctx, "Invalid Resource ID", "id", firstId) - return "", model.ErrNotFound } s.Contents = str.TruncateRunes(s.Contents, 30, "...") @@ -126,6 +120,29 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { return r.Persistable.Save(s) } +var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork} + +// resourceType resolves every ID as the current user, so an entity they cannot see cannot +// ride along behind a valid first one, and requires all IDs to be of the same kind. +func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error) { + resourceType := "" + for _, id := range strings.Split(resourceIDs, ",") { + kind, err := model.GetEntityKindByID(r.ctx, r.ds, id) + if err != nil { + return "", err + } + if !slices.Contains(shareableKinds, kind) { + log.Error(r.ctx, "Invalid Resource ID", "id", id) + return "", model.ErrNotFound + } + if resourceType != "" && kind.String() != resourceType { + return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind) + } + resourceType = kind.String() + } + return resourceType, nil +} + func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { cols := []string{"description", "downloadable"} diff --git a/core/share_test.go b/core/share_test.go index 475d40ec9..a5ee0428b 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -70,6 +70,19 @@ var _ = Describe("Share", func() { Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) }) + + It("fails when any of the resource IDs does not exist", func() { + entity := &model.Share{Description: "test", ResourceIDs: "123,missing"} + _, err := repo.Save(entity) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("fails when the resource IDs are of mixed types", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + entity := &model.Share{Description: "test", ResourceIDs: "123,456"} + _, err := repo.Save(entity) + Expect(err).To(HaveOccurred()) + }) }) Describe("Update", func() { diff --git a/core/storage/local/watcher.go b/core/storage/local/watcher.go index 1b8a4e0c8..ef404d497 100644 --- a/core/storage/local/watcher.go +++ b/core/storage/local/watcher.go @@ -3,6 +3,7 @@ package local import ( "context" "errors" + "fmt" "path/filepath" "strings" @@ -18,22 +19,18 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { return nil, errors.New("watcher already started") } input := make(chan notify.EventInfo, 500) - output := make(chan string, 500) + libPath := filepath.Join(s.u.Path, "...") + log.Debug(ctx, "Starting watcher", "lib", libPath) + if err := notify.Watch(libPath, input, WatchEvents); err != nil { + s.watching.Store(false) + return nil, fmt.Errorf("starting watcher on %s: %w", libPath, err) + } - started := make(chan struct{}) + output := make(chan string, 500) go func() { defer close(input) defer close(output) - - libPath := filepath.Join(s.u.Path, "...") - log.Debug(ctx, "Starting watcher", "lib", libPath) - err := notify.Watch(libPath, input, WatchEvents) - if err != nil { - log.Error("Error starting watcher", "lib", libPath, err) - return - } defer notify.Stop(input) - close(started) // signals the main goroutine we have started for { select { @@ -49,9 +46,5 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { } } }() - select { - case <-started: - case <-ctx.Done(): - } return output, nil } diff --git a/core/storage/local/watcher_test.go b/core/storage/local/watcher_test.go index 8d2d31367..37387c76a 100644 --- a/core/storage/local/watcher_test.go +++ b/core/storage/local/watcher_test.go @@ -137,3 +137,18 @@ type noopExtractor struct{} func (s noopExtractor) Parse(files ...string) (map[string]metadata.Info, error) { return nil, nil } func (s noopExtractor) Version() string { return "0" } + +var _ = Describe("Watcher.Start", func() { + It("returns an error instead of hanging when the path cannot be watched", func() { + local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} }) + conf.Server.Scanner.Extractor = "noop" + ls, err := storage.For(filepath.Join(GinkgoT().TempDir(), "does-not-exist")) + Expect(err).ToNot(HaveOccurred()) + lsw := ls.(storage.Watcher) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err = lsw.Start(ctx) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/db/migrations/20240511220020_add_library_table.go b/db/migrations/20240511220020_add_library_table.go index 55b521ca9..d7b4aa5c2 100644 --- a/db/migrations/20240511220020_add_library_table.go +++ b/db/migrations/20240511220020_add_library_table.go @@ -3,7 +3,6 @@ package migrations import ( "context" "database/sql" - "fmt" "github.com/navidrome/navidrome/conf" "github.com/pressly/goose/v3" @@ -28,10 +27,10 @@ func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error { return err } - _, err = tx.ExecContext(ctx, fmt.Sprintf(` - insert into library(id, name, path) values(1, 'Music Library', '%s'); - delete from property where id like 'LastScan-%%'; -`, conf.Server.MusicFolder)) + _, err = tx.ExecContext(ctx, ` + insert into library(id, name, path) values(1, 'Music Library', ?); + delete from property where id like 'LastScan-%'; +`, conf.Server.MusicFolder) if err != nil { return err } diff --git a/model/share.go b/model/share.go index ce0846d60..cf1f4cb34 100644 --- a/model/share.go +++ b/model/share.go @@ -32,9 +32,6 @@ type Share struct { func (s Share) CoverArtID() ArtworkID { ids := strings.SplitN(s.ResourceIDs, ",", 2) - if len(ids) == 0 { - return ArtworkID{} - } switch s.ResourceType { case "album": return Album{ID: ids[0]}.CoverArtID() @@ -43,6 +40,10 @@ func (s Share) CoverArtID() ArtworkID { case "artist": return Artist{ID: ids[0]}.CoverArtID() } + // Tracks can be empty when they went missing or the owner lost access to their library. + if len(s.Tracks) == 0 { + return ArtworkID{} + } rnd := random.Int64N(len(s.Tracks)) return s.Tracks[rnd].CoverArtID() } diff --git a/model/share_test.go b/model/share_test.go new file mode 100644 index 000000000..b5e38af59 --- /dev/null +++ b/model/share_test.go @@ -0,0 +1,19 @@ +package model_test + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Share.CoverArtID", func() { + It("returns an empty artwork ID for a media file share with no visible tracks", func() { + s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1"} + Expect(s.CoverArtID()).To(Equal(model.ArtworkID{})) + }) + + It("picks a track's cover for a media file share", func() { + s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1", Tracks: model.MediaFiles{{ID: "mf-1"}}} + Expect(s.CoverArtID()).To(Equal(model.MediaFile{ID: "mf-1"}.CoverArtID())) + }) +}) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 4b6dc9240..92c696688 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -72,7 +72,6 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e } func (r *shareRepository) loadMedia(share *model.Share) error { - var err error ids := strings.Split(share.ResourceIDs, ",") if len(ids) == 0 { return nil @@ -80,15 +79,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error { noMissing := func(cond Sqlizer) Sqlizer { return And{cond, Eq{"missing": false}} } + // Load as the share owner so their library access is applied, whoever renders the share. + ctx, err := r.ownerContext(share) + if err != nil { + return err + } switch share.ResourceType { case "artist": // Match by album-artist participation, not the deprecated album_artist_id // column (first album artist only), so co-album-artists are included too. - // Load as the share owner so their library access is applied. - ctx, err := r.ownerContext(share) - if err != nil { - return err - } albumRepo := NewAlbumRepository(ctx, r.db) share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) if err != nil { @@ -98,20 +97,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) return err case "album": - albumRepo := NewAlbumRepository(r.ctx, r.db) + albumRepo := NewAlbumRepository(ctx, r.db) share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.ctx, r.db) + mfRepo := NewMediaFileRepository(ctx, r.db) share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - // Load tracks as the share owner so their library access is applied. - ctx, err := r.ownerContext(share) - if err != nil { - return err - } plsRepo := NewPlaylistRepository(ctx, r.db) // Tracks returns nil when the playlist is no longer visible to the owner // (e.g. it was made private after the share was created); leave the share @@ -127,7 +121,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks = tracks.MediaFiles() return nil case "media_file": - mfRepo := NewMediaFileRepository(r.ctx, r.db) + mfRepo := NewMediaFileRepository(ctx, r.db) tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) share.Tracks = sortByIdPosition(tracks, ids) return err diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 3af91b2af..02d8a7386 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -228,7 +228,7 @@ var _ = Describe("ShareRepository", func() { }) }) - Describe("Artist share library scoping", func() { + Describe("Artist, album and media file share library scoping", func() { var otherLib model.Library var owner model.User const primaryID = "share-aa-primary" @@ -267,20 +267,26 @@ var _ = Describe("ShareRepository", func() { Expect(ur.Put(&owner)).To(Succeed()) Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) - _, err := b.NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": "art-share", "user": owner.ID, "desc": "Artist scope share", - "type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) + for _, s := range []struct{ id, typ, ids string }{ + {"art-share", "artist", secondaryID}, + {"art-album-share", "album", "art-album-ok,art-album-other"}, + {"art-mf-share", "media_file", "art-ok,art-other"}, + } { + _, err := b.NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": s.id, "user": owner.ID, "desc": "Scope share", + "type": s.typ, "ids": s.ids, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + } }) AfterEach(func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) b := GetDBXBuilder() - _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute() + _, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute() mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) alr := NewAlbumRepository(adminCtx, b).(*albumRepository) @@ -309,6 +315,23 @@ var _ = Describe("ShareRepository", func() { Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")), "an album outside the owner's libraries must not appear in the share") }) + + It("excludes albums and their tracks outside the owner's libraries from an album share", func() { + // Public share rendering has no user in the context. + share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-album-share") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok"))) + Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other"))) + Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) + }) + + It("excludes tracks outside the owner's libraries from a media file share", func() { + share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-mf-share") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) + }) }) Describe("Ownership Checks", func() { diff --git a/scanner/controller.go b/scanner/controller.go index df5aeb6f9..bfb396c6d 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/pl" + "github.com/navidrome/navidrome/utils/singleton" "golang.org/x/time/rate" ) @@ -387,3 +388,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) { s.broker.SendBroadcastMessage(ctx, status) } + +// GetInstance returns the scanner singleton: Status reads the progress counters of the controller +// running the scan, and scheduler, watcher and signal scans do not start from the API's injector. +func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker, + pls playlists.Playlists, m metrics.Metrics) model.Scanner { + return singleton.GetInstance(func() *controller { + return New(rootCtx, ds, broker, pls, m).(*controller) + }) +} diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 7ffdd69d4..45d202904 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -92,3 +92,13 @@ var _ = Describe("EffectiveFullScan", func() { Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) }) }) + +var _ = Describe("GetInstance", func() { + It("returns the same controller to every caller", func() { + ds := &tests.MockDataStore{} + pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) + b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) + Expect(a).To(BeIdenticalTo(b)) + }) +}) diff --git a/server/auth.go b/server/auth.go index 37a318a83..fdeb8c455 100644 --- a/server/auth.go +++ b/server/auth.go @@ -96,6 +96,16 @@ func buildAuthPayload(user *model.User) map[string]any { return payload } +// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs. +const MaxLoginBodySize = 8 << 10 + +func LimitLoginBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize) + next.ServeHTTP(w, r) + }) +} + func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { data := make(map[string]string) decoder := json.NewDecoder(r.Body) @@ -150,6 +160,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password err := ds.User(ctx).Put(&initialUser) if err != nil { log.Error(ctx, "Could not create initial user", "user", initialUser, err) + return fmt.Errorf("creating initial user: %w", err) } return nil } diff --git a/server/auth_test.go b/server/auth_test.go index e021c82a8..af9ffcaeb 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/md5" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -64,6 +65,14 @@ var _ = Describe("Auth", func() { }) }) + Describe("createAdminUser", func() { + It("returns the error when the user cannot be saved", func() { + ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}} + err := createAdminUser(context.Background(), ds, "johndoe", "secret") + Expect(err).To(MatchError(ContainSubstring("db is down"))) + }) + }) + Describe("Login from HTTP headers", func() { const ( trustedIpv4 = "192.168.0.42" @@ -200,6 +209,13 @@ var _ = Describe("Auth", func() { Expect(resp.Code).To(Equal(http.StatusUnauthorized)) }) + It("rejects a request body larger than the limit", func() { + body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}` + req = httptest.NewRequest("POST", "/login", strings.NewReader(body)) + LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity)) + }) + It("logs in successfully if user exists", func() { usr := ds.User(context.Background()) _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index b0a5350b3..971f7794a 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -78,12 +78,11 @@ func (api *Router) routes() http.Handler { inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated // brute-force surface, so it must share the same per-client throttle when one is configured. + login := inner.With(server.LimitLoginBody) if conf.Server.AuthRequestLimit > 0 { - limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) - inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) - } else { - inner.Post("/users/authenticatebyname", api.authenticateByName) + login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) } + login.Post("/users/authenticatebyname", api.authenticateByName) inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index 5a613f828..577677643 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -101,3 +102,15 @@ var _ = Describe("AuthenticateByName", func() { Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) }) + +var _ = Describe("AuthenticateByName body limit", func() { + It("rejects a request body larger than the limit", func() { + ds := &tests.MockDataStore{} + api := &Router{ds: ds} + w := httptest.NewRecorder() + body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}` + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body)) + server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/server/server.go b/server/server.go index 7e7cfa3ea..7e7e2f6bd 100644 --- a/server/server.go +++ b/server/server.go @@ -204,6 +204,7 @@ func (s *Server) initRoutes() { func (s *Server) mountAuthenticationRoutes() chi.Router { r := s.router return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) { + r.Use(LimitLoginBody) if conf.Server.AuthRequestLimit > 0 { log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, "windowLength", conf.Server.AuthWindowLength) From 9e950cb63d6bd965a7a71e3b03ede294b8bc83a0 Mon Sep 17 00:00:00 2001 From: Huang-404-Q <129407632+Huang-404-Q@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:50:45 +0800 Subject: [PATCH 526/596] fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org> --- cmd/backup.go | 21 ++++++++--- db/backup.go | 15 +++++++- db/backup_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/cmd/backup.go b/cmd/backup.go index c02f3a19f..eca78a272 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "time" @@ -31,7 +32,7 @@ func init() { pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero") backupRoot.AddCommand(pruneCmd) - restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore") + restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)") restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning") _ = restoreCommand.MarkFlagRequired("backup-file") backupRoot.AddCommand(restoreCommand) @@ -95,7 +96,7 @@ func runBackup(ctx context.Context) { start := time.Now() path, err := db.Backup(ctx) if err != nil { - log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err) + log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err) } elapsed := time.Since(start) @@ -140,7 +141,7 @@ func runPrune(ctx context.Context) { start := time.Now() count, err := db.Prune(ctx) if err != nil { - log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err) + log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err) } elapsed := time.Since(start) @@ -163,6 +164,18 @@ func runRestore(ctx context.Context) { return } + // A relative --backup-file is resolved against Backup.Path, the same folder + // `backup create` writes to. Without this, the value was treated as relative + // to the working directory, where the file does not exist. + if !filepath.IsAbs(restorePath) { + backupPath, err := conf.Server.Backup.Path.Path() + if err != nil { + log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err) + return + } + restorePath = filepath.Join(backupPath, restorePath) + } + if !force { fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") fmt.Printf("Please enter YES (all caps) to continue: ") @@ -178,7 +191,7 @@ func runRestore(ctx context.Context) { start := time.Now() err := db.Restore(ctx, restorePath) if err != nil { - log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err) + log.Fatal("Error restoring database", "backupFile", restorePath, err) } elapsed := time.Since(start) diff --git a/db/backup.go b/db/backup.go index 806bef8e2..74cae553e 100644 --- a/db/backup.go +++ b/db/backup.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "slices" + "strings" "time" "github.com/mattn/go-sqlite3" @@ -18,7 +19,7 @@ import ( const ( backupPrefix = "navidrome_backup" - backupRegexString = backupPrefix + "_(.+)\\.db" + backupRegexString = "^" + backupPrefix + "_(.+)\\.db$" ) var backupRegex = regexp.MustCompile(backupRegexString) @@ -40,6 +41,18 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error { } defer existingConn.Close() + // The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the + // path would create an empty database and "restore" it over the live one. + if !isBackup { + // The driver splits the DSN at '?', so such a path would open a different file. + if strings.ContainsRune(path, '?') { + return fmt.Errorf("backup path cannot contain '?': %s", path) + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("backup file not available: %w", err) + } + } + backupDb, err := sql.Open(Driver, path) if err != nil { return fmt.Errorf("opening backup database in '%s': %w", path, err) diff --git a/db/backup_test.go b/db/backup_test.go index 5d1bfc6e3..609cba3d6 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -5,12 +5,14 @@ import ( "database/sql" "math/rand" "os" + "path/filepath" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/singleton" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -103,6 +105,19 @@ var _ = Describe("database backups", func() { Entry("delete all files", 0, 0), Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)), Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically))) + + It("ignores SQLite sidecar files when counting backups", func() { + for _, suffix := range []string{"-shm", "-wal"} { + file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix) + Expect(err).ToNot(HaveOccurred()) + _ = file.Close() + } + + conf.Server.Backup.Count = len(timesDecreasingChronologically) + pruneCount, err := Prune(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(pruneCount).To(BeZero()) + }) }) Describe("backup and restore", Ordered, func() { @@ -148,4 +163,78 @@ var _ = Describe("database backups", func() { Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse()) }) }) + + Describe("backup and restore with a file-based database", Ordered, func() { + var ctx context.Context + var tempFolder string + var dbFilePath string + + BeforeAll(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + + var err error + tempFolder, err = os.MkdirTemp("", "navidrome_restore") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Close(ctx) + _ = os.RemoveAll(tempFolder) + }) + + // Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode. + dbFilePath = filepath.Join(tempFolder, "navidrome.db") + conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" + // The previous container's cleanup closed the shared *sql.DB without + // dropping the singleton; force a fresh connection for this container. + singleton.DeleteInstance[*sql.DB]() + DeferCleanup(Init(ctx)) + }) + + It("restores data into a database whose stale WAL sidecar files were left behind", func() { + By("seeding user data in the current database") + _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) + VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`) + Expect(err).ToNot(HaveOccurred()) + + By("creating a backup containing the user row") + path, err := Backup(ctx) + Expect(err).ToNot(HaveOccurred()) + + By("simulating the CLI exiting without closing the pool: sidecar files stay behind") + _, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)") + Expect(err).ToNot(HaveOccurred()) + singleton.DeleteInstance[*sql.DB]() + + err = tests.ClearDB() + Expect(err).ToNot(HaveOccurred()) + + By("restoring the backup") + Expect(Restore(ctx, path)).To(Succeed()) + + By("verifying the restored data is readable through a fresh connection") + singleton.DeleteInstance[*sql.DB]() + var userName string + Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed()) + Expect(userName).To(Equal("drilladmin")) + }) + + It("fails to restore from a backup file that does not exist, leaving the database intact", func() { + By("seeding user data in the current database") + _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) + VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`) + Expect(err).ToNot(HaveOccurred()) + + By("attempting a restore from a nonexistent file") + missingPath := filepath.Join(tempFolder, "does_not_exist.db") + err = Restore(ctx, missingPath) + Expect(err).To(HaveOccurred()) + + By("verifying the database was not wiped") + var userName string + Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed()) + Expect(userName).To(Equal("keepme")) + _, statErr := os.Stat(missingPath) + Expect(statErr).To(MatchError(os.ErrNotExist)) + }) + }) }) From d0d5403708c1232de53741856b1cfb035b807912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 11 Sep 2026 22:26:28 -0400 Subject: [PATCH 527/596] feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069) * feat(db): add repair command to rebuild a corrupted FTS5 search index A corrupted media_file_fts index made every scan fail with 'database disk image is malformed', and sqlite3's built-in 'rebuild' command cannot repair contentless FTS5 tables, leaving users to hand-drop tables and triggers. Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the reported corruption is confined to the FTS5 search tables, drops and recreates the three tables and their nine triggers and repopulates them from the base tables (which hold all the data, so nothing is lost). The result is verified with the FTS5-native 'integrity-check' command, which reads only the rebuilt indexes instead of re-scanning the whole database (on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second verify). A --rebuild flag forces the rebuild even when the check passes, for silently desynced indexes. The rebuild refuses to run while migrations are pending, and a schema-comparison test guards the duplicated DDL against drifting from the migration. The DbPath existence check and the YES confirmation prompt, previously copy-pasted across the backup commands, are extracted into shared cmd helpers used by both backup and repair. Part of #6067 * fix(db): type the FTS migration version as int64 for 32-bit builds The untyped constant defaults to int, which overflows on arm/v7 and 386. * feat(db): split repair into 'db doctor' and 'search rebuild' commands A single 'db repair' command promised more than it delivered: the only thing it could actually repair was the search index, and its diagnosis and its fix were welded together, so a forced rebuild paid the full integrity check twice. Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA integrity_check and PRAGMA foreign_key_check, and routes the user (to 'search rebuild' when corruption is FTS-only, to backup/.recover otherwise). 'navidrome search rebuild' just rebuilds and verifies the FTS index, which takes ~2s on a prod-size library instead of ~19s. * refactor(cmd): extract a testable doctor function and bound foreign key output Extract the doctor routing (check, classify, advise) into a function that takes an io.Writer, so the advice paths are unit-tested and the process exit happens in the cobra wrapper after the DB is closed (os.Exit was skipping the deferred close, leaving WAL/SHM files behind on the unhealthy paths). Aggregate foreign_key_check by (table, parent): the raw pragma emits one row per orphan, which is unbounded output on a large corrupted library. Also make confirmYES take an io.Reader, drop the unused return from the renamed requireExistingDB, share the FTS table list with the tests, and stop the schema-guard specs from paying for a seeded database they never use. * docs(cmd): promise 'never alters your data' instead of 'never modifies the database' Closing the doctor's connection can checkpoint a stale WAL into the main file (as any SQLite tool does), so the byte-level claim was too strong. The checks themselves are read-only and no logical content ever changes. * fix(cmd): make 'db doctor' advice honest when checks are inconclusive PRAGMA integrity_check stops at 100 errors and emits no marker row, so a saturated result was being read as the whole picture. IntegrityCheck now sets the limit itself and reports saturation as a truncated list, and doctor no longer claims corruption is limited to the search index in that case. Foreign key violations now print a next step instead of only flipping the exit code: migrations run with foreign_keys off, so orphan rows are a realistic leftover on a database that is not corrupt. Also corrects the 'search rebuild' help, which promised that 'db doctor' detects when a rebuild is needed -- integrity_check cannot see an index that is merely out of sync; gives the never-migrated case its intended message instead of a raw 'no such table: goose_db_version'; and extracts rebuildSearchIndex so the database is closed before log.Fatal exits. * refactor(cmd): promote 'db doctor' to a top-level 'doctor' command The 'db' group held a single subcommand, and the checks planned for it reach past the database: config, music folder permissions, external tools. None of those belong under 'db'. Promoting it also evens out the shape of the pair. The command that finds the problem is now top-level alongside 'search rebuild', the command that fixes it, matching the 'brew doctor' convention users already expect. 'db doctor' has never been released, so no alias or deprecation is needed. * refactor(db): tighten the doctor and search rebuild internals Follow-up cleanup with no behaviour change except where noted. integrity_check now asks the pragma for one row beyond the reported limit and treats that extra row as the proof it truncated, instead of inferring truncation from a saturated count. That distinguishes a list of exactly 100 issues from one that was cut short -- the old test could not, and 100 was SQLite's own default, so passing it was a no-op. ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving the prose to the layer that already owns the CLI vocabulary. The goose table probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database ever been migrated' has one spelling. Also folds ftsMigrationApplied into requireFTSMigration, lifts printFindings out of a closure that captured nothing, names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the drift test compares against the full migration chain, not the single frozen migration it claimed. * fix(db): verify the rebuilt search index before committing it RebuildFTS committed its transaction and only then ran the FTS5 integrity check, from the caller. A rebuild that produced a bad index was therefore already persisted by the time anyone noticed, leaving the user worse off than before they ran the command. The check now runs inside the transaction, so a rebuild that does not verify rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB signature for callers outside a transaction; the shared body takes the small execer interface that both *sql.DB and *sql.Tx satisfy. Adds a spec for the rollback: it removes a column the repopulating SELECT reads, so the transaction fails after the drops, and asserts the old index still answers queries. * refactor(cmd): drop the unused io.Reader parameter from confirmYES The reader was added as a test seam that no test ever used: all three callers pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused os import from backup.go and search.go. * fix(cmd): stop promising a scan clears every foreign key violation doctor told the user to run 'navidrome scan -f' for any foreign key violation. SQLStore.GC only purges albums, artists, folders, annotations, bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the next doctor run still reports them. player.user_id references user(id) and no scan phase touches that table at all. The advice now says a scan clears some of them and the rest have to be removed by hand, which keeps the next step the earlier round asked for without claiming a cleanup that does not happen. * docs(db): trim over-long comments on the doctor and rebuild paths Six comments ran past two lines or repeated something already stated nearby. The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which the new 'verifies before committing' sentence already implies, and a spec comment restated that same rationale a second time. * docs: drop em dashes from the comments added in this branch --- cmd/backup.go | 71 ++------- cmd/doctor.go | 99 ++++++++++++ cmd/doctor_test.go | 124 +++++++++++++++ cmd/search.go | 55 +++++++ cmd/utils.go | 20 +++ db/db.go | 17 ++- db/export_test.go | 4 + db/repair.go | 367 +++++++++++++++++++++++++++++++++++++++++++++ db/repair_test.go | 309 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 1001 insertions(+), 65 deletions(-) create mode 100644 cmd/doctor.go create mode 100644 cmd/doctor_test.go create mode 100644 cmd/search.go create mode 100644 db/repair.go create mode 100644 db/repair_test.go diff --git a/cmd/backup.go b/cmd/backup.go index eca78a272..932a833f2 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -2,10 +2,7 @@ package cmd import ( "context" - "fmt" - "os" "path/filepath" - "strings" "time" "github.com/navidrome/navidrome/conf" @@ -79,19 +76,7 @@ func runBackup(ctx context.Context) { conf.Server.Backup.Path = conf.NewDir(backupDir) } - idx := strings.LastIndex(conf.Server.DbPath, "?") - var path string - - if idx == -1 { - path = conf.Server.DbPath - } else { - path = conf.Server.DbPath[:idx] - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - log.Fatal("No existing database", "path", path) - return - } + requireExistingDB() start := time.Now() path, err := db.Backup(ctx) @@ -112,32 +97,13 @@ func runPrune(ctx context.Context) { conf.Server.Backup.Count = backupCount } - if conf.Server.Backup.Count == 0 && !force { - fmt.Println("Warning: pruning ALL backups") - fmt.Printf("Please enter YES (all caps) to continue: ") - var input string - _, err := fmt.Scanln(&input) - - if input != "YES" || err != nil { - log.Warn("Prune cancelled") - return - } - } - - idx := strings.LastIndex(conf.Server.DbPath, "?") - var path string - - if idx == -1 { - path = conf.Server.DbPath - } else { - path = conf.Server.DbPath[:idx] - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - log.Fatal("No existing database", "path", path) + if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") { + log.Warn("Prune cancelled") return } + requireExistingDB() + start := time.Now() count, err := db.Prune(ctx) if err != nil { @@ -150,19 +116,7 @@ func runPrune(ctx context.Context) { } func runRestore(ctx context.Context) { - idx := strings.LastIndex(conf.Server.DbPath, "?") - var path string - - if idx == -1 { - path = conf.Server.DbPath - } else { - path = conf.Server.DbPath[:idx] - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - log.Fatal("No existing database", "path", path) - return - } + requireExistingDB() // A relative --backup-file is resolved against Backup.Path, the same folder // `backup create` writes to. Without this, the value was treated as relative @@ -176,16 +130,9 @@ func runRestore(ctx context.Context) { restorePath = filepath.Join(backupPath, restorePath) } - if !force { - fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") - fmt.Printf("Please enter YES (all caps) to continue: ") - var input string - _, err := fmt.Scanln(&input) - - if input != "YES" || err != nil { - log.Warn("Restore cancelled") - return - } + if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") { + log.Warn("Restore cancelled") + return } start := time.Now() diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 000000000..fd0eb1cd7 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "context" + "database/sql" + "fmt" + "io" + "os" + + "github.com/navidrome/navidrome/db" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Check your Navidrome installation for problems", + Long: "Run read-only health checks and report what was found. Checks the database for " + + "corruption and foreign key violations, and reports whether 'navidrome search rebuild' " + + "can fix what it finds. This command never alters your data", + Run: func(cmd *cobra.Command, _ []string) { + runDoctor(cmd.Context()) + }, +} + +func runDoctor(ctx context.Context) { + requireExistingDB() + + healthy := doctor(ctx, db.Db(), os.Stdout) + db.Close(ctx) + if !healthy { + os.Exit(1) + } +} + +const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command." + +func printFindings(out io.Writer, check, noun string, items []string) { + fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun) + for _, item := range items { + fmt.Fprintln(out, " "+item) + } +} + +func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool { + healthy := true + + fmt.Fprintln(out, "Checking database integrity...") + issues, truncated, err := db.IntegrityCheck(ctx, database) + switch { + case err != nil: + fmt.Fprintln(out, "The integrity check could not complete: "+err.Error()) + fmt.Fprintln(out, recoveryAdvice) + return false + case len(issues) == 0: + fmt.Fprintln(out, "Integrity check passed.") + default: + healthy = false + printFindings(out, "Integrity check", "issue(s)", issues) + switch { + case truncated: + fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.") + fmt.Fprintln(out, recoveryAdvice) + case db.IsFTSCorruptionOnly(issues): + fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.") + default: + fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.") + fmt.Fprintln(out, recoveryAdvice) + } + } + + fmt.Fprintln(out, "Checking foreign keys...") + violations, err := db.ForeignKeyCheck(ctx, database) + switch { + case err != nil: + healthy = false + fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error()) + case len(violations) == 0: + fmt.Fprintln(out, "Foreign key check passed.") + default: + healthy = false + lines := make([]string, 0, len(violations)) + for _, v := range violations { + lines = append(lines, + fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent)) + } + printFindings(out, "Foreign key check", "violation(s)", lines) + fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+ + "in library data; the rest have to be removed by hand.") + } + + if healthy { + fmt.Fprintln(out, "Database is healthy.") + } + return healthy +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 000000000..7f8cd9728 --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/db" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("doctor", func() { + var ( + ctx context.Context + dbPath string + database *sql.DB + out *strings.Builder + reopen func() + ) + + // A file-backed DB so specs can corrupt raw pages; a table named like a real FTS + // search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks. + BeforeEach(func() { + ctx = context.Background() + dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db") + reopen = func() { + var err error + database, err = sql.Open(db.Dialect, dbPath) + Expect(err).ToNot(HaveOccurred()) + database.SetMaxOpenConns(1) + } + reopen() + DeferCleanup(func() { _ = database.Close() }) + + for _, stmt := range []string{ + `create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`, + `insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`, + `create table library(id integer primary key)`, + `create table media_file(id integer primary key, library_id integer references library(id))`, + } { + _, err := database.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + out = &strings.Builder{} + }) + + It("reports a healthy database", func() { + Expect(doctor(ctx, database, out)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("Database is healthy.")) + }) + + It("points to 'search rebuild' when corruption is limited to the search index", func() { + _, err := database.ExecContext(ctx, + `update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`) + Expect(err).ToNot(HaveOccurred()) + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("navidrome search rebuild")) + }) + + It("points to a backup restore when corruption is not limited to the search index", func() { + _, err := database.ExecContext(ctx, + `insert into library(id) + with recursive s(x) as (select 1 union all select x+1 from s where x < 200) + select x from s`) + Expect(err).ToNot(HaveOccurred()) + var rootPage, pageSize int64 + Expect(database.QueryRowContext(ctx, + `select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed()) + Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed()) + Expect(database.Close()).To(Succeed()) + f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600) + Expect(err).ToNot(HaveOccurred()) + _, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40) + Expect(err).ToNot(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + reopen() + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("backup restore")) + Expect(out.String()).ToNot(ContainSubstring("search rebuild")) + }) + + It("reports foreign key violations", func() { + _, err := database.ExecContext(ctx, `pragma foreign_keys = off`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`) + Expect(err).ToNot(HaveOccurred()) + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("Foreign key check reported")) + Expect(out.String()).To(ContainSubstring("media_file")) + Expect(out.String()).To(ContainSubstring("navidrome scan -f")) + // GC never touches player, share or playqueue, so don't promise a full cleanup. + Expect(out.String()).To(ContainSubstring("removed by hand")) + }) + + // Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the + // user to 'search rebuild', but the pragma stopped at its limit without saying so. + It("does not blame the search index when the issue list is truncated", func() { + for _, stmt := range []string{ + `create table t(a, b)`, + `with recursive s(x) as (select 1 union all select x+1 from s where x < 300) + insert into t select x, x + 10000 from s`, + `create index media_file_fts_probe on t(a)`, + `pragma writable_schema=on`, + `update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)' + where name = 'media_file_fts_probe'`, + } { + _, err := database.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + Expect(database.Close()).To(Succeed()) + reopen() + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).ToNot(ContainSubstring("search rebuild")) + Expect(out.String()).To(ContainSubstring("backup restore")) + }) +}) diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 000000000..46b7eebd5 --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/spf13/cobra" +) + +var searchRebuildForce bool + +func init() { + rootCmd.AddCommand(searchRoot) + + searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation") + searchRoot.AddCommand(searchRebuildCmd) +} + +var ( + searchRoot = &cobra.Command{ + Use: "search", + Short: "Search index maintenance", + } + + searchRebuildCmd = &cobra.Command{ + Use: "rebuild", + Short: "Rebuild the full-text search index", + Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " + + "or desynced search index without any data loss. Note that 'navidrome doctor' detects a " + + "corrupted index, but cannot tell when the index has merely drifted out of sync with the " + + "library. This must be done offline", + Run: func(cmd *cobra.Command, _ []string) { + runSearchRebuild(cmd.Context()) + }, + } +) + +func runSearchRebuild(ctx context.Context) { + requireExistingDB() + + if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") { + log.Warn("Rebuild cancelled") + return + } + + fmt.Println("Rebuilding the search index...") + err := db.RebuildFTS(ctx, db.Db()) + db.Close(ctx) + if err != nil { + log.Fatal("Error rebuilding the search index", err) + } + fmt.Println("Search index rebuilt successfully.") +} diff --git a/cmd/utils.go b/cmd/utils.go index 74da51828..a67f720ac 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -5,8 +5,11 @@ import ( "errors" "fmt" "io" + "os" + "strings" "text/tabwriter" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -15,6 +18,23 @@ import ( "github.com/navidrome/navidrome/persistence" ) +// requireExistingDB aborts the command when the database file (DbPath minus DSN +// params) does not exist. +func requireExistingDB() { + path, _, _ := strings.Cut(conf.Server.DbPath, "?") + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + } +} + +func confirmYES(warning string) bool { + fmt.Println(warning) + fmt.Printf("Please enter YES (all caps) to continue: ") + var input string + _, err := fmt.Scanln(&input) + return input == "YES" && err == nil +} + // newTabWriter keeps every CLI table on the same column settings. func newTabWriter(out io.Writer) *tabwriter.Writer { return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) diff --git a/db/db.go b/db/db.go index c53aa364a..685886edb 100644 --- a/db/db.go +++ b/db/db.go @@ -157,13 +157,24 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool { return l.numPending > 0 } +// hasGooseTable reports whether goose's bookkeeping table exists, i.e. whether the +// database has ever been migrated. +func hasGooseTable(ctx context.Context, db *sql.DB) (bool, error) { + var name string + err := db.QueryRowContext(ctx, + "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version'").Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + func isSchemaEmpty(ctx context.Context, db *sql.DB) bool { - rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck + found, err := hasGooseTable(ctx, db) if err != nil { log.Fatal(ctx, "Database could not be opened!", err) } - defer rows.Close() - return !rows.Next() + return !found } type logAdapter struct { diff --git a/db/export_test.go b/db/export_test.go index 02b88cd66..d2034bcdb 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,6 +2,10 @@ package db // Definitions for testing private methods var ( + EmbedMigrations = embedMigrations + FTSTables = ftsTables + FTSTriggerSuffixes = ftsTriggerSuffixes + FTSSearchMigration = ftsSearchMigration IsSchemaEmpty = isSchemaEmpty BackupPath = backupPath OptimizeDBAt = optimizeAt diff --git a/db/repair.go b/db/repair.go new file mode 100644 index 000000000..7f8b8d044 --- /dev/null +++ b/db/repair.go @@ -0,0 +1,367 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strings" +) + +var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"} + +var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"} + +// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the +// pragma for one extra row, because it truncates without emitting any marker. +const integrityCheckMaxIssues = 100 + +// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or +// an empty slice when healthy. The second value marks a list that was cut short. +func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) { + rows, err := database.QueryContext(ctx, + fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1)) + if err != nil { + return nil, false, fmt.Errorf("running integrity_check: %w", err) + } + defer rows.Close() + + var issues []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + return nil, false, fmt.Errorf("reading integrity_check results: %w", err) + } + issues = append(issues, line) + } + if err := rows.Err(); err != nil { + return nil, false, fmt.Errorf("reading integrity_check results: %w", err) + } + if len(issues) == 1 && issues[0] == "ok" { + return nil, false, nil + } + if len(issues) > integrityCheckMaxIssues { + return issues[:integrityCheckMaxIssues], true, nil + } + return issues, false, nil +} + +// FKViolation counts the rows in Table that reference missing rows in Parent. +type FKViolation struct { + Table string + Parent string + Count int64 +} + +// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair +// because the raw pragma emits one row per orphan, unbounded on a large library. +func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) { + rows, err := database.QueryContext(ctx, + `SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`) + if err != nil { + return nil, fmt.Errorf("running foreign_key_check: %w", err) + } + defer rows.Close() + + var violations []FKViolation + for rows.Next() { + var v FKViolation + if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil { + return nil, fmt.Errorf("reading foreign_key_check results: %w", err) + } + violations = append(violations, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("reading foreign_key_check results: %w", err) + } + return violations, nil +} + +// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the +// FTS5 search tables, meaning RebuildFTS can fully repair the database. +func IsFTSCorruptionOnly(issues []string) bool { + if len(issues) == 0 { + return false + } + for _, line := range issues { + if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) { + return false + } + } + return true +} + +// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs. +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a +// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database. +func VerifyFTS(ctx context.Context, database *sql.DB) error { + return verifyFTS(ctx, database) +} + +func verifyFTS(ctx context.Context, database execer) error { + for _, table := range ftsTables { + stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list + if _, err := database.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("verifying %s: %w", table, err) + } + } + return nil +} + +const ftsSearchMigration int64 = 20260220173400 + +var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first") + +// requireFTSMigration fails unless the FTS search migration has run. The goose table +// is probed separately because a query against a missing table fails at prepare time. +func requireFTSMigration(ctx context.Context, database *sql.DB) error { + migrated, err := hasGooseTable(ctx, database) + if err != nil { + return fmt.Errorf("checking FTS migration status: %w", err) + } + if !migrated { + return errNotMigrated + } + var applied int + if err := database.QueryRowContext(ctx, + "SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil { + return fmt.Errorf("checking FTS migration status: %w", err) + } + if applied == 0 { + return errNotMigrated + } + return nil +} + +// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the +// base tables, and verifies the result before committing. The tables are contentless, +// so no user data is lost. It needs only the FTS migration, not a fully migrated +// schema, because a corrupted DB often cannot run pending migrations. +func RebuildFTS(ctx context.Context, database *sql.DB) error { + if err := requireFTSMigration(ctx, database); err != nil { + return err + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("starting FTS rebuild transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var stmts []string + for _, table := range ftsTables { + for _, suffix := range ftsTriggerSuffixes { + stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix) + } + stmts = append(stmts, "DROP TABLE IF EXISTS "+table) + } + stmts = append(stmts, ftsSchemaDDL...) + for _, stmt := range stmts { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("rebuilding FTS schema: %w", err) + } + } + if err := verifyFTS(ctx, tx); err != nil { + return fmt.Errorf("the rebuilt search index did not verify: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing FTS rebuild: %w", err) + } + return nil +} + +// ftsSchemaDDL must reproduce what the full migration chain produces, not what any +// single migration does; the schema comparison in repair_test.go guards the drift. +var ftsSchemaDDL = []string{ + ` + CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5( + title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized, + content='', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ) + `, + ` + CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( + name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized, + content='', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ) + `, + ` + CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( + name, sort_artist_name, search_normalized, + content='', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ) + `, + ` + INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + SELECT rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''), + COALESCE(search_normalized, '') + FROM media_file + `, + ` + INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''), + COALESCE(search_participants, ''), COALESCE(discs, ''), + COALESCE(catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(album.tags, '$.albumversion') AS je), ''), + COALESCE(search_normalized, '') + FROM album + `, + ` + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') + FROM artist + `, + ` + CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN + INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, + NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, + COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), + COALESCE(NEW.search_normalized, '')); + END + `, + ` + CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN + INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, + OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, + COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), + COALESCE(OLD.search_normalized, '')); + END + `, + ` + CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file + WHEN + OLD.title IS NOT NEW.title OR + OLD.album IS NOT NEW.album OR + OLD.artist IS NOT NEW.artist OR + OLD.album_artist IS NOT NEW.album_artist OR + OLD.sort_title IS NOT NEW.sort_title OR + OLD.sort_album_name IS NOT NEW.sort_album_name OR + OLD.sort_artist_name IS NOT NEW.sort_artist_name OR + OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR + OLD.disc_subtitle IS NOT NEW.disc_subtitle OR + OLD.search_participants IS NOT NEW.search_participants OR + OLD.search_normalized IS NOT NEW.search_normalized + BEGIN + INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, + OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, + COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), + COALESCE(OLD.search_normalized, '')); + INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, + sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, + disc_subtitle, search_participants, search_normalized) + VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, + NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, + COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), + COALESCE(NEW.search_normalized, '')); + END + `, + ` + CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN + INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), + COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), + COALESCE(NEW.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(NEW.tags, '$.albumversion') AS je), ''), + COALESCE(NEW.search_normalized, '')); + END + `, + ` + CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN + INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), + COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), + COALESCE(OLD.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(OLD.tags, '$.albumversion') AS je), ''), + COALESCE(OLD.search_normalized, '')); + END + `, + ` + CREATE TRIGGER album_fts_au AFTER UPDATE ON album + WHEN + OLD.name IS NOT NEW.name OR + OLD.sort_album_name IS NOT NEW.sort_album_name OR + OLD.album_artist IS NOT NEW.album_artist OR + OLD.search_participants IS NOT NEW.search_participants OR + OLD.discs IS NOT NEW.discs OR + OLD.catalog_num IS NOT NEW.catalog_num OR + OLD.tags IS NOT NEW.tags OR + OLD.search_normalized IS NOT NEW.search_normalized + BEGIN + INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), + COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), + COALESCE(OLD.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(OLD.tags, '$.albumversion') AS je), ''), + COALESCE(OLD.search_normalized, '')); + INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, + search_participants, discs, catalog_num, album_version, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), + COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), + COALESCE(NEW.catalog_num, ''), + COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') + FROM json_each(NEW.tags, '$.albumversion') AS je), ''), + COALESCE(NEW.search_normalized, '')); + END + `, + ` + CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), + COALESCE(NEW.search_normalized, '')); + END + `, + ` + CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN + INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), + COALESCE(OLD.search_normalized, '')); + END + `, + ` + CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist + WHEN + OLD.name IS NOT NEW.name OR + OLD.sort_artist_name IS NOT NEW.sort_artist_name OR + OLD.search_normalized IS NOT NEW.search_normalized + BEGIN + INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) + VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), + COALESCE(OLD.search_normalized, '')); + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), + COALESCE(NEW.search_normalized, '')); + END + `, +} diff --git a/db/repair_test.go b/db/repair_test.go new file mode 100644 index 000000000..eb7321939 --- /dev/null +++ b/db/repair_test.go @@ -0,0 +1,309 @@ +package db_test + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/navidrome/navidrome/db" + "github.com/pressly/goose/v3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// newDB returns an in-memory database migrated up to the given goose version +// (0 = fully migrated). +func newDB(ctx context.Context, upTo int64) *sql.DB { + GinkgoHelper() + d, err := sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + d.SetMaxOpenConns(1) // non-shared :memory:, a second conn would be an empty DB + DeferCleanup(func() { _ = d.Close() }) + + _, err = d.ExecContext(ctx, "PRAGMA foreign_keys=off") + Expect(err).ToNot(HaveOccurred()) + goose.SetBaseFS(db.EmbedMigrations) + goose.SetLogger(goose.NopLogger()) + DeferCleanup(func() { goose.SetBaseFS(nil) }) + Expect(goose.SetDialect(db.Dialect)).To(Succeed()) + if upTo == 0 { + Expect(goose.UpContext(ctx, d, "migrations")).To(Succeed()) + } else { + Expect(goose.UpToContext(ctx, d, "migrations", upTo)).To(Succeed()) + } + return d +} + +// openMismatchedIndexDB builds a database whose index is declared over a different +// column than the one it was populated from, so integrity_check reports one issue per row. +func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB { + GinkgoHelper() + path := filepath.Join(GinkgoT().TempDir(), "mismatched.db") + open := func() *sql.DB { + d, err := sql.Open(db.Dialect, path) + Expect(err).ToNot(HaveOccurred()) + d.SetMaxOpenConns(1) + return d + } + d := open() + for _, stmt := range []string{ + `create table t(a, b)`, + fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d) + insert into t select x, x + 10000 from s`, rows), + `create index i on t(a)`, + `pragma writable_schema=on`, + `update sqlite_master set sql = 'CREATE INDEX i ON t(b)' where name = 'i'`, + } { + _, err := d.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema + + d = open() + DeferCleanup(func() { _ = d.Close() }) + return d +} + +var _ = Describe("IsFTSCorruptionOnly", func() { + It("is true when every issue mentions an FTS search table", func() { + Expect(db.IsFTSCorruptionOnly([]string{ + `fts5: corruption found reading blob 42 from table "media_file_fts"`, + `malformed inverted index for FTS5 table main.album_fts`, + `fts5: corruption in "artist_fts"`, + })).To(BeTrue()) + }) + + It("is false when any issue is outside the FTS search tables", func() { + Expect(db.IsFTSCorruptionOnly([]string{ + `fts5: corruption found reading blob 42 from table "media_file_fts"`, + `*** in database main ***`, + })).To(BeFalse()) + }) + + It("is false when there are no issues", func() { + Expect(db.IsFTSCorruptionOnly(nil)).To(BeFalse()) + }) +}) + +var _ = Describe("RebuildFTS schema guard", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + It("refuses to run on a schema older than the FTS migration", func() { + old := newDB(ctx, db.FTSSearchMigration-1) + + err := db.RebuildFTS(ctx, old) + Expect(err).To(MatchError(ContainSubstring("migration"))) + }) + + It("refuses to run on a database that was never migrated", func() { + empty, err := sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + empty.SetMaxOpenConns(1) + DeferCleanup(func() { _ = empty.Close() }) + + Expect(db.RebuildFTS(ctx, empty)).To(MatchError(ContainSubstring("start Navidrome once"))) + }) + + It("runs on a post-FTS schema even when newer migrations are pending", func() { + behind := newDB(ctx, 20260702152457) + + Expect(db.RebuildFTS(ctx, behind)).To(Succeed()) + }) +}) + +var _ = Describe("Repair", func() { + var ( + ctx context.Context + database *sql.DB + ) + + BeforeEach(func() { + ctx = context.Background() + database = newDB(ctx, 0) + + for _, stmt := range []string{ + `insert into artist(id, name, search_normalized) values ('ar-1', 'Ramones', 'ramones')`, + `insert into album(id, name, search_normalized) values ('al-1', 'Rocket to Russia', 'rocket to russia')`, + `insert into media_file(id, title, search_normalized) values ('mf-1', 'Teenage Lobotomy', 'teenage lobotomy')`, + `insert into media_file(id, title, search_normalized) values ('mf-2', 'Rockaway Beach', 'rockaway beach')`, + } { + _, err := database.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + }) + + corruptFTS := func(table string) { + // 8+ bytes of garbage: a 4-byte blob still parses as a valid empty structure record + _, err := database.ExecContext(ctx, `update `+table+`_data set block = x'deadbeefdeadbeef' where id > 1`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + searchFTS := func(table, term string) int { + var count int + err := database.QueryRowContext(ctx, `select count(*) from `+table+` where `+table+` match ?`, term).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + return count + } + + // ftsSchema returns name -> whitespace-normalized DDL for the FTS tables, + // their shadow tables, and their triggers. + ftsSchema := func() map[string]string { + rows, err := database.QueryContext(ctx, + `select name, sql from sqlite_master where name like '%_fts%' and sql is not null`) + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + ws := regexp.MustCompile(`\s+`) + schema := map[string]string{} + for rows.Next() { + var name, ddl string + Expect(rows.Scan(&name, &ddl)).To(Succeed()) + schema[name] = ws.ReplaceAllString(ddl, " ") + } + Expect(rows.Err()).ToNot(HaveOccurred()) + return schema + } + + Describe("IntegrityCheck", func() { + It("returns no issues for a healthy database", func() { + issues, truncated, err := db.IntegrityCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(BeEmpty()) + Expect(truncated).To(BeFalse()) + }) + + It("reports corruption in an FTS index", func() { + corruptFTS("media_file_fts") + issues, truncated, err := db.IntegrityCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).ToNot(BeEmpty()) + Expect(strings.Join(issues, "\n")).To(ContainSubstring("media_file_fts")) + Expect(truncated).To(BeFalse()) + }) + + It("flags the issue list as truncated when there are more issues than the limit", func() { + broken := openMismatchedIndexDB(ctx, 300) + + issues, truncated, err := db.IntegrityCheck(ctx, broken) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(HaveLen(100)) + Expect(truncated).To(BeTrue()) + }) + + It("does not flag truncation when the issues exactly fill the limit", func() { + broken := openMismatchedIndexDB(ctx, 100) + + issues, truncated, err := db.IntegrityCheck(ctx, broken) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(HaveLen(100)) + Expect(truncated).To(BeFalse()) + }) + }) + + Describe("ForeignKeyCheck", func() { + It("returns no violations for a healthy database", func() { + violations, err := db.ForeignKeyCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(violations).To(BeEmpty()) + }) + + It("reports rows referencing missing parents", func() { + _, err := database.ExecContext(ctx, + `insert into media_file(id, title, library_id) values ('mf-bad', 'Orphan', 999)`) + Expect(err).ToNot(HaveOccurred()) + + violations, err := db.ForeignKeyCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(violations).To(HaveLen(1)) + Expect(violations[0].Table).To(Equal("media_file")) + Expect(violations[0].Parent).To(Equal("library")) + Expect(violations[0].Count).To(BeNumerically("==", 1)) + }) + }) + + Describe("VerifyFTS", func() { + It("passes on a healthy index", func() { + Expect(db.VerifyFTS(ctx, database)).To(Succeed()) + }) + + It("fails on a corrupted index, naming the table", func() { + corruptFTS("album_fts") + Expect(db.VerifyFTS(ctx, database)).To(MatchError(ContainSubstring("album_fts"))) + }) + }) + + Describe("RebuildFTS", func() { + It("repairs a corrupted FTS index", func() { + corruptFTS("media_file_fts") + + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + issues, _, err := db.IntegrityCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(BeEmpty()) + Expect(db.VerifyFTS(ctx, database)).To(Succeed()) + Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) + Expect(searchFTS("album_fts", "russia")).To(Equal(1)) + Expect(searchFTS("artist_fts", "ramones")).To(Equal(1)) + }) + + It("recreates tables and triggers dropped by hand", func() { + for _, table := range db.FTSTables { + for _, suffix := range db.FTSTriggerSuffixes { + _, err := database.ExecContext(ctx, "drop trigger "+table+suffix) + Expect(err).ToNot(HaveOccurred()) + } + _, err := database.ExecContext(ctx, "drop table "+table) + Expect(err).ToNot(HaveOccurred()) + } + + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + Expect(searchFTS("media_file_fts", "rockaway")).To(Equal(1)) + }) + + It("rolls back and keeps the old index when the rebuild fails", func() { + // Triggers go first: SQLite refuses to drop a column they reference. + for _, suffix := range db.FTSTriggerSuffixes { + _, err := database.ExecContext(ctx, "drop trigger media_file_fts"+suffix) + Expect(err).ToNot(HaveOccurred()) + } + _, err := database.ExecContext(ctx, `alter table media_file drop column disc_subtitle`) + Expect(err).ToNot(HaveOccurred()) + + Expect(db.RebuildFTS(ctx, database)).ToNot(Succeed()) + + Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) + Expect(searchFTS("album_fts", "russia")).To(Equal(1)) + }) + + It("produces the same schema as the migration", func() { + migrated := ftsSchema() + Expect(migrated).ToNot(BeEmpty()) + + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + Expect(ftsSchema()).To(Equal(migrated)) + }) + + It("leaves working triggers behind", func() { + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + _, err := database.ExecContext(ctx, + `insert into artist(id, name, search_normalized) values ('ar-2', 'Blondie', 'blondie')`) + Expect(err).ToNot(HaveOccurred()) + Expect(searchFTS("artist_fts", "blondie")).To(Equal(1)) + + _, err = database.ExecContext(ctx, `delete from artist where id = 'ar-2'`) + Expect(err).ToNot(HaveOccurred()) + Expect(searchFTS("artist_fts", "blondie")).To(BeZero()) + }) + }) +}) From d9d38f842ae839f59356d0dbb0c33e6bf85a5a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 12 Sep 2026 00:52:54 -0400 Subject: [PATCH 528/596] fix(ui): update German, Greek, Finnish, Galician, Polish, Portuguese (BR), Thai, Ukrainian, Chinese (traditional) translations from POEditor (#5833) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/de.json | 27 +++++++++++++++------- resources/i18n/el.json | 31 +++++++++++++++++-------- resources/i18n/fi.json | 27 +++++++++++++++------- resources/i18n/gl.json | 33 ++++++++++++++++++--------- resources/i18n/pl.json | 45 ++++++++++++++++++++++++++++--------- resources/i18n/pt-br.json | 23 +++++++++++-------- resources/i18n/th.json | 27 +++++++++++++++------- resources/i18n/uk.json | 31 +++++++++++++++++-------- resources/i18n/zh-Hant.json | 27 +++++++++++++++------- 9 files changed, 190 insertions(+), 81 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 1a516d393..8f3df0b4d 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -93,7 +93,8 @@ "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", "download": "Herunterladen", "info": "Mehr Informationen", - "share": "Freigabe erstellen" + "share": "Freigabe erstellen", + "refresh": "" }, "lists": { "all": "Alle", @@ -155,11 +156,13 @@ "newPassword": "Neues Passwort", "token": "Token", "lastAccessAt": "Letzter Zugriff am", - "libraries": "Bibliotheken" + "libraries": "Bibliotheken", + "scrobbleFilter": "" }, "helperTexts": { "name": "Die Änderung wird erst nach dem nächsten Login gültig", - "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken" + "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken", + "scrobbleFilter": "" }, "notifications": { "created": "Benutzer erstellt", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken" }, "validation": { - "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein" + "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein", + "invalidScrobbleFilter": "" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Zielformat", "defaultBitRate": "Standardbitrate", "command": "Befehl" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Titelanzahl", "comment": "Kommentar", "sync": "Auto-Import", - "path": "Importieren aus" + "path": "Importieren aus", + "starred": "Favorit" }, "actions": { "selectPlaylist": "Wiedergabeliste auswählen:", @@ -403,7 +411,8 @@ "requiredHosts": "Benötigte Hosts", "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", - "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." + "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.", + "idHelp": "" }, "placeholders": { "configKey": "Schlüssel", @@ -599,7 +608,8 @@ "coverUploaded": "Cover aktualisiert", "coverRemoved": "Cover entfernt", "coverUploadError": "Fehler beim Hochladen des Covers", - "coverRemoveError": "Fehler beim Entfernen des Covers" + "coverRemoveError": "Fehler beim Entfernen des Covers", + "metadataRefreshStarted": "" }, "menu": { "library": "Bibliothek", @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} von %{total} Bibliotheken", "selectLibraries": "Bibliotheken auswählen", "none": "Keine" - } + }, + "onlyFavourites": "Nur Favoriten anzeigen" }, "player": { "playListsText": "Warteschlange abspielen", diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 3876c2e33..37882ea99 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -38,7 +38,9 @@ "missing": "Απών", "libraryName": "Βιβλιοθήκη", "composer": "Συνθέτης", - "disc": "Δίσκος %{discNumber}" + "disc": "Δίσκος %{discNumber}", + "albumGain": "Κέρδος άλμπουμ", + "trackGain": "Κέρδος παρακολούθησης" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -91,7 +93,8 @@ "addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης", "download": "Ληψη", "info": "Εμφάνιση Πληροφοριών", - "share": "Μερίδιο" + "share": "Μερίδιο", + "refresh": "Ανανέωση μεταδεδομένων" }, "lists": { "all": "Όλα", @@ -153,11 +156,13 @@ "newPassword": "Νέος Κωδικός Πρόσβασης", "token": "Token", "lastAccessAt": "Τελευταία Πρόσβαση", - "libraries": "Βιβλιοθήκες" + "libraries": "Βιβλιοθήκες", + "scrobbleFilter": "Φίλτρο Scrobble" }, "helperTexts": { "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση", - "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη" + "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη", + "scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}." }, "notifications": { "created": "Ο χρήστης δημιουργήθηκε", @@ -171,7 +176,8 @@ "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες" }, "validation": { - "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές" + "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές", + "invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης." } }, "player": { @@ -194,6 +200,9 @@ "targetFormat": "Μορφη Προορισμου", "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", "command": "Εντολή" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -208,7 +217,8 @@ "songCount": "Τραγούδια", "comment": "Σχόλιο", "sync": "Αυτόματη εισαγωγή", - "path": "Εισαγωγή από" + "path": "Εισαγωγή από", + "starred": "Ευνοούμενος" }, "actions": { "selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:", @@ -401,7 +411,8 @@ "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.", + "idHelp": "" }, "placeholders": { "configKey": "κλειδί", @@ -597,7 +608,8 @@ "coverUploaded": "Το εξώφυλλο ενημερώθηκε", "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", - "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου", + "metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο" }, "menu": { "library": "Βιβλιοθήκη", @@ -632,7 +644,8 @@ "multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες", "selectLibraries": "Επιλέξτε βιβλιοθήκες", "none": "Κανένα" - } + }, + "onlyFavourites": "Εμφάνιση μόνο αγαπημένων" }, "player": { "playListsText": "Ουρά Αναπαραγωγής", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 0e6149f87..3d1edbbd5 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -93,7 +93,8 @@ "addToPlaylist": "Lisää soittolistaan", "download": "Lataa", "info": "Info", - "share": "Jaa" + "share": "Jaa", + "refresh": "Päivitä metatiedot" }, "lists": { "all": "Kaikki", @@ -155,11 +156,13 @@ "newPassword": "Uusi salasana", "token": "Avain", "lastAccessAt": "Viimeisin käyttö", - "libraries": "Kirjastot" + "libraries": "Kirjastot", + "scrobbleFilter": "Scrobble-suodatin" }, "helperTexts": { "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään", - "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja" + "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja", + "scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta." }, "notifications": { "created": "Käyttäjä luotu", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin" }, "validation": { - "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille" + "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille", + "invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä." } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Kohde formaatti", "defaultBitRate": "Oletus bittinopeus", "command": "Komento" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Kappaleita", "comment": "Kommentti", "sync": "Automaattinen tuonti", - "path": "Tuo" + "path": "Tuo", + "starred": "Suosikki" }, "actions": { "selectPlaylist": "Valitse soittolista:", @@ -403,7 +411,8 @@ "requiredHosts": "Vaaditut palvelimet", "configValidationError": "Määrityksen validointi epäonnistui:", "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", - "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus." + "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.", + "idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa." }, "placeholders": { "configKey": "avain", @@ -599,7 +608,8 @@ "coverUploaded": "Kansikuva päivitetty", "coverRemoved": "Kansikuva poistettu", "coverUploadError": "Virhe ladattaessa kansikuvaa", - "coverRemoveError": "Virhe poistettaessa kansikuvaa" + "coverRemoveError": "Virhe poistettaessa kansikuvaa", + "metadataRefreshStarted": "Metatietoja päivitetään taustalla" }, "menu": { "library": "Kirjasto", @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} / %{total} kirjastoa", "selectLibraries": "Valitse kirjastot", "none": "Ei mitään" - } + }, + "onlyFavourites": "Näytä vain suosikit" }, "player": { "playListsText": "Jono", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 444998d03..47f2f4272 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -93,7 +93,8 @@ "addToPlaylist": "Engadir a Lista", "download": "Descargar", "info": "Obter info", - "share": "Compartir" + "share": "Compartir", + "refresh": "Actualizar metadatos" }, "lists": { "all": "Todo", @@ -155,11 +156,13 @@ "newPassword": "Novo contrasinal", "token": "Token", "lastAccessAt": "Último acceso", - "libraries": "Bibliotecas" + "libraries": "Bibliotecas", + "scrobbleFilter": "Filtro para scrobble" }, "helperTexts": { "name": "Os cambios no nome aplicaranse a próxima vez que accedas", - "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto" + "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto", + "scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais." }, "notifications": { "created": "Creouse a usuaria", @@ -173,7 +176,8 @@ "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas" }, "validation": { - "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins" + "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins", + "invalidScrobbleFilter": "" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Formato de destino", "defaultBitRate": "Taxa de bit por defecto", "command": "Orde" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Cancións", "comment": "Comentario", "sync": "Autoimportación", - "path": "Importar desde" + "path": "Importar desde", + "starred": "Favorita" }, "actions": { "selectPlaylist": "Elixe unha lista:", @@ -403,7 +411,8 @@ "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", - "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." + "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.", + "idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents." }, "placeholders": { "configKey": "clave", @@ -435,7 +444,7 @@ "minValue": "Ten que ter polo menos %{min}", "maxValue": "Ten que ter %{max} ou menos", "number": "Ten que ser un número", - "email": "Ten que ser un email válido", + "email": "Ten que ser un correo válido", "oneOf": "Ten que ser un de: %{options}", "regex": "Ten que ter un formato específico (regexp): %{pattern}", "unique": "Ten que ser único", @@ -509,7 +518,7 @@ } }, "message": { - "about": "Acerca de", + "about": "Sobre", "are_you_sure": "Tes certeza?", "bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?", "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", @@ -599,7 +608,8 @@ "coverUploaded": "Subiuse a capa", "coverRemoved": "Retirouse a capa", "coverUploadError": "Erro ao subir a capa", - "coverRemoveError": "Erro ao retirar a capa" + "coverRemoveError": "Erro ao retirar a capa", + "metadataRefreshStarted": "Actualizar en segundo plano os metadatos" }, "menu": { "library": "Biblioteca", @@ -626,7 +636,7 @@ } }, "albumList": "Álbums", - "about": "Acerca de", + "about": "Sobre", "playlists": "Listas de reprodución", "sharedPlaylists": "Listas compartidas", "librarySelector": { @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} de %{total} Bibliotecas", "selectLibraries": "Seleccionar Bibliotecas", "none": "Ningunha" - } + }, + "onlyFavourites": "Mostrar só as favoritas" }, "player": { "playListsText": "Reproducir cola", diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index 6229798e9..ff697d64c 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -37,7 +37,10 @@ "sampleRate": "Częstotliwość próbkowania", "missing": "Brak", "libraryName": "Biblioteka", - "composer": "Kompozytor" + "composer": "Kompozytor", + "disc": "Dysk %{discNumber}", + "albumGain": "Wzmocnienie albumu", + "trackGain": "Wzmocnienie utworu" }, "actions": { "addToQueue": "Odtwarzaj Później", @@ -90,7 +93,8 @@ "addToPlaylist": "Dodaj do Playlisty", "download": "Pobierz", "info": "Zdobądź Informacje", - "share": "Udostępnij" + "share": "Udostępnij", + "refresh": "Odśwież Metadane" }, "lists": { "all": "Wszystkie", @@ -152,11 +156,13 @@ "newPassword": "Nowe hasło", "token": "Token", "lastAccessAt": "Ostatnia Aktywność", - "libraries": "Biblioteki" + "libraries": "Biblioteki", + "scrobbleFilter": "Filtr scrobblowania" }, "helperTexts": { "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu", - "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki" + "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki", + "scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń." }, "notifications": { "created": "Dodano użytkownika", @@ -170,7 +176,8 @@ "adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek" }, "validation": { - "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika" + "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika", + "invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane." } }, "player": { @@ -193,6 +200,9 @@ "targetFormat": "Format Docelowy", "defaultBitRate": "Domyślny Bit Rate", "command": "Komenda" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -207,7 +217,8 @@ "songCount": "Liczba utworów", "comment": "Komentarz", "sync": "Import automatyczny", - "path": "Zaimportuj z" + "path": "Zaimportuj z", + "starred": "Ulubione" }, "actions": { "selectPlaylist": "Wybierz playlistę:", @@ -353,7 +364,8 @@ "allUsers": "Zezwalaj wszystkim użytkownikom", "selectedUsers": "Wybrani użytkownicy", "allLibraries": "Zezwalaj dla wszystkich bibliotek", - "selectedLibraries": "Wybrane biblioteki" + "selectedLibraries": "Wybrane biblioteki", + "allowWriteAccess": "Zezwól na zapis" }, "sections": { "status": "Status", @@ -398,7 +410,9 @@ "librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.", "requiredHosts": "Wymagane hosty", "configValidationError": "Weryfikacja konfiguracji nie powiodła się:", - "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy." + "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.", + "allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.", + "idHelp": "" }, "placeholders": { "configKey": "klucz", @@ -588,7 +602,14 @@ "remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.", "noSimilarSongsFound": "Brak podobnych utworów", "noTopSongsFound": "Brak najlepszych utworów", - "startingInstantMix": "Ładowanie Natychmiastowego Miksu..." + "startingInstantMix": "Ładowanie Natychmiastowego Miksu...", + "uploadCover": "Prześlij Okładkę", + "removeCover": "Usuń Okładkę", + "coverUploaded": "Okładka zaktualizowana", + "coverRemoved": "Okładka usunięta", + "coverUploadError": "Błąd przesyłania okładki", + "coverRemoveError": "Błąd usuwania okładki", + "metadataRefreshStarted": "Odświeżanie metadanych w tle" }, "menu": { "library": "Biblioteka", @@ -623,7 +644,8 @@ "multipleLibraries": "%{selected} z %{total} Bibliotek", "selectLibraries": "Wybierz Biblioteki", "none": "Żadna" - } + }, + "onlyFavourites": "Pokaż tylko ulubione" }, "player": { "playListsText": "Kolejka Odtwarzania", @@ -674,7 +696,8 @@ "exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML", "exportFailed": "Błąd kopiowania konfiguracji", "devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)", - "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach" + "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach", + "downloadToml": "Konfiguracja Pobierania (TOML)" } }, "activity": { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index ccc5f872b..2584da6cd 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -35,12 +35,12 @@ "rawTags": "Tags originais", "bitDepth": "Profundidade de bits", "sampleRate": "Taxa de amostragem", - "albumGain": "Ganho do álbum", - "trackGain": "Ganho da faixa", "missing": "Ausente", "libraryName": "Biblioteca", "composer": "Compositor", - "disc": "Disco %{discNumber}" + "disc": "Disco %{discNumber}", + "albumGain": "Ganho do álbum", + "trackGain": "Ganho da faixa" }, "actions": { "addToQueue": "Adicionar à fila", @@ -93,8 +93,8 @@ "addToPlaylist": "Adicionar à playlist", "download": "Baixar", "info": "Detalhes", - "refresh": "Atualizar Metadados", - "share": "Compartilhar" + "share": "Compartilhar", + "refresh": "Atualizar Metadados" }, "lists": { "all": "Todos", @@ -200,6 +200,9 @@ "targetFormat": "Formato", "defaultBitRate": "Bitrate padrão", "command": "Comando" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -214,7 +217,8 @@ "songCount": "Músicas", "comment": "Comentário", "sync": "Auto-importar", - "path": "Importar de" + "path": "Importar de", + "starred": "Favorita" }, "actions": { "selectPlaylist": "Selecione a playlist:", @@ -394,7 +398,6 @@ "invalidJson": "A configuração deve ser um JSON válido" }, "messages": { - "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.", "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.", "clickPermissions": "Clique em uma permissão para ver detalhes", "noConfig": "Nenhuma configuração definida", @@ -408,7 +411,8 @@ "requiredHosts": "Hosts necessários", "configValidationError": "Falha na validação da configuração:", "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura." + "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", + "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents." }, "placeholders": { "configKey": "chave", @@ -640,7 +644,8 @@ "multipleLibraries": "%{selected} de %{total} Bibliotecas", "selectLibraries": "Selecionar Bibliotecas", "none": "Nenhuma" - } + }, + "onlyFavourites": "Somente favoritas" }, "player": { "playListsText": "Fila de Execução", diff --git a/resources/i18n/th.json b/resources/i18n/th.json index fde89494e..3c5c9e07b 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -93,7 +93,8 @@ "addToPlaylist": "เพิ่มลงในเพลย์ลิสต์", "download": "ดาวน์โหลด", "info": "ดูรายละเอียด", - "share": "แบ่งปัน" + "share": "แบ่งปัน", + "refresh": "" }, "lists": { "all": "ทั้งหมด", @@ -155,11 +156,13 @@ "newPassword": "รหัสผ่านใหม่", "token": "โทเคน", "lastAccessAt": "เข้าใช้ล่าสุด", - "libraries": "ห้องสมุด" + "libraries": "ห้องสมุด", + "scrobbleFilter": "" }, "helperTexts": { "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป", - "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น" + "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น", + "scrobbleFilter": "" }, "notifications": { "created": "สร้างชื่อผู้ใช้", @@ -173,7 +176,8 @@ "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ" }, "validation": { - "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล" + "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล", + "invalidScrobbleFilter": "" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "ชนิดไฟล์เสียง", "defaultBitRate": "บิตเรท", "command": "คำสั่ง" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "เพลง", "comment": "ความคิดเห็น", "sync": "นำเข้าอัตโนมัติ", - "path": "นำเข้าจาก" + "path": "นำเข้าจาก", + "starred": "ชื่นชอบ" }, "actions": { "selectPlaylist": "เลือกเพลย์ลิสต์", @@ -403,7 +411,8 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น" + "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น", + "idHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -599,7 +608,8 @@ "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", - "coverRemoveError": "ลบภาพหน้าปกผิดพลาด" + "coverRemoveError": "ลบภาพหน้าปกผิดพลาด", + "metadataRefreshStarted": "" }, "menu": { "library": "ห้องสมุดเพลง", @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด", "selectLibraries": "เลือกห้องสมุด", "none": "ไม่มี" - } + }, + "onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ" }, "player": { "playListsText": "คิวเล่น", diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index c5644fde7..3049f9840 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -38,7 +38,9 @@ "missing": "Поле відсутнє", "libraryName": "Бібліотека", "composer": "Композитор", - "disc": "Диск %{discNumber}" + "disc": "Диск %{discNumber}", + "albumGain": "", + "trackGain": "" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -91,7 +93,8 @@ "addToPlaylist": "Додати у список відтворення", "download": "Завантажити", "info": "Отримати інформацію", - "share": "Поширити" + "share": "Поширити", + "refresh": "" }, "lists": { "all": "Усі", @@ -153,11 +156,13 @@ "newPassword": "Новий пароль", "token": "Токен", "lastAccessAt": "Останній доступ", - "libraries": "Бібліотеки" + "libraries": "Бібліотеки", + "scrobbleFilter": "" }, "helperTexts": { "name": "Змінене ім'я буде відображатися при наступній авторизації", - "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням" + "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням", + "scrobbleFilter": "" }, "notifications": { "created": "Користувача створено", @@ -171,7 +176,8 @@ "adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек" }, "validation": { - "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека" + "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека", + "invalidScrobbleFilter": "" } }, "player": { @@ -194,6 +200,9 @@ "targetFormat": "Цільовий формат", "defaultBitRate": "Швидкість передачі бітів за замовчуванням", "command": "Команда" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -208,7 +217,8 @@ "songCount": "Пісні", "comment": "Коментар", "sync": "Автоімпорт", - "path": "Імпортувати із" + "path": "Імпортувати із", + "starred": "Улюблене" }, "actions": { "selectPlaylist": "Вибрати список відтворення:", @@ -401,7 +411,8 @@ "requiredHosts": "Обов'язкові хости", "configValidationError": "Перевірка конфігурації зазнала невдачі:", "schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.", - "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання." + "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.", + "idHelp": "" }, "placeholders": { "configKey": "ключ", @@ -597,7 +608,8 @@ "coverUploaded": "Обкладинку оновлено", "coverRemoved": "Обкладинка видалена", "coverUploadError": "Помилка завантаження обкладинки", - "coverRemoveError": "Помилка видалення обкладинки" + "coverRemoveError": "Помилка видалення обкладинки", + "metadataRefreshStarted": "" }, "menu": { "library": "Бібліотека", @@ -632,7 +644,8 @@ "multipleLibraries": "%{selected} з %{total} Бібліотеки", "selectLibraries": "Вибір бібліотек", "none": "Відсутня" - } + }, + "onlyFavourites": "Показати улюблене" }, "player": { "playListsText": "Грати по черзі", diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index d00ae2ac3..5e48352ea 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -93,7 +93,8 @@ "addToPlaylist": "加入至播放清單", "download": "下載", "info": "取得資訊", - "share": "分享" + "share": "分享", + "refresh": "" }, "lists": { "all": "所有", @@ -155,11 +156,13 @@ "newPassword": "新密碼", "token": "權杖", "lastAccessAt": "上次存取", - "libraries": "媒體庫" + "libraries": "媒體庫", + "scrobbleFilter": "" }, "helperTexts": { "name": "您的名稱會在下次登入時生效", - "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫" + "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫", + "scrobbleFilter": "" }, "notifications": { "created": "使用者已建立", @@ -173,7 +176,8 @@ "adminAutoLibraries": "管理員預設可存取所有媒體庫" }, "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫", + "invalidScrobbleFilter": "" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "目標格式", "defaultBitRate": "預設位元率", "command": "指令" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "歌曲數", "comment": "註解", "sync": "自動匯入", - "path": "匯入來源" + "path": "匯入來源", + "starred": "收藏" }, "actions": { "selectPlaylist": "選取播放清單:", @@ -403,7 +411,8 @@ "requiredHosts": "必要的 Hosts", "configValidationError": "設定驗證失敗:", "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。" + "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。", + "idHelp": "" }, "placeholders": { "configKey": "鍵", @@ -599,7 +608,8 @@ "coverUploaded": "已更新封面圖", "coverRemoved": "已移除封面圖", "coverUploadError": "上傳封面圖時發生錯誤", - "coverRemoveError": "移除封面圖時發生錯誤" + "coverRemoveError": "移除封面圖時發生錯誤", + "metadataRefreshStarted": "" }, "menu": { "library": "媒體庫", @@ -634,7 +644,8 @@ "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", "selectLibraries": "選取媒體庫", "none": "無" - } + }, + "onlyFavourites": "僅顯示收藏" }, "player": { "playListsText": "播放佇列", From 2802c05a66e0d48ff9a58b087d534064b42a8c92 Mon Sep 17 00:00:00 2001 From: Rob Emery <mintsoft@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:40:59 +0100 Subject: [PATCH 529/596] feat(msi): keep install folder and settings across upgrades (#5333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implementing the RememberProperty pattern for the settings set through the UI on install. Previously, these got reset on upgrade. This is as simple as squirrelling them away in the registry for all settings except for the INSTALLDIR, as the INSTALLDIR depends on the environment that the msi is being installed into (i.e. the actual location of ProgramFiles can be anywhere technically), that needs to be dynamically set after the CostFinalize phase and in the version of WiX schema supported by wixl needs to be implemented through a customAction. This will not fix the upgrade issue for existing installs, as the information entered doesn't exist in the registry or anything so the best option imo is to backup the navidrome database and config uninstall the old version and install the new version with the desired paths. It should then upgrade from there on correctly. * Make it possible to build 386 and amd64 on the same machine * When upgrading from the pre-fix installer, it would dump everything into the C:\ root as the UI never executes to set the value for the MSI_INSTALLATIONDIRECTORY, and the custom action doesn't run on upgrade as we should be reading from the registry in that situation This will force the customaction to run when the path is the confusingly named TARGETDIR (which is C:\ in 99% of cases). All other properties when upgrading from the pre-fix to the fix will be reset to the default values as well; which was the same as the previous behaviour anyway. * build(msi): drop local ffmpeg download cache The cache key did not include the ffmpeg version, and a partial download would stick forever. CI runners start clean, so the cache only helped local builds. * fix(msi): set install directory during silent installs and upgrades SetInstallDirProperty only ran in InstallUISequence, which Windows Installer skips for /passive and /qn (the modes winget uses). With MSI_INSTALLATIONDIRECTORY unset, the files and the service went to the root of the drive with the most free space. Upgrades from releases that did not store MSI_INSTALLATIONDIRECTORY in the registry hit the same path, even with the full UI. The action now runs before CostFinalize in both sequences, whenever the registry search did not find a saved directory, and defaults to [ProgramFiles64Folder]Navidrome\ (ProgramFilesFolder on x86) so it does not depend on INSTALLDIR being resolved. The unused INSTALLDIR directory is removed. Verified on a GitHub Actions Windows runner: fresh installs with /passive, /qn and /qr, upgrades from 0.63.1 with /passive and /qr, and an upgrade between two fixed builds that keeps a custom directory and port. --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- release/wix/SettingsDlg.wxs | 2 +- release/wix/navidrome.wxs | 96 +++++++++++++++++++++---------------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs index 4a83f91da..cdc625434 100644 --- a/release/wix/SettingsDlg.wxs +++ b/release/wix/SettingsDlg.wxs @@ -16,7 +16,7 @@ <!-- Install directory --> <Control Id="NameLabel1" Type="Text" X="30" Y="60" Width="220" Height="15" TabSkip="no" Text="&Installation Folder (executable and configuration file):" /> - <Control Id="NameEdit1" Type="Edit" X="30" Y="75" Width="220" Height="18" Property="INSTALLDIR" Text="[INSTALLDIR]" /> + <Control Id="NameEdit1" Type="Edit" X="30" Y="75" Width="220" Height="18" Property="MSI_INSTALLATIONDIRECTORY" Text="[MSI_INSTALLATIONDIRECTORY]" /> <!-- NAVIDROME_PORT --> <Control Id="NameLabel2" Type="Text" X="30" Y="93" Width="100" Height="15" TabSkip="no" Text="&Port:" /> diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 6d94bab9d..71a6de6b5 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -14,6 +14,7 @@ <?include SettingsDlg.wxs?> <?include Navidrome_UI_Flow.wxs?> <Product Name="$(var.ProductName)" Id="*" UpgradeCode="$(var.UpgradeCode)" Language='1033' Codepage='1252' Version='$(var.Version)' Manufacturer='Deluan'> + <UIRef Id="Navidrome_UI_Flow"/> <Package Id='*' Keywords='Installer' Description="$(var.ProductName)" Comments='' Manufacturer='Deluan' InstallerVersion='200' Languages='1033' Compressed='yes' SummaryCodepage='1252' InstallScope='perMachine' /> @@ -23,55 +24,65 @@ <Property Id='DiskPrompt' Value="Navidrome Install [1]" /> <Property Id="REBOOT" Value="ReallySuppress" /> - <Property Id="ND_PORT" Value="4533" /> - <Property Id="ND_MUSICFOLDER" Value="C:\Music" /> - <Property Id="ND_DATAFOLDER" Value="C:\ProgramData\Navidrome" /> + <Property Id="ND_PORT" Value="4533"> + <RegistrySearch Id="ND_PORT" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_PORT' Type='raw' /> + </Property> + <Property Id="ND_MUSICFOLDER" Value="C:\Music"> + <RegistrySearch Id="ND_MUSICFOLDER" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_MUSICFOLDER' Type='raw' /> + </Property> + <Property Id="ND_DATAFOLDER" Value="C:\ProgramData\Navidrome"> + <RegistrySearch Id="ND_DATAFOLDER" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_DATAFOLDER' Type='raw' /> + </Property> + <Property Id="MSI_INSTALLATIONDIRECTORY"> + <RegistrySearch Id="MSI_INSTALLATIONDIRECTORY" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='MSI_INSTALLATIONDIRECTORY' Type='raw' /> + </Property> - <UIRef Id="Navidrome_UI_Flow"/> + <CustomAction Id="SetInstallDirProperty" Property="MSI_INSTALLATIONDIRECTORY" Value="[$(var.PlatformProgramFilesFolder)]Navidrome\" /> <Directory Id='TARGETDIR' Name='SourceDir'> - <Directory Id="$(var.PlatformProgramFilesFolder)"> - <Directory Id='INSTALLDIR' Name='Navidrome'> + <Directory Id="MSI_INSTALLATIONDIRECTORY"> + <Component Id='LICENSEFile' Guid='eb5610a4-e3f3-4f36-ae2c-e96914e460c2' Win64="$(var.Win64)"> + <File Id='LICENSE' Name='LICENSE' DiskId='1' Source='LICENSE' KeyPath='yes' /> + </Component> - <Component Id='LICENSEFile' Guid='eb5610a4-e3f3-4f36-ae2c-e96914e460c2' Win64="$(var.Win64)"> - <File Id='LICENSE' Name='LICENSE' DiskId='1' Source='LICENSE' KeyPath='yes' /> - </Component> + <Component Id='README.mdFile' Guid='d1ee412b-2ebc-4b0b-9fa7-0228ab707686' Win64="$(var.Win64)"> + <File Id='README.md' Name='README.md' DiskId='1' Source='README.md' KeyPath='yes' /> + </Component> - <Component Id='README.mdFile' Guid='d1ee412b-2ebc-4b0b-9fa7-0228ab707686' Win64="$(var.Win64)"> - <File Id='README.md' Name='README.md' DiskId='1' Source='README.md' KeyPath='yes' /> - </Component> + <Component Id="Configuration" Guid="9e17ed4b-ef13-44bf-a605-ed4132cff7f6" Win64="$(var.Win64)"> + <IniFile Id="ConfigurationPort" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="Port" Section="default" Value="'[ND_PORT]'" /> + <IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" /> + <IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" /> + <IniFile Id="FFmpegPath" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="FFmpegPath" Section="default" Value="'[MSI_INSTALLATIONDIRECTORY]ffmpeg.exe'" /> - <Component Id="Configuration" Guid="9e17ed4b-ef13-44bf-a605-ed4132cff7f6" Win64="$(var.Win64)"> - <IniFile Id="ConfigurationPort" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="Port" Section="default" Value="'[ND_PORT]'" /> - <IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" /> - <IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" /> - <IniFile Id="FFmpegPath" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="'[INSTALLDIR]ffmpeg.exe'" /> - </Component> + <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='MSI_INSTALLATIONDIRECTORY' Value='[MSI_INSTALLATIONDIRECTORY]' Type='string' /> + <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_PORT' Value='[ND_PORT]' Type='string' /> + <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_MUSICFOLDER' Value='[ND_MUSICFOLDER]' Type='string' /> + <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_DATAFOLDER' Value='[ND_DATAFOLDER]' Type='string' /> + </Component> - <Component Id='MainExecutable' Guid='e645aa06-8bbc-40d6-8d3c-73b4f5b76fd7' Win64="$(var.Win64)"> - <File Id='NavidromeExe' Name='Navidrome.exe' DiskId='1' Source='navidrome.exe' KeyPath='yes' /> - <ServiceInstall - Description='Navidrome is a self-hosted music server and streamer' - ErrorControl='ignore' - Name = '$(var.ProductName)' - Id='NavidromeService' - Start='auto' - Type='ownProcess' - Vital='yes' - Arguments='service execute --configfile "[INSTALLDIR]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' - /> - <ServiceControl Id='StartNavidromeService' Start='install' Stop='both' Remove='uninstall' Name='$(var.ProductName)' Wait='yes' /> - </Component> + <Component Id='MainExecutable' Guid='e645aa06-8bbc-40d6-8d3c-73b4f5b76fd7' Win64="$(var.Win64)"> + <File Id='NavidromeExe' Name='Navidrome.exe' DiskId='1' Source='navidrome.exe' KeyPath='yes' /> + <ServiceInstall + Description='Navidrome is a self-hosted music server and streamer' + ErrorControl='ignore' + Name = '$(var.ProductName)' + Id='NavidromeService' + Start='auto' + Type='ownProcess' + Vital='yes' + Arguments='service execute --configfile "[MSI_INSTALLATIONDIRECTORY]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' + /> + <ServiceControl Id='StartNavidromeService' Start='install' Stop='both' Remove='uninstall' Name='$(var.ProductName)' Wait='yes' /> + </Component> - <Component Id='FFMpegExecutable' Guid='d17358f7-abdc-4080-acd3-6427903a7dd8' Win64="$(var.Win64)"> - <File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' /> - </Component> + <Component Id='FFMpegExecutable' Guid='d17358f7-abdc-4080-acd3-6427903a7dd8' Win64="$(var.Win64)"> + <File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' /> + </Component> - <Component Id='FFProbeExecutable' Guid='f8a3b2c1-5d4e-4f6a-9b8c-7e2d1a0f3c5b' Win64="$(var.Win64)"> - <File Id='ffprobe.exe' Name='ffprobe.exe' DiskId='1' Source='ffprobe.exe' KeyPath='yes' /> - </Component> - - </Directory> + <Component Id='FFProbeExecutable' Guid='f8a3b2c1-5d4e-4f6a-9b8c-7e2d1a0f3c5b' Win64="$(var.Win64)"> + <File Id='ffprobe.exe' Name='ffprobe.exe' DiskId='1' Source='ffprobe.exe' KeyPath='yes' /> + </Component> </Directory> <Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]"> @@ -81,7 +92,12 @@ </Directory> </Directory> + <InstallExecuteSequence> + <Custom Action="SetInstallDirProperty" Before="CostFinalize">NOT MSI_INSTALLATIONDIRECTORY</Custom> + </InstallExecuteSequence> + <InstallUISequence> + <Custom Action="SetInstallDirProperty" Before="CostFinalize">NOT MSI_INSTALLATIONDIRECTORY</Custom> <Show Dialog="MyCustomPropertiesDlg" After="WelcomeDlg">Not Installed AND NOT WIX_UPGRADE_DETECTED</Show> </InstallUISequence> From c6732e1fdf564bb2ed2df150b1d9809079407df1 Mon Sep 17 00:00:00 2001 From: ts <933064+zerovox@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:08:25 -0600 Subject: [PATCH 530/596] feat(cli): add missing file list and remap subcommands (#5928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add missing file list and remap subcommands Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com> * fix: prevent remapping from dropping participants on target track * fix: after remapping, refresh stats synchronously * fix: only move album annotations if moving a track would empty the old album * fix(persistence): keep the new item's annotation when reassigning onto an item the user already annotated ReassignAnnotation was a plain UPDATE; the annotation table is unique on (user_id, item_id, item_type), so when a user had annotated both items the statement aborted and none of the rows moved. In the scanner that surfaced as a warning; in the missing-file remap it rolled back the whole operation. UPDATE OR IGNORE moves what it can and leaves the conflicting rows for GC. * fix(core): keep the target track's history when remapping a missing file onto it The remap discards the target's row, and GC then dropped its play counts, stars, ratings, bookmarks and every playlist entry pointing at it. That is harmless in the scanner, whose target was imported seconds earlier, but the CLI lets the user pick any existing track. Move those references onto the surviving id first; where a user already has a row for both, theirs on the missing file wins. * fix(persistence): stop FindByPaths dropping plain paths that contain a colon Any colon was taken as the libraryID separator, and a non-numeric prefix made the whole path vanish from the lookup. 'missing fix' then rejected the very paths 'missing list' printed, and M3U imports silently skipped such tracks. Only a numeric prefix qualifies a path now. * perf(cli): stream 'missing list' instead of loading every missing file into memory GetAll materialised the whole result set before a single row was written; on a library with 97k missing files that peaked at 1.28 GB of RSS. Iterate the repository cursor and write rows as they arrive. * refactor(core): tidy the missing-file remap Drop the log lines copied from deleteMissing that still said 'after deleting missing files', the debug-on-success branches, and the what-comments; build the affected album list without slice helpers. * fix(cli): move path to the last column of 'missing list' Path is the only variable-width field, so leading with it misaligns every row that follows. Applies to both csv and json. * fix(persistence): also try a numeric colon prefix as a plain path '1999: A Different Life/01.mp3' parsed as library 1999 plus a truncated path and matched nothing. The prefix is ambiguous, so search both ways. Also buffer the json branch of 'missing list', which wrote a syscall per row. * fix(persistence): move scrobbles and buffered scrobbles off a discarded media file Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix' deleting the target erased its play history and dropped scrobbles still waiting on an external service. scrobble_buffer needs OR IGNORE for its unique (user_id, service, media_file_id, play_time). * fix(persistence): recompute the cached average rating after merging annotations Merging the discarded row's annotations grows the rating population of the surviving track, so media_file.average_rating no longer matched what the annotation rows say. Only reachable since the remap started merging those rows instead of deleting them. * fix(persistence): recompute the cached average rating inside ReassignAnnotation Moving annotation rows always changes the new item's rating population, so the recompute belongs with the move rather than at each call site. Covers the album reassign in the remap and the two scanner sites, and replaces the explicit call ReassignReferences was making. Album was the worse case: rate an album, move its files, and 'missing fix' handed the rating to an album still caching an average of 0. * fix(cli): let libraryID:path win over a file literally named like one FindByPaths searches a numeric-prefixed reference both ways, so a top-level file named '1:foo.mp3' can tie with library 1's 'foo.mp3'. The CLI then rejected the reference as ambiguous while advising the exact syntax the caller had used. Also disambiguates the same path in two libraries, which is what the qualified form is for. --------- Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- cmd/missing.go | 171 +++++++++++++++++++++++ cmd/missing_test.go | 81 +++++++++++ core/maintenance.go | 97 ++++++++++++- core/maintenance_test.go | 162 +++++++++++++++++++++ model/mediafile.go | 3 + persistence/mediafile_repository.go | 40 ++++-- persistence/mediafile_repository_test.go | 123 ++++++++++++++++ persistence/sql_annotations.go | 14 +- persistence/sql_annotations_test.go | 47 +++++++ persistence/sql_bookmarks.go | 7 + scanner/phase_2_missing_tracks_test.go | 6 +- tests/mock_mediafile_repo.go | 34 +++-- 12 files changed, 756 insertions(+), 29 deletions(-) create mode 100644 cmd/missing.go create mode 100644 cmd/missing_test.go diff --git a/cmd/missing.go b/cmd/missing.go new file mode 100644 index 000000000..65f571661 --- /dev/null +++ b/cmd/missing.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "github.com/spf13/cobra" +) + +var missingListFormat string + +func init() { + missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]") + missingCmd.AddCommand(missingListCmd) + missingCmd.AddCommand(missingFixCmd) + rootCmd.AddCommand(missingCmd) +} + +var ( + missingCmd = &cobra.Command{ + Use: "missing", + Short: "Manage missing files", + Long: "List files marked as missing and remap them onto existing files", + } + + missingListCmd = &cobra.Command{ + Use: "list", + Short: "List missing files", + Run: func(cmd *cobra.Command, _ []string) { + runMissingList(cmd.Context()) + }, + } + + missingFixCmd = &cobra.Command{ + Use: "fix <missing path|id> <target path|id>", + Short: "Remap a missing file onto an existing file", + Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" + + "the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" + + "a library-relative path, or a libraryID:path pair.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + runMissingFix(cmd.Context(), args[0], args[1]) + }, + } +) + +type displayMissingFile struct { + ID string `json:"id"` + LibraryID int `json:"libraryId"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + Path string `json:"path"` +} + +func runMissingList(ctx context.Context) { + if missingListFormat != "csv" && missingListFormat != "json" { + log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat) + } + + ds, ctx := getAdminContext(ctx) + mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + Sort: "path", + }) + if err == nil { + err = writeMissingList(os.Stdout, missingListFormat, mfs) + } + if err != nil { + log.Fatal(ctx, "Failed to retrieve missing files", err) + } +} + +// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory +func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error { + if format == "json" { + bw := bufio.NewWriter(w) + _, _ = io.WriteString(bw, "[") + sep := "" + for mf, err := range mfs { + if err != nil { + return err + } + j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path}) + _, _ = fmt.Fprintf(bw, "%s%s", sep, j) + sep = "," + } + _, _ = io.WriteString(bw, "]\n") + return bw.Flush() + } + + cw := csv.NewWriter(w) + _ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"}) + for mf, err := range mfs { + if err != nil { + return err + } + _ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path}) + } + cw.Flush() + return cw.Error() +} + +func runMissingFix(ctx context.Context, missingRef, targetRef string) { + ds, ctx := getAdminContext(ctx) + + missing := resolveMediaFile(ctx, ds, missingRef) + target := resolveMediaFile(ctx, ds, targetRef) + + if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil { + log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err) + } + fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path) +} + +// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path). +func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile { + mf, err := ds.MediaFile(ctx).Get(ref) + if err == nil { + return mf + } + if !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Error looking up media file", "ref", ref, err) + } + + mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref}) + if err != nil { + log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err) + } + if len(mfs) == 0 { + log.Fatal(ctx, "No media file found", "ref", ref) + } + mfs = preferQualified(ref, mfs) + if len(mfs) > 1 { + log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs)) + } + return &mfs[0] +} + +// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path" +// reference both ways: an explicit library wins over a file literally named like one. +func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles { + id, path, ok := strings.Cut(ref, ":") + if !ok { + return mfs + } + libraryID, err := strconv.Atoi(id) + if err != nil { + return mfs + } + qualified := slice.Filter(mfs, func(mf model.MediaFile) bool { + return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path) + }) + if len(qualified) == 0 { + return mfs + } + return qualified +} diff --git a/cmd/missing_test.go b/cmd/missing_test.go new file mode 100644 index 000000000..2e96cf3c7 --- /dev/null +++ b/cmd/missing_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "errors" + "strings" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writeMissingList", func() { + cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor { + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range mfs { + if !yield(mf, nil) { + return + } + } + if err != nil { + yield(model.MediaFile{}, err) + } + } + } + song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"} + + It("writes csv with a header, quoting as needed", func() { + var out strings.Builder + Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed()) + Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n")) + }) + + It("writes a json array", func() { + var out strings.Builder + Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed()) + Expect(out.String()).To(MatchJSON(`[ + {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}, + {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"} + ]`)) + }) + + It("writes an empty json array when nothing is missing", func() { + var out strings.Builder + Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed()) + Expect(out.String()).To(MatchJSON(`[]`)) + }) + + It("returns the cursor's error", func() { + var out strings.Builder + Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom")) + }) +}) + +var _ = Describe("preferQualified", func() { + target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"} + decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"} + + It("picks the library-qualified match over a literal path that looks like one", func() { + Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target})) + }) + + It("picks the named library when the same path exists in two", func() { + other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"} + Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target})) + }) + + It("leaves an unqualified reference ambiguous", func() { + both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}} + Expect(preferQualified("foo.mp3", both)).To(Equal(both)) + }) + + It("leaves it alone when the prefix is not a library id", func() { + both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} + Expect(preferQualified("x:foo.mp3", both)).To(Equal(both)) + }) + + It("leaves it alone when no candidate matches the qualified form", func() { + both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} + Expect(preferQualified("9:nope.mp3", both)).To(Equal(both)) + }) +}) diff --git a/core/maintenance.go b/core/maintenance.go index 13d1141d3..56c0ac18d 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "fmt" "slices" "sync" @@ -14,11 +15,23 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) +var ( + // ErrNotMissing is returned when a remap is attempted from a file not marked as missing. + ErrNotMissing = errors.New("file is not marked as missing") + // ErrTargetMissing is returned when the remap target is itself a missing file. + ErrTargetMissing = errors.New("target file is missing") + // ErrSameFile is returned when the remap source and target are the same file. + ErrSameFile = errors.New("missing and target are the same file") +) + type Maintenance interface { // DeleteMissingFiles deletes specific missing files by their IDs DeleteMissingFiles(ctx context.Context, ids []string) error // DeleteAllMissingFiles deletes all files marked as missing DeleteAllMissingFiles(ctx context.Context) error + // RemapMissingFile moves a missing file's identity onto an existing file, the manual + // counterpart to the scanner's move detection (phaseMissingTracks.moveMatched). + RemapMissingFile(ctx context.Context, missingID, targetID string) error } type maintenanceService struct { @@ -40,6 +53,87 @@ func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error { return s.deleteMissing(ctx, nil) } +func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error { + if missingID == targetID { + return fmt.Errorf("%w: %q", ErrSameFile, missingID) + } + + missing, err := s.ds.MediaFile(ctx).Get(missingID) + if err != nil { + return fmt.Errorf("loading missing file %q: %w", missingID, err) + } + if !missing.Missing { + return fmt.Errorf("%w: %q", ErrNotMissing, missingID) + } + + target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID) + if err != nil { + return fmt.Errorf("loading target file %q: %w", targetID, err) + } + if target.Missing { + return fmt.Errorf("%w: %q", ErrTargetMissing, targetID) + } + + oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID + + err = s.ds.WithTx(func(tx model.DataStore) error { + discardedID := target.ID + + // Preserve the original created_at so the remapped track doesn't resurface in "Recently Added" + target.CreatedAt = missing.CreatedAt + target.ID = missing.ID + if err := tx.MediaFile(ctx).Put(target); err != nil { + return fmt.Errorf("update matched track: %w", err) + } + // Unlike the scanner's freshly-imported target, this one may carry history of its own + if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil { + return fmt.Errorf("reassign target references: %w", err) + } + if err := tx.MediaFile(ctx).Delete(discardedID); err != nil { + return fmt.Errorf("delete discarded track: %w", err) + } + + if oldAlbumID != newAlbumID { + oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) + if err != nil { + return fmt.Errorf("get old album tracks: %w", err) + } + if oldAlbumTracks == 0 { + if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + return fmt.Errorf("reassign album annotations: %w", err) + } + if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("copy album attributes: %w", err) + } + } + } + return nil + }) + if err != nil { + log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err) + return err + } + + if err := s.ds.GC(ctx); err != nil { + log.Error(ctx, "Error running GC after remapping missing file", err) + return err + } + + // Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits. + // album/artist play count aggregates are not recalculated here; they are refreshed by the next scan. + if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil { + log.Error(ctx, "Error refreshing artist stats after remapping missing file", err) + } + affectedAlbumIDs := []string{newAlbumID} + if oldAlbumID != newAlbumID { + affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID) + } + if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil { + log.Error(ctx, "Error refreshing album stats after remapping missing file", err) + } + return nil +} + // deleteMissing handles the deletion of missing files and triggers necessary cleanup operations func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error { // Track affected album IDs before deletion for refresh @@ -68,7 +162,8 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er return err } - // Refresh statistics in background + // Refresh statistics in background. album/artist play count aggregates are not recalculated + // here; they are refreshed by the next scan. s.refreshStatsAsync(ctx, affectedAlbumIDs) return nil diff --git a/core/maintenance_test.go b/core/maintenance_test.go index 09b442438..56e6d13b5 100644 --- a/core/maintenance_test.go +++ b/core/maintenance_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "sync" + "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -250,6 +251,167 @@ var _ = Describe("Maintenance", func() { }) }) }) + + Describe("RemapMissingFile", func() { + It("relocates the missing file's identity onto the target and runs GC", func() { + created := time.Now().Add(-30 * 24 * time.Hour) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Path: "old/song.mp3", AlbumID: "album1", CreatedAt: created, Missing: true}, + {ID: "t1", Path: "new/song.mp3", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + got, err := mfRepo.Get("m1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location + Expect(got.Missing).To(BeFalse()) + Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved + exists, _ := mfRepo.Exists("t1") + Expect(exists).To(BeFalse()) // discarded row removed + Expect(ds.GCCalled).To(BeTrue()) + }) + + It("moves the target's annotations, bookmarks and playlist entries onto the surviving id", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(mfRepo.ReassignReferencesCalls).To(HaveKeyWithValue("t1", "m1")) + }) + + It("reassigns album annotations when the old album is emptied", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album2", Missing: false}, + }) + + mfRepo.SetCountAll(0) + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("album1", "album2")) + }) + + It("does not reassign album annotations when the old album is not emptied", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "m2", AlbumID: "album1", Missing: false}, + {ID: "t1", AlbumID: "album2", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + + It("does not reassign annotations when the album is unchanged", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + + It("returns ErrNotFound when the missing file does not exist", func() { + mfRepo.SetData(model.MediaFiles{{ID: "t1", Missing: false}}) + + Expect(service.RemapMissingFile(ctx, "nope", "t1")).To(MatchError(model.ErrNotFound)) + }) + + It("refuses to remap from a file that is not missing", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Missing: false}, + {ID: "t1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrNotMissing)) + }) + + It("refuses to remap a file onto itself", func() { + mfRepo.SetData(model.MediaFiles{{ID: "m1", Missing: true}}) + + Expect(service.RemapMissingFile(ctx, "m1", "m1")).To(MatchError(ErrSameFile)) + }) + + It("refuses to remap onto a target that is itself missing", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Missing: true}, + {ID: "t1", Missing: true}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrTargetMissing)) + }) + + It("refreshes artist and album stats right after the remap", func() { + artistRepo := ds.MockedArtist.(*extendedArtistRepo) + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Old Album", SongCount: 2, Size: 1100, Duration: 110}, + {ID: "album2", Name: "New Album", SongCount: 1, Size: 2000, Duration: 200}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Path: "old/1.mp3", Album: "Old Album", AlbumID: "album1", Missing: true, Size: 100, Duration: 10}, + {ID: "k1", Path: "old/2.mp3", Album: "Old Album", AlbumID: "album1", Missing: false, Size: 1000, Duration: 100}, + {ID: "t1", Path: "new/1.mp3", Album: "New Album", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") + + // The old album lost the remapped track, so its stats are recalculated from the remaining one + oldAlbum, err := albumRepo.Get("album1") + Expect(err).ToNot(HaveOccurred()) + Expect(oldAlbum.SongCount).To(Equal(1)) + Expect(oldAlbum.Size).To(Equal(int64(1000))) + Expect(oldAlbum.Duration).To(BeNumerically("==", 100)) + + // The target album keeps the track, now under the missing file's ID + newAlbum, err := albumRepo.Get("album2") + Expect(err).ToNot(HaveOccurred()) + Expect(newAlbum.SongCount).To(Equal(1)) + Expect(newAlbum.Size).To(Equal(int64(2000))) + Expect(newAlbum.Duration).To(BeNumerically("==", 200)) + }) + + It("returns an error if GC fails", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + ds.GCError = errors.New("gc failed") + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ContainSubstring("gc failed"))) + }) + + It("preserves the target's participants on the remapped track", func() { + participant := model.Participant{ + Artist: model.Artist{ID: "a1", Name: "Artist", OrderArtistName: "artist", MbzArtistID: "mbz-artist"}, + } + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album2", Missing: false, Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{participant}, + }}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + // The surviving row is the missing file's ID, holding the target's data + got, err := mfRepo.GetWithParticipants("m1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant})) + }) + }) }) // Test helper to create a mock DataStore with controllable behavior diff --git a/model/mediafile.go b/model/mediafile.go index 2669018f3..7cbdb583c 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -563,6 +563,9 @@ type MediaFileRepository interface { DeleteMissing(ids []string) error DeleteAllMissing() (int64, error) FindByPaths(paths []string) (MediaFiles, error) + // ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID, + // keeping newID's own row wherever a user has both. + ReassignReferences(prevID, newID string) error // The following methods are used exclusively by the scanner: MarkMissing(bool, ...*MediaFile) error diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index da167b1a8..935a64bb9 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -362,19 +362,14 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err var unqualified []string for _, path := range paths { - parts := strings.SplitN(path, ":", 2) - if len(parts) == 2 { - // Library-qualified path: "libraryID:path" - libraryID, err := strconv.Atoi(parts[0]) - if err != nil { - // Invalid format, skip - continue + // A numeric prefix is ambiguous: "1:foo.mp3" qualifies a library, but "1999: A Life/01.mp3" + // is a plain path. Search both ways rather than guessing. + if id, rest, ok := strings.Cut(path, ":"); ok { + if libraryID, err := strconv.Atoi(id); err == nil { + byLibrary[libraryID] = append(byLibrary[libraryID], rest) } - byLibrary[libraryID] = append(byLibrary[libraryID], parts[1]) - } else { - // Unqualified path: search across all libraries - unqualified = append(unqualified, path) } + unqualified = append(unqualified, path) } query := Or{} @@ -405,6 +400,29 @@ func (r *mediaFileRepository) Delete(id string) error { return r.delete(Eq{"id": id}) } +func (r *mediaFileRepository) ReassignReferences(prevID, newID string) error { + if err := r.ReassignAnnotation(prevID, newID); err != nil { + return fmt.Errorf("reassigning annotations: %w", err) + } + if err := r.reassignBookmark(prevID, newID); err != nil { + return fmt.Errorf("reassigning bookmarks: %w", err) + } + upd := Update("playlist_tracks").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) + if _, err := r.executeSQL(upd); err != nil { + return fmt.Errorf("reassigning playlist tracks: %w", err) + } + upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) + if _, err := r.executeSQL(upd); err != nil { + return fmt.Errorf("reassigning scrobbles: %w", err) + } + // OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time) + buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID) + if _, err := r.executeSQL(buf); err != nil { + return fmt.Errorf("reassigning buffered scrobbles: %w", err) + } + return nil +} + func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { user := loggedUser(r.ctx) if !user.IsAdmin { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 8a492a813..1fb939415 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -918,6 +919,105 @@ var _ = Describe("MediaRepository", func() { }) }) + Describe("ReassignReferences", func() { + var prev, next model.MediaFile + var pr model.PlaylistRepository + var pls model.Playlist + + BeforeEach(func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + pr = NewPlaylistRepository(ctx, GetDBXBuilder()) + prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} + next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} + Expect(mr.Put(&prev)).To(Succeed()) + Expect(mr.Put(&next)).To(Succeed()) + pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} + pls.AddMediaFilesByID([]string{prev.ID}) + Expect(pr.Put(&pls)).To(Succeed()) + }) + + AfterEach(func() { + _ = pr.Delete(pls.ID) + _ = mr.Delete(prev.ID) + _ = mr.Delete(next.ID) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + }) + + It("moves annotations, bookmarks and playlist entries onto the new id", func() { + Expect(mr.SetRating(5, prev.ID)).To(Succeed()) + Expect(mr.AddBookmark(prev.ID, "here", 42)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(5)) + + bookmarks, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) + + withTracks, err := pr.GetWithTracks(pls.ID, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(withTracks.Tracks).To(HaveLen(1)) + Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) + }) + + It("moves scrobbles and buffered scrobbles onto the new id", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + scrobbles := NewScrobbleRepository(ctx, GetDBXBuilder()) + buffer := NewScrobbleBufferRepository(ctx, GetDBXBuilder()) + Expect(scrobbles.RecordScrobble(prev.ID, time.Now())).To(Succeed()) + Expect(buffer.Enqueue("lastfm", "userid", prev.ID, time.Now())).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + Expect(mr.Delete(prev.ID)).To(Succeed()) + + all, err := scrobbles.GetAll() + Expect(err).ToNot(HaveOccurred()) + mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { + return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID + }), func(sc model.Scrobble) string { return sc.MediaFileID }) + Expect(mine).To(ConsistOf(next.ID)) + + entry, err := buffer.Next("lastfm", "userid") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil()) + Expect(entry.MediaFile.ID).To(Equal(next.ID)) + }) + + It("recomputes the average rating after merging another user's annotation", func() { + other := NewMediaFileRepository(request.WithUser(log.NewContext(context.TODO()), model.User{ID: "2222"}), GetDBXBuilder()) + Expect(mr.SetRating(5, next.ID)).To(Succeed()) + Expect(other.SetRating(3, prev.ID)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AverageRating).To(Equal(4.0)) + }) + + It("keeps the new id's own annotation and bookmark when both exist", func() { + Expect(mr.SetRating(5, prev.ID)).To(Succeed()) + Expect(mr.SetRating(1, next.ID)).To(Succeed()) + Expect(mr.AddBookmark(prev.ID, "prev", 42)).To(Succeed()) + Expect(mr.AddBookmark(next.ID, "next", 7)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(1)) + bookmarks, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) + }) + }) + Describe("FindByPaths", func() { // Test fixtures for Unicode and case-sensitivity tests var testFiles []model.MediaFile @@ -930,6 +1030,8 @@ var _ = Describe("MediaRepository", func() { {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"}, // French diacritic: è (U+00E8, can decompose to e + combining grave) {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"}, + {ID: "findpath-5", LibraryID: 1, Path: "Bach: Goldberg Variations/01.mp3", Title: "Colon"}, + {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, } for _, mf := range testFiles { Expect(mr.Put(&mf)).To(Succeed()) @@ -942,6 +1044,27 @@ var _ = Describe("MediaRepository", func() { } }) + It("treats a path whose prefix is not a library id as unqualified", func() { + results, err := mr.FindByPaths([]string{"Bach: Goldberg Variations/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-5")) + }) + + It("finds a plain path whose colon prefix looks like a library id", func() { + results, err := mr.FindByPaths([]string{"1999: A Different Life/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-6")) + }) + + It("splits only the first colon of a library-qualified path", func() { + results, err := mr.FindByPaths([]string{"1:Bach: Goldberg Variations/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-5")) + }) + It("finds files by exact path", func() { results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 46ad6a0de..27445b886 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -185,12 +185,14 @@ func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error { if prevID == newID || prevID == "" || newID == "" { return nil } - upd := Update(annotationTable).Where(And{ - Eq{annotationTable + ".item_type": r.tableName}, - Eq{annotationTable + ".item_id": prevID}, - }).Set("item_id", newID) - _, err := r.executeSQL(upd) - return err + // OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement + upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?", + newID, r.tableName, prevID) + if _, err := r.executeSQL(upd); err != nil { + return err + } + // The moved rows change newID's rating population, so its cached average no longer matches + return r.updateAvgRating(newID) } func (r sqlRepository) cleanAnnotations() error { diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 5766f687f..79ad3c152 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -30,6 +30,53 @@ var _ = Describe("Annotation Filters", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) + Describe("ReassignAnnotation", func() { + var prev, next model.Album + + BeforeEach(func() { + prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1} + next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1} + Expect(albumRepo.Put(&prev)).To(Succeed()) + Expect(albumRepo.Put(&next)).To(Succeed()) + }) + + AfterEach(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) + }) + + It("moves the annotation when the new item has none", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(4)) + }) + + It("recomputes the new item's cached average rating", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AverageRating).To(Equal(4.0)) + }) + + It("keeps the new item's annotation when both exist", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + Expect(albumRepo.SetRating(2, next.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(2)) + }) + }) + Describe("annotationBoolFilter", func() { DescribeTable("creates correct SQL expressions", func(field, value string, expectedSQL string, expectedArgs []any) { diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 19f16b231..a9f53430d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -144,6 +144,13 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { return resp, nil } +func (r sqlRepository) reassignBookmark(prevID, newID string) error { + upd := Expr("update or ignore "+bookmarkTable+" set item_id = ? where item_type = ? and item_id = ?", + newID, r.tableName, prevID) + _, err := r.executeSQL(upd) + return err +} + func (r sqlRepository) cleanBookmarks() error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") c, err := r.executeSQL(del) diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index d54ceee40..f93b166c1 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -311,7 +311,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'always'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - mr.CountAllValue = 3 + mr.SetCountAll(3) mr.DeleteAllMissingValue = 3 }) It("should purge missing files", func() { @@ -325,7 +325,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'full'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull - mr.CountAllValue = 2 + mr.SetCountAll(2) mr.DeleteAllMissingValue = 2 }) It("should not purge missing files if not a full scan", func() { @@ -346,7 +346,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'never'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever - mr.CountAllValue = 1 + mr.SetCountAll(1) mr.DeleteAllMissingValue = 1 }) It("should not purge missing files", func() { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 7a1a8f926..74225cd9b 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -24,11 +24,14 @@ type MockMediaFileRepo struct { model.MediaFileRepository Data map[string]*model.MediaFile Err bool - // Add fields and methods for controlling CountAll and DeleteAllMissing in tests - CountAllValue int64 + // Add fields and methods for controlling CountAll and DeleteAllMissing in tests. + // A nil CountAllValue is unset, and CountAll falls back to counting rows in Data. + CountAllValue *int64 CountAllOptions model.QueryOptions DeleteAllMissingValue int64 - Options model.QueryOptions + // ReassignReferencesCalls records prevID -> newID + ReassignReferencesCalls map[string]string + Options model.QueryOptions // Add fields for cross-library move detection tests FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) @@ -40,6 +43,10 @@ func (m *MockMediaFileRepo) SetError(err bool) { m.Err = err } +func (m *MockMediaFileRepo) SetCountAll(count int64) { + m.CountAllValue = &count +} + func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { m.Data = make(map[string]*model.MediaFile) for i, mf := range mfs { @@ -163,6 +170,17 @@ func (m *MockMediaFileRepo) Delete(id string) error { return nil } +func (m *MockMediaFileRepo) ReassignReferences(prevID, newID string) error { + if m.Err { + return errors.New("error") + } + if m.ReassignReferencesCalls == nil { + m.ReassignReferencesCalls = make(map[string]string) + } + m.ReassignReferencesCalls[prevID] = newID + return nil +} + func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("error") @@ -251,11 +269,11 @@ func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) if m.Err { return 0, errors.New("error") } - if m.CountAllValue != 0 { - if len(opts) > 0 { - m.CountAllOptions = opts[0] - } - return m.CountAllValue, nil + if len(opts) > 0 { + m.CountAllOptions = opts[0] + } + if m.CountAllValue != nil { + return *m.CountAllValue, nil } return int64(len(m.Data)), nil } From 2d09ebc67661d9a31f02f673c4a5e94c8eeb98cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 12 Sep 2026 12:58:00 -0400 Subject: [PATCH 531/596] fix(ui): update Chinese (traditional) translations from POEditor (#6128) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/zh-Hant.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 5e48352ea..7b8819da3 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -94,7 +94,7 @@ "download": "下載", "info": "取得資訊", "share": "分享", - "refresh": "" + "refresh": "重新整理中繼資料" }, "lists": { "all": "所有", @@ -157,12 +157,12 @@ "token": "權杖", "lastAccessAt": "上次存取", "libraries": "媒體庫", - "scrobbleFilter": "" + "scrobbleFilter": "紀錄過濾條件" }, "helperTexts": { "name": "您的名稱會在下次登入時生效", "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫", - "scrobbleFilter": "" + "scrobbleFilter": "符合這些智慧播放清單規則的歌曲將不會傳送至 Last.fm、ListenBrainz 或 Scrobbler 插件。語法與行為皆與智慧播放清單的 JSON 格式相同。範例:{\"all\":[{\"lt\":{\"rating\":4}}]}。若留空則會記錄所有播放紀錄。本機播放次數不受影響。" }, "notifications": { "created": "使用者已建立", @@ -177,7 +177,7 @@ }, "validation": { "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫", - "invalidScrobbleFilter": "" + "invalidScrobbleFilter": "必須是有效的智慧播放清單規則。不支援數量限制、位移及重新整理延遲。" } }, "player": { @@ -202,7 +202,7 @@ "command": "指令" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "無" } }, "playlist": { @@ -412,7 +412,7 @@ "configValidationError": "設定驗證失敗:", "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。", - "idHelp": "" + "idHelp": "插件 ID,衍生自其檔案名稱。在 Agents 等設定選項中參照此插件時請使用此 ID。" }, "placeholders": { "configKey": "鍵", @@ -609,7 +609,7 @@ "coverRemoved": "已移除封面圖", "coverUploadError": "上傳封面圖時發生錯誤", "coverRemoveError": "移除封面圖時發生錯誤", - "metadataRefreshStarted": "" + "metadataRefreshStarted": "正在背景重新整理中繼資料" }, "menu": { "library": "媒體庫", From 4168377b656f1237da4bcb503a08094cb885b115 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan <ivan@quad4.io> Date: Sat, 12 Sep 2026 12:37:33 -0500 Subject: [PATCH 532/596] Merge commit from fork --- persistence/artist_repository.go | 21 ++++++++++-- persistence/artist_repository_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 1ff291a0f..97c1452cb 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -162,13 +162,26 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { - if _, ok := model.AllRoles[role]; ok { - return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") + if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" { + return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL") } } return Eq{"1": 2} } +// sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist +// stats (filter and sort). "total" is the aggregate key stored by the scanner. +// Unknown values must not reach SQL string concatenation. +func sanitizeArtistStatsRole(role string) (string, bool) { + if role == "" || role == "total" { + return "total", true + } + if _, ok := model.AllRoles[role]; ok { + return role, true + } + return "", false +} + // artistLibraryIdFilter filters artists based on library access through the library_artist table func artistLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_artist.library_id": value} @@ -716,7 +729,9 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { - role = v + if safe, ok := sanitizeArtistStatsRole(v); ok { + role = safe + } } } r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index d337b4c22..f6612acc4 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -112,6 +112,52 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("sanitizeArtistStatsRole", func() { + It("allowlists total and registered roles", func() { + role, ok := sanitizeArtistStatsRole("total") + Expect(ok).To(BeTrue()) + Expect(role).To(Equal("total")) + role, ok = sanitizeArtistStatsRole("albumartist") + Expect(ok).To(BeTrue()) + Expect(role).To(Equal("albumartist")) + }) + + It("rejects SQL injection payloads used in sort mappings", func() { + payload := "total'||(SELECT password FROM user LIMIT 1)||'" + role, ok := sanitizeArtistStatsRole(payload) + Expect(ok).To(BeFalse()) + Expect(role).To(BeEmpty()) + }) + }) + + Describe("ReadAll role sort SQL injection", func() { + It("does not interpolate attacker-controlled role into ORDER BY", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + payload := "total') OR 1=1--" + _, err := repo.ReadAll(rest.QueryOptions{ + Sort: "songCount", + Order: "ASC", + Filters: map[string]any{"role": payload}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'total'->>'m')")) + Expect(repo.sortMappings["song_count"]).ToNot(ContainSubstring(payload)) + }) + + It("keeps valid role sort paths", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + _, err := repo.ReadAll(rest.QueryOptions{ + Sort: "songCount", + Order: "DESC", + Filters: map[string]any{"role": "composer"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'composer'->>'m')")) + }) + }) + Describe("searchScope", func() { // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), // the way Search() does, for a repo whose context carries the given user. From 1a8463f7de3cfc3c71a3af7488492b890eea9bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 12 Sep 2026 13:38:08 -0400 Subject: [PATCH 533/596] Merge commit from fork * fix(share): always assign the authenticated user as share owner A share's UserID was taken from the request body and only defaulted when empty, so any authenticated user could create a share attributed to another user. For playlist shares the contents are resolved in the owner's library-access context, turning the spoofed owner into an access-escalation vector in multi-library setups. Force the owner from the request context at both the service boundary and the persistence layer, ignoring any client-supplied UserID. * fix(plugins): block SSRF to private IPs resolved from hostnames The HTTP host client only checked the literal host string, so a symbolic hostname (or a trailing-dot "localhost.") resolving to a private/loopback address bypassed the SSRF guard when a plugin declared no requiredHosts. Enforce the check at dial time via net.Dialer.Control on the resolved IP, which also covers redirect hops and DNS rebinding. When an explicit requiredHosts allowlist is set, defer to it as the operator's trust decision. * fix(plugins): gate private IPs on explicit IP/CIDR allowlist entries Following review feedback: an allowlisted hostname authorizes the external service, not whatever private IP it may resolve or rebind to. Enforce the resolved-IP guard even when requiredHosts is set, permitting a private address only when a literal IP or CIDR entry explicitly covers it. This keeps "reach this external API" and "reach my internal network" as two separate, explicit operator decisions. * fix(plugins): treat unspecified addresses as private in the SSRF guard Dialing 0.0.0.0 or :: reaches the local host, so they bypassed the private/loopback check. * fix(plugins): let a bare "*" allowlist reach private addresses Plugins such as AudioMuse-AI declare requiredHosts ["*"] to reach a user-configured service on the LAN, whose address the manifest cannot know. Requiring a literal IP/CIDR entry broke them. Named hosts and subdomain wildcards still cannot resolve to private addresses. * refactor(plugins): simplify the SSRF-guarded HTTP client and release its pool Build the client directly around the guarded transport instead of replacing a throwaway one, fail closed on an unparseable dial address, and close the per-plugin transport's idle connections when the plugin unloads. Trim stale comments. * fix(plugins): stop enabling extism's unguarded http_request host function Passing requiredHosts as the extism manifest's AllowedHosts enabled extism's own http_request (pdk.NewHTTPRequest), which only glob-matches the hostname and follows redirects without re-checking, bypassing the resolved-IP SSRF guard. Plugins must use host.HTTPSend. * fix(plugins): move bundled Rust examples to the host HTTP service Extism's built-in http_request is now disabled, so the webhook and Discord examples switch to nd_pdk::host::http::send. Update the README to say host.HTTPSend is the only supported way to make HTTP requests. * fix(plugins): move the Python example to the host HTTP service coverartarchive-py used extism's built-in Http.request, which is now disabled. Call Navidrome's http_send host function instead. The plugin can no longer run under the standalone extism CLI, so drop the CLI test targets and instructions. --- core/share.go | 6 ++ core/share_test.go | 10 +++ persistence/share_repository.go | 4 +- persistence/share_repository_test.go | 22 ++++++ plugins/README.md | 2 +- plugins/examples/coverartarchive-py/Makefile | 17 +---- plugins/examples/coverartarchive-py/README.md | 9 +-- .../coverartarchive-py/plugin/__init__.py | 31 ++++++--- .../discord-rich-presence-rs/src/rpc.rs | 47 ++++++++----- plugins/examples/webhook-rs/src/lib.rs | 18 +++-- plugins/host_httpclient.go | 68 +++++++++++++++++-- plugins/host_httpclient_test.go | 67 +++++++++++++++++- plugins/manager_loader.go | 14 ++-- plugins/manager_loader_test.go | 4 +- 14 files changed, 238 insertions(+), 81 deletions(-) diff --git a/core/share.go b/core/share.go index 5ee1a5667..a291a060c 100644 --- a/core/share.go +++ b/core/share.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" @@ -91,6 +92,11 @@ func (r *shareRepositoryWrapper) newId() (string, error) { func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s := entity.(*model.Share) + // Owner is always the caller; never trust a client-supplied UserID, as it + // determines the library-access context used to resolve the share contents. + if user, ok := request.UserFrom(r.ctx); ok { + s.UserID = user.ID + } id, err := r.newId() if err != nil { return "", err diff --git a/core/share_test.go b/core/share_test.go index a5ee0428b..e6dec1b92 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -5,6 +5,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -39,6 +40,15 @@ var _ = Describe("Share", func() { Expect(entity.ID).To(Equal(id)) }) + It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { + loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"}) + repo := share.NewRepository(loggedInCtx).(rest.Persistable) + entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.UserID).To(Equal("logged-in-user")) + }) + It("does not truncate ASCII labels shorter than 30 characters", func() { _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 92c696688..bcf33790d 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -170,8 +170,10 @@ func (r *shareRepository) Update(id string, entity any, cols ...string) error { func (r *shareRepository) Save(entity any) (string, error) { s := entity.(*model.Share) // TODO Validate record + // Owner is server-managed: for an authenticated request, never trust a + // client-supplied UserID, as it drives the share's library-access context. u := loggedUser(r.ctx) - if s.UserID == "" { + if u.ID != invalidUserId || s.UserID == "" { s.UserID = u.ID } s.CreatedAt = time.Now() diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 02d8a7386..af8cf2ae9 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -393,6 +393,28 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Save", func() { + It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + attackerRepo := NewShareRepository(attackerCtx, GetDBXBuilder()).(rest.Persistable) + + id, err := attackerRepo.Save(&model.Share{ + ID: "spoof-save-share", UserID: otherUser.ID, + ResourceType: "media_file", ResourceIDs: "1001", + }) + Expect(err).ToNot(HaveOccurred()) + + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + got, err := adminRepo.Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(got.UserID).To(Equal(ownerUser.ID)) + }) + }) + Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) diff --git a/plugins/README.md b/plugins/README.md index 7042b8c45..10eb34fbc 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -401,7 +401,7 @@ import "github.com/navidrome/navidrome/plugins/pdk/go/host" ### HTTP -Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. +Make HTTP requests to external services, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP (`pdk.NewHTTPRequest`) is disabled. **Manifest permission:** diff --git a/plugins/examples/coverartarchive-py/Makefile b/plugins/examples/coverartarchive-py/Makefile index e3cd60d1c..c47c242d7 100644 --- a/plugins/examples/coverartarchive-py/Makefile +++ b/plugins/examples/coverartarchive-py/Makefile @@ -1,5 +1,5 @@ # Build the Cover Art Archive Python plugin -.PHONY: build test clean +.PHONY: build clean WASM_FILE = coverartarchive-py.wasm @@ -8,20 +8,5 @@ build: $(WASM_FILE) $(WASM_FILE): plugin/__init__.py extism-py plugin/__init__.py -o $(WASM_FILE) -test: build - @echo "Testing nd_manifest..." - extism call $(WASM_FILE) nd_manifest --wasi - @echo "" - @echo "Testing nd_get_album_images with Portishead's Dummy MBID..." - extism call $(WASM_FILE) nd_get_album_images --wasi \ - --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ - --allow-host "coverartarchive.org" --allow-host "archive.org" - -test-error: build - @echo "Testing error case (missing MBID)..." - -extism call $(WASM_FILE) nd_get_album_images --wasi \ - --input '{"name":"Test Album","artist":"Test Artist"}' \ - --allow-host "coverartarchive.org" - clean: rm -f $(WASM_FILE) diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md index 77957ac4f..c779f4a43 100644 --- a/plugins/examples/coverartarchive-py/README.md +++ b/plugins/examples/coverartarchive-py/README.md @@ -51,14 +51,7 @@ zip -j coverartarchive-py.ndp manifest.json plugin.wasm ## Testing -Extract the wasm file and test: - -```bash -unzip -p coverartarchive-py.ndp plugin.wasm > coverartarchive-py.wasm -extism call coverartarchive-py.wasm nd_get_album_images --wasi \ - --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ - --allow-host "coverartarchive.org" --allow-host "archive.org" -``` +The plugin makes HTTP requests through Navidrome's `http_send` host function, so it only runs inside Navidrome (the `extism` CLI can't provide that function). Install the `.ndp` as described above, then open an album that has a MusicBrainz ID and check the Navidrome logs. ## How It Works diff --git a/plugins/examples/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py index 3c1d4149e..e6f4c6a07 100644 --- a/plugins/examples/coverartarchive-py/plugin/__init__.py +++ b/plugins/examples/coverartarchive-py/plugin/__init__.py @@ -5,16 +5,25 @@ # # Build with: # extism-py plugin/__init__.py -o coverartarchive-py.wasm -# -# Test with: -# extism call coverartarchive-py.wasm nd_get_album_images --wasi \ -# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ -# --allow-host "coverartarchive.org" --allow-host "archive.org" +import base64 import extism import json +@extism.import_fn("extism:host/user", "http_send") +def http_send(req: dict) -> dict: ... + + +def http_get(url): + """GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes).""" + resp = http_send({"request": {"method": "GET", "url": url}}) + if resp.get("error"): + raise Exception(f"HTTP request failed: {resp['error']}") + result = resp.get("result") or {} + return result.get("statusCode", 0), base64.b64decode(result.get("body") or "") + + @extism.plugin_fn def nd_get_album_images(): """Retrieve album cover images from Cover Art Archive.""" @@ -26,13 +35,13 @@ def nd_get_album_images(): # Query Cover Art Archive API url = f"https://coverartarchive.org/release/{mbid}" - response = extism.Http.request(url, meth="GET") - - if response.status_code != 200: - raise Exception(f"not found: CAA returned status {response.status_code}") - + status, body = http_get(url) + + if status != 200: + raise Exception(f"not found: CAA returned status {status}") + try: - data = json.loads(response.data_str()) + data = json.loads(body) except json.JSONDecodeError: raise Exception("not found: invalid JSON response") diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs index 3de9eff63..d90cb4341 100644 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -4,7 +4,7 @@ //! presence updates, and heartbeat management. use extism_pdk::*; -use nd_pdk::host::{cache, scheduler, websocket}; +use nd_pdk::host::{cache, http, scheduler, websocket}; use serde::{Deserialize, Serialize}; // ============================================================================ @@ -359,19 +359,33 @@ fn find_username_for_connection(connection_id: &str) -> Result<Option<String>, E Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty())) } -fn get_discord_gateway() -> Result<String, Error> { - let req = HttpRequest::new("https://discord.com/api/gateway") - .with_method("GET"); +fn send_http( + method: &str, + url: &str, + headers: std::collections::HashMap<String, String>, + body: Vec<u8>, +) -> Result<http::HTTPResponse, Error> { + http::send(http::HTTPRequest { + method: method.into(), + url: url.into(), + headers, + no_follow_redirects: false, + body, + timeout_ms: 0, + })? + .ok_or_else(|| Error::msg("empty HTTP response")) +} - let resp = http::request::<String>(&req, None::<String>)?; - if resp.status_code() >= 400 { +fn get_discord_gateway() -> Result<String, Error> { + let resp = send_http("GET", "https://discord.com/api/gateway", Default::default(), Vec::new())?; + if resp.status_code >= 400 { return Err(Error::msg(format!( "Failed to get Discord gateway: HTTP {}", - resp.status_code() + resp.status_code ))); } - let body = resp.body(); + let body = resp.body; let data: std::collections::HashMap<String, String> = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?; @@ -487,23 +501,22 @@ fn process_image_inner( client_id ); - let req = HttpRequest::new(&api_url) - .with_method("POST") - .with_header("Authorization", token) - .with_header("Content-Type", "application/json"); - - let resp = http::request::<String>(&req, Some(body))?; - if resp.status_code() >= 400 { + let headers = std::collections::HashMap::from([ + ("Authorization".to_string(), token.to_string()), + ("Content-Type".to_string(), "application/json".to_string()), + ]); + let resp = send_http("POST", &api_url, headers, body.into_bytes())?; + if resp.status_code >= 400 { if is_default { return Err(Error::msg(format!( "failed to process default image: HTTP {}", - resp.status_code() + resp.status_code ))); } return process_image_inner(DEFAULT_IMAGE, client_id, token, true); } - let body = resp.body(); + let body = resp.body; let data: Vec<std::collections::HashMap<String, String>> = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?; diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index 743c03744..794cda59c 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -12,7 +12,8 @@ //! urls = "https://example.com/webhook1,https://example.com/webhook2" //! ``` -use extism_pdk::{config, error, http, info, warn, HttpRequest}; +use extism_pdk::{config, error, info, warn}; +use nd_pdk::host::http::{self, HTTPRequest}; use nd_pdk::scrobbler::{ Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, @@ -90,11 +91,18 @@ impl Scrobbler for WebhookPlugin { let full_url = format!("{}{}", url, query); info!("Sending webhook to: {}", full_url); - let http_req = HttpRequest::new(&full_url); - match http::request::<()>(&http_req, None) { + let http_req = HTTPRequest { + method: "GET".into(), + url: full_url, + headers: Default::default(), + no_follow_redirects: false, + body: Vec::new(), + timeout_ms: 0, + }; + match http::send(http_req) { Ok(res) => { - let status = res.status_code(); - if status >= 200 && status < 300 { + let status = res.map_or(0, |r| r.status_code); + if (200..300).contains(&status) { info!("Webhook succeeded: {} (status {})", url, status); } else { warn!("Webhook returned non-2xx status: {} (status {})", url, status); diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index d52898bdd..749ba096e 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -9,7 +9,9 @@ import ( "net" "net/http" "net/url" + "slices" "strings" + "syscall" "time" "github.com/navidrome/navidrome/log" @@ -34,6 +36,7 @@ type httpServiceImpl struct { pluginName string requiredHosts []string client *http.Client + transport *http.Transport } // newHTTPService creates a new HTTPService for a plugin. @@ -46,8 +49,14 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI pluginName: pluginName, requiredHosts: requiredHosts, } + svc.transport = http.DefaultTransport.(*http.Transport).Clone() + svc.transport.DialContext = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: svc.dialControl, + }).DialContext // No client timeout: it is set per-request via context deadline. - svc.client = httpclient.New(0) + svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)} svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if req.Context().Value(noFollowRedirectsKey) != nil { return http.ErrUseLastResponse @@ -65,6 +74,12 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI return svc } +// Close releases the plugin's pooled connections when the plugin is unloaded. +func (s *httpServiceImpl) Close() error { + s.transport.CloseIdleConnections() + return nil +} + func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*host.HTTPResponse, error) { // Parse and validate URL parsedURL, err := url.Parse(request.URL) @@ -159,11 +174,49 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro return nil } +// dialControl checks the resolved IP, so hostnames can't reach private addresses unless a literal +// IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. +func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { + if slices.Contains(s.requiredHosts, "*") { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil || !isPrivateIP(ip) { + return nil + } + for _, entry := range s.requiredHosts { + if ipMatchesEntry(entry, ip) { + return nil + } + } + return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address) +} + +// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR +// that covers ip. Hostname and wildcard entries never match. +func ipMatchesEntry(entry string, ip net.IP) bool { + if _, cidr, err := net.ParseCIDR(entry); err == nil { + return cidr.Contains(ip) + } + if entryIP := net.ParseIP(entry); entryIP != nil { + return entryIP.Equal(ip) + } + return false +} + func (s *httpServiceImpl) isHostAllowed(hostname string) bool { + ip := net.ParseIP(hostname) for _, pattern := range s.requiredHosts { if matchHostPattern(pattern, hostname) { return true } + if ip != nil && ipMatchesEntry(pattern, ip) { + return true + } } return false } @@ -182,11 +235,8 @@ func extractHostname(hostStr string) string { return hostStr } -// isPrivateOrLoopback returns true if the given hostname resolves to or is -// a private, loopback, or link-local IP address. This includes: -// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 -// IPv6: ::1, fc00::/7, fe80::/10 -// It also blocks "localhost" by name. +// isPrivateOrLoopback is a pre-flight check on the literal host (IP or "localhost"); it does not +// resolve names, so dialControl remains the real guard. func isPrivateOrLoopback(hostname string) bool { if strings.EqualFold(hostname, "localhost") { return true @@ -195,7 +245,11 @@ func isPrivateOrLoopback(hostname string) bool { if ip == nil { return false } - return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() + return isPrivateIP(ip) +} + +func isPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() } // Verify interface implementation diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 4eb34a247..6edf75a97 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -3,6 +3,7 @@ package plugins import ( "context" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -43,6 +44,21 @@ var _ = Describe("httpServiceImpl", func() { Expect(err.Error()).To(ContainSubstring("private/loopback")) }) + It("should block a symbolic hostname that resolves to loopback (SSRF)", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + // The trailing dot passes the pre-flight string check; only the dial-time guard catches it. + _, port, _ := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://")) + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://localhost.:" + port + "/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + It("should block requests to localhost by name", func() { _, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", @@ -419,13 +435,53 @@ var _ = Describe("httpServiceImpl", func() { Expect(resp).To(BeNil()) }) + It("blocks a private IP reached via a hostname allowlist entry (rebinding protection)", func() { + // Allowlisting a name authorizes the external service, not whatever private + // IP it may resolve or rebind to. Only literal IP/CIDR entries do that. + svc.requiredHosts = []string{"api.example.com"} + err := svc.dialControl("tcp", "10.0.0.1:80", nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("allows a private IP that an explicit CIDR allowlist entry authorizes", func() { + svc.requiredHosts = []string{"10.0.0.0/8"} + Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(Succeed()) + }) + + It("allows private IPs when the allowlist is the bare '*' wildcard", func() { + svc.requiredHosts = []string{"*"} + Expect(svc.dialControl("tcp", "192.168.1.10:8000", nil)).To(Succeed()) + Expect(svc.dialControl("tcp", "127.0.0.1:8000", nil)).To(Succeed()) + }) + + It("still blocks private IPs for a subdomain wildcard entry", func() { + svc.requiredHosts = []string{"*.example.com"} + Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(MatchError(ContainSubstring("private/loopback"))) + }) + + It("closes idle pooled connections on Close", func() { + closed := make(chan struct{}) + ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateClosed { + close(closed) + } + } + ts.Start() + svc.requiredHosts = []string{"127.0.0.1"} + _, err := svc.Send(context.Background(), host.HTTPRequest{Method: "GET", URL: ts.URL, TimeoutMs: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.Close()).To(Succeed()) + Eventually(closed).Should(BeClosed()) + }) + It("should allow wildcard host patterns", func() { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("wildcard")) })) - // *.allowed.org is in the requiredHosts from BeforeEach, but test server is 127.0.0.1 - // Override with a wildcard that matches the test server - svc.requiredHosts = []string{"*.0.0.1"} + // The literal IP is what authorizes the loopback dial under the private-IP guard. + svc.requiredHosts = []string{"*.0.0.1", "127.0.0.1"} resp, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", URL: ts.URL, @@ -566,6 +622,11 @@ var _ = Describe("isPrivateOrLoopback", func() { Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) }) + It("should detect unspecified addresses, which dial the local host", func() { + Expect(isPrivateOrLoopback("0.0.0.0")).To(BeTrue()) + Expect(isPrivateOrLoopback("::")).To(BeTrue()) + }) + It("should allow public IPs", func() { Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 46da56396..024b14b72 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -148,7 +148,7 @@ var hostServices = []hostServiceEntry{ create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Http service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), nil, nil + return host.RegisterHTTPHostFunctions(service), service, nil }, }, { @@ -498,18 +498,12 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { return pluginConfig, nil } -// buildExtismManifest describes the plugin to extism. It must never set -// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts. +// buildExtismManifest describes the plugin to extism. It must never set AllowedPaths (extism would replace our +// jailed FSConfig) nor AllowedHosts (extism's http_request has no SSRF guard; plugins must use host.HTTPSend). func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { - manifest := extism.Manifest{ + return extism.Manifest{ Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, Config: pluginConfig, Timeout: uint64(defaultTimeout.Milliseconds()), } - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { - if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { - manifest.AllowedHosts = hosts - } - } - return manifest } diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 6326b2218..85a636202 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -23,8 +23,8 @@ var _ = Describe("buildExtismManifest", func() { Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) }) - It("carries the hosts the plugin is allowed to reach", func() { - Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"})) + It("never sets AllowedHosts, so plugin HTTP can't bypass the host service's SSRF guard", func() { + Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(BeEmpty()) }) }) From 276d767ce5baad29fe66d115a23a2d8b7fc4cab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 12 Sep 2026 13:41:00 -0400 Subject: [PATCH 534/596] Merge commit from fork * fix(plugins): apply the private-address dial guard to WebSocket connections The WebSocket host service only matched the host string against requiredHosts, so an allowlisted name resolving (or rebinding) to a private address was dialed. Share the HTTP client's resolved-IP check and allowlist matching, so WebSocket follows the same rules: named hosts can't reach private addresses, literal IP/CIDR entries and a bare "*" can. * refactor(plugins): drop redundant WebSocket dial timeout and tidy guard tests --- plugins/host_httpclient.go | 48 ++-------------------------- plugins/host_netguard.go | 58 ++++++++++++++++++++++++++++++++++ plugins/host_websocket.go | 14 ++++---- plugins/host_websocket_test.go | 45 ++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 53 deletions(-) create mode 100644 plugins/host_netguard.go diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 749ba096e..209f80eb0 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -9,7 +9,6 @@ import ( "net" "net/http" "net/url" - "slices" "strings" "syscall" "time" @@ -174,51 +173,12 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro return nil } -// dialControl checks the resolved IP, so hostnames can't reach private addresses unless a literal -// IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { - if slices.Contains(s.requiredHosts, "*") { - return nil - } - host, _, err := net.SplitHostPort(address) - if err != nil { - return err - } - ip := net.ParseIP(host) - if ip == nil || !isPrivateIP(ip) { - return nil - } - for _, entry := range s.requiredHosts { - if ipMatchesEntry(entry, ip) { - return nil - } - } - return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address) -} - -// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR -// that covers ip. Hostname and wildcard entries never match. -func ipMatchesEntry(entry string, ip net.IP) bool { - if _, cidr, err := net.ParseCIDR(entry); err == nil { - return cidr.Contains(ip) - } - if entryIP := net.ParseIP(entry); entryIP != nil { - return entryIP.Equal(ip) - } - return false + return checkPrivateDial(s.requiredHosts, address) } func (s *httpServiceImpl) isHostAllowed(hostname string) bool { - ip := net.ParseIP(hostname) - for _, pattern := range s.requiredHosts { - if matchHostPattern(pattern, hostname) { - return true - } - if ip != nil && ipMatchesEntry(pattern, ip) { - return true - } - } - return false + return isHostInAllowlist(s.requiredHosts, hostname) } // extractHostname returns the hostname portion of a host string, stripping @@ -248,9 +208,5 @@ func isPrivateOrLoopback(hostname string) bool { return isPrivateIP(ip) } -func isPrivateIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() -} - // Verify interface implementation var _ host.HTTPService = (*httpServiceImpl)(nil) diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go new file mode 100644 index 000000000..8400446ed --- /dev/null +++ b/plugins/host_netguard.go @@ -0,0 +1,58 @@ +package plugins + +import ( + "fmt" + "net" + "slices" +) + +// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a +// literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. +func checkPrivateDial(requiredHosts []string, address string) error { + if slices.Contains(requiredHosts, "*") { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil || !isPrivateIP(ip) { + return nil + } + for _, entry := range requiredHosts { + if ipMatchesEntry(entry, ip) { + return nil + } + } + return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address) +} + +func isHostInAllowlist(requiredHosts []string, hostname string) bool { + ip := net.ParseIP(hostname) + for _, pattern := range requiredHosts { + if matchHostPattern(pattern, hostname) { + return true + } + if ip != nil && ipMatchesEntry(pattern, ip) { + return true + } + } + return false +} + +// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR +// that covers ip. Hostname and wildcard entries never match. +func ipMatchesEntry(entry string, ip net.IP) bool { + if _, cidr, err := net.ParseCIDR(entry); err == nil { + return cidr.Contains(ip) + } + if entryIP := net.ParseIP(entry); entryIP != nil { + return entryIP.Equal(ip) + } + return false +} + +func isPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 82aded0cb..933e53144 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "maps" + "net" "net/http" "net/url" "strings" "sync" + "syscall" "time" "github.com/gorilla/websocket" @@ -112,6 +114,7 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade // Establish WebSocket connection dialer := websocket.Dialer{ HandshakeTimeout: 30 * time.Second, + NetDialContext: (&net.Dialer{Control: s.dialControl}).DialContext, } conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders) @@ -243,14 +246,11 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection } func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - hostWithoutPort := extractHostname(host) + return isHostInAllowlist(s.requiredHosts, extractHostname(host)) +} - for _, pattern := range s.requiredHosts { - if matchHostPattern(pattern, hostWithoutPort) { - return true - } - } - return false +func (s *webSocketServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { + return checkPrivateDial(s.requiredHosts, address) } // matchHostPattern matches a host against a pattern. diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 9f2d20bef..772d83cc9 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -8,6 +8,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -499,6 +500,50 @@ var _ = Describe("WebSocketService", Ordered, func() { }) }) + Describe("Private address protection", func() { + var wsServer *httptest.Server + var savedHosts []string + + BeforeEach(func() { + savedHosts = testService.requiredHosts + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if conn, err := upgrader.Upgrade(w, r, nil); err == nil { + _, _, _ = conn.ReadMessage() + } + })) + }) + + AfterEach(func() { + testService.closeAllConnections() + testService.requiredHosts = savedHosts + wsServer.Close() + }) + + serverPort := func() string { + u, _ := url.Parse(wsServer.URL) + return u.Port() + } + + It("blocks an allowlisted hostname that resolves to loopback", func() { + testService.requiredHosts = []string{"localhost."} + _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") + Expect(err).To(MatchError(ContainSubstring("private/loopback"))) + }) + + It("allows loopback when a CIDR entry covers it", func() { + testService.requiredHosts = []string{"127.0.0.0/8"} + _, err := testService.Connect(GinkgoT().Context(), "ws://127.0.0.1:"+serverPort(), nil, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("allows loopback when the allowlist is the bare '*' wildcard", func() { + testService.requiredHosts = []string{"*"} + _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") + Expect(err).ToNot(HaveOccurred()) + }) + }) + Describe("Plugin Unload", func() { It("should close all connections when plugin is unloaded", func() { // Create a fresh server for this test From 1072e9f7eb27faef740df5f0bd7fb18898aa650e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 12 Sep 2026 13:59:46 -0400 Subject: [PATCH 535/596] chore(plugins): document requiredHosts rules and deprecate pdk.NewHTTPRequest (#6129) * fix(plugins): align the Python HTTP example with the repo's host-call pattern Bind http_send with raw memory offsets like nowplaying-py does, drop guards for fields the host always sends, and document how plugins without a PDK call host services and which built-in HTTP APIs are disabled. * docs(plugins): document the private-address rules for HTTP requiredHosts Explain in the README and manifest schema that named hosts can't reach private addresses while IP/CIDR entries and a bare "*" can. * docs(plugins): document the private-address rules for requiredHosts Explain in the README and manifest schema that named hosts can't reach private addresses while IP/CIDR entries and a bare "*" can, for both HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper. * feat(plugins): derive Default for Rust host service structs The ndpgen client.rs template now adds Default to the derive list of host service structs, as the capability and shared types templates already do. Plugin authors can now set only the fields they need, for example HTTPRequest { method, url, ..Default::default() }. The webhook-rs and discord-rich-presence-rs examples use this form now. The golden files and the generated nd-pdk-host crate are updated to match. * feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK Navidrome no longer enables extism's http_request host function, so a request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small deprecation table and writes a Deprecated: paragraph for the listed extism functions, in both the WASM wrapper and the native stub. Linters and IDEs now point plugin authors to host.HTTPSend. The PDK example tests used to teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock. * docs(plugins): correct requiredHosts rules for websocket and private addresses Two statements in the plugin docs did not match the code. The WebSocket section claimed requiredHosts behaves like HTTP. It does not: host_httpclient.go only consults the allowlist when the list is non-empty and otherwise falls back to allowing public addresses, while host_websocket.go always calls isHostInAllowlist, so an absent list blocks every connection. The HTTP section claimed a named host can never reach a private address. checkPrivateDial scans the whole requiredHosts list, so a named host does reach a private address when the same list also holds a covering IP or CIDR. Reworded both, plus the matching requiredHosts descriptions in manifest-schema.json, and regenerated manifest_gen.go. --- plugins/README.md | 8 +- plugins/cmd/ndpgen/internal/generator_test.go | 27 +++++ plugins/cmd/ndpgen/internal/pdk_parser.go | 7 ++ .../ndpgen/internal/templates/client.rs.tmpl | 2 +- .../cmd/ndpgen/internal/templates/pdk.go.tmpl | 4 + .../internal/templates/pdk_stub.go.tmpl | 4 + .../testdata/comprehensive_client_expected.rs | 4 +- .../ndpgen/testdata/list_client_expected.rs | 2 +- .../ndpgen/testdata/search_client_expected.rs | 2 +- .../ndpgen/testdata/store_client_expected.rs | 2 +- .../ndpgen/testdata/users_client_expected.rs | 2 +- .../coverartarchive-py/plugin/__init__.py | 10 +- .../discord-rich-presence-rs/src/rpc.rs | 3 +- plugins/examples/webhook-rs/src/lib.rs | 5 +- plugins/host_httpclient.go | 6 +- plugins/host_netguard.go | 3 +- plugins/manifest-schema.json | 4 +- plugins/manifest_gen.go | 7 +- plugins/pdk/go/pdk/example_test.go | 100 ++++++++---------- plugins/pdk/go/pdk/pdk.go | 2 + plugins/pdk/go/pdk/pdk_stub.go | 2 + .../pdk/rust/nd-pdk-host/src/nd_host_http.rs | 4 +- .../rust/nd-pdk-host/src/nd_host_library.rs | 2 +- .../rust/nd-pdk-host/src/nd_host_matcher.rs | 2 +- .../src/nd_host_scrobbleretriever.rs | 6 +- .../pdk/rust/nd-pdk-host/src/nd_host_task.rs | 4 +- .../pdk/rust/nd-pdk-host/src/nd_host_users.rs | 2 +- 27 files changed, 132 insertions(+), 94 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index 10eb34fbc..7dca3a5f2 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -401,7 +401,7 @@ import "github.com/navidrome/navidrome/plugins/pdk/go/host" ### HTTP -Make HTTP requests to external services, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP (`pdk.NewHTTPRequest`) is disabled. +Make HTTP requests to external services, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP is disabled (`pdk.NewHTTPRequest` in Go, `http::request` in Rust, `extism.Http.request` in Python, `Http.request` in JS). **Manifest permission:** @@ -416,6 +416,8 @@ Make HTTP requests to external services, with timeouts, redirect control, and pr } ``` +**Private addresses:** the check runs on the resolved IP when connecting. A named host entry (`api.example.com`, `*.example.com`) never authorizes a loopback, private or link-local address on its own, even if its DNS points there. To reach a service on the local network, also list its IP or a CIDR (`192.168.1.10`, `10.0.0.0/8`), or use `"*"` when the user configures the address. Without `requiredHosts`, only public addresses are allowed. + **Host functions:** | Function | Parameters | Returns | @@ -700,6 +702,8 @@ Establish persistent WebSocket connections to external services. Your plugin mus } ``` +`requiredHosts` is mandatory here: leave it out and every connection is blocked. Unlike HTTP, there is no fallback to public addresses. Entries follow the same [private address rules](#http). + **Host functions:** | Function | Parameters | Description | @@ -1242,6 +1246,8 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` +There is no Python PDK, so call host services directly: import them from the `extism:host/user` namespace (e.g. `http_send`) with `@extism.import_fn`, and exchange JSON through Extism memory. Each function takes a JSON request and returns a JSON response with an `error` field on failure. For HTTP, send `{"request": {"method": "GET", "url": "..."}}` and read `result.statusCode` and `result.body` (base64). See [coverartarchive-py](examples/coverartarchive-py/) and [nowplaying-py](examples/nowplaying-py/). + ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index ea94c7fc6..555906489 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -1790,6 +1790,33 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)")) }) }) + + Describe("Deprecated PDK functions", func() { + symbols := &PDKSymbols{ + Functions: []PDKFunc{ + { + Name: "NewHTTPRequest", + Doc: "NewHTTPRequest returns a new `HTTPRequest`.", + Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, + Returns: []PDKReturn{{Type: "*HTTPRequest"}}, + Deprecated: "Use host.HTTPSend instead.", + }, + }, + } + + DescribeTable("emits a Deprecated paragraph after the doc line", + func(generate func(*PDKSymbols) ([]byte, error)) { + code, err := generate(symbols) + Expect(err).NotTo(HaveOccurred()) + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + Expect(string(code)).To(ContainSubstring( + "// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.\n//\n// Deprecated: Use host.HTTPSend instead.\nfunc NewHTTPRequest(")) + }, + Entry("WASM wrapper", GeneratePDKGo), + Entry("native stub", GeneratePDKGoStub), + ) + }) }) }) diff --git a/plugins/cmd/ndpgen/internal/pdk_parser.go b/plugins/cmd/ndpgen/internal/pdk_parser.go index 4756334dd..7607fe881 100644 --- a/plugins/cmd/ndpgen/internal/pdk_parser.go +++ b/plugins/cmd/ndpgen/internal/pdk_parser.go @@ -50,6 +50,12 @@ type PDKFunc struct { Params []PDKParam Returns []PDKReturn IsVariadic bool + Deprecated string // Rendered as a "Deprecated:" paragraph when set +} + +// deprecatedPDKFuncs marks extism functions that do not work inside Navidrome, with what to use instead. +var deprecatedPDKFuncs = map[string]string{ + "NewHTTPRequest": "Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead.", } // PDKParam represents a function parameter. @@ -156,6 +162,7 @@ func ParseExtismPDK() (*PDKSymbols, error) { t.Methods = append(t.Methods, fn) } } else { + fn.Deprecated = deprecatedPDKFuncs[fn.Name] symbols.Functions = append(symbols.Functions, fn) } } diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index f8b786849..786a50db5 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; {{if .Doc}} {{rustDocComment .Doc}} {{else}} -{{end}}#[derive(Debug, Clone, Serialize, Deserialize)] +{{end}}#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct {{.Name}} { {{- range .Fields}} diff --git a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl index ebaf88df0..117bd9232 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl @@ -40,6 +40,10 @@ const ( {{- if .Doc}} // {{.Name}} {{firstSentence .Doc}} {{- end}} +{{- if .Deprecated}} +// +// Deprecated: {{.Deprecated}} +{{- end}} func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} return extism.{{.Name}}({{argList .Params}}) diff --git a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl index 2bd73f107..27d06faa0 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl @@ -31,6 +31,10 @@ func ResetMock() { {{- if .Doc}} // {{.Name}} {{firstSentence .Doc}} {{- end}} +{{- if .Deprecated}} +// +// Deprecated: {{.Deprecated}} +{{- end}} func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} args := PDKMock.Called({{argList .Params}}) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs index 08dae2901..1bd953640 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs @@ -29,14 +29,14 @@ mod base64_bytes { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User2 { pub id: String, pub name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Filter2 { pub active: bool, diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.rs b/plugins/cmd/ndpgen/testdata/list_client_expected.rs index 9b54f7544..5227cadf4 100644 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/list_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Filter { pub active: bool, diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.rs b/plugins/cmd/ndpgen/testdata/search_client_expected.rs index b0ab2505a..396a22b4e 100644 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/search_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Result { pub id: String, diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.rs b/plugins/cmd/ndpgen/testdata/store_client_expected.rs index 25d2af2e0..3b9d5ac17 100644 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/store_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Item { pub id: String, diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.rs b/plugins/cmd/ndpgen/testdata/users_client_expected.rs index 40daa9cfd..4bac43ce8 100644 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/users_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub id: String, diff --git a/plugins/examples/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py index e6f4c6a07..c4a0480b5 100644 --- a/plugins/examples/coverartarchive-py/plugin/__init__.py +++ b/plugins/examples/coverartarchive-py/plugin/__init__.py @@ -12,16 +12,18 @@ import json @extism.import_fn("extism:host/user", "http_send") -def http_send(req: dict) -> dict: ... +def _http_send(offset: int) -> int: ... def http_get(url): """GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes).""" - resp = http_send({"request": {"method": "GET", "url": url}}) + request = json.dumps({"request": {"method": "GET", "url": url}}).encode("utf-8") + response_offset = _http_send(extism.memory.alloc(request).offset) + resp = json.loads(extism.memory.string(extism.memory.find(response_offset))) if resp.get("error"): raise Exception(f"HTTP request failed: {resp['error']}") - result = resp.get("result") or {} - return result.get("statusCode", 0), base64.b64decode(result.get("body") or "") + result = resp["result"] + return result["statusCode"], base64.b64decode(result.get("body", "")) @extism.plugin_fn diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs index d90cb4341..fa72f7466 100644 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -369,9 +369,8 @@ fn send_http( method: method.into(), url: url.into(), headers, - no_follow_redirects: false, body, - timeout_ms: 0, + ..Default::default() })? .ok_or_else(|| Error::msg("empty HTTP response")) } diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index 794cda59c..5f5d972a0 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -94,10 +94,7 @@ impl Scrobbler for WebhookPlugin { let http_req = HTTPRequest { method: "GET".into(), url: full_url, - headers: Default::default(), - no_follow_redirects: false, - body: Vec::new(), - timeout_ms: 0, + ..Default::default() }; match http::send(http_req) { Ok(res) => { diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 209f80eb0..54875ae8a 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -159,7 +159,7 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro hostname := extractHostname(hostStr) if len(s.requiredHosts) > 0 { - if !s.isHostAllowed(hostname) { + if !isHostInAllowlist(s.requiredHosts, hostname) { return fmt.Errorf("host %q is not allowed", hostStr) } return nil @@ -177,10 +177,6 @@ func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) erro return checkPrivateDial(s.requiredHosts, address) } -func (s *httpServiceImpl) isHostAllowed(hostname string) bool { - return isHostInAllowlist(s.requiredHosts, hostname) -} - // extractHostname returns the hostname portion of a host string, stripping // any port number and IPv6 brackets. It handles IPv6 addresses correctly // (e.g. "[::1]:8080" → "::1", "[::1]" → "::1"). diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go index 8400446ed..2244fed1f 100644 --- a/plugins/host_netguard.go +++ b/plugins/host_netguard.go @@ -41,8 +41,7 @@ func isHostInAllowlist(requiredHosts []string, hostname string) bool { return false } -// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR -// that covers ip. Hostname and wildcard entries never match. +// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR that covers ip. func ipMatchesEntry(entry string, ip net.IP) bool { if _, cidr, err := net.ParseCIDR(entry); err == nil { return cidr.Contains(ip) diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index dae0fd937..056a1caed 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -134,7 +134,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org')", + "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org'). A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", "items": { "type": "string" } @@ -174,7 +174,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org')", + "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org'). Required: with no entries every connection is blocked. A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", "items": { "type": "string" } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 6f5c01596..b5e9afd96 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -51,7 +51,8 @@ type HTTPPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for HTTP requests (e.g., 'api.example.com', - // '*.musicbrainz.org') + // '*.musicbrainz.org'). A named host alone can't reach a private address; also + // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } @@ -264,6 +265,8 @@ type WebSocketPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for WebSocket connections (e.g., - // 'api.example.com', '*.musicbrainz.org') + // 'api.example.com', '*.musicbrainz.org'). Required: with no entries every + // connection is blocked. A named host alone can't reach a private address; also + // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } diff --git a/plugins/pdk/go/pdk/example_test.go b/plugins/pdk/go/pdk/example_test.go index 5678bddd4..bf98eba2f 100644 --- a/plugins/pdk/go/pdk/example_test.go +++ b/plugins/pdk/go/pdk/example_test.go @@ -8,6 +8,7 @@ package pdk_test import ( "testing" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/stretchr/testify/mock" ) @@ -136,48 +137,39 @@ func TestProcessJSONRequest(t *testing.T) { } // ============================================================================= -// Examples using stub types (Memory, HTTPRequest, HTTPResponse) +// HTTP requests go through the host HTTP service (host.HTTPSend), not +// pdk.NewHTTPRequest, which Navidrome does not enable. // ============================================================================= // FetchData demonstrates a plugin function that makes an HTTP request. func FetchData(url string) ([]byte, error) { - // Create and configure the HTTP request - // Note: SetHeader and SetBody work directly on the stub - no mocking needed! - req := pdk.NewHTTPRequest(pdk.MethodGet, url) - req.SetHeader("Accept", "application/json") - req.SetHeader("User-Agent", "MyPlugin/1.0") - - // Send the request - this is mocked because it requires host interaction - resp := req.Send() - - // Check status - works directly on the stub - if resp.Status() != 200 { - return nil, nil + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: url, + Headers: map[string]string{ + "Accept": "application/json", + "User-Agent": "MyPlugin/1.0", + }, + }) + if err != nil { + return nil, err } - // Return body - works directly on the stub - return resp.Body(), nil + if resp.StatusCode != 200 { + return nil, nil + } + return resp.Body, nil } func TestFetchData(t *testing.T) { - pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil - // Create a stub response with test data expectedBody := []byte(`{"result": "success"}`) - stubResponse := pdk.NewStubHTTPResponse(200, map[string]string{ - "Content-Type": "application/json", - }, expectedBody) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "GET" && req.URL == "https://api.example.com/data" && + req.Headers["Accept"] == "application/json" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: expectedBody}, nil) - // Mock NewHTTPRequest to return a real HTTPRequest struct - // The struct methods (SetHeader, SetBody) work without mocking - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://api.example.com/data"). - Return(&pdk.HTTPRequest{}) - - // Mock Send to return our stub response - pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). - Return(stubResponse) - - // Call the function body, err := FetchData("https://api.example.com/data") if err != nil { @@ -188,21 +180,15 @@ func TestFetchData(t *testing.T) { t.Errorf("expected body %q, got %q", expectedBody, body) } - pdk.PDKMock.AssertExpectations(t) + host.HTTPMock.AssertExpectations(t) } func TestFetchData_NonOKStatus(t *testing.T) { - pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil - // Create a stub response with 404 status - stubResponse := pdk.NewStubHTTPResponse(404, nil, []byte("Not Found")) + host.HTTPMock.On("Send", mock.Anything). + Return(&host.HTTPResponse{StatusCode: 404, Body: []byte("Not Found")}, nil) - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://api.example.com/missing"). - Return(&pdk.HTTPRequest{}) - pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). - Return(stubResponse) - - // Call the function body, err := FetchData("https://api.example.com/missing") if err != nil { @@ -214,7 +200,7 @@ func TestFetchData_NonOKStatus(t *testing.T) { t.Errorf("expected nil body for 404, got %q", body) } - pdk.PDKMock.AssertExpectations(t) + host.HTTPMock.AssertExpectations(t) } // ProcessMemoryData demonstrates working with Memory type. @@ -292,23 +278,27 @@ func TestHTTPMethodString(t *testing.T) { // PostJSON demonstrates a more complex HTTP request with body. func PostJSON(url string, data []byte) (int, error) { - req := pdk.NewHTTPRequest(pdk.MethodPost, url) - req.SetHeader("Content-Type", "application/json") - req.SetBody(data) // Works directly on stub - - resp := req.Send() // This is mocked - return int(resp.Status()), nil + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: url, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: data, + }) + if err != nil { + return 0, err + } + return int(resp.StatusCode), nil } func TestPostJSON(t *testing.T) { - pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil - stubResponse := pdk.NewStubHTTPResponse(201, nil, nil) - - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://api.example.com/items"). - Return(&pdk.HTTPRequest{}) - pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). - Return(stubResponse) + host.HTTPMock.On("Send", host.HTTPRequest{ + Method: "POST", + URL: "https://api.example.com/items", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"name":"test"}`), + }).Return(&host.HTTPResponse{StatusCode: 201}, nil) status, err := PostJSON("https://api.example.com/items", []byte(`{"name":"test"}`)) @@ -320,5 +310,5 @@ func TestPostJSON(t *testing.T) { t.Errorf("expected status 201, got %d", status) } - pdk.PDKMock.AssertExpectations(t) + host.HTTPMock.AssertExpectations(t) } diff --git a/plugins/pdk/go/pdk/pdk.go b/plugins/pdk/go/pdk/pdk.go index 35394d700..456a238f5 100644 --- a/plugins/pdk/go/pdk/pdk.go +++ b/plugins/pdk/go/pdk/pdk.go @@ -111,6 +111,8 @@ func LogMemory(level LogLevel, m Memory) { } // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. +// +// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { return extism.NewHTTPRequest(method, url) } diff --git a/plugins/pdk/go/pdk/pdk_stub.go b/plugins/pdk/go/pdk/pdk_stub.go index 8f2707d56..1a685eecc 100644 --- a/plugins/pdk/go/pdk/pdk_stub.go +++ b/plugins/pdk/go/pdk/pdk_stub.go @@ -114,6 +114,8 @@ func LogMemory(level LogLevel, m Memory) { } // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. +// +// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { args := PDKMock.Called(method, url) var r0 *HTTPRequest diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index 1c44cd2f3..f1607112c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -30,7 +30,7 @@ mod base64_bytes { } /// HTTPRequest represents an outbound HTTP request from a plugin. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HTTPRequest { pub method: String, @@ -47,7 +47,7 @@ pub struct HTTPRequest { } /// HTTPResponse represents the response from an outbound HTTP request. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HTTPResponse { pub status_code: i32, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs index b4b9b3fb0..966c1607f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// Library represents a music library with metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Library { pub id: i32, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs index be2819257..7fdf88b7f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// MatchOptions carries optional parameters for a match request. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MatchOptions { #[serde(default)] diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs index fe80d98f6..19010924a 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// ScrobbleCountOptions carries optional parameters for counting user scrobbles -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleCountOptions { #[serde(default)] @@ -17,7 +17,7 @@ pub struct ScrobbleCountOptions { } /// ScrobbleOptions carries optional parameters for retrieving user scrobbles -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleOptions { #[serde(default)] @@ -31,7 +31,7 @@ pub struct ScrobbleOptions { } /// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleRef { pub id: i64, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs index 4f43e165c..ac82fdd08 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs @@ -30,7 +30,7 @@ mod base64_bytes { } /// QueueConfig holds configuration for a task queue. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueueConfig { pub concurrency: i32, @@ -41,7 +41,7 @@ pub struct QueueConfig { } /// TaskInfo holds the current state of a task. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskInfo { pub status: String, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs index faa795bb9..b31b39fb4 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; /// User represents a Navidrome user with minimal information exposed to plugins. /// Sensitive fields like password, email, and internal IDs are intentionally excluded. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub user_name: String, From d00c84716bbc123b5cf0631879909480ea4dfbe7 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 13 Sep 2026 11:03:56 -0400 Subject: [PATCH 536/596] fix(scanner): update go-taglib to fix permission denied on shared hosts The go-taglib WASM compilation cache lived in a shared $TMPDIR/go-taglib-wasm directory, created with 0700 by whichever user ran first. A second Navidrome instance running as another user on the same machine failed to read every file with "permission denied", so its scan found no files. The updated fork uses a per-user cache directory (go-taglib-wasm-<uid>) and falls back to running without the cache when it cannot be created or used. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1a73328f3..32db6ab1f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.27 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index 42e1505e9..5ed48d77a 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec h1:3VyOFsbsRtCQqdq/+fcmD3D6zlRvKSG7RCixgrdWfEo= -github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk= +github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= 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= From dd71f1c57f772f750cf046d8ff5897ef804a31de Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 13 Sep 2026 20:52:39 -0400 Subject: [PATCH 537/596] chore(release): fix PikaPods link and replace Danian with Zenith The release notes footer pointed to an outdated PikaPods URL and still listed Danian as a hosting option. Use the PikaPods run URL and Zenith, matching the links already used in the published v0.64.0 release notes. --- release/goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/goreleaser.yml b/release/goreleaser.yml index 103f2beaf..e7610f25e 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -114,7 +114,7 @@ release: ## Where to go next? * Read installation instructions on our [website](https://www.navidrome.org/docs/installation/). - * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution. + * Host Navidrome on [PikaPods](https://www.pikapods.com/pods?run=navidrome) or [Zenith](https://zenith.hosting/host/navidrome?ref=navidrome) for a simple cloud solution. * Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)! # Add the MSI installers to the release From 3f4b6a642cf3f1c5628c900790d2a9fcd7997fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 14 Sep 2026 22:46:21 -0400 Subject: [PATCH 538/596] fix(server): return 404 instead of 500 for missing native API resources (#6131) * fix: return 404 instead of 500 for missing native API resources The deluan/rest controller only maps rest.ErrNotFound to 404, comparing with ==. Most repositories return model.ErrNotFound, which had the same message but was a different value, so requesting a missing playlist, album, artist, song, radio, player, transcoding or library returned 500. This also applied to other users' private playlists. Make model.ErrNotFound the same value as rest.ErrNotFound. This fixes every REST route at once, with no per-route wrapping. errors.Is checks against either error keep working, and nothing wraps model.ErrNotFound before it reaches the controller. Fixes #6130 * fix(radio): return not found when deleting a missing radio station radioRepository.Delete used the shared delete helper, which never reports a missing row because SQL DELETE on zero rows is not an error. Deleting an unknown id silently succeeded: DELETE /api/radio/{id} returned 200, and the Subsonic deleteInternetRadioStation endpoint returned ok. Delete now checks the affected row count and returns model.ErrNotFound when nothing was deleted. The native API returns 404, and deleteInternetRadioStation returns error 70 (data not found). This matches Subsonic 6.1.6, gonic (both verified live) and Ampache (verified in source). Airsonic-Advanced does not implement this endpoint. The shared delete helper is unchanged, as several callers rely on deletes of absent rows succeeding. * fix(ui): return 404 for missing files that are not missing or do not exist missingRepository.Read filtered media files by bare "id" and "missing" columns. The media file query joins the library table, so SQLite rejected the query as ambiguous and GET /api/missing/{id} returned 500. Read now loads the file with MediaFileRepository.Get, which qualifies the column, and returns not found when the file does not exist or is not marked missing. * fix: report missing rows on single-item deletes and adopt deluan/rest errors.Is Bump github.com/deluan/rest to the version whose controller matches errors with errors.Is and errors.As. model.ErrNotFound stays the same value as rest.ErrNotFound, so the many hand-written conversions from model.ErrNotFound to rest.ErrNotFound in repositories, core services and test mocks did nothing. Remove them, along with the duplicate rest.ErrNotFound check in the Subsonic error mapper. Mappings from model.ErrNotAuthorized stay, as those are different errors. User and transcoding deletes had the same silent success as radio: the shared delete helper never reports a missing row, so their not-found checks never fired and DELETE /api/user/{id} and /api/transcoding/{id} returned 200 for unknown ids. Add deleteByID, which returns model.ErrNotFound when no row matched, and use it for radio, user and transcoding. Also drop the dead sql.ErrNoRows branch from delete, since a DELETE never returns it. The Subsonic deleteUser endpoint is not implemented (501), so this does not change the Subsonic API. Plugin deletes keep the silent helper: there is no REST route for them, and the plugin manager only deletes rows it just read. * chore: drop ErrNotFound comment and its identity test The alias to rest.ErrNotFound is self-explanatory, and the identity test only restated the declaration. * refactor: alias model.ErrNotAuthorized to rest.ErrPermissionDenied Like ErrNotFound, make model.ErrNotAuthorized the same value as the rest library's error, so REST endpoints map it to 403 directly. This removes the ErrNotAuthorized to rest.ErrPermissionDenied mappings in the library and playlist REST adapters and the duplicate check in the Subsonic error mapper. Handlers that check model.ErrNotAuthorized now also recognize rest.ErrPermissionDenied returned by repositories, so writePlaylistError, the image upload handlers and the public share handler return 403 for it instead of their fallback status. The error message changes from "not authorized" to "permission denied". --- core/library.go | 10 +--- core/playlists/rest_adapter.go | 20 +------- go.mod | 2 +- go.sum | 4 +- model/errors.go | 10 ++-- persistence/player_repository.go | 7 +-- persistence/playlist_repository.go | 3 -- persistence/radio_repository.go | 12 +---- persistence/radio_repository_test.go | 4 ++ persistence/share_repository.go | 7 +-- persistence/sql_base_repository.go | 16 +++++-- persistence/transcoding_repository.go | 16 +------ persistence/transcoding_repository_test.go | 5 ++ persistence/user_repository.go | 18 ++----- persistence/user_repository_test.go | 8 ++++ server/nativeapi/missing.go | 10 ++-- server/nativeapi/missing_test.go | 56 ++++++++++++++++++++++ server/nativeapi/playlists_test.go | 1 + server/subsonic/api.go | 6 +-- server/subsonic/e2e/subsonic_radio_test.go | 8 ++++ tests/mock_library_repo.go | 6 +-- tests/mock_mediafile_repo.go | 6 +-- tests/mock_plugin_repo.go | 6 +-- 23 files changed, 124 insertions(+), 117 deletions(-) create mode 100644 server/nativeapi/missing_test.go diff --git a/core/library.go b/core/library.go index d905e00cb..6df0e95b5 100644 --- a/core/library.go +++ b/core/library.go @@ -2,7 +2,6 @@ package core import ( "context" - "errors" "fmt" "io/fs" "os" @@ -307,14 +306,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error { } } - switch { - case errors.Is(err, model.ErrNotFound): - return rest.ErrNotFound - case errors.Is(err, model.ErrNotAuthorized): - return rest.ErrPermissionDenied - default: - return err - } + return err } func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error { diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index ca7cfb0cc..45642c819 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -2,7 +2,6 @@ package playlists import ( "context" - "errors" "reflect" "strings" @@ -41,15 +40,7 @@ func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string } func (r *playlistRepositoryWrapper) Delete(id string) error { - err := r.service.Delete(r.ctx, id) - switch { - case errors.Is(err, model.ErrNotFound): - return rest.ErrNotFound - case errors.Is(err, model.ErrNotAuthorized): - return rest.ErrPermissionDenied - default: - return err - } + return r.service.Delete(r.ctx, id) } func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository { @@ -92,14 +83,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { - switch { - case errors.Is(err, model.ErrNotFound): - return rest.ErrNotFound - case errors.Is(err, model.ErrNotAuthorized): - return rest.ErrPermissionDenied - default: - return err - } + return err } sent := sentFields(cols) diff --git a/go.mod b/go.mod index 32db6ab1f..9a693d651 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.5 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf + github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 diff --git a/go.sum b/go.sum index 5ed48d77a..4cda9a2f3 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk= github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= -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/rest v0.0.0-20260913134927-47b21f30cc12 h1:x4N/tx0XC9zcnipk5rpdI4qaG9JmovHW5HL97XYnO1I= +github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA= diff --git a/model/errors.go b/model/errors.go index 9e56f9501..b3f07565f 100644 --- a/model/errors.go +++ b/model/errors.go @@ -1,11 +1,15 @@ package model -import "errors" +import ( + "errors" + + "github.com/deluan/rest" +) var ( - ErrNotFound = errors.New("data not found") + ErrNotFound = rest.ErrNotFound ErrInvalidAuth = errors.New("invalid authentication") - ErrNotAuthorized = errors.New("not authorized") + ErrNotAuthorized = rest.ErrPermissionDenied ErrExpired = errors.New("access expired") ErrNotAvailable = errors.New("functionality not available") ErrValidation = errors.New("validation error") diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 353b0444f..a2b079d53 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -127,11 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) { if !r.isPermitted(t) { return "", rest.ErrPermissionDenied } - id, err := r.put(t.ID, t) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return id, err + return r.put(t.ID, t) } func (r *playerRepository) Update(id string, entity any, cols ...string) error { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index bc6bffd25..cd3e48d18 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -416,9 +416,6 @@ func (r *playlistRepository) Update(id string, entity any, cols ...string) error pls.ID = id pls.UpdatedAt = time.Now() _, err := r.put(id, pls, append(cols, "updatedAt")...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } return err } diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index e042ee6bf..4584ceaed 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" "time" . "github.com/Masterminds/squirrel" @@ -47,7 +46,7 @@ func (r *radioRepository) Delete(id string) error { return rest.ErrPermissionDenied } - return r.delete(Eq{"id": id}) + return r.deleteByID(id) } func (r *radioRepository) Get(id string) (*model.Radio, error) { @@ -132,9 +131,6 @@ func (r *radioRepository) Save(entity any) (string, error) { return "", rest.ErrPermissionDenied } err := r.Put(t) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } return t.ID, err } @@ -144,11 +140,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - err := r.Put(t, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.Put(t, cols...) } var _ model.RadioRepository = (*radioRepository)(nil) diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index a958f715d..8d4b685d9 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -52,6 +52,10 @@ var _ = Describe("RadioRepository", func() { _, err = repo.Get(radioWithHomePage.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("errors when missing", func() { + Expect(repo.Delete("notanid")).To(MatchError(model.ErrNotFound)) + }) }) Describe("Get", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index bcf33790d..0cf23068a 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" "fmt" "strings" "time" @@ -178,11 +177,7 @@ func (r *shareRepository) Save(entity any) (string, error) { } s.CreatedAt = time.Now() s.UpdatedAt = time.Now() - id, err := r.put(s.ID, s) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return id, err + return r.put(s.ID, s) } func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 5530d2568..9248f2a26 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -614,12 +614,20 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri } func (r sqlRepository) delete(cond Sqlizer) error { - del := Delete(r.tableName).Where(cond) - _, err := r.executeSQL(del) - if errors.Is(err, sql.ErrNoRows) { + _, err := r.executeSQL(Delete(r.tableName).Where(cond)) + return err +} + +// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently. +func (r sqlRepository) deleteByID(id string) error { + count, err := r.executeSQL(Delete(r.tableName).Where(Eq{"id": id})) + if err != nil { + return err + } + if count == 0 { return model.ErrNotFound } - return err + return nil } func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 96fd3efdb..fdf67806d 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -91,11 +90,7 @@ func (r *transcodingRepository) Save(entity any) (string, error) { return "", rest.ErrPermissionDenied } t := entity.(*model.Transcoding) - id, err := r.put(t.ID, t) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return id, err + return r.put(t.ID, t) } func (r *transcodingRepository) Update(id string, entity any, cols ...string) error { @@ -105,9 +100,6 @@ func (r *transcodingRepository) Update(id string, entity any, cols ...string) er t := entity.(*model.Transcoding) t.ID = id _, err := r.put(id, t) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } return err } @@ -115,11 +107,7 @@ func (r *transcodingRepository) Delete(id string) error { if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteByID(id) } var _ model.TranscodingRepository = (*transcodingRepository)(nil) diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index 73250163c..3be46bae3 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -65,6 +65,11 @@ var _ = Describe("TranscodingRepository", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) + It("returns not found when deleting a missing transcoding", func() { + err := adminRepo.(*transcodingRepository).Delete("does-not-exist") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("reads the Command field via the REST Read method", func() { tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} Expect(adminRepo.Put(tr)).To(Succeed()) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9de37876b..9399bcb53 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -244,11 +244,7 @@ func (r *userRepository) Read(id string) (any, error) { if !usr.IsAdmin && usr.ID != id { return nil, rest.ErrPermissionDenied } - usr, err := r.Get(id) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return usr, err + return r.Get(id) } func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) { @@ -314,11 +310,7 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error { if err := validateScrobbleFilter(u); err != nil { return err } - err := r.Put(u) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.Put(u) } func validatePasswordChange(newUser *model.User, logged *model.User) error { @@ -393,11 +385,7 @@ func (r *userRepository) Delete(id string) error { if !usr.IsAdmin { return rest.ErrPermissionDenied } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - if err != nil { + if err := r.deleteByID(id); err != nil { return err } diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index dc519d0a1..9a51b17dd 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -230,6 +230,14 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("Delete", func() { + It("returns not found for a missing user", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewUserRepository(adminCtx, GetDBXBuilder()).(*userRepository) + Expect(adminRepo.Delete("does-not-exist")).To(MatchError(model.ErrNotFound)) + }) + }) + Describe("ReadAll name filter", func() { var adminRepo model.ResourceRepository diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 0ad9bb0cc..de6595e00 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -6,7 +6,6 @@ import ( "maps" "net/http" - "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" @@ -46,17 +45,14 @@ func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.Query } func (r *missingRepository) Read(id string) (any, error) { - all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{ - squirrel.Eq{"id": id}, - squirrel.Eq{"missing": true}, - }}) + mf, err := r.mfRepo.Get(id) if err != nil { return nil, err } - if len(all) == 0 { + if !mf.Missing { return nil, model.ErrNotFound } - return all[0], nil + return mf, nil } func (r *missingRepository) EntityName() string { diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go new file mode 100644 index 000000000..4da550c0d --- /dev/null +++ b/server/nativeapi/missing_test.go @@ -0,0 +1,56 @@ +package nativeapi + +import ( + "bytes" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "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" +) + +var _ = Describe("Missing Files Endpoint", func() { + var router http.Handler + var token string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + conf.Server.EnableSharing = false + + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{ + {ID: "missing-1", Title: "Gone", Missing: true}, + {ID: "present-1", Title: "Here"}, + }) + userRepo := tests.CreateMockUserRepo() + ds := &tests.MockDataStore{MockedMediaFile: mfRepo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} + auth.Init(ds) + + user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"} + Expect(userRepo.Put(&user)).To(Succeed()) + var err error + token, err = auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + + router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)) + }) + + DescribeTable("GET /missing/{id}", + func(id string, status int) { + w := httptest.NewRecorder() + router.ServeHTTP(w, createAuthenticatedRequest(http.MethodGet, "/missing/"+id, &bytes.Buffer{}, token)) + Expect(w.Code).To(Equal(status), w.Body.String()) + }, + Entry("returns a missing file", "missing-1", http.StatusOK), + Entry("returns 404 for a file that is not missing", "present-1", http.StatusNotFound), + Entry("returns 404 for an unknown id", "unknown", http.StatusNotFound), + ) +}) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9abcc477f..4a6f5d03d 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -192,6 +192,7 @@ var _ = Describe("writePlaylistError", func() { }, Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound), Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden), + Entry("rest permission denied -> 403", rest.ErrPermissionDenied, http.StatusForbidden), Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict), Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest), ) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 029046c39..deedc46c7 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,7 +9,6 @@ import ( "regexp" "strconv" - "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" @@ -302,10 +301,9 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorMissingParameter, err.Error()) case errors.Is(err, req.ErrInvalidParam): err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): + case errors.Is(err, model.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied), - errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go index cd778fa79..d51cf6b14 100644 --- a/server/subsonic/e2e/subsonic_radio_test.go +++ b/server/subsonic/e2e/subsonic_radio_test.go @@ -130,4 +130,12 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations).ToNot(BeNil()) Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) }) + + It("deleteInternetRadioStation returns not found for a missing station", func() { + resp := doReq("deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound)) + }) }) diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 1a16a7e0b..0f7af2aab 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -172,11 +172,7 @@ func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) { func (m *MockLibraryRepo) Read(id string) (any, error) { idInt, _ := strconv.Atoi(id) - mf, err := m.Get(idInt) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return mf, err + return m.Get(idInt) } func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (any, error) { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 74225cd9b..a365bd2bd 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -302,11 +302,7 @@ func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { } func (m *MockMediaFileRepo) Read(id string) (any, error) { - mf, err := m.Get(id) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return mf, err + return m.Get(id) } func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (any, error) { diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go index 5d22c26aa..e65d56def 100644 --- a/tests/mock_plugin_repo.go +++ b/tests/mock_plugin_repo.go @@ -69,11 +69,7 @@ func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) { } func (m *MockPluginRepo) Read(id string) (any, error) { - p, err := m.Get(id) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return p, err + return m.Get(id) } func (m *MockPluginRepo) Put(p *model.Plugin) error { From 97270d44e194ccd467611ebe4af4acaf1d8b8b32 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:24:50 +0800 Subject: [PATCH 539/596] fix(ui): update Chinese Simplified translations (#6152) --- resources/i18n/zh-Hans.json | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 24f55357a..cae42460b 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -93,7 +93,8 @@ "shuffle": "随机播放", "addToPlaylist": "添加到歌单", "download": "下载", - "info": "查看信息" + "info": "查看信息", + "refresh": "刷新元数据" }, "lists": { "all": "全部", @@ -155,11 +156,13 @@ "currentPassword": "当前密码", "newPassword": "新密码", "token": "令牌", - "libraries": "媒体库" + "libraries": "媒体库", + "scrobbleFilter": "个性化记录过滤器" }, "helperTexts": { "name": "名称的更改将在下次登录时生效", - "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库" + "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库", + "scrobbleFilter": "符合这些智能歌单规则的歌曲不会发送到 Last.fm、ListenBrainz 或个性化记录插件。使用与智能歌单相同的 JSON 语法和行为。例如:{\"all\":[{\"lt\":{\"rating\":4}}]}。留空则记录所有播放。本地播放次数不受影响。" }, "notifications": { "created": "用户已创建", @@ -167,7 +170,8 @@ "deleted": "用户已删除" }, "validation": { - "librariesRequired": "至少为非管理员用户选择一个媒体库" + "librariesRequired": "至少为非管理员用户选择一个媒体库", + "invalidScrobbleFilter": "必须是有效的智能歌单规则。不支持数量限制(limit)、偏移量(offset)和刷新延迟(refresh delay)。" }, "message": { "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", @@ -196,6 +200,9 @@ "targetFormat": "目标格式", "defaultBitRate": "默认比特率", "command": "命令" + }, + "choices": { + "noDefaultBitRate": "无" } }, "playlist": { @@ -393,6 +400,7 @@ "invalidJson": "配置必须是有效的 JSON" }, "messages": { + "idHelp": "由插件文件名派生的插件 ID。在 Agents 等配置选项中引用此插件时使用。", "configHelp": "使用键值对配置插件。如果插件无需配置则留空。", "configValidationError": "配置验证失败:", "schemaRenderError": "无法渲染配置表单。此插件的 schema 定义可能无效。", @@ -401,10 +409,10 @@ "allUsersHelp": "启用时,插件将可以访问所有用户,包括将来创建的。", "noUsers": "未选择用户", "permissionReason": "原因", - "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户, 或启用 '允许所有用户'。", + "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户,或启用 '允许所有用户'。", "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", "noLibraries": "未选择媒体库", - "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。", + "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库,或启用 '允许所有媒体库'。", "allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。", "requiredHosts": "必需的主机" }, @@ -566,6 +574,7 @@ "coverRemoved": "封面已移除", "coverUploadError": "上传封面时出错", "coverRemoveError": "移除封面时出错", + "metadataRefreshStarted": "正在后台刷新元数据", "note": "注意", "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", @@ -598,7 +607,7 @@ "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", "shareBatchDialogTitle": "分享 %{smart_count} 项 %{resource}", - "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter", + "shareCopyToClipboard": "复制到剪切板: Ctrl+C,Enter", "shareSuccess": "URL 已复制: %{url}", "shareFailure": "URL 复制失败: %{url}", "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", From 18205366c8f1a7b91b209b770f5dc966e824d33e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 16 Sep 2026 07:28:32 -0400 Subject: [PATCH 540/596] fix(jellyfin): match Jellyfin's item payloads so strict clients can sync (#6151) * fix(jellyfin): match Jellyfin's item payloads so strict clients can sync Manet (iOS/macOS) aborted its whole library sync on the first item that was missing a key its decoder requires, leaving the library empty (#6147). Every gap was a field real Jellyfin always sends: - dates now use .NET's round-trip layout with 7 fractional digits, which Manet requires and plain RFC3339 failed - playlists carry SortName/DateCreated, and every item carries MediaType, ImageTags, ChannelId and, when Fields asks, Genres/GenreItems/Tags - albums carry Artists and LocationType; songs always carry HasLyrics - the library view is a full CollectionFolder (ChildCount, DateCreated, SortName, Path, LocationType, UserData), read from the library rows rather than the user projection, which has no counts - IncludeItemTypes matches case-insensitively and returns nothing for Jellyfin kinds Navidrome has none of, instead of falling back to every album Verified against a real Jellyfin 10.10.7 server and a live Manet client. * refactor(jellyfin): fold the repeated empty-list defaults into one helper The three mappers each initialised Genres/GenreItems/Tags the same way, and the e2e suite grew three near-identical specs walking every item type. Both now go through a single helper and one table. * refactor(jellyfin): fill the always-present item fields at the serialization edge The defaults real Jellyfin puts on every item were spread across three mappers, so item types nobody had tested yet (playlists, genres, the library view) still shipped payloads a strict client rejects. stampItem now takes the request's Fields and fills them for every item, which is provably the only path to JSON. Also drops the hand-copied BaseItemKind list: only an absent IncludeItemTypes defaults to albums now, so any type Navidrome does not serve returns nothing, as it would from Jellyfin. The synthetic playlists folder matches case-insensitively like the rest, /Items/{libraryId} reads the full library row instead of the count-less user projection, and PremiereDate and LastPlayedDate go through jellyfinDate rather than spelling the layout out again. --- server/jellyfin/api.go | 3 +- server/jellyfin/dto/dto.go | 39 ++++++++------- server/jellyfin/dto/mappers.go | 48 +++++++++++++++--- server/jellyfin/dto/mappers_test.go | 32 +++++++----- server/jellyfin/e2e/browsing_test.go | 43 ++++++++++++++-- server/jellyfin/e2e/lyrics_test.go | 6 +-- server/jellyfin/items.go | 73 +++++++++++++++++++--------- server/jellyfin/items_test.go | 15 ++++++ server/jellyfin/library.go | 14 ------ server/jellyfin/users.go | 18 +++++-- server/jellyfin/users_test.go | 28 ++++++++++- 11 files changed, 233 insertions(+), 86 deletions(-) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 971f7794a..a4a1ee367 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -213,8 +213,7 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { api.writeItems(w, r, materialized(p)) return case dto.BaseItemDto: - p.ServerId = api.serverID(r.Context()) - payload = p + payload = stampItem(p, api.serverID(r.Context()), requestFields(r)) } w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(payload); err != nil { diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 58a6b919e..6d867ed21 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -49,20 +49,23 @@ type BaseItemDto struct { // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), // distinct from Id so a song appearing more than once can be removed by occurrence // (DELETE .../Items?EntryIds=...) rather than by song id. - PlaylistItemId string `json:"PlaylistItemId,omitempty"` - Type string `json:"Type"` - IsFolder bool `json:"IsFolder"` - MediaType string `json:"MediaType,omitempty"` - CollectionType string `json:"CollectionType,omitempty"` - LocationType string `json:"LocationType,omitempty"` - HasLyrics bool `json:"HasLyrics,omitempty"` - SortName string `json:"SortName,omitempty"` - Path string `json:"Path,omitempty"` - ParentId string `json:"ParentId,omitempty"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - IndexNumber *int `json:"IndexNumber,omitempty"` - ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` - ProductionYear *int `json:"ProductionYear,omitempty"` + PlaylistItemId string `json:"PlaylistItemId,omitempty"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + LocationType string `json:"LocationType,omitempty"` + HasLyrics *bool `json:"HasLyrics,omitempty"` + // ChannelId is always null for music, but Jellyfin emits it on every item and clients may require it. + ChannelId *string `json:"ChannelId"` + Tags []string `json:"Tags,omitzero"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + ParentId string `json:"ParentId,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + IndexNumber *int `json:"IndexNumber,omitempty"` + ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` + ProductionYear *int `json:"ProductionYear,omitempty"` // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. PremiereDate *string `json:"PremiereDate,omitempty"` // DateCreated is the ISO 8601 date the item was added to the library; clients show it as @@ -73,17 +76,17 @@ type BaseItemDto struct { AlbumArtist string `json:"AlbumArtist,omitempty"` AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitempty"` + Artists []string `json:"Artists,omitzero"` ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitempty"` - GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Genres []string `json:"Genres,omitzero"` + GenreItems []NameGuidPair `json:"GenreItems,omitzero"` Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` + ImageTags map[string]string `json:"ImageTags"` // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't // calculating blurhashes. diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 1ebf66ca1..0c9199066 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -46,17 +46,24 @@ func premiereDate(date string, year int) *string { } d = fmt.Sprintf("%04d-01-01", year) } - s := d + "T00:00:00Z" + parsed, err := time.Parse(time.DateOnly, d) + if err != nil { + return nil + } + s := jellyfinDate(&parsed) return &s } -// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the +// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339. +const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00" + +// jellyfinDate formats t as the date string clients expect, or "" for the zero time so the // field is omitted rather than sent as a meaningless epoch. func jellyfinDate(t *time.Time) string { if t == nil || t.IsZero() { return "" } - return t.UTC().Format(time.RFC3339) + return t.UTC().Format(jellyfinDateLayout) } // channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. @@ -127,8 +134,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 d.Rating = &r } - if a.PlayDate != nil { - s := a.PlayDate.UTC().Format(time.RFC3339) + if s := jellyfinDate(a.PlayDate); s != "" { d.LastPlayedDate = &s } return d @@ -146,7 +152,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { MediaType: "Audio", IsFolder: false, LocationType: "FileSystem", - HasLyrics: mf.HasEmbeddedLyrics(), + HasLyrics: new(mf.HasEmbeddedLyrics()), ParentId: albumID, Album: mf.Album, AlbumId: albumID, @@ -251,6 +257,7 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { Id: EncodeID(al.ID), Type: "MusicAlbum", IsFolder: true, + LocationType: "FileSystem", ParentId: EncodeID(al.AlbumArtistID), AlbumArtist: al.AlbumArtist, Album: al.Name, @@ -266,6 +273,10 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { if tag != "" { item.ImageTags = map[string]string{"Primary": tag} } + item.Artists = []string{} + if al.AlbumArtist != "" { + item.Artists = append(item.Artists, al.AlbumArtist) + } if al.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} item.ArtistItems = item.AlbumArtists @@ -321,6 +332,26 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { return item } +// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node. +// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here. +func LibraryToBaseItem(lib model.Library) BaseItemDto { + id := EncodeLibraryID(lib.ID) + return BaseItemDto{ + Id: id, + Name: lib.Name, + SortName: lib.Name, + Type: "CollectionFolder", + CollectionType: "music", + IsFolder: true, + Path: lib.Path, + LocationType: "FileSystem", + DateCreated: jellyfinDate(&lib.CreatedAt), + ChildCount: new(lib.TotalAlbums), + UserData: &UserItemDataDto{Key: id, ItemId: id}, + BackdropImageTags: []string{}, + } +} + func GenreToBaseItem(g model.Genre) BaseItemDto { return BaseItemDto{ Name: g.Name, @@ -354,6 +385,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { MediaType: "Audio", ChildCount: new(p.SongCount), RunTimeTicks: TicksFromSeconds(p.Duration), + DateCreated: jellyfinDate(&p.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -362,6 +394,10 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { if tag != "" { item.ImageTags = map[string]string{"Primary": tag} } + // Playlists have no sort tag; the repository orders them by name. + if fields.Has("SortName") { + item.SortName = p.Name + } return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 576482e61..6dc177363 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -104,10 +104,10 @@ var _ = Describe("mappers", func() { }) It("sets HasLyrics from the media file's lyrics", func() { - Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) - Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) + Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) // "[]" is the no-lyrics sentinel, not a truthy value. - Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) + Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) }) }) @@ -120,7 +120,7 @@ var _ = Describe("mappers", func() { It("sets DateCreated from the media file's CreatedAt", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} - Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) + Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z")) }) It("omits DateCreated when CreatedAt is the zero time", func() { @@ -282,7 +282,7 @@ var _ = Describe("mappers", func() { mf.PlayDate = &playDate item := SongToBaseItem(mf, nil) Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) - Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339))) + Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z")) }) It("maps an album to a MusicAlbum folder item", func() { @@ -299,6 +299,9 @@ var _ = Describe("mappers", func() { Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1"))) Expect(item.ImageBlurHashes).To(BeNil()) + // Jellyfin sends both on every album; Manet stops its sync on an album without them. + Expect(item.Artists).To(Equal([]string{"AA"})) + Expect(item.LocationType).To(Equal("FileSystem")) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) }) @@ -426,22 +429,22 @@ var _ = Describe("mappers", func() { It("serializes a full date", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007} item := SongToBaseItem(mf, nil) - Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) }) It("pads a year-only date so clients can parse it", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z")) }) It("pads a year-month date", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) }) It("falls back to the year when no date tag exists", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z")) }) It("is omitted when the track has no date at all", func() { @@ -449,8 +452,8 @@ var _ = Describe("mappers", func() { }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z")) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z")) Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil()) }) }) @@ -475,6 +478,13 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) + It("emits DateCreated and SortName on playlists, like albums and artists", func() { + p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)} + Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) + Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty()) + Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill")) + }) + It("changes the playlist image tag when the cover content changes", func() { p := model.Playlist{ID: testID("pl-1"), Name: "Chill"} p.ImageHash = "1111111111111111" diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 832b69f25..111e246fd 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -1,6 +1,7 @@ package e2e import ( + "encoding/json" "net/http" "slices" "sort" @@ -110,10 +111,46 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(BeEmpty()) }) - It("defaults to albums when IncludeItemTypes is unrecognized", func() { - q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) + // Manet syncs collections as Boxset, and took albums coming back instead as a sync failure. + DescribeTable("returns nothing for a type it does not serve", + func(itemType string) { + q := queryResult(get("/Items?IncludeItemTypes=" + itemType + "&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }, + Entry("a Jellyfin kind Navidrome has none of", "Boxset"), + Entry("a name Jellyfin does not know either", "Nonsense"), + ) + + // A strict client (Manet) fails its whole sync on the first item missing any of these. + DescribeTable("sends the keys Jellyfin puts on every item", + func(itemType string, fields string, nonNull ...string) { + var body struct{ Items []map[string]json.RawMessage } + res := get("/Items?IncludeItemTypes=" + itemType + "&Fields=" + fields + "&Recursive=true") + Expect(json.Unmarshal(res.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Items).ToNot(BeEmpty()) + for _, it := range body.Items { + Expect(it).To(HaveKey("ChannelId"), "ChannelId is null, but always present") + for _, k := range nonNull { + Expect(it).To(HaveKey(k)) + Expect(string(it[k])).ToNot(Equal("null"), k) + } + } + }, + Entry("songs", "Audio", "Genres,Tags", "ImageTags", "HasLyrics", "Genres", "GenreItems", "Tags"), + Entry("albums", "MusicAlbum", "Genres", "ImageTags", "Genres", "GenreItems"), + Entry("artists", "MusicArtist", "Genres", "ImageTags", "Genres", "GenreItems"), + ) + + It("sends MediaType Unknown on items without one, as Jellyfin always emits it", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.Items { + Expect(it.MediaType).To(Equal("Unknown")) + } + Expect(queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")).Items[0].MediaType).To(Equal("Audio")) }) + }) Describe("ParentId browsing", func() { diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go index a49019bbf..d52a3b00f 100644 --- a/server/jellyfin/e2e/lyrics_test.go +++ b/server/jellyfin/e2e/lyrics_test.go @@ -58,14 +58,14 @@ var _ = Describe("Lyrics", func() { }) Describe("HasLyrics badge", func() { - It("is true for a track with embedded lyrics and omitted/false otherwise", func() { + It("is true for a track with embedded lyrics and false otherwise", func() { var stairway dto.BaseItemDto parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) - Expect(stairway.HasLyrics).To(BeTrue()) + Expect(*stairway.HasLyrics).To(BeTrue()) var soWhat dto.BaseItemDto parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) - Expect(soWhat.HasLyrics).To(BeFalse()) + Expect(*soWhat.HasLyrics).To(BeFalse()) }) }) }) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index d16c5e57e..9ed3d2c8d 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -219,11 +219,40 @@ func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res i api.streamResult(w, r, res, streamItemsArray) } -// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in -// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500. +// stampItem fills in what real Jellyfin puts on every item it returns, so a client that requires a +// key never meets an item without it: ServerId, MediaType, ImageTags, and the Fields-gated lists. +func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto { + it.ServerId = serverID + if it.MediaType == "" { + it.MediaType = "Unknown" + } + if it.ImageTags == nil { + it.ImageTags = map[string]string{} + } + if fields.Has("Genres") { + if it.Genres == nil { + it.Genres = []string{} + } + if it.GenreItems == nil { + it.GenreItems = []dto.NameGuidPair{} + } + } + if fields.Has("Tags") && it.Tags == nil { + it.Tags = []string{} + } + return it +} + +// requestFields parses the Fields param, which gates what stampItem and the mappers attach. +func requestFields(r *http.Request) dto.Fields { + return dto.ParseFields(req.Params(r).Strings("fields")...) +} + +// streamResult stamps every item (see stampItem). The cursor opens before the first byte, so a +// failed open is still a clean 500. func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { - sid := api.serverID(r.Context()) + sid, fields := api.serverID(r.Context()), requestFields(r) seq, err := res.seq() if err != nil { api.internalError(w, r, err) @@ -235,8 +264,7 @@ func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res item yield(dto.BaseItemDto{}, err) return } - it.ServerId = sid - if !yield(it, nil) { + if !yield(stampItem(it, sid, fields), nil) { return } } @@ -380,7 +408,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult case len(q.ids) > 0: return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): + case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"): return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil } if repo, ok := api.playlistTracksRepo(ctx, q); ok { @@ -559,23 +587,26 @@ func parseYears(r *http.Request) []int { return years } -// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to -// {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums). +// supportedTypes maps lowercased IncludeItemTypes names (Jellyfin binds them case-insensitively) +// to the item types Navidrome serves. +var supportedTypes = map[string]string{ + "audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist", +} + +// parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param +// defaults to albums, so ParentId=<artistId> still browses that artist's albums. func parseTypes(types string) []string { + if strings.TrimSpace(types) == "" { + return []string{"MusicAlbum"} + } var recognized []string for t := range strings.SplitSeq(types, ",") { - t = strings.TrimSpace(t) - switch t { - case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist": - recognized = append(recognized, t) + if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok { + recognized = append(recognized, name) } } // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. - recognized = slice.Unique(recognized) - if len(recognized) == 0 { - return []string{"MusicAlbum"} - } - return recognized + return slice.Unique(recognized) } // paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only @@ -828,14 +859,8 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this // the home screen and library tabs 404. if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { - for _, lib := range u.Libraries { - if lib.ID == libID { - return libraryView(lib), true - } - } - // Admin bypass: Libraries is empty but all access is granted, so fetch the real library. if lib, err := api.ds.Library(ctx).Get(libID); err == nil { - return libraryView(*lib), true + return dto.LibraryToBaseItem(*lib), true } } if al, err := api.ds.Album(ctx).Get(id); err == nil { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 17180c36a..c92c011fe 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -981,6 +981,7 @@ var _ = Describe("Items", func() { It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 1, Name: "Music Library"}} + ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs) r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) @@ -1124,6 +1125,20 @@ var _ = Describe("Items", func() { It("dedupes repeated types, preserving first-seen order", func() { Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) }) + + It("matches type names case-insensitively, like Jellyfin's enum binding", func() { + Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"})) + }) + + It("returns no types for real Jellyfin kinds Navidrome has none of", func() { + Expect(parseTypes("Boxset")).To(BeEmpty()) + Expect(parseTypes("BoxSet,Movie")).To(BeEmpty()) + }) + + It("defaults to albums only when IncludeItemTypes is absent", func() { + Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) + Expect(parseTypes("Nonsense")).To(BeEmpty()) + }) }) Describe("decodeFilterParam", func() { diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index b21603843..9f1f7ee73 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -7,7 +7,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" @@ -74,16 +73,3 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer { } return squirrel.Eq{"library_tag.library_id": scope} } - -// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. -// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. -func libraryView(lib model.Library) dto.BaseItemDto { - return dto.BaseItemDto{ - Id: dto.EncodeLibraryID(lib.ID), - Name: lib.Name, - Type: "CollectionFolder", - CollectionType: "music", - IsFolder: true, - BackdropImageTags: []string{}, - } -} diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index bbc60c892..ee4ace48e 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -13,10 +13,20 @@ import ( // getUserViews returns one CollectionFolder view per accessible library, so clients browse each // library as its own top-level view rather than one aggregate. func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { - u, _ := request.UserFrom(r.Context()) - views := make([]dto.BaseItemDto, 0, len(u.Libraries)) - for _, lib := range u.Libraries { - views = append(views, libraryView(lib)) + ctx := r.Context() + u, _ := request.UserFrom(ctx) + // u.Libraries comes from a projection without counts or stats, and clients hide a library that + // looks empty, so the rows are re-read in full here. + libs, err := api.ds.Library(ctx).GetAll() + if err != nil { + api.internalError(w, r, err) + return + } + views := make([]dto.BaseItemDto, 0, len(libs)) + for _, lib := range libs { + if u.HasLibraryAccess(lib.ID) { + views = append(views, dto.LibraryToBaseItem(lib)) + } } api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) } diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 3e53d598f..c6bb99f71 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -18,8 +19,14 @@ import ( var _ = Describe("Users", func() { var api *Router + // The repo holds the full rows; the user carries the id/name-only copy its projection returns. authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) + api.ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs) + stripped := make(model.Libraries, len(libs)) + for i, lib := range libs { + stripped[i] = model.Library{ID: lib.ID, Name: lib.Name} + } + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: stripped}) return r.WithContext(ctx) } BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) @@ -45,6 +52,25 @@ var _ = Describe("Users", func() { Expect(res.Items[1].Name).To(Equal("Podcasts")) }) + // Manet keeps no library, and so syncs no artists/albums/tracks, when these are missing. + It("describes the library like Jellyfin's CollectionFolder", func() { + created := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + libs := model.Libraries{{ID: 1, Name: "Music", Path: "/music", TotalAlbums: 42, CreatedAt: created}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + + item := res.Items[0] + Expect(*item.ChildCount).To(Equal(42)) + Expect(item.DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) + Expect(item.SortName).To(Equal("Music")) + Expect(item.Path).To(Equal("/music")) + Expect(item.LocationType).To(Equal("FileSystem")) + Expect(item.UserData).ToNot(BeNil()) + Expect(item.UserData.ItemId).To(Equal(dto.EncodeLibraryID(1))) + }) + It("returns a single view for a user with one library", func() { libs := model.Libraries{{ID: 1, Name: "Music"}} w := httptest.NewRecorder() From decac50f60133326a54f3f53e6b7e00340966ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 17 Sep 2026 07:29:56 -0400 Subject: [PATCH 541/596] fix(lastfm): double-encode plus signs in artist and track names (#6158) Last.fm decodes the artist and track params of artist.getInfo, artist.getSimilar, artist.getTopTracks and track.getSimilar twice, so a "+" in a name becomes a space. Names like "Florence + The Machine" resolved to a misspelled duplicate page whose bio is Last.fm's "incorrect tag" notice, and names like "+44" were not found at all. Encode "+" as %2B before the normal query encoding for those calls. album.getInfo decodes only once, so it keeps the plain encoding. --- adapters/lastfm/client.go | 16 ++++++++---- adapters/lastfm/client_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index e468aa638..7b2601cf9 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -49,6 +49,12 @@ type client struct { hc httpDoer } +// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space. +// album.getInfo decodes only once, so it must not use this. +func escapePlus(s string) string { + return strings.ReplaceAll(s, "+", "%2B") +} + func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) { params := url.Values{} params.Add("method", "album.getInfo") @@ -66,7 +72,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) { params := url.Values{} params.Add("method", "artist.getInfo") - params.Add("artist", name) + params.Add("artist", escapePlus(name)) params.Add("lang", lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -78,7 +84,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (* func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) { params := url.Values{} params.Add("method", "artist.getSimilar") - params.Add("artist", name) + params.Add("artist", escapePlus(name)) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -90,7 +96,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) ( func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) { params := url.Values{} params.Add("method", "artist.getTopTracks") - params.Add("artist", name) + params.Add("artist", escapePlus(name)) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -102,8 +108,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) { params := url.Values{} params.Add("method", "track.getSimilar") - params.Add("track", name) - params.Add("artist", artist) + params.Add("track", escapePlus(name)) + params.Add("artist", escapePlus(artist)) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { diff --git a/adapters/lastfm/client_test.go b/adapters/lastfm/client_test.go index 271ae1419..f72c84b91 100644 --- a/adapters/lastfm/client_test.go +++ b/adapters/lastfm/client_test.go @@ -35,6 +35,15 @@ var _ = Describe("client", func() { Expect(album.Name).To(Equal("Believe")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) }) + + It("does not double-encode plus signs", func() { + f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine")) + }) }) Describe("artistGetInfo", func() { @@ -48,6 +57,15 @@ var _ = Describe("client", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) }) + It("double-encodes plus signs in the artist name", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine")) + }) + It("fails if Last.fm returns an http status != 200", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), @@ -107,6 +125,15 @@ var _ = Describe("client", func() { Expect(len(similar.Artists)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar")) }) + + It("double-encodes plus signs in the artist name", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.artistGetSimilar(context.Background(), "+44", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44")) + }) }) Describe("artistGetTopTracks", func() { @@ -119,6 +146,15 @@ var _ = Describe("client", func() { Expect(len(top.Track)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks")) }) + + It("double-encodes plus signs in the artist name", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory")) + }) }) Describe("trackGetSimilar", func() { @@ -135,6 +171,17 @@ var _ = Describe("client", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough")) }) + It("double-encodes plus signs in the track and artist names", func() { + f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5) + Expect(err).ToNot(HaveOccurred()) + query := httpClient.SavedRequest.URL.Query() + Expect(query.Get("track")).To(Equal("1%2B1")) + Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers")) + }) + It("returns empty list when no similar tracks found", func() { f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} From 5bd14da65cd6ee1213aed4b5867ffdab17c6e1a3 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 17 Sep 2026 10:59:28 -0400 Subject: [PATCH 542/596] test(artwork): cover artist folder lookup for a single album without images Add an e2e spec for an artist whose only album folder has no images of its own, while the artist folder holds folder.jpg (plus unrelated images) and ArtistArtPriority starts with folder.*. Before #5856, the album's parent was promoted into the album paths, so the artist folder resolved to the library root and the artist got no image. The spec fails if that promotion comes back, and passes on current code. Refs #5823 --- core/artwork/e2e/artist_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index 5a9396cc3..4a6959d95 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -90,6 +90,28 @@ var _ = Describe("Artist artwork resolution", func() { }) }) + When("the artist's only album folder has no images of its own", func() { + // Artist/ + // ├── backdrop1.jpg + // ├── folder.jpg ← matched by folder.* + // ├── logo.png + // └── Album/ + // ├── 01 - Track.mp3 + // └── 02 - Track.mp3 + It("resolves the artist folder, not the library root", func() { + conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/backdrop1.jpg": smallPNG("backdrop"), + "Artist/folder.jpg": smallPNG("artist-folder"), + "Artist/logo.png": smallPNG("logo"), + }) + scan() + expectArtistFolder(soleArtist(), "Artist/folder.jpg") + }) + }) + When("the artist's only album has its tracks in disc subfolders", func() { // Artist/ // ├── artist.jpg ← wins (artist.* before album/artist.*) From ded4f47d93d6854f284056cbecfa33e5a35af003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 17 Sep 2026 17:17:56 -0400 Subject: [PATCH 543/596] fix(release): repair root-owned artwork and plugins folders on upgrade (#6143) * fix(release): repair root-owned artwork and plugins folders on upgrade Navidrome 0.57.0, 0.60.x and 0.61.x created the plugins and artwork folders as soon as the configuration loaded. The deb/rpm postinstall script runs navidrome as root, so fresh installs and upgrades on those versions left these folders owned by root. The service runs as the navidrome user and cannot write to them. Since 0.64.0 new artwork is stored under artwork/hashed, so affected installs fail to persist artwork and cannot read the plugins folder. The postinstall script now changes the owner of these two folders to navidrome, only when they exist and are owned by root. The change is not recursive: root created the folders empty and the service could never write inside them, so fixing the folder itself is enough and stays instant regardless of how much artwork exists. Folders an admin assigned to another user are left untouched. Fixes #6140 * fix(release): handle root-owned cache folder without install noise The postinstall script ran an unconditional chown on /var/lib/navidrome/cache during fresh installs. Since folders are created lazily, the cache folder does not exist at that point, so every fresh deb/rpm install printed "chown: cannot access '/var/lib/navidrome/cache': No such file or directory". The cache folder is now part of the same root-owned folder check used for artwork and plugins: it is fixed when it exists and is owned by root, and skipped silently otherwise. This also covers installs from 0.54.1 and 0.54.2, which created the cache folder as root before the chown was added. * fix(release): never follow symlinks when repairing folder ownership The ownership check used find's default -P mode, so -user root tested a symlink itself, while chown dereferenced it. A root-owned symlink pointing to a folder owned by another account made the postinstall script reassign that folder to navidrome, bypassing the root-owner guard. The check now only matches real directories (-type d without following links) and uses chown -h. Following the link with find -H was rejected: /var/lib/navidrome is owned by navidrome, so the service account could plant a symlink to any root-owned directory and have the next upgrade hand it over. As a trade-off, a symlink to a root-owned folder is no longer repaired; the folders affected by the original bug were always real directories. --- release/linux/postinstall.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh index ed39fb127..007114ef5 100644 --- a/release/linux/postinstall.sh +++ b/release/linux/postinstall.sh @@ -13,15 +13,16 @@ if [ ! -f /etc/navidrome/navidrome.toml ]; then printf "MusicFolder = \"/opt/navidrome/music\"\n" >> /etc/navidrome/navidrome.toml fi +# Older versions created these folders as root when this script ran `navidrome`. They were created empty, +# so no -R. Real dirs only, never following links: navidrome owns /var/lib/navidrome and could plant symlinks. +find /var/lib/navidrome/cache /var/lib/navidrome/artwork /var/lib/navidrome/plugins -maxdepth 0 -type d -user root -exec chown -h navidrome:navidrome {} + 2>/dev/null + postinstall_flag="/var/lib/navidrome/.installed" if [ ! -f "$postinstall_flag" ]; then # The primary reason why this would fail is if the service was already installed AND # someone manually removed the .installed flag. In this case, ignore the error navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || : - # Any `navidrome` command will make a cache. Make sure that this is properly owned by the Navidrome user - # and not by root - chown navidrome:navidrome /var/lib/navidrome/cache touch "$postinstall_flag" else navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml From 16567f147b6a57f9cdfc4fe37c4577cf0a4b1d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 17 Sep 2026 23:48:39 -0400 Subject: [PATCH 544/596] fix(jellyfin): match Jellyfin on login SessionInfo, item types and universal streams (#6161) * fix(jellyfin): send SessionInfo on login so JellyBox gets past sign-in JellyBox parses AuthenticateByName's SessionInfo as a required object and fails silently when it is missing, leaving the user on the login screen. Real Jellyfin always sends it (SessionManager.AuthenticateNewSessionInternal, 10.10.7 and master), so the login response now carries a full SessionInfo built from the user and the MediaBrowser auth header. It includes every field JellyBox (Id, PlayState) and Finamp (UserId, LastActivityDate, the activity and control bools, PlayState's CanSeek/IsPaused/IsMuted) require once the object is present. The session Id is derived from client and device id, so repeated logins from one install share it. * fix(jellyfin): ignore IncludeItemTypes names that aren't Jellyfin kinds JellyBox opens an album with ParentId=<album>&IncludeItemTypes=music. Music is not a BaseItemKind, and Jellyfin's comma-delimited binder drops values it cannot parse, so real Jellyfin treats the request as having no type filter and lists the album's tracks. Navidrome returned an empty list, so every album opened empty. Entries that aren't BaseItemKind names are now dropped before type resolution, so an all-unknown list behaves like an absent one. Real kinds Navidrome doesn't serve, such as Boxset, still return nothing. * fix(jellyfin): treat universal Container as the direct-play list On /Audio/{id}/universal, Container lists the "container|codec" entries the client can direct play, and TranscodingContainer/AudioCodec name the target when it can't (UniversalAudioController builds DirectPlayProfiles from it). Navidrome passed the whole list to the decider as one target format, which matched nothing and fell back to DefaultDownsamplingFormat, so JellyBox got every MP3 transcoded to Opus. /universal now has its own handler: a source matching an entry keeps its format (still downsampled under a bitrate cap), anything else is transcoded to TranscodingContainer, then AudioCodec. The /stream routes keep treating Container as the target format. * refactor(jellyfin): let the stream decider resolve universal requests streamUniversal matched the Container list itself with plain string equality and then asked the legacy resolver for the source format. That skipped the decider's container and codec aliases (mp4 vs m4a, ogg vs opus), and a direct-playable source over the bitrate cap was transcoded to its own format instead of the client's TranscodingContainer. The shared part of ResolveRequest (server-side player override, player MaxBitRate cap, decision to Request mapping) moves to a resolve helper, and a new ResolveClientRequest exposes it for callers that build their own ClientInfo. streamUniversal now turns Container into DirectPlayProfiles and TranscodingContainer/AudioCodec into a transcoding profile, so the decision uses the same rules as the Subsonic getTranscodeDecision path. streamFile and the /stream routes share a serveStream helper, NewSessionInfo reads the clock itself, and duplicate comments and tests are trimmed. --- core/stream/decider.go | 1 + core/stream/legacy_client.go | 77 ++++++++++++++++----------- core/stream/legacy_client_test.go | 69 ++++++++++++++++++++++++ server/jellyfin/api.go | 2 +- server/jellyfin/auth.go | 9 ++-- server/jellyfin/auth_test.go | 50 ++++++++++++++++-- server/jellyfin/dto/dto.go | 32 ++++++++++-- server/jellyfin/dto/mappers.go | 23 ++++++++ server/jellyfin/e2e/browsing_test.go | 7 ++- server/jellyfin/items.go | 25 ++++++++- server/jellyfin/items_test.go | 17 ++++++ server/jellyfin/stream.go | 78 ++++++++++++++++++++-------- server/jellyfin/stream_test.go | 47 +++++++++++++++++ server/subsonic/transcode_test.go | 4 ++ 14 files changed, 375 insertions(+), 66 deletions(-) diff --git a/core/stream/decider.go b/core/stream/decider.go index 3c6b01e05..886898206 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -23,6 +23,7 @@ type TranscodeDecider interface { CreateTranscodeParams(decision *TranscodeDecision) (string, error) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request + ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request } func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 652e42eba..5b732c3e2 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -73,37 +73,8 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) - - // Apply server-side player transcoding override before making the decision - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok { - modified := *clientInfo - if modified.CapBitrate(player.MaxBitRate) { - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - - decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) - if err != nil { - log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) - req.Format = "raw" - return req - } - - if decision.CanDirectPlay { - req.Format = "raw" - return req - } - - if decision.CanTranscode { - req.Format = decision.TargetFormat - req.BitRate = decision.TargetBitrate - req.SampleRate = decision.TargetSampleRate - req.BitDepth = decision.TargetBitDepth - req.Channels = decision.TargetChannels - return req + if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok { + return resolved } // No compatible profile for the requested format — retry with DefaultDownsamplingFormat @@ -119,3 +90,47 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile req.Format = "raw" return req } + +// ResolveClientRequest resolves a stream request for a client that declared its own direct play +// and transcoding profiles, falling back to raw when none fits. +func (s *deciderService) ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request { + if req, ok := s.resolve(ctx, mf, clientInfo, offset); ok { + return req + } + return Request{Format: "raw", Offset: offset} +} + +// resolve applies the server-side player overrides to clientInfo and maps the decision to a +// Request. ok is false when no profile fits. +func (s *deciderService) resolve(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) (Request, bool) { + req := Request{Offset: offset} + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok { + modified := *clientInfo + if modified.CapBitrate(player.MaxBitRate) { + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + } + + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req, true + } + switch { + case decision.CanDirectPlay: + req.Format = "raw" + case decision.CanTranscode: + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + default: + return req, false + } + return req, true +} diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index bc8405976..ad3417faf 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -414,3 +414,72 @@ var _ = Describe("ResolveRequest", func() { }) }) }) + +var _ = Describe("ResolveClientRequest", func() { + var ( + svc TranscodeDecider + ctx context.Context + ) + mp3Target := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds := &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + auth.Init(ds) + svc = NewTranscodeDecider(ds, tests.NewMockFFmpeg("")) + }) + + It("direct plays a source matching a profile through container aliases", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 5) + + Expect(req.Format).To(Equal("raw")) + Expect(req.Offset).To(Equal(5)) + }) + + It("transcodes to the client's transcoding profile when no direct play profile matches", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp3"}}}, + TranscodingProfiles: mp3Target, + } + + Expect(svc.ResolveClientRequest(ctx, mf, ci, 0).Format).To(Equal("mp3")) + }) + + It("transcodes a direct-playable source over the bitrate cap to the client's target", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := &ClientInfo{ + MaxAudioBitrate: 128, + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("applies the player's MaxBitRate cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ctx = request.WithPlayer(ctx, model.Player{MaxBitRate: 192}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index a4a1ee367..839c43ea5 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -165,7 +165,7 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/stream", api.streamAudio) r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamUniversal) r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index e7070d341..bfcfab319 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -42,12 +42,13 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { return } - // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's - // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + a := parseMediaBrowserAuth(r) + serverID := api.serverID(ctx) api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, api.serverName(), api.serverID(ctx)), + User: userToDto(usr, api.serverName(), serverID), + SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), AccessToken: token, - ServerId: api.serverID(ctx), + ServerId: serverID, }) } diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index 577677643..0821614d6 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -48,10 +48,54 @@ var _ = Describe("AuthenticateByName", func() { Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) Expect(res.User.Configuration).ToNot(BeNil()) + }) - // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's - // login doesn't require it, so it should be omitted entirely rather than sent partial. - Expect(res.SessionInfo).To(BeNil()) + Describe("SessionInfo", func() { + login := func(authHeader string) map[string]any { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + r.Header.Set("Authorization", authHeader) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var raw map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) + Expect(raw).To(HaveKey("SessionInfo")) + return raw["SessionInfo"].(map[string]any) + } + const jellybox = `MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-1", Version="2.1"` + + It("has the fields strict clients require", func() { + s := login(jellybox) + Expect(s["Id"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) + Expect(s["UserId"]).To(Equal(dto.EncodeID(testID("u1")))) + Expect(s["LastActivityDate"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) + for _, k := range []string{"SupportsRemoteControl", "SupportsMediaControl", "HasCustomDeviceName"} { + Expect(s[k]).To(BeAssignableToTypeOf(false), k) + } + ps, ok := s["PlayState"].(map[string]any) + Expect(ok).To(BeTrue()) + for _, k := range []string{"CanSeek", "IsPaused", "IsMuted"} { + Expect(ps[k]).To(BeAssignableToTypeOf(false), k) + } + }) + + It("describes the calling client", func() { + s := login(jellybox) + Expect(s).To(HaveKeyWithValue("UserName", "alice")) + Expect(s).To(HaveKeyWithValue("Client", "JellyBox")) + Expect(s).To(HaveKeyWithValue("DeviceName", "Mac")) + Expect(s).To(HaveKeyWithValue("DeviceId", "dev-1")) + Expect(s).To(HaveKeyWithValue("ApplicationVersion", "2.1")) + Expect(s).To(HaveKeyWithValue("IsActive", true)) + }) + + It("keeps the same Id for the same device, and a new one for another device", func() { + first := login(jellybox)["Id"] + Expect(login(jellybox)["Id"]).To(Equal(first)) + other := login(`MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-2", Version="2.1"`) + Expect(other["Id"]).ToNot(Equal(first)) + }) }) It("records the login time, like the web UI login does", func() { diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 6d867ed21..21e3903f2 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -202,14 +202,40 @@ type UserConfiguration struct { CastReceiverId string `json:"CastReceiverId"` } +// SessionInfo mirrors real Jellyfin's SessionInfoDto. JellyBox requires Id and PlayState, and Finamp +// requires UserId, LastActivityDate and the bools, so none of those may be omitted. type SessionInfo struct { - Id string `json:"Id"` - UserId string `json:"UserId"` + Id string `json:"Id"` + UserId string `json:"UserId"` + UserName string `json:"UserName"` + Client string `json:"Client"` + DeviceId string `json:"DeviceId"` + DeviceName string `json:"DeviceName"` + ApplicationVersion string `json:"ApplicationVersion"` + ServerId string `json:"ServerId"` + LastActivityDate string `json:"LastActivityDate"` + IsActive bool `json:"IsActive"` + SupportsMediaControl bool `json:"SupportsMediaControl"` + SupportsRemoteControl bool `json:"SupportsRemoteControl"` + HasCustomDeviceName bool `json:"HasCustomDeviceName"` + PlayableMediaTypes []string `json:"PlayableMediaTypes"` + SupportedCommands []string `json:"SupportedCommands"` + AdditionalUsers []any `json:"AdditionalUsers"` + NowPlayingQueue []any `json:"NowPlayingQueue"` + PlayState PlayerStateInfo `json:"PlayState"` +} + +type PlayerStateInfo struct { + CanSeek bool `json:"CanSeek"` + IsPaused bool `json:"IsPaused"` + IsMuted bool `json:"IsMuted"` + RepeatMode string `json:"RepeatMode"` + PlaybackOrder string `json:"PlaybackOrder"` } type AuthenticationResult struct { User *UserDto `json:"User"` - SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + SessionInfo *SessionInfo `json:"SessionInfo"` AccessToken string `json:"AccessToken"` ServerId string `json:"ServerId"` } diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 0c9199066..bb17b40c7 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" ) @@ -446,3 +447,25 @@ func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { } return d } + +// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins. +func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo { + now := time.Now() + return &SessionInfo{ + Id: EncodeID(id.NewHash(client, deviceID)), + UserId: EncodeID(u.ID), + UserName: u.UserName, + Client: client, + DeviceId: deviceID, + DeviceName: deviceName, + ApplicationVersion: version, + ServerId: serverID, + LastActivityDate: jellyfinDate(&now), + IsActive: true, + PlayableMediaTypes: []string{"Audio"}, + SupportedCommands: []string{}, + AdditionalUsers: []any{}, + NowPlayingQueue: []any{}, + PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"}, + } +} diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 111e246fd..68d87cd25 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -119,9 +119,14 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(BeEmpty()) }, Entry("a Jellyfin kind Navidrome has none of", "Boxset"), - Entry("a name Jellyfin does not know either", "Nonsense"), ) + It("treats a name Jellyfin does not know as an absent IncludeItemTypes", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(queryResult(get("/Items?Recursive=true")).TotalRecordCount)) + Expect(q.TotalRecordCount).To(BeNumerically(">", 0)) + }) + // A strict client (Manet) fails its whole sync on the first item missing any of these. DescribeTable("sends the keys Jellyfin puts on every item", func(itemType string, fields string, nonNull ...string) { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 9ed3d2c8d..124d17920 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -349,7 +349,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ } q := listParams(p) q.ids = ids - q.rawTypes = p.StringOr("includeitemtypes", "") + q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", "")) q.parentId = parentId q.genreIds = genreIds q.albumIds = albumIds @@ -593,6 +593,29 @@ var supportedTypes = map[string]string{ "audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist", } +// jellyfinItemKinds lists Jellyfin's BaseItemKind names, lowercased. +var jellyfinItemKinds = map[string]bool{ + "aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true, + "boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true, + "folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true, + "livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true, + "person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true, + "program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true, + "tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true, +} + +// knownItemKinds drops IncludeItemTypes entries that aren't BaseItemKind names, as Jellyfin's binder +// does, so an all-unknown list (JellyBox sends "music") behaves like an absent one. +func knownItemKinds(types string) string { + var known []string + for t := range strings.SplitSeq(types, ",") { + if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] { + known = append(known, t) + } + } + return strings.Join(known, ",") +} + // parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param // defaults to albums, so ParentId=<artistId> still browses that artist's albums. func parseTypes(types string) []string { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index c92c011fe..a8dfb7bb2 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -74,6 +74,18 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) + It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) + }) + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, @@ -1135,6 +1147,11 @@ var _ = Describe("Items", func() { Expect(parseTypes("BoxSet,Movie")).To(BeEmpty()) }) + It("drops names that aren't Jellyfin item kinds, case-insensitively", func() { + Expect(knownItemKinds("music")).To(BeEmpty()) + Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO")) + }) + It("defaults to albums only when IncludeItemTypes is absent", func() { Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) Expect(parseTypes("Nonsense")).To(BeEmpty()) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 87660056c..c6dc0c80e 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -1,6 +1,7 @@ package jellyfin import ( + "cmp" "fmt" "math" "net/http" @@ -10,6 +11,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -65,16 +67,14 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } -// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, -// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +// streamAudio serves /Audio/{itemId}/stream[.container], reusing the same transcode-decision + +// streaming pipeline as the Subsonic /stream endpoint. func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { mf, ok := api.mediaFileForRequest(w, r) if !ok { return } - ctx := r.Context() p := req.Params(r) - format := p.StringOr("container", "") if format == "" { // The /stream.{container} route form carries the format as a path segment, not a query param. @@ -84,17 +84,61 @@ func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { // Jellyfin's audioCodec param names the target codec when no container is given. format = p.StringOr("audiocodec", "") } + api.serveAudio(w, r, mf, format) +} + +// streamUniversal serves /Audio/{itemId}/universal, where Container lists the "container|codec" +// entries the client direct-plays, and TranscodingContainer/AudioCodec name the fallback target. +func (api *Router) streamUniversal(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + var streamReq stream.Request if p.BoolOr("static", false) { + streamReq = api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0) + } else { + streamReq = api.transcodeDecider.ResolveClientRequest(r.Context(), mf, universalClientInfo(p), 0) + } + api.serveStream(w, r, mf, streamReq) +} + +func universalClientInfo(p *req.Values) *stream.ClientInfo { + ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)} + for entry := range strings.SplitSeq(p.StringOr("container", ""), ",") { + container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|") + if container == "" { + continue + } + profile := stream.DirectPlayProfile{Containers: []string{container}, Protocols: []string{stream.ProtocolHTTP}} + if codec != "" { + profile.AudioCodecs = []string{codec} + } + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, profile) + } + codec := p.StringOr("audiocodec", "") + if container := cmp.Or(p.StringOr("transcodingcontainer", ""), codec); container != "" { + ci.TranscodingProfiles = []stream.Profile{{Container: container, AudioCodec: cmp.Or(codec, container), Protocol: stream.ProtocolHTTP}} + ci.MaxTranscodingAudioBitrate = ci.MaxAudioBitrate + } + return ci +} + +// bitRateParam reads Jellyfin's bits/sec bitrate params as the kbps the stream package expects. +func bitRateParam(p *req.Values) int { + return cmp.Or(p.IntOr("audiobitrate", 0), p.IntOr("maxstreamingbitrate", 0)) / 1000 +} + +func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, format string) { + if req.Params(r).BoolOr("static", false) { format = "raw" } + api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, format, bitRateParam(req.Params(r)), 0)) +} - // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. - bitRate := p.IntOr("audiobitrate", 0) / 1000 - if bitRate == 0 { - bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 - } - - streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) +func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { + ctx := r.Context() s, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { api.internalError(w, r, err) @@ -160,15 +204,5 @@ func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { if !ok { return } - ctx := r.Context() - streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) - s, err := api.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - api.internalError(w, r, err) - return - } - defer s.Close() - if _, err := s.Serve(ctx, w, r); err != nil { - log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) - } + api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0)) } diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index d6220d33f..3cd692c1b 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -244,6 +244,45 @@ var _ = Describe("Stream", func() { }) }) + Describe("streamUniversal", func() { + universal := func(query string) { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: testID("s1"), Suffix: "mp3", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/universal?"+query, nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) + invoke(api.streamUniversal, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("turns Container into direct play profiles and TranscodingContainer into the target", func() { + universal("Container=mp3,m4a|aac&TranscodingContainer=m4a&AudioCodec=aac&MaxStreamingBitrate=128000") + Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}}, + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}}, + })) + Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ + {Container: "m4a", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, + })) + Expect(decider.client.MaxAudioBitrate).To(Equal(128)) + Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + + It("uses AudioCodec as the target when no TranscodingContainer is given", func() { + universal("Container=mp3&AudioCodec=aac") + Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, + })) + }) + + It("serves the file as is for static=true", func() { + universal("static=true&Container=ogg") + Expect(decider.req.Format).To(Equal("raw")) + Expect(decider.client).To(BeNil()) + }) + }) + Describe("streamHls", func() { BeforeEach(func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -364,6 +403,7 @@ var _ = Describe("Stream", func() { type fakeTranscodeDecider struct { invoked bool req stream.Request + client *stream.ClientInfo } func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { @@ -384,6 +424,13 @@ func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaF return f.req } +func (f *fakeTranscodeDecider) ResolveClientRequest(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, offset int) stream.Request { + f.invoked = true + f.client = ci + f.req = stream.Request{Offset: offset} + return f.req +} + // fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether // NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed // by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 0f3c24832..1f2fdfcef 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -625,6 +625,10 @@ func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.Media return stream.Request{Format: "raw"} } +func (m *mockTranscodeDecision) ResolveClientRequest(context.Context, *model.MediaFile, *stream.ClientInfo, int) stream.Request { + return stream.Request{Format: "raw"} +} + func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { return m.token, m.tokenErr } From 549dfa7f3027a325905791626820a46f2b0afb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 19 Sep 2026 13:19:48 -0400 Subject: [PATCH 545/596] feat(jellyfin): advertise Jellyfin 12.1.0 and add the missing 12.x quick wins (#6163) * feat(jellyfin): advertise Jellyfin server version 12.1.0 Streamyfin, jellyfin-android and jellyfin-androidtv refuse servers older than 10.10, Swiftfin warns below 12.0, and @jellyfin/sdk flags anything below its minimum as unsupported, so 10.9.11 locked those clients out. 12.1.0 is the current Jellyfin release (after 10.11 Jellyfin renumbered to 12.0). No client checked has an upper bound or assumes the major is 10, and the value keeps three parts because the Kotlin SDK and Swiftfin reject two-part versions. * feat(jellyfin): acknowledge POST /Sessions/Playing/Ping Jellyfin clients ping this endpoint to keep a transcode job alive while paused. Navidrome ties transcodes to the stream request, so there is nothing to keep alive; answer 204 like Jellyfin instead of a 404. It shares one no-op handler with Sessions/Capabilities, renamed to acknowledge. * feat(jellyfin): reorder playlist entries via Items/{entryId}/Move Adds POST /Playlists/{id}/Items/{entryId}/Move/{newIndex}, which clients use to reorder playlists. It maps the entry's PlaylistItemId (its position) and Jellyfin's zero-based newIndex onto the existing core ReorderTrack, which enforces ownership. As in Jellyfin, an index past the end appends and an unknown entry is a no-op; out-of-range positions never reach Reorder, which would otherwise shift unrelated rows. * feat(jellyfin): honor position when adding items to a playlist POST /Playlists/{id}/Items takes an optional zero-based position (added to the Jellyfin spec in 12.0). Match Jellyfin: zero or negative prepends, past the end appends, otherwise the new items are inserted at that index in the order they were added. Adds PlaylistTrackRepository.Insert and core playlists.InsertTracks, which shift the following entries and insert in one transaction, instead of appending and moving each new track with its own ReorderTrack call. * feat(jellyfin): add type-specific InstantMix routes Jellyfin exposes InstantMix under Songs/, Albums/, Artists/ and Playlists/ as well as Items/, plus the legacy Artists/InstantMix and MusicGenres/InstantMix forms that take the seed as ?id=. Clients generated from the Jellyfin SDKs call the type-specific routes, which 404ed. All of them now share the existing Items/{id}/InstantMix handler; the external provider already builds mixes from song, album, artist, playlist and genre seeds. * feat(jellyfin): honor Width, Height and Fill* image size params The image endpoint only read MaxWidth/MaxHeight, so clients that size covers with fillWidth/fillHeight (Manet, Finamp) or width/height got the full-size original on a cold artwork cache: a 578 KB PNG instead of a 13 KB resize. Jellyfin applies Width/Height, caps them with MaxWidth/MaxHeight, then shrinks to the smallest size that still covers the Fill box. Navidrome resizes on one dimension, so the tightest bound wins and a fill box counts as its larger side. * feat(jellyfin): answer HEAD on audio, file and image routes Fintunes sends HEAD to /Audio/{id}/universal to read the content type and detect direct play (a Content-Length means direct play), and to the audio and image URLs before a download, aborting the download when it fails. Those routes were GET-only, so HEAD got a 404. HEAD now reuses the GET handlers. Direct play goes through Stream.Serve, which already answers HEAD; a transcode answers with the target content type and no length without starting ffmpeg, so a probe never costs a transcode. * fix(jellyfin): clamp playlist move and insert positions before adding one movePlaylistItem computed min(newIndex+1, SongCount): newIndex=MaxInt wrapped to a negative position, and Reorder then left the playlist with a gap (positions 2, 3, 999998), making the moved entry unmovable. Clamp against the playlist length first. addToPlaylist's min(position, MaxInt32)+1 wrapped on 32-bit builds and req.Int truncated large values there, so a far-past-the-end position prepended; parse as int64 and clamp before converting. * fix(playlists): validate reorder positions inside the write transaction Reorder never checked its positions, so a source outside the playlist or a destination past its end shifted rows around a missing entry and left a gap (e.g. ids 1 and 3), which made the moved entry unmovable afterwards. The Jellyfin Move handler guarded this with a SongCount read before ReorderTrack, but a concurrent removal between the two reopened it, and the native API reorder endpoint passed client positions through unchecked. Reorder now reads the last position in the same transaction, returns ErrNotFound for a source outside the playlist and clamps the destination. ReorderTrack uses an immediate transaction so that read and the updates are atomic against other writers. The Jellyfin handler drops its pre-check and maps ErrNotFound to Jellyfin's no-op 204; the native API now answers 404 for an unknown track instead of corrupting the order. --- core/playlists/playlists.go | 19 +++- core/playlists/playlists_test.go | 37 +++++++ model/playlist.go | 1 + persistence/playlist_track_repository.go | 35 ++++++- persistence/playlist_track_repository_test.go | 99 +++++++++++++++++++ server/jellyfin/README.md | 10 +- server/jellyfin/api.go | 20 +++- server/jellyfin/e2e/images_test.go | 4 + server/jellyfin/e2e/playlists_test.go | 83 ++++++++++++++++ server/jellyfin/e2e/sessions_test.go | 4 + server/jellyfin/e2e/similar_test.go | 29 ++++++ server/jellyfin/e2e/streaming_test.go | 14 +++ server/jellyfin/images.go | 20 ++-- server/jellyfin/images_test.go | 11 ++- server/jellyfin/playlists.go | 52 +++++++++- server/jellyfin/playlists_test.go | 12 +++ server/jellyfin/sessions.go | 6 +- server/jellyfin/sessions_test.go | 4 +- server/jellyfin/similar.go | 17 +++- server/jellyfin/stream.go | 9 ++ server/jellyfin/stream_test.go | 30 ++++++ server/jellyfin/system.go | 7 +- tests/mock_playlist_track_repo.go | 6 ++ 23 files changed, 497 insertions(+), 32 deletions(-) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 9ae8f09cb..1f8cc9581 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -32,6 +32,7 @@ type Playlists interface { // Track management AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) + InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) @@ -268,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids) } +// InsertTracks adds tracks before the 1-based position pos; a position past the end appends. +func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + var count int + // Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under + // concurrent writers instead of waiting for the lock. + err := s.ds.WithTxImmediate(func(tx model.DataStore) error { + var err error + count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos) + return err + }) + return count, err +} + func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err @@ -302,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return err } - return s.ds.WithTx(func(tx model.DataStore) error { + return s.ds.WithTxImmediate(func(tx model.DataStore) error { return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) }) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index dd182b3f1..dd6213555 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -324,6 +324,43 @@ var _ = Describe("Playlists", func() { }) }) + Describe("InsertTracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + }) + + It("inserts the tracks at the given position for the owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(2)) + Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"})) + Expect(mockTracks.InsertPos).To(Equal(3)) + }) + + It("denies non-owner, non-admin", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + _, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(mockTracks.AddedIds).To(BeEmpty()) + }) + + It("denies editing smart playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + }) + Describe("ReorderTrack", func() { var mockTracks *tests.MockPlaylistTrackRepo diff --git a/model/playlist.go b/model/playlist.go index abd11b8c4..f320d845b 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -184,6 +184,7 @@ type PlaylistTrackRepository interface { GetAlbumIDs(options ...QueryOptions) ([]string, error) GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) + Insert(mediaFileIds []string, pos int) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) AddDiscs(discs []DiscID) (int, error) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index cf1b8f3fa..847316868 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -195,6 +195,29 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) } +// Insert adds tracks before the 1-based position pos, shifting the following entries down; a +// position past the end appends. Callers must run it in a transaction. +func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) { + if len(mediaFileIds) == 0 { + return 0, nil + } + pos = max(pos, 1) + n := len(mediaFileIds) + // Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index. + _, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) + if err != nil { + return 0, err + } + res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) + if err != nil { + return 0, err + } + if res == 0 { + return r.Add(mediaFileIds) + } + return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) +} + func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number") var ids []string @@ -247,8 +270,18 @@ func (r *playlistTrackRepository) DeleteAll() error { return r.playlistRepo.renumber(r.playlistId) } -// Reorder moves a track from pos to newPos, shifting other tracks accordingly. +// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped +// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap. func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { + var res struct{ Max sql.NullInt32 } + if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { + return err + } + last := int(res.Max.Int32) + if pos < 1 || pos > last { + return model.ErrNotFound + } + newPos = min(max(newPos, 1), last) if pos == newPos { return nil } diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 88ddbff48..e0360adb4 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -78,6 +78,105 @@ var _ = Describe("PlaylistTrackRepository", func() { }) }) + Describe("Insert", func() { + var tracks model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) + }) + + order := func() []string { + ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + return ids + } + + DescribeTable("inserts before a 1-based position, keeping the new tracks' order", + func(pos int, want func() []string) { + Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) + Expect(order()).To(Equal(want())) + Expect(tracks.CountAll()).To(Equal(int64(4))) + }, + Entry("in the middle", 2, func() []string { + return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID} + }), + Entry("at the start, for zero or less", 0, func() []string { + return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID} + }), + Entry("at the end, past the last position", 9, func() []string { + return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID} + }), + ) + + It("renumbers positions contiguously", func() { + Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1)) + all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) + }) + }) + + Describe("Reorder", func() { + var tracks model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) + }) + + rows := func() ([]string, []string) { + all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + var ids, songs []string + for _, t := range all { + ids = append(ids, t.ID) + songs = append(songs, t.MediaFileID) + } + return ids, songs + } + + DescribeTable("clamps the destination to the playlist", + func(newPos int, want func() []string) { + Expect(tracks.Reorder(1, newPos)).To(Succeed()) + ids, songs := rows() + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(songs).To(Equal(want())) + }, + Entry("past the end moves to the end", 9, func() []string { + return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID} + }), + Entry("below 1 stays first", -4, func() []string { + return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID} + }), + ) + + DescribeTable("rejects a source position outside the playlist, leaving rows untouched", + func(pos int) { + Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound)) + ids, songs := rows() + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})) + }, + Entry("past the end", 4), + Entry("zero", 0), + ) + }) + Describe("Delete", func() { var tracks model.PlaylistTrackRepository const numTracks = deleteChunkSize*2 + 1 diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 5b9a2dba4..a20c9ce4e 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -123,13 +123,13 @@ returns direct children only (no tracks — no track is a library's direct child | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | | Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | | Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | -| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` | +| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | | Lyrics | `GET Audio/{itemId}/Lyrics` | -| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | -| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | +| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` | +| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` | | Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | | AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | @@ -350,7 +350,7 @@ make test PKG=./server/jellyfin/... just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version - ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. + ≥ 10.9 (`jellyfinVersion` advertises 12.1.0). Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` is singleflighted), and the load runs detached from the request context with a one-minute bound, so a cancelled request or hung plugin can't fail or pin the load for other waiters. diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 839c43ea5..955ebc3bb 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -93,6 +93,8 @@ func (api *Router) routes() http.Handler { conf.Server.DevArtworkThrottleBacklogTimeout)) r.Get("/items/{itemId}/images/{type}", api.getItemImage) r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) + r.Head("/items/{itemId}/images/{type}", api.getItemImage) + r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage) }) inner.Group(func(r chi.Router) { @@ -145,6 +147,12 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/similar", api.getSimilarItems) r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/songs/{itemId}/instantmix", api.getInstantMix) + r.Get("/albums/{itemId}/instantmix", api.getInstantMix) + r.Get("/artists/{itemId}/instantmix", api.getInstantMix) + r.Get("/playlists/{itemId}/instantmix", api.getInstantMix) + r.Get("/artists/instantmix", api.getInstantMixByQuery) + r.Get("/musicgenres/instantmix", api.getInstantMixByQuery) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) r.Get("/studios", api.getStudios) @@ -155,6 +163,7 @@ func (api *Router) routes() http.Handler { r.Post("/playlists/{playlistId}", api.updatePlaylist) r.Post("/playlists/{playlistId}/items", api.addToPlaylist) r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem) r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) @@ -166,6 +175,10 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/stream", api.streamAudio) r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) r.Get("/audio/{itemId}/universal", api.streamUniversal) + // Fintunes probes these with HEAD for the content type before playing or downloading. + r.Head("/audio/{itemId}/stream", api.streamAudio) + r.Head("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Head("/audio/{itemId}/universal", api.streamUniversal) r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) @@ -174,12 +187,15 @@ func (api *Router) routes() http.Handler { // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. r.Get("/items/{itemId}/file", api.streamFile) r.Get("/items/{itemId}/download", api.streamFile) + r.Head("/items/{itemId}/file", api.streamFile) + r.Head("/items/{itemId}/download", api.streamFile) r.Post("/sessions/playing", api.reportPlaybackStart) r.Post("/sessions/playing/progress", api.reportPlaybackProgress) r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) - r.Post("/sessions/capabilities", api.postCapabilities) - r.Post("/sessions/capabilities/full", api.postCapabilities) + r.Post("/sessions/playing/ping", api.acknowledge) + r.Post("/sessions/capabilities", api.acknowledge) + r.Post("/sessions/capabilities/full", api.acknowledge) // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. r.Get("/socket", api.handleSocket) diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 7fb85cba8..d1c3c132b 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -41,6 +41,10 @@ var _ = Describe("Item images", func() { Expect(u.IsAdmin).To(BeTrue()) }) + It("answers HEAD without authentication", func() { + Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK)) + }) + It("serves images without authentication (public route)", func() { id := albumID("IV") w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 50c2e17f2..174ba8660 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -19,6 +19,7 @@ var _ = Describe("Playlists", func() { playlistItems := func(plID string) dto.QueryResult { return queryResult(get("/Playlists/" + enc(plID) + "/Items")) } + order := func(plID string) []string { return names(playlistItems(plID).Items) } Describe("create", func() { It("creates an empty playlist", func() { @@ -102,6 +103,36 @@ var _ = Describe("Playlists", func() { Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) }) + Describe("at a position", func() { + var plID string + addAt := func(position string, ids ...string) { + url := "/Playlists/" + enc(plID) + "/Items?position=" + position + for _, id := range ids { + url += "&ids=" + enc(id) + } + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + } + BeforeEach(func() { + plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))}) + }) + + DescribeTable("inserts at Jellyfin's zero-based position", + func(position string, want []string) { + addAt(position, songID("So What"), songID("Help!")) + Expect(order(plID)).To(Equal(want)) + }, + Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}), + Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}), + Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}), + ) + + It("keeps an expanded album's track order", func() { + albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))})) + addAt("1", albumID("Abbey Road")) + Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something"))) + }) + }) + It("removes an entry by its PlaylistItemId", func() { plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) entryID := playlistItems(plID).Items[0].PlaylistItemId @@ -118,6 +149,58 @@ var _ = Describe("Playlists", func() { }) }) + Describe("move", func() { + move := func(u model.User, plID, entryID string, newIndex string) int { + return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code + } + var plID string + var entries []dto.BaseItemDto + BeforeEach(func() { + plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + entries = playlistItems(plID).Items + }) + + DescribeTable("moves an entry to Jellyfin's zero-based index", + func(entry int, newIndex string, want []string) { + Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent)) + Expect(order(plID)).To(Equal(want)) + var positions []string + for _, it := range playlistItems(plID).Items { + positions = append(positions, it.PlaylistItemId) + } + Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")})) + }, + Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}), + Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}), + Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}), + Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}), + ) + + It("ignores an entry that is not in the playlist", func() { + Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("404s on a malformed entry id", func() { + Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound)) + }) + + It("hides another user's private playlist", func() { + Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("forbids a non-owner on a public playlist", func() { + Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("rejects a negative index", func() { + Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest)) + }) + }) + Describe("users", func() { It("reports the current user as an editor", func() { plID := createPlaylist("Perms", nil) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go index 2057b43c6..c5c42cb2b 100644 --- a/server/jellyfin/e2e/sessions_test.go +++ b/server/jellyfin/e2e/sessions_test.go @@ -37,6 +37,10 @@ var _ = Describe("Sessions", func() { Expect(mf.PlayCount).To(BeNumerically(">=", 1)) }) + It("acknowledges a playback session ping", func() { + Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent)) + }) + It("does not count a brief play stopped before the threshold", func() { // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 5f1a88a21..06054f8bf 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -150,4 +150,33 @@ var _ = Describe("Similar", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) }) + + Describe("type-specific InstantMix routes", func() { + BeforeEach(func() { + providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}} + }) + + It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() { + q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"})) + }) + + DescribeTable("returns the provider's mix", + func(path func() string) { + q := queryResult(get(path())) + Expect(names(q.Items)).To(Equal([]string{"Mix Song"})) + }, + Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }), + Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }), + Entry("Playlists/{id}", func() string { + return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix" + }), + Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }), + Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }), + ) + + It("404s a malformed id query param", func() { + Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index 67208aed3..9b7380185 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -1,6 +1,7 @@ package e2e import ( + "fmt" "net/http" "strings" @@ -45,6 +46,19 @@ var _ = Describe("Streaming", func() { It("returns 404 for an unknown track", func() { Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) }) + + DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)", + func(path string) { + w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty()) + }, + Entry("stream", "/Audio/%s/stream"), + Entry("stream.{container}", "/Audio/%s/stream.mp3"), + Entry("universal", "/Audio/%s/universal"), + Entry("File", "/Items/%s/File"), + Entry("Download", "/Items/%s/Download"), + ) }) Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 9f1b9ce1a..0c9e0380c 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -23,14 +23,17 @@ import ( _ "golang.org/x/image/webp" ) -// imageSize picks the tighter of Jellyfin's two bounds, because Navidrome resizes on a single -// dimension: reading only MaxWidth serves the full-size original to a client that sent MaxHeight. -func imageSize(maxWidth, maxHeight int) int { - w, h := max(maxWidth, 0), max(maxHeight, 0) - if w == 0 || h == 0 { - return max(w, h) +// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are +// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound. +func imageSize(p *req.Values) int { + fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0)) + size := 0 + for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} { + if v > 0 && (size == 0 || v < size) { + size = v + } } - return min(w, h) + return size } func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { @@ -41,8 +44,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { if !ok { return } - p := req.Params(r) - size := imageSize(p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0)) + size := imageSize(req.Params(r)) artID := api.resolveArtworkID(ctx, itemId) img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 2f32e6ec9..b7b262895 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -65,7 +65,7 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) var _ = Describe("Images", func() { // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must // still get a resized image rather than the full-size original. - DescribeTable("derives the requested size from MaxWidth or MaxHeight", + DescribeTable("derives the requested size from the Jellyfin size params", func(query string, wantSize int) { ds := &tests.MockDataStore{} ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) @@ -84,6 +84,15 @@ var _ = Describe("Images", func() { Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), Entry("neither", "", 0), + Entry("Width only", "width=300", 300), + Entry("Height only", "height=300", 300), + Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300), + Entry("FillWidth only", "fillwidth=300", 300), + Entry("FillHeight only", "fillheight=300", 300), + Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300), + Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300), + Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200), + Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250), ) It("streams album artwork", func() { diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index ac790a821..1052a398f 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "errors" + "math" "net/http" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -261,8 +263,8 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) } -// addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). -// AddTracks enforces ownership; a locked playlist maps to 403, any other error to 404. +// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position +// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -275,7 +277,13 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { return } ids := api.expandContainerIDs(ctx, decoded) - if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + var err error + if position, perr := req.Params(r).Int64("position"); perr == nil { + _, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position)) + } else { + _, err = api.playlists.AddTracks(ctx, id, ids) + } + if err != nil { if errors.Is(err, model.ErrPlaylistNotEditable) { http.Error(w, "Forbidden", http.StatusForbidden) return @@ -286,6 +294,12 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so +// it can't wrap on 32-bit builds. +func insertPosition(position int64) int { + return int(min(max(position, 0), math.MaxInt32-1) + 1) +} + // removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media // file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces // ownership; a locked playlist maps to 403, any other error to 404. @@ -316,6 +330,38 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end +// indexes and rejects unknown entries, which Jellyfin treats as a no-op. +func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex")) + if err != nil || newIndex < 0 { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + // Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op. + if _, err := api.playlists.Get(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + pos, _ := strconv.Atoi(entry) + err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + // Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries // only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 270f5fe08..ae0a5da3c 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "math" "net/http" "net/http/httptest" "strings" @@ -133,6 +134,17 @@ func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error return f.removeImageErr } +var _ = DescribeTable("insertPosition", + func(position int64, want int) { + Expect(insertPosition(position)).To(Equal(want)) + }, + Entry("zero-based index becomes a 1-based position", int64(2), 3), + Entry("negative prepends", int64(-5), 1), + Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32), + Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32), + Entry("smallest int64 prepends", int64(math.MinInt64), 1), +) + var _ = Describe("Playlists", func() { var api *Router var fp *fakePlaylists diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index a145e7359..e60b1bf5b 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -113,8 +113,8 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } -// postCapabilities acknowledges Jellyfin session-capability negotiation. -// Navidrome doesn't track per-session client capabilities, so this is a no-op. -func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) { +// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per +// session) and playback pings (transcodes live only as long as their stream request). +func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 05151c39a..f2f04497c 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -152,12 +152,12 @@ var _ = Describe("Sessions", func() { }) }) - Describe("postCapabilities", func() { + Describe("acknowledge", func() { It("returns 204 No Content and does not touch the scrobbler", func() { w := httptest.NewRecorder() r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) - api.postCapabilities(w, r) + api.acknowledge(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(BeEmpty()) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 82d12f52b..1bd887f43 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -111,11 +111,26 @@ func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { // a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty // results, never a 404 the client would surface as an error. func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() id, ok := itemIDParam(w, r, "itemId") if !ok { return } + api.instantMix(w, r, id) +} + +// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms, +// which take the seed as ?id= instead of a path segment. +func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) { + id, ok := dto.DecodeID(req.Params(r).StringOr("id", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + api.instantMix(w, r, id) +} + +func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) { + ctx := r.Context() limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index c6dc0c80e..b9809bd29 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -4,6 +4,7 @@ import ( "cmp" "fmt" "math" + "mime" "net/http" "net/url" "slices" @@ -139,6 +140,14 @@ func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model. func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { ctx := r.Context() + // A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes) + // this is not direct play; direct play falls through to Serve, which answers HEAD itself. + if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" { + w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format)) + w.Header().Set("Accept-Ranges", "none") + w.WriteHeader(http.StatusOK) + return + } s, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { api.internalError(w, r, err) diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 3cd692c1b..1a6d1badc 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -244,6 +244,36 @@ var _ = Describe("Stream", func() { }) }) + Describe("HEAD requests", func() { + head := func(query string) *httptest.ResponseRecorder { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) + invoke(api.streamAudio, w, r) + return w + } + + It("answers a transcode with the target type and no length, without starting it", func() { + w := head("audioCodec=mp3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg")) + Expect(w.Header().Get("Content-Length")).To(BeEmpty()) + Expect(w.Body.String()).To(BeEmpty()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("answers direct play through the streamer, without a body", func() { + w := head("static=true") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(BeEmpty()) + }) + }) + Describe("streamUniversal", func() { universal := func(query string) { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index ccc1277df..0a85d4545 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -20,10 +20,9 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" ) -// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate -// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required -// for Feishin to use the server lyrics endpoint. -const jellyfinVersion = "10.9.11" +// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on +// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. +const jellyfinVersion = "12.1.0" func (api *Router) serverName() string { if conf.Server.Jellyfin.ServerName != "" { diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 83751bb28..5666e7cc7 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -13,6 +13,7 @@ type MockPlaylistTrackRepo struct { DeletedIds []string Reordered bool AddCount int + InsertPos int Err error AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } @@ -89,6 +90,11 @@ func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { return m.AddCount, nil } +func (m *MockPlaylistTrackRepo) Insert(ids []string, pos int) (int, error) { + m.InsertPos = pos + return m.Add(ids) +} + func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { if m.Err != nil { return 0, m.Err From fed35e08de2d784af8ad7d7f9533d56b59e6f42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 19 Sep 2026 13:33:19 -0400 Subject: [PATCH 546/596] feat(jellyfin): add opt-in LAN auto-discovery (#6169) * feat(jellyfin): compute the address advertised by auto-discovery * fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address * feat(jellyfin): answer LAN auto-discovery broadcasts * test(jellyfin): e2e check that auto-discovery matches the public server identity * feat(jellyfin): add opt-in AutoDiscovery option and start the listener * fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures * refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored. * refactor(jellyfin): run auto-discovery as its own service in the main errgroup Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot. * refactor(jellyfin): build Discovery through wire and drop the serverName forwarder startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly. * fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is. * docs(jellyfin): note which address auto-discovery advertises on restricted binds Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is. --- cmd/root.go | 13 ++ cmd/wire_gen.go | 9 +- cmd/wire_injectors.go | 7 ++ conf/configuration.go | 2 + server/jellyfin/README.md | 22 ++++ server/jellyfin/api.go | 2 - server/jellyfin/auth.go | 2 +- server/jellyfin/discovery.go | 110 ++++++++++++++++ server/jellyfin/discovery_test.go | 175 ++++++++++++++++++++++++++ server/jellyfin/e2e/discovery_test.go | 50 ++++++++ server/jellyfin/system.go | 45 +++---- server/jellyfin/system_test.go | 12 ++ server/jellyfin/users.go | 2 +- 13 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 server/jellyfin/discovery.go create mode 100644 server/jellyfin/discovery_test.go create mode 100644 server/jellyfin/e2e/discovery_test.go diff --git a/cmd/root.go b/cmd/root.go index c4e360010..f632b0425 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,6 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startSignaller(ctx)) g.Go(startScheduler(ctx)) g.Go(startPlaybackServer(ctx)) + g.Go(startJellyfinDiscovery(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) @@ -343,6 +344,18 @@ func startInsightsCollector(ctx context.Context) func() error { } } +// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server. +func startJellyfinDiscovery(ctx context.Context) func() error { + return func() error { + if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery { + log.Debug("Jellyfin auto-discovery is DISABLED") + return nil + } + CreateJellyfinDiscovery().Serve(ctx) + return nil + } +} + // startPlaybackServer starts the Navidrome playback server, if configured. // It is responsible for the Jukebox functionality func startPlaybackServer(ctx context.Context) func() error { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index fd04c44c5..d6bf4da07 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -168,6 +168,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router { return router } +func CreateJellyfinDiscovery() *jellyfin.Discovery { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + discovery := jellyfin.NewDiscovery(dataStore) + return discovery +} + func CreateInsights() metrics.Insights { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -249,7 +256,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index a54d4ae1b..527617959 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -36,6 +36,7 @@ var allProviders = wire.NewSet( server.New, subsonic.New, jellyfin.New, + jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, @@ -108,6 +109,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router { )) } +func CreateJellyfinDiscovery() *jellyfin.Discovery { + panic(wire.Build( + allProviders, + )) +} + func CreateInsights() metrics.Insights { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index e549e2a61..4812f2515 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -235,6 +235,7 @@ type jellyfinOptions struct { // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string + AutoDiscovery bool // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB // cursor — and its pooled connection — for the whole client-paced response, so without a bound // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. @@ -1087,6 +1088,7 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) viper.SetDefault("jellyfin.enabled", false) viper.SetDefault("jellyfin.servername", "") + viper.SetDefault("jellyfin.autodiscovery", false) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index a20c9ce4e..893642cdd 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -22,6 +22,8 @@ Enabled = true ServerName = "My Music Server" # Optional: usernames to show in the client login user-picker (default: none). See "Public user list". ExposedPublicUsers = "alice, bob" +# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". +AutoDiscovery = true # Optional: max collection responses streaming at once (default: half the DB connection pool, # min 2). Each streaming response holds a DB connection for its whole duration; excess requests # queue rather than fail. @@ -34,6 +36,7 @@ or via environment variables: ND_JELLYFIN_ENABLED=true ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +ND_JELLYFIN_AUTODISCOVERY=true ``` Once enabled, the API is mounted at: @@ -46,6 +49,25 @@ All the paths below are relative to that base URL (e.g. `System/Info/Public` mea `http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. +## Auto discovery + +With `AutoDiscovery = true`, Navidrome answers the Jellyfin LAN discovery broadcast +(`who is JellyfinServer?` on UDP port 7359), so clients list the server without a typed URL. +It is off by default because a real Jellyfin server on the same host owns that port. If the port +is taken, Navidrome logs a warning and keeps running without discovery. + +The advertised address is `BaseURL` when it includes a host. Otherwise it is the bind `Address` +when that is a specific IP, or else the local IP that faces the requesting client, plus `Port`. With a +unix socket `Address` there is no port to advertise, so discovery only starts when `BaseURL` has a host. + +Discovery answers on all IPv4 interfaces, but the advertised address follows `BaseURL`, `Address` and +`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other +networks get an address they cannot reach. Set `BaseURL` to the address clients should use. + +Docker: publish the port (`-p 7359:7359/udp`). In bridge mode the server only sees its container +IP, so also set `ND_BASEURL` to the LAN address (for example `http://192.168.1.10:4533`), or use +host networking. Keep UDP 7359 on the LAN: never forward it from the internet. + ## Authentication Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 955ebc3bb..eddd14f66 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -3,7 +3,6 @@ package jellyfin import ( "encoding/json" "net/http" - "sync" "time" "github.com/go-chi/chi/v5" @@ -41,7 +40,6 @@ type Router struct { broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group - serverIDMu sync.Mutex serverIDVal string } diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index bfcfab319..ba4fe40f1 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -45,7 +45,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { a := parseMediaBrowserAuth(r) serverID := api.serverID(ctx) api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, api.serverName(), serverID), + User: userToDto(usr, serverName(), serverID), SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), AccessToken: token, ServerId: serverID, diff --git a/server/jellyfin/discovery.go b/server/jellyfin/discovery.go new file mode 100644 index 000000000..d111287a6 --- /dev/null +++ b/server/jellyfin/discovery.go @@ -0,0 +1,110 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net" + "strconv" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" +) + +// Jellyfin clients broadcast this query text to this UDP port. +const ( + discoveryPort = 7359 + discoveryQuery = "who is jellyfinserver?" +) + +type discoveryInfo struct { + Address string `json:"Address"` + Id string `json:"Id"` + Name string `json:"Name"` + EndpointAddress *string `json:"EndpointAddress"` +} + +// Discovery answers LAN auto-discovery broadcasts with the same identity the Router reports. +type Discovery struct { + ds model.DataStore + serverIDVal string +} + +func NewDiscovery(ds model.DataStore) *Discovery { + return &Discovery{ds: ds} +} + +func (d *Discovery) serverID(ctx context.Context) string { + return resolveServerID(ctx, d.ds, &d.serverIDVal) +} + +// Serve runs until ctx is done. A failed bind is only logged: discovery is best-effort. +func (d *Discovery) Serve(ctx context.Context) { + if !hasAdvertisableAddress() { + log.Warn(ctx, "Jellyfin API: auto-discovery is off, a unix socket server needs a BaseURL with a host to advertise") + return + } + // udp4 only: a dual-stack bind can share the port with another server and never get a packet. + conn, err := net.ListenPacket("udp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(discoveryPort))) + if err != nil { + log.Warn(ctx, "Jellyfin API: auto-discovery is off, the UDP port is unavailable. Is another Jellyfin server running?", "port", discoveryPort, err) + return + } + log.Info(ctx, "Jellyfin API: listening for auto-discovery broadcasts", "port", discoveryPort) + d.ServeOn(ctx, conn) +} + +// ServeOn answers discovery queries on conn until ctx is done, then closes conn. +func (d *Discovery) ServeOn(ctx context.Context, conn net.PacketConn) { + defer conn.Close() + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + buf := make([]byte, 1024) + for { + n, remote, err := conn.ReadFrom(buf) + if err != nil { + if ctx.Err() == nil { + log.Error(ctx, "Jellyfin API: auto-discovery listener stopped", err) + } + return + } + if !strings.Contains(strings.ToLower(string(buf[:n])), discoveryQuery) { + continue + } + info := discoveryInfo{Address: discoveryAddress(ctx, remote), Id: d.serverID(ctx), Name: serverName()} + res, _ := json.Marshal(info) + log.Debug(ctx, "Jellyfin API: answering auto-discovery request", "from", remote.String(), "address", info.Address) + if _, err := conn.WriteTo(res, remote); err != nil { + log.Debug(ctx, "Jellyfin API: could not answer auto-discovery request", "to", remote.String(), err) + } + } +} + +// Behind a unix socket nothing listens on Port, so only a BaseURL host gives clients an address. +func hasAdvertisableAddress() bool { + return conf.Server.BaseHost != "" || !strings.HasPrefix(conf.Server.Address, "unix:") +} + +func discoveryAddress(ctx context.Context, remote net.Addr) string { + scheme := gg.If(conf.Server.TLSEnabled(), "https", "http") + host := net.JoinHostPort(localIPFor(remote), strconv.Itoa(conf.Server.Port)) + return publicurl.AbsoluteURL(request.WithServerAddress(ctx, scheme, host), consts.URLPathJellyfinAPI, nil) +} + +// On a multi-homed host, only the interface that routes to the requester is reachable by it. +func localIPFor(remote net.Addr) string { + if ip := parseIP(conf.Server.Address); ip.IsValid() && !ip.IsUnspecified() { + return ip.String() + } + c, err := net.Dial("udp", remote.String()) + if err != nil { + return conf.Server.Address + } + defer c.Close() + return c.LocalAddr().(*net.UDPAddr).IP.String() +} diff --git a/server/jellyfin/discovery_test.go b/server/jellyfin/discovery_test.go new file mode 100644 index 000000000..bcd11fe76 --- /dev/null +++ b/server/jellyfin/discovery_test.go @@ -0,0 +1,175 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type failingConn struct { + net.PacketConn + closed bool +} + +func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) { + return 0, nil, errors.New("read failed") +} + +func (c *failingConn) Close() error { + c.closed = true + return nil +} + +var _ = Describe("Discovery", func() { + var d *Discovery + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "Test Server" + conf.Server.Address = "0.0.0.0" + conf.Server.Port = 4533 + conf.Server.BaseHost = "" + conf.Server.BaseScheme = "" + conf.Server.BasePath = "" + conf.Server.TLSCert = "" + conf.Server.TLSKey = "" + d = &Discovery{} + }) + + DescribeTable("discoveryAddress", + func(setup func(), expected string) { + setup() + remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000} + Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected)) + }, + Entry("uses the BaseURL host and scheme when set", func() { + conf.Server.BaseScheme = "https" + conf.Server.BaseHost = "music.example.com" + conf.Server.BasePath = "/nd" + }, "https://music.example.com/nd/jellyfin"), + Entry("uses a specific bind Address with the Port", func() { + conf.Server.Address = "192.168.1.10" + }, "http://192.168.1.10:4533/jellyfin"), + Entry("falls back to the interface facing the requester when Address is unspecified", func() {}, + "http://127.0.0.1:4533/jellyfin"), + Entry("falls back to the interface facing the requester when Address is empty", func() { + conf.Server.Address = "" + }, "http://127.0.0.1:4533/jellyfin"), + Entry("advertises https when TLS is configured", func() { + conf.Server.TLSCert = "/path/cert.pem" + conf.Server.TLSKey = "/path/key.pem" + }, "https://127.0.0.1:4533/jellyfin"), + Entry("advertises http when only the TLS cert is configured", func() { + conf.Server.TLSCert = "/path/cert.pem" + }, "http://127.0.0.1:4533/jellyfin"), + Entry("keeps a path-only BaseURL as the path prefix", func() { + conf.Server.BasePath = "/music" + }, "http://127.0.0.1:4533/music/jellyfin"), + Entry("does not double the slash when BasePath has a trailing slash", func() { + conf.Server.BasePath = "/music/" + }, "http://127.0.0.1:4533/music/jellyfin"), + ) + + DescribeTable("hasAdvertisableAddress", + func(address, baseHost string, expected bool) { + conf.Server.Address = address + conf.Server.BaseHost = baseHost + Expect(hasAdvertisableAddress()).To(Equal(expected)) + }, + Entry("TCP listener", "0.0.0.0", "", true), + Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false), + Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true), + ) + + It("closes the connection when the read loop fails", func() { + fake := &failingConn{} + d.ServeOn(context.Background(), fake) + Expect(fake.closed).To(BeTrue()) + }) + + Describe("ServeOn", func() { + var ( + server net.PacketConn + client net.PacketConn + cancel context.CancelFunc + done chan struct{} + ) + + BeforeEach(func() { + var err error + server, err = net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + client, err = net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(client.Close) + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + done = make(chan struct{}) + go func() { + defer close(done) + d.ServeOn(ctx, server) + }() + DeferCleanup(func() { + cancel() + Eventually(done).Should(BeClosed()) + }) + }) + + send := func(msg string) { + _, err := client.WriteTo([]byte(msg), server.LocalAddr()) + Expect(err).ToNot(HaveOccurred()) + } + receive := func() ([]byte, error) { + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) + buf := make([]byte, 1024) + n, _, err := client.ReadFrom(buf) + return buf[:n], err + } + + It("answers the discovery query with the server identity", func() { + send("who is JellyfinServer?") + res, err := receive() + Expect(err).ToNot(HaveOccurred()) + + var info discoveryInfo + Expect(json.Unmarshal(res, &info)).To(Succeed()) + Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin")) + Expect(info.Id).To(Equal(d.serverID(context.Background()))) + Expect(info.Name).To(Equal("Test Server")) + Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`)) + }) + + It("matches the query case-insensitively", func() { + send("WHO IS JELLYFINSERVER?") + _, err := receive() + Expect(err).ToNot(HaveOccurred()) + }) + + // The loop is serial, so a reply to the first packet would arrive before the second's. + It("ignores unrelated packets", func() { + send("who is PlexServer?") + send("who is JellyfinServer?") + _, err := receive() + Expect(err).ToNot(HaveOccurred()) + Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed()) + _, _, err = client.ReadFrom(make([]byte, 1024)) + Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue()) + }) + + It("stops and closes the socket when the context is cancelled", func() { + cancel() + Eventually(done).Should(BeClosed()) + _, _, err := server.ReadFrom(make([]byte, 1)) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/jellyfin/e2e/discovery_test.go b/server/jellyfin/e2e/discovery_test.go new file mode 100644 index 000000000..0126ef3c6 --- /dev/null +++ b/server/jellyfin/e2e/discovery_test.go @@ -0,0 +1,50 @@ +package e2e + +import ( + "context" + "encoding/json" + "net" + "time" + + "github.com/navidrome/navidrome/server/jellyfin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Auto discovery", func() { + BeforeEach(func() { setupTestDB() }) + + It("advertises the same Id and Name as /System/Info/Public", func() { + server, err := net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + client, err := net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(client.Close) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + jellyfin.NewDiscovery(ds).ServeOn(ctx, server) + }() + DeferCleanup(func() { + cancel() + Eventually(done).Should(BeClosed()) + }) + + _, err = client.WriteTo([]byte("who is JellyfinServer?"), server.LocalAddr()) + Expect(err).ToNot(HaveOccurred()) + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) + buf := make([]byte, 1024) + n, _, err := client.ReadFrom(buf) + Expect(err).ToNot(HaveOccurred()) + var reply map[string]any + Expect(json.Unmarshal(buf[:n], &reply)).To(Succeed()) + + var pub map[string]any + parseInto(rawReq("GET", "/System/Info/Public", ""), &pub) + Expect(reply["Id"]).To(Equal(pub["Id"])) + Expect(reply["Name"]).To(Equal(pub["ServerName"])) + Expect(reply["Address"]).To(HaveSuffix("/jellyfin")) + }) +}) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 0a85d4545..35ade5ff1 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -10,6 +10,7 @@ import ( "net/netip" "path" "strings" + "sync" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" @@ -24,35 +25,37 @@ import ( // it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. const jellyfinVersion = "12.1.0" -func (api *Router) serverName() string { +func serverName() string { if conf.Server.Jellyfin.ServerName != "" { return conf.Server.Jellyfin.ServerName } return fmt.Sprintf("Navidrome %s", consts.Version) } -// serverID returns a stable Id that survives restarts, get-or-created in the Property table. -// Jellyfin clients cache ServerId across sessions, so a per-process value would break -// re-authentication. api.ds is nil only in unit tests; New() always sets it. -// -// The mutex serializes first-boot resolution so concurrent requests can't persist different -// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a -// temporary id and retries on the next request rather than pinning a value. func (api *Router) serverID(ctx context.Context) string { - api.serverIDMu.Lock() - defer api.serverIDMu.Unlock() - if api.serverIDVal != "" { - return api.serverIDVal + return resolveServerID(ctx, api.ds, &api.serverIDVal) +} + +// Package-level: the Router and Discovery are separate objects and must not persist different ids. +var serverIDMu sync.Mutex + +// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is +// cached: a transient failure yields a temporary id and retries on the next call. +func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string { + serverIDMu.Lock() + defer serverIDMu.Unlock() + if *cached != "" { + return *cached } - if api.ds == nil { - api.serverIDVal = newServerID() - return api.serverIDVal + if ds == nil { + *cached = newServerID() + return *cached } - id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + id, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): id = newServerID() - if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + if err := ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { log.Error(ctx, "Jellyfin API: could not persist server id", err) return id } @@ -61,8 +64,8 @@ func (api *Router) serverID(ctx context.Context) string { return newServerID() } // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. - api.serverIDVal = strings.ReplaceAll(id, "-", "") - return api.serverIDVal + *cached = strings.ReplaceAll(id, "-", "") + return *cached } // newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). @@ -74,7 +77,7 @@ func newServerID() string { func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { return dto.PublicSystemInfo{ LocalAddress: localAddress(r), - ServerName: api.serverName(), + ServerName: serverName(), Version: jellyfinVersion, ProductName: "Jellyfin Server", Id: api.serverID(r.Context()), @@ -106,7 +109,7 @@ func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { func (api *Router) ping(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(api.serverName())) + _, _ = w.Write([]byte(serverName())) } // getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 4b3ac240e..c9368f3b5 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "sync" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -173,6 +174,17 @@ var _ = Describe("System", func() { Expect(second.serverID(ctx)).To(Equal(id)) }) + It("resolves one id when a Router and a Discovery race on first boot", func() { + r, d := &Router{ds: ds}, NewDiscovery(ds) + ids := make([]string, 2) + var wg sync.WaitGroup + wg.Go(func() { ids[0] = r.serverID(ctx) }) + wg.Go(func() { ids[1] = d.serverID(ctx) }) + wg.Wait() + Expect(ids[0]).ToNot(BeEmpty()) + Expect(ids[1]).To(Equal(ids[0])) + }) + It("memoizes the id across repeated calls on the same Router", func() { r := &Router{ds: ds} id := r.serverID(ctx) diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index ee4ace48e..dddcd99da 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -34,7 +34,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u, _ := request.UserFrom(ctx) - api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx))) + api.ok(w, r, userToDto(&u, serverName(), api.serverID(ctx))) } // getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login From be8ec15168b78fda2b30db659f394df2d34e9a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 19 Sep 2026 14:07:42 -0400 Subject: [PATCH 547/596] feat(ui): format dates using the selected language (#6160) Dates were rendered with the browser locale, ignoring the language chosen in Personal settings, so a user browsing in German still saw US-style dates. All date rendering now resolves its locale through a useDateLocale hook that returns the selected language, augmented with the region from navigator.languages when the language carries none (Intl reads a bare "en" as en-US). Applied to DateField, the rated/loved tooltips, the mobile user list and album release dates; the three inline timestamp tooltips now share a formatDateTime helper. Closes #229 --- ui/src/album/AlbumDetails.jsx | 9 ++++--- ui/src/common/DateField.jsx | 4 ++- ui/src/common/DateField.test.jsx | 32 ++++++++++++++++++++++ ui/src/common/LoveButton.jsx | 5 +++- ui/src/common/RatingField.jsx | 5 +++- ui/src/i18n/useDateLocale.js | 14 ++++++++++ ui/src/i18n/useDateLocale.test.js | 45 +++++++++++++++++++++++++++++++ ui/src/user/UserList.jsx | 5 +++- ui/src/utils/formatters.js | 3 +++ 9 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 ui/src/common/DateField.test.jsx create mode 100644 ui/src/i18n/useDateLocale.js create mode 100644 ui/src/i18n/useDateLocale.test.js diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index f55d94ffc..7fbc479db 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -31,6 +31,7 @@ import { SizeField, useAlbumsPerPage, } from '../common' +import { useDateLocale } from '../i18n/useDateLocale' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -151,6 +152,7 @@ export const Details = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() const record = useRecordContext(props) + const locale = useDateLocale() // Create an array of detail elements let details = [] @@ -161,12 +163,13 @@ export const Details = (props) => { // Calculate date related fields const yearRange = formatRange(record, 'year') - const date = record.date ? formatFullDate(record.date) : yearRange + const date = record.date ? formatFullDate(record.date, locale) : yearRange const originalDate = record.originalDate - ? formatFullDate(record.originalDate) + ? formatFullDate(record.originalDate, locale) : formatRange(record, 'originalYear') - const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate) + const releaseDate = + record?.releaseDate && formatFullDate(record.releaseDate, locale) const dateToUse = originalDate || date const isOriginalDate = originalDate && dateToUse !== date diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx index dce24a2b9..dac9cff08 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.jsx @@ -1,12 +1,14 @@ import React from 'react' import { isDateSet } from '../utils/validations' import { DateField as RADateField } from 'react-admin' +import { useDateLocale } from '../i18n/useDateLocale' export const DateField = (props) => { const { record, source } = props + const locale = useDateLocale() const value = record?.[source] if (!isDateSet(value)) return null - return <RADateField {...props} /> + return <RADateField locales={locale} {...props} /> } DateField.defaultProps = { diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx new file mode 100644 index 000000000..47d56756b --- /dev/null +++ b/ui/src/common/DateField.test.jsx @@ -0,0 +1,32 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { DateField } from './DateField' + +vi.mock('react-admin', async (importOriginal) => ({ + ...(await importOriginal()), + useLocale: vi.fn(), +})) + +describe('<DateField>', () => { + const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' } + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue('de') + }) + + it('formats the date using the selected language', () => { + render(<DateField record={record} source="updatedAt" />) + expect(screen.getByText('17.9.2026')).toBeInTheDocument() + }) + + it('renders nothing when the date is not set', () => { + const { container } = render( + <DateField record={{ id: '1' }} source="updatedAt" />, + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx index 492ba95b3..128b375c2 100644 --- a/ui/src/common/LoveButton.jsx +++ b/ui/src/common/LoveButton.jsx @@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { useToggleLove } from './useToggleLove' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' import config from '../config' import { isDateSet } from '../utils/validations' @@ -40,6 +42,7 @@ export const LoveButton = ({ const record = useRecordContext({ record: recordProp }) || {} const classes = useStyles({ color, visible, loved: record.starred }) const [toggleLove, loading] = useToggleLove(resource, record) + const locale = useDateLocale() const handleToggleLove = useCallback( (e) => { @@ -61,7 +64,7 @@ export const LoveButton = ({ className={clsx(classes.love, className)} title={ isDateSet(record.starredAt) - ? new Date(record.starredAt).toLocaleString() + ? formatDateTime(record.starredAt, locale) : undefined } {...rest} diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx index f92b0d948..f892b475a 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.jsx @@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations' import StarBorderIcon from '@material-ui/icons/StarBorder' import clsx from 'clsx' import { useRating } from './useRating' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' const useStyles = makeStyles({ @@ -32,6 +34,7 @@ export const RatingField = ({ const record = useRecordContext(rest) || {} const [rate, rating] = useRating(resource, record) const classes = useStyles({ color, visible }) + const locale = useDateLocale() const stopPropagation = (e) => { e.stopPropagation() @@ -50,7 +53,7 @@ export const RatingField = ({ onClick={(e) => stopPropagation(e)} title={ isDateSet(record.ratedAt) - ? new Date(record.ratedAt).toLocaleString() + ? formatDateTime(record.ratedAt, locale) : undefined } > diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js new file mode 100644 index 000000000..c9298a473 --- /dev/null +++ b/ui/src/i18n/useDateLocale.js @@ -0,0 +1,14 @@ +import { useLocale } from 'react-admin' + +// Our language codes are mostly region-less ("en"), and Intl reads a bare "en" +// as en-US. Borrow the region from the browser when it speaks the same language. +const resolveDateLocale = (locale, browserLocales = []) => { + if (!locale || locale.includes('-')) return locale + const base = locale.toLowerCase() + return ( + browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale + ) +} + +export const useDateLocale = () => + resolveDateLocale(useLocale(), navigator.languages) diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js new file mode 100644 index 000000000..c0ea5ab40 --- /dev/null +++ b/ui/src/i18n/useDateLocale.test.js @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useDateLocale } from './useDateLocale' + +vi.mock('react-admin', () => ({ + useLocale: vi.fn(), +})) + +describe('useDateLocale', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderWith = async (locale, browserLocales) => { + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue(locale) + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales) + return renderHook(() => useDateLocale()).result + } + + it('adds the region from the browser when the language has none', async () => { + const result = await renderWith('en', ['en-GB', 'fr-FR']) + expect(result.current).toEqual('en-GB') + }) + + it('falls back to the language when no browser entry matches', async () => { + const result = await renderWith('de', ['en-US', 'fr-FR']) + expect(result.current).toEqual('de') + }) + + it('keeps a language that already carries a region or script', async () => { + expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br') + expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans') + }) + + it('matches the browser language case-insensitively', async () => { + const result = await renderWith('pt', ['PT-PT']) + expect(result.current).toEqual('PT-PT') + }) + + it('returns undefined when there is no language', async () => { + const result = await renderWith(undefined, ['en-GB']) + expect(result.current).toBeUndefined() + }) +}) diff --git a/ui/src/user/UserList.jsx b/ui/src/user/UserList.jsx index 4faf31785..7922ca08d 100644 --- a/ui/src/user/UserList.jsx +++ b/ui/src/user/UserList.jsx @@ -9,6 +9,8 @@ import { } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { List, DateField } from '../common' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' const UserFilter = (props) => ( <Filter {...props} variant={'outlined'}> @@ -18,6 +20,7 @@ const UserFilter = (props) => ( const UserList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const locale = useDateLocale() return ( <List @@ -31,7 +34,7 @@ const UserList = (props) => { <SimpleList primaryText={(record) => record.userName} secondaryText={(record) => - record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString() + record.lastLoginAt && formatDateTime(record.lastLoginAt, locale) } tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')} /> diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index cfcb84b05..f7c59c195 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -95,6 +95,9 @@ export const formatFullDate = (date, locale) => { return new Date(date).toLocaleDateString(locale, options) } +export const formatDateTime = (value, locale) => + new Date(value).toLocaleString(locale) + export const formatNumber = (value, locale) => { if (value === null || value === undefined) return '0' return value.toLocaleString(locale) From b76ae14286179b9dd69b2a958124ed4a7d032dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 19 Sep 2026 14:57:01 -0400 Subject: [PATCH 548/596] feat(jellyfin): add Quick Connect sign-in (#6174) * feat(jellyfin): add Quick Connect sign-in Jellyfin clients can now sign in without a password: the client shows a 6-digit code, a signed-in user approves it, and the client redeems a secret for its access token. - core/quickconnect: in-memory store shared by both routers through wire. Codes expire after 10 minutes; a secret redeems only once (Jellyfin allows repeats for 10 minutes); at most 1000 pending requests. - Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect. Admins may approve for another user via UserId, like Swiftfin's admin page. Initiate and redeem share the login rate limiter; Connect does not, since Finamp and Streamyfin poll it every second. - Web UI: a Quick Connect item in the user menu looks up the code and shows the app and device before approving, so a user can't be tricked into approving an unknown device blindly. - Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters when the Jellyfin API is enabled. * refactor(jellyfin): tidy Quick Connect naming and route guards Group the Quick Connect routes under one requireQuickConnect guard, make the request's device a named field so req.Device.ID can't be mistaken for a request id, and rename the web API response type to quickConnectDevice. * refactor(jellyfin): remove duplicated Jellyfin date formatting function * test(jellyfin): set play count and starred in the song fixture literal * refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper * fix(jellyfin): bound the client fields Quick Connect keeps in memory Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version header fields for up to ten minutes. With no header size limit, each pending request could hold about 1 MB, and even a short field kept the whole header alive because the parsed values are substrings of it. Reject fields over 512 bytes and copy the stored values. Also answer 500 instead of 401 when the redeem user lookup fails for a reason other than the user being gone. * fix(jellyfin): rate-limit Quick Connect code approval Any signed-in user could try codes without limit on the Jellyfin Authorize endpoint and the web UI lookup/authorize endpoints, and so could approve another person's pending device for their own account. Apply the same per-IP limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces. --- cmd/wire_gen.go | 7 +- conf/configuration.go | 2 + core/quickconnect/quickconnect.go | 203 ++++++++++++ core/quickconnect/quickconnect_suite_test.go | 17 + core/quickconnect/quickconnect_test.go | 190 +++++++++++ core/wire_providers.go | 2 + server/jellyfin/README.md | 27 +- server/jellyfin/api.go | 17 +- server/jellyfin/api_test.go | 40 ++- server/jellyfin/auth.go | 7 +- server/jellyfin/dto/dto.go | 11 + server/jellyfin/dto/mappers.go | 20 +- server/jellyfin/dto/mappers_test.go | 6 +- server/jellyfin/e2e/e2e_suite_test.go | 2 + server/jellyfin/e2e/quickconnect_test.go | 91 ++++++ server/jellyfin/e2e/system_test.go | 8 - server/jellyfin/quickconnect.go | 145 +++++++++ server/jellyfin/quickconnect_test.go | 301 ++++++++++++++++++ server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/system.go | 4 - server/jellyfin/system_test.go | 11 - server/nativeapi/config_test.go | 2 +- server/nativeapi/library_test.go | 2 +- server/nativeapi/metadata_test.go | 2 +- server/nativeapi/missing_test.go | 2 +- server/nativeapi/native_api.go | 7 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- server/nativeapi/quickconnect.go | 76 +++++ server/nativeapi/quickconnect_test.go | 148 +++++++++ .../user_password_token_refresh_test.go | 2 +- server/serve_index.go | 2 + server/serve_index_test.go | 2 + ui/src/config.js | 1 + ui/src/dataProvider/wrapperDataProvider.js | 9 + ui/src/dialogs/QuickConnectDialog.jsx | 128 ++++++++ ui/src/dialogs/QuickConnectDialog.test.jsx | 103 ++++++ ui/src/dialogs/index.js | 1 + ui/src/i18n/en.json | 11 + ui/src/layout/AppBar.jsx | 73 +++-- ui/src/layout/AppBar.test.jsx | 22 ++ 43 files changed, 1626 insertions(+), 88 deletions(-) create mode 100644 core/quickconnect/quickconnect.go create mode 100644 core/quickconnect/quickconnect_suite_test.go create mode 100644 core/quickconnect/quickconnect_test.go create mode 100644 server/jellyfin/e2e/quickconnect_test.go create mode 100644 server/jellyfin/quickconnect.go create mode 100644 server/jellyfin/quickconnect_test.go create mode 100644 server/nativeapi/quickconnect.go create mode 100644 server/nativeapi/quickconnect_test.go create mode 100644 ui/src/dialogs/QuickConnectDialog.jsx create mode 100644 ui/src/dialogs/QuickConnectDialog.test.jsx diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6bf4da07..43ba808d0 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -79,7 +80,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider) + quickConnect := quickconnect.GetInstance() + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect) return router } @@ -135,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) + quickConnect := quickconnect.GetInstance() + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect) return router } diff --git a/conf/configuration.go b/conf/configuration.go index 4812f2515..6f295ebeb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -236,6 +236,7 @@ type jellyfinOptions struct { // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string AutoDiscovery bool + QuickConnect bool // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB // cursor — and its pooled connection — for the whole client-paced response, so without a bound // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. @@ -1089,6 +1090,7 @@ func setViperDefaults() { viper.SetDefault("jellyfin.enabled", false) viper.SetDefault("jellyfin.servername", "") viper.SetDefault("jellyfin.autodiscovery", false) + viper.SetDefault("jellyfin.quickconnect", true) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/core/quickconnect/quickconnect.go b/core/quickconnect/quickconnect.go new file mode 100644 index 000000000..9b69bc904 --- /dev/null +++ b/core/quickconnect/quickconnect.go @@ -0,0 +1,203 @@ +// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and +// an already signed-in user approves it, so the client can sign in without a password. +package quickconnect + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/random" + "github.com/navidrome/navidrome/utils/singleton" +) + +const timeout = 10 * time.Minute + +// Initiate is unauthenticated, so the number of live requests must be bounded. +const maxPending = 1000 + +var ( + ErrAlreadyAuthorized = errors.New("quick connect request already authorized") + ErrTooManyRequests = errors.New("too many pending quick connect requests") +) + +type Device struct { + ID string + Name string + App string + AppVersion string +} + +type Request struct { + Device Device + Secret string + Code string + DateAdded time.Time + UserID string +} + +func (r Request) Authorized() bool { + return r.UserID != "" +} + +type QuickConnect interface { + Initiate(device Device) (Request, error) + Status(secret string) (Request, error) + // Lookup returns a request still waiting for approval. + Lookup(code string) (Request, error) + Authorize(code, userID string) (Request, error) + // Redeem returns the id of the user who approved the request. It succeeds only once per secret. + Redeem(secret string) (string, error) +} + +type entry struct { + Request + expiresAt time.Time +} + +type quickConnect struct { + mu sync.Mutex + bySecret map[string]*entry + byCode map[string]*entry +} + +// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests. +func GetInstance() QuickConnect { + return singleton.GetInstance(newStore) +} + +func New() QuickConnect { + return newStore() +} + +func newStore() *quickConnect { + return &quickConnect{ + bySecret: map[string]*entry{}, + byCode: map[string]*entry{}, + } +} + +// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API. +func Enabled() bool { + return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect +} + +func (qc *quickConnect) Initiate(device Device) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + qc.expire() + if len(qc.bySecret) >= maxPending { + return Request{}, ErrTooManyRequests + } + // The fields may be substrings of a much larger header; copy them so the header isn't retained. + device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name), + App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)} + now := time.Now() + e := &entry{ + Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now}, + expiresAt: now.Add(timeout), + } + qc.bySecret[e.Secret] = e + qc.byCode[e.Code] = e + return e.Request, nil +} + +func (qc *quickConnect) Status(secret string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.find(qc.bySecret, secret) + if err != nil { + return Request{}, err + } + return e.Request, nil +} + +func (qc *quickConnect) Lookup(code string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.findPending(code) + if err != nil { + return Request{}, err + } + return e.Request, nil +} + +func (qc *quickConnect) Authorize(code, userID string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.findPending(code) + if err != nil { + return Request{}, err + } + e.UserID = userID + e.expiresAt = time.Now().Add(timeout) + return e.Request, nil +} + +func (qc *quickConnect) Redeem(secret string) (string, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.find(qc.bySecret, secret) + if err != nil || !e.Authorized() { + return "", model.ErrNotFound + } + qc.remove(e) + return e.UserID, nil +} + +func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) { + qc.expire() + e, ok := index[key] + if !ok { + return nil, model.ErrNotFound + } + return e, nil +} + +func (qc *quickConnect) findPending(code string) (*entry, error) { + e, err := qc.find(qc.byCode, normalizeCode(code)) + if err == nil && e.Authorized() { + return nil, ErrAlreadyAuthorized + } + return e, err +} + +func (qc *quickConnect) expire() { + now := time.Now() + for _, e := range qc.bySecret { + if !now.Before(e.expiresAt) { + qc.remove(e) + } + } +} + +func (qc *quickConnect) remove(e *entry) { + delete(qc.bySecret, e.Secret) + delete(qc.byCode, e.Code) +} + +func (qc *quickConnect) newCode() string { + for { + code := fmt.Sprintf("%06d", random.Int64N(900000)+100000) + if _, taken := qc.byCode[code]; !taken { + return code + } + } +} + +func newSecret() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Users often type the code in groups ("123 456"). +func normalizeCode(code string) string { + return strings.Join(strings.Fields(code), "") +} diff --git a/core/quickconnect/quickconnect_suite_test.go b/core/quickconnect/quickconnect_suite_test.go new file mode 100644 index 000000000..029f4e333 --- /dev/null +++ b/core/quickconnect/quickconnect_suite_test.go @@ -0,0 +1,17 @@ +package quickconnect + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestQuickConnect(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "QuickConnect Suite") +} diff --git a/core/quickconnect/quickconnect_test.go b/core/quickconnect/quickconnect_test.go new file mode 100644 index 000000000..b03186807 --- /dev/null +++ b/core/quickconnect/quickconnect_test.go @@ -0,0 +1,190 @@ +package quickconnect + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"} + +var _ = Describe("QuickConnect", func() { + var qc QuickConnect + + BeforeEach(func() { + qc = New() + }) + + Describe("Initiate", func() { + It("creates a pending request with a 6-digit code and a random secret", func() { + req, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`)) + Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`)) + Expect(req.Device).To(Equal(device)) + Expect(req.DateAdded).ToNot(BeZero()) + Expect(req.Authorized()).To(BeFalse()) + }) + + It("never gives two live requests the same code or secret", func() { + codes := map[string]bool{} + secrets := map[string]bool{} + for range 500 { + req, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + codes[req.Code] = true + secrets[req.Secret] = true + } + Expect(codes).To(HaveLen(500)) + Expect(secrets).To(HaveLen(500)) + }) + + It("refuses new requests when too many are pending", func() { + for range maxPending { + _, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + } + _, err := qc.Initiate(device) + Expect(err).To(MatchError(ErrTooManyRequests)) + }) + }) + + Describe("Status", func() { + It("returns the request for a known secret", func() { + req, _ := qc.Initiate(device) + got, err := qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(req)) + }) + + It("returns ErrNotFound for an unknown secret", func() { + _, err := qc.Status("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Lookup", func() { + It("finds a request by code, ignoring spaces", func() { + req, _ := qc.Initiate(device) + got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:]) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(req)) + }) + + It("returns ErrNotFound for an unknown code", func() { + _, err := qc.Lookup("000000") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("refuses a request that is already authorized", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + _, err := qc.Lookup(req.Code) + Expect(err).To(MatchError(ErrAlreadyAuthorized)) + }) + }) + + Describe("Authorize", func() { + It("marks the request as authorized by the user", func() { + req, _ := qc.Initiate(device) + got, err := qc.Authorize(" "+req.Code+" ", "user-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Authorized()).To(BeTrue()) + Expect(got.UserID).To(Equal("user-1")) + + status, _ := qc.Status(req.Secret) + Expect(status.Authorized()).To(BeTrue()) + }) + + It("refuses a request that is already authorized", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + _, err := qc.Authorize(req.Code, "user-2") + Expect(err).To(MatchError(ErrAlreadyAuthorized)) + }) + + It("returns ErrNotFound for an unknown code", func() { + _, err := qc.Authorize("000000", "user-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Redeem", func() { + It("returns the approving user once, then forgets the request", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + + userID, err := qc.Redeem(req.Secret) + Expect(err).ToNot(HaveOccurred()) + Expect(userID).To(Equal("user-1")) + + _, err = qc.Redeem(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Status(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Lookup(req.Code) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("refuses a request that is not authorized yet", func() { + req, _ := qc.Initiate(device) + _, err := qc.Redeem(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give. +func TestPendingRequestExpires(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + req, _ := qc.Initiate(device) + + time.Sleep(timeout - time.Second) + _, err := qc.Status(req.Secret) + g.Expect(err).ToNot(HaveOccurred()) + + time.Sleep(2 * time.Second) + _, err = qc.Status(req.Secret) + g.Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Authorize(req.Code, "user-1") + g.Expect(err).To(MatchError(model.ErrNotFound)) + }) +} + +func TestAuthorizeExtendsExpiry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + req, _ := qc.Initiate(device) + + time.Sleep(timeout - time.Second) + _, err := qc.Authorize(req.Code, "user-1") + g.Expect(err).ToNot(HaveOccurred()) + + time.Sleep(timeout - time.Second) + userID, err := qc.Redeem(req.Secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(userID).To(Equal("user-1")) + }) +} + +func TestExpiredRequestsFreeCapacity(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + for range maxPending { + _, _ = qc.Initiate(device) + } + time.Sleep(timeout + time.Second) + _, err := qc.Initiate(device) + g.Expect(err).ToNot(HaveOccurred()) + }) +} diff --git a/core/wire_providers.go b/core/wire_providers.go index a09fcc108..b3df9b2dc 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/stream" ) @@ -32,6 +33,7 @@ var Set = wire.NewSet( ffmpeg.New, scrobbler.GetPlayTracker, playback.GetInstance, + quickconnect.GetInstance, metrics.GetInstance, lyrics.NewLyrics, ) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 893642cdd..c0c5e6ca1 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -24,6 +24,8 @@ ServerName = "My Music Server" ExposedPublicUsers = "alice, bob" # Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". AutoDiscovery = true +# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect". +QuickConnect = false # Optional: max collection responses streaming at once (default: half the DB connection pool, # min 2). Each streaming response holds a DB connection for its whole duration; excess requests # queue rather than fail. @@ -37,6 +39,7 @@ ND_JELLYFIN_ENABLED=true ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" ND_JELLYFIN_AUTODISCOVERY=true +ND_JELLYFIN_QUICKCONNECT=false ``` Once enabled, the API is mounted at: @@ -82,6 +85,27 @@ surface. Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. +### Quick Connect + +Quick Connect signs a new device in without typing a password. The client shows a 6-digit code, +a signed-in user approves it, and the client gets its `AccessToken`. It is on by default +(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other +Quick Connect call returns 401. + +1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the + auth header) returns the `Code` and a `Secret`. +2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which + shows the app and device before approving) or from a signed-in Jellyfin client with + `POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user. +3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`. +4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as + `AuthenticateByName`. + +Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike +Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval +(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some +clients poll it every second. + ### Public user list (login picker) `GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the @@ -140,7 +164,8 @@ returns direct children only (no tracks — no track is a library's direct child | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) | +| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index eddd14f66..4a471d1f0 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -40,17 +41,18 @@ type Router struct { broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group + quickConnect quickconnect.QuickConnect serverIDVal string } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, @@ -81,6 +83,11 @@ func (api *Router) routes() http.Handler { login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) } login.Post("/users/authenticatebyname", api.authenticateByName) + quickConnectLogin := login.With(requireQuickConnect) + quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate) + quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect) + // Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown. + inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect) inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. @@ -107,6 +114,12 @@ func (api *Router) routes() http.Handler { r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) r.Get("/users/{userId}", api.getCurrentUser) + // Throttled like login so a signed-in user cannot enumerate other people's pending codes. + approve := r.With(requireQuickConnect) + if conf.Server.AuthRequestLimit > 0 { + approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) + } + approve.Post("/quickconnect/authorize", api.quickConnectAuthorize) // Cursor-backed collections: each streams straight from the DB, holding a connection for the // whole client-paced response, so enough slow clients would take the entire pool and stall the diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 605303793..a64dcfe8f 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -19,7 +20,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -27,7 +28,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -37,7 +38,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -54,7 +55,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -71,7 +72,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() @@ -86,11 +87,38 @@ var _ = Describe("Router", func() { Expect(login()).To(Equal(http.StatusTooManyRequests)) }) + It("rate-limits Quick Connect approval by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + conf.Server.Jellyfin.QuickConnect = true + ds := &tests.MockDataStore{} + auth.Init(ds) + usr := model.User{ID: testID("alice"), UserName: "alice"} + Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New()) + + authorize := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + return w.Code + } + // An unknown code is 404; the limiter cuts in on the 3rd attempt with 429. + Expect(authorize()).To(Equal(http.StatusNotFound)) + Expect(authorize()).To(Equal(http.StatusNotFound)) + Expect(authorize()).To(Equal(http.StatusTooManyRequests)) + }) + It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 1 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Every request arrives on the same proxy connection, so only the resolved client IP // can separate the buckets. handler := middleware.ClientIPFromHeader("X-Real-IP")(api) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index ba4fe40f1..32b4a1432 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -30,10 +30,15 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + api.signIn(w, r, usr) +} + +func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) { + ctx := r.Context() // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a // never/stale "Last Login" in the admin UI. if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { - log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) + log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err) } token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 21e3903f2..4a28eb313 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -233,6 +233,17 @@ type PlayerStateInfo struct { PlaybackOrder string `json:"PlaybackOrder"` } +type QuickConnectResult struct { + Authenticated bool `json:"Authenticated"` + Secret string `json:"Secret"` + Code string `json:"Code"` + DeviceId string `json:"DeviceId"` + DeviceName string `json:"DeviceName"` + AppName string `json:"AppName"` + AppVersion string `json:"AppVersion"` + DateAdded string `json:"DateAdded"` +} + type AuthenticationResult struct { User *UserDto `json:"User"` SessionInfo *SessionInfo `json:"SessionInfo"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bb17b40c7..fc7e329de 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -51,16 +51,16 @@ func premiereDate(date string, year int) *string { if err != nil { return nil } - s := jellyfinDate(&parsed) + s := JellyfinDate(&parsed) return &s } // Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339. const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00" -// jellyfinDate formats t as the date string clients expect, or "" for the zero time so the +// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the // field is omitted rather than sent as a meaningless epoch. -func jellyfinDate(t *time.Time) string { +func JellyfinDate(t *time.Time) string { if t == nil || t.IsZero() { return "" } @@ -135,7 +135,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 d.Rating = &r } - if s := jellyfinDate(a.PlayDate); s != "" { + if s := JellyfinDate(a.PlayDate); s != "" { d.LastPlayedDate = &s } return d @@ -159,7 +159,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { AlbumId: albumID, AlbumArtist: mf.AlbumArtist, RunTimeTicks: TicksFromSeconds(mf.Duration), - DateCreated: jellyfinDate(&mf.CreatedAt), + DateCreated: JellyfinDate(&mf.CreatedAt), Container: mf.Suffix, CanDownload: true, BackdropImageTags: []string{}, @@ -265,7 +265,7 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { ChildCount: new(al.SongCount), SongCount: new(al.SongCount), RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: jellyfinDate(&al.CreatedAt), + DateCreated: JellyfinDate(&al.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -318,7 +318,7 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { IsFolder: true, AlbumCount: new(ar.AlbumCount), SongCount: new(ar.SongCount), - DateCreated: jellyfinDate(ar.CreatedAt), + DateCreated: JellyfinDate(ar.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -346,7 +346,7 @@ func LibraryToBaseItem(lib model.Library) BaseItemDto { IsFolder: true, Path: lib.Path, LocationType: "FileSystem", - DateCreated: jellyfinDate(&lib.CreatedAt), + DateCreated: JellyfinDate(&lib.CreatedAt), ChildCount: new(lib.TotalAlbums), UserData: &UserItemDataDto{Key: id, ItemId: id}, BackdropImageTags: []string{}, @@ -386,7 +386,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { MediaType: "Audio", ChildCount: new(p.SongCount), RunTimeTicks: TicksFromSeconds(p.Duration), - DateCreated: jellyfinDate(&p.CreatedAt), + DateCreated: JellyfinDate(&p.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -460,7 +460,7 @@ func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, server DeviceName: deviceName, ApplicationVersion: version, ServerId: serverID, - LastActivityDate: jellyfinDate(&now), + LastActivityDate: JellyfinDate(&now), IsActive: true, PlayableMediaTypes: []string{"Audio"}, SupportedCommands: []string{}, diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 6dc177363..259904673 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -17,10 +17,10 @@ var _ = Describe("mappers", func() { ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, + Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, + PlayCount: 2, + Starred: true, } - mf.PlayCount = 2 - mf.Starred = true item := SongToBaseItem(mf, nil) Expect(item.Type).To(Equal("Audio")) Expect(item.MediaType).To(Equal("Audio")) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 1ee19ea01..aa93f7e38 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -44,6 +44,7 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -338,6 +339,7 @@ func setupTestDB() { sonicSvc, lyrics.NewLyrics(ds, nil), events.NoopBroker(), + quickconnect.New(), ) } diff --git a/server/jellyfin/e2e/quickconnect_test.go b/server/jellyfin/e2e/quickconnect_test.go new file mode 100644 index 000000000..1beffc56f --- /dev/null +++ b/server/jellyfin/e2e/quickconnect_test.go @@ -0,0 +1,91 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QuickConnect", func() { + BeforeEach(func() { + setupTestDB() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.QuickConnect = true + }) + + clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`) + r.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, r) + return w + } + initiate := func() dto.QuickConnectResult { + var pending dto.QuickConnectResult + parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending) + Expect(pending.Authenticated).To(BeFalse()) + return pending + } + redeem := func(deviceID, secret string) *httptest.ResponseRecorder { + return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`) + } + + It("signs a new client in with a code approved by a signed-in user", func() { + Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true")) + pending := initiate() + + Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK)) + + var status dto.QuickConnectResult + parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status) + Expect(status.Authenticated).To(BeTrue()) + + // Android TV redeems with a different DeviceId than it initiated with. + w := redeem("other-device", pending.Secret) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Name).To(Equal(regularUser.UserName)) + Expect(res.SessionInfo).ToNot(BeNil()) + Expect(res.SessionInfo.DeviceId).To(Equal("other-device")) + + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + me := httptest.NewRecorder() + router.ServeHTTP(me, r) + Expect(me.Code).To(Equal(http.StatusOK)) + + Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + It("lets an admin approve a code for another user", func() { + pending := initiate() + Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code). + To(Equal(http.StatusOK)) + + var res dto.AuthenticationResult + parseInto(redeem("new-device", pending.Secret), &res) + Expect(res.User.Name).To(Equal(regularUser.UserName)) + }) + + It("requires authentication to approve a code", func() { + pending := initiate() + Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("answers 401 on every Quick Connect call when disabled", func() { + conf.Server.Jellyfin.QuickConnect = false + Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false")) + Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index ac8c350c4..51443bdc9 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -81,12 +81,4 @@ var _ = Describe("System", func() { Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) }) }) - - Describe("GET /QuickConnect/Enabled", func() { - It("reports QuickConnect disabled", func() { - w := rawReq("GET", "/QuickConnect/Enabled", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) - }) - }) }) diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go new file mode 100644 index 000000000..c478b1894 --- /dev/null +++ b/server/jellyfin/quickconnect.go @@ -0,0 +1,145 @@ +package jellyfin + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +func requireQuickConnect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !conf.Server.Jellyfin.QuickConnect { + http.Error(w, "Quick connect is disabled", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, conf.Server.Jellyfin.QuickConnect) +} + +// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded. +const maxQuickConnectField = 512 + +func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) { + a := parseMediaBrowserAuth(r) + if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" || + max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField { + http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest) + return + } + req, err := api.quickConnect.Initiate(quickconnect.Device{ + ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version, + }) + if errors.Is(err, quickconnect.ErrTooManyRequests) { + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, quickConnectResult(req)) +} + +func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) { + req, err := api.quickConnect.Status(r.URL.Query().Get("secret")) + if err != nil { + http.Error(w, "Unknown secret", http.StatusNotFound) + return + } + api.ok(w, r, quickConnectResult(req)) +} + +func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + caller, _ := request.UserFrom(ctx) + userID := caller.ID + if encoded := r.URL.Query().Get("userid"); encoded != "" { + var ok bool + if userID, ok = dto.DecodeID(encoded); !ok { + http.Error(w, "Invalid userId", http.StatusBadRequest) + return + } + } + target := caller + if userID != caller.ID { + if !caller.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + usr, err := api.ds.User(ctx).Get(userID) + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Unknown user", http.StatusNotFound) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + target = *usr + } + + req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID) + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Unknown code", http.StatusNotFound) + case errors.Is(err, quickconnect.ErrAlreadyAuthorized): + http.Error(w, "Code already used", http.StatusConflict) + case err != nil: + api.internalError(w, r, err) + default: + log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName, + "approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name) + api.ok(w, r, true) + } +} + +func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body struct { + Secret string `json:"Secret"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + userID, err := api.quickConnect.Redeem(body.Secret) + if err != nil { + http.Error(w, "Unknown secret", http.StatusNotFound) + return + } + usr, err := api.ds.User(ctx).Get(userID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + api.signIn(w, r, usr) +} + +func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult { + return dto.QuickConnectResult{ + Authenticated: req.Authorized(), + Secret: req.Secret, + Code: req.Code, + DeviceId: req.Device.ID, + DeviceName: req.Device.Name, + AppName: req.Device.App, + AppVersion: req.Device.AppVersion, + DateAdded: dto.JellyfinDate(&req.DateAdded), + } +} diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go new file mode 100644 index 000000000..0478a356a --- /dev/null +++ b/server/jellyfin/quickconnect_test.go @@ -0,0 +1,301 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fullQuickConnect struct{ quickconnect.QuickConnect } + +func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) { + return quickconnect.Request{}, quickconnect.ErrTooManyRequests +} + +var _ = Describe("QuickConnect", func() { + const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"` + var ( + api *Router + ds *tests.MockDataStore + qc quickconnect.QuickConnect + alice = model.User{ID: testID("alice"), UserName: "alice"} + bob = model.User{ID: testID("bob"), UserName: "bob"} + admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true} + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.QuickConnect = true + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + for _, u := range []model.User{alice, bob, admin} { + Expect(ur.Put(&u)).To(Succeed()) + } + qc = quickconnect.New() + api = &Router{ds: ds, quickConnect: qc} + }) + + as := func(r *http.Request, u model.User) *http.Request { + return r.WithContext(request.WithUser(r.Context(), u)) + } + initiate := func() quickconnect.Request { + req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + return req + } + + Describe("GET /QuickConnect/Enabled", func() { + DescribeTable("reports the config value", + func(enabled bool, expected string) { + conf.Server.Jellyfin.QuickConnect = enabled + w := httptest.NewRecorder() + api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON(expected)) + }, + Entry("enabled", true, "true"), + Entry("disabled", false, "false"), + ) + }) + + Describe("requireQuickConnect", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) + + It("rejects requests with 401 when Quick Connect is disabled", func() { + conf.Server.Jellyfin.QuickConnect = false + w := httptest.NewRecorder() + requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("passes requests through when Quick Connect is enabled", func() { + w := httptest.NewRecorder() + requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) + Expect(w.Code).To(Equal(http.StatusTeapot)) + }) + }) + + Describe("POST /QuickConnect/Initiate", func() { + initiateWith := func(authHeader string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil) + r.Header.Set("Authorization", authHeader) + api.quickConnectInitiate(w, r) + return w + } + + It("returns all QuickConnectResult fields, with the device from the auth header", func() { + w := initiateWith(finamp) + + Expect(w.Code).To(Equal(http.StatusOK)) + var raw map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) + // sdk-kotlin declares every field non-null. + Expect(raw).To(HaveKeyWithValue("Authenticated", false)) + Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`))) + Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`))) + Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1")) + Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7")) + Expect(raw).To(HaveKeyWithValue("AppName", "Finamp")) + Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0")) + Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty()))) + + _, err := qc.Status(raw["Secret"].(string)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns 400 when the auth header does not identify the client", func() { + w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when a client field is oversized", func() { + long := strings.Repeat("x", maxQuickConnectField+1) + api.quickConnect = fullQuickConnect{qc} + w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 429 when too many requests are pending", func() { + api.quickConnect = fullQuickConnect{qc} + Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests)) + }) + }) + + Describe("GET /QuickConnect/Connect", func() { + connect := func(secret string) (int, dto.QuickConnectResult) { + w := httptest.NewRecorder() + invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil)) + var res dto.QuickConnectResult + if w.Code == http.StatusOK { + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + } + return w.Code, res + } + + It("reports whether the request has been authorized", func() { + req := initiate() + code, res := connect(req.Secret) + Expect(code).To(Equal(http.StatusOK)) + Expect(res.Authenticated).To(BeFalse()) + Expect(res.Code).To(Equal(req.Code)) + + _, err := qc.Authorize(req.Code, alice.ID) + Expect(err).ToNot(HaveOccurred()) + code, res = connect(req.Secret) + Expect(code).To(Equal(http.StatusOK)) + Expect(res.Authenticated).To(BeTrue()) + }) + + It("returns 404 for an unknown secret", func() { + code, _ := connect("unknown") + Expect(code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("POST /QuickConnect/Authorize", func() { + authorize := func(query string, u model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u)) + return w + } + approver := func(req quickconnect.Request) string { + got, err := qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + return got.UserID + } + + It("approves the code for the caller when no userId is given", func() { + req := initiate() + w := authorize("code="+req.Code, alice) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON("true")) + Expect(approver(req)).To(Equal(alice.ID)) + }) + + It("accepts the caller's own userId", func() { + req := initiate() + w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(approver(req)).To(Equal(alice.ID)) + }) + + It("forbids a non-admin from approving for another user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(approver(req)).To(BeEmpty()) + }) + + It("lets an admin approve for another user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(approver(req)).To(Equal(bob.ID)) + }) + + It("returns 404 when an admin approves for an unknown user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(approver(req)).To(BeEmpty()) + }) + + It("returns 400 for a malformed userId", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId=not-a-guid", admin) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 for an unknown code", func() { + w := authorize("code=000000", alice) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + req := initiate() + Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK)) + Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict)) + Expect(approver(req)).To(Equal(alice.ID)) + }) + }) + + Describe("POST /Users/AuthenticateWithQuickConnect", func() { + redeem := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body)) + r.Header.Set("Authorization", finamp) + api.authenticateWithQuickConnect(w, r) + return w + } + redeemSecret := func(secret string) *httptest.ResponseRecorder { + return redeem(`{"Secret":"` + secret + `"}`) + } + + It("signs in the approving user once", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + + w := redeemSecret(req.Secret) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Name).To(Equal("alice")) + Expect(res.ServerId).ToNot(BeEmpty()) + Expect(res.SessionInfo).ToNot(BeNil()) + Expect(res.SessionInfo.DeviceId).To(Equal("dev-1")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + It("accepts a camelCase body", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 while the request is not approved", func() { + req := initiate() + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + DescribeTable("returns 400 for a bad body", + func(body string) { + Expect(redeem(body).Code).To(Equal(http.StatusBadRequest)) + }, + Entry("not JSON", `nope`), + Entry("no secret", `{}`), + ) + + It("returns 401 when the approving user no longer exists", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, testID("ghost")) + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 500 when the user lookup fails", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + ds.User(context.Background()).(*tests.MockedUserRepo).Error = errors.New("db down") + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index 62b52cfff..70da5b1eb 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 401c791fd..b4be0e244 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 35ade5ff1..49e41470c 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -153,7 +153,3 @@ func parseIP(addr string) netip.Addr { } return ip.Unmap() } - -func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, false) -} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index c9368f3b5..2350a7342 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -145,17 +145,6 @@ var _ = Describe("System", func() { Expect(info.IsInNetwork).To(BeTrue()) }) - It("reports quick connect as disabled", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) - api.quickConnectEnabled(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var enabled bool - Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed()) - Expect(enabled).To(BeFalse()) - }) - Context("serverID with a real DataStore", func() { var ctx context.Context var ds *tests.MockDataStore diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index d1007f457..7c4f00fdd 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 13b33c238..cef2e06ad 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go index ebc9aeb28..ae6e301a8 100644 --- a/server/nativeapi/metadata_test.go +++ b/server/nativeapi/metadata_test.go @@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() { } auth.Init(ds) provider = &fakeProvider{} - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) router = server.JWTVerifier(nativeRouter) adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go index 4da550c0d..9d7575a0c 100644 --- a/server/nativeapi/missing_test.go +++ b/server/nativeapi/missing_test.go @@ -40,7 +40,7 @@ var _ = Describe("Missing Files Endpoint", func() { token, err = auth.CreateToken(&user) Expect(err).ToNot(HaveOccurred()) - router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)) + router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) }) DescribeTable("GET /missing/{id}", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 57a712a20..f931834c6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -48,10 +49,11 @@ type Router struct { pluginManager PluginManager imgUpload artwork.Uploader provider external.Provider + quickConnect quickconnect.QuickConnect } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect} r.Handler = r.routes() return r } @@ -88,6 +90,7 @@ func (api *Router) routes() http.Handler { api.addMissingFilesRoute(r) api.addKeepAliveRoute(r) api.addInsightsRoute(r) + api.addQuickConnectRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { api.addInspectRoute(r) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index 203fcd4cf..954c872a7 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 4a6f5d03d..2f6c578f9 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 1683885e7..4e45ddb92 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/quickconnect.go b/server/nativeapi/quickconnect.go new file mode 100644 index 000000000..61d402cc5 --- /dev/null +++ b/server/nativeapi/quickconnect.go @@ -0,0 +1,76 @@ +package nativeapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type quickConnectDevice struct { + AppName string `json:"appName"` + AppVersion string `json:"appVersion"` + DeviceName string `json:"deviceName"` +} + +func (api *Router) addQuickConnectRoute(r chi.Router) { + if !quickconnect.Enabled() { + return + } + r.Route("/quickconnect", func(r chi.Router) { + // Throttled like login so a signed-in user cannot enumerate other people's pending codes. + if conf.Server.AuthRequestLimit > 0 { + r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) + } + r.Get("/", lookupQuickConnect(api.quickConnect)) + r.Post("/authorize", authorizeQuickConnect(api.quickConnect)) + }) +} + +func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := qc.Lookup(r.URL.Query().Get("code")) + writeQuickConnectResult(w, r, req, err) + } +} + +func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ctx := r.Context() + user, _ := request.UserFrom(ctx) + req, err := qc.Authorize(body.Code, user.ID) + if err == nil { + log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name) + } + writeQuickConnectResult(w, r, req, err) + } +} + +func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) { + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Unknown code", http.StatusNotFound) + case errors.Is(err, quickconnect.ErrAlreadyAuthorized): + http.Error(w, "Code already used", http.StatusConflict) + case err != nil: + log.Error(r.Context(), "Quick Connect failed", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + default: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name}) + } +} diff --git a/server/nativeapi/quickconnect_test.go b/server/nativeapi/quickconnect_test.go new file mode 100644 index 000000000..f2f06f0f3 --- /dev/null +++ b/server/nativeapi/quickconnect_test.go @@ -0,0 +1,148 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Quick Connect endpoints", func() { + var ( + qc quickconnect.QuickConnect + pending quickconnect.Request + user = model.User{ID: "u1", UserName: "alice"} + ) + + BeforeEach(func() { + qc = quickconnect.New() + var err error + pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + }) + + asUser := func(r *http.Request) *http.Request { + return r.WithContext(request.WithUser(r.Context(), user)) + } + decode := func(w *httptest.ResponseRecorder) quickConnectDevice { + var res quickConnectDevice + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + return res + } + finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"} + + Describe("GET /quickconnect", func() { + lookup := func(code string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil))) + return w + } + + It("describes the device waiting for the code", func() { + w := lookup(pending.Code) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decode(w)).To(Equal(finamp)) + }) + + It("does not approve the code", func() { + lookup(pending.Code) + got, _ := qc.Status(pending.Secret) + Expect(got.Authorized()).To(BeFalse()) + }) + + It("returns 404 for an unknown code", func() { + Expect(lookup("000000").Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + _, _ = qc.Authorize(pending.Code, "someone") + Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict)) + }) + }) + + Describe("POST /quickconnect/authorize", func() { + authorize := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body)))) + return w + } + + It("approves the code for the signed-in user", func() { + w := authorize(`{"code":"` + pending.Code + `"}`) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decode(w)).To(Equal(finamp)) + got, _ := qc.Status(pending.Secret) + Expect(got.UserID).To(Equal(user.ID)) + }) + + It("returns 404 for an unknown code", func() { + Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + _, _ = qc.Authorize(pending.Code, "someone") + Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict)) + }) + + It("returns 400 for a bad body", func() { + Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("route registration", func() { + serve := func() int { + r := chi.NewRouter() + (&Router{quickConnect: qc}).addQuickConnectRoute(r) + w := httptest.NewRecorder() + r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil))) + return w.Code + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.Enabled = true + conf.Server.Jellyfin.QuickConnect = true + }) + + It("mounts the routes when Jellyfin Quick Connect is on", func() { + Expect(serve()).To(Equal(http.StatusOK)) + }) + + It("rate-limits code attempts when a login limit is configured", func() { + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + r := chi.NewRouter() + (&Router{quickConnect: qc}).addQuickConnectRoute(r) + attempt := func() int { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil) + req.RemoteAddr = "10.0.0.1:1234" + r.ServeHTTP(w, asUser(req)) + return w.Code + } + Expect(attempt()).To(Equal(http.StatusNotFound)) + Expect(attempt()).To(Equal(http.StatusNotFound)) + Expect(attempt()).To(Equal(http.StatusTooManyRequests)) + }) + + It("skips the routes when the Jellyfin API is off", func() { + conf.Server.Jellyfin.Enabled = false + Expect(serve()).To(Equal(http.StatusNotFound)) + }) + + It("skips the routes when Quick Connect is off", func() { + conf.Server.Jellyfin.QuickConnect = false + Expect(serve()).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go index 2a363980f..27454fc7d 100644 --- a/server/nativeapi/user_password_token_refresh_test.go +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() auth.Init(ds) userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) }) diff --git a/server/serve_index.go b/server/serve_index.go index a538daf1a..651c8c907 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -75,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, + "enableQuickConnect": quickconnect.Enabled(), "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 78f3873b8..23215a5ef 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -97,6 +97,8 @@ var _ = Describe("serveIndex", func() { Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true), Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true), Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true), + Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true), + Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false), Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true), Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true), Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"), diff --git a/ui/src/config.js b/ui/src/config.js index 39f0cd467..e406e47cf 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -38,6 +38,7 @@ const defaultConfig = { devUIShowConfig: true, devNewEventStream: false, enableReplayGain: true, + enableQuickConnect: false, defaultDownsamplingFormat: 'opus', publicBaseUrl: '/share', separator: '/', diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index e79beb787..f0e44ce1d 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -218,6 +218,15 @@ const wrapperDataProvider = { ({ json }) => ({ data: json }), ) }, + lookupQuickConnect: (code) => + httpClient( + `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`, + ).then(({ json }) => ({ data: json })), + authorizeQuickConnect: (code) => + httpClient(`${REST_URL}/quickconnect/authorize`, { + method: 'POST', + body: JSON.stringify({ code }), + }).then(({ json }) => ({ data: json })), inspect: (songId) => { return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ data: json, diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx new file mode 100644 index 000000000..cde7e9eaf --- /dev/null +++ b/ui/src/dialogs/QuickConnectDialog.jsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { useDataProvider, useNotify, useTranslate } from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from '@material-ui/core' + +export const QuickConnectDialog = ({ open, onClose }) => { + const translate = useTranslate() + const notify = useNotify() + const dataProvider = useDataProvider() + const [code, setCode] = useState('') + const [pending, setPending] = useState(null) + const [loading, setLoading] = useState(false) + + const handleClose = () => { + setCode('') + setPending(null) + onClose() + } + + const handleError = (error) => { + setPending(null) + const invalid = error?.status === 404 || error?.status === 409 + notify( + invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError', + 'warning', + ) + } + + const run = (request, onSuccess) => { + setLoading(true) + request + .then(({ data }) => onSuccess(data)) + .catch(handleError) + .finally(() => setLoading(false)) + } + + const lookup = (event) => { + event.preventDefault() + run(dataProvider.lookupQuickConnect(code), setPending) + } + + const approve = () => + run(dataProvider.authorizeQuickConnect(code), (data) => { + notify('message.quickConnectApproved', 'success', { + app: data.appName, + device: data.deviceName, + }) + handleClose() + }) + + return ( + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="quick-connect-dialog" + fullWidth + maxWidth="xs" + > + <DialogTitle id="quick-connect-dialog"> + {translate('menu.quickConnect.name')} + </DialogTitle> + {pending ? ( + <> + <DialogContent> + <DialogContentText> + {translate('menu.quickConnect.confirm', { + app: pending.appName, + version: pending.appVersion, + device: pending.deviceName, + })} + </DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={() => setPending(null)} color="primary"> + {translate('ra.action.back')} + </Button> + <Button onClick={approve} color="primary" disabled={loading}> + {translate('menu.quickConnect.approve')} + </Button> + </DialogActions> + </> + ) : ( + <form onSubmit={lookup}> + <DialogContent> + <DialogContentText> + {translate('menu.quickConnect.help')} + </DialogContentText> + <TextField + id="quickConnectCode" + variant="outlined" + fullWidth + autoFocus + label={translate('menu.quickConnect.code')} + value={code} + onChange={(event) => setCode(event.target.value)} + inputProps={{ inputMode: 'numeric', autoComplete: 'off' }} + /> + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="primary"> + {translate('ra.action.cancel')} + </Button> + <Button + type="submit" + color="primary" + disabled={!code.trim() || loading} + > + {translate('menu.quickConnect.continue')} + </Button> + </DialogActions> + </form> + )} + </Dialog> + ) +} + +QuickConnectDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx new file mode 100644 index 000000000..5dda1cbdf --- /dev/null +++ b/ui/src/dialogs/QuickConnectDialog.test.jsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { DataProviderContext } from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { describe, afterEach, it, expect, vi } from 'vitest' +import { QuickConnectDialog } from './QuickConnectDialog' + +const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' } + +const renderDialog = (dataProvider, onClose = vi.fn()) => { + render( + <DataProviderContext.Provider value={dataProvider}> + <TestContext initialState={{ admin: { ui: { optimistic: false } } }}> + <QuickConnectDialog open={true} onClose={onClose} /> + </TestContext> + </DataProviderContext.Provider>, + ) + return onClose +} + +const enterCode = (code) => { + fireEvent.change(screen.getByRole('textbox'), { target: { value: code } }) + fireEvent.click(screen.getByText('menu.quickConnect.continue')) +} + +describe('QuickConnectDialog', () => { + afterEach(cleanup) + + it('shows the device before approving the code', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + } + const onClose = renderDialog(dataProvider) + + enterCode('123 456') + await screen.findByText('menu.quickConnect.approve') + expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456') + expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('menu.quickConnect.approve')) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456') + }) + + it('goes back to the code input', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn(), + } + renderDialog(dataProvider) + + enterCode('123456') + await screen.findByText('menu.quickConnect.approve') + fireEvent.click(screen.getByText('ra.action.back')) + + expect(screen.getByRole('textbox')).toHaveValue('123456') + expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() + }) + + it('stays on the code input when the code is unknown', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }), + authorizeQuickConnect: vi.fn(), + } + const onClose = renderDialog(dataProvider) + + enterCode('000000') + await waitFor(() => + expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(), + ) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText('menu.quickConnect.approve')).toBeNull() + expect(onClose).not.toHaveBeenCalled() + }) + + it('returns to the code input when approval fails', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }), + } + const onClose = renderDialog(dataProvider) + + enterCode('123456') + fireEvent.click(await screen.findByText('menu.quickConnect.approve')) + + await screen.findByRole('textbox') + expect(onClose).not.toHaveBeenCalled() + }) + + it('disables Continue until a code is typed', () => { + renderDialog({ lookupQuickConnect: vi.fn() }) + expect( + screen.getByText('menu.quickConnect.continue').closest('button'), + ).toBeDisabled() + }) +}) diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 86586aef0..3234e4685 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -2,4 +2,5 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' export * from './SaveQueueDialog' +export * from './QuickConnectDialog' export * from './Dialogs' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index fd200fa60..f5af05b7b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -591,6 +591,9 @@ "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", + "quickConnectApproved": "%{app} on %{device} is now signed in", + "quickConnectInvalidCode": "Invalid or expired code", + "quickConnectError": "Could not approve the code", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", "lastfmLinkFailure": "Last.fm could not be linked", "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", @@ -622,6 +625,14 @@ "none": "None" }, "settings": "Settings", + "quickConnect": { + "name": "Quick Connect", + "code": "Code", + "help": "Enter the code shown by a Jellyfin app to sign it in to your account", + "confirm": "Sign in %{app} %{version} on %{device} to your account?", + "continue": "Continue", + "approve": "Approve" + }, "version": "Version", "theme": "Theme", "personal": { diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 561701dce..7de111e67 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -6,12 +6,17 @@ import { usePermissions, getResources, } from 'react-admin' -import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md' +import { + MdInfo, + MdPerson, + MdPhonelink, + MdSupervisorAccount, +} from 'react-icons/md' import { useSelector } from 'react-redux' import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core' import ViewListIcon from '@material-ui/icons/ViewList' import { Dialogs } from '../dialogs/Dialogs' -import { AboutDialog } from '../dialogs' +import { AboutDialog, QuickConnectDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' import NowPlayingPanel from './NowPlayingPanel' @@ -33,33 +38,34 @@ const useStyles = makeStyles( }, ) -const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { - const classes = useStyles(rest) - const translate = useTranslate() - const [open, setOpen] = React.useState(false) +const DialogMenuItem = forwardRef( + ({ onClick, label, icon, dialog, ...rest }, ref) => { + const classes = useStyles(rest) + const [open, setOpen] = React.useState(false) - const handleOpen = () => { - setOpen(true) - } - const handleClose = () => { - onClick && onClick() - setOpen(false) - } - const label = translate('menu.about') - return ( - <> - <MenuItem ref={ref} onClick={handleOpen} className={classes.root}> - <ListItemIcon className={classes.icon}> - <MdInfo title={label} size={24} /> - </ListItemIcon> - {label} - </MenuItem> - <AboutDialog onClose={handleClose} open={open} /> - </> - ) -}) + const handleClose = () => { + onClick && onClick() + setOpen(false) + } + return ( + <> + <MenuItem + ref={ref} + onClick={() => setOpen(true)} + className={classes.root} + > + <ListItemIcon className={classes.icon}> + {createElement(icon, { title: label, size: 24 })} + </ListItemIcon> + {label} + </MenuItem> + {createElement(dialog, { onClose: handleClose, open })} + </> + ) + }, +) -AboutMenuItem.displayName = 'AboutMenuItem' +DialogMenuItem.displayName = 'DialogMenuItem' const settingsResources = (resource) => resource.name !== 'user' && @@ -126,13 +132,24 @@ const CustomUserMenu = ({ onClick, ...rest }) => { {config.devActivityPanel && permissions === 'admin' && <ActivityPanel />} <UserMenu {...rest}> <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> + {config.enableQuickConnect && ( + <DialogMenuItem + label={translate('menu.quickConnect.name')} + icon={MdPhonelink} + dialog={QuickConnectDialog} + /> + )} <Divider /> {renderUserMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} <Divider /> - <AboutMenuItem /> + <DialogMenuItem + label={translate('menu.about')} + icon={MdInfo} + dialog={AboutDialog} + /> </UserMenu> <Dialogs /> </> diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx index f39dd75cb..7c8cd3dcd 100644 --- a/ui/src/layout/AppBar.test.jsx +++ b/ui/src/layout/AppBar.test.jsx @@ -33,12 +33,14 @@ vi.mock('../dialogs/Dialogs', () => ({ })) vi.mock('../dialogs', () => ({ AboutDialog: () => <div />, + QuickConnectDialog: () => <div />, })) describe('<AppBar />', () => { beforeEach(() => { config.devActivityPanel = true config.enableNowPlaying = true + config.enableQuickConnect = false store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -62,4 +64,24 @@ describe('<AppBar />', () => { ) expect(screen.queryByTestId('now-playing-panel')).toBeNull() }) + + it('shows the Quick Connect menu item when enabled', () => { + config.enableQuickConnect = true + render( + <Provider store={store}> + <AppBar /> + </Provider>, + ) + expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0) + }) + + it('hides the Quick Connect menu item when disabled', () => { + render( + <Provider store={store}> + <AppBar /> + </Provider>, + ) + expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0) + expect(screen.queryAllByText('menu.about')).not.toHaveLength(0) + }) }) From fb45ad7b9c705c52b7a8df6051252e5a9010e16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 19 Sep 2026 17:08:49 -0400 Subject: [PATCH 549/596] fix(auth): ExtAuth logout redirect on unauthenticated loads, and warning spam from untrusted sources (#6176) * fix(ui): only redirect to ExtAuth logout URL for proxy-authenticated sessions react-admin calls authProvider.logout() when the boot-time checkAuth fails and after a 401, not only when the user clicks Logout. With ExtAuth.LogoutURL set, every unauthenticated page load (e.g. direct LAN access that bypasses the auth proxy) was sent to the IdP sign-out page and the login form was never shown. Redirect only when the page was authenticated by the reverse proxy (config.auth is present). Other sessions fall back to the login form. Fixes #6175 Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): only warn about untrusted ExtAuth sources when the header is sent UsernameFromExtAuthHeader checked the source IP before looking for the user header, so every request from an IP outside ExtAuth.TrustedSources logged a warning, even when it carried no header at all. With direct LAN access alongside a forward-auth proxy, a single polling client produced a constant stream of warnings (twice per Subsonic request, since the middleware chain resolves the username in both checkRequiredParameters and authenticate). Look for the header first and warn only when an untrusted source actually sends it, which is the case worth seeing: a misconfigured proxy or a spoof attempt. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- server/auth.go | 8 +++--- server/auth_test.go | 53 +++++++++++++++++++++++++++++++++++++ ui/src/authProvider.js | 3 ++- ui/src/authProvider.test.js | 47 ++++++++++++++++++++++++++++++++ ui/src/setupTests.js | 3 +++ 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 ui/src/authProvider.test.js diff --git a/server/auth.go b/server/auth.go index fdeb8c455..d9ade7b29 100644 --- a/server/auth.go +++ b/server/auth.go @@ -218,14 +218,14 @@ func UsernameFromExtAuthHeader(r *http.Request) string { log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.") return "" } - if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { - log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) - return "" - } username := r.Header.Get(conf.Server.ExtAuth.UserHeader) if username == "" { return "" } + if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { + log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) + return "" + } log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username) return username } diff --git a/server/auth_test.go b/server/auth_test.go index af9ffcaeb..a4d592c51 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -16,12 +16,15 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" ) var _ = Describe("Auth", func() { @@ -234,6 +237,56 @@ var _ = Describe("Auth", func() { }) }) + Describe("UsernameFromExtAuthHeader", func() { + var hook *test.Hook + var r *http.Request + + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16" + prevLevel := log.CurrentLevel() + l, h := test.NewNullLogger() + hook = h + prevLogger := log.SetDefaultLogger(l) + log.SetLevel(log.LevelWarn) + DeferCleanup(func() { + log.SetDefaultLogger(prevLogger) + log.SetLevel(prevLevel) + }) + r = httptest.NewRequest("GET", "/", nil) + }) + + warnings := func() []*logrus.Entry { + var ws []*logrus.Entry + for _, e := range hook.AllEntries() { + if e.Level == logrus.WarnLevel { + ws = append(ws, e) + } + } + return ws + } + + It("returns the username from a trusted source", func() { + r.Header.Set("Remote-User", "janedoe") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42")) + Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe")) + Expect(warnings()).To(BeEmpty()) + }) + + It("does not warn when an untrusted source sends no user header", func() { + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) + Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) + Expect(warnings()).To(BeEmpty()) + }) + + It("warns when an untrusted source sends the user header", func() { + r.Header.Set("Remote-User", "janedoe") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) + Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) + Expect(warnings()).To(HaveLen(1)) + Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication")) + }) + }) + Describe("tokenFromHeader", func() { It("returns the token when the Authorization header is set correctly", func() { req := httptest.NewRequest("GET", "/", nil) diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 18badbc9c..f93114ded 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -64,7 +64,8 @@ const authProvider = { logout: () => { removeItems() - if (config.extAuthLogoutURL) { + // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form + if (config.extAuthLogoutURL && config.auth) { window.location.href = config.extAuthLogoutURL return Promise.resolve(false) } diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js new file mode 100644 index 000000000..b16a39815 --- /dev/null +++ b/ui/src/authProvider.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import config from './config' +import authProvider from './authProvider' + +vi.mock('./config', () => ({ default: {} })) + +describe('authProvider.logout', () => { + const logoutURL = 'https://auth.example.com/signout' + + beforeEach(() => { + vi.stubGlobal('location', { href: '' }) + localStorage.setItem('is-authenticated', 'true') + localStorage.setItem('token', 'abc') + }) + + afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() + delete config.extAuthLogoutURL + delete config.auth + }) + + it('clears the stored session', async () => { + await authProvider.logout() + expect(localStorage.getItem('is-authenticated')).toBeNull() + expect(localStorage.getItem('token')).toBeNull() + }) + + it('does not redirect when no logout URL is configured', async () => { + config.auth = { id: '1' } + await expect(authProvider.logout()).resolves.toBeUndefined() + expect(window.location.href).toBe('') + }) + + it('redirects to the logout URL when the page was authenticated by the proxy', async () => { + config.extAuthLogoutURL = logoutURL + config.auth = { id: '1' } + await expect(authProvider.logout()).resolves.toBe(false) + expect(window.location.href).toBe(logoutURL) + }) + + it('does not redirect when the page was not authenticated by the proxy', async () => { + config.extAuthLogoutURL = logoutURL + await expect(authProvider.logout()).resolves.toBeUndefined() + expect(window.location.href).toBe('') + }) +}) diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index ddb999f3c..7cb46e09c 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -14,6 +14,9 @@ const localStorageMock = (function () { setItem: function (key, value) { store[key] = value.toString() }, + removeItem: function (key) { + delete store[key] + }, clear: function () { store = {} }, From 672c0af580b182ffb219dc5f64c62b456163fd38 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 19 Sep 2026 17:10:49 -0400 Subject: [PATCH 550/596] docs(README): update Docker networking instructions for UDP discovery --- server/jellyfin/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index c0c5e6ca1..e38997569 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -67,9 +67,10 @@ Discovery answers on all IPv4 interfaces, but the advertised address follows `Ba `Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other networks get an address they cannot reach. Set `BaseURL` to the address clients should use. -Docker: publish the port (`-p 7359:7359/udp`). In bridge mode the server only sees its container -IP, so also set `ND_BASEURL` to the LAN address (for example `http://192.168.1.10:4533`), or use -host networking. Keep UDP 7359 on the LAN: never forward it from the internet. +Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not +deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server. +With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host +networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet. ## Authentication From 49f626c00a0ef3449530cd37d3121734982d86f9 Mon Sep 17 00:00:00 2001 From: David Vedvick <dvedvick@gmail.com> Date: Sat, 19 Sep 2026 20:05:58 -0500 Subject: [PATCH 551/596] feat(smartplaylists): add support for referencing playlists using paths (#5187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for referencing playlists using paths Signed-off-by: David <dvedvick@gmail.com> * feat: Support relative playlist paths in smartlists Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): protect against nil panic Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): refreshing child playlists Signed-off-by: David <dvedvick@gmail.com> * chore(smartplaylists): log field parsing error Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): handle empty playlist paths Signed-off-by: David <dvedvick@gmail.com> * refactor(smartplaylists): make NormalizeChildPaths non-mutating Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field Rules that reference a playlist by id have no path field, and the reverse, so the warning fired on every refresh. The log call also had a bad argument count. * fix(smartplaylists): ignore empty inPlaylist id and path references An empty path matched every playlist without a file path, including the referencing playlist itself, so the refresh recursed until the stack overflowed. An empty id also shadowed a valid path in the same rule. * fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms A playlist path is stored in the Unicode form the filesystem reports, which can differ from the form typed in the .nsp file. The exact comparison then found no playlist for names with accents. * fix(smartplaylists): keep all criteria fields when normalizing child paths The field-by-field copy dropped RefreshDelay. * fix(smartplaylists): clean absolute inPlaylist path references Only relative references were cleaned, so an absolute reference such as /music/./child.nsp never matched the stored /music/child.nsp. * fix(smartplaylists): stop infinite recursion on playlists that reference each other Two smart playlists referencing each other, by id or by path, recursed until the stack overflowed and the server died. The refresh now tracks visited playlists. * fix(smartplaylists): resolve inPlaylist path references with OS-native separators Playlist.Path is OS-native, but references in a .nsp file use forward slashes. On Windows they never matched, and a leading slash was not seen as absolute. The specs now build OS-native paths, so they also run on Windows. * fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved A playlist created in the UI has no file path, so a relative reference silently matched nothing. * refactor(smartplaylists): simplify child playlist reference handling Share one extractor for child ids and paths, return only the normalized rules instead of a playlist copy, and resolve each path reference in a single switch. * test(smartplaylists): store the Unicode child path in OS-native form Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched the normalized reference. --------- Signed-off-by: David <dvedvick@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- model/criteria/criteria.go | 17 ++-- model/criteria/criteria_test.go | 30 ++++-- model/criteria/operators.go | 35 +++---- model/playlist.go | 64 ++++++++++++ model/playlist_test.go | 99 +++++++++++++++++++ persistence/criteria_sql.go | 25 ++++- persistence/criteria_sql_test.go | 12 ++- persistence/smart_playlist_repository.go | 49 +++++++-- persistence/smart_playlist_repository_test.go | 90 ++++++++++++++++- 9 files changed, 370 insertions(+), 51 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 5d7dc3826..e6db3b481 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool { } func (c Criteria) ChildPlaylistIds() []string { - if c.Expression == nil { - return nil - } + return c.childPlaylistRefs(conjunction.ChildPlaylistIds) +} +func (c Criteria) ChildPlaylistPaths() []string { + return c.childPlaylistRefs(conjunction.ChildPlaylistPaths) +} + +func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string { parent, ok := c.Expression.(conjunction) if !ok { return nil } - - ids := parent.ChildPlaylistIds() - slices.Sort(ids) - return slices.Compact(ids) + refs := extract(parent) + slices.Sort(refs) + return slices.Compact(refs) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 5e653150a..de5124568 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() { Context("with child playlists", func() { var ( - topLevelInPlaylistID string - topLevelNotInPlaylistID string - nestedAnyInPlaylistID string - nestedAnyNotInPlaylistID string - nestedAllInPlaylistID string - nestedAllNotInPlaylistID string + topLevelInPlaylistID string + topLevelInPlaylistPath string + topLevelNotInPlaylistID string + nestedAnyInPlaylistID string + nestedAnyNotInPlaylistID string + nestedAllInPlaylistID string + nestedAllNotInPlaylistID string + nestedAnyNotInPlaylistPath string ) BeforeEach(func() { topLevelInPlaylistID = uuid.NewString() + topLevelInPlaylistPath = "./test.nsp" topLevelNotInPlaylistID = uuid.NewString() nestedAnyInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistID = uuid.NewString() + nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u" nestedAllInPlaylistID = uuid.NewString() nestedAllNotInPlaylistID = uuid.NewString() @@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() { goObj = Criteria{ Expression: All{ InPlaylist{"id": topLevelInPlaylistID}, + InPlaylist{"path": topLevelInPlaylistPath}, NotInPlaylist{"id": topLevelNotInPlaylistID}, Any{ InPlaylist{"id": nestedAnyInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID}, + NotInPlaylist{"path": nestedAnyNotInPlaylistPath}, }, All{ InPlaylist{"id": nestedAllInPlaylistID}, @@ -359,6 +365,18 @@ var _ = Describe("Criteria", func() { ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) }) + It("extracts all child smart playlist paths from expression criteria", func() { + paths := goObj.ChildPlaylistPaths() + gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath)) + }) + It("ignores empty child playlist paths", func() { + c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}} + gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty()) + }) + It("ignores empty child playlist ids", func() { + c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}} + gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty()) + }) It("extracts child smart playlist IDs from deeply nested expression", func() { goObj = Criteria{ Expression: Any{ diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 14a02ff4b..ec32f2d16 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,8 +1,9 @@ package criteria -// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively type conjunction interface { ChildPlaylistIds() []string + ChildPlaylistPaths() []string } type ( @@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) } -func (all All) ChildPlaylistIds() (ids []string) { - return extractPlaylistIds(all) -} +func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") } + +func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") } type ( Any []Expression @@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) } -func (any Any) ChildPlaylistIds() (ids []string) { - return extractPlaylistIds(any) -} +func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") } + +func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") } type Is map[string]any type Eq = Is @@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func extractPlaylistIds(inputRule any) (ids []string) { - var id string - var ok bool - +func extractPlaylistField(inputRule any, field string) (values []string) { switch rule := inputRule.(type) { case Any: for _, rules := range rule { - ids = append(ids, extractPlaylistIds(rules)...) + values = append(values, extractPlaylistField(rules, field)...) } case All: for _, rules := range rule { - ids = append(ids, extractPlaylistIds(rules)...) + values = append(values, extractPlaylistField(rules, field)...) } - case InPlaylist: - if id, ok = rule["id"].(string); ok { - ids = append(ids, id) - } - case NotInPlaylist: - if id, ok = rule["id"].(string); ok { - ids = append(ids, id) + case InPlaylist, NotInPlaylist: + if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" { + values = append(values, value) } } - return } diff --git a/model/playlist.go b/model/playlist.go index f320d845b..19fedc9fa 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -2,12 +2,16 @@ package model import ( "iter" + "maps" + "os" + "path/filepath" "slices" "strconv" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) @@ -137,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string { return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } +// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths. +func (pls Playlist) NormalizedRules() *criteria.Criteria { + if pls.Rules == nil || pls.Rules.Expression == nil { + return pls.Rules + } + + rules := *pls.Rules + rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path) + return &rules +} + +func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression { + switch rule := inputRule.(type) { + case criteria.Any: + anyCriteria := make(criteria.Any, len(rule)) + for i, rules := range rule { + anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) + } + return anyCriteria + case criteria.All: + allCriteria := make(criteria.All, len(rule)) + for i, rules := range rule { + allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) + } + return allCriteria + case criteria.InPlaylist: + return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) + case criteria.NotInPlaylist: + return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) + } + + return inputRule +} + +func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any { + path, ok := rule["path"].(string) + if !ok || path == "" { + return rule + } + + // References use forward slashes to stay portable, while Playlist.Path is OS-native. + path = filepath.FromSlash(path) + switch { + case isAbsPlaylistRef(path): + path = filepath.Clean(path) + case referencingPlaylistPath != "": + path = filepath.Join(filepath.Dir(referencingPlaylistPath), path) + default: + log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path) + } + normalized := maps.Clone(rule) + normalized["path"] = path + return normalized +} + +// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute. +func isAbsPlaylistRef(path string) bool { + return filepath.IsAbs(path) || os.IsPathSeparator(path[0]) +} + type Playlists []Playlist type PlaylistCursor iter.Seq2[Playlist, error] diff --git a/model/playlist_test.go b/model/playlist_test.go index d98c85716..7ffb69b63 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,6 +1,7 @@ package model_test import ( + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -88,4 +89,102 @@ var _ = Describe("Playlist", func() { Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) }) }) + + Describe("NormalizedRules()", func() { + // absPath builds an OS-native absolute path so these specs also run on Windows. + absPath := func(parts ...string) string { + abs, err := filepath.Abs(filepath.Join(parts...)) + Expect(err).ToNot(HaveOccurred()) + return abs + } + normalize := func(pls model.Playlist) criteria.Expression { + return pls.NormalizedRules().Expression + } + + It("resolves relative references against the playlist folder", func() { + pls := model.Playlist{ + Path: absPath("test", "nested", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.InPlaylist{"path": "../up.m3u"}, + criteria.NotInPlaylist{"path": "./sibling.nsp"}, + criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}}, + }}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": absPath("test", "up.m3u")}, + criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")}, + criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}}, + })) + }) + + It("cleans absolute references", func() { + dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp" + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.NotInPlaylist{"path": absPath("music", "child.nsp")}, + })) + }) + + It("treats a leading slash as absolute on every OS", func() { + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")}, + })) + }) + + It("leaves empty paths and id references untouched", func() { + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.InPlaylist{"path": ""}, + criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, + criteria.Eq{"artist": "Bob Dealin"}, + }}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": ""}, + criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, + criteria.Eq{"artist": "Bob Dealin"}, + })) + }) + + It("skips relative references when the playlist has no path", func() { + pls := model.Playlist{ + Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")}, + })) + }) + + It("preserves every other criteria field", func() { + rules := criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}}, + Sort: "title", + Order: "desc", + Limit: 10, + LimitPercent: 25, + Offset: 5, + RefreshDelay: 3 * time.Hour, + } + pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules} + + normalized := *pls.NormalizedRules() + normalized.Expression = rules.Expression + Expect(normalized).To(Equal(rules)) + }) + + It("does not mutate the original playlist rules", func() { + original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}} + pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}} + _ = pls.NormalizedRules() + Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"})) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 890f757d7..d2f817438 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -13,6 +13,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "golang.org/x/text/unicode/norm" ) type smartPlaylistJoinType int @@ -344,11 +345,15 @@ func startOfPeriod(numDays int64, from time.Time) string { } func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { - playlistID, ok := values["id"].(string) - if !ok { - return nil, errors.New("playlist id not given") + var condition squirrel.Sqlizer + if playlistId, ok := values["id"].(string); ok && playlistId != "" { + condition = squirrel.Eq{"pl.playlist_id": playlistId} + } else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" { + condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)} + } else { + return nil, errors.New("playlist id or path not given") } - filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + filters := squirrel.And{condition} if !c.owner.IsAdmin { if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) @@ -373,6 +378,18 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil } +// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD. +func pathVariants(path string) []string { + variants := []string{path} + if alt := norm.NFC.String(path); alt != path { + variants = append(variants, alt) + } + if alt := norm.NFD.String(path); alt != path { + variants = append(variants, alt) + } + return variants +} + func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { return roleCond{role: info.Name(), cond: cond, not: negate} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 8d0069b0d..ef91ec989 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -47,7 +47,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1), + Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1), + Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1), Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), @@ -268,6 +271,13 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) + It("returns an error when inPlaylist has empty path", func() { + _, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}}, + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where() + Expect(err).To(MatchError(ContainSubstring("playlist id or path not given"))) + }) + It("returns an error for a range over a tag/role field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where() Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 9d2ac9590..24c6f5fc5 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" "time" . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/text/unicode/norm" ) // PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated @@ -16,6 +19,17 @@ import ( // refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + return r.refreshSmartPlaylistTree(pls, map[string]struct{}{}) +} + +// The visited set stops playlists that reference each other from recursing forever. +func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool { + if _, seen := visited[pls.ID]; seen { + log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) + return false + } + visited[pls.ID] = struct{}{} + usr := loggedUser(r.ctx) if !r.shouldRefreshSmartPlaylist(pls, usr) { return false @@ -30,9 +44,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) - if !r.refreshChildPlaylists(pls, rulesSQL) { + if !r.refreshChildPlaylists(pls, rulesSQL, visited) { return false } @@ -89,28 +103,47 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr // refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. // Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. -func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { childPlaylistIds := rulesSQL.ChildPlaylistIds() - if len(childPlaylistIds) == 0 { + childPlaylistPaths := rulesSQL.ChildPlaylistPaths() + if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 { return true } - childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + var conditions Or + if len(childPlaylistIds) > 0 { + conditions = append(conditions, Eq{"playlist.id": childPlaylistIds}) + } + if len(childPlaylistPaths) > 0 { + lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...) + conditions = append(conditions, Eq{"playlist.path": lookupPaths}) + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions}) if err != nil { - log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) return false } - found := make(map[string]struct{}, len(childPlaylists)) + found := make(map[string]struct{}, len(childPlaylists)*2) for i := range childPlaylists { found[childPlaylists[i].ID] = struct{}{} - r.refreshSmartPlaylist(&childPlaylists[i]) + if childPlaylists[i].Path != "" { + found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{} + } + r.refreshSmartPlaylistTree(&childPlaylists[i], visited) } for _, id := range childPlaylistIds { if _, ok := found[id]; !ok { log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) } } + + for _, path := range childPlaylistPaths { + if _, ok := found[norm.NFC.String(path)]; !ok { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID) + } + } return true } diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 6f8684d5c..33da0c1b7 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -124,13 +125,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.Contains{"title": "Day"}, }, } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules} Expect(repo.Put(&nestedPls)).To(Succeed()) DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + childRules = &criteria.Criteria{ Expression: criteria.All{ + criteria.Eq{"artist": "シートベルツ"}, + }, + } + nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPathPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.Any{ criteria.InPlaylist{"id": nestedPls.ID}, + criteria.InPlaylist{"path": nestedPathPls.Path}, }, }} Expect(repo.Put(&parentPls)).To(Succeed()) @@ -148,17 +159,88 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - // Nested playlist should now have been refreshed (EvaluatedAt set) + // Nested playlists should now have been refreshed (EvaluatedAt set) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) }) }) + It("does not recurse forever when two smart playlists reference each other", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&plsA)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(plsA.ID) }) + + plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}}, + }} + Expect(repo.Put(&plsB)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(plsB.ID) }) + + plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}} + Expect(repo.Put(&plsA)).To(Succeed()) + + _, err := repo.GetWithTracks(plsA.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("does not treat an empty path as a reference to every playlist without a path", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&bystander)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(bystander.ID) }) + + parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": ""}}, + }} + Expect(repo.Put(&parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + + _, err := repo.GetWithTracks(parent.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + reloaded, err := repo.Get(bystander.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.EvaluatedAt).To(BeNil()) + }) + + It("matches a child path stored in a different Unicode normalization form", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&child)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(child.ID) }) + + parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}}, + }} + Expect(repo.Put(&parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + + pls, err := repo.GetWithTracks(parent.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + }) + When("refresh delay has not expired", func() { It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour From 2913c13cdf7471eb632fbc87bc02df1d071711bf Mon Sep 17 00:00:00 2001 From: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:10:47 +0300 Subject: [PATCH 552/596] fix(i18n): improve Swedish translation (#6177) Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com> --- resources/i18n/sv.json | 426 ++++++++++++++++++++++------------------- 1 file changed, 226 insertions(+), 200 deletions(-) diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 228cc2cf3..ff4f53a6b 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -11,22 +11,22 @@ "title": "Titel", "artist": "Artist", "album": "Album", - "path": "Sökväg", + "path": "Filsökväg", "genre": "Genre", - "compilation": "Samling", + "compilation": "Samlingsalbum", "year": "År", "size": "Filstorlek", "updatedAt": "Uppdaterad", - "bitRate": "Bitrate", - "discSubtitle": "Underrubrik", + "bitRate": "Bithastighet", + "discSubtitle": "Skivans undertitel", "starred": "Favorit", "comment": "Kommentar", "rating": "Betyg", "quality": "Kvalitet", "bpm": "BPM", "playDate": "Senast spelad", - "channels": "Channels", - "createdAt": "Skapad", + "channels": "Kanaler", + "createdAt": "Tillagd", "grouping": "Gruppering", "mood": "Stämning", "participants": "Ytterligare medverkande", @@ -35,19 +35,21 @@ "rawTags": "Omodifierade taggar", "bitDepth": "Bitdjup", "sampleRate": "Samplingsfrekvens", - "missing": "Saknade", + "missing": "Saknas", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "Disc %{discNumber}" + "disc": "Skiva %{discNumber}", + "albumGain": "Volymjustering (album)", + "trackGain": "Volymjustering (låt)" }, "actions": { "addToQueue": "Lägg till i kön", "playNow": "Spela nu", "addToPlaylist": "Lägg till i spellista", - "shuffleAll": "Shuffle", + "shuffleAll": "Blanda alla", "download": "Ladda ner", - "playNext": "Spela nästa", - "info": "Mer information", + "playNext": "Spela härnäst", + "info": "Visa information", "showInPlaylist": "Visa i spellista", "instantMix": "Direktmix" } @@ -62,12 +64,12 @@ "playCount": "Spelningar", "name": "Namn", "genre": "Genre", - "compilation": "Samling", + "compilation": "Samlingsalbum", "year": "År", "updatedAt": "Uppdaterad", "comment": "Kommentar", "rating": "Betyg", - "createdAt": "Skapad", + "createdAt": "Tillagt", "size": "Storlek", "originalDate": "Originaldatum", "releaseDate": "Utgivningsdatum", @@ -80,27 +82,28 @@ "media": "Media", "mood": "Stämning", "date": "Inspelningsdatum", - "missing": "Saknade", + "missing": "Saknas", "libraryName": "Bibliotek" }, "actions": { "playAll": "Spela", "playNext": "Spela härnäst", "addToQueue": "Lägg till i kön", - "shuffle": "Shuffle", + "shuffle": "Blanda", "addToPlaylist": "Lägg till i spellista", "download": "Ladda ner", - "info": "Mer information", - "share": "Dela" + "info": "Visa information", + "share": "Dela", + "refresh": "Uppdatera metadata" }, "lists": { "all": "Alla", - "random": "Blanda", - "recentlyAdded": "Senast tillagda", - "recentlyPlayed": "Senast spelade", + "random": "Slumpmässiga", + "recentlyAdded": "Nyligen tillagda", + "recentlyPlayed": "Nyligen spelade", "mostPlayed": "Mest spelade", "starred": "Favoriter", - "topRated": "Bästa betyg" + "topRated": "Högst betygsatta" } }, "artist": { @@ -114,26 +117,26 @@ "genre": "Genre", "size": "Storlek", "role": "Roll", - "missing": "Saknade" + "missing": "Saknas" }, "roles": { "albumartist": "Albumartist |||| Albumartister", "artist": "Artist |||| Artister", - "composer": "Kompositör |||| Kompositörer", + "composer": "Kompositör |||| Kompositörer", "conductor": "Dirigent |||| Dirigenter", "lyricist": "Textförfattare |||| Textförfattare", "arranger": "Arrangör |||| Arrangörer", "producer": "Producent |||| Producenter", "director": "Inspelningsledare |||| Inspelningsledare", "engineer": "Ljudtekniker |||| Ljudtekniker", - "mixer": "Mixare |||| Mixare", - "remixer": "Remixare |||| Remixare", - "djmixer": "DJ-mixare |||| DJ-mixare", - "performer": "Utövande artist |||| Utövande artister", - "maincredit": "Albumartister eller Artist |||| Albumartister eller Artister" + "mixer": "Mixare |||| Mixare", + "remixer": "Remixare |||| Remixare", + "djmixer": "DJ-mixare |||| DJ-mixare", + "performer": "Utövande artist |||| Utövande artister", + "maincredit": "Albumartist eller artist |||| Albumartister eller artister" }, "actions": { - "shuffle": "Shuffle", + "shuffle": "Blanda", "radio": "Radio", "topSongs": "Topplåtar" } @@ -142,7 +145,7 @@ "name": "Användare |||| Användare", "fields": { "userName": "Användarnamn", - "isAdmin": "Är admin", + "isAdmin": "Administratör", "lastLoginAt": "Senaste inloggning", "updatedAt": "Uppdaterad", "name": "Namn", @@ -151,13 +154,15 @@ "changePassword": "Byt lösenord?", "currentPassword": "Nuvarande lösenord", "newPassword": "Nytt lösenord", - "token": "Token", + "token": "Åtkomsttoken", "lastAccessAt": "Senaste åtkomst", - "libraries": "Bibliotek" + "libraries": "Bibliotek", + "scrobbleFilter": "Scrobblingsfilter" }, "helperTexts": { "name": "Ändringar av ditt namn syns först vid nästa inloggning", - "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek" + "libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken", + "scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte." }, "notifications": { "created": "Användare skapad", @@ -165,13 +170,14 @@ "deleted": "Användare borttagen" }, "message": { - "listenBrainzToken": "Ange din ListenBrainz användar-token.", + "listenBrainzToken": "Ange din användartoken för ListenBrainz.", "clickHereForToken": "Klicka här för att hämta din token", "selectAllLibraries": "Välj alla bibliotek", - "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek" + "adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek" }, "validation": { - "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer" + "librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer", + "invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte." } }, "player": { @@ -179,12 +185,12 @@ "fields": { "name": "Namn", "transcodingId": "Omkodning", - "maxBitRate": "Max. bitrate", + "maxBitRate": "Högsta bithastighet", "client": "Klient", "userName": "Användarnamn", "lastSeen": "Senast sedd", - "reportRealPath": "Visa hela sökvägen", - "scrobbleEnabled": "Scrobbla till extern tjänst" + "reportRealPath": "Rapportera den faktiska sökvägen", + "scrobbleEnabled": "Scrobbla till externa tjänster" } }, "transcoding": { @@ -192,8 +198,11 @@ "fields": { "name": "Namn", "targetFormat": "Målformat", - "defaultBitRate": "Standardbitrate", + "defaultBitRate": "Standardbithastighet", "command": "Kommando" + }, + "choices": { + "noDefaultBitRate": "Ingen" } }, "playlist": { @@ -207,8 +216,9 @@ "createdAt": "Skapad", "songCount": "Låtar", "comment": "Kommentar", - "sync": "Auto-import", - "path": "Importera från" + "sync": "Automatisk import", + "path": "Importera från", + "starred": "Favorit" }, "actions": { "selectPlaylist": "Välj en spellista:", @@ -216,24 +226,24 @@ "export": "Exportera", "makePublic": "Gör offentlig", "makePrivate": "Gör privat", - "saveQueue": "Spara kö till spellista", - "searchOrCreate": "Sök spellista eller skapa ny...", - "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval" + "saveQueue": "Spara kön i en spellista", + "searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...", + "pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista", + "removeFromSelection": "Ta bort från urvalet" }, "message": { - "duplicate_song": "Lägg till dubletter", - "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", - "noPlaylistsFound": "Hittade inga spellistor", + "duplicate_song": "Lägg till dubbletter", + "song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?", + "noPlaylistsFound": "Inga spellistor hittades", "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { - "name": "Radio |||| Radior", + "name": "Radiostation |||| Radiostationer", "fields": { "name": "Namn", - "streamUrl": "Stream-URL", - "homePageUrl": "Hemside-URL", + "streamUrl": "Strömmens URL", + "homePageUrl": "Webbplatsens URL", "updatedAt": "Uppdaterad", "createdAt": "Skapad" }, @@ -242,7 +252,7 @@ } }, "share": { - "name": "Dela |||| Delningar", + "name": "Delning |||| Delningar", "fields": { "username": "Delad av", "url": "URL", @@ -252,11 +262,13 @@ "lastVisitedAt": "Senast besökt", "visitCount": "Besök", "format": "Format", - "maxBitRate": "Max. bitrate", + "maxBitRate": "Högsta bithastighet", "updatedAt": "Uppdaterad", "createdAt": "Skapad", - "downloadable": "Tillåt nedladdning?" - } + "downloadable": "Tillåt nedladdningar?" + }, + "notifications": {}, + "actions": {} }, "missing": { "name": "Saknad fil |||| Saknade filer", @@ -267,11 +279,11 @@ "libraryName": "Bibliotek" }, "actions": { - "remove": "Radera", - "remove_all": "Radera alla" + "remove": "Ta bort", + "remove_all": "Ta bort alla" }, "notifications": { - "removed": "Saknade fil(er) borttagna" + "removed": "Saknade filer har tagits bort" }, "empty": "Inga saknade filer" }, @@ -280,8 +292,8 @@ "fields": { "name": "Namn", "path": "Sökväg", - "remotePath": "Ta bort sökväg", - "lastScanAt": "Senaste scan", + "remotePath": "Fjärrsökväg", + "lastScanAt": "Senaste skanning", "songCount": "Låtar", "albumCount": "Album", "artistCount": "Artister", @@ -302,33 +314,33 @@ "statistics": "Statistik" }, "actions": { - "scan": "Scanna bibliotek", + "scan": "Skanna biblioteket", "manageUsers": "Hantera användaråtkomst", - "viewDetails": "Se detaljer", - "quickScan": "Snabbscan", - "fullScan": "Komplett scan" + "viewDetails": "Visa detaljer", + "quickScan": "Snabbskanning", + "fullScan": "Fullständig skanning" }, "notifications": { "created": "Biblioteket har skapats", "updated": "Biblioteket har uppdaterats", - "deleted": "Biblioteket har raderats", - "scanStarted": "Biblioteksscan startad", - "scanCompleted": "Biblioteksscan avslutad", - "quickScanStarted": "Snabbscan startad", - "fullScanStarted": "Komplett scan startad", - "scanError": "Fel vid start av scan. Se loggarna" + "deleted": "Biblioteket har tagits bort", + "scanStarted": "Skanning av biblioteket har startat", + "scanCompleted": "Skanning av biblioteket är klar", + "quickScanStarted": "Snabbskanning har startat", + "fullScanStarted": "Fullständig skanning har startat", + "scanError": "Kunde inte starta skanningen. Kontrollera loggarna" }, "validation": { - "nameRequired": "Biblioteksnamn krävs", - "pathRequired": "Bibliotekssökväg krävs", - "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog", - "pathNotFound": "Bibliotekssökväg hittades inte", - "pathNotAccessible": "Bibliotekssökväg inte tillgänglig", - "pathInvalid": "Ogiltig bibliotekssökväg" + "nameRequired": "Ange ett biblioteksnamn", + "pathRequired": "Ange en sökväg till biblioteket", + "pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog", + "pathNotFound": "Bibliotekets sökväg hittades inte", + "pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig", + "pathInvalid": "Ogiltig sökväg till biblioteket" }, "messages": { - "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.", - "scanInProgress": "Scanning pågår...", + "deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.", + "scanInProgress": "Skanning pågår...", "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" } }, @@ -339,44 +351,44 @@ "name": "Namn", "description": "Beskrivning", "version": "Version", - "author": "Författare", - "website": "Website", + "author": "Upphovsperson", + "website": "Webbplats", "permissions": "Behörigheter", - "enabled": "Aktiverad", + "enabled": "Aktiverat", "status": "Status", "path": "Sökväg", "lastError": "Fel", "hasError": "Fel", - "updatedAt": "Uppdaterad", - "createdAt": "Installerad", + "updatedAt": "Uppdaterat", + "createdAt": "Installerat", "configKey": "Nyckel", "configValue": "Värde", "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "Tillåt skrivrättigheter" + "allowWriteAccess": "Tillåt skrivåtkomst" }, "sections": { "status": "Status", - "info": "Tilläggsinformation", + "info": "Information om tillägget", "configuration": "Konfiguration", "manifest": "Manifest", - "usersPermission": "Användarbehörigheter", - "libraryPermission": "Biblioteksbehörigheter" + "usersPermission": "Åtkomst till användare", + "libraryPermission": "Åtkomst till bibliotek" }, "status": { - "enabled": "Aktiverad", - "disabled": "Inaktiverad" + "enabled": "Aktiverat", + "disabled": "Inaktiverat" }, "actions": { "enable": "Aktivera", "disable": "Inaktivera", - "disabledDueToError": "Åtgärda felet innan aktivering", + "disabledDueToError": "Åtgärda felet före aktivering", "disabledUsersRequired": "Välj användare före aktivering", "disabledLibrariesRequired": "Välj bibliotek före aktivering", "addConfig": "Lägg till konfiguration", - "rescan": "Scanna om" + "rescan": "Skanna om" }, "notifications": { "enabled": "Tillägg aktiverat", @@ -391,17 +403,18 @@ "configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.", "clickPermissions": "Klicka på en behörighet för mer information", "noConfig": "Ingen konfiguration angiven", - "allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.", + "allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.", "noUsers": "Inga användare valda", "permissionReason": "Orsak", - "usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.", - "allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.", + "usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.", + "allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.", "noLibraries": "Inga bibliotek valda", - "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", - "requiredHosts": "Krävda värdar", + "librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.", + "requiredHosts": "Värdar som krävs", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." + "schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.", + "idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents." }, "placeholders": { "configKey": "nyckel", @@ -412,40 +425,40 @@ "ra": { "auth": { "welcome1": "Tack för att du installerade Navidrome!", - "welcome2": "Skapa först ett admin-konto", + "welcome2": "Börja med att skapa ett administratörskonto", "confirmPassword": "Bekräfta lösenord", - "buttonCreateAdmin": "Skapa admin-konto", + "buttonCreateAdmin": "Skapa administratörskonto", "auth_check_error": "Logga in för att fortsätta", "user_menu": "Profil", "username": "Användarnamn", "password": "Lösenord", "sign_in": "Logga in", - "sign_in_error": "Felaktig inloggning, försök igen", + "sign_in_error": "Inloggningen misslyckades. Försök igen", "logout": "Logga ut", - "insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill" + "insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen" }, "validation": { "invalidChars": "Använd enbart bokstäver och siffror", - "passwordDoesNotMatch": "Lösenordet matchar inte", - "required": "Krävs", - "minLength": "Måste ha minst %{min} tecken", - "maxLength": "Får maximalt ha %{max} tecken", + "passwordDoesNotMatch": "Lösenorden stämmer inte överens", + "required": "Obligatoriskt", + "minLength": "Måste innehålla minst %{min} tecken", + "maxLength": "Får innehålla högst %{max} tecken", "minValue": "Måste vara minst %{min}", - "maxValue": "Får maximalt vara %{max}", - "number": "Måste vara ett nummer", + "maxValue": "Får vara högst %{max}", + "number": "Måste vara ett tal", "email": "Måste vara en giltig e-postadress", - "oneOf": "Måste vara en av: %{options}", - "regex": "Måste matcha ett specifikt format (regexp): %{pattern}", - "unique": "Måste vara unik", + "oneOf": "Måste vara något av: %{options}", + "regex": "Måste matcha det reguljära uttrycket: %{pattern}", + "unique": "Måste vara unikt", "url": "Måste vara en giltig URL" }, "action": { "add_filter": "Lägg till filter", "add": "Lägg till", "back": "Tillbaka", - "bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda", + "bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda", "cancel": "Avbryt", - "clear_input_value": "Rensa", + "clear_input_value": "Rensa värdet", "clone": "Klona", "confirm": "Bekräfta", "create": "Skapa", @@ -454,8 +467,8 @@ "export": "Exportera", "list": "Lista", "refresh": "Uppdatera", - "remove_filter": "Ta bort filter", - "remove": "Radera", + "remove_filter": "Ta bort det här filtret", + "remove": "Ta bort", "save": "Spara", "search": "Sök", "show": "Visa", @@ -477,15 +490,15 @@ }, "page": { "create": "Skapa %{name}", - "dashboard": "Dashboard", + "dashboard": "Översikt", "edit": "%{name} #%{id}", "error": "Ett fel uppstod", "list": "%{name}", - "loading": "Laddar", - "not_found": "Hittade inget", + "loading": "Läser in", + "not_found": "Hittades inte", "show": "%{name} #%{id}", - "empty": "Ingen %{name} ännu.", - "invite": "Vill du lägga till en?" + "empty": "%{name}: inga poster ännu.", + "invite": "Vill du lägga till något?" }, "input": { "file": { @@ -497,13 +510,13 @@ "upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild." }, "references": { - "all_missing": "Hittade ingen referensdata.", - "many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.", - "single_missing": "Associerade referenser verkar inte längre vara tillgängliga." + "all_missing": "Kunde inte hitta referensdata.", + "many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.", + "single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig." }, "password": { - "toggle_visible": "Dölj password", - "toggle_hidden": "Visa password" + "toggle_visible": "Dölj lösenord", + "toggle_hidden": "Visa lösenord" } }, "message": { @@ -511,16 +524,16 @@ "are_you_sure": "Är du säker?", "bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?", "bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}", - "delete_content": "Vill du verkligen ta bort detta innehåll?", + "delete_content": "Vill du verkligen ta bort det här objektet?", "delete_title": "Ta bort %{name} #%{id}", "details": "Detaljer", "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", - "invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel", - "loading": "Sidan läses in, var god vänta", + "invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna", + "loading": "Sidan läses in. Vänta ett ögonblick", "no": "Nej", "not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.", "yes": "Ja", - "unsaved_changes": "Du har osparade ändringar. Ignorera dem?" + "unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?" }, "navigation": { "no_results": "Inga resultat hittades", @@ -529,23 +542,23 @@ "page_out_from_end": "Det finns inga fler sidor", "page_out_from_begin": "Det finns ingen sida före sida 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", - "page_rows_per_page": "Antal per sida:", + "page_rows_per_page": "Objekt per sida:", "next": "Nästa", "prev": "Föregående", "skip_nav": "Hoppa till innehåll" }, "notification": { - "updated": "Element uppdaterat |||| %{smart_count} element uppdaterade", - "created": "Element skapat", - "deleted": "Element borttaget |||| %{smart_count} element borttagna", - "bad_item": "Felaktigt element", - "item_doesnt_exist": "Element finns inte", + "updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade", + "created": "Objekt skapat", + "deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna", + "bad_item": "Felaktigt objekt", + "item_doesnt_exist": "Objektet finns inte", "http_error": "Kommunikationsfel med servern", - "data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.", - "i18n_error": "Kunde inte läsa in översättningen av det valda språket", + "data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.", + "i18n_error": "Kunde inte läsa in översättningarna för det valda språket", "canceled": "Åtgärden avbröts", - "logged_out": "Sessionen har avslutats, anslut på nytt.", - "new_version": "Det finns en ny version! Uppdatera detta fönster." + "logged_out": "Sessionen har avslutats. Logga in igen.", + "new_version": "En ny version finns tillgänglig! Ladda om det här fönstret." }, "toggleFieldsMenu": { "columnsToDisplay": "Kolumner att visa", @@ -556,39 +569,39 @@ }, "message": { "note": "OBSERVERA", - "transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).", - "transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.", - "songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan", - "noPlaylistsAvailable": "Ingen tillgänglig", - "delete_user_title": "Ta bort användare '%{name}'", - "delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?", - "notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar", - "notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https", - "lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt", - "lastfmLinkFailure": "Last.fm kunde inte länkas", - "lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat", - "lastfmUnlinkFailure": "Last.fm kunde inte avlänkas", + "transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.", + "transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.", + "songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan", + "noPlaylistsAvailable": "Inga tillgängliga", + "delete_user_title": "Ta bort användaren '%{name}'", + "delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?", + "notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar", + "notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS", + "lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats", + "lastfmLinkFailure": "Det gick inte att koppla Last.fm", + "lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats", + "lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm", "openIn": { "lastfm": "Öppna i Last.fm", "musicbrainz": "Öppna i MusicBrainz" }, "lastfmLink": "Läs mer...", - "listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}", - "listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat", - "listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas", + "listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}", + "listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats", + "listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz", "downloadOriginalFormat": "Ladda ner i originalformat", "shareOriginalFormat": "Dela i originalformat", "shareDialogTitle": "Dela %{resource} '%{name}'", - "shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}", - "shareSuccess": "URL kopierades till urklipp: %{url}", - "shareFailure": "Fel vid kopiering av URL %{url} till urklipp", + "shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}", + "shareSuccess": "URL:en har kopierats till urklipp: %{url}", + "shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp", "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", "remove_missing_title": "Ta bort saknade filer", - "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", "remove_all_missing_title": "Ta bort alla saknade filer", - "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", "startingInstantMix": "Laddar direktmix...", @@ -597,7 +610,11 @@ "coverUploaded": "Omslagsbild uppdaterad", "coverRemoved": "Omslagsbild borttagen", "coverUploadError": "Fel vid uppladdning av omslagsbild", - "coverRemoveError": "Fel vid borttagning av omslagsbild" + "coverRemoveError": "Fel vid borttagning av omslagsbild", + "metadataRefreshStarted": "Metadata uppdateras i bakgrunden", + "quickConnectApproved": "%{app} på %{device} är nu inloggad", + "quickConnectInvalidCode": "Koden är ogiltig eller har gått ut", + "quickConnectError": "Kunde inte godkänna koden" }, "menu": { "library": "Bibliotek", @@ -610,17 +627,17 @@ "theme": "Tema", "language": "Språk", "defaultView": "Standardvy", - "desktop_notifications": "Skrivbordsmeddelanden", + "desktop_notifications": "Skrivbordsaviseringar", "lastfmScrobbling": "Scrobbla till Last.fm", "listenBrainzScrobbling": "Scrobbla till ListenBrainz", "replaygain": "ReplayGain-läge", - "preAmp": "ReplayGain PreAmp (dB)", + "preAmp": "Förförstärkning för ReplayGain (dB)", "gain": { "none": "Inaktiverad", - "album": "Använd gain för album", - "track": "Använd gain für låtar" + "album": "Använd albumets volymjustering", + "track": "Använd låtens volymjustering" }, - "lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad" + "lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad" } }, "albumList": "Album", @@ -632,10 +649,19 @@ "multipleLibraries": "%{selected} av %{total} bibliotek", "selectLibraries": "Välj bibliotek", "none": "Inga" - } + }, + "quickConnect": { + "name": "Snabbanslutning", + "code": "Kod", + "help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen", + "confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?", + "continue": "Fortsätt", + "approve": "Godkänn" + }, + "onlyFavourites": "Visa endast favoriter" }, "player": { - "playListsText": "Spela kön", + "playListsText": "Uppspelningskö", "openText": "Öppna", "closeText": "Stäng", "notContentText": "Ingen musik", @@ -645,26 +671,26 @@ "previousTrackText": "Föregående låt", "reloadText": "Ladda om", "volumeText": "Volym", - "toggleLyricText": "Låttext av/på", + "toggleLyricText": "Visa eller dölj låttexten", "toggleMiniModeText": "Minimera", - "destroyText": "Radera", + "destroyText": "Stäng spelaren och rensa kön", "downloadText": "Ladda ner", - "removeAudioListsText": "Ta bort audiolistor", + "removeAudioListsText": "Rensa kön", "clickToDeleteText": "Klicka för att ta bort %{name}", "emptyLyricText": "Ingen låttext", "playModeText": { "order": "I ordningsföljd", - "orderLoop": "Upprepa", - "singleLoop": "Upprepa en", - "shufflePlay": "Shuffle" + "orderLoop": "Upprepa alla", + "singleLoop": "Upprepa en låt", + "shufflePlay": "Blanda" } }, "about": { "links": { - "homepage": "Hemsida", + "homepage": "Webbplats", "source": "Källkod", - "featureRequests": "Funktionalitetförfrågan", - "lastInsightsCollection": "Senaste Insights-kollektion", + "featureRequests": "Förslag på nya funktioner", + "lastInsightsCollection": "Senaste insamling av användningsdata", "insights": { "disabled": "Inaktiverad", "waiting": "Väntar" @@ -672,16 +698,16 @@ }, "tabs": { "about": "Om", - "config": "Inställningar" + "config": "Konfiguration" }, "config": { - "configName": "Inställningsnamn", + "configName": "Inställning", "environmentVariable": "Miljövariabel", - "currentValue": "Nuvarande värde", - "configurationFile": "Inställningsfil", - "exportToml": "Exportera inställningar (TOML)", - "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", - "exportFailed": "Kopiering av inställningarna misslyckades", + "currentValue": "Aktuellt värde", + "configurationFile": "Konfigurationsfil", + "exportToml": "Exportera konfiguration (TOML)", + "exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format", + "exportFailed": "Kunde inte kopiera konfigurationen", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", "downloadToml": "Ladda ner konfiguration (TOML)" @@ -689,28 +715,28 @@ }, "activity": { "title": "Aktivitet", - "totalScanned": "Genomsökta mappar", - "quickScan": "Snabbscan", - "fullScan": "Komplett scan", - "serverUptime": "Serverdrifttid", + "totalScanned": "Skannade mappar totalt", + "quickScan": "Snabb", + "fullScan": "Fullständig", + "serverUptime": "Serverns drifttid", "serverDown": "OFFLINE", - "scanType": "Typ", - "status": "Fel vid scanning", - "elapsedTime": "Spelad tid", - "selectiveScan": "Urval" + "scanType": "Senaste skanning", + "status": "Skanningsfel", + "elapsedTime": "Förfluten tid", + "selectiveScan": "Selektiv" }, "help": { - "title": "Navidrome kortkommandon", + "title": "Kortkommandon i Navidrome", "hotkeys": { "show_help": "Visa denna hjälp", - "toggle_menu": "Växla sidomeny", + "toggle_menu": "Visa eller dölj sidomenyn", "toggle_play": "Spela / pausa", "prev_song": "Föregående låt", "next_song": "Nästa låt", - "vol_up": "Volym upp", - "vol_down": "Volym ner", - "toggle_love": "Lägg till låt i favoriter", - "current_song": "Hoppa till nuvarande låt" + "vol_up": "Höj volymen", + "vol_down": "Sänk volymen", + "toggle_love": "Lägg till låten i favoriter", + "current_song": "Gå till aktuell låt" } }, "nowPlaying": { @@ -718,4 +744,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} \ No newline at end of file +} From a5334ee46b9cbfc5fe3191c5ce9262117f543307 Mon Sep 17 00:00:00 2001 From: strecke <35091155+strecke@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:17:57 +0200 Subject: [PATCH 553/596] fix(ui): update missing German translations (#6146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update missing German translations * Fix typo in idHelp message in German translation * Update German translations to remove formal "Sie" forms --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/de.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 8f3df0b4d..400a2364d 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -94,7 +94,7 @@ "download": "Herunterladen", "info": "Mehr Informationen", "share": "Freigabe erstellen", - "refresh": "" + "refresh": "Metadaten aktualisieren" }, "lists": { "all": "Alle", @@ -157,12 +157,12 @@ "token": "Token", "lastAccessAt": "Letzter Zugriff am", "libraries": "Bibliotheken", - "scrobbleFilter": "" + "scrobbleFilter": "Scrobble-Filter" }, "helperTexts": { "name": "Die Änderung wird erst nach dem nächsten Login gültig", "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken", - "scrobbleFilter": "" + "scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt." }, "notifications": { "created": "Benutzer erstellt", @@ -177,7 +177,7 @@ }, "validation": { "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein", - "invalidScrobbleFilter": "" + "invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt." } }, "player": { @@ -202,7 +202,7 @@ "command": "Befehl" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "Keine" } }, "playlist": { @@ -398,6 +398,7 @@ "invalidJson": "Konfiguration muss valides JSON sein" }, "messages": { + "idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.", "configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.", "clickPermissions": "Berechtigung anklicken für mehr Details", "noConfig": "Keine Konfiguration gesetzt", @@ -411,8 +412,7 @@ "requiredHosts": "Benötigte Hosts", "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", - "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.", - "idHelp": "" + "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." }, "placeholders": { "configKey": "Schlüssel", @@ -573,7 +573,7 @@ "noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar", "delete_user_title": "Benutzer '%{name}' löschen", "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?", - "notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert", + "notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert", "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", "lastfmLinkFailure": "Last.fm konnte nicht verbunden werden", @@ -609,7 +609,7 @@ "coverRemoved": "Cover entfernt", "coverUploadError": "Fehler beim Hochladen des Covers", "coverRemoveError": "Fehler beim Entfernen des Covers", - "metadataRefreshStarted": "" + "metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund" }, "menu": { "library": "Bibliothek", @@ -731,4 +731,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} \ No newline at end of file +} From 8e784b6af7baa7ccfcfaf56edaecf56d598ad045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 12:37:18 -0400 Subject: [PATCH 554/596] fix: apply the per-user library filter to bookmarks, playlists and now-playing (#6179) On a multi-library instance, a few reads and writes built their own queries without the per-user library filter that every other media read applies. A user granted only some libraries could see, and store, tracks from libraries they had no access to. - getBookmarks now filters the query. It has to be the query and not the result: the loop below it pre-sizes the response from the bookmark count, so a row dropped afterwards would emit an empty bookmark entry. - createBookmark rejects an id the caller cannot read, returning error 70 to match getSong. Stored rows are left alone rather than purged, so a temporary revoke does not lose saved playback positions. - playlistTrackRepository Read, Count and GetAlbumIDs get the filter their siblings CountAll and GetMediaFileIDs already had. Read is the one that mattered most: its id is the integer playlist position, so it needed no track id at all. - Playlist track writes are filtered in playlistRepository.addTracks, the only writer of playlist_tracks rows apart from smart playlists, so Add, Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all go through it. Insert reserves a slot per requested id, so when the filter drops one it renumbers to close the hole. - playTracker.GetNowPlaying honours its context instead of discarding it. The cache is process-global, so the filter belongs in the tracker rather than in the Subsonic handler, and any future caller inherits it. Admins and single-library installs are unaffected: applyLibraryFilter and HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as admin, and M3U and CLI imports already resolve tracks through FindByPaths as the same user, so neither changes. --- core/scrobbler/play_tracker.go | 9 +- core/scrobbler/play_tracker_test.go | 43 ++++- persistence/persistence_suite_test.go | 22 +++ persistence/playlist_repository.go | 42 ++++- persistence/playlist_track_repository.go | 16 +- persistence/playlist_track_repository_test.go | 149 ++++++++++++++++++ persistence/sql_bookmarks.go | 1 + persistence/sql_bookmarks_test.go | 48 ++++++ server/subsonic/bookmarks.go | 8 + server/subsonic/bookmarks_test.go | 48 ++++++ .../e2e/subsonic_multilibrary_test.go | 18 +++ tests/mock_mediafile_repo.go | 9 ++ 12 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 server/subsonic/bookmarks_test.go diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 63397f8f6..e68fcd942 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" + "github.com/navidrome/navidrome/utils/slice" ) const ( @@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { +func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) { + // The cache is process-global, so it holds every user's playback, across all libraries. res := p.playMap.Values() + if user, ok := request.UserFrom(ctx); ok { + res = slice.Filter(res, func(s PlaybackSession) bool { + return user.HasLibraryAccess(s.MediaFile.LibraryID) + }) + } slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0e768c3f4..d79233e15 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1"}) + ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} fake = &fakeScrobbler{Authorized: true} @@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() { track = model.MediaFile{ ID: "123", + LibraryID: 1, Title: "Track Title", Album: "Track Album", AlbumID: "al-1", @@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() { Expect(playing[1].Username).To(Equal("user-1")) Expect(playing[1].MediaFile.ID).To(Equal("123")) }) + + It("hides sessions playing from libraries the caller cannot access", func() { + hidden := track + hidden.ID = "789" + hidden.LibraryID = 2 + _ = ds.MediaFile(ctx).Put(&hidden) + reporter := request.WithPlayer( + request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), + model.Player{ScrobbleEnabled: true}, + ) + _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ + MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) + + playing, err := tracker.GetNowPlaying(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only") + }) + + It("shows every session to an admin", func() { + hidden := track + hidden.ID = "789" + hidden.LibraryID = 2 + _ = ds.MediaFile(ctx).Put(&hidden) + reporter := request.WithPlayer( + request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), + model.Player{ScrobbleEnabled: true}, + ) + _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ + MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) + + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true}) + playing, err := tracker.GetNowPlaying(adminCtx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("789")) + }) }) Describe("Expiration events", func() { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index f146cb06b..644284c0b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -169,6 +169,28 @@ func p(path string) string { return filepath.FromSlash(path) } +// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so +// specs can assert that a query filters by library. Cleans itself up after the spec. +func restrictedFixture(name string) (context.Context, model.Library, model.User) { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + db := GetDBXBuilder() + + lib := model.Library{Name: name + " Library", Path: "/" + name} + lr := NewLibraryRepository(adminCtx, db) + Expect(lr.Put(&lib)).To(Succeed()) + + user := createUserWithLibraries(name+"-restricted", []int{1}) + ur := NewUserRepository(adminCtx, db) + Expect(ur.Put(&user)).To(Succeed()) + Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed()) + + DeferCleanup(func() { + _ = NewUserRepository(adminCtx, db).Delete(user.ID) + _ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID}) + }) + return adminCtx, lib, user +} + var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index cd3e48d18..2afef9f45 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -13,6 +13,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st return err } - return r.addTracks(playlistId, 1, mediaFileIds) + _, err = r.addTracks(playlistId, 1, mediaFileIds) + return err } -func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error { +// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns +// the library check: every caller, including a full replace through Put, goes through it. +func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) { + mediaFileIds, err := r.keepAccessible(mediaFileIds) + if err != nil { + return 0, err + } // Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit // Add new tracks, chunk by chunk pos := startingPos @@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media ins = ins.Values(playlistId, t, pos) pos++ } - _, err := r.executeSQL(ins) - if err != nil { - return err + if _, err := r.executeSQL(ins); err != nil { + return 0, err } } r.enqueueCoverRebuild(playlistId) - return r.refreshCounters(&model.Playlist{ID: playlistId}) + return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId}) +} + +// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked +// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. +func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) { + if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + return mediaFileIds, nil + } + accessible := make(map[string]struct{}, len(mediaFileIds)) + for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { + sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") + var found []string + if err := r.queryAllSlice(sq, &found); err != nil { + return nil, err + } + for _, id := range found { + accessible[id] = struct{}{} + } + } + return slice.Filter(mediaFileIds, func(id string) bool { + _, ok := accessible[id] + return ok + }), nil } // refreshCounters updates total playlist duration, size and count diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 847316868..848b67be0 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er query := Select(). LeftJoin("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") return r.count(query, r.parseRestOptions(r.ctx, options...)) } @@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) { ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) + sel = r.applyLibraryFilter(sel, "f") var trk dbPlaylistTrack err := r.queryOne(sel, &trk) return trk.PlaylistTrack, err @@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([] query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "mf") var ids []string err := r.queryAllSlice(query, &ids) if err != nil { @@ -187,12 +190,11 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { // Get next pos (ID) in playlist sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) var res struct{ Max sql.NullInt32 } - err := r.queryOne(sq, &res) - if err != nil { + if err := r.queryOne(sq, &res); err != nil { return 0, err } - return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) + return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) } // Insert adds tracks before the 1-based position pos, shifting the following entries down; a @@ -215,7 +217,13 @@ func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, e if res == 0 { return r.Add(mediaFileIds) } - return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) + inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) + if err != nil || inserted == n { + return inserted, err + } + // The shift above reserved a slot per requested id, so ids dropped by the library filter + // leave a hole. Close it. + return inserted, r.playlistRepo.renumber(r.playlistId) } func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index e0360adb4..1a6bc9dc6 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -1,11 +1,13 @@ package persistence import ( + "context" "strconv" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -221,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() { Expect(tracks.CountAll()).To(BeZero()) }) }) + + Describe("library access", func() { + var otherLib model.Library + var restrictedUser model.User + var adminCtx, userCtx context.Context + var userTracks model.PlaylistTrackRepository + var plsID string + + BeforeEach(func() { + adminCtx, otherLib, restrictedUser = restrictedFixture("pls") + userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) + db := GetDBXBuilder() + + adminMr := NewMediaFileRepository(adminCtx, db) + Expect(adminMr.Put(&model.MediaFile{ + ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album", + Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist", + })).To(Succeed()) + DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") }) + + adminPls := NewPlaylistRepository(adminCtx, db) + pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true} + Expect(adminPls.Put(&pls)).To(Succeed()) + plsID = pls.ID + DeferCleanup(func() { _ = adminPls.Delete(plsID) }) + Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) + + userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false) + }) + + It("Read does not return a track outside the user's libraries", func() { + _, err := userTracks.Read("2") + Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access") + }) + + It("Read still returns a track inside the user's libraries", func() { + trk, err := userTracks.Read("1") + Expect(err).ToNot(HaveOccurred()) + Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID)) + }) + + It("Count excludes tracks outside the user's libraries", func() { + Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing") + }) + + It("GetAlbumIDs excludes albums outside the user's libraries", func() { + Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album")) + }) + + Describe("Add", func() { + var ownTracks model.PlaylistTrackRepository + + BeforeEach(func() { + userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName} + Expect(userPls.Put(&own)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) }) + ownTracks = userPls.Tracks(own.ID, false) + }) + + It("drops ids outside the user's libraries", func() { + Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) + Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID)) + }) + + It("drops them when reached through AddAlbums", func() { + Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero()) + }) + + It("drops them when reached through Insert", func() { + Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1)) + + Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) + + Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) + trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}), + "positions must stay contiguous when an id is dropped") + }) + }) + + Describe("Put", func() { + storedIDs := func(id string) []string { + ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs() + Expect(err).ToNot(HaveOccurred()) + return ids + } + put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string { + pls.OwnerID = owner.ID + pls.Tracks = nil + pls.AddMediaFilesByID(ids) + Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) }) + return pls.ID + } + + It("drops ids outside the user's libraries when creating a playlist", func() { + id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track") + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID})) + }) + + It("drops them when replacing the tracks of an existing playlist", func() { + pls := &model.Playlist{Name: "Replaced"} + put(userCtx, restrictedUser, pls, songDayInALife.ID) + + put(userCtx, restrictedUser, pls, "pls-otherlib-track") + + Expect(storedIDs(pls.ID)).To(BeEmpty()) + }) + + It("does not count a dropped id, so it cannot be told apart from an unknown one", func() { + hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track") + unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track") + + userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + h, err := userPls.Get(hidden) + Expect(err).ToNot(HaveOccurred()) + u, err := userPls.Get(unknown) + Expect(err).ToNot(HaveOccurred()) + Expect(h.SongCount).To(Equal(u.SongCount)) + Expect(h.Duration).To(Equal(u.Duration)) + Expect(h.Size).To(Equal(u.Size)) + }) + + It("keeps order and duplicates of the accessible ids", func() { + id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"}, + songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID) + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID})) + }) + + It("keeps every id when run as an admin, as the scanner's playlist sync does", func() { + id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track") + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"})) + }) + }) + + It("still shows everything to an admin", func() { + adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false) + Expect(adminTracks.Count()).To(Equal(int64(2))) + _, err := adminTracks.Read("2") + Expect(err).ToNot(HaveOccurred()) + }) + }) }) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index a9f53430d..cff57dc9d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { sq := r.newSelect().Columns(r.tableName + ".*") sq = r.withAnnotation(sq, idField) sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) + sq = r.applyLibraryFilter(sq) var mfs dbMediaFiles // TODO Decouple from media_file err := r.queryAll(sq, &mfs) if err != nil { diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index 712a928db..ae01a0e35 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() { Expect(mr.GetBookmarks()).To(BeEmpty()) }) }) + + Describe("library access", func() { + var otherLib model.Library + var restrictedUser model.User + var adminCtx context.Context + var userMr model.MediaFileRepository + + BeforeEach(func() { + adminCtx, otherLib, restrictedUser = restrictedFixture("bmk") + + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.Put(&model.MediaFile{ + ID: "bmk-otherlib-track", LibraryID: otherLib.ID, + Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked", + })).To(Succeed()) + DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") }) + + userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) + userMr = NewMediaFileRepository(userCtx, GetDBXBuilder()) + }) + + It("does not return bookmarks for tracks outside the user's libraries", func() { + Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed()) + + Expect(userMr.GetBookmarks()).To(BeEmpty()) + }) + + It("still returns the bookmark for an admin", func() { + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed()) + DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") }) + + bms, err := adminMr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track")) + }) + + It("keeps returning bookmarks for tracks inside the user's libraries", func() { + Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed()) + DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) }) + + bms, err := userMr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) + }) + }) }) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 4a7ebaa6c..7ac492ca8 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -47,6 +47,14 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) position := p.Int64Or("position", 0) repo := api.ds.MediaFile(r.Context()) + ok, err := repo.Exists(id) + if err != nil { + return nil, err + } + if !ok { + return nil, newError(responses.ErrorDataNotFound, "Song not found") + } + err = repo.AddBookmark(id, comment, position) if err != nil { return nil, err diff --git a/server/subsonic/bookmarks_test.go b/server/subsonic/bookmarks_test.go new file mode 100644 index 000000000..0fcb81ab9 --- /dev/null +++ b/server/subsonic/bookmarks_test.go @@ -0,0 +1,48 @@ +package subsonic + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Bookmarks", func() { + var router *Router + var ds *tests.MockDataStore + var mfRepo *tests.MockMediaFileRepo + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"}) + mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "visible"}}) + }) + + Describe("CreateBookmark", func() { + It("rejects an id the user cannot read", func() { + r := newGetRequest("id=hidden", "position=1").WithContext(ctx) + + _, err := router.CreateBookmark(r) + + Expect(err).To(HaveOccurred()) + Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound)) + Expect(mfRepo.BookmarksAdded).To(BeEmpty()) + }) + + It("accepts an id the user can read", func() { + r := newGetRequest("id=visible", "position=1").WithContext(ctx) + + _, err := router.CreateBookmark(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible")) + }) + }) +}) diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index 98f87ca17..18e8c6391 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -224,6 +224,24 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(resp.Playlist.Entry).To(HaveLen(1)) Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) }) + + It("non-admin user cannot store a song from another library through createPlaylist", func() { + resp := doReqWithUser(userLib1Only, "createPlaylist", + "name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + ownID := resp.Playlist.Id + + stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) + Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted") + Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID)) + + By("replacing the tracks of the same playlist") + resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) + Expect(stored.Playlist.Entry).To(BeEmpty()) + }) }) Describe("Cross-library shares", Ordered, func() { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index a365bd2bd..2093a007d 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -37,6 +37,7 @@ type MockMediaFileRepo struct { FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) MatchesCriteriaValue bool MatchesCriteriaErr error + BookmarksAdded []string } func (m *MockMediaFileRepo) SetError(err bool) { @@ -76,6 +77,14 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { return nil, model.ErrNotFound } +func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error { + if m.Err { + return errors.New("error") + } + m.BookmarksAdded = append(m.BookmarksAdded, id) + return nil +} + func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") From 8b4125267eda31c48c193776d8dc4653a2e5af48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 13:40:14 -0400 Subject: [PATCH 555/596] fix(artwork): only use image files as local artwork sources (#6180) * fix(playlists): limit local cover paths to images in owner's libraries A local #EXTALBUMARTURL path (absolute or file://) was only checked against the union of all libraries. The artwork resolver then opened it with no further check and served the bytes as the playlist cover, undecoded. Any user who can upload an M3U could read any file under any library root, including libraries they were not granted, through getCoverArt (GHSA-vwq6-xrw5-phpg). resolveImageURL now requires an image extension, and for uploaded playlists (no folder) the library holding the cover must pass the owner's HasLibraryAccess. Scanner and CLI imports keep the all-libraries check, since those files are admin-controlled. resolveLocalFile, used by every file-backed artwork source, now ignores paths without an image extension, which covers playlists stored before this fix that were not resolved yet. openOriginal refuses a stored file-backed row whose path is not an image, so the existing dangling path re-resolves it and the playlist falls back to the generated grid. No migration is needed. * fix(artwork): skip non-image files matched by folder cover patterns Album and disc folder sources opened any file in the folder's image list that matched a cover pattern, without checking its extension. openOriginal now refuses to serve file-backed rows whose path is not an image, so a stored row like that would be refused, re-resolved to the same file, and refused again on every view. The list comes from the scanner, which only records image files, but a database scanned where the OS mime table knows more image types than the serving process could still reach this. Both fromExternalFile variants now skip matches that are not image files, so the album falls back to its next source instead. Also correct the parser comment: a playlist without a folder can come from an API upload or from a CLI import of a file outside all libraries. * fix(artwork): check stored source type before using the resize cache The image-extension check for file-backed rows ran inside openOriginal, which the resize cache skips on a hit. Before the fix, a resized request for a playlist pointing at a non-image file cached the raw bytes, because a failed resize falls back to the original data. After the upgrade the same request still hit that entry and returned the file. serveHash now refuses a file-backed row whose path is not an image before calling serveSource, so both full-size and resized requests go through dangling and re-resolve the item. The stale cache entry is keyed by the old hash and is no longer reachable once the row changes. * test: register mime_types.yaml in test binaries Artwork resolution now skips candidates that are not image files, and model.IsImageFile answers from the process mime table. The server registers the extra image types from resources/mime_types.yaml through a conf hook, but a test binary only does that if it links conf/mime, so the artwork e2e suite fell back to the host table: .jxl resolves on macOS and Linux and does not on Windows, where the #5950 cover spec then found no source. tests.Init now imports conf/mime for its side effect, so every suite that loads the test config sees the same image types as the server. * fix(artwork): drop the image-file guard from the disc art reader The guard was added to both fromExternalFile variants, but disc artwork keeps no state row and is never queued, so it cannot hit the refuse-and-re-resolve loop the guard exists to prevent. The only case where it can fire is a real image whose extension this process's mime table does not know, and there it drops a disc cover that used to work. The album variant keeps the guard, since those resolutions are stored and re-served. --- core/artwork/artwork.go | 5 +++ core/artwork/artwork_test.go | 47 +++++++++++++++++++++++++++ core/artwork/resolve.go | 2 +- core/artwork/resolve_test.go | 17 ++++++++++ core/artwork/sources.go | 2 +- core/artwork/sources_internal_test.go | 12 +++++++ core/playlists/import_test.go | 31 ++++++++++++++++++ core/playlists/parse_m3u.go | 13 +++++--- tests/init_tests.go | 1 + 9 files changed, 123 insertions(+), 7 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 663d06d25..e27fa118e 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -168,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode if !entityExists(ctx, s.ds, artID) { return nil, ErrUnavailable } + // Checked here, not in openOriginal: a resize-cache hit never opens the source. + if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) { + log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath) + return s.dangling(ctx, artID) + } art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) if err != nil { if errors.Is(err, model.ErrNotFound) { diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 907b300de..8b35a872e 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() { Expect(readAll(img)).To(Equal(coverBytes)) }) + It("treats a file-backed row pointing at a non-image file as dangling", func() { + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "alni") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd", + Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + + It("refuses a non-image file-backed row even when a resized copy is already cached", func() { + secret := []byte("password=secret") + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "alnic") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee", + Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), + })).To(Succeed()) + + // Older versions cached the raw bytes when the resize failed. + seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil } + stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(stream)).To(Equal(secret)) + Expect(stream.Close()).To(Succeed()) + Eventually(func(g Gomega) { + s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(s.Cached).To(BeTrue()) + _ = s.Close() + }).Should(Succeed()) + + _, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index e7a2d3765..f4f3ef725 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -572,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold // resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other // open failure says nothing about whether the image exists. func resolveLocalFile(path, source string) (resolution, bool) { - if path == "" { + if path == "" || !model.IsImageFile(path) { return resolution{}, false } f, err := os.Open(path) diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 402a11363..da144d8e2 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) + It("never opens a local ExternalImageURL that is not an image file", func() { + folderRepo.result = nil // no grid tiles, so only the local file could produce a reader + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.sourcePath).ToNot(Equal(secretPath)) + }) + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { conf.Server.EnableM3UExternalAlbumArt = true folderRepo.result = nil // no grid tiles, so the external failure is what surfaces diff --git a/core/artwork/sources.go b/core/artwork/sources.go index f2abf9da5..ae41acc48 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -37,7 +37,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } - if !match { + if !match || !model.IsImageFile(name) { continue } f, err := libFS.Open(file) diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go index 4282575a5..5f70b1cc5 100644 --- a/core/artwork/sources_internal_test.go +++ b/core/artwork/sources_internal_test.go @@ -48,6 +48,18 @@ var _ = Describe("fromExternalFile", func() { Expect(b).To(Equal([]byte("a"))) Expect(path).To(Equal("a/cover.jpg")) }) + + It("skips a matching file that is not an image", func() { + fsys := fstest.MapFS{ + "a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")}, + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("a/cover.jpg")) + }) }) var _ = Describe("fromTag", func() { diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 445561266..a90a703d9 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -236,6 +236,24 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) + It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() { + tmpDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed()) + + m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() { conf.Server.EnableM3UExternalAlbumArt = false @@ -1011,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) + DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries", + func(imageURL, expected string) { + ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}}) + repo.data = []string{"tests/test.mp3"} + m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n" + pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(expected)) + }, + Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")), + Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""), + ) + // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation, // so we need exact matching for non-ASCII characters. It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() { diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 286f2e420..b9cb154cb 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) @@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m continue } if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { - pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher) + owner, _ := request.UserFrom(ctx) + pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner) continue } // Skip empty lines and extended info @@ -286,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string { +func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher // Resolve to local absolute path localPath, ok := resolveLocalPath(value, folder) - if !ok { + if !ok || !model.IsImageFile(localPath) { return "" } - // Validate path is within a known library - if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 { + lib, ok := matcher.findLibrary(localPath) + // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. + if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { return "" } return localPath diff --git a/tests/init_tests.go b/tests/init_tests.go index 902cf196d..eee4428a8 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/navidrome/navidrome/conf" + _ "github.com/navidrome/navidrome/conf/mime" // registers mime_types.yaml, so tests see the same image types as the server "github.com/navidrome/navidrome/log" ) From 0429fb3d40ffcc2c52d684334ad31c65f8a7e466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 15:27:20 -0400 Subject: [PATCH 556/596] fix(test): stop the Windows test job from failing at random (#6182) * fix(persistence): don't format a nil-model row in wrapCursor * test(plugins): assert task queue delay against the first dispatch, not consecutive gaps * test(artwork): let the e2e worker wait outlast one retry * chore: trim comments * fix(persistence): guard dbFolder and dbMediaFile String() against a nil model --- core/artwork/e2e/e2e_suite_test.go | 3 ++- persistence/folder_repository.go | 8 ++++++++ persistence/folder_repository_test.go | 7 +++++++ persistence/mediafile_repository.go | 8 ++++++++ persistence/mediafile_repository_test.go | 7 +++++++ persistence/sql_base_repository.go | 3 ++- plugins/host_taskqueue_test.go | 9 +++++---- 7 files changed, 39 insertions(+), 6 deletions(-) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go index 8881da3a6..390be14a7 100644 --- a/core/artwork/e2e/e2e_suite_test.go +++ b/core/artwork/e2e/e2e_suite_test.go @@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { done <- worker.Run(runCtx) }() - Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + // Long enough for one retry (3-7s backoff, 5s poll tick). + Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue()) cancel() Eventually(done, 2*time.Second).Should(Receive(BeNil())) } diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index a4b73d9d6..a1136ad8d 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -27,6 +27,14 @@ type dbFolder struct { ImageFiles string `structs:"-" json:"-"` } +// String guards the promoted Folder.String(), which would dereference a nil Folder. +func (f dbFolder) String() string { + if f.Folder == nil { + return "<nil>" + } + return f.Folder.String() +} + func (f *dbFolder) PostScan() error { var err error if f.ImageFiles != "" { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 545d8ad49..b7bc52751 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -310,6 +310,13 @@ var _ = Describe("FolderRepository", func() { }) }) + Describe("dbFolder.String", func() { + It("does not dereference a nil Folder", func() { + Expect(fmt.Sprint(dbFolder{})).To(Equal("<nil>")) + Expect(fmt.Sprint(&dbFolder{})).To(Equal("<nil>")) + }) + }) + Describe("wrapFolderCursor", func() { It("does not panic when the cursor yields a dbFolder with nil Folder", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 935a64bb9..835c39b1e 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -38,6 +38,14 @@ type dbMediaFile struct { RgTrackPeak *float64 `structs:"-" json:"-"` } +// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile. +func (m dbMediaFile) String() string { + if m.MediaFile == nil { + return "<nil>" + } + return m.MediaFile.String() +} + func (m *dbMediaFile) PostScan() error { m.RGTrackGain = m.RgTrackGain m.RGTrackPeak = m.RgTrackPeak diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 1fb939415..d81b5e9ee 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1203,6 +1203,13 @@ var _ = Describe("MediaRepository", func() { }) }) + Describe("dbMediaFile.String", func() { + It("does not dereference a nil MediaFile", func() { + Expect(fmt.Sprint(dbMediaFile{})).To(Equal("<nil>")) + Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("<nil>")) + }) + }) + Describe("wrapMediaFileCursor", func() { It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 9248f2a26..bc841db03 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -378,8 +378,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S for row, err := range cursor { m := toModel(row) if m == nil { + // Don't format row: its String() derefs the nil model (golang/go#81238). var zero T - yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err)) return } if !yield(*m, err) || err != nil { diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index 72fb4cccd..993ddc7f6 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -689,11 +689,12 @@ var _ = Describe("TaskQueueService", func() { copy(times, dispatchTimes) mu.Unlock() - // Consecutive dispatches should have at least ~160ms gap (80% of 200ms) + // Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps. for i := 1; i < len(times); i++ { - gap := times[i].Sub(times[i-1]) - Expect(gap).To(BeNumerically(">=", 160*time.Millisecond), - fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap)) + offset := times[i].Sub(times[0]) + minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond + Expect(offset).To(BeNumerically(">=", minOffset), + fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset)) } }) }) From 237276efcd1e571714eff5e469eb7328b439fe69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 20:46:46 -0400 Subject: [PATCH 557/596] fix(artwork): block private and loopback addresses in remote image fetches (#6181) * fix(artwork): block private and loopback addresses in remote image fetches fromURL fetched any URL with a plain HTTP client, and two untrusted inputs reach it. A playlist can set #EXTALBUMARTURL to an http(s) URL, which the artwork worker later fetches when EnableM3UExternalAlbumArt is on, so any user who can import a playlist controls the target. Metadata agents, including WASM plugins without the http permission, return image URLs that the core fetches too. Either path could make the server request loopback, LAN or link-local addresses and store the response as artwork that is served back. Add httpclient.NewExternal, which dials through a net.Dialer Control hook that rejects private, loopback, link-local and unspecified addresses. The check runs at dial time on the resolved IP, so DNS names, redirects and DNS rebinding are covered. fromURL now uses one shared client built with it and treats a refused address as a definitive miss, so the item settles absent instead of retrying and tripping the agent's circuit breaker. httpclient.New is unchanged for the other callers. The IP classification moves from plugins to the new utils/netguard package, shared by the plugin host client and the new constructor. The artwork test suite swaps in a client that allows loopback so existing specs can keep using httptest servers; the fromURL specs use the production client to assert the refusal. * fix(httpclient): keep dialing a configured proxy in the guarded client The guard runs on the resolved address, and with HTTP_PROXY set that address is the proxy, not the image host. A proxy on a private address would have had every remote artwork fetch refused, and a refusal settles the item as absent, so covers would silently disappear for those setups. Dial the configured proxy endpoint directly and keep the guard for every other dial. A proxy relays the request itself, so it is the operator's egress policy, the same one every other httpclient.New caller already goes through. * fix(httpclient): exempt only the hop that actually goes through the proxy The exemption matched any dial to a configured proxy's address, but net/http never proxies loopback targets, so a URL aimed at a loopback proxy was dialed directly and skipped the guard. That let an image URL reach that one address. Tag each request with the proxy it resolves to and exempt a dial only when it is that hop. Redirects re-enter the RoundTripper, so every hop is tagged on its own. --- core/artwork/artwork_suite_test.go | 11 +++ core/artwork/sources.go | 13 +++- core/artwork/sources_internal_test.go | 74 +++++++++++++++++++ plugins/host_httpclient.go | 3 +- plugins/host_netguard.go | 8 +- utils/httpclient/httpclient.go | 49 ++++++++++++ .../httpclient_proxy_internal_test.go | 68 +++++++++++++++++ utils/httpclient/httpclient_test.go | 31 ++++++++ utils/netguard/netguard.go | 38 ++++++++++ utils/netguard/netguard_suite_test.go | 17 +++++ utils/netguard/netguard_test.go | 65 ++++++++++++++++ 11 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 utils/httpclient/httpclient_proxy_internal_test.go create mode 100644 utils/netguard/netguard.go create mode 100644 utils/netguard/netguard_suite_test.go create mode 100644 utils/netguard/netguard_test.go diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 1ea82b7fa..93aacc1fa 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -2,18 +2,21 @@ package artwork import ( "io/fs" + "net/netip" "net/url" "os" "path/filepath" "runtime" "strings" "testing" + "time" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" @@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) { RunSpecs(t, "Artwork Suite") } +// productionImageClient keeps the guarded client for the specs that assert it refuses loopback. +var productionImageClient = remoteImageClient + +// httptest servers listen on loopback, which the production client refuses. +var _ = BeforeSuite(func() { + remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")) +}) + // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. type osDirFS struct{ fs.FS } diff --git a/core/artwork/sources.go b/core/artwork/sources.go index ae41acc48..daa084c16 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" "go.senan.xyz/taglib" ) @@ -150,10 +151,18 @@ type readCloser struct { io.Closer } +// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach +// internal hosts. Shared so fetches reuse connections. +var remoteImageClient = httpclient.NewExternal(5 * time.Second) + func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { - hc := httpclient.New(5 * time.Second) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) - resp, err := hc.Do(req) //nolint:gosec + resp, err := remoteImageClient.Do(req) + if errors.Is(err, netguard.ErrPrivateAddress) { + // Retrying cannot change where the URL points: settle absent instead of tripping the breaker. + log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err) + return nil, "", model.ErrNotFound + } if err != nil { return nil, "", err } diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go index 5f70b1cc5..bc81b56e7 100644 --- a/core/artwork/sources_internal_test.go +++ b/core/artwork/sources_internal_test.go @@ -5,13 +5,87 @@ import ( "errors" "io" "io/fs" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" "os" + "strings" + "sync/atomic" "testing/fstest" + "time" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("fromURL", func() { + var ( + hits atomic.Int32 + target *httptest.Server + ) + + BeforeEach(func() { + hits.Store(0) + target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("image-bytes")) + })) + DeferCleanup(target.Close) + }) + + useClient := func(c *http.Client) { + prev := remoteImageClient + remoteImageClient = c + DeferCleanup(func() { remoteImageClient = prev }) + } + fetch := func(rawURL string) ([]byte, error) { + u, err := url.Parse(rawURL) + Expect(err).ToNot(HaveOccurred()) + r, _, err := fromURL(GinkgoT().Context(), u) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + } + // Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused. + onlyLocalhostV4 := func() *http.Client { + return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32")) + } + + DescribeTable("refuses private and loopback targets as a definitive miss", + func(rawURL string) { + useClient(productionImageClient) + u, _ := url.Parse(target.URL) + _, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port())) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(hits.Load()).To(BeZero()) + }, + Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"), + Entry("localhost", "http://localhost:PORT/x"), + Entry("cloud metadata", "http://169.254.169.254/"), + Entry("IPv6 loopback", "http://[::1]/"), + ) + + It("refuses a redirect from an allowed host to a loopback address", func() { + useClient(onlyLocalhostV4()) + redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound)) + DeferCleanup(redirector.Close) + + _, err := fetch(redirector.URL) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(hits.Load()).To(BeZero()) + }) + + It("fetches from an allowed address", func() { + useClient(onlyLocalhostV4()) + Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes"))) + }) +}) + var _ = Describe("fromExternalFile", func() { It("opens a matching file via the library FS", func() { fsys := fstest.MapFS{ diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 54875ae8a..3265ac4b7 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" ) const ( @@ -201,7 +202,7 @@ func isPrivateOrLoopback(hostname string) bool { if ip == nil { return false } - return isPrivateIP(ip) + return netguard.IsPrivateIP(ip) } // Verify interface implementation diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go index 2244fed1f..f78aaf6a8 100644 --- a/plugins/host_netguard.go +++ b/plugins/host_netguard.go @@ -4,6 +4,8 @@ import ( "fmt" "net" "slices" + + "github.com/navidrome/navidrome/utils/netguard" ) // checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a @@ -17,7 +19,7 @@ func checkPrivateDial(requiredHosts []string, address string) error { return err } ip := net.ParseIP(host) - if ip == nil || !isPrivateIP(ip) { + if ip == nil || !netguard.IsPrivateIP(ip) { return nil } for _, entry := range requiredHosts { @@ -51,7 +53,3 @@ func ipMatchesEntry(entry string, ip net.IP) bool { } return false } - -func isPrivateIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() -} diff --git a/utils/httpclient/httpclient.go b/utils/httpclient/httpclient.go index 7fb48f36d..b0e7b5681 100644 --- a/utils/httpclient/httpclient.go +++ b/utils/httpclient/httpclient.go @@ -3,10 +3,15 @@ package httpclient import ( + "context" + "net" "net/http" + "net/netip" + "net/url" "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/utils/netguard" ) type uaTransport struct { @@ -33,3 +38,47 @@ func NewTransport(base http.RoundTripper) http.RoundTripper { func New(timeout time.Duration) *http.Client { return &http.Client{Timeout: timeout, Transport: NewTransport(nil)} } + +// proxyFunc resolves the proxy for a request; tests replace it. +var proxyFunc = http.ProxyFromEnvironment + +type proxyAddrKey struct{} + +// NewExternal is New for URLs from untrusted sources: it refuses to dial private, loopback, +// link-local and unspecified addresses, except those covered by allowed. +func NewExternal(timeout time.Duration, allowed ...netip.Prefix) *http.Client { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = proxyFunc + direct := net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + guarded := direct + guarded.Control = netguard.DialControl(allowed...) + t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + // Exempt the hop to the proxy, which relays the request and is operator config. A URL + // aimed at the proxy's own address is not proxied, so it stays guarded. + if proxy, ok := ctx.Value(proxyAddrKey{}).(string); ok && proxy == addr { + return direct.DialContext(ctx, network, addr) + } + return guarded.DialContext(ctx, network, addr) + } + return &http.Client{Timeout: timeout, Transport: NewTransport(&proxyTagger{base: t})} +} + +// proxyTagger records the proxy each request resolves to, so the dialer can tell a hop to the +// proxy from a dial to the URL's own host. +type proxyTagger struct{ base http.RoundTripper } + +func (p *proxyTagger) RoundTrip(req *http.Request) (*http.Response, error) { + if u, err := proxyFunc(req); err == nil && u != nil { + req = req.WithContext(context.WithValue(req.Context(), proxyAddrKey{}, proxyAddr(u))) + } + return p.base.RoundTrip(req) +} + +// proxyAddr mirrors how net/http addresses a proxy connection. +func proxyAddr(u *url.URL) string { + port := u.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443", "socks5": "1080", "socks5h": "1080"}[u.Scheme] + } + return net.JoinHostPort(u.Hostname(), port) +} diff --git a/utils/httpclient/httpclient_proxy_internal_test.go b/utils/httpclient/httpclient_proxy_internal_test.go new file mode 100644 index 000000000..2d542d8fe --- /dev/null +++ b/utils/httpclient/httpclient_proxy_internal_test.go @@ -0,0 +1,68 @@ +package httpclient + +import ( + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/utils/netguard" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("NewExternal with a proxy", func() { + var proxy *httptest.Server + var proxied atomic.Int32 + var proxyURL *url.URL + + BeforeEach(func() { + proxied.Store(0) + proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + proxied.Add(1) + _, _ = w.Write([]byte("via proxy")) + })) + DeferCleanup(proxy.Close) + proxyURL, _ = url.Parse(proxy.URL) + prev := proxyFunc + DeferCleanup(func() { proxyFunc = prev }) + }) + + It("dials the configured proxy even though it listens on a private address", func() { + proxyFunc = func(*http.Request) (*url.URL, error) { return proxyURL, nil } + + resp, err := NewExternal(time.Second).Get("http://navidrome.example.com/cover.jpg") + Expect(err).ToNot(HaveOccurred()) + defer resp.Body.Close() + Expect(proxied.Load()).To(Equal(int32(1))) + }) + + // net/http never proxies loopback targets, so a URL aimed at a loopback proxy is dialed directly. + It("refuses a direct dial to the proxy's own address when the request is not proxied", func() { + proxyFunc = func(r *http.Request) (*url.URL, error) { + if r.URL.Hostname() == "127.0.0.1" { + return nil, nil + } + return proxyURL, nil + } + + _, err := NewExternal(time.Second).Get(proxy.URL + "/secret") + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + Expect(proxied.Load()).To(BeZero()) + }) + + It("still refuses a direct dial to a private address", func() { + proxyFunc = func(r *http.Request) (*url.URL, error) { + if r.URL.Scheme == "https" { + return proxyURL, nil + } + return nil, nil + } + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + DeferCleanup(target.Close) + + _, err := NewExternal(time.Second).Get(target.URL) + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + }) +}) diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go index c86b51165..7b1e58797 100644 --- a/utils/httpclient/httpclient_test.go +++ b/utils/httpclient/httpclient_test.go @@ -3,10 +3,12 @@ package httpclient_test import ( "net/http" "net/http/httptest" + "net/netip" "time" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -16,6 +18,7 @@ var _ = Describe("httpclient", func() { var receivedUA string BeforeEach(func() { + receivedUA = "" server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedUA = r.Header.Get("User-Agent") })) @@ -48,6 +51,34 @@ var _ = Describe("httpclient", func() { }) }) + Describe("NewExternal", func() { + It("refuses to connect to a loopback server", func() { + c := httpclient.NewExternal(time.Second) + _, err := c.Get(server.URL) + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + Expect(receivedUA).To(BeEmpty()) + }) + + It("refuses a redirect from an allowed host to a private address", func() { + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) + })) + DeferCleanup(redirector.Close) + + c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.1/32")) + _, err := c.Get(redirector.URL) + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + }) + + It("connects to addresses covered by an allowed prefix and sets the User-Agent", func() { + c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.0/8")) + resp, err := c.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) + }) + }) + Describe("NewTransport", func() { It("uses the default transport when base is nil", func() { c := &http.Client{Transport: httpclient.NewTransport(nil)} diff --git a/utils/netguard/netguard.go b/utils/netguard/netguard.go new file mode 100644 index 000000000..43d80ecc9 --- /dev/null +++ b/utils/netguard/netguard.go @@ -0,0 +1,38 @@ +// Package netguard keeps outbound connections driven by untrusted input away from internal addresses. +package netguard + +import ( + "errors" + "fmt" + "net" + "net/netip" + "syscall" +) + +var ErrPrivateAddress = errors.New("dial to private/loopback address blocked") + +// IsPrivateIP reports whether ip is loopback, private, link-local or unspecified. +func IsPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} + +// DialControl returns a net.Dialer Control hook that rejects private addresses not covered by allowed. +// It sees the resolved IP, so DNS names, redirects and rebinding cannot get around it. +func DialControl(allowed ...netip.Prefix) func(network, address string, c syscall.RawConn) error { + return func(_, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil { + return fmt.Errorf("%w: unparseable address %q: %w", ErrPrivateAddress, address, err) + } + addr := ap.Addr().Unmap() + if !IsPrivateIP(addr.AsSlice()) { + return nil + } + for _, p := range allowed { + if p.Contains(addr) { + return nil + } + } + return fmt.Errorf("%w: %s", ErrPrivateAddress, address) + } +} diff --git a/utils/netguard/netguard_suite_test.go b/utils/netguard/netguard_suite_test.go new file mode 100644 index 000000000..7769e0c82 --- /dev/null +++ b/utils/netguard/netguard_suite_test.go @@ -0,0 +1,17 @@ +package netguard_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNetguard(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Netguard Suite") +} diff --git a/utils/netguard/netguard_test.go b/utils/netguard/netguard_test.go new file mode 100644 index 000000000..733204226 --- /dev/null +++ b/utils/netguard/netguard_test.go @@ -0,0 +1,65 @@ +package netguard_test + +import ( + "net" + "net/netip" + + "github.com/navidrome/navidrome/utils/netguard" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("netguard", func() { + DescribeTable("IsPrivateIP", + func(addr string, expected bool) { + Expect(netguard.IsPrivateIP(net.ParseIP(addr))).To(Equal(expected)) + }, + Entry("IPv4 loopback", "127.0.0.1", true), + Entry("IPv4 loopback range", "127.0.0.2", true), + Entry("IPv6 loopback", "::1", true), + Entry("IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", true), + Entry("10.x", "10.1.2.3", true), + Entry("172.16.x", "172.16.0.1", true), + Entry("192.168.x", "192.168.1.10", true), + Entry("IPv6 unique local", "fd00::1", true), + Entry("link-local (cloud metadata)", "169.254.169.254", true), + Entry("IPv6 link-local", "fe80::1", true), + Entry("link-local multicast", "224.0.0.1", true), + Entry("IPv4 unspecified", "0.0.0.0", true), + Entry("IPv6 unspecified", "::", true), + Entry("public IPv4", "93.184.216.34", false), + Entry("172.32.x is outside the private block", "172.32.0.1", false), + Entry("public IPv6", "2606:4700:4700::1111", false), + ) + + Describe("DialControl", func() { + It("rejects private and loopback addresses", func() { + control := netguard.DialControl() + for _, addr := range []string{"127.0.0.1:80", "[::1]:443", "169.254.169.254:80", "10.0.0.1:8080", "0.0.0.0:80"} { + Expect(control("tcp", addr, nil)).To(MatchError(netguard.ErrPrivateAddress), addr) + } + }) + + It("allows public addresses", func() { + control := netguard.DialControl() + Expect(control("tcp", "93.184.216.34:443", nil)).To(Succeed()) + Expect(control("tcp6", "[2606:4700:4700::1111]:443", nil)).To(Succeed()) + }) + + It("allows private addresses covered by an allowed prefix, and only those", func() { + control := netguard.DialControl(netip.MustParsePrefix("127.0.0.1/32")) + Expect(control("tcp", "127.0.0.1:8080", nil)).To(Succeed()) + Expect(control("tcp", "127.0.0.2:8080", nil)).To(MatchError(netguard.ErrPrivateAddress)) + }) + + It("matches IPv4-mapped IPv6 addresses against IPv4 prefixes", func() { + control := netguard.DialControl(netip.MustParsePrefix("127.0.0.0/8")) + Expect(control("tcp", "[::ffff:127.0.0.1]:80", nil)).To(Succeed()) + }) + + It("fails closed when the address is not an IP", func() { + Expect(netguard.DialControl()("tcp", "localhost:80", nil)).To(HaveOccurred()) + Expect(netguard.DialControl()("tcp", "garbage", nil)).To(HaveOccurred()) + }) + }) +}) From 9e8811e4c5cfb22a73acdca840256836794d0f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 21:10:41 -0400 Subject: [PATCH 558/596] fix(server): enforce player ownership on create and registration (#6184) POST /api/player passed the ownership check using the userId from the request body, then saved with the body id. When that id belonged to another user's player, the save became an update with no owner restriction, overwriting the row and moving it to the caller. Save now always creates a new player and ignores any id in the body; edits keep going through the owner-scoped Update. Player registration also reused a player by the id sent in the Subsonic player cookie or the Jellyfin DeviceId without checking its owner, letting a user attach to another user's player and overwrite its name, user agent and IP. Register now only reuses a player owned by the requesting user, falling back to the user's own players otherwise. --- core/players.go | 2 +- core/players_test.go | 15 +++++++++++++-- persistence/player_repository.go | 2 +- persistence/player_repository_test.go | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/core/players.go b/core/players.go index 963914514..b757f8460 100644 --- a/core/players.go +++ b/core/players.go @@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip user, _ := request.UserFrom(ctx) if playerID != "" { plr, err = p.ds.Player(ctx).Get(playerID) - if err == nil && plr.Client != client { + if err == nil && (plr.Client != client || plr.UserId != user.ID) { playerID = "" } } diff --git a/core/players_test.go b/core/players_test.go index 90c265fcc..55ec16833 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -61,8 +61,19 @@ var _ = Describe("Players", func() { Expect(trc).To(BeNil()) }) + It("does not reuse another user's player by ID", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"} + repo.add(plr) + p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(Equal("123")) + Expect(p.UserId).To(Equal("userid")) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + It("finds players by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -93,7 +104,7 @@ var _ = Describe("Players", func() { }) It("finds player by ID and return its transcoding", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index a2b079d53..a2114b060 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -126,7 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) { if !r.isPermitted(t) { return "", rest.ErrPermissionDenied } - return r.put(t.ID, t) + return r.put("", t) // Save only creates; edits go through the owner-scoped Update } func (r *playerRepository) Update(id string, entity any, cols ...string) error { diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index b7085a1fb..4a7701ac2 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -288,6 +288,27 @@ var _ = Describe("PlayerRepository", func() { Expect(*stored).To(Equal(adminPlayer1)) }) + It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() { + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, + ReportRealPath: true, + } + + id, err := regularRepo.Save(&spoofed) + Expect(err).To(BeNil()) + Expect(id).ToNot(Equal(adminPlayer1.ID)) + + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + + created, err := adminRepo.Get(id) + Expect(err).To(BeNil()) + Expect(created.UserId).To(Equal(regularUser.ID)) + }) + It("does not let a regular user reassign their own player to another user", func() { // Owner updates their own player but tries to give it away to the admin. The update // succeeds for the other fields, but user_id is never written, so ownership stays put. From cabfd16f9faee4a205fc6470339df2fa009be359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 21:36:35 -0400 Subject: [PATCH 559/596] fix(ui): update Finnish, Dutch translations from POEditor (#6148) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/fi.json | 17 ++++++++++++++--- resources/i18n/nl.json | 36 +++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 3d1edbbd5..f79d71f94 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -202,7 +202,7 @@ "command": "Komento" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "Ei mikään" } }, "playlist": { @@ -609,7 +609,10 @@ "coverRemoved": "Kansikuva poistettu", "coverUploadError": "Virhe ladattaessa kansikuvaa", "coverRemoveError": "Virhe poistettaessa kansikuvaa", - "metadataRefreshStarted": "Metatietoja päivitetään taustalla" + "metadataRefreshStarted": "Metatietoja päivitetään taustalla", + "quickConnectApproved": "", + "quickConnectInvalidCode": "", + "quickConnectError": "" }, "menu": { "library": "Kirjasto", @@ -645,7 +648,15 @@ "selectLibraries": "Valitse kirjastot", "none": "Ei mitään" }, - "onlyFavourites": "Näytä vain suosikit" + "onlyFavourites": "Näytä vain suosikit", + "quickConnect": { + "name": "", + "code": "", + "help": "", + "confirm": "", + "continue": "", + "approve": "" + } }, "player": { "playListsText": "Jono", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 46c3df9de..cb276b8f4 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -93,7 +93,8 @@ "addToPlaylist": "Toevoegen aan afspeellijst", "download": "Downloaden", "info": "Meer info", - "share": "Delen" + "share": "Delen", + "refresh": "Metadata verversen" }, "lists": { "all": "Alle", @@ -155,11 +156,13 @@ "newPassword": "Nieuw wachtwoord", "token": "Token", "lastAccessAt": "Meest recente toegang", - "libraries": "Bibliotheken" + "libraries": "Bibliotheken", + "scrobbleFilter": "Scrobble filter" }, "helperTexts": { "name": "Naamswijziging wordt pas zichtbaar bij de volgende login", - "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken" + "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken", + "scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten." }, "notifications": { "created": "Aangemaakt door gebruiker", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken" }, "validation": { - "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers" + "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers", + "invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Doelformaat", "defaultBitRate": "Standaard bitrate", "command": "Commando" + }, + "choices": { + "noDefaultBitRate": "Geen" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Nummers", "comment": "Commentaar", "sync": "Auto-importeren", - "path": "Importeer vanuit" + "path": "Importeer vanuit", + "starred": "Favoriet" }, "actions": { "selectPlaylist": "Selecteer een afspeellijst:", @@ -403,7 +411,8 @@ "requiredHosts": "Benodigde hosts", "configValidationError": "Configuratiecheck mislukt", "schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.", - "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen." + "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.", + "idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten." }, "placeholders": { "configKey": "Sleutel", @@ -599,7 +608,11 @@ "coverUploaded": "Albumhoes bijgewerkt", "coverRemoved": "Albumhoes verwijderd", "coverUploadError": "Fout bij het toevoegen albumhoes", - "coverRemoveError": "Fout bij verwijderen albumhoes" + "coverRemoveError": "Fout bij verwijderen albumhoes", + "metadataRefreshStarted": "Metadata verversen op de achtergrond", + "quickConnectApproved": "", + "quickConnectInvalidCode": "", + "quickConnectError": "" }, "menu": { "library": "Bibliotheek", @@ -634,6 +647,15 @@ "multipleLibraries": "%{selected} van %{total} bibliotheken", "selectLibraries": "Selecteer bibliotheken", "none": "Geen" + }, + "onlyFavourites": "Toon alleen favorieten", + "quickConnect": { + "name": "", + "code": "", + "help": "", + "confirm": "", + "continue": "", + "approve": "" } }, "player": { From c3b9b4ecb37f443770866a2068db5326118e7b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Sep 2026 22:02:23 -0400 Subject: [PATCH 560/596] fix(subsonic): rate limit failed authentication attempts (#6185) * fix(subsonic): limit failed authentication attempts per client IP and username The Subsonic API checked credentials on every request with no limit on failures, so any account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same settings that guard the UI login. Every request carries credentials, so only failures count: a slot is taken before the check and given back on success or on a server error, which also stops concurrent guesses from overshooting the limit. Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket. * refactor(subsonic): simplify failed authentication limiter Release the limiter slot from a single place in authenticate(), after the user lookup and credential check, instead of separately in the canceled branch. Store attempt counters by value instead of by pointer, and drop limiter unit tests that only repeated the middleware specs. * fix(subsonic): wait for an in-flight auth check instead of rejecting Slots were reserved before the credential check and only released afterwards, so once AuthRequestLimit checks for the same client IP and username overlapped, the next request was answered with error code 40 even when its credentials were valid. Clients that fan out parallel requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty five, and the web UI authenticates its own /rest calls the same way. A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather than failing when other checks for that key are in flight. Failures are recorded after the check, and a request is only rejected when the key already reached the limit within the window. A waiting request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land. * docs(subsonic): state the real guess ceiling of the auth limiter The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead. --- server/middlewares.go | 13 +- server/subsonic/auth_limiter.go | 134 +++++++++++++++++++ server/subsonic/auth_limiter_test.go | 143 ++++++++++++++++++++ server/subsonic/middlewares.go | 30 +++-- server/subsonic/middlewares_test.go | 187 +++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 13 deletions(-) create mode 100644 server/subsonic/auth_limiter.go create mode 100644 server/subsonic/auth_limiter_test.go diff --git a/server/middlewares.go b/server/middlewares.go index c54e947f3..674337e92 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -232,15 +232,20 @@ func trustedProxyPrefixes(list string) []string { return prefixes } -// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware, -// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer -// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket. +// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers +// cannot be rotated for a fresh bucket. func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { - return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil + return ClientIP(r), nil }) } +// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The +// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. +func ClientIP(r *http.Request) string { + return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) +} + // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key // can only be set once. func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler { diff --git a/server/subsonic/auth_limiter.go b/server/subsonic/auth_limiter.go new file mode 100644 index 000000000..88df6eb50 --- /dev/null +++ b/server/subsonic/auth_limiter.go @@ -0,0 +1,134 @@ +package subsonic + +import ( + "cmp" + "context" + "hash/maphash" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" +) + +// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures +// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait. +type authLimiter struct { + limit int + window time.Duration + seed maphash.Seed + mu sync.Mutex + keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory + lastSweep time.Time +} + +type authAttempts struct { + failures int + start time.Time + slots chan struct{} + refs int +} + +// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled +// limiter hands back. +type authSlot struct { + limiter *authLimiter + entry *authAttempts +} + +// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything. +func newAuthLimiter(limit int, window time.Duration) *authLimiter { + if limit <= 0 { + return nil + } + return &authLimiter{ + limit: limit, + window: cmp.Or(window, consts.DefaultAuthWindowLength), + seed: maphash.MakeSeed(), + keys: map[uint64]*authAttempts{}, + } +} + +// acquire reserves a credential check for key, waiting while other checks for the same key are in +// flight. It only fails when the key already reached `limit` failures in the current window. +func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) { + if l == nil { + return nil, true + } + a, ok := l.reserve(key) + if !ok { + return nil, false + } + + select { + case a.slots <- struct{}{}: + case <-ctx.Done(): + l.unref(a) + return nil, false + } + + l.mu.Lock() + blocked := a.failures >= l.limit + if blocked { + a.refs-- + } + l.mu.Unlock() + if blocked { + <-a.slots + return nil, false + } + return &authSlot{limiter: l, entry: a}, true +} + +func (l *authLimiter) reserve(key string) (*authAttempts, bool) { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + l.sweep(now) + + h := maphash.String(l.seed, key) + a := l.keys[h] + switch { + case a == nil: + a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)} + l.keys[h] = a + case now.Sub(a.start) >= l.window: + a.failures, a.start = 0, now + } + if a.failures >= l.limit { + return nil, false + } + a.refs++ + return a, true +} + +func (l *authLimiter) unref(a *authAttempts) { + l.mu.Lock() + a.refs-- + l.mu.Unlock() +} + +func (s *authSlot) release(failed bool) { + if s == nil { + return + } + s.limiter.mu.Lock() + if failed { + s.entry.failures++ + } + s.entry.refs-- + s.limiter.mu.Unlock() + <-s.entry.slots +} + +// sweep drops idle expired keys once per window, so memory is bounded by recent attempts. +func (l *authLimiter) sweep(now time.Time) { + if now.Sub(l.lastSweep) < l.window { + return + } + for h, a := range l.keys { + if a.refs == 0 && now.Sub(a.start) >= l.window { + delete(l.keys, h) + } + } + l.lastSweep = now +} diff --git a/server/subsonic/auth_limiter_test.go b/server/subsonic/auth_limiter_test.go new file mode 100644 index 000000000..66e1a594f --- /dev/null +++ b/server/subsonic/auth_limiter_test.go @@ -0,0 +1,143 @@ +package subsonic + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authLimiter", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + acquire := func(l *authLimiter, key string) (*authSlot, bool) { + GinkgoHelper() + return l.acquire(ctx, key) + } + + It("blocks a key after the configured number of failures", func() { + l := newAuthLimiter(2, time.Minute) + for range 2 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(true) + } + + _, ok := acquire(l, "k") + Expect(ok).To(BeFalse()) + }) + + It("never counts successful checks", func() { + l := newAuthLimiter(2, time.Minute) + for range 50 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(false) + } + }) + + It("keeps keys independent", func() { + l := newAuthLimiter(1, time.Minute) + slot, _ := acquire(l, "a") + slot.release(true) + _, ok := acquire(l, "a") + Expect(ok).To(BeFalse()) + + _, ok = acquire(l, "b") + Expect(ok).To(BeTrue()) + }) + + It("waits for an in-flight check instead of failing the request", func() { + l := newAuthLimiter(1, time.Minute) + held, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + + waiting := make(chan bool, 1) + go func() { + slot, ok := l.acquire(ctx, "k") + slot.release(false) + waiting <- ok + }() + Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive()) + + held.release(false) + Eventually(waiting).Should(Receive(BeTrue())) + }) + + It("stops waiting when the request is canceled", func() { + l := newAuthLimiter(1, time.Minute) + held, _ := acquire(l, "k") + DeferCleanup(func() { held.release(false) }) + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + _, ok := l.acquire(canceled, "k") + Expect(ok).To(BeFalse()) + }) + + It("does not let concurrent guesses overshoot the limit", func() { + l := newAuthLimiter(5, time.Minute) + hold := make(chan struct{}) + var checks atomic.Int32 + var wg sync.WaitGroup + for range 50 { + wg.Go(func() { + slot, ok := l.acquire(ctx, "k") + if !ok { + return + } + checks.Add(1) + <-hold + slot.release(true) + }) + } + + Eventually(checks.Load).Should(Equal(int32(5))) + Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5))) + close(hold) + wg.Wait() + + _, ok := acquire(l, "k") + Expect(ok).To(BeFalse()) + }) + + It("allows everything when the limit is disabled", func() { + l := newAuthLimiter(0, time.Minute) + for range 10 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(true) + } + }) +}) + +// testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestAuthLimiterWindow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + l := newAuthLimiter(1, 20*time.Second) + for _, key := range []string{"a", "b"} { + slot, ok := l.acquire(ctx, key) + g.Expect(ok).To(BeTrue()) + slot.release(true) + } + _, ok := l.acquire(ctx, "a") + g.Expect(ok).To(BeFalse()) + + time.Sleep(20 * time.Second) + slot, ok := l.acquire(ctx, "a") + g.Expect(ok).To(BeTrue()) + slot.release(false) + g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped") + }) +} diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 6dfa2263f..35e13eaa5 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -98,6 +98,7 @@ func checkRequiredParameters(next http.Handler) http.Handler { } func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { + limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -126,21 +127,32 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { salt, _ := p.String("s") jwt, _ := p.String("jwt") - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + // Blocked attempts get the same response as a wrong password, so they reveal nothing + limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username) + slot, allowed := limiter.acquire(ctx, limitKey) + if !allowed { + if ctx.Err() != nil { + return + } + log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorAuthenticationFail)) return } + + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if err == nil { + err = validateCredentials(usr, pass, token, salt, jwt) + } + invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth) + slot.release(invalidLogin) switch { - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, context.Canceled): + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + case invalidLogin: log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) case err != nil: log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - } } } diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index cb34b92e7..62de09e76 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" + "sync/atomic" "time" "github.com/navidrome/navidrome/conf" @@ -306,6 +308,166 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) + + When("failed attempts reach AuthRequestLimit", func() { + var cp http.Handler + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 3 + conf.Server.AuthWindowLength = time.Minute + cp = authenticate(ds)(next) + }) + + serve := func(r *http.Request) *httptest.ResponseRecorder { + next.called = false + rec := httptest.NewRecorder() + cp.ServeHTTP(rec, r) + return rec + } + failTimes := func(n int, params ...string) { + for range n { + Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`)) + } + } + + It("rejects the correct password exactly like a wrong one", func() { + failTimes(3, "u=admin", "p=WRONG") + + rec := serve(newGetRequest("u=admin", "p=wordpass")) + + Expect(next.called).To(BeFalse()) + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(rec.Header().Get("Retry-After")).To(BeEmpty()) + }) + + It("counts attempts against unknown usernames", func() { + failTimes(3, "u=newuser", "p=secret") + _ = ds.User(context.TODO()).Put(&model.User{UserName: "newuser", NewPassword: "secret"}) + + serve(newGetRequest("u=newuser", "p=secret")) + Expect(next.called).To(BeFalse()) + }) + + It("treats usernames case-insensitively", func() { + failTimes(3, "u=ADMIN", "p=WRONG") + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeFalse()) + }) + + It("does not count successful logins", func() { + for range 10 { + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + } + }) + + It("does not count server errors", func() { + userRepo := ds.User(context.TODO()).(*tests.MockedUserRepo) + userRepo.Error = errors.New("db down") + failTimes(5, "u=admin", "p=wordpass") + userRepo.Error = nil + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + }) + + It("does not block other usernames from the same IP", func() { + _ = ds.User(context.TODO()).Put(&model.User{UserName: "other", NewPassword: "otherpass"}) + failTimes(3, "u=admin", "p=WRONG") + + serve(newGetRequest("u=other", "p=otherpass")) + Expect(next.called).To(BeTrue()) + }) + + It("does not block the same username from another IP", func() { + failTimes(3, "u=admin", "p=WRONG") + + r := newGetRequest("u=admin", "p=wordpass") + r.RemoteAddr = "198.51.100.7:1234" + serve(r) + Expect(next.called).To(BeTrue()) + }) + + It("does not limit reverse proxy authentication", func() { + conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" + conf.Server.ExtAuth.UserHeader = "Remote-User" + failTimes(3, "u=admin", "p=WRONG") + + r := newGetRequest() + r.Header.Add("Remote-User", "admin") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) + serve(r) + Expect(next.called).To(BeTrue()) + }) + + It("is disabled when AuthRequestLimit is 0", func() { + conf.Server.AuthRequestLimit = 0 + cp = authenticate(ds)(next) + failTimes(10, "u=admin", "p=WRONG") + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + }) + }) + + When("valid requests overlap", func() { + var gate *gatedUserRepo + var gatedDS model.DataStore + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 5 + conf.Server.AuthWindowLength = time.Minute + gate = &gatedUserRepo{ + UserRepository: ds.User(context.TODO()), + entered: make(chan struct{}, 64), + proceed: make(chan struct{}), + } + gatedDS = &gatedDataStore{DataStore: ds, users: gate} + }) + + It("lets every valid request through while checks are in flight", func() { + const burst = 6 + cp := authenticate(gatedDS)(&countingHandler{}) + var passed atomic.Int32 + var wg sync.WaitGroup + for range burst { + wg.Go(func() { + rec := httptest.NewRecorder() + cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass")) + if !strings.Contains(rec.Body.String(), `code="40"`) { + passed.Add(1) + } + }) + } + for range conf.Server.AuthRequestLimit { + Eventually(gate.entered).Should(Receive()) + } + close(gate.proceed) + wg.Wait() + + Expect(passed.Load()).To(Equal(int32(burst))) + }) + + It("caps concurrent credential checks for wrong passwords", func() { + cp := authenticate(gatedDS)(&countingHandler{}) + var wg sync.WaitGroup + for i := range 100 { + wg.Go(func() { + cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i))) + }) + } + + limit := int32(conf.Server.AuthRequestLimit) + Eventually(gate.lookups.Load).Should(Equal(limit)) + Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit)) + close(gate.proceed) + wg.Wait() + }) + }) }) Describe("AdminOnly", func() { @@ -560,3 +722,28 @@ func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) } return &model.Player{ID: id}, mp.transcoding, nil } + +type gatedDataStore struct { + model.DataStore + users model.UserRepository +} + +func (g *gatedDataStore) User(context.Context) model.UserRepository { return g.users } + +type gatedUserRepo struct { + model.UserRepository + entered chan struct{} + proceed chan struct{} + lookups atomic.Int32 +} + +func (g *gatedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { + g.lookups.Add(1) + g.entered <- struct{}{} + <-g.proceed + return g.UserRepository.FindByUsernameWithPassword(username) +} + +type countingHandler struct{ calls atomic.Int32 } + +func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) } From 6b3938b5b659e1a9f707efb16649667084bfb3b9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 20 Sep 2026 22:25:55 -0400 Subject: [PATCH 561/596] fix(subsonic): warn when a nowPlaying scrobble sends multiple ids The scrobble endpoint accepts multiple ids, but a nowPlaying notification (submission=false) describes a single track, so only the first id is used. The extra ids were dropped silently, which made client bugs invisible. Log a warning instead, keeping the existing behavior for clients that rely on it. --- server/subsonic/media_annotation.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index cfbff3ecb..a43162cf4 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -181,6 +181,9 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) } } else { + if len(ids) > 1 { + log.Warn(ctx, "Multiple ids sent to a nowPlaying notification, only the first one will be used", "ids", ids) + } err := api.scrobblerNowPlaying(ctx, ids[0], position) if err != nil { log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) From 00bb120288af84f3764e684b9b2bab70d5028219 Mon Sep 17 00:00:00 2001 From: Antonio Russo <aerusso@aerusso.net> Date: Mon, 21 Sep 2026 07:06:12 -0600 Subject: [PATCH 562/596] fix(contrib): support libblas in systemd sandbox (#6190) Navidrome calls out to ffprobe, which in turn may use libblas on some setups (e.g., Debian 13). The previous syscall filter excluded the "mbind" syscall (via @resources). Through direct experimentation, mbind is required by libblas, and so it is added to the allowed syscall list. Signed-off-by: Antonio Enrico Russo <aerusso@aerusso.net> --- contrib/navidrome.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/navidrome.service b/contrib/navidrome.service index 5e6cbedce..ef61d2c65 100644 --- a/contrib/navidrome.service +++ b/contrib/navidrome.service @@ -36,7 +36,7 @@ RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources -SystemCallFilter=setrlimit +SystemCallFilter=setrlimit mbind SystemCallArchitectures=native UMask=0066 From 07c756db3c0a4f44856a230d71cd40e4cf8e3768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 21 Sep 2026 16:18:38 -0400 Subject: [PATCH 563/596] fix(ui): update Portuguese (BR) translations from POEditor (#6197) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/pt-br.json | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 2584da6cd..6fcde56ca 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -202,7 +202,7 @@ "command": "Comando" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "Nenhum" } }, "playlist": { @@ -609,7 +609,10 @@ "coverRemoved": "Capa removida", "coverUploadError": "Erro ao enviar capa", "coverRemoveError": "Erro ao remover capa", - "metadataRefreshStarted": "Atualizando metadados em segundo plano" + "metadataRefreshStarted": "Atualizando metadados em segundo plano", + "quickConnectApproved": "%{app} em %{device} está conectado agora", + "quickConnectInvalidCode": "Código inválido ou expirado", + "quickConnectError": "Não foi possível aprovar o código" }, "menu": { "library": "Biblioteca", @@ -645,7 +648,15 @@ "selectLibraries": "Selecionar Bibliotecas", "none": "Nenhuma" }, - "onlyFavourites": "Somente favoritas" + "onlyFavourites": "Somente favoritas", + "quickConnect": { + "name": "Conexão Rápida", + "code": "Código", + "help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta", + "confirm": "Conectar %{app} %{version} em %{device} à sua conta?", + "continue": "Continuar", + "approve": "Aprovar" + } }, "player": { "playListsText": "Fila de Execução", From cb7b042e36b00db3d20de83e175a30287361e8b0 Mon Sep 17 00:00:00 2001 From: Karl Ostendorf <karl@ostendorf.com> Date: Mon, 21 Sep 2026 23:25:46 +0200 Subject: [PATCH 564/596] fix(artwork): make stored images group-readable (#6189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Karl Ostendorf <karl@ostendorf.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- core/artwork/image_store.go | 3 +++ core/artwork/image_store_test.go | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 5dbe727e4..2ded3e636 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { if err := tmp.Close(); err != nil { return err } + if err := os.Chmod(tmp.Name(), 0640); err != nil { + return err + } return os.Rename(tmp.Name(), dst) } diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index f7b2467ca..7de3ba761 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() { Expect(got).To(Equal(data)) }) + It("writes group-readable image files", func() { + tests.SkipOnWindows("uses Unix file permission bits") + data := []byte("jpeg-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + info, err := os.Stat(store.path(h, "image/jpeg")) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640))) + }) + It("is idempotent on duplicate writes and preserves the original content", func() { data := []byte("dup") h, _ := hashImage(bytes.NewReader(data)) From 285dc4f39155954e6bb9af93f88da71b9b9cbe21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 21 Sep 2026 17:34:32 -0400 Subject: [PATCH 565/596] fix(lastfm): report a failure when the artist page has no image (#6198) GetArtistImages scrapes the og:image tag off the Last.fm artist page, because the API only ever returns the placeholder image. Last.fm now answers non-browser clients with a Fastly bot challenge, served as a 200 with valid HTML, so the query found no og:image and the agent returned an empty list with no error. The artwork worker read that as a definitive "this artist has no image" and settled the state as absent, silently and with nothing in the log. A real artist page always carries an og:image, so its absence now returns an error instead. The worker keeps the previous state, other agents still get their turn, and its per-agent circuit breaker bounds the retries. The error is deliberately not a RetryLaterError: that would park the whole Last.fm agent, including the API-backed biography, similar-artists and top-songs calls, which the page block does not affect. The new fixture is the real 3038-byte challenge page. Fixes #6192 --- adapters/lastfm/agent.go | 8 +- adapters/lastfm/agent_test.go | 27 +++++- .../lastfm.artist.page.challenge.html | 88 +++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/lastfm.artist.page.challenge.html diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 7f005db1a..dbd73c30d 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -241,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti var ( artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name + + // Not a RetryLaterError on purpose: parking the agent would also stall its API-backed + // methods, which the page block does not affect. + errNoArtistPage = errors.New("no artist image in Last.fm page") ) func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { @@ -267,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) var res []agents.ExternalImage n := cascadia.Query(node, artistOpenGraphQuery) if n == nil { - return res, nil + // A real artist page always has og:image; its absence means a bot challenge or a redesign. + log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL) + return nil, errNoArtistPage } for _, attr := range n.Attr { if attr.Key != "content" { diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index ce81e0916..b9fb786c3 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -648,18 +648,41 @@ var _ = Describe("lastfmAgent", func() { Expect(images).To(BeEmpty()) }) - It("returns empty list if page has no meta tags", func() { + It("errors when the page has no meta tags", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html") httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).To(MatchError(errNoArtistPage)) + }) + + It("errors when Last.fm serves a bot challenge page", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(MatchError(errNoArtistPage)) Expect(images).To(BeEmpty()) }) + It("does not park the agent: the failure is not a retry-later", func() { + // A RetryLaterError would cool down the agent's API-backed methods too. + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse()) + }) + It("returns error if API call fails", func() { apiClient.Err = errors.New("api error") _, err := agent.GetArtistImages(ctx, "123", "U2", "") diff --git a/tests/fixtures/lastfm.artist.page.challenge.html b/tests/fixtures/lastfm.artist.page.challenge.html new file mode 100644 index 000000000..f431891bd --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.challenge.html @@ -0,0 +1,88 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'self'; img-src 'self' data:; media-src 'self' data:; object-src 'none'; style-src 'self' 'sha256-o4vzfmmUENEg4chMjjRP9EuW9ucGnGIGVdbl8d0SHQQ='; script-src 'self' 'sha256-a9bHdQGvRzDwDVzx8m+Rzw+0FHZad8L0zjtBwkxOIz4=';" + /> + <link + href="/_fs-ch-1T1wmsGaOgGaSxcX/assets/inter-var.woff2" + rel="preload" + as="font" + type="font/woff2" + crossorigin + /> + <link href="/_fs-ch-1T1wmsGaOgGaSxcX/assets/styles.css" rel="stylesheet" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Client Challenge + + + + + + + + From 9e3deb4330b346ab0e78aa4e48605d074a6bcee3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 21 Sep 2026 19:27:48 -0400 Subject: [PATCH 566/596] ci: scope digest artifact cleanup to the current run The cleanup job listed artifacts repo-wide, so it deleted digest files uploaded by any concurrent pipeline run. When the v0.64.1 tag run overlapped with a master run, the master run's cleanup removed three of the tag run's digests before the manifest job downloaded them, and 0.64.1/latest shipped with only linux/arm64, arm/v7 and riscv64. List artifacts for the current run instead, so a run can only delete its own digests. --- .github/workflows/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index aa8e29e49..2702163d3 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -549,7 +549,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do + for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact done From 961ee8c4136fbd18b78de1929ee5f97a1ae37f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 22 Sep 2026 19:51:32 -0400 Subject: [PATCH 567/596] fix(scanner): keep tag numbers within the int32 range (#6202) * fix(scanner): keep tag numbers within the int32 range A track number of 4294967295 (-1 stored as an unsigned 32-bit tag) was saved as-is by 64-bit builds. 32-bit builds (armv5/6/7, 386) cannot read that value back into an int, so every scan failed with "converting driver.Value type int64 to a int: value out of range" when loading the folder's media files. Track and disc numbers (and their totals) are now parsed as int32 and fall back to 0 when out of range, matching how unparseable values are handled. BPM values outside the int32 range are dropped. A migration resets existing out-of-range track_number, disc_number and bpm values, and removes out-of-range keys from album.discs, so databases written by 64-bit builds are readable again by 32-bit ones. Persistent IDs are unaffected because they use the raw tag text. Fixes #6200 * fix(scanner): accept the int32 minimum as a BPM value The BPM range check compared the absolute value against MaxInt32, which rejected -2147483648 even though it fits in an int32. Compare against MinInt32 and MaxInt32 separately, matching atoi32 and the migration. * fix(scanner): treat negative track, disc and BPM values as missing Track numbers, disc numbers and BPM can never be negative, so negative tag values now map to 0 (track/disc, including totals) or nil (BPM), the same as unparseable ones. The migration resets existing negative values as well as the ones above the int32 range, and keeps only album disc keys from 0 to MaxInt32. --- ...428_clamp_media_file_int32_tag_numbers.sql | 24 +++++++++++++++++++ model/metadata/map_mediafile.go | 4 ++-- model/metadata/map_mediafile_test.go | 9 +++++++ model/metadata/metadata.go | 19 +++++++++------ model/metadata/metadata_test.go | 4 ++++ 5 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql diff --git a/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql b/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql new file mode 100644 index 000000000..dced5d744 --- /dev/null +++ b/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +-- 32-bit builds cannot read values above the int32 range written by 64-bit builds. +update media_file set track_number = 0 +where track_number < 0 or track_number > 2147483647; + +update media_file set disc_number = 0 +where disc_number < 0 or disc_number > 2147483647; + +update media_file set bpm = null +where bpm < 0 or bpm > 2147483647; + +update album set discs = ( + select json_group_object(key, value) from json_each(album.discs) + where cast(key as integer) between 0 and 2147483647 +) +where json_valid(discs) and exists ( + select 1 from json_each(album.discs) + where cast(key as integer) not between 0 and 2147483647 +); +-- +goose StatementEnd + +-- +goose Down +SELECT 1; diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index b3ce4ef02..6d12feba9 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -37,8 +37,8 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.CatalogNum = md.String(model.TagCatalogNumber) mf.Comment = md.String(model.TagComment) if f := md.NullableFloat(model.TagBPM); f != nil { - if v := int(math.Round(*f)); v != 0 { - mf.BPM = new(v) + if r := math.Round(*f); r > 0 && r <= math.MaxInt32 { + mf.BPM = new(int(r)) } } mf.Lyrics = md.mapLyrics() diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index 75a7ed358..baaf8fab5 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -131,6 +131,15 @@ var _ = Describe("ToMediaFile", func() { Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil()) Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil()) }) + It("leaves BPM nil when the tag does not fit in 32 bits", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil()) + }) + It("leaves BPM nil when the tag is negative", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil()) + }) + It("keeps the largest 32-bit BPM value", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647))) + }) }) Describe("BitDepth", func() { diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 0efbe94ec..7843e7010 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -148,15 +148,20 @@ func (md Metadata) tuple(key model.TagName) (int, int) { return 0, 0 } tuple := strings.Split(tag, "/") - t1, t2 := 0, 0 - t1, _ = strconv.Atoi(tuple[0]) + total := md.first(key + "total") if len(tuple) > 1 { - t2, _ = strconv.Atoi(tuple[1]) - } else { - t2tag := md.first(key + "total") - t2, _ = strconv.Atoi(t2tag) + total = tuple[1] } - return t1, t2 + return tagNumber(tuple[0]), tagNumber(total) +} + +// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds. +func tagNumber(s string) int { + v, err := strconv.ParseInt(s, 10, 32) + if err != nil || v < 0 { + return 0 + } + return int(v) } var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 09a2dfde0..c84d93981 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -226,6 +226,10 @@ var _ = Describe("Metadata", func() { Entry(nil, "2/10", "", 2, 10), Entry(nil, "", "", 0, 0), Entry(nil, "A", "", 0, 0), + Entry("ignores values that do not fit in 32 bits", "4294967295", "4294967296", 0, 0), + Entry("ignores a total that does not fit in 32 bits", "2/4294967295", "", 2, 0), + Entry("keeps the largest 32-bit value", "2147483647", "", 2147483647, 0), + Entry("ignores negative values", "-1", "-2", 0, 0), ) Describe("Performers", func() { From a3f41fb422dee23036fd45ead569f0c278a036a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20S=C3=A1nchez=20Zapico?= Date: Wed, 23 Sep 2026 15:47:16 +0200 Subject: [PATCH 568/596] sec(server): sanitize user-controlled filenames in Content-Disposition (#5895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sec(server): sanitize user-controlled filenames in Content-Disposition Playlist export, Subsonic download and public share download built the Content-Disposition header by interpolating a user-controlled name into a quoted-string with fmt.Sprintf. A name containing a double quote closes the string early and the rest is parsed as additional parameters, so a playlist named `party"; filename="evil.html` yielded attachment; filename="party"; filename="evil.html.m3u" letting whoever chose the name decide what the browser saves the download as. The names come from playlists, album/artist names and media file tags. Go's net/http already rewrites CR and LF in header values to spaces, so response splitting was not reachable; parameter injection was. Add str.ContentDispositionAttachment, which emits a sanitized ASCII-only quoted `filename` plus an RFC 5987 `filename*` carrying the original UTF-8 name, and use it at all four call sites. The `filename*` parameter also fixes non-ASCII names, which previously went out raw or were mangled by sanitizing. Signed-off-by: zapisanchez * fix(server): keep download names intact and sanitize filename* Rework ContentDispositionAttachment after review. Names with no ASCII letters now fall back to download. instead of a bare extension (東京.mp3 gave filename="mp3"). filename* is built from the same sanitized name as the ASCII fallback, so path separators, reserved characters, control and bidi characters, and invalid UTF-8 no longer reach it. The ASCII fallback transliterates accents and typographic punctuation (Legião -> Legiao, She’s -> She's) through the existing sanitize.Accents and str.Clear helpers, keeps leading dots, and only trims trailing ones. Names are capped at 255 bytes, keeping the extension. Pure ASCII names now get only the quoted filename parameter, so the header for them matches the previous output byte for byte. filename* is encoded with mime.FormatMediaType instead of a hand-written RFC 5987 encoder. Adds tests for the M3U export and Subsonic download headers. * fix(server): handle dot-only names and long fake extensions A name made only of dots trimmed down to an empty filename. It now falls back to download, like an empty stem does. path.Ext treats anything after the last dot as the extension, so a long suffix with no real extension was kept whole and replaced the stem with download, going past the 255-byte cap. Suffixes longer than 16 bytes are now treated as part of the stem and truncated with it. Neither case is reachable from the current call sites, which always append a short extension. --------- Signed-off-by: zapisanchez Co-authored-by: Deluan --- server/nativeapi/playlists.go | 4 +- server/nativeapi/playlists_test.go | 40 ++++++ server/public/handle_downloads.go | 7 +- server/public/handle_downloads_test.go | 21 +++ .../e2e/subsonic_media_retrieval_test.go | 4 +- server/subsonic/stream.go | 10 +- utils/str/content_disposition.go | 80 ++++++++++++ utils/str/content_disposition_test.go | 120 ++++++++++++++++++ 8 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 utils/str/content_disposition.go create mode 100644 utils/str/content_disposition_test.go diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index d215eb9dd..00c1575a5 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/str" ) type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc @@ -101,8 +102,7 @@ func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc { log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name) w.Header().Set("Content-Type", "audio/x-mpegurl") - disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name) - w.Header().Set("Content-Disposition", disposition) + w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(playlist.Name+".m3u")) _, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec if err != nil { diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 2f6c578f9..349b4a662 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/deluan/rest" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -183,6 +184,37 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) }) +var _ = Describe("handleExportPlaylist", func() { + export := func(name string) *httptest.ResponseRecorder { + r := chi.NewRouter() + r.Get("/playlist/{playlistId}", handleExportPlaylist(&mockPlaylistsService{ + playlist: &model.Playlist{ID: "pls-1", Name: name}, + })) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/playlist/pls-1", nil)) + return w + } + + It("names the download after the playlist", func() { + w := export("Road Trip") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Road Trip.m3u"`)) + }) + + It("does not let the playlist name inject a second filename parameter", func() { + w := export(`party"; filename="evil.html`) + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`)) + }) + + It("keeps non-ASCII names in filename*", func() { + w := export("Кино") + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="download.m3u"; filename*=utf-8''%D0%9A%D0%B8%D0%BD%D0%BE.m3u`)) + }) +}) + var _ = Describe("writePlaylistError", func() { DescribeTable("maps a service error to an HTTP status", func(err error, expected int) { @@ -231,6 +263,7 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) { type mockPlaylistsService struct { playlists.Playlists tracksRepo rest.Repository + playlist *model.Playlist removeImageFn func(ctx context.Context, id string) error setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error } @@ -249,6 +282,13 @@ func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader i return model.ErrNotFound } +func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { + if m.playlist == nil { + return nil, model.ErrNotFound + } + return m.playlist, nil +} + func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository { return m.tracksRepo } diff --git a/server/public/handle_downloads.go b/server/public/handle_downloads.go index 0012c4b35..e50d7609a 100644 --- a/server/public/handle_downloads.go +++ b/server/public/handle_downloads.go @@ -2,9 +2,7 @@ package public import ( "cmp" - "fmt" "net/http" - "strings" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" @@ -31,9 +29,8 @@ func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { return } - name := str.SanitizeFilename(cmp.Or(s.Description, s.ID)) - name = strings.ReplaceAll(name, ",", "_") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".zip")) + name := cmp.Or(s.Description, s.ID) + w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip")) w.Header().Set("Content-Type", "application/zip") err = pub.archiver.ZipShare(ctx, s, w) diff --git a/server/public/handle_downloads_test.go b/server/public/handle_downloads_test.go index 1a97f4379..d2118f47e 100644 --- a/server/public/handle_downloads_test.go +++ b/server/public/handle_downloads_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "mime" "net/http" "net/http/httptest" "time" @@ -95,6 +96,26 @@ var _ = Describe("handleDownloads", func() { Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`)) }) + It("sanitizes the UTF-8 filename* as well", func() { + shareIs(&model.Share{ID: "abc123", Description: "Sigur Rós/Live", Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Sigur Ros_Live.zip"; filename*=utf-8''Sigur%20R%C3%B3s_Live.zip`)) + }) + + It("does not let the share description inject a second filename parameter", func() { + shareIs(&model.Share{ID: "abc123", Description: `mix"; filename="evil.html`, Downloadable: true}) + + w := makeRequest("abc123") + + disposition := w.Header().Get("Content-Disposition") + Expect(disposition).ToNot(ContainSubstring(`filename="evil.html`)) + _, params, err := mime.ParseMediaType(disposition) + Expect(err).ToNot(HaveOccurred()) + Expect(params["filename"]).To(Equal(`mix_; filename=_evil.html.zip`)) + }) + It("returns 403 without invoking the archiver when the share is not downloadable", func() { shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false}) diff --git a/server/subsonic/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go index 079b131ff..268d93b82 100644 --- a/server/subsonic/e2e/subsonic_media_retrieval_test.go +++ b/server/subsonic/e2e/subsonic_media_retrieval_test.go @@ -106,7 +106,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { }) Describe("Download", func() { - var trackID string + var trackID, trackTitle string BeforeAll(func() { // All test tracks are mp3 at 320kbps @@ -114,6 +114,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) trackID = songs[0].ID + trackTitle = songs[0].Title }) It("returns error when id parameter is missing", func() { @@ -143,6 +144,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="` + trackTitle + `.opus"`)) }) It("returns error when downloads are disabled", func() { diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 28b4585f0..b4a6b821c 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -3,10 +3,8 @@ package subsonic import ( "context" "errors" - "fmt" "net/http" "strconv" - "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/stream" @@ -15,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/str" ) func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { @@ -94,9 +93,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. } setHeaders := func(name string) { - name = strings.ReplaceAll(name, ",", "_") - disposition := fmt.Sprintf("attachment; filename=\"%s.zip\"", name) - w.Header().Set("Content-Disposition", disposition) + w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip")) w.Header().Set("Content-Type", "application/zip") } @@ -115,8 +112,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. } }() - disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) - w.Header().Set("Content-Disposition", disposition) + w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(stream.Name())) _, err = stream.Serve(ctx, w, r) return nil, err diff --git a/utils/str/content_disposition.go b/utils/str/content_disposition.go new file mode 100644 index 000000000..34ae8a5c6 --- /dev/null +++ b/utils/str/content_disposition.go @@ -0,0 +1,80 @@ +package str + +import ( + "cmp" + "fmt" + "mime" + "path" + "strings" + "unicode" + "unicode/utf8" + + "github.com/deluan/sanitize" +) + +const ( + maxFilenameBytes = 255 + maxExtensionBytes = 16 + fallbackFilename = "download" +) + +// ContentDispositionAttachment builds an RFC 6266 attachment header value for a user-controlled filename. +// Non-ASCII names also get a filename* parameter, which clients prefer over the ASCII fallback. +func ContentDispositionAttachment(filename string) string { + stem, ext := splitFilename(filename) + header := fmt.Sprintf("attachment; filename=%q", joinFilename(toASCII(stem), toASCII(ext))) + name := joinFilename(stem, ext) + if isASCII(name) { + return header + } + // FormatMediaType emits a percent-encoded filename* for non-ASCII values + extended := mime.FormatMediaType("attachment", map[string]string{"filename": name}) + return header + strings.TrimPrefix(extended, "attachment") +} + +func splitFilename(filename string) (stem, ext string) { + name := strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { + return -1 + } + return r + }, SanitizeFilename(strings.ToValidUTF8(filename, "_"))) + ext = path.Ext(name) + if len(ext) > maxExtensionBytes { + ext = "" + } + stem = strings.TrimSuffix(name, ext) + if limit := maxFilenameBytes - len(ext); len(stem) > limit { + for limit > 0 && !utf8.RuneStart(stem[limit]) { + limit-- + } + stem = stem[:limit] + } + return stem, ext +} + +func joinFilename(stem, ext string) string { + stem = cmp.Or(strings.TrimSpace(stem), fallbackFilename) + return cmp.Or(strings.TrimRight(stem+ext, " ."), fallbackFilename) +} + +func toASCII(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r == ',': + return '_' + case r >= ' ' && r <= '~': + return r + } + return -1 + }, sanitize.Accents(SanitizeFilename(Clear(s)))) +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + return false + } + } + return true +} diff --git a/utils/str/content_disposition_test.go b/utils/str/content_disposition_test.go new file mode 100644 index 000000000..944392a4e --- /dev/null +++ b/utils/str/content_disposition_test.go @@ -0,0 +1,120 @@ +package str_test + +import ( + "mime" + "regexp" + "strings" + + "github.com/navidrome/navidrome/utils/str" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var asciiFilenameRe = regexp.MustCompile(`(?:^|; )filename="([^"]*)"`) + +// asciiName returns the quoted filename parameter, the one used by clients that ignore filename*. +func asciiName(filename string) string { + m := asciiFilenameRe.FindAllStringSubmatch(str.ContentDispositionAttachment(filename), -1) + ExpectWithOffset(1, m).To(HaveLen(1), "expected exactly one quoted filename parameter") + return m[0][1] +} + +// decodedName returns the name an RFC 6266 client picks, preferring filename* when present. +func decodedName(filename string) string { + disp, params, err := mime.ParseMediaType(str.ContentDispositionAttachment(filename)) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + ExpectWithOffset(1, disp).To(Equal("attachment")) + return params["filename"] +} + +var _ = Describe("ContentDispositionAttachment", func() { + Describe("parameter injection", func() { + const attack = `party"; filename="evil.html.m3u` + + It("does not let a quote in the name open a second parameter", func() { + Expect(str.ContentDispositionAttachment(attack)).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`)) + }) + + It("keeps the header parseable", func() { + Expect(decodedName(attack)).To(Equal("party_; filename=_evil.html.m3u")) + }) + + It("does not let a quote in a non-ASCII name inject either", func() { + const utf8Attack = `東京"; filename="evil.html.m3u` + header := str.ContentDispositionAttachment(utf8Attack) + Expect(strings.Count(header, `"`)).To(Equal(2)) + Expect(decodedName(utf8Attack)).To(Equal("東京_; filename=_evil.html.m3u")) + }) + }) + + Describe("ASCII names", func() { + It("sends only the quoted filename, unchanged", func() { + Expect(str.ContentDispositionAttachment("My Playlist.m3u")).To(Equal(`attachment; filename="My Playlist.m3u"`)) + }) + + It("keeps leading dots", func() { + Expect(asciiName("...And Justice for All.zip")).To(Equal("...And Justice for All.zip")) + }) + + It("trims surrounding spaces and trailing dots", func() { + Expect(asciiName(" Greatest Hits .zip")).To(Equal("Greatest Hits.zip")) + Expect(asciiName("Loose End. ")).To(Equal("Loose End")) + }) + + It("falls back to a placeholder when only dots are left", func() { + Expect(str.ContentDispositionAttachment("...")).To(Equal(`attachment; filename="download"`)) + }) + + It("caps a long name whose last dot is not an extension", func() { + name := asciiName("a." + strings.Repeat("x", 300)) + Expect(len(name)).To(BeNumerically("<=", 255)) + Expect(name).To(HavePrefix("a.xxx")) + }) + + It("replaces path separators and reserved characters", func() { + Expect(asciiName("AC/DC: Live, 1979?.zip")).To(Equal("AC_DC_ Live_ 1979_.zip")) + }) + + It("drops control characters", func() { + Expect(asciiName("line\r\nbreak\x00\t.mp3")).To(Equal("linebreak.mp3")) + }) + }) + + Describe("non-ASCII names", func() { + It("transliterates accents in the ASCII fallback", func() { + Expect(asciiName("Legião Urbana.zip")).To(Equal("Legiao Urbana.zip")) + }) + + It("converts typographic punctuation in the ASCII fallback", func() { + Expect(asciiName("She’s a Woman — Live.mp3")).To(Equal("She's a Woman - Live.mp3")) + Expect(asciiName("“Heroes”.mp3")).To(Equal("_Heroes_.mp3")) + Expect(decodedName("She’s a Woman.mp3")).To(Equal("She’s a Woman.mp3")) + }) + + It("keeps the extension when no ASCII letters survive", func() { + Expect(asciiName("東京.mp3")).To(Equal("download.mp3")) + Expect(asciiName("Кино.m3u")).To(Equal("download.m3u")) + Expect(asciiName("東京")).To(Equal("download")) + }) + + DescribeTable("filename* carries the sanitized UTF-8 name", + func(filename, expected string) { + Expect(decodedName(filename)).To(Equal(expected)) + }, + Entry("accented", "Legião Urbana.zip", "Legião Urbana.zip"), + Entry("CJK", "東京.zip", "東京.zip"), + Entry("emoji", "🎵 mix.zip", "🎵 mix.zip"), + Entry("comma and percent", "Sigur Rós, 100%.zip", "Sigur Rós, 100%.zip"), + Entry("path separators", "Sigur Rós/Live: Heima.zip", "Sigur Rós_Live_ Heima.zip"), + Entry("control characters", "Sigur Rós\r\n\x00.zip", "Sigur Rós.zip"), + Entry("bidi override", "Björk\u202Eexe.mp3", "Björkexe.mp3"), + Entry("invalid UTF-8", "Bj\xf6rk Café.mp3", "Bj_rk Café.mp3"), + ) + + It("caps the name length and keeps the extension", func() { + name := decodedName(strings.Repeat("東", 300) + ".zip") + Expect(len(name)).To(BeNumerically("<=", 255)) + Expect(name).To(HaveSuffix("東.zip")) + }) + }) +}) From 39028f65c80734fcf8a00b2b40ce210cd3e5ea91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 23 Sep 2026 09:54:35 -0400 Subject: [PATCH 569/596] fix(jellyfin): honor IsPublic when creating a playlist (#6204) POST /Playlists dropped the client's IsPublic flag, so every playlist was created private. JellyBox Player's create-playlist form defaults its "public" checkbox to true, so JellyBox users could never create a public playlist. Upstream's PlaylistsController passes IsPublic into PlaylistCreationRequest. core/playlists.Create has no visibility parameter and widening it would ripple into the Subsonic and native APIs, so createPlaylist follows the same pattern updatePlaylist already uses: after Create succeeds, a non-nil IsPublic is applied with a follow-up Update. The field is a pointer so an absent one keeps today's default instead of forcing private. If that second write fails the handler surfaces the error through playlistError rather than returning the id: answering 200 for a playlist that is not as visible as the client asked is the same silent drop this fixes. --- server/jellyfin/e2e/e2e_suite_test.go | 8 +++++- server/jellyfin/e2e/playlists_test.go | 22 +++++++++++++---- server/jellyfin/playlists.go | 8 ++++++ server/jellyfin/playlists_test.go | 35 +++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index aa93f7e38..5aa38cba1 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -222,8 +222,14 @@ func createPlaylistAs(user model.User, name string, encodedIds ...string) string } body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) Expect(err).ToNot(HaveOccurred()) + return createPlaylistBodyAs(user, string(body)) +} + +// createPlaylistBodyAs posts a raw create body, for tests that need fields the helpers above don't +// build, and returns the new playlist's decoded id. +func createPlaylistBodyAs(user model.User, body string) string { var res map[string]string - parseInto(postAs(user, "/Playlists", string(body)), &res) + parseInto(postAs(user, "/Playlists", body), &res) Expect(res["Id"]).ToNot(BeEmpty()) id, ok := dto.DecodeID(res["Id"]) Expect(ok).To(BeTrue()) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 174ba8660..3dd53227f 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -21,12 +21,19 @@ var _ = Describe("Playlists", func() { } order := func(plID string) []string { return names(playlistItems(plID).Items) } + createWith := func(body string) string { return createPlaylistBodyAs(adminUser, body) } + openAccess := func(plID string) bool { + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + return info.OpenAccess + } + Describe("create", func() { It("creates an empty playlist", func() { plID := createPlaylist("Empty", nil) var info dto.PlaylistInfo parseInto(get("/Playlists/"+enc(plID)), &info) - Expect(info.OpenAccess).To(BeFalse()) + Expect(info.OpenAccess).To(BeFalse(), "a playlist created without IsPublic stays private") Expect(info.Shares).To(BeEmpty()) Expect(info.ItemIds).To(BeEmpty()) }) @@ -48,6 +55,14 @@ var _ = Describe("Playlists", func() { Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) }) + It("creates a public playlist when the client sends IsPublic true", func() { + Expect(openAccess(createWith(`{"Name":"Public","Ids":[],"IsPublic":true}`))).To(BeTrue()) + }) + + It("creates a private playlist when the client sends IsPublic false", func() { + Expect(openAccess(createWith(`{"Name":"Private","Ids":[],"IsPublic":false}`))).To(BeFalse()) + }) + // dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get // dropped while the well-formed entries are still used to create a playlist. It("404s when one of the Ids is malformed, without creating a playlist", func() { @@ -355,10 +370,7 @@ var _ = Describe("Playlists", func() { It("makes a playlist public", func() { plID := createPlaylist("Make Public", nil) Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - - var info dto.PlaylistInfo - parseInto(get("/Playlists/"+enc(plID)), &info) - Expect(info.OpenAccess).To(BeTrue()) + Expect(openAccess(plID)).To(BeTrue()) // Now visible to other users. Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) }) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 1052a398f..5fd2df8c9 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -48,6 +48,7 @@ type createPlaylistRequest struct { Name string `json:"Name"` Ids []string `json:"Ids"` MediaType string `json:"MediaType"` + IsPublic *bool `json:"IsPublic"` } // createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to @@ -69,6 +70,13 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { api.internalError(w, r, err) return } + // Create takes no visibility, so a requested one costs a second write. + if body.IsPublic != nil { + if err := api.playlists.Update(r.Context(), id, nil, nil, body.IsPublic, nil, nil); err != nil { + api.playlistError(w, r, err) + return + } + } api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)}) } diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index ae0a5da3c..edaa63dd1 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -55,6 +55,16 @@ type fakePlaylists struct { deletePlaylistID string deleteErr error + + updatePlaylistID string + updatePublic *bool + updateErr error +} + +func (f *fakePlaylists) Update(_ context.Context, playlistID string, _ *string, _ *string, public *bool, _ []string, _ []int) error { + f.updatePlaylistID = playlistID + f.updatePublic = public + return f.updateErr } func (f *fakePlaylists) Delete(_ context.Context, id string) error { @@ -184,6 +194,31 @@ var _ = Describe("Playlists", func() { invoke(api.createPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + + createReq := func(body string) *http.Request { + return httptest.NewRequest("POST", "/Playlists", strings.NewReader(body)). + WithContext(GinkgoT().Context()) + } + + DescribeTable("visibility", + func(body string, wantPublic *bool, wantUpdatedID string) { + w := httptest.NewRecorder() + invoke(api.createPlaylist, w, createReq(body)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fp.updatePublic).To(Equal(wantPublic)) + Expect(fp.updatePlaylistID).To(Equal(wantUpdatedID)) + }, + Entry("applies IsPublic true", `{"Name":"Mix","IsPublic":true}`, new(true), testID("pl-new")), + Entry("applies an explicit IsPublic false", `{"Name":"Mix","IsPublic":false}`, new(false), testID("pl-new")), + Entry("leaves visibility alone when IsPublic is omitted", `{"Name":"Mix"}`, nil, ""), + ) + + It("returns 500 when the visibility update fails", func() { + fp.updateErr = errors.New("boom") + w := httptest.NewRecorder() + invoke(api.createPlaylist, w, createReq(`{"Name":"Mix","IsPublic":true}`)) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) }) Describe("getPlaylistItems", func() { From 27483a46dcda604b5e494b3b2010b617878e0593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20S=C3=A1nchez=20Zapico?= Date: Wed, 23 Sep 2026 18:10:25 +0200 Subject: [PATCH 570/596] fix(server): fail startup on initial setup errors and fix JSON/M3U response headers (#5897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): stop swallowing errors and correct two response bugs Four independent bugs found while reviewing the HTTP layer: initial_setup.go: createInitialAdminUser assigned the users.Put error to a shadowed err, so the outer err (always nil by then, since a CountAll failure panics) was returned instead. A failure to create the admin user was reported as success, and initialSetup went on to commit the "setup complete" property in the same transaction — so no admin user existed and initial setup was skipped on every later boot. auth.go: createAdminUser logged the Put error but returned nil, so createAdmin fell through to doLogin and answered 401 "Invalid username or password" instead of surfacing the real failure. It also logged the whole model.User, which puts the new admin's password in the log in clear text; every other call site logs user.UserName. native_api.go: writeDeleteManyResponse did not return after http.Error when marshaling failed, then wrote a nil body over the 500. It also built the single-id body by hand with html.EscapeString, which does not escape backslashes, so an id ending in one produced `{"id":"a\"}` — invalid JSON. Both shapes now go through json.Marshal. A failed Write is now logged rather than answered with http.Error, which could not work once the body had started. handle_shares.go: handleM3U set Content-Type after WriteHeader, so it was never sent and shared playlists were served with a sniffed type. Signed-off-by: zapisanchez * fix(server): address review feedback - writeDeleteManyResponse uses rest.RespondWithJSON, so the response now has Content-Type: application/json. This also removes a marshal error branch that could never run. - createInitialAdminUser returns the CountAll error instead of panicking, and wraps its errors. initialSetup now stops the server with log.Fatal when setup fails. Before, the error was dropped and the server started with a half-done setup. - Trim comments that described PR history. --------- Signed-off-by: zapisanchez Co-authored-by: Deluan --- server/auth.go | 2 +- server/auth_test.go | 12 +++++ server/initial_setup.go | 14 ++--- server/initial_setup_test.go | 23 ++++++++ server/nativeapi/delete_many_response_test.go | 49 +++++++++++++++++ server/nativeapi/native_api.go | 22 +++----- server/public/handle_shares.go | 2 +- server/public/handle_shares_test.go | 52 +++++++++++++++++++ 8 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 server/nativeapi/delete_many_response_test.go create mode 100644 server/public/handle_shares_test.go diff --git a/server/auth.go b/server/auth.go index d9ade7b29..2aaa93e63 100644 --- a/server/auth.go +++ b/server/auth.go @@ -159,7 +159,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password } err := ds.User(ctx).Put(&initialUser) if err != nil { - log.Error(ctx, "Could not create initial user", "user", initialUser, err) + log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err) return fmt.Errorf("creating initial user: %w", err) } return nil diff --git a/server/auth_test.go b/server/auth_test.go index a4d592c51..abe144a12 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -76,6 +76,18 @@ var _ = Describe("Auth", func() { }) }) + Describe("createAdmin when the user cannot be stored", func() { + It("responds 500 rather than falling through to login", func() { + failing := dsWithFailingPut(errors.New("db is down")) + req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`)) + resp = httptest.NewRecorder() + + createAdmin(failing)(resp, req) + + Expect(resp.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + Describe("Login from HTTP headers", func() { const ( trustedIpv4 = "192.168.0.42" diff --git a/server/initial_setup.go b/server/initial_setup.go index 7e974dc21..e75220abe 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -16,7 +16,7 @@ import ( func initialSetup(ds model.DataStore) { ctx := context.TODO() - _ = ds.WithTx(func(tx model.DataStore) error { + err := ds.WithTx(func(tx model.DataStore) error { if err := tx.Library(ctx).StoreMusicFolder(); err != nil { return err } @@ -36,6 +36,9 @@ func initialSetup(ds model.DataStore) { err = properties.Put(consts.InitialSetupFlagKey, time.Now().String()) return err }, "initial setup") + if err != nil { + log.Fatal("Error running initial setup", err) + } } // If the Dev Admin user is not present, create it @@ -43,7 +46,7 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error { users := ds.User(context.TODO()) c, err := users.CountAll(model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) if err != nil { - panic(fmt.Sprintf("Could not access User table: %s", err)) + return fmt.Errorf("could not access User table: %w", err) } if c == 0 { newID := id.NewRandom() @@ -57,12 +60,11 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error { NewPassword: initialPassword, IsAdmin: true, } - err := users.Put(&initialUser) - if err != nil { - log.Error("Could not create initial admin user", "user", initialUser, err) + if err := users.Put(&initialUser); err != nil { + return fmt.Errorf("could not create initial admin user: %w", err) } } - return err + return nil } func checkFFmpegInstallation() { diff --git a/server/initial_setup_test.go b/server/initial_setup_test.go index 982046f78..0ce8a39fa 100644 --- a/server/initial_setup_test.go +++ b/server/initial_setup_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -9,6 +10,17 @@ import ( . "github.com/onsi/gomega" ) +type failingPutUserRepo struct { + model.UserRepository + err error +} + +func (r *failingPutUserRepo) Put(*model.User) error { return r.err } + +func dsWithFailingPut(err error) model.DataStore { + return &tests.MockDataStore{MockedUser: &failingPutUserRepo{UserRepository: tests.CreateMockUserRepo(), err: err}} +} + var _ = Describe("initial_setup", func() { var ds model.DataStore @@ -32,5 +44,16 @@ var _ = Describe("initial_setup", func() { Expect(createInitialAdminUser(ds, "second")).To(BeNil()) Expect(ur.CountAll()).To(Equal(int64(1))) }) + + It("returns the error when the user cannot be stored", func() { + boom := errors.New("db is down") + Expect(createInitialAdminUser(dsWithFailingPut(boom), "pass123")).To(MatchError(boom)) + }) + + It("returns the error when the user table cannot be read", func() { + boom := errors.New("db is down") + ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: boom}} + Expect(createInitialAdminUser(ds, "pass123")).To(MatchError(boom)) + }) }) }) diff --git a/server/nativeapi/delete_many_response_test.go b/server/nativeapi/delete_many_response_test.go new file mode 100644 index 000000000..7d911d6cd --- /dev/null +++ b/server/nativeapi/delete_many_response_test.go @@ -0,0 +1,49 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writeDeleteManyResponse", func() { + var w *httptest.ResponseRecorder + + write := func(ids ...string) map[string]any { + w = httptest.NewRecorder() + writeDeleteManyResponse(w, httptest.NewRequest("DELETE", "/missing", nil), ids) + + var body map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed(), "response body must be valid JSON: %s", w.Body.String()) + return body + } + + It("returns a single id as an object", func() { + Expect(write("abc123")).To(HaveKeyWithValue("id", "abc123")) + }) + + It("returns multiple ids as a list", func() { + Expect(write("a", "b")).To(HaveKeyWithValue("ids", ConsistOf("a", "b"))) + }) + + It("stays valid JSON when the id contains a backslash", func() { + Expect(write(`a\`)).To(HaveKeyWithValue("id", `a\`)) + }) + + It("stays valid JSON when the id contains a quote", func() { + Expect(write(`a"b`)).To(HaveKeyWithValue("id", `a"b`)) + }) + + It("does not HTML-escape the id into entities", func() { + Expect(write("a&b")).To(HaveKeyWithValue("id", "a&b")) + }) + + It("responds 200 with a JSON content type", func() { + write("abc123") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f931834c6..a7c53df09 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -2,8 +2,6 @@ package nativeapi import ( "context" - "encoding/json" - "html" "net/http" "strconv" "time" @@ -206,22 +204,18 @@ func (api *Router) addMissingFilesRoute(r chi.Router) { } func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) { - var resp []byte - var err error + var payload any if len(ids) == 1 { - resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`) + payload = struct { + ID string `json:"id"` + }{ID: ids[0]} } else { - resp, err = json.Marshal(&struct { + payload = struct { Ids []string `json:"ids"` - }{Ids: ids}) - if err != nil { - log.Error(r.Context(), "Error marshaling response", "ids", ids, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - } + }{Ids: ids} } - _, err = w.Write(resp) //nolint:gosec - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if err := rest.RespondWithJSON(w, http.StatusOK, payload); err != nil { + log.Error(r.Context(), "Error writing response", "ids", ids, err) } } diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index d67cfe456..367bff501 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -58,8 +58,8 @@ 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.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec } diff --git a/server/public/handle_shares_test.go b/server/public/handle_shares_test.go new file mode 100644 index 000000000..1bf631fd4 --- /dev/null +++ b/server/public/handle_shares_test.go @@ -0,0 +1,52 @@ +package public + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("handleM3U", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var pub *Router + + BeforeEach(func() { + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + pub = &Router{ds: ds, share: core.NewShare(ds)} + }) + + makeRequest := func(id string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/"+id+"/m3u?%3Aid="+id, nil) + w := httptest.NewRecorder() + pub.handleM3U(w, r) + return w + } + + It("sets the M3U content type", func() { + share := &model.Share{ID: "abc123", Tracks: model.MediaFiles{{ID: "t1", Title: "Track 1"}}} + shareRepo.ID = share.ID + shareRepo.Entity = share + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusOK)) + // Result() has the headers sent at WriteHeader time, unlike w.Header() + Expect(w.Result().Header.Get("Content-Type")).To(Equal("audio/x-mpegurl")) + Expect(w.Body.String()).To(HavePrefix("#EXTM3U")) + }) + + It("returns 404 when the share does not exist", func() { + shareRepo.ID = "other" + shareRepo.Entity = &model.Share{ID: "other"} + + Expect(makeRequest("missing").Code).To(Equal(http.StatusNotFound)) + }) +}) From ee6dd1bc031154788988c7b62cac18f2a49c67b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 23 Sep 2026 17:04:50 -0400 Subject: [PATCH 571/596] fix(scanner): stop DB lock starvation during scans on slow storage (#6201) * fix(artwork): pause the artwork worker while a scan is running The artwork worker added in 0.64 writes to the database continuously, including while a scan runs. On slow storage the scanner holds the write lock for many seconds per folder, so the two writers keep timing each other out: artwork writes fail with "database is locked", and a single busy timeout on the scanner side aborts the whole scan. The worker now stops dispatching queue items while scanner.IsScanning reports true, including mid-batch, and resumes on the next poll after the scan ends. Artwork requests are unaffected, since they serve local art without the worker. * fix(db): run ANALYZE one index at a time so writers are not starved A full ANALYZE is a single write transaction, so every other write waits for it to finish and fails after the 15s busy timeout. On slow NAS storage it was measured taking over 26 minutes. The analysis now runs ANALYZE per index (per table for unindexed and WITHOUT ROWID tables), which produces the same sqlite_stat1 rows as a full ANALYZE, and pauses briefly between steps (up to 150ms, just above SQLite's longest busy-handler sleep) so waiting writers get the lock. * fix(scanner): ignore Synology @eaDir metadata folders Synology creates an @eaDir folder next to media files, holding one subfolder per file with generated thumbnails. The scanner and watcher treated them as regular folders, which on one reported library added tens of thousands of extra folders to every scan. * fix(db): analyze tables with only partial indexes as a whole A partial index does not record the table's row count, so a table whose only indexes are partial needs a table-level ANALYZE to get the sqlite_stat1 row a full ANALYZE would write. Navidrome's schema has no such table today, but the stepped analysis should match a full ANALYZE for any schema a future migration creates. * fix(scanner): retry busy folder saves and stop phase 1 on a fatal error On slow storage, a single SQLITE_BUSY while saving a folder aborted the whole scan, even when another writer held the lock only briefly. The folder save now runs as a retryable unit: on a busy error it waits (5s, 10s, 15s) and reruns the transaction, up to three times, before failing. Side effects that do not survive a rollback (the album ID map consumed by persistAlbum, the artwork queue items, the image-change record) are rebuilt per attempt or recorded only after a successful commit. When a folder save does fail, phase 1 used to keep walking the library and reading tags for every remaining folder, discarding the results, before reporting the error; a reporter saw 40 silent minutes. The walk now stops as soon as the save fails, and the walker honors cancellation instead of blocking on its channel. Because an early stop leaves folders unvisited, phase 1 no longer marks unvisited folders missing when the phase failed; the resumed scan handles them. * refactor(persistence): move busy retry into DataStore.WithTxRetry The scanner retried its folder save itself, which meant it had to know SQLite error codes. WithTxRetry now owns that policy: it reruns the block in a fresh transaction on SQLITE_BUSY, up to three times with growing delays, and runs it only once when already inside a transaction, since the outer transaction would still hold the lock. The block receives the context to use, and attempts that will be retried carry a marker so a busy statement in them is logged as a warning; only the final attempt logs errors. The scanner's inner error logs are folded into wrapped errors, so a recovered retry no longer prints error-level lines, and the folder path travels in the log context. * fix(persistence): join the enclosing transaction in a nested WithTxRetry Called on a store that is already inside a transaction, WithTxRetry went through WithTx, which opens a second, independent transaction on another connection. That transaction waits on the lock the outer one holds and fails with SQLITE_BUSY, and if it does succeed the outer transaction cannot roll it back. It now runs the block on the enclosing transaction, which owns the lock, the commit and the rollback. Found by a Codex (gpt-6-sol) review. * fix(scanner): retry the remaining scan writes on a busy database Every write step after phase 1 still aborted the whole scan on a single SQLITE_BUSY: phase 1 finalize, phase 2 moves and purge, phase 3 album saves and play count refreshes, the deferred playlist import flag, library ScanBegin, GC, the missing-artwork enqueue, tag counts, and the final library update. They now go through WithTxRetry. The phase 2 move had to be made rerun-safe first: it changed the target track's ID inside the transaction, so a rerun would have deleted the moved track itself, and it marked album annotations as handled even when the transaction rolled back. It now works on a copy per attempt and records the annotation reassignment only after a commit. Artist.RefreshStats is left alone: it updates artists in batches outside a transaction, and one transaction around all of them would hold the write lock for the whole refresh on slow storage. Phase 4 playlist imports go through the playlist service and are left for a follow-up. * fix(scanner): claim the album before moving its annotations The rerun-safe moveMatched checked processedAlbumAnnotations before its transaction and marked the album only after the commit. Phase 2 runs same-library and cross-library moves in separate pipeline stages, so two moves into one album could both pass the check; the second would reassign annotations again and overwrite the album's created_at. The album is now claimed under the lock before the transaction, as the old code effectively did, and the claim is released if the move fails so a later move can still reassign. Found by a Codex (gpt-6-sol) review. * fix(artwork): keep artwork housekeeping from writing during scans The artwork worker already pauses while a scan runs, but its housekeeping jobs did not: the hourly missing-artwork recheck (a bulk INSERT ... SELECT over albums and artists), the startup run of the same recheck, and the daily prune all kept competing with the scanner for the write lock. They now run through LockForMaintenance, like the scheduled DB analysis: they skip while a scan is running and keep a scan from starting until they finish. Skipping the recheck loses nothing, since each scan with changes queues missing artwork at its end. * refactor(scanner): log retried step errors once, from the caller Blocks passed to WithTxRetry still logged their own errors at error level on every attempt, so a busy error that a retry absorbed printed several error lines (GC printed three). They now return wrapped errors and the callers, which already log them, report the final outcome once. Also: drop a leftover variable in phase 1 finalize, check the walk context once, stop repeating the folder field that is already in the log context, stop shadowing finalize's err in phase 3, and format the WithTxRetry scope the same way as WithTx. * test(scanner): make the scanner suite's temp DB cleanup best effort Which DB file the process-wide DB handle opens depends on which spec touches it first. When the Scanner container wins the random order, its temp DB stays open until db.Close after RunSpecs, and on Windows removing the temp dir fails with 'being used by another process'. Ginkgo pins that on the container's last spec, which is now one of the busy-database specs. The sibling suites skip Windows for the same reason; this one now removes its temp dir on a best-effort basis instead, so it keeps running there. --- cmd/root.go | 28 ++- core/artwork/worker.go | 13 ++ core/artwork/worker_test.go | 52 +++++ db/db.go | 6 + db/db_test.go | 16 ++ db/optimize.go | 56 ++++- db/optimize_test.go | 37 ++++ model/datastore.go | 3 + persistence/persistence.go | 60 +++++- persistence/persistence_test.go | 73 +++++++ persistence/sql_base_repository.go | 4 + scanner/phase_1_folders.go | 280 +++++++++++++------------ scanner/phase_2_missing_tracks.go | 97 +++++---- scanner/phase_2_missing_tracks_test.go | 114 ++++++++++ scanner/phase_3_refresh_albums.go | 17 +- scanner/phase_4_playlists.go | 5 +- scanner/scanner.go | 41 ++-- scanner/scanner_test.go | 62 +++++- scanner/walk_dir_tree.go | 13 +- scanner/walk_dir_tree_test.go | 1 + tests/mock_data_store.go | 4 + 21 files changed, 754 insertions(+), 228 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index f632b0425..02cd30240 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -375,10 +375,26 @@ func startPlaybackServer(ctx context.Context) func() error { func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error { return func() error { log.Info(ctx, "Starting artwork worker") + // The scanner writes to the DB for its whole run; competing for the write lock makes both fail. + worker.PauseWhile(scanner.IsScanning) return worker.Run(ctx) } } +// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting +// until it ends; both write to the DB, and competing for the lock can make either fail. +func outsideScan(ctx context.Context, job string, run func(context.Context) error) { + release, ok := scanner.LockForMaintenance() + if !ok { + log.Debug(ctx, "Skipping "+job+" because a scan is in progress") + return + } + defer release() + if err := run(ctx); err != nil { + log.Error(ctx, "Error running "+job, err) + } +} + // scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and // reports an artwork config change without acting on it. func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { @@ -386,26 +402,20 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu schedulerInstance := scheduler.GetInstance() if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { - if err := worker.EnqueueMissingAll(ctx); err != nil { - log.Error(ctx, "Error enqueueing missing artwork rechecks", err) - } + outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll) }); err != nil { log.Error(ctx, "Error scheduling artwork missing-state recheck", err) } if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { - if err := worker.RunPrune(ctx); err != nil { - log.Error(ctx, "Error running artwork prune", err) - } + outsideScan(ctx, "artwork prune", worker.RunPrune) }); err != nil { log.Error(ctx, "Error scheduling artwork prune", err) } // Also run the missing-row recheck once at startup so a never-scanned entity is picked up // immediately, not only on the next hourly tick (e.g. after enabling the feature). - if err := worker.EnqueueMissingAll(ctx); err != nil { - log.Error(ctx, "Error enqueueing missing artwork rechecks", err) - } + outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll) if err := worker.ReconcileConfig(ctx); err != nil { log.Error(ctx, "Error checking the artwork config fingerprint", err) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index bb09be55e..e8fb3a11c 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -46,6 +46,7 @@ type Worker struct { pruneMu sync.RWMutex pools []*drainPool runCtx context.Context + paused func() bool gatesMu sync.Mutex gates map[string]*extGate @@ -59,6 +60,7 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg broker: broker, pools: newDrainPools(), runCtx: context.Background(), + paused: func() bool { return false }, gates: map[string]*extGate{}, } w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate) @@ -90,6 +92,11 @@ var ( } ) +// PauseWhile holds off queue draining whenever paused reports true. Call it before Run. +func (w *Worker) PauseWhile(paused func() bool) { + w.paused = paused +} + // Run blocks draining the queue until ctx is cancelled. func (w *Worker) Run(ctx context.Context) error { w.runCtx = ctx @@ -143,6 +150,9 @@ func (w *Worker) EnqueueMissingAll(ctx context.Context) error { } func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) { + if w.paused() { + return 0, nil + } // Dequeue well past the pool size so a slow external lookup never idles the other slots. // DequeueBatch does not mark rows taken, so this is one query per pass, not per slot. items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...) @@ -170,6 +180,9 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i wg.Wait() return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error } + if w.paused() { + break + } wg.Go(func() { defer func() { <-sem }() out, got := w.process(ctx, item) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index ebb8de251..d74e3a5ed 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -866,6 +866,34 @@ var _ = Describe("Worker", func() { } }) + It("stops dispatching and leaves the rest queued when paused mid-batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + albums := model.Albums{} + for i := range 8 { + id := fmt.Sprintf("alp%d", i) + albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + } + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) + // Pauses as soon as the first item has left the queue. + w.PauseWhile(func() bool { + n, _ := queueRepo.Count() + return n < 8 + }) + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue") + }) + It("dequeues past the worker pool so one drain covers many items", func() { for i := range 16 { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}}) @@ -896,6 +924,30 @@ var _ = Describe("Worker", func() { Eventually(done, time.Second).Should(Receive(BeNil())) }) + It("does not drain the queue while paused", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + w.PauseWhile(func() bool { return true }) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- w.Run(runCtx) }() + DeferCleanup(func() { + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) + }) + + Consistently(func() any { return findQueued(queueRepo, "al", "al1") }, 300*time.Millisecond).ShouldNot(BeNil()) + }) + It("does not leak goroutines after Run exits", func() { DeferCleanup(configtest.SetupConfig()) diff --git a/db/db.go b/db/db.go index 685886edb..a9c6c4a15 100644 --- a/db/db.go +++ b/db/db.go @@ -133,6 +133,12 @@ func ErrorCodes(err error) (code, extended int, ok bool) { return int(se.Code), int(se.ExtendedCode), true } +// IsBusy reports whether err is SQLITE_BUSY, including BUSY_SNAPSHOT, which only a new transaction clears. +func IsBusy(err error) bool { + code, _, ok := ErrorCodes(err) + return ok && code == int(sqlite3.ErrBusy) +} + type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/db_test.go b/db/db_test.go index 2ce01dc3d..e3a52e1fb 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -3,8 +3,12 @@ package db_test import ( "context" "database/sql" + "errors" + "fmt" "testing" + "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" @@ -19,6 +23,18 @@ func TestDB(t *testing.T) { RunSpecs(t, "DB Suite") } +var _ = DescribeTable("IsBusy", + func(err error, expected bool) { + Expect(db.IsBusy(err)).To(Equal(expected)) + }, + Entry("SQLITE_BUSY", sqlite3.Error{Code: sqlite3.ErrBusy}, true), + Entry("SQLITE_BUSY_SNAPSHOT", sqlite3.Error{Code: sqlite3.ErrBusy, ExtendedCode: sqlite3.ErrBusySnapshot}, true), + Entry("a wrapped SQLITE_BUSY", fmt.Errorf("persisting: %w", sqlite3.Error{Code: sqlite3.ErrBusy}), true), + Entry("another SQLite error", sqlite3.Error{Code: sqlite3.ErrConstraint}, false), + Entry("a non-SQLite error", errors.New("database is locked"), false), + Entry("nil", nil, false), +) + var _ = Describe("IsSchemaEmpty", func() { var database *sql.DB var ctx context.Context diff --git a/db/optimize.go b/db/optimize.go index f46906c4e..aff36e8fd 100644 --- a/db/optimize.go +++ b/db/optimize.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "strings" "sync" "time" @@ -134,16 +135,65 @@ func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error { return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err)) } log.Debug(ctx, "Refreshing query planner statistics") - _, err := db.ExecContext(ctx, "ANALYZE") - if err != nil { + if err := analyzeInSteps(ctx, db); err != nil { return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err)) } - if err = recordAnalyzeSuccess(ctx, db, now); err != nil { + if err := recordAnalyzeSuccess(ctx, db, now); err != nil { return recordAnalyzeError(ctx, db, now, err) } return nil } +// One ANALYZE per index (whole table if WITHOUT ROWID or lacking a non-partial index) yields the +// same sqlite_stat1 rows as a full ANALYZE, but frees the write lock between steps. +const analyzeTargetsSQL = ` +SELECT i.name FROM sqlite_schema i JOIN pragma_table_list t ON t.schema = 'main' AND t.name = i.tbl_name +WHERE i.type = 'index' AND t.wr = 0 AND EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0) +UNION ALL +SELECT t.name FROM pragma_table_list t +WHERE t.schema = 'main' AND t.type IN ('table', 'shadow') AND t.name NOT LIKE 'sqlite_%' + AND (t.wr = 1 OR NOT EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0))` + +// analyzeMaxYield is just above SQLite's longest busy-handler sleep, so every waiting writer +// retries during the pause. +const analyzeMaxYield = 150 * time.Millisecond + +func analyzeInSteps(ctx context.Context, db *sql.DB) error { + targets, err := analyzeTargets(ctx, db) + if err != nil { + return err + } + for _, target := range targets { + start := time.Now() + if _, err := db.ExecContext(ctx, `ANALYZE "`+strings.ReplaceAll(target, `"`, `""`)+`"`); err != nil { + return fmt.Errorf("analyzing %s: %w", target, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(min(time.Since(start), analyzeMaxYield)): + } + } + return nil +} + +func analyzeTargets(ctx context.Context, db *sql.DB) ([]string, error) { + rows, err := db.QueryContext(ctx, analyzeTargetsSQL) + if err != nil { + return nil, fmt.Errorf("listing ANALYZE targets: %w", err) + } + defer rows.Close() + var targets []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("listing ANALYZE targets: %w", err) + } + targets = append(targets, name) + } + return targets, rows.Err() +} + func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error { tx, err := db.BeginTx(ctx, nil) if err != nil { diff --git a/db/optimize_test.go b/db/optimize_test.go index da9b3b9c9..6ad9b9cb4 100644 --- a/db/optimize_test.go +++ b/db/optimize_test.go @@ -75,6 +75,43 @@ var _ = Describe("Optimize", func() { Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) }) + It("produces the same statistics as a single full ANALYZE", func() { + putProperty(consts.DBAnalyzePendingKey, "1") + for _, stmt := range []string{ + "create table unindexed(id integer primary key, v int)", + "insert into unindexed(v) select flag from analyze_probe", + "create table no_rowid(k text primary key, v int) without rowid", + "insert into no_rowid select 'k' || id, id % 7 from analyze_probe", + "create index no_rowid_v on no_rowid(v)", + "create table partial_only(id integer primary key, v int)", + "insert into partial_only(v) select id % 5 from analyze_probe", + "create index partial_only_v on partial_only(v) where v = 1", + "analyze", + } { + _, err := database.Exec(stmt) + Expect(err).ToNot(HaveOccurred()) + } + statRows := func() []string { + rows, err := database.Query("select tbl || '|' || coalesce(idx, '') || '|' || stat from sqlite_stat1 order by 1") + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + var res []string + for rows.Next() { + var s string + Expect(rows.Scan(&s)).To(Succeed()) + res = append(res, s) + } + return res + } + fullAnalyze := statRows() + _, err := database.Exec("delete from sqlite_stat1") + Expect(err).ToNot(HaveOccurred()) + + Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) + + Expect(statRows()).To(Equal(fullAnalyze)) + }) + It("runs when no previous analysis was recorded", func() { ran, err := db.OptimizeDBIfNeeded(ctx, database, now) Expect(err).ToNot(HaveOccurred()) diff --git a/model/datastore.go b/model/datastore.go index 273ca714b..26687d5d4 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -47,5 +47,8 @@ type DataStore interface { WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error + // WithTxRetry runs block in a transaction, rerunning it while SQLite reports the database busy. + // For background work only (it can take minutes), and block must be safe to rerun after a rollback. + WithTxRetry(ctx context.Context, block func(ctx context.Context, tx DataStore) error, scope ...string) error GC(ctx context.Context, libraryIDs ...int) error } diff --git a/persistence/persistence.go b/persistence/persistence.go index 9d3a33cfc..589812266 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -3,6 +3,7 @@ package persistence import ( "context" "database/sql" + "fmt" "reflect" "time" @@ -138,11 +139,15 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository return nil } -func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error { - var msg string +func scopeLabel(scope []string) string { if len(scope) > 0 { - msg = scope[0] + return scope[0] } + return "" +} + +func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error { + msg := scopeLabel(scope) start := time.Now() conn, inTx := s.db.(*dbx.DB) if !inTx { @@ -177,6 +182,51 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope . }, scope...) } +// txRetryDelay spaces out reruns of a busy transaction. Each attempt has already waited out the +// busy timeout, so WithTxRetry gives up only after a sustained lock. +var txRetryDelay = 5 * time.Second + +const txMaxRetries = 3 + +func (s *SQLStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, scope ...string) error { + // Inside a transaction, join it: the outer one holds the lock and owns commit and rollback + if _, ok := s.db.(*dbx.DB); !ok { + return block(ctx, s) + } + for attempt := 0; ; attempt++ { + attemptCtx := ctx + if attempt < txMaxRetries { + attemptCtx = withBusyRetry(ctx) + } + err := s.WithTx(func(tx model.DataStore) error { return block(attemptCtx, tx) }, scope...) + if attempt == txMaxRetries || !db.IsBusy(err) { + return err + } + log.Warn(ctx, "Database busy, retrying transaction", "scope", scopeLabel(scope), "attempt", attempt+1, err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt+1) * txRetryDelay): + } + } +} + +type busyRetryKey struct{} + +// withBusyRetry marks a transaction attempt that WithTxRetry will rerun, so a busy statement in it +// is logged as a warning rather than an error. +func withBusyRetry(ctx context.Context) context.Context { + return context.WithValue(ctx, busyRetryKey{}, true) +} + +func hasBusyRetry(ctx context.Context) bool { + if ctx == nil { + return false + } + retry, _ := ctx.Value(busyRetryKey{}).(bool) + return retry +} + func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace := func(ctx context.Context, msg string, f func() error) func() error { return func() error { @@ -207,9 +257,9 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), ) if err != nil { - log.Error(ctx, "Error tidying up database", err) + return fmt.Errorf("tidying up database: %w", err) } - return err + return nil } func (s *SQLStore) getDBXBuilder() dbx.Builder { diff --git a/persistence/persistence_test.go b/persistence/persistence_test.go index 13e56bde1..e13f6a231 100644 --- a/persistence/persistence_test.go +++ b/persistence/persistence_test.go @@ -2,7 +2,10 @@ package persistence import ( "context" + "errors" + "time" + "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -55,4 +58,74 @@ var _ = Describe("SQLStore", func() { }) }) }) + + Describe("WithTxRetry", func() { + busy := sqlite3.Error{Code: sqlite3.ErrBusy} + BeforeEach(func() { + DeferCleanup(func(d time.Duration) { txRetryDelay = d }, txRetryDelay) + txRetryDelay = 0 + }) + + It("reruns a busy transaction from a clean rollback", func() { + var attempts []bool + err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + attempts = append(attempts, hasBusyRetry(ctx)) + Expect(tx.Property(ctx).Put("retry-key", "attempt")).To(Succeed()) + if len(attempts) < 3 { + return busy + } + return nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(attempts).To(Equal([]bool{true, true, true})) + Expect(ds.Property(ctx).Get("retry-key")).To(Equal("attempt")) + }) + + It("gives up after the last retry, which is not marked as retried", func() { + var attempts []bool + err := ds.WithTxRetry(ctx, func(ctx context.Context, _ model.DataStore) error { + attempts = append(attempts, hasBusyRetry(ctx)) + return busy + }) + Expect(db.IsBusy(err)).To(BeTrue()) + Expect(attempts).To(Equal([]bool{true, true, true, false})) + }) + + It("does not rerun on other errors", func() { + calls := 0 + err := ds.WithTxRetry(ctx, func(context.Context, model.DataStore) error { + calls++ + return sqlite3.Error{Code: sqlite3.ErrConstraint} + }) + Expect(err).To(HaveOccurred()) + Expect(calls).To(Equal(1)) + }) + + It("does not rerun when called inside a transaction", func() { + calls := 0 + err := ds.WithTx(func(tx model.DataStore) error { + return tx.WithTxRetry(ctx, func(context.Context, model.DataStore) error { + calls++ + return busy + }) + }) + Expect(db.IsBusy(err)).To(BeTrue()) + Expect(calls).To(Equal(1)) + }) + + It("joins the enclosing transaction instead of opening another", func() { + rollback := errors.New("rollback") + err := ds.WithTx(func(tx model.DataStore) error { + Expect(tx.Property(ctx).Put("outer-key", "v")).To(Succeed()) + Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error { + Expect(inner.Property(ctx).Get("outer-key")).To(Equal("v")) + return inner.Property(ctx).Put("inner-key", "v") + })).To(Succeed()) + return rollback + }) + Expect(err).To(MatchError(rollback)) + _, err = ds.Property(ctx).Get("inner-key") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index bc841db03..026e42b03 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -643,5 +643,9 @@ func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffect if code, extended, ok := db.ErrorCodes(err); ok { fields = append(fields, "sqliteCode", code, "sqliteExtended", extended) } + if db.IsBusy(err) && hasBusyRetry(r.ctx) { + log.Warn(append(fields, err)...) + return + } log.Error(append(fields, err)...) } diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 6107b3316..feefde032 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -45,7 +45,9 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor jobs = append(jobs, job) } - return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state, imageChanges: &imageChangeCollector{ds: ds}} + walkCtx, stopWalk := context.WithCancelCause(ctx) + return &phaseFolders{jobs: jobs, ctx: ctx, walkCtx: walkCtx, stopWalk: stopWalk, ds: ds, state: state, + imageChanges: &imageChangeCollector{ds: ds}} } type scanJob struct { @@ -123,6 +125,8 @@ type phaseFolders struct { jobs []*scanJob ds model.DataStore ctx context.Context + walkCtx context.Context // cancelled when a folder fails to persist, so the walk stops early + stopWalk context.CancelCauseFunc state *scanState prevAlbumPIDConf string imageChanges *imageChangeCollector @@ -144,15 +148,15 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { var total int64 var totalChanged int64 for _, job := range p.jobs { - if utils.IsCtxDone(p.ctx) { + if utils.IsCtxDone(p.walkCtx) { break } - outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...) + outputChan, err := walkDirTree(p.walkCtx, job, job.targetFolders...) if err != nil { log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err) } - for folder := range pl.ReadOrDone(p.ctx, outputChan) { + for folder := range pl.ReadOrDone(p.walkCtx, outputChan) { job.numFolders.Add(1) p.state.sendProgress(&ProgressInfo{ LibID: job.lib.ID, @@ -208,6 +212,9 @@ func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] { func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { defer p.measure(entry)() + if err := context.Cause(p.walkCtx); err != nil { + return entry, err + } // Load children mediafiles from DB cursor, err := p.ds.MediaFile(p.ctx).GetCursor(model.QueryOptions{ @@ -331,128 +338,129 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) - // Collect artwork queue items for changed albums/artists, enqueued in the same transaction - var queueItems []model.ArtworkQueueItem - - err := p.ds.WithTx(func(tx model.DataStore) error { - // Instantiate all repositories just once per folder - folderRepo := tx.Folder(p.ctx) - tagRepo := tx.Tag(p.ctx) - artistRepo := tx.Artist(p.ctx) - libraryRepo := tx.Library(p.ctx) - albumRepo := tx.Album(p.ctx) - mfRepo := tx.MediaFile(p.ctx) - - // A new folder's albums/artists are enqueued below; only pre-existing folders need the diff. - if !entry.isNew() { - if changed, artistImage := entry.imagesChanged(); changed { - p.imageChanges.record(entry.job.lib, imageChangedFolder{ - id: entry.id, path: entry.path, artistImage: artistImage, - }) - } - } - - // Save folder to DB - folder := entry.toFolder() - err := folderRepo.Put(folder) - if err != nil { - log.Error(p.ctx, "Scanner: Error persisting folder to DB", "folder", entry.path, err) - return err - } - - // Save all tags to DB - err = tagRepo.Add(entry.job.lib.ID, entry.tags...) - if err != nil { - log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err) - return err - } - - // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later - for i := range entry.artists { - err = artistRepo.Put(&entry.artists[i], "name", - "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") - if err != nil { - log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) - return err - } - err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID) - if err != nil { - log.Error(p.ctx, "Scanner: Error adding artist to library", "lib", entry.job.lib.ID, "artist", entry.artists[i].Name, err) - return err - } - if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID)) - } - } - - // Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later - for i := range entry.albums { - err = p.persistAlbum(albumRepo, &entry.albums[i], entry.albumIDMap) - if err != nil { - log.Error(p.ctx, "Scanner: Error persisting album to DB", "folder", entry.path, "album", entry.albums[i], err) - return err - } - if entry.albums[i].Name != consts.UnknownAlbum { - queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID)) - } - } - - // Save all tracks to DB - for i := range entry.tracks { - err = mfRepo.Put(&entry.tracks[i]) - if err != nil { - log.Error(p.ctx, "Scanner: Error persisting mediafile to DB", "folder", entry.path, "track", entry.tracks[i], err) - return err - } - } - - // A re-imported track returns to unresolved so new embedded art is picked up lazily. - if len(entry.tracks) > 0 { - trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) - if err := tx.Artwork(p.ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil { - log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err) - } - } - - // Mark all missing tracks as not available - if len(entry.missingTracks) > 0 { - err = mfRepo.MarkMissing(true, entry.missingTracks...) - if err != nil { - log.Error(p.ctx, "Scanner: Error marking missing tracks", "folder", entry.path, err) - return err - } - - // Touch all albums that have missing tracks, so they get refreshed in later phases - groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) { - return mf.AlbumID, struct{}{} - }) - albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks)) - err = albumRepo.Touch(albumsToUpdate...) - if err != nil { - log.Error(p.ctx, "Scanner: Error touching album", "folder", entry.path, "albums", albumsToUpdate, err) - return err - } - } - - // Enqueue artwork resolution for changed albums/artists. Never fails the scan. - // A full scan re-imports every track, so a re-import is no evidence the art changed. - if len(queueItems) > 0 { - queue := tx.ArtworkQueue(p.ctx) - enqueue := queue.Enqueue - if p.state.fullScan { - enqueue = queue.EnqueueIfMissing - } - if err := enqueue(queueItems...); err != nil { - log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err) - } - } - return nil + ctx := log.NewContext(p.ctx, "folder", entry.path) + err := p.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + return p.persistFolder(ctx, tx, entry) }, "scanner: persist changes") if err != nil { - log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) + log.Error(ctx, "Scanner: Error persisting changes to DB", err) + p.stopWalk(err) + return entry, err } - return entry, err + // A new folder's albums/artists were enqueued with it; only pre-existing folders need the diff. + if !entry.isNew() { + if changed, artistImage := entry.imagesChanged(); changed { + p.imageChanges.record(entry.job.lib, imageChangedFolder{ + id: entry.id, path: entry.path, artistImage: artistImage, + }) + } + } + return entry, nil +} + +// persistFolder writes the folder in tx. WithTxRetry may rerun it after a rollback. +func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, entry *folderEntry) error { + // Collect artwork queue items for changed albums/artists, enqueued in the same transaction + var queueItems []model.ArtworkQueueItem + // persistAlbum consumes the map, so a rerun needs the original + albumIDMap := maps.Clone(entry.albumIDMap) + + // Instantiate all repositories just once per folder + folderRepo := tx.Folder(ctx) + tagRepo := tx.Tag(ctx) + artistRepo := tx.Artist(ctx) + libraryRepo := tx.Library(ctx) + albumRepo := tx.Album(ctx) + mfRepo := tx.MediaFile(ctx) + + // Save folder to DB + folder := entry.toFolder() + err := folderRepo.Put(folder) + if err != nil { + return fmt.Errorf("persisting folder: %w", err) + } + + // Save all tags to DB + err = tagRepo.Add(entry.job.lib.ID, entry.tags...) + if err != nil { + return fmt.Errorf("persisting tags: %w", err) + } + + // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later + for i := range entry.artists { + err = artistRepo.Put(&entry.artists[i], "name", + "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") + if err != nil { + return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err) + } + err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID) + if err != nil { + return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err) + } + if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { + queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID)) + } + } + + // Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later + for i := range entry.albums { + err = p.persistAlbum(albumRepo, &entry.albums[i], albumIDMap) + if err != nil { + return err + } + if entry.albums[i].Name != consts.UnknownAlbum { + queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID)) + } + } + + // Save all tracks to DB + for i := range entry.tracks { + err = mfRepo.Put(&entry.tracks[i]) + if err != nil { + return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err) + } + } + + // A re-imported track returns to unresolved so new embedded art is picked up lazily. + if len(entry.tracks) > 0 { + trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) + if err := tx.Artwork(ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil { + log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err) + } + } + + // Mark all missing tracks as not available + if len(entry.missingTracks) > 0 { + err = mfRepo.MarkMissing(true, entry.missingTracks...) + if err != nil { + return fmt.Errorf("marking missing tracks: %w", err) + } + + // Touch all albums that have missing tracks, so they get refreshed in later phases + groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) { + return mf.AlbumID, struct{}{} + }) + albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks)) + err = albumRepo.Touch(albumsToUpdate...) + if err != nil { + return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err) + } + } + + // Enqueue artwork resolution for changed albums/artists. Never fails the scan. + // A full scan re-imports every track, so a re-import is no evidence the art changed. + if len(queueItems) > 0 { + queue := tx.ArtworkQueue(ctx) + enqueue := queue.Enqueue + if p.state.fullScan { + enqueue = queue.EnqueueIfMissing + } + if err := enqueue(queueItems...); err != nil { + log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err) + } + } + return nil } // persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID @@ -499,34 +507,32 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { } func (p *phaseFolders) finalize(err error) error { - errF := p.ds.WithTx(func(tx model.DataStore) error { + p.stopWalk(nil) + defer p.imageChanges.enqueue(p.ctx) + // A failed phase may not have walked every folder, and unvisited ones must not be marked missing + if err != nil { + return err + } + return p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { for _, job := range p.jobs { // Mark all folders that were not updated as missing if len(job.lastUpdates) == 0 { continue } folderIDs := slices.Collect(maps.Keys(job.lastUpdates)) - err := tx.Folder(p.ctx).MarkMissing(true, folderIDs...) - if err != nil { - log.Error(p.ctx, "Scanner: Error marking missing folders", "lib", job.lib.Name, err) - return err + if err := tx.Folder(ctx).MarkMissing(true, folderIDs...); err != nil { + return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err) } - err = tx.MediaFile(p.ctx).MarkMissingByFolder(true, folderIDs...) - if err != nil { - log.Error(p.ctx, "Scanner: Error marking tracks in missing folders", "lib", job.lib.Name, err) - return err + if err := tx.MediaFile(ctx).MarkMissingByFolder(true, folderIDs...); err != nil { + return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err) } // Touch all albums that have missing folders, so they get refreshed in later phases - _, err = tx.Album(p.ctx).TouchByMissingFolder() - if err != nil { - log.Error(p.ctx, "Scanner: Error touching albums with missing folders", "lib", job.lib.Name, err) - return err + if _, err := tx.Album(ctx).TouchByMissingFolder(); err != nil { + return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err) } } return nil }, "scanner: finalize phaseFolders") - p.imageChanges.enqueue(p.ctx) - return errors.Join(err, errF) } var _ phase[*folderEntry] = (*phaseFolders)(nil) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 8c258b833..6ccc9a46c 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -273,66 +273,68 @@ func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (mod } func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error { - return p.ds.WithTx(func(tx model.DataStore) error { - discardedID := target.ID - oldAlbumID := missing.AlbumID - newAlbumID := target.AlbumID + oldAlbumID := missing.AlbumID + newAlbumID := target.AlbumID + // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target. + // Claimed before the transaction so a concurrent move skips it, and released if the move fails. + reassignAlbum := oldAlbumID != newAlbumID + if reassignAlbum { + p.annotationMutex.Lock() + reassignAlbum = !p.processedAlbumAnnotations[newAlbumID] + p.processedAlbumAnnotations[newAlbumID] = true + p.annotationMutex.Unlock() + if !reassignAlbum { + log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) + } + } + err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + // A rerun must start from the original target, not the one the rolled-back attempt changed + moved := target // Preserve the original created_at from the missing file, so moved tracks // don't appear in "Recently Added" - target.CreatedAt = missing.CreatedAt + moved.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 - err := tx.MediaFile(p.ctx).Put(&target) - if err != nil { + moved.ID = missing.ID + if err := tx.MediaFile(ctx).Put(&moved); err != nil { return fmt.Errorf("update matched track: %w", err) } // Discard the new mediafile row (the one that was moved to) - err = tx.MediaFile(p.ctx).Delete(discardedID) - if err != nil { + if err := tx.MediaFile(ctx).Delete(target.ID); err != nil { return fmt.Errorf("delete discarded track: %w", err) } - // Handle album annotation reassignment if AlbumID changed - if oldAlbumID != newAlbumID { - // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target - p.annotationMutex.RLock() - alreadyProcessed := p.processedAlbumAnnotations[newAlbumID] - p.annotationMutex.RUnlock() - - if !alreadyProcessed { - p.annotationMutex.Lock() - // Double-check pattern to avoid race conditions - if !p.processedAlbumAnnotations[newAlbumID] { - // Reassign direct album annotations (starred, rating) - log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) - if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { - 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 - } - p.annotationMutex.Unlock() - } else { - log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) + if reassignAlbum { + // Reassign direct album annotations (starred, rating) + log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) + if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + log.Warn(ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) } - } - p.state.changesDetected.Store(true) + // Keep created_at field from previous instance of the album, so moved albums + // don't appear in "Recently Added" + if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Warn(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 + } return nil - }) + }, "scanner: move matched track") + if err != nil { + if reassignAlbum { + p.annotationMutex.Lock() + delete(p.processedAlbumAnnotations, newAlbumID) + p.annotationMutex.Unlock() + } + return err + } + p.state.changesDetected.Store(true) + return nil } func (p *phaseMissingTracks) finalize(err error) error { @@ -355,7 +357,12 @@ func (p *phaseMissingTracks) finalize(err error) error { } func (p *phaseMissingTracks) purgeMissing() error { - deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing() + var deletedCount int64 + err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + var err error + deletedCount, err = tx.MediaFile(ctx).DeleteAllMissing() + return err + }, "scanner: purge missing") if err != nil { return fmt.Errorf("error deleting missing files: %w", err) } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index f93b166c1..b7aa52f90 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -2,6 +2,8 @@ package scanner import ( "context" + "errors" + "maps" "time" "github.com/navidrome/navidrome/conf" @@ -146,6 +148,87 @@ var _ = Describe("phaseMissingTracks", func() { Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) }) + Context("claiming the album annotation reassignment", func() { + var probe *probeTxDS + missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} + matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} + BeforeEach(func() { + probe = &probeTxDS{MockDataStore: ds.(*tests.MockDataStore)} + probe.MockedAlbum = tests.CreateMockAlbumRepo() + phase = createPhaseMissingTracks(ctx, state, probe) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + }) + + It("claims the target album before the transaction, so a concurrent move skips it", func() { + probe.during = func() { + phase.annotationMutex.RLock() + defer phase.annotationMutex.RUnlock() + Expect(phase.processedAlbumAnnotations).To(HaveKeyWithValue("new-album", true)) + } + Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed()) + }) + + It("releases the claim when the move fails, so a later move can reassign", func() { + probe.err = errors.New("boom") + Expect(phase.moveMatched(matchedTrack, missingTrack)).To(MatchError("boom")) + Expect(phase.processedAlbumAnnotations).ToNot(HaveKey("new-album")) + }) + }) + + Context("when the move transaction is rerun after a busy rollback", func() { + var rerunDS *rerunTxDS + BeforeEach(func() { + rerunDS = &rerunTxDS{MockDataStore: ds.(*tests.MockDataStore)} + rerunDS.snapshot = func() func() { + saved := maps.Clone(mr.Data) + return func() { mr.Data = saved } + } + phase = createPhaseMissingTracks(ctx, state, rerunDS) + }) + + It("keeps the moved track", func() { + missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} + matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + _, err := phase.processMissingTracks(&missingTracks{ + missing: []model.MediaFile{missingTrack}, + matched: []model.MediaFile{matchedTrack}, + }) + Expect(err).ToNot(HaveOccurred()) + + movedTrack, err := ds.MediaFile(ctx).Get("1") + Expect(err).ToNot(HaveOccurred()) + Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) + }) + + It("reassigns the album annotations in the attempt that commits", func() { + albumRepo := tests.CreateMockAlbumRepo() + rerunDS.MockedAlbum = albumRepo + restoreTracks := rerunDS.snapshot + rerunDS.snapshot = func() func() { + restore := restoreTracks() + return func() { + restore() + albumRepo.ReassignAnnotationCalls = nil + } + } + missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} + matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + _, err := phase.processMissingTracks(&missingTracks{ + missing: []model.MediaFile{missingTrack}, + matched: []model.MediaFile{matchedTrack}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("old-album", "new-album")) + }) + }) + It("should move the matched track when the missing track has the same tags and filename", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} @@ -957,3 +1040,34 @@ var _ = Describe("phaseMissingTracks", func() { }) }) }) + +// rerunTxDS runs every WithTxRetry block twice, as a retry after a rolled-back busy attempt would. +// The mock is not transactional, so snapshot returns the function that plays the rollback. +type rerunTxDS struct { + *tests.MockDataStore + snapshot func() (rollback func()) +} + +func (d *rerunTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error { + rollback := d.snapshot() + _ = block(ctx, d.MockDataStore) + rollback() + return block(ctx, d.MockDataStore) +} + +// probeTxDS runs a hook inside each WithTxRetry block, and can fail the transaction after it. +type probeTxDS struct { + *tests.MockDataStore + during func() + err error +} + +func (d *probeTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error { + if err := block(ctx, d.MockDataStore); err != nil { + return err + } + if d.during != nil { + d.during() + } + return d.err +} diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go index 33e0fed01..964ab7408 100644 --- a/scanner/phase_3_refresh_albums.go +++ b/scanner/phase_3_refresh_albums.go @@ -103,7 +103,9 @@ func (p *phaseRefreshAlbums) refreshAlbum(album *model.Album) (*model.Album, err return nil, nil } start := time.Now() - err := p.ds.Album(p.ctx).Put(album) + err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + return tx.Album(ctx).Put(album) + }, "scanner: refresh album") log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start), err) if err != nil { return nil, fmt.Errorf("refreshing album %s: %w", album.ID, err) @@ -130,7 +132,12 @@ func (p *phaseRefreshAlbums) finalize(err error) error { } // Refresh album annotations start := time.Now() - cnt, err := p.ds.Album(p.ctx).RefreshPlayCounts() + var cnt int64 + err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + var txErr error + cnt, txErr = tx.Album(ctx).RefreshPlayCounts() + return txErr + }, "scanner: refresh album play counts") if err != nil { return fmt.Errorf("refreshing album annotations: %w", err) } @@ -138,7 +145,11 @@ func (p *phaseRefreshAlbums) finalize(err error) error { // Refresh artist annotations start = time.Now() - cnt, err = p.ds.Artist(p.ctx).RefreshPlayCounts() + err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + var txErr error + cnt, txErr = tx.Artist(ctx).RefreshPlayCounts() + return txErr + }, "scanner: refresh artist play counts") if err != nil { return fmt.Errorf("refreshing artist annotations: %w", err) } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index baa8b749a..4e11fa81d 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -101,7 +101,10 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { // import the playlists, and returns an error if the flag can't be persisted (so // the scan does not complete as successful without recording the recovery). func (p *phasePlaylists) deferImport() error { - if err := p.ds.Property(p.ctx).Put(consts.PlaylistsImportPendingFlagKey, "1"); err != nil { + err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + return tx.Property(ctx).Put(consts.PlaylistsImportPendingFlagKey, "1") + }, "scanner: defer playlist import") + if err != nil { return fmt.Errorf("recording pending playlist import: %w", err) } log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+ diff --git a/scanner/scanner.go b/scanner/scanner.go index d73007bdd..a8a192771 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -217,7 +217,9 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt for _, lib := range state.libraries { if lib.LastScanStartedAt.IsZero() { // This is a new scan - mark it as started - err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) + err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + return tx.Library(ctx).ScanBegin(lib.ID, state.fullScan) + }, "scanner: begin library scan") if err != nil { log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) state.sendWarning(err.Error()) @@ -253,7 +255,7 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error { return func() error { state.sendProgress(&ProgressInfo{ForceUpdate: true}) - return s.ds.WithTx(func(tx model.DataStore) error { + return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { if state.changesDetected.Load() { start := time.Now() @@ -264,9 +266,7 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs) } - err := tx.GC(ctx, libraryIDs...) - if err != nil { - log.Error(ctx, "Scanner: Error running GC", err) + if err := tx.GC(ctx, libraryIDs...); err != nil { return fmt.Errorf("running GC: %w", err) } log.Debug(ctx, "Scanner: GC completed", "elapsed", time.Since(start)) @@ -286,10 +286,14 @@ func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanS return nil } start := time.Now() - queue := s.ds.ArtworkQueue(ctx) var total int64 for _, kind := range []model.Kind{model.KindAlbumArtwork, model.KindArtistArtwork} { - n, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityScan) + var n int64 + err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + var err error + n, err = tx.ArtworkQueue(ctx).EnqueueAllMissing(kind, model.ArtworkPriorityScan) + return err + }, "scanner: enqueue missing artwork") if err != nil { log.Error(ctx, "Scanner: Error enqueueing missing artwork", "kind", kind, err) return fmt.Errorf("enqueueing missing artwork: %w", err) @@ -316,7 +320,9 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun log.Debug(ctx, "Scanner: Refreshed artist stats", "stats", stats, "elapsed", time.Since(start)) start = time.Now() - err = s.ds.Tag(ctx).UpdateCounts() + err = s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + return tx.Tag(ctx).UpdateCounts() + }, "scanner: update tag counts") if err != nil { log.Error(ctx, "Scanner: Error updating tag counts", err) return fmt.Errorf("updating tag counts: %w", err) @@ -329,28 +335,21 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() - return s.ds.WithTx(func(tx model.DataStore) error { + return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { for _, lib := range state.libraries { - err := tx.Library(ctx).ScanEnd(lib.ID) - if err != nil { - log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err) - return fmt.Errorf("updating last scan completed: %w", err) + if err := tx.Library(ctx).ScanEnd(lib.ID); err != nil { + return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) } - err = tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track) - if err != nil { - log.Error(ctx, "Scanner: Error updating track PID conf", err) + if err := tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track); err != nil { return fmt.Errorf("updating track PID conf: %w", err) } - err = tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album) - if err != nil { - log.Error(ctx, "Scanner: Error updating album PID conf", err) + if err := tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album); err != nil { return fmt.Errorf("updating album PID conf: %w", err) } if state.changesDetected.Load() { log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) if err := tx.Library(ctx).RefreshStats(lib.ID); err != nil { - log.Error(ctx, "Scanner: Error refreshing library stats", "lib", lib.Name, err) - return fmt.Errorf("refreshing library stats: %w", err) + return fmt.Errorf("refreshing library stats for %s: %w", lib.Name, err) } } else { log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 8542b3ac6..30f4a2b97 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -4,12 +4,16 @@ import ( "context" "database/sql" "errors" + "fmt" + "os" "path/filepath" + "sync/atomic" "testing/fstest" "time" "github.com/Masterminds/squirrel" "github.com/google/uuid" + "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -52,7 +56,10 @@ var _ = Describe("Scanner", Ordered, func() { BeforeAll(func() { ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) - tmpDir := GinkgoT().TempDir() + // The DB stays open until the suite ends, and Windows can't delete an open file + tmpDir, err := os.MkdirTemp("", "scanner-test") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") log.Warn("Using DB at " + conf.Server.DbPath) //conf.Server.DbPath = ":memory:" @@ -1240,8 +1247,55 @@ var _ = Describe("Scanner", Ordered, func() { Expect(albumArtistStats.SongCount).To(Equal(3)) // 3 songs }) }) + + Context("when the database is busy", func() { + var busyDS *busyPersistDS + BeforeEach(func() { + // One album across many folders: the suite's single DB connection deadlocks phase 3 on many albums + album := template(_t{"albumartist": "Artist", "album": "Album"}) + files := fstest.MapFS{} + for i := range 30 { + files[fmt.Sprintf("Artist/Part %02d/%02d - Song.mp3", i, i+1)] = album(track(i+1, fmt.Sprintf("Song %02d", i+1))) + } + createFS(files) + busyDS = &busyPersistDS{MockDataStore: ds} + s = scanner.New(ctx, busyDS, events.NoopBroker(), + playlists.NewPlaylists(busyDS, artwork.NewUploader(busyDS)), metrics.NewNoopInstance()) + }) + + It("gives up and stops walking the library when the database stays busy", func() { + busyDS.failures.Store(1000) + + Expect(runScanner(ctx, true)).To(MatchError(ContainSubstring("database is locked"))) + + Expect(mfRepo.cursorCalls.Load()).To(BeNumerically("<", 30)) + }) + + It("does not mark unvisited folders missing when the scan gives up", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + busyDS.failures.Store(1000) + + Expect(runScanner(ctx, true)).ToNot(Succeed()) + + Expect(ds.Folder(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) + }) + }) }) +// busyPersistDS fails the scanner's folder saves with SQLITE_BUSY, as if WithTxRetry ran out of retries. +type busyPersistDS struct { + *tests.MockDataStore + failures atomic.Int32 +} + +func (b *busyPersistDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, label ...string) error { + if len(label) > 0 && label[0] == "scanner: persist changes" && b.failures.Add(-1) >= 0 { + return sqlite3.Error{Code: sqlite3.ErrBusy} + } + return b.MockDataStore.WithTxRetry(ctx, block, label...) +} + func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) { return func(path string) (*model.MediaFile, error) { list, err := ds.MediaFile(ctx).FindByPaths([]string{path}) @@ -1258,6 +1312,12 @@ func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*mo type mockMediaFileRepo struct { model.MediaFileRepository GetMissingAndMatchingError error + cursorCalls atomic.Int32 +} + +func (m *mockMediaFileRepo) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) { + m.cursorCalls.Add(1) + return m.MediaFileRepository.GetCursor(options...) } func (m *mockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 20f6d4213..1864a6a44 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -53,6 +53,9 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<- // Recursively walk this folder and all its children err = walkFolder(ctx, job, folderPath, checker, results) + if utils.IsCtxDone(ctx) { + return + } if err != nil { log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err) continue @@ -87,9 +90,12 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker folder.path = dir folder.elapsed.Start() - results <- folder - - return nil + select { + case results <- folder: + return nil + case <-ctx.Done(): + return ctx.Err() + } } func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) { @@ -291,6 +297,7 @@ var ignoredDirs = []string{ "$RECYCLE.BIN", "#snapshot", "@Recycle", + "@eaDir", "@Recently-Snapshot", ".git", ".streams", diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 9fb650c4d..43939e5c2 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -564,6 +564,7 @@ var _ = Describe("walk_dir_tree", func() { Entry("dir starting with ellipsis", "...unhidden_folder", false), Entry("recycle bin", "$Recycle.Bin", true), Entry("snapshot dir", "#snapshot", true), + Entry("synology metadata dir", "@eaDir", true), ) }) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 32f56a4f0..6a0ebbb31 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -329,6 +329,10 @@ func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, l return block(db) } +func (db *MockDataStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, label ...string) error { + return block(ctx, db) +} + func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.MediaFile, *model.MediaFile: From 101145742f4762164202cb4858c9126258bdc463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 23 Sep 2026 18:42:11 -0400 Subject: [PATCH 572/596] test(plugins): stub DNS in the host SSRF guard tests (#6208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dial-time SSRF guard runs on the resolved IP, so the tests that prove a symbolic hostname cannot reach loopback used "localhost." — a trailing dot never matches /etc/hosts, so Go queries real DNS. Machines whose resolver does not answer "localhost." (a VPN DNS, for example) got "no such host" before the dial guard ever ran, failing three specs. Add tests.StubResolver, a net.Resolver backed by an in-memory DNS responder over net.Pipe, and let the plugin dialers take a resolver so tests can inject it. Name resolution in those specs no longer depends on the machine's DNS. --- plugins/host_httpclient.go | 1 + plugins/host_httpclient_test.go | 4 +++ plugins/host_netguard.go | 3 ++ plugins/host_websocket.go | 2 +- plugins/host_websocket_test.go | 1 + plugins/plugins_suite_test.go | 6 ++++ tests/dns_stub.go | 54 +++++++++++++++++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/dns_stub.go diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 3265ac4b7..6a9a4d2d6 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -54,6 +54,7 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, Control: svc.dialControl, + Resolver: dialResolver, }).DialContext // No client timeout: it is set per-request via context deadline. svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)} diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 6edf75a97..81e3192fd 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -20,6 +20,10 @@ var _ = Describe("httpServiceImpl", func() { ts *httptest.Server ) + BeforeEach(func() { + stubLocalhostDNS() + }) + AfterEach(func() { if ts != nil { ts.Close() diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go index f78aaf6a8..ab092009a 100644 --- a/plugins/host_netguard.go +++ b/plugins/host_netguard.go @@ -8,6 +8,9 @@ import ( "github.com/navidrome/navidrome/utils/netguard" ) +// dialResolver is nil in production (the system resolver); tests swap in a stub to avoid real DNS. +var dialResolver *net.Resolver + // checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a // literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. func checkPrivateDial(requiredHosts []string, address string) error { diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 933e53144..d9d82665c 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -114,7 +114,7 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade // Establish WebSocket connection dialer := websocket.Dialer{ HandshakeTimeout: 30 * time.Second, - NetDialContext: (&net.Dialer{Control: s.dialControl}).DialContext, + NetDialContext: (&net.Dialer{Control: s.dialControl, Resolver: dialResolver}).DialContext, } conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders) diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 772d83cc9..9b94e4b70 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -505,6 +505,7 @@ var _ = Describe("WebSocketService", Ordered, func() { var savedHosts []string BeforeEach(func() { + stubLocalhostDNS() savedHosts = testService.requiredHosts upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index cc1b45f97..aa07eae32 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -46,6 +46,12 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } +// stubLocalhostDNS resolves "localhost." without real DNS: the trailing dot never matches /etc/hosts. +func stubLocalhostDNS() { + dialResolver = tests.StubResolver(map[string]string{"localhost.": "127.0.0.1"}) + DeferCleanup(func() { dialResolver = nil }) +} + // createTestManager creates a new plugin Manager with the given plugin config. // It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager. // Returns the manager, temp directory path, and a cleanup function. diff --git a/tests/dns_stub.go b/tests/dns_stub.go new file mode 100644 index 000000000..7cd7faf6d --- /dev/null +++ b/tests/dns_stub.go @@ -0,0 +1,54 @@ +package tests + +import ( + "context" + "encoding/binary" + "io" + "net" + "net/netip" + + "golang.org/x/net/dns/dnsmessage" +) + +// StubResolver returns a net.Resolver that answers A queries from records (absolute name to IPv4, +// e.g. "localhost." to "127.0.0.1") instead of the system DNS. Other names do not resolve. +func StubResolver(records map[string]string) *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(context.Context, string, string) (net.Conn, error) { + client, server := net.Pipe() + go serveStubDNS(server, records) + return client, nil + }, + } +} + +// net.Pipe is not a PacketConn, so the resolver sends one query framed with a 2-byte length prefix. +func serveStubDNS(conn net.Conn, records map[string]string) { + defer conn.Close() + var size [2]byte + if _, err := io.ReadFull(conn, size[:]); err != nil { + return + } + buf := make([]byte, binary.BigEndian.Uint16(size[:])) + if _, err := io.ReadFull(conn, buf); err != nil { + return + } + var msg dnsmessage.Message + if err := msg.Unpack(buf); err != nil || len(msg.Questions) != 1 { + return + } + msg.Response = true + q := msg.Questions[0] + if ip, err := netip.ParseAddr(records[q.Name.String()]); err == nil && q.Type == dnsmessage.TypeA { + msg.Answers = []dnsmessage.Resource{{ + Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: q.Class}, + Body: &dnsmessage.AResource{A: ip.As4()}, + }} + } + resp, err := msg.Pack() + if err != nil { + return + } + _, _ = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(resp))), resp...)) +} From 69b496383deb9e58e3930da7628ab563af938946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 24 Sep 2026 21:24:46 -0400 Subject: [PATCH 573/596] docs(jellyfin): refresh the README's known limitations (#6220) * docs(jellyfin): refresh the README's known limitations Rewrite the Known limitations list against the current code and Jellyfin 12.1, dropping stale entries (synthetic blurhash, unchecked artist access, global genres) and adding the real gaps: search skipping filters, the one-character minimum, the 2,000-item search cap, position-based playlist entry ids, the rating param, missing endpoints and unemitted Fields. Also move the lyrics description into its own section, add the missing routes and filters to the endpoint table, and drop the stale artist-access TODO in resolveItemByID. * docs(jellyfin): list MaxConcurrentStreams env var and all e2e stubs --- server/jellyfin/README.md | 109 ++++++++++++++++++++++---------------- server/jellyfin/items.go | 3 +- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index e38997569..9315ac4f9 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -40,6 +40,7 @@ ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" ND_JELLYFIN_AUTODISCOVERY=true ND_JELLYFIN_QUICKCONNECT=false +ND_JELLYFIN_MAXCONCURRENTSTREAMS=4 ``` Once enabled, the API is mounted at: @@ -140,8 +141,10 @@ Jellyfin has no native concept of multiple music libraries the way Navidrome doe Navidrome library the current user can access is exposed as its own top-level Jellyfin "CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the -authenticated user has access to; a library (or item within it) the user cannot access returns -`404`, never `403`, so ids can't be used as an existence oracle. +authenticated user has access to. Fetching an item the user cannot access returns `404`, never +`403`, so ids can't be used as an existence oracle. An inaccessible library id sent as `ParentId` +is not a `404`: it is simply not treated as a library, so none of that library's content is +returned. ### Browsing filters @@ -152,7 +155,7 @@ tracks — Finamp's artist screen sends these *alongside* `ParentId=` album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); -`SearchTerm`; +`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`; `Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone `isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome @@ -169,9 +172,9 @@ returns direct children only (no tracks — no track is a library's direct child | Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | -| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | -| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | +| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Items/Latest`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | +| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | | Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | | Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | @@ -267,6 +270,22 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are advertised and served but packed-audio players won't decode them. +## Lyrics + +`GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in 100ns ticks, +word-level `Cues` when present), resolved through the full `core/lyrics` pipeline (embedded, `.lrc` +sidecars, plugins per `LyricsPriority`). No lyrics returns `404`, never an empty `200`. + +Results, misses included, are cached for 5 minutes: Jellify fetches lyrics for every played track and +Feishin on every song change, so lyric-less tracks are the hot path. Concurrent misses on the same +track share one pipeline run. That run is detached from the request, so a cancelled request doesn't +fail it for other waiters, and its context has a one-minute deadline. + +Finamp opens its lyrics view only when the track has a `Lyric` `MediaStream` (`HasLyrics` is just a +list badge). Browse lists set both from embedded lyrics only. `PlaybackInfo` runs the full pipeline +per track, so sidecar and plugin lyrics show up there. Feishin also requires server version ≥ 10.9 +(we advertise 12.1.0). + ## AudioMuse-AI compatible endpoints Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) @@ -356,8 +375,9 @@ curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in [`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and -real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot -isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with: +real repositories (only artwork, streaming, ffmpeg, external metadata agents and sonic similarity +are stubbed), with per-`Describe` snapshot isolation — mirroring the Subsonic `server/subsonic/e2e` +suite. Run it with: ```bash make test PKG=./server/jellyfin/... @@ -365,42 +385,37 @@ make test PKG=./server/jellyfin/... ## Known limitations -- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's - libraries (genre tags aren't per-library entities in Navidrome's model). -- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each - belong to exactly one library and are checked against `user.HasLibraryAccess` on every - fetch), an artist can have content across multiple libraries via `library_artist`, so there's - no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against. - Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` - *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client - that already has an artist id from elsewhere is not re-checked against library membership. -- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is - populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** - blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a - multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and - stores it per image, so its placeholder approximates the art. Ours satisfies the protocol - (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash - warning) but renders as a flat color while art loads. A proper implementation would compute the - real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it - keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback - for art that hasn't been rendered yet. -- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a - `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a - working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up - would broadcast real session/playstate and library-change events over it (via `server/events`), - mirroring Jellyfin's session messages. -- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in - 100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline - (embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also - caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less - tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade - gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is - just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel - check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per - track so sidecar/plugin lyrics also light up. Feishin additionally requires server version - ≥ 10.9 (`jellyfinVersion` advertises 12.1.0). - Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` - is singleflighted), and the load runs detached from the request context with a one-minute bound, - so a cancelled request or hung plugin can't fail or pin the load for other waiters. - Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in - lists (request-time sources can't be known at list time without per-row I/O). +- **Genre lists ignore `ParentId`.** `GET Genres`/`MusicGenres` and + `Items?IncludeItemTypes=MusicGenre` list the genres of every library the user can access, never + only the `ParentId` library. `Items?IncludeItemTypes=MusicGenre` also ignores `SearchTerm`. + `GET Items/Filters` is the exception: its genres follow `ParentId`. +- **Search skips some filters.** With `SearchTerm`, `Filters=IsFavorite`, `IsPlayed`, `IsUnplayed` + and `isFavorite`/`isPlayed` are skipped, so the response holds every search match. The full-text + search's first phase has no annotation join to filter on. Artist searches also skip the role (album + artist vs. artist) and `GenreIds`. Playlist lists ignore `SearchTerm`. +- **Search pages are capped at 2,000 items.** A larger `Limit` is lowered to 2,000. Jellyfin has no + cap. +- **One-character searches return nothing.** The shared search layer ignores terms shorter than two + characters, so album, artist and song searches for `a` come back empty. Real Jellyfin has no + minimum. +- **Playlist entry ids are positions, not song ids.** `PlaylistItemId` encodes the entry's position + in the playlist, so ids change when entries are inserted or removed; re-read the list after an + edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work; + clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`. +- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating` + (0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends + only `likes` clears the rating. +- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy + `UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback + reporting. +- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are + accepted but never returned. +- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork + pipeline computes and stores. Until that happens for an item, the field is omitted: a fake value + would pin the wrong placeholder in clients that key their image cache on it. +- **The WebSocket only keeps alive; it pushes no events.** `GET socket` sends `ForceKeepAlive` and + answers `KeepAlive` pings, so real-time clients (Finamp) keep a working session instead of + reconnecting in a loop. It never pushes session, playstate or library-change events. +- **List lyric badges only see embedded lyrics.** Tracks with only sidecar or plugin lyrics show no + `HasLyrics` badge in lists, because those sources can't be known at list time without per-row I/O. + `PlaybackInfo` and `Audio/{id}/Lyrics` still find them (see "Lyrics"). diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 124d17920..23e35dd59 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -893,8 +893,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi return dto.AlbumToBaseItem(*al, fields), true } if ar, err := api.ds.Artist(ctx).Get(id); err == nil { - // TODO: an artist spans multiple libraries (library_artist), so there's no single - // LibraryID to gate here; artist access relies on list-time scoping and persistence. + // Artist.Get already scopes to the user's libraries via library_artist. return dto.ArtistToBaseItem(*ar, fields), true } if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { From a0c416037655a2d4476d3e055907540605f5bf87 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 24 Sep 2026 21:40:49 -0400 Subject: [PATCH 574/596] chore: update golangci-lint version to v2.14.0 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 81a609422..eccccbffb 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.13.2 +GOLANGCI_LINT_VERSION ?= v2.14.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") From 3f89baaec8f3285429496d5f0b796165034c70ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 24 Sep 2026 22:07:17 -0400 Subject: [PATCH 575/596] test(server): make the handleM3U spec independent of spec order (#6221) It relied on another spec having set auth.PublicTokenAuth, so it panicked whenever Ginkgo ran it first. --- server/public/handle_shares_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/public/handle_shares_test.go b/server/public/handle_shares_test.go index 1bf631fd4..4de2a75c1 100644 --- a/server/public/handle_shares_test.go +++ b/server/public/handle_shares_test.go @@ -4,7 +4,9 @@ import ( "net/http" "net/http/httptest" + "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -17,6 +19,7 @@ var _ = Describe("handleM3U", func() { var pub *Router BeforeEach(func() { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) ds = &tests.MockDataStore{} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo From 336fa482f84f2600f7cf79464fd828549e4aa43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 25 Sep 2026 12:19:33 -0400 Subject: [PATCH 576/596] fix(plugins): skip the startup error reset when no plugin has an error (#6223) The plugin manager clears last_error on every startup with an UPDATE that takes the SQLite write lock even when no row matches. On slow storage the startup scan often holds the lock at that moment, so the reset waited out the busy timeout and logged "database is locked", even with no plugins installed. ClearErrors now checks for errors with a read first and only writes when there is something to clear. --- persistence/plugin_repository.go | 8 ++++++++ persistence/plugin_repository_test.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index c1e36f0b1..7d5781f49 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -35,6 +35,14 @@ func (r *pluginRepository) ClearErrors() error { if !r.isPermitted() { return rest.ErrPermissionDenied } + // An UPDATE takes the write lock even when nothing matches, so only run it when there is an error to clear + var hasErrors bool + if err := r.db.NewQuery("SELECT EXISTS (SELECT 1 FROM plugin WHERE last_error != '')").Row(&hasErrors); err != nil { + return err + } + if !hasErrors { + return nil + } _, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute() return err } diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go index dc68b0892..9b135057e 100644 --- a/persistence/plugin_repository_test.go +++ b/persistence/plugin_repository_test.go @@ -1,7 +1,10 @@ package persistence import ( + "context" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" @@ -198,6 +201,18 @@ var _ = Describe("PluginRepository", func() { err := repo.ClearErrors() Expect(err).To(BeNil()) }) + + It("does not need the write lock when no plugins have errors", func() { + _ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) + conn, err := db.Db().Conn(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(conn.Close) + _, err = conn.ExecContext(GinkgoT().Context(), "BEGIN IMMEDIATE") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") }) + + Expect(repo.ClearErrors()).To(Succeed()) + }) }) }) From 659d067abaaf31d4a261836331a1f6bc00746c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 25 Sep 2026 16:19:30 -0400 Subject: [PATCH 577/596] fix(archiver): give same-named albums their own folder in artist zips (#6225) * refactor: add Tags.First and slice.GroupOrdered helpers Tags.First returns the first value of a tag or an empty string, replacing the inline len-check-then-index pattern in FullTitle, FullAlbumName, Album.FullName and the Subsonic album version mapping. slice.GroupOrdered is slice.Group returning the groups in first-seen order, for callers that need a deterministic order the map-based Group cannot give. * fix(archiver): give same-named albums their own folder in artist zips Artist zips put every album in a folder named after the album, so two albums with the same name (an original and a deluxe edition, or names that only differ in characters the sanitizer replaces) were merged into one folder, with tracks mixed together and duplicate zip entries when file names collided. The folder is now named after FullAlbumName(), so with AppendAlbumVersion on (the default) the version is part of the name, matching what clients display. Albums whose sanitized names still clash get a " [suffix]" taken from the first field that has a distinct, non-empty value for all of them: album version, year, release type, record label, catalog number, then a short album id. This follows the shape of beets' %aunique{} path function. Albums are also grouped with slice.GroupOrdered instead of a map, so the zip is deterministic. * fix(archiver): use the release year to tell same-named albums apart Taggers often write an edition's date to the Date tag next to an original date, and the scanner then stores the original year in Year and the edition's year in ReleaseYear. Reissues of the same album therefore share Year, so the year disambiguator could not tell them apart and they fell through to the album id suffix. Prefer ReleaseYear and fall back to Year when it is not set. Found by downloading an artist zip from a live server built from this branch. * fix(archiver): let one clashing album keep the plain folder name A disambiguator was only accepted when every clashing album had a non-empty value, so an original and its deluxe edition (with the version not appended to the name) fell through to the album id suffix. Accept a field whose values are distinct across the group even when one of them is empty, as beets' %aunique{} does: that album keeps the plain name, which the suffixed folders cannot clash with. Two or more empty values still count as a tie. * test(archiver): refactor tests for album naming conventions and query order --- core/archiver.go | 70 +++++++++++++++++-- core/archiver_test.go | 136 +++++++++++++++++++++++++++++++++++++ model/album.go | 4 +- model/mediafile.go | 8 +-- model/tag.go | 7 ++ model/tag_test.go | 4 ++ server/subsonic/helpers.go | 4 +- utils/slice/slice.go | 17 +++++ utils/slice/slice_test.go | 12 ++++ 9 files changed, 246 insertions(+), 16 deletions(-) diff --git a/core/archiver.go b/core/archiver.go index c9436279e..6406ca075 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -2,12 +2,14 @@ package core import ( "archive/zip" + "cmp" "context" "errors" "fmt" "io" "os" "path/filepath" + "strconv" "strings" "github.com/Masterminds/squirrel" @@ -58,16 +60,16 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr } z := createZipWriter(out, format, bitrate) - albums := slice.Group(mfs, func(mf model.MediaFile) string { - return mf.AlbumID - }) + albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID }) + folders := albumFolders(albums) for _, album := range albums { discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber }) isMultiDisc := len(discs) > 1 - log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, + folder := folders[album[0].AlbumID] + log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { - file := a.albumFilename(mf, format, isMultiDisc) + file := a.albumFilename(mf, format, isMultiDisc, folder) if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Stop iterating: continuing would just rack up more // rejections from the limiter. Close finalises whatever @@ -96,7 +98,61 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer { return z } -func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string { +// Tried in order; the first one whose values are distinct across the clashing albums wins. +// One album may have an empty value: it keeps the plain name, which the others can't clash with. +var albumDisambiguators = []func(model.MediaFile) string{ + func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) }, + func(mf model.MediaFile) string { + // Reissues share Year (often the original's) but not ReleaseYear. + if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 { + return strconv.Itoa(y) + } + return "" + }, + func(mf model.MediaFile) string { return mf.MbzAlbumType }, + func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) }, + func(mf model.MediaFile) string { return mf.CatalogNum }, + func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] }, + func(mf model.MediaFile) string { return mf.AlbumID }, +} + +// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the +// same folder get a " [suffix]" from the first disambiguator that tells them all apart. +func albumFolders(albums [][]model.MediaFile) map[string]string { + byName := map[string][]model.MediaFile{} + for _, album := range albums { + name := str.SanitizeFilename(album[0].FullAlbumName()) + byName[name] = append(byName[name], album[0]) + } + folders := make(map[string]string, len(albums)) + for name, group := range byName { + if len(group) == 1 { + folders[group[0].AlbumID] = name + continue + } + fields: + for _, field := range albumDisambiguators { + ids := make(map[string]string, len(group)) // suffix -> album id + for _, mf := range group { + s := str.SanitizeFilename(field(mf)) + if _, dup := ids[s]; dup { + continue fields + } + ids[s] = mf.AlbumID + } + for s, id := range ids { + folders[id] = name + if s != "" { + folders[id] = fmt.Sprintf("%s [%s]", name, s) + } + } + break + } + } + return folders +} + +func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string { _, file := filepath.Split(mf.Path) if format != "raw" { file = strings.TrimSuffix(file, mf.Suffix) + format @@ -104,7 +160,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc if isMultiDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) + return fmt.Sprintf("%s/%s", folder, file) } // ZipShare takes an already-loaded share: Share.Load records a visit, so diff --git a/core/archiver_test.go b/core/archiver_test.go index 461af1800..86833717a 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" @@ -91,6 +93,140 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3")) }) + + When("albums that share a name", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + // zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order. + zipArtistEntries := func(mfs model.MediaFiles) []string { + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", mock.Anything).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + + out := new(bytes.Buffer) + Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).To(BeNil()) + names := make([]string, len(zr.File)) + for i, f := range zr.File { + names[i] = f.Name + } + return names + } + + It("keeps the albums in query order", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"}, + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"}, + {Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"}, + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"}, + }) + Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"})) + }) + + It("suffixes the year when it tells the albums apart", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, + {Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005}, + }) + Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"})) + }) + + It("prefers the release year, so reissues of the same original are told apart", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011}, + {Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996}, + }) + Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"})) + }) + + It("names the folder after the full album name", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, + Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, + Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}}, + }) + Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"})) + }) + + It("prefers the album version over the year when it is not part of the name", func() { + conf.Server.Subsonic.AppendAlbumVersion = false + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, + Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, + Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, + }) + Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"})) + }) + + It("leaves the one album without the field unsuffixed", func() { + conf.Server.Subsonic.AppendAlbumVersion = false + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, + Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, + }) + Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"})) + }) + + It("skips a field that is empty on more than one album", func() { + conf.Server.Subsonic.AppendAlbumVersion = false + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005}, + {Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010, + Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, + }) + Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"})) + }) + + It("skips a field that is the same on every album", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"}, + }) + Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"})) + }) + + It("falls back to the album id when nothing differs", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001}, + }) + Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"})) + }) + + It("treats names that sanitize to the same folder as a clash", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005}, + }) + Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"})) + }) + + It("sanitizes the suffix", func() { + conf.Server.Subsonic.AppendAlbumVersion = false + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}}, + }) + Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"})) + }) + + It("leaves the folder name alone when only one album has it", func() { + names := zipArtistEntries(model.MediaFiles{ + {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, + {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005}, + }) + Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"})) + }) + }) }) Context("when the transcode limiter rejects a file", func() { diff --git a/model/album.go b/model/album.go index ee24bfa96..114d19e1e 100644 --- a/model/album.go +++ b/model/album.go @@ -76,8 +76,8 @@ func (a Album) CoverArtID() ArtworkID { } func (a Album) FullName() string { - if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { - return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0]) + if v := a.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" { + return appendSuffix(a.Name, v) } return a.Name } diff --git a/model/mediafile.go b/model/mediafile.go index 7cbdb583c..1147eaa35 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -105,15 +105,15 @@ type MediaFile struct { } func (mf MediaFile) FullTitle() string { - if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { - return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0]) + if s := mf.Tags.First(TagSubtitle); conf.Server.Subsonic.AppendSubtitle && s != "" { + return appendSuffix(mf.Title, s) } return mf.Title } func (mf MediaFile) FullAlbumName() string { - if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { - return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0]) + if v := mf.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" { + return appendSuffix(mf.Album, v) } return mf.Album } diff --git a/model/tag.go b/model/tag.go index 234cfb359..152b7164e 100644 --- a/model/tag.go +++ b/model/tag.go @@ -80,6 +80,13 @@ func (t Tags) Values(name TagName) []string { return t[name] } +func (t Tags) First(name TagName) string { + if v := t[name]; len(v) > 0 { + return v[0] + } + return "" +} + func (t Tags) IDs() []string { var ids []string for name, tag := range t { diff --git a/model/tag_test.go b/model/tag_test.go index 4dc99019b..c5a941b4e 100644 --- a/model/tag_test.go +++ b/model/tag_test.go @@ -43,6 +43,10 @@ var _ = Describe("Tag", func() { Expect(tags.Values("genre")).To(ConsistOf("Rock", "Pop")) Expect(tags.Values("artist")).To(ConsistOf("The Beatles")) }) + It("should get the first value by name", func() { + Expect(tags.First("genre")).To(Equal("Rock")) + Expect(tags.First("missing")).To(BeEmpty()) + }) Describe("Hash", func() { It("should always return the same value for the same tags ", func() { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 2d9d53b18..55b1b213e 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -515,9 +515,7 @@ func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubs dir.IsCompilation = album.Compilation dir.DiscTitles = buildDiscSubtitles(album) dir.ExplicitStatus = mapExplicitStatus(album.ExplicitStatus) - if len(album.Tags.Values(model.TagAlbumVersion)) > 0 { - dir.Version = album.Tags.Values(model.TagAlbumVersion)[0] - } + dir.Version = album.Tags.First(model.TagAlbumVersion) return &dir } diff --git a/utils/slice/slice.go b/utils/slice/slice.go index 73537c8f8..e3677e754 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -33,6 +33,23 @@ func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T { return m } +// GroupOrdered is Group with the groups in first-seen order. +func GroupOrdered[T any, K comparable](s []T, keyFunc func(T) K) [][]T { + var groups [][]T + index := map[K]int{} + for _, item := range s { + k := keyFunc(item) + i, ok := index[k] + if !ok { + i = len(groups) + index[k] = i + groups = append(groups, nil) + } + groups[i] = append(groups[i], item) + } + return groups +} + func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V { m := make(map[K]V, len(s)) for _, item := range s { diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 27548d693..506b61b9f 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -63,6 +63,18 @@ var _ = Describe("Slice Utils", func() { }) }) + Describe("GroupOrdered", func() { + It("returns nil for an empty input", func() { + Expect(slice.GroupOrdered([]int{}, func(v int) int { return v })).To(BeNil()) + }) + + It("keeps groups in first-seen order and items in input order", func() { + keyFunc := func(v int) int { return v % 3 } + result := slice.GroupOrdered([]int{2, 1, 4, 3, 5, 6, 8}, keyFunc) + Expect(result).To(Equal([][]int{{2, 5, 8}, {1, 4}, {3, 6}})) + }) + }) + Describe("ToMap", func() { It("returns empty map for an empty input", func() { transformFunc := func(v int) (int, string) { return v, strconv.Itoa(v) } From 83fff44c822d47d1275b9dfb0142bdfefac90cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 25 Sep 2026 16:42:49 -0400 Subject: [PATCH 578/596] feat(archiver): add folder cover image to downloaded zips (#6224) * feat(archiver): add folder cover image to downloaded zips Album, artist, playlist and share downloads now include the item's cover as folder., the image most players and car stereos show for the files next to it. This helps users who copy transcoded downloads to offline devices, since transcoding drops the embedded artwork (#5841). Album folders get the album cover, the artist zip root gets the artist image, and playlist and share zips get the playlist or shared item's cover at the root. The image is the same one getCoverArt serves, resized to 500px (not square), and named by its detected type. Items without artwork get no image, and a cover that fails to load is logged and skipped so it never breaks the archive. The archiver reads covers through a new core.CoverArtReader interface, implemented by artwork.CoverArtReader, to avoid an import cycle between core and core/artwork. * refactor(archiver): read covers through artwork.Artwork directly The archiver no longer needs a local CoverArtReader interface and adapter. The only reason core/artwork imported core was a core.AbsolutePath call in loadArtistFolder, which now reads the library path from the repository and cleans it the same way. With the import cycle gone, the archiver takes artwork.Artwork and treats ErrUnavailable and ErrNotFound as no cover. Covers are now written after each album's tracks (and after all tracks for the archive root), so a slow artwork lookup does not delay the first bytes of the download. * fix(archiver): read share covers as admin so private playlists keep theirs Public share downloads run with an anonymous context, and the playlist repository hides private playlists from anonymous users, so a zip of a shared private playlist silently had no folder image. Like the public image handler, the share itself is the authorization: the cover lookup now runs with an admin user. Only the cover read is elevated; streaming keeps the anonymous context, so the transcode limiter still keys public downloads the same way. * style(archiver): trim comments Shorten the comments added by the folder cover image change and drop the ones the names already explain. * fix(archiver): add one cover per album folder in artist zips Albums with the same name share a zip folder (pre-existing naming), so an artist zip with two such albums wrote two folder. entries at the same path. Keep the first cover and skip the rest for that folder. --- cmd/wire_gen.go | 4 +- core/archiver.go | 96 ++++++++++-- core/archiver_test.go | 178 +++++++++++++++++++++- core/artwork/folders_artist.go | 6 +- core/artwork/folders_artist_paths_test.go | 15 +- 5 files changed, 270 insertions(+), 29 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 43ba808d0..cb06cc047 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -95,7 +95,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) + archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) @@ -152,7 +152,7 @@ func CreatePublicRouter() *public.Router { transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) + archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) return router } diff --git a/core/archiver.go b/core/archiver.go index 6406ca075..33236d889 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -7,20 +7,27 @@ import ( "errors" "fmt" "io" + "net/http" "os" + "path" "path/filepath" "strconv" "strings" + "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) +const archiveCoverArtSize = 500 + type Archiver interface { ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error @@ -28,18 +35,19 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver { - return &archiver{ds: ds, ms: ms, shares: shares} +func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { + return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork} } type archiver struct { - ds model.DataStore - ms stream.MediaStreamer - shares Share + ds model.DataStore + ms stream.MediaStreamer + shares Share + artwork artwork.Artwork } func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}) + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{}) } func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { @@ -49,10 +57,11 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist), squirrel.Eq{"missing": false}, } - return a.zipAlbums(ctx, id, format, bitrate, out, filter) + return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID()) } -func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error { +// rootArt, when set, is added to the archive root. +func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error { mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"}) if err != nil { log.Error(ctx, "Error loading mediafiles from artist", "id", id, err) @@ -80,7 +89,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr return addErr } } + // After the tracks, so a slow artwork lookup doesn't delay the first bytes. + a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder) } + a.addCoverArtToZip(ctx, z, rootArt, "") err = z.Close() if err != nil { log.Error(ctx, "Error closing zip file", "id", id, err) @@ -170,7 +182,10 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) return model.ErrNotAuthorized } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) + // The share is the authorization (as in the public image handler): an anonymous lookup would + // hide a private playlist. Only the cover read is elevated. + coverCtx := request.WithUser(ctx, model.User{IsAdmin: true}) + return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { @@ -181,10 +196,10 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi } mfs := pls.MediaFiles() log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs)) - return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true) + return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true) } -func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error { +func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error { z := createZipWriter(out, format, bitrate) zippedMfs := make(model.MediaFiles, len(mfs)) @@ -199,6 +214,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st mf.Path = file zippedMfs[idx] = mf } + a.addCoverArtToZip(coverCtx, z, coverArt, "") // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { @@ -276,3 +292,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return nil } + +// addCoverArtToZip adds the cover as dir/folder.. Errors are logged, never returned. +func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) { + if artID.ID == "" { + return + } + // Buffered so a failed read leaves no empty entry. + data, err := a.readCoverArt(ctx, artID) + if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) { + log.Debug(ctx, "No cover art to add to zip", "artID", artID) + return + } + if err != nil { + log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err) + return + } + ext := coverArtExtension(data) + if ext == "" { + log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID) + return + } + w, err := z.CreateHeader(&zip.FileHeader{ + Name: path.Join(dir, "folder."+ext), + Modified: time.Now(), + Method: zip.Store, + }) + if err != nil { + log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err) + return + } + if _, err = w.Write(data); err != nil { + log.Warn(ctx, "Error zipping cover art", "artID", artID, err) + } +} + +func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) { + img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false) + if err != nil { + return nil, err + } + defer img.Close() + return io.ReadAll(img) +} + +// Resizing may re-encode the image, so the type comes from its bytes. +func coverArtExtension(data []byte) string { + switch http.DetectContentType(data) { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + } + return "" +} diff --git a/core/archiver_test.go b/core/archiver_test.go index 86833717a..9dab44cef 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "errors" "io" "strings" @@ -11,8 +12,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,13 +28,15 @@ var _ = Describe("Archiver", func() { ms *mockMediaStreamer ds *mockDataStore sh *mockShare + ca *mockCoverArt ) BeforeEach(func() { ms = &mockMediaStreamer{} sh = &mockShare{} ds = &mockDataStore{} - arch = core.NewArchiver(ms, ds, sh) + ca = &mockCoverArt{images: map[string][]byte{}} + arch = core.NewArchiver(ms, ds, sh, ca) }) Context("ZipAlbum", func() { @@ -332,8 +337,179 @@ var _ = Describe("Archiver", func() { Expect(string(m3uContent)).To(Equal(expectedM3U)) }) }) + Context("cover art", func() { + var ( + jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg") + pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png") + ) + + mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) { + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + } + + It("adds the album cover to the album folder", func() { + ca.images["al-1"] = jpegData + mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(2)) + Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData)) + Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false})) + }) + + It("adds the artist image to the root and each album cover to its folder", func() { + ca.images["ar-1"] = pngData + ca.images["al-1"] = jpegData + ca.images["al-2"] = jpegData + mockAlbumTracks(squirrel.And{ + persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + }, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(5)) + Expect(files).To(HaveKeyWithValue("folder.png", pngData)) + Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData)) + Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData)) + }) + + It("puts each same-named album's cover in that album's own folder", func() { + ca.images["al-1"] = jpegData + ca.images["al-2"] = pngData + mockAlbumTracks(squirrel.And{ + persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + }, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData)) + Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData)) + }) + + It("adds the playlist cover to the root", func() { + ca.images["pl-1"] = jpegData + plRepo := &mockPlaylistRepository{} + plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{ + ID: "1", + Name: "Test Playlist", + Tracks: []model.PlaylistTrack{ + {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}}, + }, + }, nil) + ds.On("Playlist", mock.Anything).Return(plRepo) + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + + out := new(bytes.Buffer) + Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(3)) + Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) + Expect(files).To(HaveKey("Test Playlist.m3u")) + }) + + It("adds the shared item's cover to the root, even for a private playlist", func() { + ca.images["pl-10"] = jpegData + ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) + share := &model.Share{ + ID: "1", + Downloadable: true, + Format: "mp3", + MaxBitRate: 128, + ResourceType: "playlist", + ResourceIDs: "10", + Tracks: model.MediaFiles{ + {ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"}, + }, + } + + out := new(bytes.Buffer) + Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(2)) + Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) + Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true})) + }) + + It("still builds the archive when the cover cannot be read", func() { + ca.err = errors.New("boom") + mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + }) + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) + + files := readZip(out) + Expect(files).To(HaveLen(1)) + Expect(files).To(HaveKey("Album/01 - track1.mp3")) + }) + }) }) +func readZip(out *bytes.Buffer) map[string][]byte { + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + files := make(map[string][]byte, len(zr.File)) + for _, f := range zr.File { + r, err := f.Open() + Expect(err).ToNot(HaveOccurred()) + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + _ = r.Close() + files[f.Name] = data + } + return files +} + +type coverRequest struct { + id string + size int + square bool + admin bool +} + +type mockCoverArt struct { + artwork.Artwork + images map[string][]byte + err error + requests []coverRequest +} + +func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) { + user, _ := request.UserFrom(ctx) + m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin}) + if m.err != nil { + return nil, m.err + } + data, ok := m.images[artID.String()] + if !ok { + return nil, artwork.ErrUnavailable + } + return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil +} + type mockDataStore struct { mock.Mock model.DataStore diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go index 1ca1ce034..efef42f81 100644 --- a/core/artwork/folders_artist.go +++ b/core/artwork/folders_artist.go @@ -14,7 +14,6 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -169,8 +168,9 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu folderPath = filepath.Dir(folderPath) } - // TODO: Hacky, but the easiest way to get the folder ID ATM - libPath := core.AbsolutePath(ctx, ds, libID, "") + // Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".". + libPath, _ := ds.Library(ctx).GetPath(libID) + libPath = filepath.Join(libPath) folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, diff --git a/core/artwork/folders_artist_paths_test.go b/core/artwork/folders_artist_paths_test.go index cc8af3e63..127661891 100644 --- a/core/artwork/folders_artist_paths_test.go +++ b/core/artwork/folders_artist_paths_test.go @@ -6,7 +6,6 @@ import ( "path/filepath" "time" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -47,11 +46,11 @@ var _ = Describe("loadArtistFolder", func() { BeforeEach(func() { ctx = context.Background() - DeferCleanup(stubCoreAbsolutePath()) - updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute) repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}} - ds = &tests.MockDataStore{MockedFolder: repo} + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}}) + ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo} albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}} }) @@ -107,11 +106,3 @@ var _ = Describe("loadArtistFolder", func() { Expect(upd).To(BeZero()) }) }) - -func stubCoreAbsolutePath() func() { - original := core.AbsolutePath - core.AbsolutePath = func(context.Context, model.DataStore, int, string) string { - return filepath.FromSlash("/music") - } - return func() { core.AbsolutePath = original } -} From b293b962566019d33c3e094164649b75c777b657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 25 Sep 2026 18:06:10 -0400 Subject: [PATCH 579/596] refactor(persistence): stateless repositories with per-call context (#6149) * refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls. --- .golangci.yml | 4 + adapters/lastfm/agent_test.go | 2 +- adapters/lastfm/auth_router_test.go | 2 +- adapters/listenbrainz/agent_test.go | 2 +- cmd/artwork.go | 34 +- cmd/artwork_test.go | 90 ++-- cmd/missing.go | 6 +- cmd/pls.go | 10 +- cmd/plugin.go | 8 +- cmd/root.go | 10 +- cmd/svc.go | 2 +- cmd/user.go | 26 +- cmd/utils.go | 4 +- core/agents/local_agent.go | 6 +- core/agents/session_keys.go | 6 +- core/archiver.go | 4 +- core/archiver_test.go | 24 +- core/artwork/artwork.go | 24 +- core/artwork/artwork_suite_test.go | 7 +- core/artwork/artwork_test.go | 44 +- core/artwork/disc.go | 6 +- core/artwork/e2e/acquire_serve_test.go | 36 +- core/artwork/e2e/artist_test.go | 4 +- core/artwork/e2e/e2e_suite_test.go | 10 +- core/artwork/e2e/mediafile_test.go | 2 +- core/artwork/e2e/playlist_test.go | 10 +- core/artwork/e2e/radio_test.go | 4 +- core/artwork/e2e/resolution_harness_test.go | 18 +- core/artwork/folders_album.go | 6 +- core/artwork/folders_artist.go | 4 +- core/artwork/housekeeping.go | 24 +- core/artwork/housekeeping_test.go | 8 +- core/artwork/library_fs.go | 2 +- core/artwork/library_fs_test.go | 4 +- core/artwork/processor.go | 10 +- core/artwork/processor_test.go | 54 +-- core/artwork/prune.go | 10 +- core/artwork/prune_test.go | 48 +- core/artwork/resolve.go | 16 +- core/artwork/uploader_test.go | 25 +- core/artwork/worker.go | 18 +- core/artwork/worker_soak_test.go | 10 +- core/artwork/worker_test.go | 119 ++--- core/auth/auth.go | 8 +- core/common.go | 2 +- core/external/extdata_helper_test.go | 18 +- core/external/provider.go | 16 +- core/external/provider_refreshinfo_test.go | 8 +- core/external/provider_similarsongs.go | 8 +- .../external/provider_updatealbuminfo_test.go | 2 +- .../provider_updateartistinfo_test.go | 2 +- core/library.go | 140 +++--- core/library_test.go | 110 ++--- core/lyrics/lyrics.go | 2 +- core/maintenance.go | 36 +- core/maintenance_test.go | 20 +- core/matcher/matcher.go | 10 +- core/matcher/matcher_test.go | 8 +- core/metrics/insights.go | 28 +- core/metrics/prometheus.go | 8 +- core/playback/device.go | 2 +- core/playback/playbackserver.go | 2 +- core/players.go | 10 +- core/players_test.go | 6 +- core/playlists/import.go | 14 +- core/playlists/import_test.go | 6 +- core/playlists/parse_m3u.go | 6 +- core/playlists/parse_m3u_test.go | 2 +- core/playlists/playlists.go | 61 +-- core/playlists/playlists_test.go | 6 +- core/playlists/rest_adapter.go | 45 +- core/playlists/rest_adapter_test.go | 120 ++--- core/scrobbler/buffered_scrobbler.go | 16 +- core/scrobbler/buffered_scrobbler_test.go | 24 +- core/scrobbler/play_tracker.go | 24 +- core/scrobbler/play_tracker_test.go | 42 +- core/share.go | 91 ++-- core/share_test.go | 41 +- core/sonic/sonic.go | 6 +- core/stream/decider.go | 6 +- core/stream/media_streamer.go | 2 +- core/stream/media_streamer_test.go | 6 +- core/user.go | 56 +-- core/user_test.go | 42 +- db/db.go | 2 +- go.mod | 2 +- go.sum | 18 +- model/album.go | 34 +- model/annotation.go | 13 +- model/artist.go | 23 +- model/artwork.go | 55 ++- model/bookmark.go | 11 +- model/datastore.go | 49 +- model/folder.go | 21 +- model/genre.go | 11 +- model/get_entity.go | 10 +- model/get_entity_test.go | 6 +- model/library.go | 29 +- model/mediafile.go | 51 +- model/player.go | 15 +- model/playlist.go | 54 ++- model/playqueue.go | 9 +- model/plugin.go | 21 +- model/properties.go | 10 +- model/radio.go | 16 +- model/scrobble.go | 16 +- model/scrobble_buffer.go | 17 +- model/searchable.go | 4 +- model/share.go | 12 +- model/tag.go | 9 +- model/transcoding.go | 16 +- model/user.go | 29 +- model/user_props.go | 10 +- persistence/album_repository.go | 137 +++--- persistence/album_repository_test.go | 338 +++++++------ persistence/artist_repository.go | 177 +++---- persistence/artist_repository_test.go | 434 ++++++++++------- persistence/artwork_hydration.go | 2 +- persistence/artwork_hydration_test.go | 120 ++--- persistence/artwork_queue_repository.go | 71 ++- persistence/artwork_queue_repository_test.go | 228 ++++----- persistence/artwork_repository.go | 48 +- persistence/artwork_repository_test.go | 140 +++--- persistence/criteria_sql_benchmark_test.go | 6 +- persistence/e2e/e2e_suite_test.go | 36 +- persistence/e2e/smartplaylist_test.go | 4 +- persistence/folder_repository.go | 87 ++-- persistence/folder_repository_test.go | 98 ++-- persistence/genre_repository.go | 36 +- persistence/genre_repository_test.go | 79 ++- persistence/item_tags_test.go | 35 +- persistence/library_repository.go | 136 +++--- persistence/library_repository_test.go | 81 ++-- persistence/mediafile_repository.go | 211 ++++---- persistence/mediafile_repository_test.go | 450 +++++++++--------- persistence/persistence.go | 207 ++++---- persistence/persistence_suite_test.go | 62 +-- persistence/persistence_test.go | 36 +- persistence/player_repository.go | 88 ++-- persistence/player_repository_test.go | 109 ++--- persistence/playlist_repository.go | 214 ++++----- persistence/playlist_repository_test.go | 176 +++---- persistence/playlist_track_repository.go | 140 +++--- persistence/playlist_track_repository_test.go | 153 +++--- persistence/playqueue_repository.go | 51 +- persistence/playqueue_repository_test.go | 124 ++--- persistence/plugin_cleanup_test.go | 81 ++-- persistence/plugin_repository.go | 67 ++- persistence/plugin_repository_test.go | 99 ++-- persistence/property_repository.go | 21 +- persistence/property_repository_test.go | 14 +- persistence/radio_repository.go | 97 ++-- persistence/radio_repository_test.go | 74 +-- persistence/scrobble_buffer_repository.go | 29 +- .../scrobble_buffer_repository_test.go | 54 +-- persistence/scrobble_repository.go | 53 +-- persistence/scrobble_repository_test.go | 37 +- persistence/share_repository.go | 116 +++-- persistence/share_repository_test.go | 260 +++++----- persistence/smart_playlist_repository.go | 73 +-- persistence/smart_playlist_repository_test.go | 191 ++++---- persistence/sort_index_coverage_test.go | 6 +- persistence/sql_annotations.go | 55 +-- persistence/sql_annotations_test.go | 75 +-- persistence/sql_base_repository.go | 151 +++--- persistence/sql_base_repository_test.go | 61 +-- persistence/sql_bookmarks.go | 77 +-- persistence/sql_bookmarks_test.go | 58 +-- persistence/sql_participations.go | 13 +- persistence/sql_restful.go | 14 +- persistence/sql_search.go | 21 +- persistence/sql_search_fts.go | 5 +- persistence/sql_search_fts_test.go | 42 +- persistence/sql_search_like.go | 5 +- persistence/sql_search_like_test.go | 12 +- persistence/sql_tags.go | 49 +- persistence/tag_library_filtering_test.go | 16 +- persistence/tag_repository.go | 31 +- persistence/tag_repository_test.go | 65 +-- persistence/transcoding_repository.go | 81 ++-- persistence/transcoding_repository_test.go | 78 ++- persistence/user_props_repository.go | 21 +- persistence/user_repository.go | 188 ++++---- persistence/user_repository_test.go | 218 +++++---- plugins/host_library.go | 4 +- plugins/host_library_test.go | 22 +- plugins/host_matcher_test.go | 44 +- plugins/host_scrobbleretriever.go | 6 +- plugins/host_scrobbleretriever_test.go | 28 +- plugins/host_storage_test.go | 2 +- plugins/host_subsonicapi.go | 2 +- plugins/host_subsonicapi_test.go | 33 +- plugins/host_taskqueue.go | 2 +- plugins/host_users.go | 2 +- plugins/host_users_test.go | 26 +- plugins/host_websocket.go | 2 +- plugins/manager.go | 36 +- plugins/manager_loader.go | 10 +- plugins/manager_plugin.go | 2 +- plugins/manager_readonly_test.go | 22 +- plugins/manager_sync.go | 22 +- plugins/manager_sync_test.go | 28 +- plugins/manager_watcher.go | 14 +- plugins/manager_watcher_test.go | 20 +- scanner/controller.go | 16 +- scanner/controller_test.go | 6 +- scanner/image_changes.go | 6 +- scanner/phase_1_folders.go | 54 +-- scanner/phase_2_missing_tracks.go | 18 +- scanner/phase_2_missing_tracks_test.go | 132 ++--- scanner/phase_3_refresh_albums.go | 12 +- scanner/phase_3_refresh_albums_test.go | 4 +- scanner/phase_4_playlists.go | 16 +- scanner/phase_4_playlists_test.go | 22 +- scanner/scanner.go | 32 +- scanner/scanner_benchmark_test.go | 2 +- scanner/scanner_multilibrary_test.go | 112 ++--- scanner/scanner_selective_test.go | 42 +- scanner/scanner_test.go | 148 +++--- scanner/watcher.go | 4 +- server/auth.go | 31 +- server/auth_test.go | 28 +- server/events/sse.go | 2 +- server/initial_setup.go | 18 +- server/initial_setup_test.go | 24 +- server/jellyfin/annotations.go | 12 +- server/jellyfin/annotations_test.go | 44 +- server/jellyfin/api_test.go | 13 +- server/jellyfin/auth.go | 4 +- server/jellyfin/auth_test.go | 18 +- server/jellyfin/browsing.go | 6 +- server/jellyfin/browsing_test.go | 30 +- server/jellyfin/e2e/auth_test.go | 4 +- server/jellyfin/e2e/e2e_suite_test.go | 10 +- server/jellyfin/e2e/multiuser_test.go | 4 +- server/jellyfin/e2e/playlists_test.go | 18 +- server/jellyfin/e2e/sessions_test.go | 6 +- server/jellyfin/e2e/similar_test.go | 4 +- server/jellyfin/images.go | 8 +- server/jellyfin/images_test.go | 14 +- server/jellyfin/items.go | 52 +- server/jellyfin/items_test.go | 158 +++--- server/jellyfin/lyrics_test.go | 2 +- server/jellyfin/middlewares.go | 2 +- server/jellyfin/middlewares_test.go | 10 +- server/jellyfin/playlists.go | 16 +- server/jellyfin/playlists_test.go | 14 +- server/jellyfin/quickconnect.go | 4 +- server/jellyfin/quickconnect_test.go | 7 +- server/jellyfin/similar.go | 2 +- server/jellyfin/similar_test.go | 8 +- server/jellyfin/socket_test.go | 5 +- server/jellyfin/stream.go | 2 +- server/jellyfin/stream_test.go | 38 +- server/jellyfin/system.go | 4 +- server/jellyfin/system_test.go | 8 +- server/jellyfin/users.go | 4 +- server/jellyfin/users_test.go | 14 +- server/middlewares.go | 2 +- server/middlewares_test.go | 10 +- server/nativeapi/artists.go | 16 +- server/nativeapi/config_test.go | 9 +- server/nativeapi/inspect.go | 2 +- server/nativeapi/library_test.go | 15 +- server/nativeapi/metadata_test.go | 21 +- server/nativeapi/missing.go | 29 +- server/nativeapi/missing_test.go | 5 +- server/nativeapi/native_api.go | 57 +-- server/nativeapi/native_api_song_test.go | 12 +- server/nativeapi/playlists.go | 10 +- server/nativeapi/playlists_test.go | 25 +- server/nativeapi/plugin.go | 20 +- server/nativeapi/plugin_test.go | 42 +- server/nativeapi/queue.go | 12 +- server/nativeapi/queue_test.go | 2 +- server/nativeapi/radios.go | 22 +- server/nativeapi/translations.go | 20 +- .../user_password_token_refresh_test.go | 9 +- server/public/handle_streams.go | 6 +- server/public/handle_streams_test.go | 6 +- server/serve_index.go | 2 +- server/serve_index_test.go | 3 +- server/subsonic/album_lists.go | 14 +- server/subsonic/album_lists_test.go | 20 +- server/subsonic/bookmarks.go | 30 +- server/subsonic/bookmarks_test.go | 2 +- server/subsonic/browsing.go | 20 +- server/subsonic/browsing_test.go | 8 +- server/subsonic/e2e/e2e_suite_test.go | 6 +- .../subsonic/e2e/subsonic_album_lists_test.go | 4 +- server/subsonic/e2e/subsonic_artwork_test.go | 14 +- .../subsonic/e2e/subsonic_bookmarks_test.go | 4 +- server/subsonic/e2e/subsonic_browsing_test.go | 24 +- .../e2e/subsonic_media_annotation_test.go | 14 +- .../e2e/subsonic_media_retrieval_test.go | 4 +- .../e2e/subsonic_multilibrary_test.go | 16 +- .../subsonic/e2e/subsonic_playlists_test.go | 30 +- server/subsonic/e2e/subsonic_sharing_test.go | 14 +- .../e2e/subsonic_sonic_similarity_test.go | 4 +- server/subsonic/e2e/subsonic_stream_test.go | 2 +- .../subsonic/e2e/subsonic_transcode_test.go | 22 +- server/subsonic/library_scanning.go | 4 +- server/subsonic/library_scanning_test.go | 12 +- server/subsonic/media_annotation.go | 22 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval.go | 4 +- server/subsonic/media_retrieval_test.go | 6 +- server/subsonic/middlewares.go | 4 +- server/subsonic/middlewares_test.go | 26 +- server/subsonic/radio.go | 8 +- server/subsonic/searching.go | 10 +- server/subsonic/searching_test.go | 6 +- server/subsonic/sharing.go | 22 +- server/subsonic/stream.go | 2 +- server/subsonic/transcode.go | 4 +- tests/harness/harness.go | 8 +- tests/mock_album_repo.go | 35 +- tests/mock_artist_repo.go | 37 +- tests/mock_artwork_queue_repo.go | 31 +- tests/mock_artwork_repo.go | 21 +- tests/mock_data_store.go | 123 ++--- tests/mock_genre_repo.go | 25 +- tests/mock_library_repo.go | 55 +-- tests/mock_library_service.go | 15 +- tests/mock_mediafile_repo.go | 81 ++-- tests/mock_playlist_repo.go | 37 +- tests/mock_playlist_track_repo.go | 28 +- tests/mock_playqueue_repo.go | 9 +- tests/mock_plugin_repo.go | 45 +- tests/mock_property_repo.go | 16 +- tests/mock_radio_repository.go | 25 +- tests/mock_scrobble_buffer_repo.go | 15 +- tests/mock_scrobble_repo.go | 24 +- tests/mock_share_repo.go | 16 +- tests/mock_tag_repo.go | 4 +- tests/mock_transcoding_repo.go | 10 +- tests/mock_user_props_repo.go | 16 +- tests/mock_user_repo.go | 48 +- tests/mock_user_service.go | 7 +- 339 files changed, 6220 insertions(+), 6173 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7df4f8ec3..923c06c7a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,6 +9,7 @@ linters: - asciicheck - bidichk - bodyclose + - containedctx - copyloopvar - dogsled - durationcheck @@ -57,6 +58,9 @@ linters: - gosec path: _test\.go text: "G703" + - path: _test\.go + linters: + - containedctx - path-except: 'db/migrations/' linters: - forbidigo diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index b9fb786c3..0f68bd46b 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient var track *model.MediaFile BeforeEach(func() { - _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") + _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1") httpClient = &tests.FakeHttpClient{} client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go index 1f65c059e..476daf443 100644 --- a/adapters/lastfm/auth_router_test.go +++ b/adapters/lastfm/auth_router_test.go @@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() { }) storedSessionKey := func(userID string) string { - key, _ := userProps.Get(userID, sessionKeyProperty) + key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty) return key } diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index a201b7c3a..d2aa07f1a 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() { BeforeEach(func() { ds = &tests.MockDataStore{} ctx = context.Background() - _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") + _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1") httpClient = &tests.FakeHttpClient{} agent = listenBrainzConstructor(ds) agent.client = newClient("http://localhost:8080", httpClient) diff --git a/cmd/artwork.go b/cmd/artwork.go index 9a64cd8c3..72685678e 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 { } func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { - q := ds.ArtworkQueue(ctx) + q := ds.ArtworkQueue() var rep statusReport var err error - if rep.queue, err = q.CountQueued(nil, nil); err != nil { + if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil { return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) } for _, k := range artwork.ReprocessKinds { - sources, err := q.SourcesInUse(k) + sources, err := q.SourcesInUse(ctx, k) if err != nil { return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) } slices.Sort(sources) for _, s := range sources { - n, err := q.CountBySource(k, []string{s}) + n, err := q.CountBySource(ctx, k, []string{s}) if err != nil { return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) } rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) // An absent state is exactly a row with no source, so it needs no second query. if s == "" { - failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed}) + failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed}) if err != nil { return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) } @@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error } rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() - if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil { + if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil { return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err) } return rep, nil @@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc { // validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when // it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only. -func validateSources(q model.ArtworkQueueRepository, sources []string) error { +func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error { if len(sources) == 0 { return nil } var inUse []string for _, k := range artwork.ReprocessKinds { - found, err := q.SourcesInUse(k) + found, err := q.SourcesInUse(ctx, k) if err != nil { return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) } @@ -475,8 +475,8 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { // actually inserted; the two differ because an already-queued row is left untouched. func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string, imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error { - q := ds.ArtworkQueue(ctx) - if err := validateSources(q, sources); err != nil { + q := ds.ArtworkQueue() + if err := validateSources(ctx, q, sources); err != nil { return err } @@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin matched := make([]int64, len(kinds)) var total, external int64 for i, k := range kinds { - n, err := q.CountBySource(k, sources) + n, err := q.CountBySource(ctx, k, sources) if err != nil { return fmt.Errorf("counting %s artwork: %w", k, err) } @@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin if matched[i] == 0 { continue } - n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck) + n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck) if err != nil { return fmt.Errorf("queueing %s artwork: %w", k, err) } @@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int, dryRun bool, confirm confirmFunc, out io.Writer) error { - q := ds.ArtworkQueue(ctx) - matched, err := q.CountQueued(kinds, priorities) + q := ds.ArtworkQueue() + matched, err := q.CountQueued(ctx, kinds, priorities) if err != nil { return fmt.Errorf("counting queued artwork: %w", err) } @@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, return nil } - cancelled, err := q.PurgeQueued(kinds, priorities) + cancelled, err := q.PurgeQueued(ctx, kinds, priorities) if err != nil { return fmt.Errorf("cancelling queued artwork: %w", err) } @@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) { } rep := explainReport{kind: kind, id: id, name: name} if artwork.KeepsState(kind) { - rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err) } - rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary) + rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err) } diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 71b450914..df0ea0665 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -491,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() { It("names the absent state as reprocess --source accepts it", func() { ds := &tests.MockDataStore{} - art := ds.Artwork(ctx).(*tests.MockArtworkRepo) - Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), + art := ds.Artwork().(*tests.MockArtworkRepo) + Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed()) shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1", stored: &model.ItemArtwork{AttemptedAt: time.Now()}})) - q := ds.ArtworkQueue(ctx) - Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(), + q := ds.ArtworkQueue() + Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(), "explain's spelling of a source must be pasteable into --source") - Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), + Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), "a parenthesised name would be rejected, so explain must not print one") }) }) @@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() { decline := func(io.Writer, int64, int64) bool { return false } put := func(kind model.Kind, id, source string) { - Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, + Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed()) } @@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() { conf.Server.EnableM3UExternalAlbumArt = false imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1} ds = &tests.MockDataStore{} - art = ds.Artwork(ctx).(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + art = ds.Artwork().(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) out.Reset() put(model.KindArtistArtwork, "ar-1", "external:deezer") put(model.KindArtistArtwork, "ar-2", "") @@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() { Expect(out.String()).To(ContainSubstring("album")) Expect(out.String()).To(ContainSubstring("TOTAL")) Expect(out.String()).To(ContainSubstring("Dry run")) - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) }) It("queues nothing when the operator declines", func() { Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Aborted")) - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) }) DescribeTable("records the applied config only for a run that leaves nothing on the old one", func(selected []model.Kind, sources []string, dryRun, applied bool) { - Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) @@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() { if applied { want = artwork.ConfigFingerprint() } - Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) + Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) }, Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), @@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() { It("queues the matching items at recheck priority, leaving their artwork state alone", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) - Expect(queue.Count()).To(Equal(int64(2))) - queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(queue.Count(ctx)).To(Equal(int64(2))) + queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck)) - _, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + _, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued") - stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork") }) @@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() { It("targets the absent state", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed()) - Expect(queue.Count()).To(Equal(int64(1))) - _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(queue.Count(ctx)).To(Equal(int64(1))) + _, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) }) It("reports matched and queued separately when part of the set is already queued", func() { - Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", + Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed()) Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items")) Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1")) - queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump), "an already-queued row keeps its priority and backoff") @@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() { }, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Nothing")) - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) }) It("reports an empty selection as a dry run when one was asked for", func() { @@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() { Expect(err.Error()).To(ContainSubstring("external:deezer")) Expect(err.Error()).To(ContainSubstring("folder")) Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name") - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) }) It("accepts the absent filter with nothing absent, still rejecting a typo", func() { @@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() { imageAgents, false, accept, &out)).To(Succeed(), "a reserved source must stay valid once the library has none of it") Expect(out.String()).To(ContainSubstring("Nothing matches")) - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}), imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") @@ -797,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() { Expect(out.String()).To(ContainSubstring("Nothing matches"), "a well-formed filter must not be reported as a typo because of the kinds selected") - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) }) }) @@ -816,19 +816,19 @@ var _ = Describe("collectStatus", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - art = ds.Artwork(ctx).(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + art = ds.Artwork().(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) put := func(kind model.Kind, id, source, hash string, attempted time.Time) { - Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, + Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) // ar-3 is absent because it gave up, so the two absent artists split across the columns. - Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", + Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) - Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", + Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) }) @@ -848,7 +848,7 @@ var _ = Describe("collectStatus", func() { }) It("compares the stored fingerprint against the current one", func() { - Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) rep, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() { It("queues nothing", func() { _, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) - Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything") + Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything") }) }) @@ -973,21 +973,21 @@ var _ = Describe("refreshItems", func() { albums := tests.CreateMockAlbumRepo() albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}}) ds = &tests.MockDataStore{MockedAlbum: albums} - art = ds.Artwork(ctx).(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + art = ds.Artwork().(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) out.Reset() }) It("clears the stored state and queues each id at Bump priority", func() { - Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), + Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) Expect(refreshItems(ctx, ds, []model.ArtworkID{ {Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero()) - _, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + _, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) - queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump)) Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n")) @@ -996,7 +996,7 @@ var _ = Describe("refreshItems", func() { It("skips an id that does not exist instead of queuing it", func() { Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1)) - _, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + _, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") Expect(out.String()).To(BeEmpty()) }) @@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) out.Reset() - Expect(queue.Enqueue( + Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill}, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary, @@ -1164,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() { Expect(out.String()).To(ContainSubstring("backfill")) Expect(out.String()).To(ContainSubstring("TOTAL")) Expect(out.String()).To(ContainSubstring("Dry run")) - Expect(queue.Count()).To(BeNumerically("==", 3)) + Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) }) It("cancels nothing when the operator declines", func() { Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Aborted")) - Expect(queue.Count()).To(BeNumerically("==", 3)) + Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) }) It("deletes the selected rows and leaves the rest queued", func() { Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed()) - Expect(queue.Count()).To(BeNumerically("==", 1)) - _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(queue.Count(ctx)).To(BeNumerically("==", 1)) + _, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued") Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items.")) }) It("cancels every kind and priority when neither filter is given", func() { Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed()) - Expect(queue.Count()).To(BeZero()) + Expect(queue.Count(ctx)).To(BeZero()) }) It("stops at a selection that matches nothing instead of prompting", func() { @@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() { Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Nothing matches this selection.")) - Expect(queue.Count()).To(BeNumerically("==", 3)) + Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) }) It("reports a queue read failure instead of reporting nothing to cancel", func() { diff --git a/cmd/missing.go b/cmd/missing.go index 65f571661..ce95e39f0 100644 --- a/cmd/missing.go +++ b/cmd/missing.go @@ -72,7 +72,7 @@ func runMissingList(ctx context.Context) { } ds, ctx := getAdminContext(ctx) - mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{ + mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, Sort: "path", }) @@ -128,7 +128,7 @@ func runMissingFix(ctx context.Context, missingRef, targetRef string) { // resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path). func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile { - mf, err := ds.MediaFile(ctx).Get(ref) + mf, err := ds.MediaFile().Get(ctx, ref) if err == nil { return mf } @@ -136,7 +136,7 @@ func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *mode log.Fatal(ctx, "Error looking up media file", "ref", ref, err) } - mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref}) + mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref}) if err != nil { log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err) } diff --git a/cmd/pls.go b/cmd/pls.go index 93b411483..bf16ea420 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -109,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model. } options.Filters = squirrel.Eq{"owner_id": user.ID} } - pls, err := ds.Playlist(ctx).GetAll(options) + pls, err := ds.Playlist().GetAll(ctx, options) if err != nil { log.Fatal(ctx, "Failed to retrieve playlists", err) } @@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model. } func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { - playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false) + playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) + playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) if err != nil { log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if len(playlists) > 0 { - playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) + playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false) if err != nil { log.Fatal("Error retrieving playlist", "name", nameOrID, err) } @@ -194,7 +194,7 @@ func runExport(ctx context.Context) { exported := 0 for _, pls := range allPls { - plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false) if err != nil { log.Error("Error loading playlist tracks", "playlist", pls.Name, err) continue diff --git a/cmd/plugin.go b/cmd/plugin.go index ded28e969..7b8a9a393 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) { } requirePluginsEnabled(ctx) ds, ctx := getAdminContext(ctx) - p, err := ds.Plugin(ctx).Get(arg) + p, err := ds.Plugin().Get(ctx, arg) if err != nil { log.Fatal(ctx, "Plugin not found", "id", arg, err) } @@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) { } requirePluginsEnabled(ctx) ds, ctx := getAdminContext(ctx) - p, err := ds.Plugin(ctx).Get(arg) + p, err := ds.Plugin().Get(ctx, arg) if err != nil { log.Fatal(ctx, "Plugin not found", "id", arg, err) } @@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) { func runPluginList(ctx context.Context) { requirePluginsEnabled(ctx) ds, ctx := getAdminContext(ctx) - list, err := ds.Plugin(ctx).GetAll() + list, err := ds.Plugin().GetAll(ctx) if err != nil { log.Fatal(ctx, "Failed to list plugins", err) } @@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { requirePluginsEnabled(cmd.Context()) ds, ctx := getAdminContext(cmd.Context()) - cur, err := ds.Plugin(ctx).Get(args[0]) + cur, err := ds.Plugin().Get(ctx, args[0]) if err != nil { log.Fatal(ctx, "Plugin not found", "id", args[0], err) } diff --git a/cmd/root.go b/cmd/root.go index 02cd30240..b23674441 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -184,11 +184,11 @@ func schedulePeriodicScan(ctx context.Context) func() error { } func pidHashChanged(ds model.DataStore) (bool, error) { - pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "") + pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "") if err != nil { return false, err } - pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "") + pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "") if err != nil { return false, err } @@ -199,11 +199,11 @@ func pidHashChanged(ds model.DataStore) (bool, error) { func runInitialScan(ctx context.Context) func() error { return func() error { ds := CreateDataStore() - fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0") + fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0") if err != nil { return err } - inProgress, err := ds.Library(ctx).ScanInProgress() + inProgress, err := ds.Library().ScanInProgress(ctx) if err != nil { return err } @@ -219,7 +219,7 @@ func runInitialScan(ctx context.Context) func() error { switch { case fullScanRequired == "1": log.Warn(ctx, "Full scan required after migration") - _ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey) + _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) case pidHasChanged: log.Warn(ctx, "PID config changed, performing full scan") fullScanRequired = "1" diff --git a/cmd/svc.go b/cmd/svc.go index 7fec708ff..c71f5ef2b 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{ } type svcControl struct { - ctx context.Context + ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop cancel context.CancelFunc done chan struct{} } diff --git a/cmd/user.go b/cmd/user.go index 1abf157b7..eb64e69fe 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) { ds, ctx := getAdminContext(ctx) err := ds.WithTx(func(tx model.DataStore) error { - existingUser, err := tx.User(ctx).FindByUsername(userID) + existingUser, err := tx.User().FindByUsername(ctx, userID) if existingUser != nil { return fmt.Errorf("existing user '%s'", userID) } @@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) { } if len(libraryIds) > 0 && !setAdmin { - user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) + user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) if err != nil { return err } @@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) { return libraryError(user.Libraries) } } else { - user.Libraries, err = tx.Library(ctx).GetAll() + user.Libraries, err = tx.Library().GetAll(ctx) if err != nil { return err } } - err = tx.User(ctx).Put(&user) + err = tx.User().Put(ctx, &user) if err != nil { return err } @@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) { updatedIds[idx] = lib.ID } - err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds) + err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds) return err }) @@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) { var user *model.User err = ds.WithTx(func(tx model.DataStore) error { - count, err := tx.User(ctx).CountAll() + count, err := tx.User().CountAll(ctx) if err != nil { return err } @@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) { return err } - return tx.User(ctx).Delete(user.ID) + return tx.User().Delete(ctx, user.ID) }) if err != nil { @@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) { } if len(libraryIds) > 0 && !setAdmin { - libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) + libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) if err != nil { return err @@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) { } if setAdmin && !user.IsAdmin { - libraries, err := tx.Library(ctx).GetAll() + libraries, err := tx.Library().GetAll(ctx) if err != nil { return err } @@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) { return nil } - err := tx.User(ctx).Put(user) + err := tx.User().Put(ctx, user) if err != nil { return err } @@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) { updatedIds[idx] = lib.ID } - err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds) + err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds) if err != nil { return err } @@ -393,13 +393,11 @@ func runUserList(ctx context.Context) { ds, ctx := getAdminContext(ctx) - users, err := ds.User(ctx).ReadAll() + userList, err := ds.User().ReadAll(ctx) if err != nil { log.Fatal(ctx, "Failed to retrieve users", err) } - userList := users.(model.Users) - if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) _ = w.Write([]string{ diff --git a/cmd/utils.go b/cmd/utils.go index a67f720ac..72ec67f90 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -52,14 +52,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) { } func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) { - user, err := ds.User(ctx).FindByUsername(id) + user, err := ds.User().FindByUsername(ctx, id) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, fmt.Errorf("finding user by name: %w", err) } if errors.Is(err, model.ErrNotFound) { - user, err = ds.User(ctx).Get(id) + user, err = ds.User().Get(ctx, id) if err != nil { return nil, fmt.Errorf("finding user by id: %w", err) } diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index 1cb9060a1..c777ab46d 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string { } func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { - top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{ Sort: "playCount", Order: "desc", Max: count, @@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid } func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - seed, err := p.ds.MediaFile(ctx).Get(id) + seed, err := p.ds.MediaFile().Get(ctx, id) if err != nil { return nil, err } @@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis return nil, nil } // Ask for extra so we can drop the seed itself and still fill the count. - candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{ Filters: squirrel.And{ persistence.SongGenres.ByID(genreIDs), squirrel.Eq{"missing": false}, diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index cea6005ff..1eb414b15 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -13,13 +13,13 @@ type SessionKeys struct { } func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { - return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey) + return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey) } func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) { - return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName) + return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName) } func (sk *SessionKeys) Delete(ctx context.Context, userId string) error { - return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName) + return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName) } diff --git a/core/archiver.go b/core/archiver.go index 33236d889..6f362322a 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -62,7 +62,7 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr // rootArt, when set, is added to the archive root. func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error { - mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"}) + mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"}) if err != nil { log.Error(ctx, "Error loading mediafiles from artist", "id", id, err) return err @@ -189,7 +189,7 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false) + pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false) if err != nil { log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err) return err diff --git a/core/archiver_test.go b/core/archiver_test.go index 9dab44cef..4e00ce78c 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -52,7 +52,7 @@ var _ = Describe("Archiver", func() { Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) + ds.On("MediaFile").Return(mfRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) @@ -84,7 +84,7 @@ var _ = Describe("Archiver", func() { Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) + ds.On("MediaFile").Return(mfRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) @@ -246,7 +246,7 @@ var _ = Describe("Archiver", func() { Filters: squirrel.Eq{"album_id": "1"}, Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) + ds.On("MediaFile").Return(mfRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}). Return(nil, stream.ErrTooManyTranscodes).Once() @@ -310,7 +310,7 @@ var _ = Describe("Archiver", func() { plRepo := &mockPlaylistRepository{} plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) - ds.On("Playlist", mock.Anything).Return(plRepo) + ds.On("Playlist").Return(plRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) @@ -515,17 +515,17 @@ type mockDataStore struct { model.DataStore } -func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { - args := m.Called(ctx) +func (m *mockDataStore) MediaFile() model.MediaFileRepository { + args := m.Called() return args.Get(0).(model.MediaFileRepository) } -func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { - args := m.Called(ctx) +func (m *mockDataStore) Playlist() model.PlaylistRepository { + args := m.Called() return args.Get(0).(model.PlaylistRepository) } -func (m *mockDataStore) Library(context.Context) model.LibraryRepository { +func (m *mockDataStore) Library() model.LibraryRepository { return &mockLibraryRepository{} } @@ -534,7 +534,7 @@ type mockLibraryRepository struct { model.LibraryRepository } -func (m *mockLibraryRepository) GetPath(id int) (string, error) { +func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) { return "/music", nil } @@ -543,7 +543,7 @@ type mockMediaFileRepository struct { model.MediaFileRepository } -func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { args := m.Called(options) return args.Get(0).(model.MediaFiles), args.Error(1) } @@ -553,7 +553,7 @@ type mockPlaylistRepository struct { model.PlaylistRepository } -func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) { +func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) { args := m.Called(id, refreshSmartPlaylists, includeMissing) return args.Get(0).(*model.Playlist), args.Error(1) } diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index e27fa118e..2a9ebe771 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID var err error switch artID.Kind { case model.KindArtistArtwork: - found, err = ds.Artist(ctx).Exists(artID.ID) + found, err = ds.Artist().Exists(ctx, artID.ID) case model.KindAlbumArtwork: - found, err = ds.Album(ctx).Exists(artID.ID) + found, err = ds.Album().Exists(ctx, artID.ID) case model.KindMediaFileArtwork: - found, err = ds.MediaFile(ctx).Exists(artID.ID) + found, err = ds.MediaFile().Exists(ctx, artID.ID) case model.KindPlaylistArtwork: - found, err = ds.Playlist(ctx).Exists(artID.ID) + found, err = ds.Playlist().Exists(ctx, artID.ID) case model.KindRadioArtwork: - found, err = ds.Radio(ctx).Exists(artID.ID) + found, err = ds.Radio().Exists(ctx, artID.ID) case model.KindDiscArtwork: albumID, _, perr := model.ParseDiscArtworkID(artID.ID) if perr != nil { return false } - found, err = ds.Album(ctx).Exists(albumID) + found, err = ds.Album().Exists(ctx, albumID) default: return false } @@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa } func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary) + ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary) switch { case errors.Is(err, model.ErrNotFound): return s.provisional(ctx, artID, size, square) @@ -173,7 +173,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath) return s.dangling(ctx, artID) } - art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) + art, err := s.ds.Artwork().GetImage(ctx, ia.Hash) if err != nil { if errors.Is(err, model.ErrNotFound) { return s.dangling(ctx, artID) @@ -264,13 +264,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz // The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL // must fall back to disc/album instead of serving stale persisted embedded art. if !conf.Server.EnableMediaFileCoverArt { - mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + mf, err := s.ds.MediaFile().Get(ctx, artID.ID) if err != nil { return nil, err } return s.Get(ctx, mf.DiscCoverArtID(), size, square) } - ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary) + ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary) switch { case err == nil && ia.Hash != "": return s.serveHash(ctx, artID, ia, size, square) @@ -283,7 +283,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz } noRow := errors.Is(err, model.ErrNotFound) - mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + mf, err := s.ds.MediaFile().Get(ctx, artID.ID) if err != nil { return nil, err } @@ -342,7 +342,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, } func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { - err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary, diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 93aacc1fa..921a77b89 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,6 +1,7 @@ package artwork import ( + "context" "io/fs" "net/netip" "net/url" @@ -108,15 +109,15 @@ type fakeFolderRepo struct { otherAudioErr error } -func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { +func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) { return f.result, f.err } -func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { +func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) { return f.hasOtherAudio, f.otherAudioErr } -func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { +func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) { f.getCallCount++ if f.getErr != nil { return nil, f.getErr diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 8b35a872e..2e8ed070e 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() { hash, err := hashImage(bytes.NewReader(imgBytes)) Expect(err).ToNot(HaveOccurred()) Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed()) - Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) seedEntity(kind, id) return hash } @@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() { GinkgoHelper() switch kind { case "al": - Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed()) case "mf": - Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed()) + Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed()) } } @@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() { imgPath := filepath.Join(dir, "cover.jpg") Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) mtime := fileMtime(imgPath) - Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) seedEntity("al", "al2") - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa", Source: "folder", SourcePath: imgPath, RefMtime: mtime, })).To(Succeed()) @@ -163,9 +163,9 @@ var _ = Describe("Artwork", func() { dir := GinkgoT().TempDir() secretPath := filepath.Join(dir, "config.ini") Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) - Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) seedEntity("al", "alni") - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd", Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), })).To(Succeed()) @@ -180,9 +180,9 @@ var _ = Describe("Artwork", func() { dir := GinkgoT().TempDir() secretPath := filepath.Join(dir, "config.ini") Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed()) - Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) seedEntity("al", "alnic") - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee", Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), })).To(Succeed()) @@ -210,9 +210,9 @@ var _ = Describe("Artwork", func() { dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) seedEntity("al", "al3") - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb", Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, })).To(Succeed()) @@ -220,7 +220,7 @@ var _ = Describe("Artwork", func() { _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false) Expect(err).To(MatchError(ErrUnavailable)) Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb")) }) @@ -229,9 +229,9 @@ var _ = Describe("Artwork", func() { dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) seedEntity("al", "al3b") - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc", Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, })).To(Succeed()) @@ -252,7 +252,7 @@ var _ = Describe("Artwork", func() { }) It("never re-enqueues an absent state on view, however old", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), })).To(Succeed()) @@ -272,7 +272,7 @@ var _ = Describe("Artwork", func() { Expect(readAll(img)).To(Equal(coverBytes)) Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -283,7 +283,7 @@ var _ = Describe("Artwork", func() { _, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false) Expect(err).To(MatchError(ErrUnavailable)) Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -310,7 +310,7 @@ var _ = Describe("Artwork", func() { It("delegates to the album when the track's state is absent", func() { seedFoundStore("al", "albm", coverBytes) - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}}) img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false) @@ -343,7 +343,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) Expect(len(readAll(img))).To(BeNumerically(">", 0)) Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -494,7 +494,7 @@ var _ = Describe("Artwork", func() { }) It("falls back to the artist placeholder for an absent artist", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false) Expect(err).ToNot(HaveOccurred()) @@ -535,7 +535,7 @@ var _ = Describe("EntityExists", func() { artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar1"}}) radioRepo := tests.CreateMockedRadioRepo() - Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed()) + Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed()) ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo} }) diff --git a/core/artwork/disc.go b/core/artwork/disc.go index acd8a3740..a050f6685 100644 --- a/core/artwork/disc.go +++ b/core/artwork/disc.go @@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) } - al, err := ds.Album(ctx).Get(albumID) + al, err := ds.Album().Get(ctx, albumID) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A } // Query mediafiles for this album + disc to find folder associations and first track - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Sort: "track_number", Order: "ASC", Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, @@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A // Resolve folder IDs to library-relative paths discFoldersRel := make(map[string]bool) if len(folderIDs) > 0 { - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"folder.id": folderIDs}, }) if err != nil { diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index 34dfb2cac..8cfa54113 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() { itemFound := func(kind model.Kind, id string) func() bool { return func() bool { - ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) return err == nil && ia.Hash != "" } } itemAbsent := func(kind model.Kind, id string) func() bool { return func() bool { - ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) return err == nil && ia.Hash == "" } } // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. bump := func(kind, id string) { GinkgoHelper() - Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, })).To(Succeed()) @@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() { bump("al", "al1") runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder")) @@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() { bump("ar", "ar1") runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1")) - ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("upload")) @@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() { bump("pl", "pl1") runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1")) - ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("generated")) img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) Expect(err).ToNot(HaveOccurred()) Expect(img.Hash).To(Equal(ia.Hash)) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.Mime).To(Equal("image/png")) Expect(len(readAll(img))).To(BeNumerically(">", 0)) @@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() { bump("ra", "ra1") runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("upload")) @@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() { provisionalBytes := readAll(provisional) Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) - _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") // The provisional read enqueued a Bump; drain it. runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1")) - ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("embedded")) Expect(ia.Hash).To(Equal(provisional.Hash)) @@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() { bump("al", "al1") runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.Mime).To(Equal("image/jpeg")) Expect(art.Width).To(BeNumerically(">", 0)) @@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() { bump("ra", "ra1") runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.Mime).To(Equal("image/gif")) Expect(art.Width).To(BeNumerically("==", 4)) @@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() { return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")() }) - ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) - ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) + ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash") Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes)) @@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() { bump("ra", "ra1") runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) staleHash := ia.Hash @@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() { // That failed read enqueued a re-resolution. runWorkerUntil(ctx, worker, func() bool { - cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) return gerr == nil && cur.Hash != "" && cur.Hash != staleHash }) img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index 4a6959d95..38c28a9b2 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -201,7 +201,7 @@ var _ = Describe("Artist artwork resolution", func() { uploaded := ar.ID + "_upload.jpg" writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded")) ar.UploadedImage = uploaded - Expect(rds.Artist(rctx).Put(&ar)).To(Succeed()) + Expect(rds.Artist().Put(rctx, &ar)).To(Succeed()) ia := acquire(model.KindArtistArtwork, ar.ID) Expect(ia.Source).To(Equal("upload")) @@ -279,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() { func soleArtist() model.Artist { GinkgoHelper() - artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{ + artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{ Filters: squirrel.Eq{"artist.name": "Artist"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go index 390be14a7..d03e22228 100644 --- a/core/artwork/e2e/e2e_suite_test.go +++ b/core/artwork/e2e/e2e_suite_test.go @@ -67,13 +67,17 @@ type fakeFolderRepo struct { result []model.Folder } -func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil } +func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) { + return f.result, nil +} -func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { +func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) { return false, nil } -func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound } +func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) { + return nil, model.ErrNotFound +} func writeUpload(entityType, name, srcFixture string) string { GinkgoHelper() diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go index d26756dcd..3ddb9dc48 100644 --- a/core/artwork/e2e/mediafile_test.go +++ b/core/artwork/e2e/mediafile_test.go @@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() { func mediafileOn(relPath string) model.MediaFile { GinkgoHelper() - mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{ + mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{ Filters: squirrel.Like{"media_file.path": relPath}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go index f5ac8a7e3..0d862b20e 100644 --- a/core/artwork/e2e/playlist_test.go +++ b/core/artwork/e2e/playlist_test.go @@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() { }) scan() - mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{}) + mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) - Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) ia := acquire(model.KindPlaylistArtwork, pl.ID) Expect(ia.Source).To(Equal("generated")) @@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() { setLayout(layout) scan() - mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{}) + mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(4)) ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"} pl.AddMediaFilesByID(ids) - Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) ia := acquire(model.KindPlaylistArtwork, pl.ID) Expect(ia.Source).To(Equal("generated")) @@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist { if pl.OwnerID == "" { pl.OwnerID = "admin-1" } - Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) return pl } diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go index bba85224a..72af215d1 100644 --- a/core/artwork/e2e/radio_test.go +++ b/core/artwork/e2e/radio_test.go @@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() { It("returns the uploaded image bytes", func() { writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo")) rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} - Expect(rds.Radio(rctx).Put(&rd)).To(Succeed()) + Expect(rds.Radio().Put(rctx, &rd)).To(Succeed()) ia := acquire(model.KindRadioArtwork, rd.ID) Expect(ia.Source).To(Equal("upload")) @@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() { // (no files on disk — the resolver has no sources to fall back to) It("settles absent", func() { rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(rds.Radio(rctx).Put(&rd)).To(Succeed()) + Expect(rds.Radio().Put(rctx, &rd)).To(Succeed()) ia := acquire(model.KindRadioArtwork, rd.ID) Expect(ia.Hash).To(BeEmpty()) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go index fff62ce69..fbd89925e 100644 --- a/core/artwork/e2e/resolution_harness_test.go +++ b/core/artwork/e2e/resolution_harness_test.go @@ -99,11 +99,11 @@ func setupResolutionHarness() { rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} - Expect(rds.User(rctx).Put(&adminUser)).To(Succeed()) + Expect(rds.User().Put(rctx, &adminUser)).To(Succeed()) lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} - Expect(rds.Library(rctx).Put(&lib)).To(Succeed()) - Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(rds.Library().Put(rctx, &lib)).To(Succeed()) + Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed()) loadEmbeddedFixture() @@ -140,13 +140,13 @@ func scan() { func acquire(kind model.Kind, id string) model.ItemArtwork { GinkgoHelper() // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{ ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, })).To(Succeed()) var ia *model.ItemArtwork runResolutionWorkerUntil(func() bool { - got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary) if err != nil { return false } @@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) { // A drain settles every ready item, so byte-level folder assertions must precede any acquire. func requireNoStateRow(kind model.Kind, id string) { GinkgoHelper() - _, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + _, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "assert %s %q before acquiring any other entity in this spec", kind, id) } @@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA { // Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath. func storedBytes(ia model.ItemArtwork) []byte { GinkgoHelper() - art, err := rds.Artwork(rctx).GetImage(ia.Hash) + art, err := rds.Artwork().GetImage(rctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) r, err := rstore.Open(ia.Hash, art.Mime) Expect(err).ToNot(HaveOccurred()) @@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) { func firstAlbum() model.Album { GinkgoHelper() - albums, err := rds.Album(rctx).GetAll(model.QueryOptions{}) + albums, err := rds.Album().GetAll(rctx, model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) return albums[0] @@ -353,7 +353,7 @@ func firstAlbum() model.Album { func albumByName(name string) model.Album { GinkgoHelper() - albums, err := rds.Album(rctx).GetAll(model.QueryOptions{}) + albums, err := rds.Album().GetAll(rctx, model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) for _, al := range albums { if al.Name == name { diff --git a/core/artwork/folders_album.go b/core/artwork/folders_album.go index 88f0181a3..7b423adb6 100644 --- a/core/artwork/folders_album.go +++ b/core/artwork/folders_album.go @@ -36,7 +36,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model. } func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) { - return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) + return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) } // folderImages collects the folders' image files, sorted so files without @@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo if len(folders) < 2 && anyFolderHasImages(folders) { return nil, nil } - parent, err := ds.Folder(ctx).Get(commonParentID) + parent, err := ds.Folder().Get(ctx, commonParentID) if errors.Is(err, model.ErrNotFound) { log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID) return nil, nil @@ -91,7 +91,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo // The library root can never be an album root return nil, nil } - hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs) + hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs) if err != nil { return nil, err } diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go index efef42f81..3403935db 100644 --- a/core/artwork/folders_artist.go +++ b/core/artwork/folders_artist.go @@ -169,14 +169,14 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu } // Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".". - libPath, _ := ds.Library(ctx).GetPath(libID) + libPath, _ := ds.Library().GetPath(ctx, libID) libPath = filepath.Join(libPath) folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, "libPath", libPath, "libID", libID, "albumPaths", paths) - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) + folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) if err != nil || len(folders) == 0 { log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID, "libPath", libPath, "libID", libID, err) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 3ca452bdc..5ec701541 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -70,7 +70,7 @@ func ConfigFingerprint() string { // resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { current := ConfigFingerprint() - stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "") + stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "") if err != nil { return err } @@ -89,14 +89,14 @@ func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { // MarkConfigApplied records the current fingerprint as the one the library is resolved under. func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { - return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) + return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) } // enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { - queue := ds.ArtworkQueue(ctx) + queue := ds.ArtworkQueue() for _, kind := range ReprocessKinds { - if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { + if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil { return err } } @@ -108,31 +108,31 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { switch kind { case model.KindArtistArtwork: - ar, err := ds.Artist(ctx).Get(id) + ar, err := ds.Artist().Get(ctx, id) if err != nil { return "", err } return ar.Name, nil case model.KindAlbumArtwork: - al, err := ds.Album(ctx).Get(id) + al, err := ds.Album().Get(ctx, id) if err != nil { return "", err } return al.Name, nil case model.KindPlaylistArtwork: - pls, err := ds.Playlist(ctx).Get(id) + pls, err := ds.Playlist().Get(ctx, id) if err != nil { return "", err } return pls.Name, nil case model.KindRadioArtwork: - rd, err := ds.Radio(ctx).Get(id) + rd, err := ds.Radio().Get(ctx, id) if err != nil { return "", err } return rd.Name, nil case model.KindMediaFileArtwork: - mf, err := ds.MediaFile(ctx).Get(id) + mf, err := ds.MediaFile().Get(ctx, id) if err != nil { return "", err } @@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string if err != nil { return "", err } - al, err := ds.Album(ctx).Get(albumID) + al, err := ds.Album().Get(ctx, albumID) if err != nil { return "", err } @@ -162,11 +162,11 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string // Refresh drops an item's resolved artwork state and re-queues it at Bump priority. func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { - if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil { + if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil { return fmt.Errorf("clearing artwork state: %w", err) } item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} - if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil { return fmt.Errorf("enqueuing artwork refresh: %w", err) } return nil diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 2027cba9a..0f501d0b1 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() { It("records the current fingerprint when none was ever stored", func() { Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) + Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) }) It("leaves a stale fingerprint stored, so the warning survives a restart", func() { - Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) + Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) }) }) @@ -153,7 +153,7 @@ var _ = Describe("ItemName", func() { {ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}, }) ds = &tests.MockDataStore{MockedAlbum: albumRepo} - Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) }) It("returns the album name", func() { diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index 6e6099325..84e088c68 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -30,7 +30,7 @@ func (v libraryView) Abs(rel string) string { // loadLibraryView resolves the MusicFS and absolute root path in a single // library lookup. func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) { - lib, err := ds.Library(ctx).Get(libID) + lib, err := ds.Library().Get(ctx, libID) if err != nil { return libraryView{}, err } diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go index 22498e7a1..868c5e45a 100644 --- a/core/artwork/library_fs_test.go +++ b/core/artwork/library_fs_test.go @@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() { }) It("returns a view for a library backed by registered storage", func() { - Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) + Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) lib, err := loadLibraryView(ctx, ds, 1) Expect(err).ToNot(HaveOccurred()) @@ -45,7 +45,7 @@ var _ = Describe("loadLibraryView", Ordered, func() { }) It("returns an error when the library path uses an unregistered scheme", func() { - Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) + Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) _, err := loadLibraryView(ctx, ds, 2) Expect(err).To(HaveOccurred()) }) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index cf2176775..78e625e94 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -82,7 +82,7 @@ type processor struct { // acquire resolves one queue item end to end: find an image, hash/decode/ // blurhash it, place its bytes, and persist the resulting state. func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { - repo := p.ds.Artwork(ctx) + repo := p.ds.Artwork() start := time.Now() defer func() { log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID, @@ -137,7 +137,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) - art, err := repo.GetImage(hash) + art, err := repo.GetImage(ctx, hash) switch { case err == nil && art.Width > 0: log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, @@ -195,7 +195,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i if err != nil { return nil, fmt.Errorf("writing image store: %w", err) } - if err := repo.PutImage(art); err != nil { + if err := repo.PutImage(ctx, art); err != nil { return nil, fmt.Errorf("persisting artwork image: %w", err) } ia := &model.ItemArtwork{ @@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i Trace: traceFrom(ctx).encode(sourcePath), } // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. - if err := repo.PutItemArtwork(ia); err != nil { + if err := repo.PutItemArtwork(ctx, ia); err != nil { return nil, fmt.Errorf("persisting item artwork state: %w", err) } return ia, nil @@ -218,7 +218,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i // writeAbsent records a known-absent state: every source answered definitively "no". func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome { - err := repo.PutItemArtwork(&model.ItemArtwork{ + err := repo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: item.ItemKind, ItemID: item.ItemID, ImageType: item.ImageType, diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 554ca08dc..8eec97cde 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(out).To(Equal(outcomeFound)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).ToNot(BeEmpty()) Expect(ia.Source).To(Equal("folder")) Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) Expect(ia.RefMtime).To(BeNumerically(">", 0)) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) // Every placeholder is derived from the one shared thumbnail, so all three land together. Expect(art.BlurHash).ToNot(BeEmpty()) @@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(out).To(Equal(outcomeFound)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("embedded")) Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.BlurHash).ToNot(BeEmpty()) @@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(out).To(Equal(outcomeAbsent)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(BeEmpty()) Expect(ia.Source).To(BeEmpty()) @@ -200,7 +200,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) Expect(out).To(Equal(outcomeFailed)) - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent") }) @@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) Expect(out).To(Equal(outcomeFailed)) - _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") }) @@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() { Expect(out).To(Equal(outcomeFailed)) Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).ToNot(BeEmpty()) Expect(ia.Source).To(Equal("folder")) @@ -316,10 +316,10 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) Expect(out).To(Equal(outcomeFound)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder")) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.Width).To(BeZero()) Expect(art.BlurHash).To(BeEmpty()) @@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) Expect(out).To(Equal(outcomeFailed)) - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) Expect(out).To(Equal(outcomeFailed)) - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) Expect(out).To(Equal(outcomeFound)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("external:deezerFake")) Expect(ia.Hash).ToNot(BeEmpty()) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) rc, err := store.Open(ia.Hash, art.Mime) Expect(err).ToNot(HaveOccurred()) @@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() { out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(out1).To(Equal(outcomeFound)) - ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) + ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) // A re-decode instead of a hash dedup would overwrite this sentinel. @@ -407,11 +407,11 @@ var _ = Describe("processor.acquire", func() { out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) Expect(out2).To(Equal(outcomeFound)) - ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) + ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia2.Hash).To(Equal(ia1.Hash)) - reused, err := artRepo.GetImage(ia1.Hash) + reused, err := artRepo.GetImage(ctx, ia1.Hash) Expect(err).ToNot(HaveOccurred()) Expect(reused.BlurHash).To(Equal("SENTINEL")) }) @@ -437,7 +437,7 @@ var _ = Describe("processor.acquire", func() { folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) Expect(outN).To(Equal(outcomeFound)) - iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) + iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(iaA.Source).To(Equal("folder")) Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) @@ -451,19 +451,19 @@ var _ = Describe("processor.acquire", func() { folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) Expect(outN).To(Equal(outcomeFound)) - iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) + iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(iaB.Hash).To(Equal(iaA.Hash)) Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) - iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) + iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) Expect(artRepo.Data).To(HaveLen(1)) - reused, err := artRepo.GetImage(iaA.Hash) + reused, err := artRepo.GetImage(ctx, iaA.Hash) Expect(err).ToNot(HaveOccurred()) Expect(reused.BlurHash).To(Equal("SENTINEL")) }) @@ -483,7 +483,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) Expect(out).To(Equal(outcomeFailed)) - _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) Expect(out).To(Equal(outcomeFailed)) - _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() { hash, err := hashImage(bytes.NewReader(imgBytes)) Expect(err).ToNot(HaveOccurred()) - Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) + Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) Expect(out).To(Equal(outcomeFound)) - upgraded, err := artRepo.GetImage(hash) + upgraded, err := artRepo.GetImage(ctx, hash) Expect(err).ToNot(HaveOccurred()) Expect(upgraded.Width).To(BeNumerically(">", 0)) Expect(upgraded.BlurHash).ToNot(BeEmpty()) @@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() { out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) Expect(out).To(Equal(outcomeFailed)) - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) }) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index c79458b8f..15b4a5673 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -14,9 +14,9 @@ const pruneMinAge = time.Hour func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { start := time.Now() defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }() - repo := ds.Artwork(ctx) + repo := ds.Artwork() - purged, err := repo.PurgeDanglingItems() + purged, err := repo.PurgeDanglingItems(ctx) if err != nil { return err } @@ -25,7 +25,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { } // Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed). - queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling() + queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx) if err != nil { return err } @@ -35,7 +35,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { // Files younger than the grace window may belong to acquisitions whose rows aren't committed yet. cutoff := time.Now().Add(-pruneMinAge) - orphans, err := repo.PurgeOrphans(cutoff) + orphans, err := repo.PurgeOrphans(ctx, cutoff) if err != nil { return err } @@ -44,7 +44,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { } // Read after the delete, so the sweep below reclaims the files of the rows just removed. - mimes, err := repo.GetMimeByHash() + mimes, err := repo.GetMimeByHash(ctx) if err != nil { return err } diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 8b8504f89..674211dfa 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -18,18 +18,20 @@ type flakyGetArtworkRepo struct { *tests.MockArtworkRepo } -func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) { +func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) { return nil, errors.New("db locked") } var _ = Describe("Prune", func() { + var ctx context.Context var ds *tests.MockDataStore var store *ImageStore var awRepo *tests.MockArtworkRepo BeforeEach(func() { + ctx = GinkgoT().Context() ds = &tests.MockDataStore{} - awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo) + awRepo = ds.Artwork().(*tests.MockArtworkRepo) store = NewImageStore(GinkgoT().TempDir()) }) @@ -41,9 +43,9 @@ var _ = Describe("Prune", func() { } It("purges dangling item_artwork state for gone entities, summed across kinds", func() { - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) awRepo.ExistingIDs = map[string]map[string]bool{ "al": {}, "ar": {"live-artist": true}, @@ -51,17 +53,17 @@ var _ = Describe("Prune", func() { Expect(prune(context.Background(), ds, store)).To(Succeed()) - _, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary) + _, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) - _, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary) + _, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) - _, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary) + _, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) }) It("purges dangling artwork_queue rows for gone entities", func() { queueRepo := tests.CreateMockArtworkQueueRepo() - Expect(queueRepo.Enqueue( + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary}, model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary}, )).To(Succeed()) @@ -80,17 +82,17 @@ var _ = Describe("Prune", func() { Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) old := time.Now().Add(-2 * time.Hour) Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) ageArtwork(h, old) kept := []byte("kept-bytes") hk, _ := hashImage(bytes.NewReader(kept)) Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) Expect(prune(context.Background(), ds, store)).To(Succeed()) - _, err := awRepo.GetImage(h) + _, err := awRepo.GetImage(ctx, h) Expect(err).To(MatchError(model.ErrNotFound)) _, err = store.Open(h, "image/jpeg") Expect(os.IsNotExist(err)).To(BeTrue()) @@ -103,14 +105,14 @@ var _ = Describe("Prune", func() { data := []byte("reacquired-bytes") h, _ := hashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) ageArtwork(h, time.Now().Add(-2*time.Hour)) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1", ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed()) Expect(prune(context.Background(), ds, store)).To(Succeed()) - _, err := awRepo.GetImage(h) + _, err := awRepo.GetImage(ctx, h) Expect(err).ToNot(HaveOccurred()) rc, err := store.Open(h, "image/jpeg") Expect(err).ToNot(HaveOccurred()) @@ -122,11 +124,11 @@ var _ = Describe("Prune", func() { h, _ := hashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) // Reacquisition refreshed created_at, so the row is unreferenced but too young to drop. - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) Expect(prune(context.Background(), ds, store)).To(Succeed()) - _, err := awRepo.GetImage(h) + _, err := awRepo.GetImage(ctx, h) Expect(err).ToNot(HaveOccurred()) rc, err := store.Open(h, "image/jpeg") Expect(err).ToNot(HaveOccurred()) @@ -137,7 +139,7 @@ var _ = Describe("Prune", func() { data := []byte("racing-bytes") h, _ := hashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) ageArtwork(h, time.Now().Add(-2*time.Hour)) // The row is orphaned, but a concurrent acquisition just touched the file's mtime. @@ -170,7 +172,7 @@ var _ = Describe("Prune", func() { Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) // The row records the current mime; the .png file is a superseded variant. - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) Expect(prune(context.Background(), ds, store)).To(Succeed()) @@ -192,14 +194,14 @@ var _ = Describe("Prune", func() { hb, _ := hashImage(bytes.NewReader(blocked)) Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed()) Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) ageArtwork(hb, old) good := []byte("good-bytes") hg, _ := hashImage(bytes.NewReader(good)) Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed()) Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) ageArtwork(hg, old) // A read-only shard directory makes os.Remove fail (EACCES) for hb's file only. @@ -210,13 +212,13 @@ var _ = Describe("Prune", func() { Expect(prune(context.Background(), ds, store)).To(Succeed()) - _, err := awRepo.GetImage(hg) + _, err := awRepo.GetImage(ctx, hg) Expect(err).To(MatchError(model.ErrNotFound)) _, err = store.Open(hg, "image/jpeg") Expect(os.IsNotExist(err)).To(BeTrue()) // The row purge does not depend on file removal, so only the file survives. - _, err = awRepo.GetImage(hb) + _, err = awRepo.GetImage(ctx, hb) Expect(err).To(MatchError(model.ErrNotFound)) rc, err := store.Open(hb, "image/jpeg") Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index f4f3ef725..40baa2495 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -198,7 +198,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io // resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources. func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) { - al, err := r.ds.Album(ctx).Get(albumID) + al, err := r.ds.Album().Get(ctx, albumID) if err != nil { return resolution{}, err } @@ -243,7 +243,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution // resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority. func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) { - ar, err := r.ds.Artist(ctx).Get(artistID) + ar, err := r.ds.Artist().Get(ctx, artistID) if err != nil { return resolution{}, err } @@ -259,7 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } // Only consider albums where the artist is the sole album artist. - als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)}) + als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)}) if err != nil { return resolution{}, err } @@ -328,7 +328,7 @@ const PlaylistGridSamples = 4 // resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { - pl, err := r.ds.Playlist(ctx).Get(playlistID) + pl, err := r.ds.Playlist().Get(ctx, playlistID) if err != nil { return resolution{}, err } @@ -374,8 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } } - albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false). - GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) + albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false). + GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if err != nil { return resolution{}, err } @@ -428,7 +428,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso // resolveRadio serves only an uploaded image; there is no fallback. func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) { - radio, err := r.ds.Radio(ctx).Get(radioID) + radio, err := r.ds.Radio().Get(ctx, radioID) if err != nil { return resolution{}, err } @@ -439,7 +439,7 @@ func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution // resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art // is a definitive absent. func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) { - mf, err := r.ds.MediaFile(ctx).Get(id) + mf, err := r.ds.MediaFile().Get(ctx, id) if err != nil { return resolution{}, err } diff --git a/core/artwork/uploader_test.go b/core/artwork/uploader_test.go index 44f5ede26..b1420c935 100644 --- a/core/artwork/uploader_test.go +++ b/core/artwork/uploader_test.go @@ -16,12 +16,14 @@ import ( ) var _ = Describe("Uploader", func() { + var ctx context.Context var svc Uploader var tmpDir string var artRepo *tests.MockArtworkRepo var queueRepo *tests.MockArtworkQueueRepo BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) @@ -33,7 +35,6 @@ var _ = Describe("Uploader", func() { Describe("SetImage", func() { It("creates directory and saves image file", func() { - ctx := context.Background() reader := strings.NewReader("fake image data") filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) @@ -46,7 +47,6 @@ var _ = Describe("Uploader", func() { }) It("falls back to ID-only filename when name cleans to empty", func() { - ctx := context.Background() reader := strings.NewReader("data") filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") Expect(err).ToNot(HaveOccurred()) @@ -54,7 +54,6 @@ var _ = Describe("Uploader", func() { }) It("removes old image when replacing", func() { - ctx := context.Background() oldDir := filepath.Join(tmpDir, "artwork", "artist") Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) oldFile := filepath.Join(oldDir, "ar-1_old.png") @@ -70,15 +69,13 @@ var _ = Describe("Uploader", func() { }) It("ignores missing old file without error", func() { - ctx := context.Background() reader := strings.NewReader("data") _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) }) It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { - ctx := context.Background() - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", })).To(Succeed()) @@ -87,25 +84,24 @@ var _ = Describe("Uploader", func() { // SetImage only writes the file; the state row survives and nothing is queued until // the caller has persisted the new filename and called EnqueueArtwork. - _, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) - Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty()) }) }) Describe("EnqueueArtwork", func() { It("clears artwork state and enqueues a Bump", func() { - ctx := context.Background() - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", })).To(Succeed()) svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") - _, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) - queued, err := queueRepo.DequeueBatch(1000) + queued, err := queueRepo.DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "ar"), @@ -116,13 +112,12 @@ var _ = Describe("Uploader", func() { It("is a no-op for an unknown entity type", func() { svc.EnqueueArtwork(context.Background(), "unknown", "x-1") - Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty()) }) }) Describe("RemoveImage", func() { It("removes the file at the given path", func() { - ctx := context.Background() dir := filepath.Join(tmpDir, "artwork", "artist") Expect(os.MkdirAll(dir, 0755)).To(Succeed()) path := filepath.Join(dir, "ar-1_test.jpg") @@ -134,13 +129,11 @@ var _ = Describe("Uploader", func() { }) It("succeeds when file does not exist", func() { - ctx := context.Background() err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") Expect(err).ToNot(HaveOccurred()) }) It("succeeds with empty path", func() { - ctx := context.Background() err := svc.RemoveImage(ctx, "") Expect(err).ToNot(HaveOccurred()) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index e8fb3a11c..28e51958c 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -45,7 +45,7 @@ type Worker struct { broker events.Broker pruneMu sync.RWMutex pools []*drainPool - runCtx context.Context + runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run paused func() bool gatesMu sync.Mutex @@ -155,7 +155,7 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i } // Dequeue well past the pool size so a slow external lookup never idles the other slots. // DequeueBatch does not mark rows taken, so this is one query per pass, not per slot. - items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...) + items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...) if err != nil { return 0, err } @@ -246,12 +246,12 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc ctx = withTrace(ctx, trace) out, got, retryIn := w.proc.acquire(ctx, item) - queue := w.proc.ds.ArtworkQueue(ctx) + queue := w.proc.ds.ArtworkQueue() switch out { case outcomeFound, outcomeAbsent: // A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives // here and the next drain re-resolves it. - if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { + if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } case outcomeFoundStale, outcomeFailed: @@ -260,7 +260,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its // fresh, immediate eligibility. - if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil { + if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil { log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, @@ -271,7 +271,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc // Art already being served is kept: exhaustion means unreachable, not removed. settled := "kept previous state" if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { - writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) + writeAbsent(ctx, w.proc.ds.Artwork(), item) settled = "recorded absent" } // The queue row is about to go, taking the only record of the failure with it. This write is @@ -279,7 +279,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc w.recordGiveUp(ctx, item, encoded) log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) - if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { + if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err) } } @@ -293,7 +293,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, if !ok { return } - if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil { + if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil { log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err) } } @@ -303,7 +303,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue if !ok { return false } - ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType) + ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType) return err == nil && ia.Hash != "" } diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index 803cc2dfe..9be95cf2a 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -21,6 +21,12 @@ import ( const soakCycles = 2200 var _ = Describe("Worker soak", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + It("does not leak goroutines, heap, or fds over many acquisition cycles", func() { if testing.Short() { Skip("skipping soak test in short mode") @@ -100,9 +106,9 @@ var _ = Describe("Worker soak", func() { // Read-back exercises the surfaces a caller would use after acquisition. if out == outcomeFound { kind, _ := model.ParseKind(it.ItemKind) - ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i) rc, err := store.Open(ia.Hash, art.Mime) switch { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index d74e3a5ed..a6c07b763 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -56,8 +56,8 @@ type reenqueueOnDequeue struct { done bool } -func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...) +func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...) if !r.done && len(items) > 0 { r.done = true for k, it := range r.Data { @@ -124,18 +124,27 @@ type visibilityPlaylistDS struct { tracks model.PlaylistTrackRepository } -func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { +func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository { repo := tests.CreateMockPlaylistRepo() repo.TracksRepo = v.tracks - if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { - repo.SetData(model.Playlists{v.private}) + repo.SetData(model.Playlists{v.private}) + return &visibilityPlaylistRepo{MockPlaylistRepo: repo} +} + +type visibilityPlaylistRepo struct { + *tests.MockPlaylistRepo +} + +func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) { + if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin { + return nil, model.ErrNotFound } - return repo + return v.MockPlaylistRepo.Get(ctx, id) } func adminUserRepo() *tests.MockedUserRepo { repo := tests.CreateMockUserRepo() - Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) return repo } @@ -157,8 +166,8 @@ var _ = Describe("Worker", func() { ) BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() var err error repoRoot, err = os.Getwd() Expect(err).ToNot(HaveOccurred()) @@ -200,7 +209,7 @@ var _ = Describe("Worker", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, })).To(Succeed()) @@ -208,11 +217,11 @@ var _ = Describe("Worker", func() { Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(1)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder")) - count, err := queueRepo.Count() + count, err := queueRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeZero(), "a found item must be deleted from the queue") }) @@ -223,7 +232,7 @@ var _ = Describe("Worker", func() { ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, }) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump, })).To(Succeed()) @@ -231,12 +240,12 @@ var _ = Describe("Worker", func() { Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(1)) - ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("embedded")) Expect(ia.Hash).ToNot(BeEmpty()) - art, err := artRepo.GetImage(ia.Hash) + art, err := artRepo.GetImage(ctx, ia.Hash) Expect(err).ToNot(HaveOccurred()) r, err := store.Open(ia.Hash, art.Mime) Expect(err).ToNot(HaveOccurred()) @@ -245,7 +254,7 @@ var _ = Describe("Worker", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store") - count, err := queueRepo.Count() + count, err := queueRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeZero()) }) @@ -254,7 +263,7 @@ var _ = Describe("Worker", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) @@ -265,7 +274,7 @@ var _ = Describe("Worker", func() { Expect(it.Attempts).To(Equal(1)) Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) @@ -275,7 +284,7 @@ var _ = Describe("Worker", func() { // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. const askedFor = 90 * time.Minute imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) @@ -296,7 +305,7 @@ var _ = Describe("Worker", func() { {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) @@ -307,7 +316,7 @@ var _ = Describe("Worker", func() { Expect(it.Attempts).To(Equal(1)) Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") @@ -327,7 +336,7 @@ var _ = Describe("Worker", func() { racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, })).To(Succeed()) @@ -337,7 +346,7 @@ var _ = Describe("Worker", func() { // The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op. Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil()) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder")) }) @@ -349,7 +358,7 @@ var _ = Describe("Worker", func() { racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) dequeued := findQueued(queueRepo, "al", "al8").RetryAt n, err := w.drain(ctx, 1) @@ -368,7 +377,7 @@ var _ = Describe("Worker", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) // Age the row past the retry budget. expireQueued(queueRepo, "al9") @@ -377,7 +386,7 @@ var _ = Describe("Worker", func() { Expect(n).To(Equal(1)) Expect(findQueued(queueRepo, "al", "al9")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(BeEmpty()) }) @@ -385,13 +394,13 @@ var _ = Describe("Worker", func() { It("keeps already-served art when the retry budget is exhausted", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}}) - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary, Hash: "cafebabe", Source: "external:lastfm", })).To(Succeed()) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) expireQueued(queueRepo, "al10") n, err := w.drain(ctx, 1) @@ -399,7 +408,7 @@ var _ = Describe("Worker", func() { Expect(n).To(Equal(1)) Expect(findQueued(queueRepo, "al", "al10")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") }) @@ -409,7 +418,7 @@ var _ = Describe("Worker", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed()) _, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -430,13 +439,13 @@ var _ = Describe("Worker", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed()) expireQueued(queueRepo, "al13") _, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to") Expect(ia.Hash).To(BeEmpty()) Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty()) @@ -445,20 +454,20 @@ var _ = Describe("Worker", func() { It("keeps the failure on the state row after the queue row is deleted", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary, Hash: "cafebabe", Source: "external:lastfm", })).To(Succeed()) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed()) expireQueued(queueRepo, "al12") _, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) Expect(findQueued(queueRepo, "al", "al12")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(), "the queue row is gone, so this is the only remaining record of the failure") @@ -473,7 +482,7 @@ var _ = Describe("Worker", func() { ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, }) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) expireQueued(queueRepo, "mfX") n, err := w.drain(ctx, 1) @@ -481,7 +490,7 @@ var _ = Describe("Worker", func() { Expect(n).To(Equal(1)) Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying") - _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) + _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "no row leaves the track unresolved, so a later view can still recover it") // Known gap: with no row and no absent settle, there is nowhere to keep the failure. @@ -496,14 +505,14 @@ var _ = Describe("Worker", func() { tracks: &tests.MockPlaylistTrackRepo{}, } w = NewWorker(vds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(1)) Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(BeEmpty()) }) @@ -523,9 +532,9 @@ var _ = Describe("Worker", func() { {ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}}, {ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}}, }) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) n, err := w.drain(ctx, 3) Expect(err).ToNot(HaveOccurred()) @@ -572,7 +581,7 @@ var _ = Describe("Worker", func() { conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) folderRepo.result = nil - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) @@ -582,7 +591,7 @@ var _ = Describe("Worker", func() { Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it") Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3")) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found") }) @@ -591,7 +600,7 @@ var _ = Describe("Worker", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) @@ -725,7 +734,7 @@ var _ = Describe("Worker", func() { {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, }) conf.Server.UICoverArtSize = 300 - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, })).To(Succeed()) }) @@ -813,11 +822,11 @@ var _ = Describe("Worker", func() { // Artists first, exactly as Backfill orders them. for _, a := range artists { - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill, })).To(Succeed()) } - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill, })).To(Succeed()) @@ -832,12 +841,12 @@ var _ = Describe("Worker", func() { }) Eventually(func() bool { - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary) return err == nil && ia.Hash != "" }, 5*time.Second, 50*time.Millisecond).Should(BeTrue(), "a blocked external pool must not hold up local artwork") - _, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended") }) }) @@ -849,7 +858,7 @@ var _ = Describe("Worker", func() { for i := range 8 { id := fmt.Sprintf("alc%d", i) albums = append(albums, model.Album{ID: id, Name: "Album"}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, })).To(Succeed()) } @@ -875,21 +884,21 @@ var _ = Describe("Worker", func() { for i := range 8 { id := fmt.Sprintf("alp%d", i) albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, })).To(Succeed()) } ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) // Pauses as soon as the first item has left the queue. w.PauseWhile(func() bool { - n, _ := queueRepo.Count() + n, _ := queueRepo.Count(ctx) return n < 8 }) _, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) - count, err := queueRepo.Count() + count, err := queueRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue") }) @@ -897,7 +906,7 @@ var _ = Describe("Worker", func() { It("dequeues past the worker pool so one drain covers many items", func() { for i := range 16 { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}}) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan, })).To(Succeed()) } @@ -932,7 +941,7 @@ var _ = Describe("Worker", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, })).To(Succeed()) w.PauseWhile(func() bool { return true }) diff --git a/core/auth/auth.go b/core/auth/auth.go index b36bb2696..1bdc917da 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -48,7 +48,7 @@ func Init(ds model.DataStore) { } func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string { - secret, err := ds.Property(ctx).Get(key) + secret, err := ds.Property().Get(ctx, key) if err != nil || secret == "" { log.Info(ctx, "Creating new JWT secret", "key", key) return createNewSecret(ctx, ds, key) @@ -154,9 +154,9 @@ func CheckClaims(c Claims, usr model.User, audience string) error { } func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { - u, err := ds.User(ctx).FindFirstAdmin() + u, err := ds.User().FindFirstAdmin(ctx) if err != nil { - c, err := ds.User(ctx).CountAll() + c, err := ds.User().CountAll(ctx) if c == 0 && err == nil { log.Debug(ctx, "No admin user yet!", err) } else { @@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - if err := ds.Property(ctx).Put(key, encSecret); err != nil { + if err := ds.Property().Put(ctx, key, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } return secret diff --git a/core/common.go b/core/common.go index 6ff349b1b..db2dbbf1b 100644 --- a/core/common.go +++ b/core/common.go @@ -19,7 +19,7 @@ func userName(ctx context.Context) string { // BFR We should only access files through the `storage.Storage` interface. This will require changing how // TagLib and ffmpeg access files var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string { - libPath, err := ds.Library(ctx).GetPath(libId) + libPath, err := ds.Library().GetPath(ctx, libId) if err != nil { return path } diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index f7a155cd9..73d88e5b4 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -31,7 +31,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) { } // Get implements model.ArtistRepository. -func (m *mockArtistRepo) Get(id string) (*model.Artist, error) { +func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) @@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) { } // GetAll implements model.ArtistRepository. -func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { +func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) { } // Get implements model.MediaFileRepository. -func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) { +func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) @@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) { } // GetAllByTags implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(options...) +func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(ctx, options...) } // GetAll implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi } // GetRandom implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo { } // Get implements model.AlbumRepository. -func (m *mockAlbumRepo) Get(id string) (*model.Album, error) { +func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) @@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) { } // GetAll implements model.AlbumRepository. -func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) { +func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v diff --git a/core/external/provider.go b/core/external/provider.go index 3a3f4bd46..185725259 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -182,7 +182,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl } } - err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album) + err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album) if err != nil { log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName, "elapsed", time.Since(start), err) @@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au if !throttled { artist.ExternalInfoUpdatedAt = new(time.Now()) } - err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) + err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), err) @@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) if len(ids) == 0 { return matches, nil } - res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"artist.id": ids}, }) if err != nil { @@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis if len(mbids) == 0 { return matches, nil } - res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"mbz_artist_id": mbids}, }) if err != nil { @@ -612,7 +612,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis clauses := slice.Map(names, func(name string) squirrel.Sqlizer { return squirrel.Like{"artist.name": name} }) - res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Or(clauses), }) if err != nil { @@ -628,7 +628,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) { if id != "" { - artist, err := e.ds.Artist(ctx).Get(id) + artist, err := e.ds.Artist().Get(ctx, id) if err == nil { return &auxArtist{Artist: *artist}, nil } @@ -644,7 +644,7 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA return nil, model.ErrNotFound } - artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Like{"artist.name": artistName}, Max: 1, }) @@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int ids = append(ids, sa.ID) } - similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"artist.id": ids}, }) if err != nil { diff --git a/core/external/provider_refreshinfo_test.go b/core/external/provider_refreshinfo_test.go index e7910a734..a1216dd94 100644 --- a/core/external/provider_refreshinfo_test.go +++ b/core/external/provider_refreshinfo_test.go @@ -71,8 +71,8 @@ var _ = Describe("Provider - RefreshInfo", func() { ag = new(mockAgents) broker = &fakeBroker{} p = external.NewProvider(ds, ag, matcher.New(ds), broker) - mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) }) It("repopulates an artist even when its info is fresh", func() { @@ -84,7 +84,7 @@ var _ = Describe("Provider - RefreshInfo", func() { Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) - saved, err := mockArtistRepo.Get("ar-1") + saved, err := mockArtistRepo.Get(ctx, "ar-1") Expect(err).ToNot(HaveOccurred()) Expect(saved.Biography).To(Equal("Fresh Bio")) }) @@ -99,7 +99,7 @@ var _ = Describe("Provider - RefreshInfo", func() { Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) - saved, err := mockAlbumRepo.Get("al-1") + saved, err := mockAlbumRepo.Get(ctx, "al-1") Expect(err).ToNot(HaveOccurred()) Expect(saved.Description).To(Equal("Fresh Notes")) }) diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go index 4ab465b03..305b8d6ec 100644 --- a/core/external/provider_similarsongs.go +++ b/core/external/provider_similarsongs.go @@ -37,7 +37,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode if !errors.Is(err, model.ErrNotFound) { return nil, err } - genre, err := e.ds.Genre(ctx).Get(id) + genre, err := e.ds.Genre().Get(ctx, id) if err != nil { return nil, err } @@ -178,13 +178,13 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model. func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. - repo := e.ds.Playlist(ctx).Tracks(playlistID, true) + repo := e.ds.Playlist().Tracks(ctx, playlistID, true) if repo == nil { return nil, model.ErrNotFound } // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated // seed wastes an agent call and can reach the mix twice through the seed fallback. - tracks, err := repo.GetAll(model.QueryOptions{ + tracks, err := repo.GetAll(ctx, model.QueryOptions{ Sort: "random", Max: n * 4, Filters: squirrel.Eq{"missing": false}, @@ -225,7 +225,7 @@ func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n // sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so // missing files would surface as unplayable entries. func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { - return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + return e.ds.MediaFile().GetRandom(ctx, model.QueryOptions{ Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, Max: n, }) diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index e168aa026..d2fd4364e 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -35,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ds = new(tests.MockDataStore) ag = new(mockAgents) p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index c722aaee8..5e2087d35 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -38,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ds = new(tests.MockDataStore) ag = new(mockAgents) p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) }) It("returns error when artist is not found", func() { diff --git a/core/library.go b/core/library.go index 6df0e95b5..628ee4b7b 100644 --- a/core/library.go +++ b/core/library.go @@ -32,25 +32,28 @@ type Library interface { SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error - NewRepository(ctx context.Context) rest.Repository + Repository() rest.Repository[model.Library] } type libraryService struct { - ds model.DataStore - scanner model.Scanner - watcher Watcher - broker events.Broker - pluginManager PluginUnloader + ds model.DataStore + broker events.Broker + repo *libraryRepositoryWrapper } // NewLibrary creates a new Library service func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library { return &libraryService{ - ds: ds, - scanner: scanner, - watcher: watcher, - broker: broker, - pluginManager: pluginManager, + ds: ds, + broker: broker, + repo: &libraryRepositoryWrapper{ + LibraryRepository: ds.Library(), + ds: ds, + scanner: scanner, + watcher: watcher, + broker: broker, + pluginManager: pluginManager, + }, } } @@ -58,16 +61,16 @@ func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, brok func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { // Verify user exists - if _, err := s.ds.User(ctx).Get(userID); err != nil { + if _, err := s.ds.User().Get(ctx, userID); err != nil { return nil, err } - return s.ds.User(ctx).GetUserLibraries(userID) + return s.ds.User().GetUserLibraries(ctx, userID) } func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { // Verify user exists - user, err := s.ds.User(ctx).Get(userID) + user, err := s.ds.User().Get(ctx, userID) if err != nil { return err } @@ -90,7 +93,7 @@ func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, li } // Set user libraries - err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs) + err = s.ds.User().SetUserLibraries(ctx, userID, libraryIDs) if err != nil { return fmt.Errorf("error setting user libraries: %w", err) } @@ -115,7 +118,7 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin } // Check if user has explicit access to this library - libraries, err := s.ds.User(ctx).GetUserLibraries(userID) + libraries, err := s.ds.User().GetUserLibraries(ctx, userID) if err != nil { log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err) return fmt.Errorf("error checking library access: %w", err) @@ -132,25 +135,14 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin // REST repository wrapper -func (s *libraryService) NewRepository(ctx context.Context) rest.Repository { - repo := s.ds.Library(ctx) - wrapper := &libraryRepositoryWrapper{ - ctx: ctx, - LibraryRepository: repo, - Repository: repo.(rest.Repository), - ds: s.ds, - scanner: s.scanner, - watcher: s.watcher, - broker: s.broker, - pluginManager: s.pluginManager, - } - return wrapper +func (s *libraryService) Repository() rest.Repository[model.Library] { + return s.repo } +var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil) + type libraryRepositoryWrapper struct { - rest.Repository model.LibraryRepository - ctx context.Context ds model.DataStore scanner model.Scanner watcher Watcher @@ -158,59 +150,58 @@ type libraryRepositoryWrapper struct { pluginManager PluginUnloader } -func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { - lib := entity.(*model.Library) - if err := r.validateLibrary(lib); err != nil { +func (r *libraryRepositoryWrapper) Save(ctx context.Context, lib *model.Library) (string, error) { + if err := r.validateLibrary(ctx, lib); err != nil { return "", err } - err := r.LibraryRepository.Put(lib) + err := r.LibraryRepository.Put(ctx, lib) if err != nil { return "", r.mapError(err) } // Start watcher and trigger scan after successful library creation if r.watcher != nil { - if err := r.watcher.Watch(r.ctx, lib); err != nil { - log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + if err := r.watcher.Watch(ctx, lib); err != nil { + log.Warn(ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) } } if r.scanner != nil { - go r.triggerScan(lib, "new") + go r.triggerScan(ctx, lib, "new") } // Send library refresh event to all clients if r.broker != nil { event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID))) - log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) + r.broker.SendBroadcastMessage(ctx, event.With("library", strconv.Itoa(lib.ID))) + log.Debug(ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) } return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error { - lib := entity.(*model.Library) +func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity model.Library, cols ...string) error { + lib := &entity libID, err := strconv.Atoi(id) if err != nil { return fmt.Errorf("invalid library ID: %s", id) } lib.ID = libID - if err := r.validateLibrary(lib); err != nil { + if err := r.validateLibrary(ctx, lib); err != nil { return err } // Get the original library to check if path changed - originalLib, err := r.Get(libID) + originalLib, err := r.Get(ctx, libID) if err != nil { return r.mapError(err) } pathChanged := originalLib.Path != lib.Path - err = r.LibraryRepository.Put(lib, cols...) + err = r.LibraryRepository.Put(ctx, lib, cols...) if err != nil { return r.mapError(err) } @@ -218,27 +209,36 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) // Restart watcher and trigger scan if path was updated if pathChanged { if r.watcher != nil { - if err := r.watcher.Watch(r.ctx, lib); err != nil { - log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + if err := r.watcher.Watch(ctx, lib); err != nil { + log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) } } if r.scanner != nil { - go r.triggerScan(lib, "updated") + go r.triggerScan(ctx, lib, "updated") } } // Send library refresh event to all clients if r.broker != nil { event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) - log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) + r.broker.SendBroadcastMessage(ctx, event.With("library", id)) + log.Debug(ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) } return nil } -func (r *libraryRepositoryWrapper) Delete(id string) error { +func (r *libraryRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { + for _, id := range ids { + if err := r.deleteOne(ctx, id); err != nil { + return err + } + } + return nil +} + +func (r *libraryRepositoryWrapper) deleteOne(ctx context.Context, id string) error { libID, err := strconv.Atoi(id) if err != nil { return &rest.ValidationError{Errors: map[string]string{ @@ -247,7 +247,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { } // Get library info before deletion for logging - lib, err := r.Get(libID) + lib, err := r.Get(ctx, libID) if err != nil { return r.mapError(err) } @@ -255,7 +255,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { // Run the deletion in a transaction so the cascade delete and the orphaned-artist // reconciliation it triggers (see libraryRepository.Delete) commit atomically. err = r.ds.WithTx(func(tx model.DataStore) error { - return tx.Library(r.ctx).Delete(libID) + return tx.Library().Delete(ctx, libID) }, "delete library") if err != nil { return r.mapError(err) @@ -263,25 +263,25 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { // Stop watcher and trigger scan after successful library deletion to clean up orphaned data if r.watcher != nil { - if err := r.watcher.StopWatching(r.ctx, libID); err != nil { - log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) + if err := r.watcher.StopWatching(ctx, libID); err != nil { + log.Warn(ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) } } if r.scanner != nil { - go r.triggerScan(lib, "deleted") + go r.triggerScan(ctx, lib, "deleted") } // Send library refresh event to all clients if r.broker != nil { event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) - log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) + r.broker.SendBroadcastMessage(ctx, event.With("library", id)) + log.Debug(ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) } // After successful deletion, check if any plugins were auto-disabled // and need to be unloaded from memory - r.pluginManager.UnloadDisabledPlugins(r.ctx) + r.pluginManager.UnloadDisabledPlugins(ctx) return nil } @@ -309,7 +309,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error { return err } -func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error { +func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library *model.Library) error { validationErrors := make(map[string]string) if library.Name == "" { @@ -320,7 +320,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error validationErrors["path"] = "ra.validation.required" } else { // Validate path format and accessibility - if err := r.validateLibraryPath(library); err != nil { + if err := r.validateLibraryPath(ctx, library); err != nil { validationErrors["path"] = err.Error() } } @@ -332,7 +332,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error return nil } -func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error { +func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { // Validate path format if !filepath.IsAbs(library.Path) { return fmt.Errorf("library path must be absolute") @@ -350,7 +350,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e fsys, err := fileStore.FS() if err != nil { - log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err) + log.Warn(ctx, "Error validating library.path", "path", library.Path, err) return fmt.Errorf("resources.library.validation.pathInvalid") } @@ -358,7 +358,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e info, err := fs.Stat(fsys, ".") if err != nil { // Parse the error message to check for "not a directory" - log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err) + log.Warn(ctx, "Error stating library.path", "path", library.Path, err) errStr := err.Error() if strings.Contains(errStr, "not a directory") || strings.Contains(errStr, "The directory name is invalid.") { @@ -385,7 +385,7 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in } // Use CountAll to efficiently validate library IDs exist - count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{ + count, err := s.ds.Library().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"id": libraryIDs}, }) if err != nil { @@ -399,13 +399,13 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in return nil } -func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) { - log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) +func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { + log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) start := time.Now() - warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library + warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library if err != nil { - log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) + log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) } else { - log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) + log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) } } diff --git a/core/library_test.go b/core/library_test.go index 43097414d..5402eac22 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -66,18 +66,18 @@ var _ = Describe("Library Service", func() { }) Describe("Library CRUD Operations", func() { - var repo rest.Persistable + var repo rest.Persistable[model.Library] BeforeEach(func() { - r := service.NewRepository(ctx) - repo = r.(rest.Persistable) + r := service.Repository() + repo = r.(rest.Persistable[model.Library]) }) Describe("Create", func() { It("creates a new library successfully", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data[1].Name).To(Equal("New Library")) @@ -87,7 +87,7 @@ var _ = Describe("Library Service", func() { It("fails when library name is empty", func() { library := &model.Library{Path: tempDir} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ra.validation.required")) @@ -96,7 +96,7 @@ var _ = Describe("Library Service", func() { It("fails when library path is empty", func() { library := &model.Library{Name: "Test"} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ra.validation.required")) @@ -105,7 +105,7 @@ var _ = Describe("Library Service", func() { It("fails when library path is not absolute", func() { library := &model.Library{Name: "Test", Path: "relative/path"} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -140,7 +140,7 @@ var _ = Describe("Library Service", func() { return errors.New("UNIQUE constraint failed: library.name") } - _, err = repo.Save(library) + _, err = repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -157,7 +157,7 @@ var _ = Describe("Library Service", func() { return errors.New("UNIQUE constraint failed: library.path") } - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -181,7 +181,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update("1", library) + err = repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library")) @@ -191,7 +191,7 @@ var _ = Describe("Library Service", func() { It("forwards the columns sent by the client to the repository", func() { library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} - err := repo.Update("1", library, "name", "path") + err := repo.Update(ctx, "1", *library, "name", "path") Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) @@ -205,7 +205,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir} - err = repo.Update("999", library) + err = repo.Update(ctx, "999", *library) Expect(err).To(HaveOccurred()) Expect(err).To(Equal(model.ErrNotFound)) @@ -214,7 +214,7 @@ var _ = Describe("Library Service", func() { It("fails when library name is empty", func() { library := &model.Library{ID: 1, Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ra.validation.required")) @@ -224,7 +224,7 @@ var _ = Describe("Library Service", func() { unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir) library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath))) @@ -239,7 +239,7 @@ var _ = Describe("Library Service", func() { // Update the library keeping the same name (should be allowed) library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) }) @@ -253,7 +253,7 @@ var _ = Describe("Library Service", func() { // Update the library keeping the same path (should be allowed) library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) }) @@ -284,7 +284,7 @@ var _ = Describe("Library Service", func() { // Try to update library 2 to have the same name as library 1 library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir} - err = repo.Update("2", library) + err = repo.Update(ctx, "2", *library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -312,7 +312,7 @@ var _ = Describe("Library Service", func() { // Try to update library 2 to have the same path as library 1 library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir} - err = repo.Update("2", library) + err = repo.Update(ctx, "2", *library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -327,7 +327,7 @@ var _ = Describe("Library Service", func() { It("fails when path is not absolute", func() { library := &model.Library{Name: "Test", Path: "relative/path"} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -339,7 +339,7 @@ var _ = Describe("Library Service", func() { nonExistentPath := filepath.Join(tempDir, "nonexistent") library := &model.Library{Name: "Test", Path: nonExistentPath} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -354,7 +354,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{Name: "Test", Path: testFile} - _, err = repo.Save(library) + _, err = repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -371,7 +371,7 @@ var _ = Describe("Library Service", func() { It("handles multiple validation errors", func() { library := &model.Library{Name: "", Path: "relative/path"} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -393,7 +393,7 @@ var _ = Describe("Library Service", func() { It("fails when updated path is not absolute", func() { library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -410,7 +410,7 @@ var _ = Describe("Library Service", func() { // Update the library keeping the same name (should be allowed) library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) }) @@ -419,7 +419,7 @@ var _ = Describe("Library Service", func() { nonExistentPath := filepath.Join(tempDir, "nonexistent") library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -434,7 +434,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{ID: 1, Name: "Test", Path: testFile} - err = repo.Update("1", library) + err = repo.Update(ctx, "1", *library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -446,7 +446,7 @@ var _ = Describe("Library Service", func() { // Try to update with empty name and invalid path library := &model.Library{ID: 1, Name: "", Path: "relative/path"} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -467,14 +467,14 @@ var _ = Describe("Library Service", func() { }) It("deletes an existing library successfully", func() { - err := repo.Delete("1") + err := repo.Delete(ctx, "1") Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data).To(HaveLen(0)) }) It("fails when library doesn't exist", func() { - err := repo.Delete("999") + err := repo.Delete(ctx, "999") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(model.ErrNotFound)) @@ -613,17 +613,17 @@ var _ = Describe("Library Service", func() { }) Describe("Scan Triggering", func() { - var repo rest.Persistable + var repo rest.Persistable[model.Library] BeforeEach(func() { - r := service.NewRepository(ctx) - repo = r.(rest.Persistable) + r := service.Repository() + repo = r.(rest.Persistable[model.Library]) }) It("triggers scan when creating a new library", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).NotTo(HaveOccurred()) // Wait briefly for the goroutine to complete @@ -649,7 +649,7 @@ var _ = Describe("Library Service", func() { // Update the library with a new path library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update("1", library) + err = repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) // Wait briefly for the goroutine to complete @@ -670,7 +670,7 @@ var _ = Describe("Library Service", func() { // Update the library name only (same path) library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) // Wait a bit to ensure no scan was triggered @@ -683,7 +683,7 @@ var _ = Describe("Library Service", func() { // Try to create library with invalid data (empty name) library := &model.Library{Path: tempDir} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).To(HaveOccurred()) // Ensure no scan was triggered since creation failed @@ -700,7 +700,7 @@ var _ = Describe("Library Service", func() { // Try to update with invalid data (empty name) library := &model.Library{ID: 1, Name: "", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).To(HaveOccurred()) // Ensure no scan was triggered since update failed @@ -716,7 +716,7 @@ var _ = Describe("Library Service", func() { }) // Delete the library - err := repo.Delete("1") + err := repo.Delete(ctx, "1") Expect(err).NotTo(HaveOccurred()) // Wait briefly for the goroutine to complete @@ -731,7 +731,7 @@ var _ = Describe("Library Service", func() { It("does not trigger scan when library deletion fails", func() { // Try to delete a non-existent library - err := repo.Delete("999") + err := repo.Delete(ctx, "999") Expect(err).To(HaveOccurred()) // Ensure no scan was triggered since deletion failed @@ -744,7 +744,7 @@ var _ = Describe("Library Service", func() { It("starts watcher when creating a new library", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).NotTo(HaveOccurred()) // Verify watcher was started @@ -773,7 +773,7 @@ var _ = Describe("Library Service", func() { // Update library with new path library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update("1", library) + err = repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) // Verify watcher was restarted @@ -793,7 +793,7 @@ var _ = Describe("Library Service", func() { // Update library with same path but different name library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) // Verify watcher was NOT restarted (since path didn't change) @@ -808,7 +808,7 @@ var _ = Describe("Library Service", func() { {ID: 1, Name: "Test Library", Path: tempDir}, }) - err := repo.Delete("1") + err := repo.Delete(ctx, "1") Expect(err).NotTo(HaveOccurred()) // Verify watcher was stopped @@ -826,7 +826,7 @@ var _ = Describe("Library Service", func() { }) // Mock deletion to fail by trying to delete non-existent library - err := repo.Delete("999") + err := repo.Delete(ctx, "999") Expect(err).To(HaveOccurred()) // Verify watcher was NOT stopped since deletion failed @@ -838,11 +838,11 @@ var _ = Describe("Library Service", func() { }) Describe("Event Broadcasting", func() { - var repo rest.Persistable + var repo rest.Persistable[model.Library] BeforeEach(func() { - r := service.NewRepository(ctx) - repo = r.(rest.Persistable) + r := service.Repository() + repo = r.(rest.Persistable[model.Library]) // Clear any events from broker broker.Events = []events.Event{} }) @@ -850,7 +850,7 @@ var _ = Describe("Library Service", func() { It("sends refresh event when creating a library", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(library) + _, err := repo.Save(ctx, library) Expect(err).NotTo(HaveOccurred()) Expect(broker.Events).To(HaveLen(1)) @@ -863,7 +863,7 @@ var _ = Describe("Library Service", func() { }) library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} - err := repo.Update("1", library) + err := repo.Update(ctx, "1", *library) Expect(err).NotTo(HaveOccurred()) Expect(broker.Events).To(HaveLen(1)) @@ -875,7 +875,7 @@ var _ = Describe("Library Service", func() { {ID: 2, Name: "Library to Delete", Path: tempDir}, }) - err := repo.Delete("2") + err := repo.Delete(ctx, "2") Expect(err).NotTo(HaveOccurred()) Expect(broker.Events).To(HaveLen(1)) @@ -883,13 +883,13 @@ var _ = Describe("Library Service", func() { }) Describe("Plugin Manager Integration", func() { - var repo rest.Persistable + var repo rest.Persistable[model.Library] BeforeEach(func() { // Reset the call count for each test pluginManager.unloadCalls = 0 - r := service.NewRepository(ctx) - repo = r.(rest.Persistable) + r := service.Repository() + repo = r.(rest.Persistable[model.Library]) }) It("calls UnloadDisabledPlugins after successful library deletion", func() { @@ -897,14 +897,14 @@ var _ = Describe("Library Service", func() { {ID: 2, Name: "Library to Delete", Path: tempDir}, }) - err := repo.Delete("2") + err := repo.Delete(ctx, "2") Expect(err).NotTo(HaveOccurred()) Expect(pluginManager.unloadCalls).To(Equal(1)) }) It("does not call UnloadDisabledPlugins when library deletion fails", func() { // Try to delete non-existent library - err := repo.Delete("999") + err := repo.Delete(ctx, "999") Expect(err).To(HaveOccurred()) Expect(pluginManager.unloadCalls).To(Equal(0)) }) diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index b9fb8cb74..d3e8d72b8 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -57,7 +57,7 @@ func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (mod func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { opts := songsByArtistTitleWithLyricsFirst(artist, title) opts.Max = maxLegacyLyricsCandidates - mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts) + mediaFiles, err := l.ds.MediaFile().GetAll(ctx, opts) if err != nil { return nil, err } diff --git a/core/maintenance.go b/core/maintenance.go index 56c0ac18d..58265ea2c 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -58,7 +58,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta return fmt.Errorf("%w: %q", ErrSameFile, missingID) } - missing, err := s.ds.MediaFile(ctx).Get(missingID) + missing, err := s.ds.MediaFile().Get(ctx, missingID) if err != nil { return fmt.Errorf("loading missing file %q: %w", missingID, err) } @@ -66,7 +66,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta return fmt.Errorf("%w: %q", ErrNotMissing, missingID) } - target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID) + target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID) if err != nil { return fmt.Errorf("loading target file %q: %w", targetID, err) } @@ -82,27 +82,27 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta // Preserve the original created_at so the remapped track doesn't resurface in "Recently Added" target.CreatedAt = missing.CreatedAt target.ID = missing.ID - if err := tx.MediaFile(ctx).Put(target); err != nil { + if err := tx.MediaFile().Put(ctx, target); err != nil { return fmt.Errorf("update matched track: %w", err) } // Unlike the scanner's freshly-imported target, this one may carry history of its own - if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil { + if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil { return fmt.Errorf("reassign target references: %w", err) } - if err := tx.MediaFile(ctx).Delete(discardedID); err != nil { + if err := tx.MediaFile().Delete(ctx, discardedID); err != nil { return fmt.Errorf("delete discarded track: %w", err) } if oldAlbumID != newAlbumID { - oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) + oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) if err != nil { return fmt.Errorf("get old album tracks: %w", err) } if oldAlbumTracks == 0 { - if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { return fmt.Errorf("reassign album annotations: %w", err) } - if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { + if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { return fmt.Errorf("copy album attributes: %w", err) } } @@ -121,7 +121,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta // Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits. // album/artist play count aggregates are not recalculated here; they are refreshed by the next scan. - if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil { + if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil { log.Error(ctx, "Error refreshing artist stats after remapping missing file", err) } affectedAlbumIDs := []string{newAlbumID} @@ -146,10 +146,10 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er // Delete missing files within a transaction err = s.ds.WithTx(func(tx model.DataStore) error { if len(ids) == 0 { - _, err := tx.MediaFile(ctx).DeleteAllMissing() + _, err := tx.MediaFile().DeleteAllMissing(ctx) return err } - return tx.MediaFile(ctx).DeleteMissing(ids) + return tx.MediaFile().DeleteMissing(ctx, ids) }) if err != nil { log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) @@ -192,11 +192,11 @@ func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []strin // refreshAlbumChunk processes a single chunk of album IDs func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error { - albumRepo := s.ds.Album(ctx) - mfRepo := s.ds.MediaFile(ctx) + albumRepo := s.ds.Album() + mfRepo := s.ds.MediaFile() // Batch load existing albums - albums, err := albumRepo.GetAll(model.QueryOptions{ + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.id": albumIDs}, }) if err != nil { @@ -210,7 +210,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s } // Batch load all media files for these albums - mediaFiles, err := mfRepo.GetAll(model.QueryOptions{ + mediaFiles, err := mfRepo.GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album_id": albumIDs}, Sort: "album_id, path", }) @@ -243,7 +243,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s newAlbum.UpdatedAt = time.Now() newAlbum.CreatedAt = oldAlbum.CreatedAt - if err := albumRepo.Put(&newAlbum); err != nil { + if err := albumRepo.Put(ctx, &newAlbum); err != nil { log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err) // Continue with other albums instead of failing entirely continue @@ -265,7 +265,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri } } - mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: filters, }) if err != nil { @@ -293,7 +293,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu // Refresh artist stats in background s.wg.Go(func() { bgCtx := request.AddValues(context.Background(), ctx) - if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil { + if _, err := s.ds.Artist().RefreshStats(bgCtx, true); err != nil { log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) } else { log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") diff --git a/core/maintenance_test.go b/core/maintenance_test.go index 56e6d13b5..4ffc098d9 100644 --- a/core/maintenance_test.go +++ b/core/maintenance_test.go @@ -262,12 +262,12 @@ var _ = Describe("Maintenance", func() { Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - got, err := mfRepo.Get("m1") + got, err := mfRepo.Get(ctx, "m1") Expect(err).ToNot(HaveOccurred()) Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location Expect(got.Missing).To(BeFalse()) Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved - exists, _ := mfRepo.Exists("t1") + exists, _ := mfRepo.Exists(ctx, "t1") Expect(exists).To(BeFalse()) // discarded row removed Expect(ds.GCCalled).To(BeTrue()) }) @@ -369,14 +369,14 @@ var _ = Describe("Maintenance", func() { Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") // The old album lost the remapped track, so its stats are recalculated from the remaining one - oldAlbum, err := albumRepo.Get("album1") + oldAlbum, err := albumRepo.Get(ctx, "album1") Expect(err).ToNot(HaveOccurred()) Expect(oldAlbum.SongCount).To(Equal(1)) Expect(oldAlbum.Size).To(Equal(int64(1000))) Expect(oldAlbum.Duration).To(BeNumerically("==", 100)) // The target album keeps the track, now under the missing file's ID - newAlbum, err := albumRepo.Get("album2") + newAlbum, err := albumRepo.Get(ctx, "album2") Expect(err).ToNot(HaveOccurred()) Expect(newAlbum.SongCount).To(Equal(1)) Expect(newAlbum.Size).To(Equal(int64(2000))) @@ -407,7 +407,7 @@ var _ = Describe("Maintenance", func() { Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) // The surviving row is the missing file's ID, holding the target's data - got, err := mfRepo.GetWithParticipants("m1") + got, err := mfRepo.GetWithParticipants(ctx, "m1") Expect(err).ToNot(HaveOccurred()) Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant})) }) @@ -447,7 +447,7 @@ type extendedMediaFileRepo struct { deleteMissingError error } -func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error { +func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string) error { m.deleteMissingCalled = true m.deletedIDs = ids if m.deleteMissingError != nil { @@ -470,7 +470,7 @@ type extendedAlbumRepo struct { failOnce bool } -func (m *extendedAlbumRepo) Put(album *model.Album) error { +func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error { m.mu.Lock() m.putCallCount++ m.lastPutData = album @@ -490,7 +490,7 @@ func (m *extendedAlbumRepo) Put(album *model.Album) error { } m.mu.Unlock() - return m.MockAlbumRepo.Put(album) + return m.MockAlbumRepo.Put(ctx, album) } func (m *extendedAlbumRepo) GetPutCallCount() int { @@ -507,7 +507,7 @@ type extendedArtistRepo struct { refreshStatsError error } -func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) { +func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { m.mu.Lock() m.refreshStatsCalled = true err := m.refreshStatsError @@ -516,7 +516,7 @@ func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) { if err != nil { return 0, err } - return m.MockArtistRepo.RefreshStats(allArtists) + return m.MockArtistRepo.RefreshStats(ctx, allArtists) } func (m *extendedArtistRepo) IsRefreshStatsCalled() bool { diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 25b8fda5f..3eb4db152 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -95,7 +95,7 @@ func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map if len(ids) == 0 { return nil } - res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"media_file.id": ids}, squirrel.Eq{"missing": false}, @@ -134,7 +134,7 @@ func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result m if len(mbids) == 0 { return nil } - res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"mbz_recording_id": mbids}, squirrel.Eq{"missing": false}, @@ -180,7 +180,7 @@ func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result m if len(isrcs) == 0 { return nil } - res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ + res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, Sort: "starred desc, rating desc, year asc, compilation asc", }) @@ -442,7 +442,7 @@ func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (r filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))}) } if len(filter) > 0 { - artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) + artists, err := m.ds.Artist().GetAll(ctx, model.QueryOptions{Filters: filter}) if err != nil { return resolvedArtists{}, err } @@ -543,7 +543,7 @@ func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) return nil, nil } args := slice.Map(artistIDs, func(id string) any { return id }) - return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + return m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Expr( "media_file.id IN (SELECT media_file_id FROM media_file_artists "+ diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index a46db8a09..0f5389ca5 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -1267,7 +1267,7 @@ func newMockMediaFileRepo() *mockMediaFileRepo { return &mockMediaFileRepo{} } -func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -1279,8 +1279,8 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi return args.Get(0).(model.MediaFiles), args.Error(1) } -func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(options...) +func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(ctx, options...) } func (m *mockMediaFileRepo) SetError(hasError bool) { @@ -1298,7 +1298,7 @@ func newMockArtistRepo() *mockArtistRepo { return &mockArtistRepo{} } -func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { +func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v diff --git a/core/metrics/insights.go b/core/metrics/insights.go index d952f517a..4a78a7f3f 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -47,11 +47,11 @@ type insightsCollector struct { func GetInstance(ds model.DataStore) Insights { return singleton.GetInstance(func() *insightsCollector { - id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey) + id, err := ds.Property().Get(context.TODO(), consts.InsightsIDKey) if err != nil { log.Trace("Could not get Insights ID from DB. Creating one", err) id = uuid.NewString() - err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id) + err = ds.Property().Put(context.TODO(), consts.InsightsIDKey, id) if err != nil { log.Trace("Could not save Insights ID to DB", err) } @@ -87,7 +87,7 @@ func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, succe } func (c *insightsCollector) sendInsights(ctx context.Context) { - count, err := c.ds.User(ctx).CountAll(model.QueryOptions{}) + count, err := c.ds.User().CountAll(ctx, model.QueryOptions{}) if err != nil { log.Trace(ctx, "Could not check user count", err) return @@ -245,41 +245,41 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { // Library info var err error - data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll() + data.Library.Tracks, err = c.ds.MediaFile().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading tracks count", err) } - data.Library.Albums, err = c.ds.Album(ctx).CountAll() + data.Library.Albums, err = c.ds.Album().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading albums count", err) } - data.Library.Artists, err = c.ds.Artist(ctx).CountAll() + data.Library.Artists, err = c.ds.Artist().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading artists count", err) } - data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll() + data.Library.Playlists, err = c.ds.Playlist().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading playlists count", err) } - data.Library.Shares, err = c.ds.Share(ctx).CountAll() + data.Library.Shares, err = c.ds.Share().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading shares count", err) } - data.Library.Radios, err = c.ds.Radio(ctx).Count() + data.Library.Radios, err = c.ds.Radio().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading radios count", err) } - data.Library.Libraries, err = c.ds.Library(ctx).CountAll() + data.Library.Libraries, err = c.ds.Library().CountAll(ctx) if err != nil { log.Trace(ctx, "Error reading libraries count", err) } - data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{ + data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, }) if err != nil { log.Trace(ctx, "Error reading active users count", err) } - data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix() + data.Library.FileSuffixes, err = c.ds.MediaFile().CountBySuffix(ctx) if err != nil { log.Trace(ctx, "Error reading file suffixes count", err) } @@ -297,7 +297,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { // Collect active players if permitted if conf.Server.DevEnablePlayerInsights { - data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{ + data.Library.ActivePlayers, err = c.ds.Player().CountByClient(ctx, model.QueryOptions{ Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)}, }) if err != nil { @@ -324,7 +324,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { // hasSmartPlaylists checks if there are any smart playlists (playlists with rules) func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) { - count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{ + count, err := c.ds.Playlist().CountAll(ctx, model.QueryOptions{ Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}}, }) return count > 0, err diff --git a/core/metrics/prometheus.go b/core/metrics/prometheus.go index 412483156..c85706560 100644 --- a/core/metrics/prometheus.go +++ b/core/metrics/prometheus.go @@ -197,28 +197,28 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics { }) func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) { - albumsCount, err := ds.Album(ctx).CountAll() + albumsCount, err := ds.Album().CountAll(ctx) if err != nil { log.Warn("album CountAll error", err) return } targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount)) - artistCount, err := ds.Artist(ctx).CountAll() + artistCount, err := ds.Artist().CountAll(ctx) if err != nil { log.Warn("artist CountAll error", err) return } targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount)) - songsCount, err := ds.MediaFile(ctx).CountAll() + songsCount, err := ds.MediaFile().CountAll(ctx) if err != nil { log.Warn("media CountAll error", err) return } targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount)) - usersCount, err := ds.User(ctx).CountAll() + usersCount, err := ds.User().CountAll(ctx) if err != nil { log.Warn("user CountAll error", err) return diff --git a/core/playback/device.go b/core/playback/device.go index fd08b340e..8e4e8880e 100644 --- a/core/playback/device.go +++ b/core/playback/device.go @@ -23,7 +23,7 @@ type Track interface { } type playbackDevice struct { - serviceCtx context.Context + serviceCtx context.Context //nolint:containedctx // playback service lifecycle ctx ParentPlaybackServer PlaybackServer Default bool User string diff --git a/core/playback/playbackserver.go b/core/playback/playbackserver.go index 7dd02dcb1..48e3bcfaf 100644 --- a/core/playback/playbackserver.go +++ b/core/playback/playbackserver.go @@ -111,7 +111,7 @@ func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) { // GetMediaFile retrieves the MediaFile given by the id parameter func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) { - return ps.datastore.MediaFile(*ps.ctx).Get(id) + return ps.datastore.MediaFile().Get(*ps.ctx, id) } // GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device. diff --git a/core/players.go b/core/players.go index b757f8460..e03d8caa2 100644 --- a/core/players.go +++ b/core/players.go @@ -37,14 +37,14 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip var err error user, _ := request.UserFrom(ctx) if playerID != "" { - plr, err = p.ds.Player(ctx).Get(playerID) + plr, err = p.ds.Player().Get(ctx, playerID) if err == nil && (plr.Client != client || plr.UserId != user.ID) { playerID = "" } } username := userName(ctx) if err != nil || playerID == "" { - plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent) + plr, err = p.ds.Player().FindMatch(ctx, user.ID, client, userAgent) if err == nil { log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", username, "type", userAgent) } else { @@ -66,17 +66,17 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - err = p.ds.Player(ctx).Put(plr) + err = p.ds.Player().Put(ctx, plr) if err != nil { log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err) } }) if plr.TranscodingId != "" { - trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId) + trc, err = p.ds.Transcoding().Get(ctx, plr.TranscodingId) } return plr, trc, err } func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) { - return p.ds.Player(ctx).Get(playerId) + return p.ds.Player().Get(ctx, playerId) } diff --git a/core/players_test.go b/core/players_test.go index 55ec16833..302d63157 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -145,14 +145,14 @@ func (m *mockPlayerRepository) add(p *model.Player) { m.data[p.ID] = *p } -func (m *mockPlayerRepository) Get(id string) (*model.Player, error) { +func (m *mockPlayerRepository) Get(_ context.Context, id string) (*model.Player, error) { if p, ok := m.data[id]; ok { return &p, nil } return nil, model.ErrNotFound } -func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) { +func (m *mockPlayerRepository) FindMatch(_ context.Context, userId, client, userAgent string) (*model.Player, error) { for _, p := range m.data { if p.Client == client && p.UserId == userId && p.UserAgent == userAgent { return &p, nil @@ -161,7 +161,7 @@ func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*mod return nil, model.ErrNotFound } -func (m *mockPlayerRepository) Put(p *model.Player) error { +func (m *mockPlayerRepository) Put(_ context.Context, p *model.Player) error { m.lastSaved = p return nil } diff --git a/core/playlists/import.go b/core/playlists/import.go index e41f61bd1..658bd92dc 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -39,7 +39,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo } if pls.ID != "" && pls.Sync != sync { pls.Sync = sync - if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil { + if putErr := s.ds.Playlist().Put(ctx, pls); putErr != nil { return nil, putErr } } @@ -74,7 +74,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo var errNotInLibrary = fmt.Errorf("path not in any library") func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) { - libs, err := s.ds.Library(ctx).GetAll() + libs, err := s.ds.Library().GetAll(ctx) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) } - folder, err := s.ds.Folder(ctx).GetByPath(lib, dir) + folder, err := s.ds.Folder().GetByPath(ctx, lib, dir) if err != nil { return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err) } @@ -122,7 +122,7 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla log.Error(ctx, "Error parsing playlist", err) return nil, err } - err = s.ds.Playlist(ctx).Put(pls) + err = s.ds.Playlist().Put(ctx, pls) if err != nil { log.Error(ctx, "Error saving playlist", err) return nil, err @@ -166,14 +166,14 @@ func fingerprint(h *xxh3.Hasher) string { // findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode // normalization forms to handle cross-platform filesystem differences. func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) { - pls, err := s.ds.Playlist(ctx).FindByPath(path) + pls, err := s.ds.Playlist().FindByPath(ctx, path) if errors.Is(err, model.ErrNotFound) { altPath := norm.NFD.String(path) if altPath == path { altPath = norm.NFC.String(path) } if altPath != path { - pls, err = s.ds.Playlist(ctx).FindByPath(altPath) + pls, err = s.ds.Playlist().FindByPath(ctx, altPath) } } return pls, err @@ -221,5 +221,5 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.Public = conf.Server.DefaultPlaylistPublicVisibility } } - return s.ds.Playlist(ctx).Put(newPls) + return s.ds.Playlist().Put(ctx, newPls) } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index a90a703d9..25960f0fe 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -1172,7 +1172,7 @@ type mockedMediaFileRepo struct { data map[string]model.MediaFile } -func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) { +func (r *mockedMediaFileRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { var mfs model.MediaFiles // If data map provided, look up files @@ -1212,7 +1212,7 @@ type mockedMediaFileFromListRepo struct { data []string } -func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) { +func (r *mockedMediaFileFromListRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { var mfs model.MediaFiles for idx, dataPath := range r.data { @@ -1247,7 +1247,7 @@ type mockFolderRepoForImport struct { folder *model.Folder } -func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) { +func (m *mockFolderRepoForImport) GetByPath(_ context.Context, _ model.Library, _ string) (*model.Folder, error) { if m.folder != nil { return m.folder, nil } diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index b9cb154cb..9610e9dbb 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -20,7 +20,7 @@ import ( ) func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { - mediaFileRepository := s.ds.MediaFile(ctx) + mediaFileRepository := s.ds.MediaFile() resolver, err := newPathResolver(ctx, s.ds) if err != nil { return err @@ -96,7 +96,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m } } - found, err := mediaFileRepository.FindByPaths(lookupCandidates) + found, err := mediaFileRepository.FindByPaths(ctx, lookupCandidates) if err != nil { log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err) continue @@ -215,7 +215,7 @@ type pathResolver struct { // newPathResolver creates a pathResolver with libraries loaded from the datastore. func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) { - libs, err := ds.Library(ctx).GetAll() + libs, err := ds.Library().GetAll(ctx) if err != nil { return nil, err } diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index d7fd5e001..b6a3a96f9 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -24,7 +24,7 @@ var _ = Describe("libraryMatcher", func() { // Helper function to create a libraryMatcher from the mock datastore createMatcher := func(ds model.DataStore) *libraryMatcher { - libs, err := ds.Library(ctx).GetAll() + libs, err := ds.Library().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) return newLibraryMatcher(libs) } diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 1f8cc9581..c9bc03b97 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -49,8 +49,8 @@ type Playlists interface { ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) // REST adapters - NewRepository(ctx context.Context) rest.Repository - TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository + Repository() rest.Repository[model.Playlist] + TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] } // ImageUploadService is a local interface satisfied by artwork.Uploader. @@ -64,10 +64,13 @@ type ImageUploadService interface { type playlists struct { ds model.DataStore imgUpload ImageUploadService + repo *playlistRepositoryWrapper } func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists { - return &playlists{ds: ds, imgUpload: imgUpload} + s := &playlists{ds: ds, imgUpload: imgUpload} + s.repo = &playlistRepositoryWrapper{PlaylistRepository: ds.Playlist(), service: s} + return s } func InPath(folder model.Folder) bool { @@ -86,30 +89,30 @@ func InPath(folder model.Folder) bool { // --- Read operations --- func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { - return s.ds.Playlist(ctx).GetAll(options...) + return s.ds.Playlist().GetAll(ctx, options...) } func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) { - return s.ds.Playlist(ctx).Get(id) + return s.ds.Playlist().Get(ctx, id) } func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) { - return s.ds.Playlist(ctx).GetWithTracks(id, true, false) + return s.ds.Playlist().GetWithTracks(ctx, id, true, false) } func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { - return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) + return s.ds.Playlist().GetPlaylists(ctx, mediaFileId) } // Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather // than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards // its error behind a nil (and warns), and this is probed with ids that are usually not playlists. func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { - repo := s.ds.Playlist(ctx) - if _, err := repo.Get(id); err != nil { + repo := s.ds.Playlist() + if _, err := repo.Get(ctx, id); err != nil { return nil, err } - tracks := repo.Tracks(id, true) + tracks := repo.Tracks(ctx, id, true) if tracks == nil { return nil, model.ErrNotFound } @@ -127,7 +130,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, var err error if playlistId != "" { - pls, err = tx.Playlist(ctx).Get(playlistId) + pls, err = tx.Playlist().Get(ctx, playlistId) if err != nil { return err } @@ -145,7 +148,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, pls.Tracks = nil pls.AddMediaFilesByID(ids) - err = tx.Playlist(ctx).Put(pls) + err = tx.Playlist().Put(ctx, pls) playlistId = pls.ID return err }) @@ -165,7 +168,7 @@ func (s *playlists) Delete(ctx context.Context, id string) error { } } - return s.ds.Playlist(ctx).Delete(id) + return s.ds.Playlist().Delete(ctx, id) } func (s *playlists) Update(ctx context.Context, playlistID string, @@ -183,21 +186,21 @@ func (s *playlists) Update(ctx context.Context, playlistID string, return err } return s.ds.WithTxImmediate(func(tx model.DataStore) error { - repo := tx.Playlist(ctx) + repo := tx.Playlist() if len(idxToRemove) > 0 { - tracksRepo := repo.Tracks(playlistID, false) + tracksRepo := repo.Tracks(ctx, playlistID, false) // Convert 0-based indices to 1-based position IDs and delete them directly, // avoiding the need to load all tracks into memory. positions := make([]string, len(idxToRemove)) for i, idx := range idxToRemove { positions[i] = strconv.Itoa(idx + 1) } - if err := tracksRepo.Delete(positions...); err != nil { + if err := tracksRepo.Delete(ctx, positions...); err != nil { return err } if len(idsToAdd) > 0 { - if _, err := tracksRepo.Add(idsToAdd); err != nil { + if _, err := tracksRepo.Add(ctx, idsToAdd); err != nil { return err } } @@ -205,7 +208,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string, } if len(idsToAdd) > 0 { - if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil { + if _, err := repo.Tracks(ctx, playlistID, false).Add(ctx, idsToAdd); err != nil { return err } } @@ -221,7 +224,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string, // checkWritable fetches the playlist and verifies the current user can modify it. func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) { - pls, err := s.ds.Playlist(ctx).Get(id) + pls, err := s.ds.Playlist().Get(ctx, id) if err != nil { return nil, err } @@ -257,7 +260,7 @@ func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls if public != nil { pls.Public = *public } - return ds.Playlist(ctx).Put(pls) + return ds.Playlist().Put(ctx, pls) } // --- Track management operations --- @@ -266,7 +269,7 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids) + return s.ds.Playlist().Tracks(ctx, playlistID, false).Add(ctx, ids) } // InsertTracks adds tracks before the 1-based position pos; a position past the end appends. @@ -279,7 +282,7 @@ func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []s // concurrent writers instead of waiting for the lock. err := s.ds.WithTxImmediate(func(tx model.DataStore) error { var err error - count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos) + count, err = tx.Playlist().Tracks(ctx, playlistID, false).Insert(ctx, ids, pos) return err }) return count, err @@ -289,21 +292,21 @@ func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds [ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds) + return s.ds.Playlist().Tracks(ctx, playlistID, false).AddAlbums(ctx, albumIds) } func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds) + return s.ds.Playlist().Tracks(ctx, playlistID, false).AddArtists(ctx, artistIds) } func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs) + return s.ds.Playlist().Tracks(ctx, playlistID, false).AddDiscs(ctx, discs) } func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error { @@ -311,7 +314,7 @@ func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackId return err } return s.ds.WithTx(func(tx model.DataStore) error { - return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...) + return tx.Playlist().Tracks(ctx, playlistID, false).Delete(ctx, trackIds...) }) } @@ -320,7 +323,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int return err } return s.ds.WithTxImmediate(func(tx model.DataStore) error { - return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) + return tx.Playlist().Tracks(ctx, playlistID, false).Reorder(ctx, pos, newPos) }) } @@ -339,7 +342,7 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } pls.UploadedImage = filename - if err := s.ds.Playlist(ctx).Put(pls); err != nil { + if err := s.ds.Playlist().Put(ctx, pls); err != nil { return err } s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) @@ -357,7 +360,7 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { } pls.UploadedImage = "" - if err := s.ds.Playlist(ctx).Put(pls); err != nil { + if err := s.ds.Playlist().Put(ctx, pls); err != nil { return err } s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index dd6213555..ec44de3bc 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -493,15 +493,15 @@ var _ = Describe("Playlists", func() { It("clears the resolved artwork state and re-queues after removing an upload", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", })).To(Succeed()) Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) - _, err := ds.Artwork(ctx).GetItemArtwork(model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary) + _, err := ds.Artwork().GetItemArtwork(ctx, model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) - queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100) + queued, _ := ds.ArtworkQueue().DequeueBatch(ctx, 100) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "pl"), HaveField("ItemID", "pls-1"), diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 45642c819..f8e8a9d41 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -14,42 +14,43 @@ import ( // --- REST adapter (follows Share/Library pattern) --- -func (s *playlists) NewRepository(ctx context.Context) rest.Repository { - return &playlistRepositoryWrapper{ - ctx: ctx, - PlaylistRepository: s.ds.Playlist(ctx), - service: s, - } +func (s *playlists) Repository() rest.Repository[model.Playlist] { + return s.repo } -// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter. -// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository), -// and rest.Persistable by delegating to service methods for all mutations. +// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter, +// delegating to service methods for all mutations. type playlistRepositoryWrapper struct { model.PlaylistRepository - ctx context.Context service *playlists } -func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { - return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) +var _ rest.Persistable[model.Playlist] = (*playlistRepositoryWrapper)(nil) + +func (r *playlistRepositoryWrapper) Save(ctx context.Context, entity *model.Playlist) (string, error) { + return r.service.savePlaylist(ctx, entity) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) +func (r *playlistRepositoryWrapper) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error { + return r.service.updatePlaylistEntity(ctx, id, &entity, cols...) } -func (r *playlistRepositoryWrapper) Delete(id string) error { - return r.service.Delete(r.ctx, id) +func (r *playlistRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { + for _, id := range ids { + if err := r.service.Delete(ctx, id); err != nil { + return err + } + } + return nil } -func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository { - repo := s.ds.Playlist(ctx) - tracks := repo.Tracks(playlistId, refreshSmartPlaylist) +func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] { + repo := s.ds.Playlist() + tracks := repo.Tracks(ctx, playlistId, refreshSmartPlaylist) if tracks == nil { return nil } - return tracks.(rest.Repository) + return tracks } // savePlaylist creates a new playlist, assigning the owner from context. @@ -63,7 +64,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri pls.UploadedImage = "" // Managed by image upload endpoint pls.ExternalImageURL = "" // Managed by M3U import / plugins only pls.EvaluatedAt = nil // Server-managed - err := s.ds.Playlist(ctx).Put(pls) + err := s.ds.Playlist().Put(ctx, pls) if err != nil { return "", err } @@ -156,7 +157,7 @@ func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.P if len(updateCols) == 0 { return nil } - return s.ds.Playlist(ctx).Put(current, updateCols...) + return s.ds.Playlist().Put(ctx, current, updateCols...) } // sentFields returns a predicate that reports whether a JSON field was present diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index fbfab350c..e82dd9df5 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -31,7 +31,7 @@ var _ = Describe("REST Adapter", func() { }) Describe("NewRepository", func() { - var repo rest.Persistable + var repo rest.Persistable[model.Playlist] BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ @@ -43,9 +43,9 @@ var _ = Describe("REST Adapter", func() { Describe("Save", func() { It("sets the owner from the context user", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "New Playlist"} - id, err := repo.Save(pls) + id, err := repo.Save(ctx, pls) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeEmpty()) Expect(pls.OwnerID).To(Equal("user-1")) @@ -53,16 +53,16 @@ var _ = Describe("REST Adapter", func() { It("forces a new creation by clearing ID", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{ID: "should-be-cleared", Name: "New"} - _, err := repo.Save(pls) + _, err := repo.Save(ctx, pls) Expect(err).ToNot(HaveOccurred()) Expect(pls.ID).ToNot(Equal("should-be-cleared")) }) It("clears server-managed fields to prevent injection via REST API", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{ Name: "Legit Playlist", Comment: "A comment", @@ -74,7 +74,7 @@ var _ = Describe("REST Adapter", func() { ExternalImageURL: "http://evil.example.com/ssrf", EvaluatedAt: new(time.Now()), } - _, err := repo.Save(pls) + _, err := repo.Save(ctx, pls) Expect(err).ToNot(HaveOccurred()) saved := mockPlsRepo.Last @@ -95,33 +95,33 @@ var _ = Describe("REST Adapter", func() { Describe("Update", func() { It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "Updated"} - err := repo.Update("pls-1", pls) + err := repo.Update(ctx, "pls-1", *pls) Expect(err).ToNot(HaveOccurred()) }) It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "Updated"} - err := repo.Update("pls-1", pls) + err := repo.Update(ctx, "pls-1", *pls) Expect(err).ToNot(HaveOccurred()) }) It("denies non-owner, non-admin", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "Updated"} - err := repo.Update("pls-1", pls) + err := repo.Update(ctx, "pls-1", *pls) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) It("denies regular user from changing ownership", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"} - err := repo.Update("pls-1", pls) + err := repo.Update(ctx, "pls-1", *pls) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) @@ -133,9 +133,9 @@ var _ = Describe("REST Adapter", func() { // entity.OwnerID. sentFields normalizes both sides so the // permission gate fires regardless of casing. ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{OwnerID: "other-user"} - err := repo.Update("pls-1", pls, colName) + err := repo.Update(ctx, "pls-1", *pls, colName) Expect(err).To(Equal(rest.ErrPermissionDenied)) }, Entry("canonical camelCase", "ownerId"), @@ -152,10 +152,10 @@ var _ = Describe("REST Adapter", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules} - err := repo.Update("smart-1", pls) + err := repo.Update(ctx, "smart-1", *pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) @@ -171,10 +171,10 @@ var _ = Describe("REST Adapter", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} pls := &model.Playlist{Rules: newRules} - err := repo.Update("smart-1", pls, "rules") + err := repo.Update(ctx, "smart-1", *pls, "rules") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty()) }) @@ -190,9 +190,9 @@ var _ = Describe("REST Adapter", func() { UpdatedAt: originalTime, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "File Playlist", Sync: false} - err := repo.Update("file-pls", pls) + err := repo.Update(ctx, "file-pls", *pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Sync).To(BeFalse()) Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) @@ -207,9 +207,9 @@ var _ = Describe("REST Adapter", func() { Sync: false, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "Manual Playlist", Sync: true} - err := repo.Update("manual-pls", pls) + err := repo.Update(ctx, "manual-pls", *pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(BeNil()) }) @@ -224,9 +224,9 @@ var _ = Describe("REST Adapter", func() { UpdatedAt: originalTime, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "My Playlist", Public: true} - err := repo.Update("pls-pub", pls) + err := repo.Update(ctx, "pls-pub", *pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Public).To(BeTrue()) Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) @@ -241,9 +241,9 @@ var _ = Describe("REST Adapter", func() { Sync: true, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "New Name", Sync: false} - err := repo.Update("file-pls2", pls) + err := repo.Update(ctx, "file-pls2", *pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("New Name")) Expect(mockPlsRepo.Last.Sync).To(BeFalse()) @@ -251,9 +251,9 @@ var _ = Describe("REST Adapter", func() { It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) pls := &model.Playlist{Name: "Updated"} - err := repo.Update("nonexistent", pls) + err := repo.Update(ctx, "nonexistent", *pls) Expect(err).To(Equal(rest.ErrNotFound)) }) @@ -274,8 +274,8 @@ var _ = Describe("REST Adapter", func() { }) It("preserves name and comment when only public is sent (bulk Make Public)", func() { - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("partial", &model.Playlist{Public: true}, "public") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "partial", model.Playlist{Public: true}, "public") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) @@ -290,16 +290,16 @@ var _ = Describe("REST Adapter", func() { Path: "/music/p.m3u", Sync: true, } - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "file-partial", model.Playlist{Sync: false}, "sync") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) Expect(mockPlsRepo.Last.Sync).To(BeFalse()) }) It("renames the playlist when only name is sent", func() { - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "partial", model.Playlist{Name: "Renamed"}, "name") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) @@ -307,8 +307,8 @@ var _ = Describe("REST Adapter", func() { }) It("clears the comment when an empty comment is sent explicitly", func() { - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "partial", model.Playlist{Comment: ""}, "comment") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) @@ -323,9 +323,9 @@ var _ = Describe("REST Adapter", func() { Public: true, Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, } - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} - err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules") + err := repo.Update(ctx, "smart-partial", model.Playlist{Rules: newRules}, "rules") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) @@ -342,9 +342,9 @@ var _ = Describe("REST Adapter", func() { Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, EvaluatedAt: &evaluatedAt, } - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}} - err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules") + err := repo.Update(ctx, "smart-reset", model.Playlist{Rules: newRules}, "rules") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil()) }) @@ -358,8 +358,8 @@ var _ = Describe("REST Adapter", func() { Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, EvaluatedAt: &evaluatedAt, } - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "smart-keep", model.Playlist{Name: "Renamed Smart"}, "name") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil()) Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) @@ -373,10 +373,10 @@ var _ = Describe("REST Adapter", func() { OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, } - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} - err := repo.Update("smart-edit", - &model.Playlist{Name: "Smart Renamed", Rules: newRules}, + err := repo.Update(ctx, "smart-edit", + model.Playlist{Name: "Smart Renamed", Rules: newRules}, "name", "rules") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) @@ -392,11 +392,11 @@ var _ = Describe("REST Adapter", func() { OwnerID: "user-1", Rules: rules, } - repo = ps.NewRepository(ctx).(rest.Persistable) + repo = ps.Repository().(rest.Persistable[model.Playlist]) // Same rules sent back — rulesEqual should report no change and // the request should no-op (no Put call). sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} - err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules") + err := repo.Update(ctx, "smart-idempotent", model.Playlist{Rules: sameRules}, "rules") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened }) @@ -410,8 +410,8 @@ var _ = Describe("REST Adapter", func() { Public: false, Rules: rules, } - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("smart-public", &model.Playlist{Public: true}, "public") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "smart-public", model.Playlist{Public: true}, "public") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Public).To(BeTrue()) Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) @@ -421,8 +421,8 @@ var _ = Describe("REST Adapter", func() { It("does not treat a missing ownerId as an ownership transfer attempt", func() { // A non-admin user sending only {public:true} should not be blocked // just because OwnerID is the zero value in the deserialized entity. - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("partial", &model.Playlist{Public: true}, "public") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "partial", model.Playlist{Public: true}, "public") Expect(err).ToNot(HaveOccurred()) }) @@ -431,8 +431,8 @@ var _ = Describe("REST Adapter", func() { // like {"Name":"x"}, but rest.Put's field-name extraction is // case-sensitive. sentFields normalizes both sides so a request // with {"Name":"Renamed"} is honored, not silently ignored. - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Update(ctx, "partial", model.Playlist{Name: "Renamed"}, "Name") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) @@ -443,16 +443,16 @@ var _ = Describe("REST Adapter", func() { Describe("Delete", func() { It("delegates to service Delete with permission checks", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Delete("pls-1") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Delete(ctx, "pls-1") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) }) It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - repo = ps.NewRepository(ctx).(rest.Persistable) - err := repo.Delete("pls-1") + repo = ps.Repository().(rest.Persistable[model.Playlist]) + err := repo.Delete(ctx, "pls-1") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index c5c096a0b..30bced623 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -70,7 +70,7 @@ type bufferedScrobbler struct { loader Loader service string wakeSignal chan struct{} - ctx context.Context + ctx context.Context //nolint:containedctx // scrobbler lifecycle ctx, cancelled by Stop cancel context.CancelFunc } @@ -97,7 +97,7 @@ func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track } func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { - err := b.ds.ScrobbleBuffer(ctx).Enqueue(b.service, userId, s.ID, s.TimeStamp) + err := b.ds.ScrobbleBuffer().Enqueue(ctx, b.service, userId, s.ID, s.TimeStamp) if err != nil { return err } @@ -154,8 +154,8 @@ func (b *bufferedScrobbler) run(ctx context.Context) { } func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) { - buffer := b.ds.ScrobbleBuffer(ctx) - userIds, err := buffer.UserIDs(b.service) + buffer := b.ds.ScrobbleBuffer() + userIds, err := buffer.UserIDs(ctx, b.service) if err != nil { log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err) return false, 0 @@ -176,14 +176,14 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) // Scrobbles are drained on a background context that no longer carries the // request's authenticated user. Restore it from the buffered userId so that // scrobblers relying on the user in the context (e.g. plugins) still get it. - if user, err := b.ds.User(ctx).Get(userId); err != nil { + if user, err := b.ds.User().Get(ctx, userId); err != nil { log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err) } else { ctx = request.WithUser(ctx, *user) } - buffer := b.ds.ScrobbleBuffer(ctx) + buffer := b.ds.ScrobbleBuffer() for { - entry, err := buffer.Next(b.service, userId) + entry, err := buffer.Next(ctx, b.service, userId) if err != nil { log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err) return false, 0 @@ -210,7 +210,7 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service, "userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err) } - err = buffer.Dequeue(entry) + err = buffer.Dequeue(ctx, entry) if err != nil { log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index 16172194b..6ecd92cab 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -26,7 +26,7 @@ var _ = Describe("BufferedScrobbler", func() { ctx = context.Background() buffer = tests.CreateMockedScrobbleBufferRepo() userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) + Expect(userRepo.Put(ctx, &model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{ MockedScrobbleBuffer: buffer, MockedUser: userRepo, @@ -55,7 +55,7 @@ var _ = Describe("BufferedScrobbler", func() { track := model.MediaFile{ID: "123", Title: "Test Track"} now := time.Now() scrobble := Scrobble{MediaFile: track, TimeStamp: now} - Expect(buffer.Length()).To(Equal(int64(0))) + Expect(buffer.Length(ctx)).To(Equal(int64(0))) Expect(scr.ScrobbleCalled.Load()).To(BeFalse()) Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) @@ -131,7 +131,7 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) { g := NewWithT(t) buffer := tests.CreateMockedScrobbleBufferRepo() userRepo := tests.CreateMockUserRepo() - g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) + g.Expect(userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} flaky := &recoveringScrobbler{} @@ -147,7 +147,7 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) { // First attempt fires immediately on the enqueue wake and is left buffered. synctest.Wait() g.Expect(flaky.count.Load()).To(Equal(int32(1))) - g.Expect(buffer.Length()).To(Equal(int64(1))) + g.Expect(buffer.Length(t.Context())).To(Equal(int64(1))) // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { @@ -165,10 +165,10 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) { flaky.succeed() bs.sendWakeSignal() synctest.Wait() - g.Expect(buffer.Length()).To(Equal(int64(1)), "wake during backoff drained early") + g.Expect(buffer.Length(t.Context())).To(Equal(int64(1)), "wake during backoff drained early") time.Sleep(80 * time.Second) synctest.Wait() - g.Expect(buffer.Length()).To(Equal(int64(0))) + g.Expect(buffer.Length(t.Context())).To(Equal(int64(0))) }) } @@ -176,7 +176,7 @@ func TestBufferedScrobblerBackoffWindow(t *testing.T) { synctest.Test(t, func(t *testing.T) { buffer := tests.CreateMockedScrobbleBufferRepo() userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} scr := &fakeScrobbler{Authorized: true} scr.SetError(errors.Join(errors.New("boom"), ErrRetryLater)) @@ -211,7 +211,7 @@ func TestBufferedScrobblerHonorsServerDelay(t *testing.T) { synctest.Test(t, func(t *testing.T) { buffer := tests.CreateMockedScrobbleBufferRepo() userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} scr := &fakeScrobbler{Authorized: true} scr.SetError(errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: 30 * time.Second})) @@ -244,8 +244,8 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { synctest.Test(t, func(t *testing.T) { buffer := tests.CreateMockedScrobbleBufferRepo() userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) - _ = userRepo.Put(&model.User{ID: "user2", UserName: "bob"}) + _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) + _ = userRepo.Put(t.Context(), &model.User{ID: "user2", UserName: "bob"}) ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} scr := &recoveringScrobbler{delays: map[string]time.Duration{ "user1": 10 * time.Second, @@ -253,8 +253,8 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { }} // Both are buffered before the drain goroutine exists: it drains once on startup, and // seeing only one user there would park it on that user's delay, ignoring the other. - _ = buffer.Enqueue("test", "user1", "1", time.Now()) - _ = buffer.Enqueue("test", "user2", "2", time.Now()) + _ = buffer.Enqueue(t.Context(), "test", "user1", "1", time.Now()) + _ = buffer.Enqueue(t.Context(), "test", "user2", "2", time.Now()) bs := newBufferedScrobbler(ds, scr, "test") defer bs.Stop() diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index e68fcd942..541a8e92b 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -68,14 +68,14 @@ type ReportPlaybackParams struct { } type nowPlayingEntry struct { - ctx context.Context + ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker userId string track *model.MediaFile position int } type playbackReportEntry struct { - ctx context.Context + ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker info PlaybackSession filtered bool } @@ -293,7 +293,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) return nil } - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) if err != nil { return err } @@ -328,7 +328,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) if getErr != nil || info.MediaFile.ID != params.MediaId { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) if err != nil { return err } @@ -364,7 +364,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP var loadedMF *model.MediaFile haveVerdict := false if !params.IgnoreScrobble && player.ScrobbleEnabled { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) if err != nil { return err } @@ -409,7 +409,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP mf := loadedMF if mf == nil { var mfErr error - mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + mf, mfErr = p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) if mfErr != nil { return mfErr } @@ -477,7 +477,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro success := 0 for _, s := range submissions { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(s.TrackID) + mf, err := p.ds.MediaFile().GetWithParticipants(ctx, s.TrackID) if err != nil { log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err) continue @@ -504,22 +504,22 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error { return p.ds.WithTx(func(tx model.DataStore) error { - err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp) + err := tx.MediaFile().IncPlayCount(ctx, track.ID, timestamp) if err != nil { return err } - err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp) + err = tx.Album().IncPlayCount(ctx, track.AlbumID, timestamp) if err != nil { return err } for _, artist := range track.Participants[model.RoleArtist] { - err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp) + err = tx.Artist().IncPlayCount(ctx, artist.ID, timestamp) if err != nil { return err } } if conf.Server.EnableScrobbleHistory { - return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp) + return tx.Scrobble().RecordScrobble(ctx, track.ID, timestamp) } return nil }) @@ -538,7 +538,7 @@ func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) boo log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err) return false } - match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c) + match, err := p.ds.MediaFile().MatchesCriteria(ctx, t.ID, c) if err != nil { log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err) return false diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index d79233e15..045827811 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -55,12 +55,12 @@ type flipOnPlayRepo struct { played atomic.Bool } -func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error { +func (r *flipOnPlayRepo) IncPlayCount(ctx context.Context, id string, ts time.Time) error { r.played.Store(true) - return r.MediaFileRepository.IncPlayCount(id, ts) + return r.MediaFileRepository.IncPlayCount(ctx, id, ts) } -func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) { +func (r *flipOnPlayRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) { if r.played.Load() { return r.after, nil } @@ -73,9 +73,9 @@ type slowMediaFileRepo struct { model.MediaFileRepository } -func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { +func (s *slowMediaFileRepo) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) { time.Sleep(5 * time.Millisecond) - return s.MediaFileRepository.GetWithParticipants(id) + return s.MediaFileRepository.GetWithParticipants(ctx, id) } var _ = Describe("PlayTracker", func() { @@ -119,13 +119,13 @@ var _ = Describe("PlayTracker", func() { model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1"), _p("ar-2", "Artist 2")}, }, } - _ = ds.MediaFile(ctx).Put(&track) + _ = ds.MediaFile().Put(ctx, &track) artist1 = model.Artist{ID: "ar-1"} - _ = ds.Artist(ctx).Put(&artist1) + _ = ds.Artist().Put(ctx, &artist1) artist2 = model.Artist{ID: "ar-2"} - _ = ds.Artist(ctx).Put(&artist2) + _ = ds.Artist().Put(ctx, &artist2) album = model.Album{ID: "al-1"} - _ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album) + _ = ds.Album().(*tests.MockAlbumRepo).Put(ctx, &album) }) AfterEach(func() { @@ -149,7 +149,7 @@ var _ = Describe("PlayTracker", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" - _ = ds.MediaFile(ctx).Put(&track2) + _ = ds.MediaFile().Put(ctx, &track2) ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ @@ -180,7 +180,7 @@ var _ = Describe("PlayTracker", func() { hidden := track hidden.ID = "789" hidden.LibraryID = 2 - _ = ds.MediaFile(ctx).Put(&hidden) + _ = ds.MediaFile().Put(ctx, &hidden) reporter := request.WithPlayer( request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), model.Player{ScrobbleEnabled: true}, @@ -199,7 +199,7 @@ var _ = Describe("PlayTracker", func() { hidden := track hidden.ID = "789" hidden.LibraryID = 2 - _ = ds.MediaFile(ctx).Put(&hidden) + _ = ds.MediaFile().Put(ctx, &hidden) reporter := request.WithPlayer( request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), model.Player{ScrobbleEnabled: true}, @@ -360,7 +360,7 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) mockDS := ds.(*tests.MockDataStore) - mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) + mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo) Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) @@ -376,7 +376,7 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) mockDS := ds.(*tests.MockDataStore) - mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) + mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo) Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0)) }) }) @@ -388,7 +388,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`}) - repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + repo = ds.MediaFile().(*tests.MockMediaFileRepo) }) It("does not send a matching track to the agent", func() { @@ -489,7 +489,7 @@ var _ = Describe("PlayTracker", func() { var flip *flipOnPlayRepo install := func(before, after bool) { - flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after} + flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(), before: before, after: after} ds.(*tests.MockDataStore).MockedMediaFile = flip } @@ -632,7 +632,7 @@ var _ = Describe("PlayTracker", func() { It("starting replaces existing entry when switching tracks on same player", func() { track2 := track track2.ID = "456" - _ = ds.MediaFile(ctx).Put(&track2) + _ = ds.MediaFile().Put(ctx, &track2) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, @@ -659,7 +659,7 @@ var _ = Describe("PlayTracker", func() { track2 := track track2.ID = "456" - _ = ds.MediaFile(ctx).Put(&track2) + _ = ds.MediaFile().Put(ctx, &track2) err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", @@ -760,7 +760,7 @@ var _ = Describe("PlayTracker", func() { model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, }, } - _ = ds.MediaFile(ctx).Put(&longTrack) + _ = ds.MediaFile().Put(ctx, &longTrack) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, @@ -958,7 +958,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { track2 := track track2.ID = "456" - _ = ds.MediaFile(ctx).Put(&track2) + _ = ds.MediaFile().Put(ctx, &track2) }) It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() { @@ -1023,7 +1023,7 @@ var _ = Describe("PlayTracker", func() { }) It("never lets a concurrent starting report downgrade the playing session", func() { - ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)} + ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile()} for i := range 20 { raceClientId := fmt.Sprintf("race-client-%d", i) var wg sync.WaitGroup diff --git a/core/share.go b/core/share.go index a291a060c..33c8996ef 100644 --- a/core/share.go +++ b/core/share.go @@ -8,7 +8,6 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,22 +20,24 @@ import ( type Share interface { Load(ctx context.Context, id string) (*model.Share, error) - NewRepository(ctx context.Context) rest.Repository + Repository() model.ShareRepository } func NewShare(ds model.DataStore) Share { return &shareService{ - ds: ds, + ds: ds, + repo: &shareRepositoryWrapper{ShareRepository: ds.Share(), ds: ds}, } } type shareService struct { - ds model.DataStore + ds model.DataStore + repo *shareRepositoryWrapper } func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) { - repo := s.ds.Share(ctx) - share, err := repo.Get(id) + repo := s.ds.Share() + share, err := repo.Get(ctx, id) if err != nil { return nil, err } @@ -47,40 +48,29 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error share.LastVisitedAt = new(time.Now()) share.VisitCount++ - err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") + err = repo.Update(ctx, id, *share, "last_visited_at", "visit_count") if err != nil { log.Warn(ctx, "Could not increment visit count for share", "share", share.ID) } return share, nil } -func (s *shareService) NewRepository(ctx context.Context) rest.Repository { - repo := s.ds.Share(ctx) - wrapper := &shareRepositoryWrapper{ - ctx: ctx, - ShareRepository: repo, - Repository: repo.(rest.Repository), - Persistable: repo.(rest.Persistable), - ds: s.ds, - } - return wrapper +func (s *shareService) Repository() model.ShareRepository { + return s.repo } type shareRepositoryWrapper struct { model.ShareRepository - rest.Repository - rest.Persistable - ctx context.Context - ds model.DataStore + ds model.DataStore } -func (r *shareRepositoryWrapper) newId() (string, error) { +func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) { for { id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) if err != nil { return "", err } - exists, err := r.Exists(id) + exists, err := r.Exists(ctx, id) if err != nil { return "", err } @@ -90,14 +80,13 @@ func (r *shareRepositoryWrapper) newId() (string, error) { } } -func (r *shareRepositoryWrapper) Save(entity any) (string, error) { - s := entity.(*model.Share) +func (r *shareRepositoryWrapper) Save(ctx context.Context, s *model.Share) (string, error) { // Owner is always the caller; never trust a client-supplied UserID, as it // determines the library-access context used to resolve the share contents. - if user, ok := request.UserFrom(r.ctx); ok { + if user, ok := request.UserFrom(ctx); ok { s.UserID = user.ID } - id, err := r.newId() + id, err := r.newId(ctx) if err != nil { return "", err } @@ -106,39 +95,39 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - s.ResourceType, err = r.resourceType(s.ResourceIDs) + s.ResourceType, err = r.resourceType(ctx, s.ResourceIDs) if err != nil { return "", err } switch s.ResourceType { case "artist": - s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs) + s.Contents = r.contentsLabelFromArtist(ctx, s.ID, s.ResourceIDs) case "album": - s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs) + s.Contents = r.contentsLabelFromAlbums(ctx, s.ID, s.ResourceIDs) case "playlist": - s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs) + s.Contents = r.contentsLabelFromPlaylist(ctx, s.ID, s.ResourceIDs) case "media_file": - s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs) + s.Contents = r.contentsLabelFromMediaFiles(ctx, s.ID, s.ResourceIDs) } s.Contents = str.TruncateRunes(s.Contents, 30, "...") - return r.Persistable.Save(s) + return r.ShareRepository.Save(ctx, s) } var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork} // resourceType resolves every ID as the current user, so an entity they cannot see cannot // ride along behind a valid first one, and requires all IDs to be of the same kind. -func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error) { +func (r *shareRepositoryWrapper) resourceType(ctx context.Context, resourceIDs string) (string, error) { resourceType := "" for _, id := range strings.Split(resourceIDs, ",") { - kind, err := model.GetEntityKindByID(r.ctx, r.ds, id) + kind, err := model.GetEntityKindByID(ctx, r.ds, id) if err != nil { return "", err } if !slices.Contains(shareableKinds, kind) { - log.Error(r.ctx, "Invalid Resource ID", "id", id) + log.Error(ctx, "Invalid Resource ID", "id", id) return "", model.ErrNotFound } if resourceType != "" && kind.String() != resourceType { @@ -149,53 +138,53 @@ func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error return resourceType, nil } -func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { +func (r *shareRepositoryWrapper) Update(ctx context.Context, id string, entity model.Share, _ ...string) error { cols := []string{"description", "downloadable"} // TODO Better handling of Share expiration - if !V(entity.(*model.Share).ExpiresAt).IsZero() { + if !V(entity.ExpiresAt).IsZero() { cols = append(cols, "expires_at") } - return r.Persistable.Update(id, entity, cols...) + return r.ShareRepository.Update(ctx, id, entity, cols...) } -func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromArtist(ctx context.Context, shareID string, ids string) string { idList := strings.SplitN(ids, ",", 2) - a, err := r.ds.Artist(r.ctx).Get(idList[0]) + a, err := r.ds.Artist().Get(ctx, idList[0]) if err != nil { - log.Error(r.ctx, "Error retrieving artist name for share", "share", shareID, err) + log.Error(ctx, "Error retrieving artist name for share", "share", shareID, err) return "" } return a.Name } -func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromAlbums(ctx context.Context, shareID string, ids string) string { idList := strings.Split(ids, ",") - all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) + all, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) if err != nil { - log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) + log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) return "" } names := slice.Map(all, func(a model.Album) string { return a.Name }) return strings.Join(names, ", ") } -func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(shareID string, id string) string { - pls, err := r.ds.Playlist(r.ctx).Get(id) +func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(ctx context.Context, shareID string, id string) string { + pls, err := r.ds.Playlist().Get(ctx, id) if err != nil { - log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) + log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) return "" } return pls.Name } -func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(ctx context.Context, shareID string, ids string) string { idList := strings.Split(ids, ",") - mfs, err := r.ds.MediaFile(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.And{ + mfs, err := r.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.And{ squirrel.Eq{"media_file.id": idList}, squirrel.Eq{"missing": false}, }}) if err != nil { - log.Error(r.ctx, "Error retrieving media files for share", "share", shareID, err) + log.Error(ctx, "Error retrieving media files for share", "share", shareID, err) return "" } diff --git a/core/share_test.go b/core/share_test.go index e6dec1b92..8ce2bf270 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -14,27 +14,27 @@ import ( var _ = Describe("Share", func() { var ds model.DataStore var share Share - var mockedRepo rest.Persistable + var mockedRepo rest.Persistable[model.Share] ctx := context.Background() BeforeEach(func() { ds = &tests.MockDataStore{} - mockedRepo = ds.Share(ctx).(rest.Persistable) + mockedRepo = ds.Share().(rest.Persistable[model.Share]) share = NewShare(ds) }) Describe("NewRepository", func() { - var repo rest.Persistable + var repo rest.Persistable[model.Share] BeforeEach(func() { - repo = share.NewRepository(ctx).(rest.Persistable) - _ = ds.Album(ctx).Put(&model.Album{ID: "123", Name: "Album"}) + repo = share.Repository().(rest.Persistable[model.Share]) + _ = ds.Album().Put(ctx, &model.Album{ID: "123", Name: "Album"}) }) Describe("Save", func() { It("it sets a random ID", func() { entity := &model.Share{Description: "test", ResourceIDs: "123"} - id, err := repo.Save(entity) + id, err := repo.Save(ctx, entity) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeEmpty()) Expect(entity.ID).To(Equal(id)) @@ -42,63 +42,62 @@ var _ = Describe("Share", func() { It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"}) - repo := share.NewRepository(loggedInCtx).(rest.Persistable) + repo := share.Repository().(rest.Persistable[model.Share]) entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"} - _, err := repo.Save(entity) + _, err := repo.Save(loggedInCtx, entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.UserID).To(Equal("logged-in-user")) }) It("does not truncate ASCII labels shorter than 30 characters", func() { - _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} - _, err := repo.Save(entity) + _, err := repo.Save(ctx, entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("Example Media File")) }) It("truncates ASCII labels longer than 30 characters", func() { - _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) + _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) entity := &model.Share{Description: "test", ResourceIDs: "789"} - _, err := repo.Save(entity) + _, err := repo.Save(ctx, entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("Example Media File But The ...")) }) It("does not truncate CJK labels shorter than 30 runes", func() { - _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"}) + _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "青春コンプレックス"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} - _, err := repo.Save(entity) + _, err := repo.Save(ctx, entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("青春コンプレックス")) }) It("truncates CJK labels longer than 30 runes", func() { - _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) + _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) entity := &model.Share{Description: "test", ResourceIDs: "789"} - _, err := repo.Save(entity) + _, err := repo.Save(ctx, entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) }) It("fails when any of the resource IDs does not exist", func() { entity := &model.Share{Description: "test", ResourceIDs: "123,missing"} - _, err := repo.Save(entity) + _, err := repo.Save(ctx, entity) Expect(err).To(MatchError(model.ErrNotFound)) }) It("fails when the resource IDs are of mixed types", func() { - _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"}) entity := &model.Share{Description: "test", ResourceIDs: "123,456"} - _, err := repo.Save(entity) + _, err := repo.Save(ctx, entity) Expect(err).To(HaveOccurred()) }) }) Describe("Update", func() { It("filters out read-only fields", func() { - entity := &model.Share{} - err := repo.Update("id", entity) + err := repo.Update(ctx, "id", model.Share{}) Expect(err).ToNot(HaveOccurred()) Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description", "downloadable")) }) diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go index 67f5cc7da..8645d3d28 100644 --- a/core/sonic/sonic.go +++ b/core/sonic/sonic.go @@ -100,7 +100,7 @@ func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) return nil, err } - mf, err := s.ds.MediaFile(ctx).Get(id) + mf, err := s.ds.MediaFile().Get(ctx, id) if err != nil { return nil, fmt.Errorf("getting media file %s: %w", id, err) } @@ -120,11 +120,11 @@ func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count return nil, err } - startMF, err := s.ds.MediaFile(ctx).Get(startID) + startMF, err := s.ds.MediaFile().Get(ctx, startID) if err != nil { return nil, fmt.Errorf("getting start media file %s: %w", startID, err) } - endMF, err := s.ds.MediaFile(ctx).Get(endID) + endMF, err := s.ds.MediaFile().Get(ctx, endID) if err != nil { return nil, fmt.Errorf("getting end media file %s: %w", endID, err) } diff --git a/core/stream/decider.go b/core/stream/decider.go index 886898206..38839cdf0 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -312,7 +312,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai // It checks the DB first (for user-customized values), then falls back to // the built-in defaults, and finally to fallbackBitrate. func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int { - if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 { + if t, err := ds.Transcoding().FindByFormat(ctx, format); err == nil && t.DefaultBitRate > 0 { return t.DefaultBitRate } for _, dt := range consts.DefaultTranscodings { @@ -327,7 +327,7 @@ func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string // It checks the DB first (for user-customized commands), then falls back to // the built-in default command. Returns "" if the format is unknown. func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string { - t, err := ds.Transcoding(ctx).FindByFormat(format) + t, err := ds.Transcoding().FindByFormat(ctx, format) if err == nil && t.Command != "" { return t.Command } @@ -447,7 +447,7 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) } mf.ProbeData = string(data) - if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil { + if err := s.ds.MediaFile().UpdateProbeData(ctx, mf.ID, mf.ProbeData); err != nil { log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err) // Don't fail the decision — we have the data in memory } diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index 6db2f6338..c250b0c4e 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -133,7 +133,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } type Stream struct { - ctx context.Context + ctx context.Context //nolint:containedctx // stream outlives the call that built it; Read has no ctx mf *model.MediaFile bitRate int format string diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index e06599208..5a4bcd480 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -36,7 +36,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.CacheFolder = conf.NewDir(cacheDir) conf.Server.TranscodingCacheSize = "100MB" ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) testCache := stream.NewTranscodingCache() @@ -51,7 +51,7 @@ var _ = Describe("MediaStreamer", func() { var mf *model.MediaFile BeforeEach(func() { var err error - mf, err = ds.MediaFile(ctx).Get("123") + mf, err = ds.MediaFile().Get(ctx, "123") Expect(err).ToNot(HaveOccurred()) }) It("returns a seekable stream if format is 'raw'", func() { @@ -151,7 +151,7 @@ var _ = Describe("MediaStreamer", func() { var mf *model.MediaFile BeforeEach(func() { var err error - mf, err = ds.MediaFile(ctx).Get("123") + mf, err = ds.MediaFile().Get(ctx, "123") Expect(err).ToNot(HaveOccurred()) }) diff --git a/core/user.go b/core/user.go index f13e90167..d67d69dcb 100644 --- a/core/user.go +++ b/core/user.go @@ -15,62 +15,40 @@ type PluginUnloader interface { // User provides business logic for user management with plugin coordination. type User interface { - NewRepository(ctx context.Context) rest.Repository + Repository() rest.Repository[model.User] } type userService struct { - ds model.DataStore - pluginManager PluginUnloader + repo *userRepositoryWrapper } // NewUser creates a new User service func NewUser(ds model.DataStore, pluginManager PluginUnloader) User { return &userService{ - ds: ds, - pluginManager: pluginManager, + repo: &userRepositoryWrapper{ + UserRepository: ds.User(), + pluginManager: pluginManager, + }, } } -// NewRepository returns a REST repository wrapper for user operations. +// Repository returns a REST repository wrapper for user operations. // The wrapper intercepts Delete operations to coordinate plugin unloading. -func (s *userService) NewRepository(ctx context.Context) rest.Repository { - repo := s.ds.User(ctx) - wrapper := &userRepositoryWrapper{ - ctx: ctx, - UserRepository: repo, - pluginManager: s.pluginManager, - } - return wrapper +func (s *userService) Repository() rest.Repository[model.User] { + return s.repo } type userRepositoryWrapper struct { model.UserRepository - ctx context.Context pluginManager PluginUnloader } -// Save implements rest.Persistable by delegating to the underlying repository. -func (r *userRepositoryWrapper) Save(entity any) (string, error) { - return r.UserRepository.(rest.Persistable).Save(entity) -} - -// Update implements rest.Persistable by delegating to the underlying repository. -func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error { - return r.UserRepository.(rest.Persistable).Update(id, entity, cols...) -} - -// Delete implements rest.Persistable and coordinates plugin unloading. -func (r *userRepositoryWrapper) Delete(id string) error { - // The underlying repository Delete handles the database cleanup - // including calling cleanupPluginUserReferences - err := r.UserRepository.(rest.Persistable).Delete(id) - if err != nil { - return err - } - - // After successful deletion, check if any plugins were auto-disabled - // and need to be unloaded from memory - r.pluginManager.UnloadDisabledPlugins(r.ctx) - - return nil +var _ rest.Persistable[model.User] = (*userRepositoryWrapper)(nil) + +// Delete unloads plugins even on error: a bulk delete can fail after earlier users were removed +// and their plugins auto-disabled. +func (r *userRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { + err := r.UserRepository.Delete(ctx, ids...) + r.pluginManager.UnloadDisabledPlugins(ctx) + return err } diff --git a/core/user_test.go b/core/user_test.go index b2d3117f8..880637770 100644 --- a/core/user_test.go +++ b/core/user_test.go @@ -29,19 +29,19 @@ var _ = Describe("User Service", func() { }) Describe("NewRepository", func() { - It("returns a rest.Persistable", func() { - repo := service.NewRepository(ctx) - _, ok := repo.(rest.Persistable) + It("returns a rest.Persistable[model.User]", func() { + repo := service.Repository() + _, ok := repo.(rest.Persistable[model.User]) Expect(ok).To(BeTrue()) }) }) Describe("Delete", func() { - var repo rest.Persistable + var repo rest.Persistable[model.User] BeforeEach(func() { - r := service.NewRepository(ctx) - repo = r.(rest.Persistable) + r := service.Repository() + repo = r.(rest.Persistable[model.User]) // Add a test user user := &model.User{ @@ -50,37 +50,45 @@ var _ = Describe("User Service", func() { IsAdmin: false, } user.NewPassword = "password" - Expect(userRepo.Put(user)).To(Succeed()) + Expect(userRepo.Put(ctx, user)).To(Succeed()) }) It("deletes the user successfully", func() { - err := repo.Delete("user-123") + err := repo.Delete(ctx, "user-123") Expect(err).NotTo(HaveOccurred()) // Verify user is deleted - _, err = userRepo.Get("user-123") + _, err = userRepo.Get(ctx, "user-123") Expect(err).To(Equal(model.ErrNotFound)) }) It("calls UnloadDisabledPlugins after successful deletion", func() { - err := repo.Delete("user-123") + err := repo.Delete(ctx, "user-123") Expect(err).NotTo(HaveOccurred()) Expect(pluginManager.unloadCalls).To(Equal(1)) }) - It("does not call UnloadDisabledPlugins when deletion fails", func() { - // Try to delete non-existent user - err := repo.Delete("non-existent") - Expect(err).To(HaveOccurred()) - Expect(pluginManager.unloadCalls).To(Equal(0)) + It("still calls UnloadDisabledPlugins when deletion fails", func() { + err := repo.Delete(ctx, "non-existent") + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(pluginManager.unloadCalls).To(Equal(1)) + }) + + It("unloads plugins when a bulk delete fails after removing earlier users", func() { + err := repo.Delete(ctx, "user-123", "non-existent") + Expect(err).To(MatchError(model.ErrNotFound)) + + _, err = userRepo.Get(ctx, "user-123") + Expect(err).To(Equal(model.ErrNotFound)) + Expect(pluginManager.unloadCalls).To(Equal(1)) }) It("returns error when repository fails", func() { userRepo.Error = errors.New("database error") - err := repo.Delete("user-123") + err := repo.Delete(ctx, "user-123") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("database error")) - Expect(pluginManager.unloadCalls).To(Equal(0)) + Expect(pluginManager.unloadCalls).To(Equal(1)) }) }) }) diff --git a/db/db.go b/db/db.go index a9c6c4a15..66e48cede 100644 --- a/db/db.go +++ b/db/db.go @@ -184,7 +184,7 @@ func isSchemaEmpty(ctx context.Context, db *sql.DB) bool { } type logAdapter struct { - ctx context.Context + ctx context.Context //nolint:containedctx // goose logger interface has no ctx silent bool } diff --git a/go.mod b/go.mod index 9a693d651..01fb0f73d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.5 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 + github.com/deluan/rest v1.0.1 github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 diff --git a/go.sum b/go.sum index 4cda9a2f3..bcc1395ba 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk= github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= -github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 h1:x4N/tx0XC9zcnipk5rpdI4qaG9JmovHW5HL97XYnO1I= -github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= +github.com/deluan/rest v1.0.1 h1:Enuzzfd88C1/lG6Jqr2NgRreJoClaFuWcOuspeKWHZ8= +github.com/deluan/rest v1.0.1/go.mod h1:r0yO0VgBWOb5Xb7aCPIedtcOuwmA2Oe5lGailC5pvc4= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA= @@ -109,8 +109,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -128,8 +126,6 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8 github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -138,7 +134,6 @@ github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -231,13 +226,8 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -252,7 +242,6 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -310,7 +299,6 @@ golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= @@ -318,7 +306,6 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= @@ -333,7 +320,6 @@ golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/model/album.go b/model/album.go index 114d19e1e..a43195419 100644 --- a/model/album.go +++ b/model/album.go @@ -1,14 +1,15 @@ package model import ( + "context" "iter" "math" "sync" "time" - "github.com/navidrome/navidrome/conf" - + "github.com/deluan/rest" "github.com/gohugoio/hashstructure" + "github.com/navidrome/navidrome/conf" ) type Album struct { @@ -137,24 +138,25 @@ type Albums []Album type AlbumCursor iter.Seq2[Album, error] type AlbumRepository interface { - CountAll(...QueryOptions) (int64, error) - Exists(id string) (bool, error) - Put(*Album) error - UpdateExternalInfo(*Album) error - Get(id string) (*Album, error) - GetAll(...QueryOptions) (Albums, error) + rest.Repository[Album] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Exists(ctx context.Context, id string) (bool, error) + Put(ctx context.Context, m *Album) error + UpdateExternalInfo(ctx context.Context, m *Album) error + Get(ctx context.Context, id string) (*Album, error) + GetAll(ctx context.Context, options ...QueryOptions) (Albums, error) // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in // any of the given library-relative subtrees. - GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error) - GetCursor(...QueryOptions) (AlbumCursor, error) - GetYears(libraryIDs ...int) ([]int, error) + GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib Library, paths ...string) ([]string, error) + GetCursor(ctx context.Context, options ...QueryOptions) (AlbumCursor, error) + GetYears(ctx context.Context, libraryIDs ...int) ([]int, error) // The following methods are used exclusively by the scanner: - Touch(ids ...string) error - TouchByMissingFolder() (int64, error) - GetTouchedAlbums(libID int) (AlbumCursor, error) - RefreshPlayCounts() (int64, error) - CopyAttributes(fromID, toID string, columns ...string) error + Touch(ctx context.Context, ids ...string) error + TouchByMissingFolder(ctx context.Context) (int64, error) + GetTouchedAlbums(ctx context.Context, libID int) (AlbumCursor, error) + RefreshPlayCounts(ctx context.Context) (int64, error) + CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error AnnotatedRepository SearchableRepository[Albums] diff --git a/model/annotation.go b/model/annotation.go index 5228028a6..64b8ddc17 100644 --- a/model/annotation.go +++ b/model/annotation.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "context" + "time" +) type Annotations struct { PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` @@ -13,8 +16,8 @@ type Annotations struct { } type AnnotatedRepository interface { - IncPlayCount(itemID string, ts time.Time) error - SetStar(starred bool, itemIDs ...string) error - SetRating(rating int, itemID string) error - ReassignAnnotation(prevID string, newID string) error + IncPlayCount(ctx context.Context, itemID string, ts time.Time) error + SetStar(ctx context.Context, starred bool, itemIDs ...string) error + SetRating(ctx context.Context, rating int, itemID string) error + ReassignAnnotation(ctx context.Context, prevID string, newID string) error } diff --git a/model/artist.go b/model/artist.go index f88b3a974..985cc6c7b 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,11 +1,13 @@ package model import ( + "context" "iter" "maps" "slices" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/consts" ) @@ -84,18 +86,19 @@ type ArtistIndexes []ArtistIndex type ArtistCursor iter.Seq2[Artist, error] type ArtistRepository interface { - CountAll(options ...QueryOptions) (int64, error) - Exists(id string) (bool, error) - Put(m *Artist, colsToUpdate ...string) error - UpdateExternalInfo(a *Artist) error - Get(id string) (*Artist, error) - GetAll(options ...QueryOptions) (Artists, error) - GetCursor(options ...QueryOptions) (ArtistCursor, error) - GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) + rest.Repository[Artist] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Exists(ctx context.Context, id string) (bool, error) + Put(ctx context.Context, m *Artist, colsToUpdate ...string) error + UpdateExternalInfo(ctx context.Context, a *Artist) error + Get(ctx context.Context, id string) (*Artist, error) + GetAll(ctx context.Context, options ...QueryOptions) (Artists, error) + GetCursor(ctx context.Context, options ...QueryOptions) (ArtistCursor, error) + GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: - RefreshPlayCounts() (int64, error) - RefreshStats(allArtists bool) (int64, error) + RefreshPlayCounts(ctx context.Context) (int64, error) + RefreshStats(ctx context.Context, allArtists bool) (int64, error) AnnotatedRepository SearchableRepository[Artists] diff --git a/model/artwork.go b/model/artwork.go index 8856a35fa..d5f62ab13 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "context" + "time" +) // Artwork is one unique image, identified by the XXH3-64 hash of its bytes. type Artwork struct { @@ -116,60 +119,60 @@ const ( // Delete* takes the rows to remove; Purge* finds them itself and reports how many went. type ArtworkRepository interface { - GetImage(hash string) (*Artwork, error) - PutImage(a *Artwork) error + GetImage(ctx context.Context, hash string) (*Artwork, error) + PutImage(ctx context.Context, a *Artwork) error // PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff. - PurgeOrphans(createdBefore time.Time) (int64, error) - GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error) - PutItemArtwork(ia *ItemArtwork) error + PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error) + GetItemArtwork(ctx context.Context, kind Kind, id, imageType string) (*ItemArtwork, error) + PutItemArtwork(ctx context.Context, ia *ItemArtwork) error // PutLastFailure records the trace of the attempt that exhausted the retry budget. - PutLastFailure(kind Kind, id, imageType, trace string) error - DeleteForItems(kind Kind, ids []string) error + PutLastFailure(ctx context.Context, kind Kind, id, imageType, trace string) error + DeleteForItems(ctx context.Context, kind Kind, ids []string) error // GetInfoForItems hydrates a page in one batched query. - GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error) + GetInfoForItems(ctx context.Context, kind Kind, ids []string) (map[string]ItemArtworkInfo, error) // GetMimeByHash returns hash -> current mime for every stored artwork. - GetMimeByHash() (map[string]string, error) + GetMimeByHash(ctx context.Context) (map[string]string, error) // PurgeDanglingItems removes state rows whose entity no longer exists. - PurgeDanglingItems() (int64, error) + PurgeDanglingItems(ctx context.Context) (int64, error) } type ArtworkQueueRepository interface { // Get returns the pending row for an item, or ErrNotFound when it is not queued. - Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error) + Get(ctx context.Context, kind Kind, id, imageType string) (*ArtworkQueueItem, error) // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. - Enqueue(items ...ArtworkQueueItem) error + Enqueue(ctx context.Context, items ...ArtworkQueueItem) error // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a // request-triggered read-through never resets a failed resolution's backoff. - EnqueuePreservingBackoff(items ...ArtworkQueueItem) error + EnqueuePreservingBackoff(ctx context.Context, items ...ArtworkQueueItem) error // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. - EnqueueAllMissing(kind Kind, priority int) (int64, error) + EnqueueAllMissing(ctx context.Context, kind Kind, priority int) (int64, error) // EnqueueIfMissing inserts only for items with no item_artwork row yet. - EnqueueIfMissing(items ...ArtworkQueueItem) error + EnqueueIfMissing(ctx context.Context, items ...ArtworkQueueItem) error // CountBySource reports how many items of a kind currently resolve from the given sources. // An empty sources slice means every source; "" matches absent state, and the pseudo-source // ArtworkSourceFailed matches the absent states that gave up. - CountBySource(kind Kind, sources []string) (int64, error) + CountBySource(ctx context.Context, kind Kind, sources []string) (int64, error) // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. - SourcesInUse(kind Kind) ([]string, error) + SourcesInUse(ctx context.Context, kind Kind) ([]string, error) // EnqueueBySource inserts queue rows for items of a kind whose current source matches. // It does not clear existing artwork state: the current image stays until it is replaced. - EnqueueBySource(kind Kind, sources []string, priority int) (int64, error) + EnqueueBySource(ctx context.Context, kind Kind, sources []string, priority int) (int64, error) // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. - DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error) + DequeueBatch(ctx context.Context, n int, kinds ...string) ([]ArtworkQueueItem, error) // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. - MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error + MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. - DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error - Count() (int64, error) + DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error + Count(ctx context.Context) (int64, error) // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; // an empty filter means every one. - CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) + CountQueued(ctx context.Context, kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) // PurgeDangling removes queue rows whose entity no longer exists. - PurgeDangling() (int64, error) + PurgeDangling(ctx context.Context) (int64, error) // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. - PurgeQueued(kinds []Kind, priorities []int) (int64, error) + PurgeQueued(ctx context.Context, kinds []Kind, priorities []int) (int64, error) } type ArtworkQueueStat struct { diff --git a/model/bookmark.go b/model/bookmark.go index 7c6637ce9..768d4a462 100644 --- a/model/bookmark.go +++ b/model/bookmark.go @@ -1,15 +1,18 @@ package model -import "time" +import ( + "context" + "time" +) type Bookmarkable struct { BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"` } type BookmarkableRepository interface { - AddBookmark(id, comment string, position int64) error - DeleteBookmark(id string) error - GetBookmarks() (Bookmarks, error) + AddBookmark(ctx context.Context, id, comment string, position int64) error + DeleteBookmark(ctx context.Context, id string) error + GetBookmarks(ctx context.Context) (Bookmarks, error) } type Bookmark struct { diff --git a/model/datastore.go b/model/datastore.go index 26687d5d4..6ded8c575 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -4,7 +4,6 @@ import ( "context" "github.com/Masterminds/squirrel" - "github.com/deluan/rest" ) type QueryOptions struct { @@ -16,34 +15,28 @@ type QueryOptions struct { Seed string // for random sorting } -type ResourceRepository interface { - rest.Repository -} - type DataStore interface { - Library(ctx context.Context) LibraryRepository - Folder(ctx context.Context) FolderRepository - Album(ctx context.Context) AlbumRepository - Artist(ctx context.Context) ArtistRepository - MediaFile(ctx context.Context) MediaFileRepository - Genre(ctx context.Context) GenreRepository - Tag(ctx context.Context) TagRepository - Playlist(ctx context.Context) PlaylistRepository - PlayQueue(ctx context.Context) PlayQueueRepository - Transcoding(ctx context.Context) TranscodingRepository - Player(ctx context.Context) PlayerRepository - Radio(ctx context.Context) RadioRepository - Share(ctx context.Context) ShareRepository - Property(ctx context.Context) PropertyRepository - User(ctx context.Context) UserRepository - UserProps(ctx context.Context) UserPropsRepository - ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository - Scrobble(ctx context.Context) ScrobbleRepository - Plugin(ctx context.Context) PluginRepository - Artwork(ctx context.Context) ArtworkRepository - ArtworkQueue(ctx context.Context) ArtworkQueueRepository - - Resource(ctx context.Context, model any) ResourceRepository + Library() LibraryRepository + Folder() FolderRepository + Album() AlbumRepository + Artist() ArtistRepository + MediaFile() MediaFileRepository + Genre() GenreRepository + Tag() TagRepository + Playlist() PlaylistRepository + PlayQueue() PlayQueueRepository + Transcoding() TranscodingRepository + Player() PlayerRepository + Radio() RadioRepository + Share() ShareRepository + Property() PropertyRepository + User() UserRepository + UserProps() UserPropsRepository + ScrobbleBuffer() ScrobbleBufferRepository + Scrobble() ScrobbleRepository + Plugin() PluginRepository + Artwork() ArtworkRepository + ArtworkQueue() ArtworkQueueRepository WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error diff --git a/model/folder.go b/model/folder.go index 5207a9db0..701fca8cd 100644 --- a/model/folder.go +++ b/model/folder.go @@ -1,6 +1,7 @@ package model import ( + "context" "fmt" "iter" "os" @@ -83,19 +84,19 @@ type FolderUpdateInfo struct { } type FolderRepository interface { - Get(id string) (*Folder, error) - GetByPath(lib Library, path string) (*Folder, error) - GetAll(...QueryOptions) ([]Folder, error) - CountAll(...QueryOptions) (int64, error) - GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) + Get(ctx context.Context, id string) (*Folder, error) + GetByPath(ctx context.Context, lib Library, path string) (*Folder, error) + GetAll(ctx context.Context, options ...QueryOptions) ([]Folder, error) + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + GetFolderUpdateInfo(ctx context.Context, lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) // HasAudioOutsideFolders reports whether any folder in parent's subtree // (including parent itself) contains audio files and is not one of the // given folder IDs. - HasAudioOutsideFolders(parent Folder, excludeFolderIDs []string) (bool, error) - Put(*Folder) error - MarkMissing(missing bool, ids ...string) error - GetTouchedWithPlaylists() (FolderCursor, error) + HasAudioOutsideFolders(ctx context.Context, parent Folder, excludeFolderIDs []string) (bool, error) + Put(ctx context.Context, f *Folder) error + MarkMissing(ctx context.Context, missing bool, ids ...string) error + GetTouchedWithPlaylists(ctx context.Context) (FolderCursor, error) // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring // the scan-timestamp gate used by GetTouchedWithPlaylists. - GetAllWithPlaylists() (FolderCursor, error) + GetAllWithPlaylists(ctx context.Context) (FolderCursor, error) } diff --git a/model/genre.go b/model/genre.go index fa5b6ec62..147422e67 100644 --- a/model/genre.go +++ b/model/genre.go @@ -1,5 +1,11 @@ package model +import ( + "context" + + "github.com/deluan/rest" +) + type Genre struct { ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"` Name string `structs:"name" json:"name"` @@ -10,6 +16,7 @@ type Genre struct { type Genres []Genre type GenreRepository interface { - GetAll(...QueryOptions) (Genres, error) - Get(id string) (*Genre, error) + rest.Repository[Genre] + GetAll(ctx context.Context, options ...QueryOptions) (Genres, error) + Get(ctx context.Context, id string) (*Genre, error) } diff --git a/model/get_entity.go b/model/get_entity.go index e5d41f1be..cfb3968ef 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -23,11 +23,11 @@ func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) kind Kind get func() (any, error) }{ - {KindArtistArtwork, func() (any, error) { return ds.Artist(ctx).Get(id) }}, - {KindAlbumArtwork, func() (any, error) { return ds.Album(ctx).Get(id) }}, - {KindPlaylistArtwork, func() (any, error) { return ds.Playlist(ctx).Get(id) }}, - {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile(ctx).Get(id) }}, - {KindRadioArtwork, func() (any, error) { return ds.Radio(ctx).Get(id) }}, + {KindArtistArtwork, func() (any, error) { return ds.Artist().Get(ctx, id) }}, + {KindAlbumArtwork, func() (any, error) { return ds.Album().Get(ctx, id) }}, + {KindPlaylistArtwork, func() (any, error) { return ds.Playlist().Get(ctx, id) }}, + {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile().Get(ctx, id) }}, + {KindRadioArtwork, func() (any, error) { return ds.Radio().Get(ctx, id) }}, } for _, g := range getters { entity, err := g.get() diff --git a/model/get_entity_test.go b/model/get_entity_test.go index 4e589406a..73e21a036 100644 --- a/model/get_entity_test.go +++ b/model/get_entity_test.go @@ -19,7 +19,7 @@ var _ = Describe("GetEntityByID", func() { }) It("returns the entity matching the id", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) entity, err := model.GetEntityByID(ctx, ds, "a1") Expect(err).ToNot(HaveOccurred()) Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) @@ -32,7 +32,7 @@ var _ = Describe("GetEntityByID", func() { }) It("propagates unexpected repository errors instead of reporting not-found", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true) + ds.Album().(*tests.MockAlbumRepo).SetError(true) _, err := model.GetEntityByID(ctx, ds, "a1") Expect(err).To(HaveOccurred()) Expect(err).ToNot(MatchError(model.ErrNotFound)) @@ -49,7 +49,7 @@ var _ = Describe("GetEntityKindByID", func() { }) It("returns the artwork kind for the matching id", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) kind, err := model.GetEntityKindByID(ctx, ds, "a1") Expect(err).ToNot(HaveOccurred()) Expect(kind).To(Equal(model.KindAlbumArtwork)) diff --git a/model/library.go b/model/library.go index aceab533a..1e33222ac 100644 --- a/model/library.go +++ b/model/library.go @@ -1,8 +1,10 @@ package model import ( + "context" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/utils/slice" ) @@ -39,23 +41,24 @@ func (l Libraries) IDs() []int { } type LibraryRepository interface { - Get(id int) (*Library, error) + rest.Repository[Library] + Get(ctx context.Context, id int) (*Library, error) // GetPath returns the path of the library with the given ID. // Its implementation must be optimized to avoid unnecessary queries. - GetPath(id int) (string, error) - GetAll(...QueryOptions) (Libraries, error) - CountAll(...QueryOptions) (int64, error) - Put(l *Library, colsToUpdate ...string) error - Delete(id int) error - StoreMusicFolder() error - AddArtist(id int, artistID string) error + GetPath(ctx context.Context, id int) (string, error) + GetAll(ctx context.Context, options ...QueryOptions) (Libraries, error) + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Put(ctx context.Context, l *Library, colsToUpdate ...string) error + Delete(ctx context.Context, id int) error + StoreMusicFolder(ctx context.Context) error + AddArtist(ctx context.Context, id int, artistID string) error // User-library association methods - GetUsersWithLibraryAccess(libraryID int) (Users, error) + GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (Users, error) // TODO These methods should be moved to a core service - ScanBegin(id int, fullScan bool) error - ScanEnd(id int) error - ScanInProgress() (bool, error) - RefreshStats(id int) error + ScanBegin(ctx context.Context, id int, fullScan bool) error + ScanEnd(ctx context.Context, id int) error + ScanInProgress(ctx context.Context) (bool, error) + RefreshStats(ctx context.Context, id int) error } diff --git a/model/mediafile.go b/model/mediafile.go index 1147eaa35..0c56b4825 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,6 +2,7 @@ package model import ( "cmp" + "context" "encoding/json" "fmt" "iter" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/deluan/rest" "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -537,42 +539,43 @@ func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string { type MediaFileCursor iter.Seq2[MediaFile, error] type MediaFileRepository interface { - CountAll(options ...QueryOptions) (int64, error) - CountBySuffix(options ...QueryOptions) (map[string]int64, error) - Exists(id string) (bool, error) - Put(m *MediaFile) error - UpdateProbeData(id string, data string) error - Get(id string) (*MediaFile, error) - GetWithParticipants(id string) (*MediaFile, error) - GetAll(options ...QueryOptions) (MediaFiles, error) + rest.Repository[MediaFile] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + CountBySuffix(ctx context.Context, options ...QueryOptions) (map[string]int64, error) + Exists(ctx context.Context, id string) (bool, error) + Put(ctx context.Context, m *MediaFile) error + UpdateProbeData(ctx context.Context, id string, data string) error + Get(ctx context.Context, id string) (*MediaFile, error) + GetWithParticipants(ctx context.Context, id string) (*MediaFile, error) + GetAll(ctx context.Context, options ...QueryOptions) (MediaFiles, error) // GetRandom returns up to options.Max media files in random order, applying the same // filters as GetAll. Sort/Order are ignored. - GetRandom(options ...QueryOptions) (MediaFiles, error) - GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) + GetRandom(ctx context.Context, options ...QueryOptions) (MediaFiles, error) + GetAllByTags(ctx context.Context, tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) // MatchesCriteria reports whether the media file matches the criteria's rule // expression, using the logged user's annotations. Limit and offset are ignored. - MatchesCriteria(id string, c criteria.Criteria) (bool, error) - GetCursor(options ...QueryOptions) (MediaFileCursor, error) + MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error) + GetCursor(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error) // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given // folders or their direct children. - GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error) + GetAlbumIDsByFolder(ctx context.Context, lib Library, folderIDs ...string) ([]string, error) // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. - GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error) - Delete(id string) error - DeleteMissing(ids []string) error - DeleteAllMissing() (int64, error) - FindByPaths(paths []string) (MediaFiles, error) + GetCursorWithArtwork(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error) + Delete(ctx context.Context, id string) error + DeleteMissing(ctx context.Context, ids []string) error + DeleteAllMissing(ctx context.Context) (int64, error) + FindByPaths(ctx context.Context, paths []string) (MediaFiles, error) // ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID, // keeping newID's own row wherever a user has both. - ReassignReferences(prevID, newID string) error + ReassignReferences(ctx context.Context, prevID, newID string) error // The following methods are used exclusively by the scanner: - MarkMissing(bool, ...*MediaFile) error - MarkMissingByFolder(missing bool, folderIDs ...string) error - GetMissingAndMatching(libId int) (MediaFileCursor, error) - FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error) - FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error) + MarkMissing(ctx context.Context, missing bool, mfs ...*MediaFile) error + MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error + GetMissingAndMatching(ctx context.Context, libId int) (MediaFileCursor, error) + FindRecentFilesByMBZTrackID(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error) + FindRecentFilesByProperties(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error) AnnotatedRepository BookmarkableRepository diff --git a/model/player.go b/model/player.go index 39ea99d1a..2e4484a10 100644 --- a/model/player.go +++ b/model/player.go @@ -1,7 +1,10 @@ package model import ( + "context" "time" + + "github.com/deluan/rest" ) type Player struct { @@ -23,9 +26,11 @@ type Player struct { type Players []Player type PlayerRepository interface { - Get(id string) (*Player, error) - FindMatch(userId, client, userAgent string) (*Player, error) - Put(p *Player) error - CountAll(...QueryOptions) (int64, error) - CountByClient(...QueryOptions) (map[string]int64, error) + rest.Repository[Player] + rest.Persistable[Player] + Get(ctx context.Context, id string) (*Player, error) + FindMatch(ctx context.Context, userId, client, userAgent string) (*Player, error) + Put(ctx context.Context, p *Player) error + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error) } diff --git a/model/playlist.go b/model/playlist.go index 19fedc9fa..d2ed97682 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,6 +1,7 @@ package model import ( + "context" "iter" "maps" "os" @@ -9,6 +10,7 @@ import ( "strconv" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -206,19 +208,19 @@ type Playlists []Playlist type PlaylistCursor iter.Seq2[Playlist, error] type PlaylistRepository interface { - ResourceRepository + rest.Repository[Playlist] + rest.Persistable[Playlist] AnnotatedRepository - CountAll(options ...QueryOptions) (int64, error) - Exists(id string) (bool, error) - Put(pls *Playlist, cols ...string) error - Get(id string) (*Playlist, error) - GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) - GetAll(options ...QueryOptions) (Playlists, error) - GetCursor(options ...QueryOptions) (PlaylistCursor, error) - FindByPath(path string) (*Playlist, error) - Delete(id string) error - Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository - GetPlaylists(mediaFileId string) (Playlists, error) + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Exists(ctx context.Context, id string) (bool, error) + Put(ctx context.Context, pls *Playlist, cols ...string) error + Get(ctx context.Context, id string) (*Playlist, error) + GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) + GetAll(ctx context.Context, options ...QueryOptions) (Playlists, error) + GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistCursor, error) + FindByPath(ctx context.Context, path string) (*Playlist, error) + Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository + GetPlaylists(ctx context.Context, mediaFileId string) (Playlists, error) } type PlaylistTrack struct { @@ -241,18 +243,18 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] type PlaylistTrackRepository interface { - ResourceRepository - CountAll(options ...QueryOptions) (int64, error) - GetAll(options ...QueryOptions) (PlaylistTracks, error) - GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error) - GetAlbumIDs(options ...QueryOptions) ([]string, error) - GetMediaFileIDs(options ...QueryOptions) ([]string, error) - Add(mediaFileIds []string) (int, error) - Insert(mediaFileIds []string, pos int) (int, error) - AddAlbums(albumIds []string) (int, error) - AddArtists(artistIds []string) (int, error) - AddDiscs(discs []DiscID) (int, error) - Delete(id ...string) error - DeleteAll() error - Reorder(pos int, newPos int) error + rest.Repository[PlaylistTrack] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + GetAll(ctx context.Context, options ...QueryOptions) (PlaylistTracks, error) + GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistTrackCursor, error) + GetAlbumIDs(ctx context.Context, options ...QueryOptions) ([]string, error) + GetMediaFileIDs(ctx context.Context, options ...QueryOptions) ([]string, error) + Add(ctx context.Context, mediaFileIds []string) (int, error) + Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error) + AddAlbums(ctx context.Context, albumIds []string) (int, error) + AddArtists(ctx context.Context, artistIds []string) (int, error) + AddDiscs(ctx context.Context, discs []DiscID) (int, error) + Delete(ctx context.Context, id ...string) error + DeleteAll(ctx context.Context) error + Reorder(ctx context.Context, pos int, newPos int) error } diff --git a/model/playqueue.go b/model/playqueue.go index 03b562253..ddc53d7da 100644 --- a/model/playqueue.go +++ b/model/playqueue.go @@ -1,6 +1,7 @@ package model import ( + "context" "time" ) @@ -18,11 +19,11 @@ type PlayQueue struct { type PlayQueues []PlayQueue type PlayQueueRepository interface { - Store(queue *PlayQueue, colNames ...string) error + Store(ctx context.Context, queue *PlayQueue, colNames ...string) error // Retrieve returns the playqueue without loading the full MediaFiles // (Items only contain IDs) - Retrieve(userId string) (*PlayQueue, error) + Retrieve(ctx context.Context, userId string) (*PlayQueue, error) // RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded - RetrieveWithMediaFiles(userId string) (*PlayQueue, error) - Clear(userId string) error + RetrieveWithMediaFiles(ctx context.Context, userId string) (*PlayQueue, error) + Clear(ctx context.Context, userId string) error } diff --git a/model/plugin.go b/model/plugin.go index 18d66e305..a448a9633 100644 --- a/model/plugin.go +++ b/model/plugin.go @@ -1,6 +1,11 @@ package model -import "time" +import ( + "context" + "time" + + "github.com/deluan/rest" +) type Plugin struct { ID string `structs:"id" json:"id"` @@ -22,11 +27,11 @@ type Plugin struct { type Plugins []Plugin type PluginRepository interface { - ResourceRepository - ClearErrors() error - CountAll(options ...QueryOptions) (int64, error) - Delete(id string) error - Get(id string) (*Plugin, error) - GetAll(options ...QueryOptions) (Plugins, error) - Put(p *Plugin) error + rest.Repository[Plugin] + ClearErrors(ctx context.Context) error + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Delete(ctx context.Context, id string) error + Get(ctx context.Context, id string) (*Plugin, error) + GetAll(ctx context.Context, options ...QueryOptions) (Plugins, error) + Put(ctx context.Context, p *Plugin) error } diff --git a/model/properties.go b/model/properties.go index 06bb9ebec..24b56db26 100644 --- a/model/properties.go +++ b/model/properties.go @@ -1,8 +1,10 @@ package model +import "context" + type PropertyRepository interface { - Put(id string, value string) error - Get(id string) (string, error) - Delete(id string) error - DefaultGet(id string, defaultValue string) (string, error) + Put(ctx context.Context, id string, value string) error + Get(ctx context.Context, id string) (string, error) + Delete(ctx context.Context, id string) error + DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) } diff --git a/model/radio.go b/model/radio.go index 013a24beb..a7e0e9218 100644 --- a/model/radio.go +++ b/model/radio.go @@ -1,8 +1,10 @@ package model import ( + "context" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/consts" ) @@ -29,11 +31,11 @@ func (r Radio) UploadedImagePath() string { type Radios []Radio type RadioRepository interface { - ResourceRepository - CountAll(options ...QueryOptions) (int64, error) - Delete(id string) error - Exists(id string) (bool, error) - Get(id string) (*Radio, error) - GetAll(options ...QueryOptions) (Radios, error) - Put(u *Radio, colsToUpdate ...string) error + rest.Repository[Radio] + rest.Persistable[Radio] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Exists(ctx context.Context, id string) (bool, error) + Get(ctx context.Context, id string) (*Radio, error) + GetAll(ctx context.Context, options ...QueryOptions) (Radios, error) + Put(ctx context.Context, u *Radio, colsToUpdate ...string) error } diff --git a/model/scrobble.go b/model/scrobble.go index a8022fc16..b99d25b6a 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -1,6 +1,11 @@ package model -import "time" +import ( + "context" + "time" + + "github.com/deluan/rest" +) type Scrobble struct { ID int64 `structs:"id" json:"id"` @@ -10,10 +15,11 @@ type Scrobble struct { } type ScrobbleRepository interface { - CountAll(options ...QueryOptions) (int64, error) - Get(id string) (*Scrobble, error) - GetAll(options ...QueryOptions) (Scrobbles, error) - RecordScrobble(mediaFileID string, submissionTime time.Time) error + rest.Repository[Scrobble] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Get(ctx context.Context, id string) (*Scrobble, error) + GetAll(ctx context.Context, options ...QueryOptions) (Scrobbles, error) + RecordScrobble(ctx context.Context, mediaFileID string, submissionTime time.Time) error } type Scrobbles []Scrobble diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index 43ee2cc01..dda8dfce3 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "context" + "time" +) type ScrobbleEntry struct { ID string @@ -15,10 +18,10 @@ type ScrobbleEntry struct { type ScrobbleEntries []ScrobbleEntry type ScrobbleBufferRepository interface { - UserIDs(service string) ([]string, error) - Enqueue(service, userId, mediaFileId string, playTime time.Time) error - Next(service string, userId string) (*ScrobbleEntry, error) - Dequeue(entry *ScrobbleEntry) error - Length() (int64, error) - Discard(service string) error + UserIDs(ctx context.Context, service string) ([]string, error) + Enqueue(ctx context.Context, service, userId, mediaFileId string, playTime time.Time) error + Next(ctx context.Context, service string, userId string) (*ScrobbleEntry, error) + Dequeue(ctx context.Context, entry *ScrobbleEntry) error + Length(ctx context.Context) (int64, error) + Discard(ctx context.Context, service string) error } diff --git a/model/searchable.go b/model/searchable.go index a64a0171c..6dba989c1 100644 --- a/model/searchable.go +++ b/model/searchable.go @@ -1,5 +1,7 @@ package model +import "context" + type SearchableRepository[T any] interface { - Search(q string, options ...QueryOptions) (T, error) + Search(ctx context.Context, q string, options ...QueryOptions) (T, error) } diff --git a/model/share.go b/model/share.go index cf1f4cb34..952fd9759 100644 --- a/model/share.go +++ b/model/share.go @@ -2,9 +2,11 @@ package model import ( "cmp" + "context" "strings" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/utils/random" ) @@ -56,8 +58,10 @@ func (s Share) ToM3U8() string { } type ShareRepository interface { - Exists(id string) (bool, error) - Get(id string) (*Share, error) - GetAll(options ...QueryOptions) (Shares, error) - CountAll(options ...QueryOptions) (int64, error) + rest.Repository[Share] + rest.Persistable[Share] + Exists(ctx context.Context, id string) (bool, error) + Get(ctx context.Context, id string) (*Share, error) + GetAll(ctx context.Context, options ...QueryOptions) (Shares, error) + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) } diff --git a/model/tag.go b/model/tag.go index 152b7164e..60fa252dd 100644 --- a/model/tag.go +++ b/model/tag.go @@ -2,10 +2,12 @@ package model import ( "cmp" + "context" "fmt" "slices" "strings" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" "github.com/zeebo/xxh3" @@ -162,9 +164,10 @@ func (t Tags) Add(name TagName, v string) { } type TagRepository interface { - Add(libraryID int, tags ...Tag) error - UpdateCounts() error - GetAll(name TagName, options ...QueryOptions) (TagList, error) + rest.Repository[Tag] + Add(ctx context.Context, libraryID int, tags ...Tag) error + UpdateCounts(ctx context.Context) error + GetAll(ctx context.Context, name TagName, options ...QueryOptions) (TagList, error) } type TagName string diff --git a/model/transcoding.go b/model/transcoding.go index 9b81a7c9c..da5e09e91 100644 --- a/model/transcoding.go +++ b/model/transcoding.go @@ -1,5 +1,11 @@ package model +import ( + "context" + + "github.com/deluan/rest" +) + type Transcoding struct { ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` @@ -11,8 +17,10 @@ type Transcoding struct { type Transcodings []Transcoding type TranscodingRepository interface { - Get(id string) (*Transcoding, error) - CountAll(...QueryOptions) (int64, error) - Put(*Transcoding) error - FindByFormat(format string) (*Transcoding, error) + rest.Repository[Transcoding] + rest.Persistable[Transcoding] + Get(ctx context.Context, id string) (*Transcoding, error) + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Put(ctx context.Context, t *Transcoding) error + FindByFormat(ctx context.Context, format string) (*Transcoding, error) } diff --git a/model/user.go b/model/user.go index 37bdca33d..bbeb4a05d 100644 --- a/model/user.go +++ b/model/user.go @@ -1,7 +1,10 @@ package model import ( + "context" "time" + + "github.com/deluan/rest" ) type User struct { @@ -46,21 +49,21 @@ func (u User) HasLibraryAccess(libraryID int) bool { type Users []User type UserRepository interface { - ResourceRepository - CountAll(...QueryOptions) (int64, error) - Delete(id string) error - Get(id string) (*User, error) - GetAll(options ...QueryOptions) (Users, error) - Put(*User) error - UpdateLastLoginAt(id string) error - UpdateLastAccessAt(id string) error - FindFirstAdmin() (*User, error) + rest.Repository[User] + rest.Persistable[User] + CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Get(ctx context.Context, id string) (*User, error) + GetAll(ctx context.Context, options ...QueryOptions) (Users, error) + Put(ctx context.Context, u *User) error + UpdateLastLoginAt(ctx context.Context, id string) error + UpdateLastAccessAt(ctx context.Context, id string) error + FindFirstAdmin(ctx context.Context) (*User, error) // FindByUsername must be case-insensitive - FindByUsername(username string) (*User, error) + FindByUsername(ctx context.Context, username string) (*User, error) // FindByUsernameWithPassword is the same as above, but also returns the decrypted password - FindByUsernameWithPassword(username string) (*User, error) + FindByUsernameWithPassword(ctx context.Context, username string) (*User, error) // Library association methods - GetUserLibraries(userID string) (Libraries, error) - SetUserLibraries(userID string, libraryIDs []int) error + GetUserLibraries(ctx context.Context, userID string) (Libraries, error) + SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error } diff --git a/model/user_props.go b/model/user_props.go index c2eb536ec..af19f34f1 100644 --- a/model/user_props.go +++ b/model/user_props.go @@ -1,8 +1,10 @@ package model +import "context" + type UserPropsRepository interface { - Put(userId, key string, value string) error - Get(userId, key string) (string, error) - Delete(userId, key string) error - DefaultGet(userId, key string, defaultValue string) (string, error) + Put(ctx context.Context, userId, key string, value string) error + Get(ctx context.Context, userId, key string) (string, error) + Delete(ctx context.Context, userId, key string) error + DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 808c880fb..8e7662b8f 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -102,9 +102,8 @@ func (as dbAlbums) toModels() model.Albums { return slice.Map(as, func(a dbAlbum) model.Album { return *a.Album }) } -func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumRepository { +func NewAlbumRepository(db dbx.Builder) model.AlbumRepository { r := &albumRepository{} - r.ctx = ctx r.db = db r.tableName = "album" r.registerModel(&model.Album{}, albumFilters()) @@ -190,49 +189,49 @@ func allRolesFilter(_ string, value any) Sqlizer { return ParticipantIDFilter("album", value) } -func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { - query := r.newSelect() - query = r.applyLibraryFilter(query) +func (r *albumRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + query := r.newSelect(ctx) + query = r.applyLibraryFilter(ctx, query) if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(query, "album.id") + query = r.withAnnotation(ctx, query, "album.id") } - return r.count(query, options...) + return r.count(ctx, query, options...) } -func (r *albumRepository) Exists(id string) (bool, error) { +func (r *albumRepository) Exists(ctx context.Context, id string) (bool, error) { // The exists() helper applies no library filter, so it would report rows the caller cannot see. - c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"album.id": id}))) + c, err := r.count(ctx, r.applyLibraryFilter(ctx, r.newSelect(ctx).Where(Eq{"album.id": id}))) return c > 0, err } -func (r *albumRepository) Put(al *model.Album) error { +func (r *albumRepository) Put(ctx context.Context, al *model.Album) error { al.ImportedAt = time.Now() - id, err := r.put(al.ID, &dbAlbum{Album: al}) + id, err := r.put(ctx, al.ID, &dbAlbum{Album: al}) if err != nil { return err } al.ID = id - if err := r.updateParticipants(al.ID, al.Participants); err != nil { + if err := r.updateParticipants(ctx, al.ID, al.Participants); err != nil { return err } - return r.updateTags(al.ID, al.Tags) + return r.updateTags(ctx, al.ID, al.Tags) } // TODO Move external metadata to a separated table -func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { - _, err := r.put(al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") +func (r *albumRepository) UpdateExternalInfo(ctx context.Context, al *model.Album) error { + _, err := r.put(ctx, al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") return err } -func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). +func (r *albumRepository) selectAlbum(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(ctx, options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on album.library_id = library.id") - sql = r.withAnnotation(sql, "album.id") - return r.applyLibraryFilter(sql) + sql = r.withAnnotation(ctx, sql, "album.id") + return r.applyLibraryFilter(ctx, sql) } -func (r *albumRepository) Get(id string) (*model.Album, error) { - res, err := r.GetAll(model.QueryOptions{Filters: Eq{"album.id": id}}) +func (r *albumRepository) Get(ctx context.Context, id string) (*model.Album, error) { + res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"album.id": id}}) if err != nil { return nil, err } @@ -242,31 +241,31 @@ func (r *albumRepository) Get(id string) (*model.Album, error) { return &res[0], nil } -func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) { - sq := r.selectAlbum(options...) +func (r *albumRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Albums, error) { + sq := r.selectAlbum(ctx, options...) var res dbAlbums - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) if err != nil { return nil, err } albums := res.toModels() - r.hydrateArtwork(albums) + r.hydrateArtwork(ctx, albums) return albums, nil } -func (r *albumRepository) hydrateArtwork(albums model.Albums) { - hydrateItems(r.ctx, r.db, model.KindAlbumArtwork, albums, +func (r *albumRepository) hydrateArtwork(ctx context.Context, albums model.Albums) { + hydrateItems(ctx, r.db, model.KindAlbumArtwork, albums, func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) } // getAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. -func (r *albumRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) +func (r *albumRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(ctx, r.newSelect(ctx, options...).Columns("album.id")) if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(sq, "album.id") + sq = r.withAnnotation(ctx, sq, "album.id") } ids := []string{} - err := r.queryAllSlice(sq, &ids) + err := r.queryAllSlice(ctx, sq, &ids) return ids, err } @@ -282,7 +281,7 @@ func SoleAlbumArtistFilter(artistID string) Sqlizer { // GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's // notion of an album's folders. -func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, paths ...string) ([]string, error) { +func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib model.Library, paths ...string) ([]string, error) { if len(paths) == 0 { return nil, nil } @@ -295,7 +294,7 @@ func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, pat sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album"). Where(And{soleAlbumArtistFilter, inSubtree}) var chunkIDs []string - if err := r.queryAllSlice(sq, &chunkIDs); err != nil { + if err := r.queryAllSlice(ctx, sq, &chunkIDs); err != nil { return nil, err } ids = append(ids, chunkIDs...) @@ -303,37 +302,37 @@ func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, pat return ids, nil } -func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.getAllIDs(options...) +func (r *albumRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.AlbumCursor, error) { + ids, err := r.getAllIDs(ctx, options...) if err != nil { return nil, err } opts := chunkOptions(options, "album.id") return model.AlbumCursor(streamByIDs(ids, func(chunk []string) (model.Albums, error) { - return r.GetAll(opts(chunk)) + return r.GetAll(ctx, opts(chunk)) })), nil } -func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { +func (r *albumRepository) GetYears(ctx context.Context, libraryIDs ...int) ([]int, error) { cond := And{Gt{"max_year": 0}, Eq{"missing": false}} if len(libraryIDs) > 0 { cond = append(cond, Eq{"library_id": libraryIDs}) } - sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) + sq := r.applyLibraryFilter(ctx, Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) years := []int{} - err := r.queryAllSlice(sq, &years) + err := r.queryAllSlice(ctx, sq, &years) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } return years, nil } -func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { +func (r *albumRepository) CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error { // Cast values to text so go-sqlite3 does not decode datetime columns as time.Time // and reformat them as RFC3339 when written back. sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) }) var from dbx.NullStringMap - err := r.queryOne(Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from) + err := r.queryOne(ctx, Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from) if err != nil { return fmt.Errorf("getting album to copy fields from: %w", err) } @@ -351,35 +350,35 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) if len(to) == 0 { return nil } - _, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) + _, err = r.executeSQL(ctx, Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) return err } // Touch flags an album as being scanned by the scanner, but not necessarily updated. // This is used for when missing tracks are detected for an album during scan. -func (r *albumRepository) Touch(ids ...string) error { +func (r *albumRepository) Touch(ctx context.Context, ids ...string) error { if len(ids) == 0 { return nil } for ids := range slices.Chunk(ids, 200) { upd := Update(r.tableName).Set("imported_at", time.Now()).Where(Eq{"id": ids}) - c, err := r.executeSQL(upd) + c, err := r.executeSQL(ctx, upd) if err != nil { return fmt.Errorf("error touching albums: %w", err) } - log.Debug(r.ctx, "Touching albums", "ids", ids, "updated", c) + log.Debug(ctx, "Touching albums", "ids", ids, "updated", c) } return nil } // TouchByMissingFolder touches all albums that have missing folders -func (r *albumRepository) TouchByMissingFolder() (int64, error) { +func (r *albumRepository) TouchByMissingFolder(ctx context.Context) (int64, error) { upd := Update(r.tableName).Set("imported_at", time.Now()). Where(And{ NotEq{"folder_ids": nil}, ConcatExpr("EXISTS (SELECT 1 FROM json_each(folder_ids) AS je JOIN main.folder AS f ON je.value = f.id WHERE f.missing = true)"), }) - c, err := r.executeSQL(upd) + c, err := r.executeSQL(ctx, upd) if err != nil { return 0, fmt.Errorf("error touching albums by missing folder: %w", err) } @@ -389,13 +388,13 @@ func (r *albumRepository) TouchByMissingFolder() (int64, error) { // GetTouchedAlbums returns all albums that were touched by the scanner for a given library, in the // current library scan run. // It does not need to load participants, as they are not used by the scanner. -func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { - query := r.selectAlbum(). +func (r *albumRepository) GetTouchedAlbums(ctx context.Context, libID int) (model.AlbumCursor, error) { + query := r.selectAlbum(ctx). Where(And{ Eq{"library.id": libID}, ConcatExpr("album.imported_at > library.last_scan_at"), }) - cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, query) + cursor, err := queryWithStableResults[dbAlbum](ctx, r.sqlRepository, query) if err != nil { return nil, err } @@ -408,7 +407,7 @@ func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { // RefreshPlayCounts updates the play count and last play date annotations for all albums, based // on the media files associated with them. -func (r *albumRepository) RefreshPlayCounts() (int64, error) { +func (r *albumRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { query := Expr(` with play_counts as ( select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date @@ -424,21 +423,21 @@ on conflict (user_id, item_id, item_type) do update set play_count = excluded.play_count, play_date = excluded.play_date; `) - return r.executeSQL(query) + return r.executeSQL(ctx, query) } -func (r *albumRepository) purgeEmpty(libraryIDs ...int) error { +func (r *albumRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error { del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)") // If libraryIDs are specified, only purge albums from those libraries if len(libraryIDs) > 0 { del = del.Where(Eq{"library_id": libraryIDs}) } - c, err := r.executeSQL(del) + c, err := r.executeSQL(ctx, del) if err != nil { return fmt.Errorf("purging empty albums: %w", err) } if c > 0 { - log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c) + log.Debug(ctx, "Purged empty albums", "totalDeleted", c) } return nil } @@ -449,40 +448,32 @@ var albumSearchConfig = searchConfig{ MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"}, } -func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) { +func (r *albumRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Albums, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } var res dbAlbums - err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts) + err := r.doSearch(ctx, r.selectAlbum(ctx, options...), q, &res, albumSearchConfig, opts) if err != nil { return nil, fmt.Errorf("searching album %q: %w", q, err) } albums := res.toModels() - r.hydrateArtwork(albums) + r.hydrateArtwork(ctx, albums) return albums, nil } -func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *albumRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *albumRepository) Read(id string) (any, error) { - return r.Get(id) +func (r *albumRepository) Read(ctx context.Context, id string) (*model.Album, error) { + return r.Get(ctx, id) } -func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *albumRepository) EntityName() string { - return "album" -} - -func (r *albumRepository) NewInstance() any { - return &model.Album{} +func (r *albumRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Album, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } var _ model.AlbumRepository = (*albumRepository)(nil) -var _ model.ResourceRepository = (*albumRepository)(nil) +var _ rest.Repository[model.Album] = (*albumRepository)(nil) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index c3d7f018f..7652e703d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "sync" "time" "github.com/Masterminds/squirrel" @@ -22,11 +23,11 @@ import ( // rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of // `datetime` columns into time.Time. -func rawColumn(r sqlRepository, id, column string) string { +func rawColumn(ctx context.Context, r sqlRepository, id, column string) string { var res struct{ Value string } sel := squirrel.Select("cast(" + column + " as text) as value"). From(r.tableName).Where(squirrel.Eq{"id": id}) - ExpectWithOffset(1, r.queryOne(sel, &res)).To(Succeed()) + ExpectWithOffset(1, r.queryOne(ctx, sel, &res)).To(Succeed()) return res.Value } @@ -36,7 +37,7 @@ var _ = Describe("AlbumRepository", func() { BeforeEach(func() { ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) - albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) + albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) }) Describe("natural sorting", func() { @@ -48,12 +49,12 @@ var _ = Describe("AlbumRepository", func() { for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} { aid := "nat-" + n ids = append(ids, aid) - Expect(albumRepo.Put(&model.Album{ + Expect(albumRepo.Put(ctx, &model.Album{ ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n, })).To(Succeed()) } DeferCleanup(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) }) }) @@ -61,8 +62,8 @@ var _ = Describe("AlbumRepository", func() { func(naturalSorting, preferSortTags bool, expected []string) { conf.Server.EnableNaturalSorting = naturalSorting conf.Server.PreferSortTags = preferSortTags - albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) - albums, err := albumRepo.GetAll(model.QueryOptions{ + albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Sort: "name", Filters: squirrel.Eq{"album.id": ids}, }) Expect(err).ToNot(HaveOccurred()) @@ -79,7 +80,7 @@ var _ = Describe("AlbumRepository", func() { Describe("Get", func() { var Get = func(id string) (*model.Album, error) { - album, err := albumRepo.Get(id) + album, err := albumRepo.Get(ctx, id) if album != nil { album.ImportedAt = time.Time{} } @@ -99,29 +100,29 @@ var _ = Describe("AlbumRepository", func() { BeforeEach(func() { srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC) - Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) - Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) - Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) DeferCleanup(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}})) }) }) It("copies a valid created_at from source to destination", func() { - Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) - got, err := albumRepo.Get("copy-dst") + Expect(albumRepo.CopyAttributes(ctx, "copy-src", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get(ctx, "copy-dst") Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second)) }) It("leaves destination untouched when source created_at is zero", func() { - Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed()) - got, err := albumRepo.Get("copy-dst") + Expect(albumRepo.CopyAttributes(ctx, "copy-zero", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get(ctx, "copy-dst") Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) }) It("returns not found and leaves destination untouched when source does not exist", func() { - err := albumRepo.CopyAttributes("copy-missing", "copy-dst", "created_at") + err := albumRepo.CopyAttributes(ctx, "copy-missing", "copy-dst", "created_at") Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue()) - got, getErr := albumRepo.Get("copy-dst") + got, getErr := albumRepo.Get(ctx, "copy-dst") Expect(getErr).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) }) @@ -129,35 +130,35 @@ var _ = Describe("AlbumRepository", func() { // Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"), // which string-sorts above every space-format timestamp and pins the album to the // top of "Recently Added". - Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) - Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")). - To(Equal(rawColumn(albumRepo.sqlRepository, "copy-src", "created_at"))) - Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T")) + Expect(albumRepo.CopyAttributes(ctx, "copy-src", "copy-dst", "created_at")).To(Succeed()) + Expect(rawColumn(ctx, albumRepo.sqlRepository, "copy-dst", "created_at")). + To(Equal(rawColumn(ctx, albumRepo.sqlRepository, "copy-src", "created_at"))) + Expect(rawColumn(ctx, albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T")) }) }) Describe("GetCursor", func() { It("yields the same albums as GetAll", func() { opts := model.QueryOptions{Sort: "name"} - want, err := albumRepo.GetAll(opts) + want, err := albumRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + Expect(collectCursor(albumRepo.GetCursor(ctx, opts))).To(Equal([]model.Album(want))) }) It("honors Max/Offset like GetAll", func() { opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} - want, err := albumRepo.GetAll(opts) + want, err := albumRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + Expect(collectCursor(albumRepo.GetCursor(ctx, opts))).To(Equal([]model.Album(want))) }) }) Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { - want, err := albumRepo.GetAll() + want, err := albumRepo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := albumRepo.getAllIDs() + ids, err := albumRepo.getAllIDs(ctx) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) }) @@ -165,13 +166,13 @@ var _ = Describe("AlbumRepository", func() { Describe("GetSoleAlbumArtistIDsInSubtrees", func() { It("returns the sole album artists of albums with folders in the subtree", func() { - folderRepo := newFolderRepository(ctx, GetDBXBuilder()) - lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + folderRepo := newFolderRepository(GetDBXBuilder()) + lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist") outTree := model.NewFolder(*lib, "OtherTree/Artist") - Expect(folderRepo.Put(inTree)).To(Succeed()) - Expect(folderRepo.Put(outTree)).To(Succeed()) + Expect(folderRepo.Put(ctx, inTree)).To(Succeed()) + Expect(folderRepo.Put(ctx, outTree)).To(Succeed()) // album_artist_id is deliberately wrong: the artist must come from participants inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID}, @@ -181,34 +182,34 @@ var _ = Describe("AlbumRepository", func() { duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID}, Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}} for _, al := range []model.Album{inAl, outAl, duoAl} { - Expect(albumRepo.Put(&al)).To(Succeed()) + Expect(albumRepo.Put(ctx, &al)).To(Succeed()) } DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute() _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute() }) - ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, "SubtreeAlbums") + ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib, "SubtreeAlbums") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded }) It("stays under SQLite's expression tree depth limit with many paths", func() { - lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) paths := make([]string, 200) for i := range paths { paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i) } - _, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, paths...) + _, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib, paths...) Expect(err).ToNot(HaveOccurred()) }) It("returns nothing when given no paths", func() { - lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) - ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib) + ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(BeEmpty()) }) @@ -221,13 +222,13 @@ var _ = Describe("AlbumRepository", func() { Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999", Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}} - Expect(albumRepo.Put(&sole)).To(Succeed()) - Expect(albumRepo.Put(&duo)).To(Succeed()) + Expect(albumRepo.Put(ctx, &sole)).To(Succeed()) + Expect(albumRepo.Put(ctx, &duo)).To(Succeed()) DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute() }) - als, err := albumRepo.GetAll(model.QueryOptions{Filters: SoleAlbumArtistFilter("2")}) + als, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: SoleAlbumArtistFilter("2")}) Expect(err).ToNot(HaveOccurred()) Expect(als).To(HaveLen(1)) Expect(als[0].ID).To(Equal(sole.ID)) @@ -236,7 +237,7 @@ var _ = Describe("AlbumRepository", func() { Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { - albums, err := albumRepo.GetAll(opts...) + albums, err := albumRepo.GetAll(ctx, opts...) for i := range albums { albums[i].ImportedAt = time.Time{} } @@ -280,7 +281,7 @@ var _ = Describe("AlbumRepository", func() { Describe("recently_added sort", func() { AfterEach(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album"). + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album"). Where(squirrel.Like{"id": "ra-%"})) }) @@ -299,18 +300,18 @@ var _ = Describe("AlbumRepository", func() { // Same second, different nanoseconds: datetime() would tie these. earlier := &model.Album{LibraryID: 1, ID: "ra-earlier", Name: "Earlier"} later := &model.Album{LibraryID: 1, ID: "ra-later", Name: "Later"} - Expect(albumRepo.Put(earlier)).To(Succeed()) - Expect(albumRepo.Put(later)).To(Succeed()) - _, err := albumRepo.executeSQL(squirrel.Update("album"). + Expect(albumRepo.Put(ctx, earlier)).To(Succeed()) + Expect(albumRepo.Put(ctx, later)).To(Succeed()) + _, err := albumRepo.executeSQL(ctx, squirrel.Update("album"). Set("created_at", "2024-01-15 10:00:00.100000000+00:00"). Where(squirrel.Eq{"id": "ra-earlier"})) Expect(err).ToNot(HaveOccurred()) - _, err = albumRepo.executeSQL(squirrel.Update("album"). + _, err = albumRepo.executeSQL(ctx, squirrel.Update("album"). Set("created_at", "2024-01-15 10:00:00.900000000+00:00"). Where(squirrel.Eq{"id": "ra-later"})) Expect(err).ToNot(HaveOccurred()) - albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{Sort: "recently_added", Order: "desc"}) Expect(err).ToNot(HaveOccurred()) Expect(indexOf(albums, "ra-later")).To(BeNumerically("<", indexOf(albums, "ra-earlier")), ".900 should sort before .100 in desc order") @@ -321,17 +322,17 @@ var _ = Describe("AlbumRepository", func() { // match the unfiltered order (the inversion mechanism in #5673). ids := []string{"ra-t1", "ra-t2", "ra-t3", "ra-t4"} for _, aid := range ids { - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) } - _, err := albumRepo.executeSQL(squirrel.Update("album"). + _, err := albumRepo.executeSQL(ctx, squirrel.Update("album"). Set("created_at", "2024-02-20 12:00:00+00:00"). Where(squirrel.Eq{"id": ids})) Expect(err).ToNot(HaveOccurred()) - all, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + all, err := albumRepo.GetAll(ctx, model.QueryOptions{Sort: "recently_added", Order: "desc"}) Expect(err).ToNot(HaveOccurred()) - subset, err := albumRepo.GetAll(model.QueryOptions{ + subset, err := albumRepo.GetAll(ctx, model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"album.id": []string{"ra-t1", "ra-t3"}}}) Expect(err).ToNot(HaveOccurred()) @@ -348,20 +349,20 @@ var _ = Describe("AlbumRepository", func() { BeforeEach(func() { // Create album without any annotation (no star, no rating) albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1} - Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed()) + Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) Describe("starred", func() { It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(rest.QueryOptions{ + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res.(model.Albums) + albums := res var found bool for _, a := range albums { @@ -374,11 +375,11 @@ var _ = Describe("AlbumRepository", func() { }) It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(rest.QueryOptions{ + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res.(model.Albums) + albums := res for _, a := range albums { Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) @@ -388,11 +389,11 @@ var _ = Describe("AlbumRepository", func() { Describe("has_rating", func() { It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(rest.QueryOptions{ + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"has_rating": "false"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res.(model.Albums) + albums := res var found bool for _, a := range albums { @@ -405,11 +406,11 @@ var _ = Describe("AlbumRepository", func() { }) It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(rest.QueryOptions{ + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"has_rating": "true"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res.(model.Albums) + albums := res for _, a := range albums { Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) @@ -425,12 +426,12 @@ var _ = Describe("AlbumRepository", func() { conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) for range playCount { - Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) + Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed()) } - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.PlayCount).To(Equal(int64(expected))) }, @@ -448,12 +449,12 @@ var _ = Describe("AlbumRepository", func() { conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) for range playCount { - Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) + Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed()) } - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.PlayCount).To(Equal(int64(expected))) }, @@ -470,83 +471,79 @@ var _ = Describe("AlbumRepository", func() { Describe("Album.AverageRating", func() { It("returns 0 when no ratings exist", func() { newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed()) - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(0.0)) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed()) - Expect(albumRepo.SetRating(4, newID)).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 4, newID)).To(Succeed()) - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(4.0)) - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("calculates average across multiple users", func() { newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed()) - Expect(albumRepo.SetRating(4, newID)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 4, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository) - Expect(user2Repo.SetRating(5, newID)).To(Succeed()) + Expect(albumRepo.SetRating(user2Ctx, 5, newID)).To(Succeed()) - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(4.5)) - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed()) - Expect(albumRepo.SetRating(3, newID)).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 3, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository) - Expect(user2Repo.SetRating(0, newID)).To(Succeed()) + Expect(albumRepo.SetRating(user2Ctx, 0, newID)).To(Succeed()) - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(3.0)) - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("rounds to 2 decimal places", func() { newID := id.NewRandom() - Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed()) - Expect(albumRepo.SetRating(5, newID)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 5, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository) - Expect(user2Repo.SetRating(4, newID)).To(Succeed()) + Expect(albumRepo.SetRating(user2Ctx, 4, newID)).To(Succeed()) user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser) - user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository) - Expect(user3Repo.SetRating(4, newID)).To(Succeed()) + Expect(albumRepo.SetRating(user3Ctx, 4, newID)).To(Succeed()) - album, err := albumRepo.Get(newID) + album, err := albumRepo.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333... - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) }) @@ -715,10 +712,11 @@ var _ = Describe("AlbumRepository", func() { } var artistRepo *artistRepository + var artistCtx context.Context BeforeEach(func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - artistRepo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + artistCtx = request.WithUser(ctx, adminUser) + artistRepo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) }) // Helper to verify album_artists records @@ -730,7 +728,7 @@ var _ = Describe("AlbumRepository", func() { Where(squirrel.Eq{"album_id": albumID}). OrderBy("role", "artist_id", "sub_role") - err := albumRepo.queryAll(sq, &actual) + err := albumRepo.queryAll(ctx, sq, &actual) Expect(err).ToNot(HaveOccurred()) Expect(actual).To(Equal(expected)) } @@ -743,7 +741,7 @@ var _ = Describe("AlbumRepository", func() { OrderArtistName: "real artist", SortArtistName: "Artist, Real", } - err := createArtistWithLibrary(artistRepo, artist, 1) + err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) Expect(err).ToNot(HaveOccurred()) // Create an album with participants that reference the real artist @@ -764,7 +762,7 @@ var _ = Describe("AlbumRepository", func() { } // Insert the album - err = albumRepo.Put(album) + err = albumRepo.Put(ctx, album) Expect(err).ToNot(HaveOccurred()) // Verify that participant records were actually inserted into album_artists table @@ -775,13 +773,13 @@ var _ = Describe("AlbumRepository", func() { verifyAlbumArtists(album.ID, expected) // Clean up the test artist and album created for this test - _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("finds albums through the participant-based filters", func() { artist := &model.Artist{ID: "filter-artist-1", Name: "Filter Artist", OrderArtistName: "filter artist"} - Expect(createArtistWithLibrary(artistRepo, artist, 1)).To(Succeed()) + Expect(createArtistWithLibrary(artistCtx, artistRepo, artist, 1)).To(Succeed()) album := &model.Album{ LibraryID: 1, @@ -794,36 +792,36 @@ var _ = Describe("AlbumRepository", func() { model.RoleComposer: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, }, } - Expect(albumRepo.Put(album)).To(Succeed()) + Expect(albumRepo.Put(ctx, album)).To(Succeed()) - byArtist, err := albumRepo.GetAll(model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) + byArtist, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) Expect(err).ToNot(HaveOccurred()) Expect(byArtist).To(HaveLen(1)) Expect(byArtist[0].ID).To(Equal(album.ID)) - byComposer, err := albumRepo.GetAll(model.QueryOptions{Filters: artistRoleFilter("role_composer_id", artist.ID)}) + byComposer, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistRoleFilter("role_composer_id", artist.ID)}) Expect(err).ToNot(HaveOccurred()) Expect(byComposer).To(HaveLen(1)) - byLyricist, err := albumRepo.GetAll(model.QueryOptions{Filters: artistRoleFilter("role_lyricist_id", artist.ID)}) + byLyricist, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistRoleFilter("role_lyricist_id", artist.ID)}) Expect(err).ToNot(HaveOccurred()) Expect(byLyricist).To(BeEmpty()) - byAnyRole, err := albumRepo.GetAll(model.QueryOptions{Filters: allRolesFilter("role_total_id", artist.ID)}) + byAnyRole, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: allRolesFilter("role_total_id", artist.ID)}) Expect(err).ToNot(HaveOccurred()) Expect(byAnyRole).To(HaveLen(1)) - count, err := albumRepo.CountAll(model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) + count, err := albumRepo.CountAll(ctx, model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(1))) - _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("clears album_artists rows when saved with empty participants", func() { artist := &model.Artist{ID: "clear-artist-1", Name: "Clear Artist", OrderArtistName: "clear artist"} - Expect(createArtistWithLibrary(artistRepo, artist, 1)).To(Succeed()) + Expect(createArtistWithLibrary(artistCtx, artistRepo, artist, 1)).To(Succeed()) album := &model.Album{ LibraryID: 1, @@ -836,14 +834,14 @@ var _ = Describe("AlbumRepository", func() { }, } DeferCleanup(func() { - _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) - Expect(albumRepo.Put(album)).To(Succeed()) + Expect(albumRepo.Put(ctx, album)).To(Succeed()) verifyAlbumArtists(album.ID, []albumArtistRecord{{ArtistID: artist.ID, Role: "albumartist", SubRole: ""}}) album.Participants = model.Participants{} - Expect(albumRepo.Put(album)).To(Succeed()) + Expect(albumRepo.Put(ctx, album)).To(Succeed()) verifyAlbumArtists(album.ID, []albumArtistRecord{}) }) @@ -859,9 +857,9 @@ var _ = Describe("AlbumRepository", func() { Name: "Real Artist 2", OrderArtistName: "real artist 2", } - err := createArtistWithLibrary(artistRepo, artist1, 1) + err := createArtistWithLibrary(artistCtx, artistRepo, artist1, 1) Expect(err).ToNot(HaveOccurred()) - err = createArtistWithLibrary(artistRepo, artist2, 1) + err = createArtistWithLibrary(artistCtx, artistRepo, artist2, 1) Expect(err).ToNot(HaveOccurred()) // Create an album with mix of valid and invalid artist IDs @@ -885,7 +883,7 @@ var _ = Describe("AlbumRepository", func() { } // This should not fail - only valid artists should be inserted - err = albumRepo.Put(album) + err = albumRepo.Put(ctx, album) Expect(err).ToNot(HaveOccurred()) // Verify that only valid artist IDs were inserted into album_artists table @@ -899,8 +897,8 @@ var _ = Describe("AlbumRepository", func() { // Clean up the test artists and album created for this test artistIDs := []string{artist1.ID, artist2.ID} - _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("handles complex nested JSON with multiple roles and sub-roles", func() { @@ -913,7 +911,7 @@ var _ = Describe("AlbumRepository", func() { } for _, artist := range artists { - err := createArtistWithLibrary(artistRepo, artist, 1) + err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) Expect(err).ToNot(HaveOccurred()) } @@ -940,7 +938,7 @@ var _ = Describe("AlbumRepository", func() { }, } - err := albumRepo.Put(album) + err := albumRepo.Put(ctx, album) Expect(err).ToNot(HaveOccurred()) // Verify complex JSON structure was correctly parsed and inserted @@ -959,8 +957,8 @@ var _ = Describe("AlbumRepository", func() { for i, artist := range artists { artistIDs[i] = artist.ID } - _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("handles albums with non-existent artist IDs without constraint errors", func() { @@ -991,7 +989,7 @@ var _ = Describe("AlbumRepository", func() { // This should not fail with foreign key constraint error // The updateParticipants method should handle non-existent artist IDs gracefully - err := albumRepo.Put(album) + err := albumRepo.Put(ctx, album) Expect(err).ToNot(HaveOccurred()) // Verify that no participant records were inserted since all artist IDs were invalid @@ -999,7 +997,7 @@ var _ = Describe("AlbumRepository", func() { verifyAlbumArtists(album.ID, []albumArtistRecord{}) // Clean up the test album created for this test - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("removes stale role associations when artist role changes", func() { @@ -1013,7 +1011,7 @@ var _ = Describe("AlbumRepository", func() { Name: "Role Change Artist", OrderArtistName: "role change artist", } - err := createArtistWithLibrary(artistRepo, artist, 1) + err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) Expect(err).ToNot(HaveOccurred()) // Create album with artist as both albumartist and composer @@ -1033,7 +1031,7 @@ var _ = Describe("AlbumRepository", func() { }, } - err = albumRepo.Put(album) + err = albumRepo.Put(ctx, album) Expect(err).ToNot(HaveOccurred()) // Verify initial state: artist has both albumartist and composer roles @@ -1050,7 +1048,7 @@ var _ = Describe("AlbumRepository", func() { }, } - err = albumRepo.Put(album) + err = albumRepo.Put(ctx, album) Expect(err).ToNot(HaveOccurred()) // Verify that the albumartist role was removed - only composer should remain @@ -1062,14 +1060,14 @@ var _ = Describe("AlbumRepository", func() { verifyAlbumArtists(album.ID, expectedAfter) // Clean up - _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) }) Describe("GetYears", func() { It("returns distinct album years ascending, excluding zero", func() { - years, err := albumRepo.GetYears() + years, err := albumRepo.GetYears(ctx) Expect(err).ToNot(HaveOccurred()) // Sorted ascending, no duplicates, no zero-year entries. Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) @@ -1084,13 +1082,13 @@ var _ = Describe("AlbumRepository", func() { // Insert two albums with the same non-zero max_year (2005). album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} - Expect(albumRepo.Put(album1)).To(Succeed()) - Expect(albumRepo.Put(album2)).To(Succeed()) + Expect(albumRepo.Put(ctx, album1)).To(Succeed()) + Expect(albumRepo.Put(ctx, album2)).To(Succeed()) DeferCleanup(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) }) - years, err := albumRepo.GetYears() + years, err := albumRepo.GetYears(ctx) Expect(err).ToNot(HaveOccurred()) // Count occurrences of 2005 in the result @@ -1104,10 +1102,10 @@ var _ = Describe("AlbumRepository", func() { }) It("scopes years to the given libraries", func() { - all, err := albumRepo.GetYears() + all, err := albumRepo.GetYears(ctx) Expect(err).ToNot(HaveOccurred()) // A library with no albums yields no years. - scoped, err := albumRepo.GetYears(99999) + scoped, err := albumRepo.GetYears(ctx, 99999) Expect(err).ToNot(HaveOccurred()) Expect(scoped).To(BeEmpty()) Expect(all).ToNot(BeEmpty()) @@ -1115,12 +1113,12 @@ var _ = Describe("AlbumRepository", func() { It("excludes years that belong only to missing albums", func() { gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} - Expect(albumRepo.Put(gone)).To(Succeed()) + Expect(albumRepo.Put(ctx, gone)).To(Succeed()) DeferCleanup(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) }) - years, err := albumRepo.GetYears() + years, err := albumRepo.GetYears(ctx) Expect(err).ToNot(HaveOccurred()) Expect(years).ToNot(ContainElement(1911)) }) @@ -1169,15 +1167,15 @@ var _ = Describe("AlbumRepository", func() { Describe("ReplayGain", func() { BeforeEach(func() { DeferCleanup(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) }) }) It("round-trips album ReplayGain gain and peak", func() { - Expect(albumRepo.Put(&model.Album{ + Expect(albumRepo.Put(ctx, &model.Album{ ID: "rg-1", Name: "rg", LibraryID: 1, RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), })).To(Succeed()) - got, err := albumRepo.Get("rg-1") + got, err := albumRepo.Get(ctx, "rg-1") Expect(err).ToNot(HaveOccurred()) Expect(got.RGAlbumGain).ToNot(BeNil()) Expect(*got.RGAlbumGain).To(Equal(-7.5)) @@ -1185,8 +1183,8 @@ var _ = Describe("AlbumRepository", func() { Expect(*got.RGAlbumPeak).To(Equal(0.98)) }) It("reads nil when ReplayGain is unset", func() { - Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) - got, err := albumRepo.Get("rg-2") + Expect(albumRepo.Put(ctx, &model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get(ctx, "rg-2") Expect(err).ToNot(HaveOccurred()) Expect(got.RGAlbumGain).To(BeNil()) Expect(got.RGAlbumPeak).To(BeNil()) @@ -1196,16 +1194,46 @@ var _ = Describe("AlbumRepository", func() { // Exists must apply the same library filter as Get/GetAll/CountAll. Describe("Exists library visibility", func() { It("hides an album the user has no library access to", func() { - Expect(albumRepo.Put(&model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed()) DeferCleanup(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "vis-album"})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": "vis-album"})) }) - Expect(albumRepo.Exists("vis-album")).To(BeTrue(), "admin sees it") + Expect(albumRepo.Exists(ctx, "vis-album")).To(BeTrue(), "admin sees it") restricted := model.User{ID: "restricted_album_user", UserName: "ra", Name: "RA", Email: "ra@t.com"} rctx := request.WithUser(GinkgoT().Context(), restricted) - Expect(NewAlbumRepository(rctx, GetDBXBuilder()).Exists("vis-album")).To(BeFalse()) + Expect(albumRepo.Exists(rctx, "vis-album")).To(BeFalse()) + }) + + It("keeps per-user library visibility separate on a shared repository", func() { + adminCtx := request.WithUser(ctx, adminUser) + adminCount, err := albumRepo.CountAll(adminCtx) + Expect(err).ToNot(HaveOccurred()) + Expect(adminCount).To(BeNumerically(">", 0)) + + // A user with no library grants, so its visibility can't drift with other specs + restrictedCtx := request.WithUser(ctx, model.User{ID: "shared-repo-restricted"}) + restrictedCount, err := albumRepo.CountAll(restrictedCtx) + Expect(err).ToNot(HaveOccurred()) + Expect(restrictedCount).To(BeZero()) + + var wg sync.WaitGroup + for i := range 20 { + wg.Add(1) + go func(i int) { + defer GinkgoRecover() + defer wg.Done() + c, want := adminCtx, adminCount + if i%2 == 1 { + c, want = restrictedCtx, restrictedCount + } + got, err := albumRepo.CountAll(c) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(want)) + }(i) + } + wg.Wait() }) }) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 97c1452cb..b90a48c48 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "slices" "strings" @@ -129,9 +130,8 @@ func (dba dbArtists) toModels() model.Artists { return res } -func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistRepository { +func NewArtistRepository(db dbx.Builder) model.ArtistRepository { r := &artistRepository{} - r.ctx = ctx r.db = db r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups) r.tableName = "artist" // To be used by the idFilter below @@ -188,8 +188,8 @@ func artistLibraryIdFilter(_ string, value any) Sqlizer { } // applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table -func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) SelectBuilder { - user := loggedUser(r.ctx) +func (r *artistRepository) applyLibraryFilterToArtistQuery(ctx context.Context, query SelectBuilder) SelectBuilder { + user := loggedUser(ctx) // Join with library_artist first to ensure only artists with content in libraries are included // Exclude artists with empty stats (no actual content in the library) query = query.Join("library_artist on library_artist.artist_id = artist.id") @@ -204,106 +204,106 @@ func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) return query } -func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { +func (r *artistRepository) selectArtist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} - query := r.newSelect(options...).Columns("artist.*", + query := r.newSelect(ctx, options...).Columns("artist.*", "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") - query = r.applyLibraryFilterToArtistQuery(query) + query = r.applyLibraryFilterToArtistQuery(ctx, query) query = query.GroupBy("artist.id") - return r.withAnnotation(query, "artist.id") + return r.withAnnotation(ctx, query, "artist.id") } -func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { - query := r.newSelect() - query = r.applyLibraryFilterToArtistQuery(query) +func (r *artistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + query := r.newSelect(ctx) + query = r.applyLibraryFilterToArtistQuery(ctx, query) // Only the annotation join is gated; the library_artist join above (and its count(distinct)) // must stay, since an artist can span multiple libraries. if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(query, "artist.id") + query = r.withAnnotation(ctx, query, "artist.id") } - return r.count(query, options...) + return r.count(ctx, query, options...) } // Exists checks if an artist with the given ID exists in the database and is accessible by the current user. -func (r *artistRepository) Exists(id string) (bool, error) { +func (r *artistRepository) Exists(ctx context.Context, id string) (bool, error) { // Create a query using the same library filtering logic as selectArtist() - query := r.newSelect().Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) - query = r.applyLibraryFilterToArtistQuery(query) + query := r.newSelect(ctx).Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) + query = r.applyLibraryFilterToArtistQuery(ctx, query) var res struct{ Exist int64 } - err := r.queryOne(query, &res) + err := r.queryOne(ctx, query, &res) return res.Exist > 0, err } -func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { +func (r *artistRepository) Put(ctx context.Context, a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} dba.CreatedAt = new(time.Now()) dba.UpdatedAt = dba.CreatedAt - _, err := r.put(dba.ID, dba, colsToUpdate...) + _, err := r.put(ctx, dba.ID, dba, colsToUpdate...) return err } -func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error { +func (r *artistRepository) UpdateExternalInfo(ctx context.Context, a *model.Artist) error { dba := &dbArtist{Artist: a} - _, err := r.put(a.ID, dba, + _, err := r.put(ctx, a.ID, dba, "biography", "small_image_url", "medium_image_url", "large_image_url", "similar_artists", "external_url", "external_info_updated_at") return err } -func (r *artistRepository) Get(id string) (*model.Artist, error) { - sel := r.selectArtist().Where(Eq{"artist.id": id}) +func (r *artistRepository) Get(ctx context.Context, id string) (*model.Artist, error) { + sel := r.selectArtist(ctx).Where(Eq{"artist.id": id}) var dba dbArtists - if err := r.queryAll(sel, &dba); err != nil { + if err := r.queryAll(ctx, sel, &dba); err != nil { return nil, err } if len(dba) == 0 { return nil, model.ErrNotFound } res := dba.toModels() - r.hydrateArtwork(res) + r.hydrateArtwork(ctx, res) return &res[0], nil } -func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) { - sel := r.selectArtist(options...) +func (r *artistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Artists, error) { + sel := r.selectArtist(ctx, options...) var dba dbArtists - err := r.queryAll(sel, &dba) + err := r.queryAll(ctx, sel, &dba) if err != nil { return nil, err } res := dba.toModels() - r.hydrateArtwork(res) + r.hydrateArtwork(ctx, res) return res, err } // getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the // heavy stats columns and JSON post-processing. -func (r *artistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") +func (r *artistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilterToArtistQuery(ctx, r.newSelect(ctx, options...).Columns("artist.id")).GroupBy("artist.id") if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(sq, "artist.id") + sq = r.withAnnotation(ctx, sq, "artist.id") } ids := []string{} - err := r.queryAllSlice(sq, &ids) + err := r.queryAllSlice(ctx, sq, &ids) return ids, err } // hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. -func (r *artistRepository) hydrateArtwork(artists model.Artists) { - hydrateItems(r.ctx, r.db, model.KindArtistArtwork, artists, +func (r *artistRepository) hydrateArtwork(ctx context.Context, artists model.Artists) { + hydrateItems(ctx, r.db, model.KindArtistArtwork, artists, func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) } -func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.getAllIDs(options...) +func (r *artistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { + ids, err := r.getAllIDs(ctx, options...) if err != nil { return nil, err } opts := chunkOptions(options, "artist.id") return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) { - return r.GetAll(opts(chunk)) + return r.GetAll(ctx, opts(chunk)) })), nil } @@ -324,7 +324,7 @@ func (r *artistRepository) getIndexKey(a model.Artist) string { // GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured. // It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums). // TODO Cache the index (recalculate at scan time) -func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { +func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { // Validate library IDs. If no library IDs are provided, return an empty index. if len(libraryIds) == 0 { return nil, nil @@ -352,7 +352,7 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles options.Filters = And{options.Filters, libFilter} } - artists, err := r.GetAll(options) + artists, err := r.GetAll(ctx, options) if err != nil { return nil, err } @@ -367,7 +367,7 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles return result, nil } -func (r *artistRepository) purgeEmpty() error { +func (r *artistRepository) purgeEmpty(ctx context.Context) error { orphanFilter := "id not in (select artist_id from album_artists)" // Collect uploaded image filenames before deleting @@ -375,18 +375,18 @@ func (r *artistRepository) purgeEmpty() error { Where(orphanFilter). Where("uploaded_image != ''") var imageFiles []string - if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { + if err := r.queryAllSlice(ctx, sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { return fmt.Errorf("collecting artist images for cleanup: %w", err) } // Delete orphan artists del := Delete(r.tableName).Where(orphanFilter) - c, err := r.executeSQL(del) + c, err := r.executeSQL(ctx, del) if err != nil { return fmt.Errorf("purging empty artists: %w", err) } if c > 0 { - log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) + log.Debug(ctx, "Purged empty artists", "totalDeleted", c) } if len(imageFiles) == 0 { @@ -394,11 +394,11 @@ func (r *artistRepository) purgeEmpty() error { } // Best-effort cleanup of uploaded image files - log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) + log.Debug(ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) for _, filename := range imageFiles { path := model.UploadedImagePath(consts.EntityArtist, filename) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err) + log.Warn(ctx, "Failed to remove artist image during GC", "path", path, err) } } return nil @@ -407,9 +407,9 @@ func (r *artistRepository) purgeEmpty() error { // markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the // search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can // be dropped: RefreshStats cleanup and library deletion cascade. -func (r *artistRepository) markOrphansMissing() error { - _, err := r.executeSQL(Expr( - "update artist set missing = true where missing = false " + +func (r *artistRepository) markOrphansMissing(ctx context.Context) error { + _, err := r.executeSQL(ctx, Expr( + "update artist set missing = true where missing = false "+ "and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)")) if err != nil { return fmt.Errorf("marking orphaned artists missing: %w", err) @@ -418,7 +418,7 @@ func (r *artistRepository) markOrphansMissing() error { } // markMissing marks artists as missing if all their albums are missing. -func (r *artistRepository) markMissing() error { +func (r *artistRepository) markMissing(ctx context.Context) error { q := Expr(` with artists_with_non_missing_albums as ( select distinct aa.artist_id @@ -429,7 +429,7 @@ with artists_with_non_missing_albums as ( update artist set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums)); `) - _, err := r.executeSQL(q) + _, err := r.executeSQL(ctx, q) if err != nil { return fmt.Errorf("marking missing artists: %w", err) } @@ -438,7 +438,7 @@ set missing = (artist.id not in (select artist_id from artists_with_non_missing_ // RefreshPlayCounts updates the play count and last play date annotations for all artists, based // on the media files associated with them. -func (r *artistRepository) RefreshPlayCounts() (int64, error) { +func (r *artistRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { query := Expr(` with play_counts as ( select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date @@ -456,13 +456,13 @@ on conflict (user_id, item_id, item_type) do update set play_count = excluded.play_count, play_date = excluded.play_date; `) - return r.executeSQL(query) + return r.executeSQL(ctx, query) } // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. // When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. // This method now calculates per-library statistics and stores them in the library_artist junction table. -func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { +func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { var allTouchedArtistIDs []string if allArtists { // Refresh stats for all artists @@ -470,7 +470,7 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { return 0, fmt.Errorf("fetching all artist IDs: %w", err) } - log.Debug(r.ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) + log.Debug(ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) } else { // Only refresh artists with updated timestamps touchedArtistsQuerySQL := ` @@ -481,11 +481,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { return 0, fmt.Errorf("fetching touched artist IDs: %w", err) } - log.Debug(r.ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) + log.Debug(ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) } if len(allTouchedArtistIDs) == 0 { - log.Debug(r.ctx, "RefreshStats: No artists to update.") + log.Debug(ctx, "RefreshStats: No artists to update.") return 0, nil } @@ -558,7 +558,7 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { batchCounter := 0 for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) { batchCounter++ - log.Trace(r.ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch)) + log.Trace(ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch)) // Create placeholders for each ID in the IN clauses placeholders := make([]string, len(artistIDBatch)) @@ -584,7 +584,7 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { // Now use Expr with the expanded SQL and all parameters sqlizer := Expr(batchSQL, args...) - rowsAffected, err := r.executeSQL(sqlizer) + rowsAffected, err := r.executeSQL(ctx, sqlizer) if err != nil { return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err) } @@ -593,23 +593,23 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { // Remove library_artist entries for artists that no longer have any content in a library. cleanupSQL := Delete("library_artist").Where("stats = '{}'") - cleanupRows, err := r.executeSQL(cleanupSQL) + cleanupRows, err := r.executeSQL(ctx, cleanupSQL) if err != nil { - log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", err) + log.Warn(ctx, "Failed to cleanup empty library_artist entries", err) } else { if cleanupRows > 0 { - log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + log.Debug(ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) } // Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan // also heals any left by older versions. if cleanupRows > 0 || allArtists { - if err := r.markOrphansMissing(); err != nil { - log.Warn(r.ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) + if err := r.markOrphansMissing(ctx); err != nil { + log.Warn(ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) } } } - log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) + log.Debug(ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } @@ -649,39 +649,39 @@ func artistLibraryFilter(libraryIDs []int) Sqlizer { return Expr("EXISTS ("+sub+")", args...) } -func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) { +func (r *artistRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } // Artists have no library_id column, so the library_id filter callers pass (same as albums/songs) // can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg). - scope := r.searchScope(opts.Filters) + scope := r.searchScope(ctx, opts.Filters) if isLibraryIDFilter(opts.Filters) { opts.Filters = nil } var res dbArtists - err := r.doSearch(r.selectArtist(opts), q, &res, r.searchCfg(scope), opts) + err := r.doSearch(ctx, r.selectArtist(ctx, opts), q, &res, r.searchCfg(scope), opts) if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } artists := res.toModels() - r.hydrateArtwork(artists) + r.hydrateArtwork(ctx, artists) return artists, nil } // searchScope returns the library IDs the search must be restricted to, or nil to skip the filter // entirely (the fast-path: the user sees everything the search could return, so a filter would be // pure O(offset) overhead). It intersects the requested libraries with what the user can see. -func (r *artistRepository) searchScope(filter Sqlizer) []int { - visible, err := r.visibleLibraryIDs() +func (r *artistRepository) searchScope(ctx context.Context, filter Sqlizer) []int { + visible, err := r.visibleLibraryIDs(ctx) if err != nil { return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen } requested := r.requestedLibraryIDs(filter) if requested == nil { // No explicit request: scope to the visible set, unless the user sees everything. - if r.userSeesAllLibraries(visible) { + if r.userSeesAllLibraries(ctx, visible) { return nil } return visible @@ -717,15 +717,17 @@ func isLibraryIDFilter(filter Sqlizer) bool { return ok } -func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *artistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *artistRepository) Read(id string) (any, error) { - return r.Get(id) +func (r *artistRepository) Read(ctx context.Context, id string) (*model.Artist, error) { + return r.Get(ctx, id) } -func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { +// sortMappingsForRole copies the shared mappings so a role-specific sort never leaks into them. +// Anything sanitizeArtistStatsRole rejects falls back to the "total" aggregate. +func (r *artistRepository) sortMappingsForRole(options ...rest.QueryOptions) map[string]string { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { @@ -734,19 +736,18 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { } } } - r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" - r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" - r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')" - return r.GetAll(r.parseRestOptions(r.ctx, options...)) + mappings := maps.Clone(r.sortMappings) + mappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" + mappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" + mappings["size"] = "sum(stats->>'" + role + "'->>'s')" + return mappings } -func (r *artistRepository) EntityName() string { - return "artist" -} - -func (r *artistRepository) NewInstance() any { - return &model.Artist{} +func (r *artistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Artist, error) { + scoped := *r + scoped.sortMappings = r.sortMappingsForRole(options...) + return scoped.GetAll(ctx, scoped.parseRestOptions(ctx, options...)) } var _ model.ArtistRepository = (*artistRepository)(nil) -var _ model.ResourceRepository = (*artistRepository)(nil) +var _ rest.Repository[model.Artist] = (*artistRepository)(nil) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index f6612acc4..914c2f89a 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -49,6 +50,11 @@ func createUserWithLibraries(userID string, libraryIDs []int) model.User { } var _ = Describe("ArtistRepository", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) Context("Core Functionality", func() { Describe("GetIndexKey", func() { @@ -130,31 +136,70 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("ReadAll role sort SQL injection", func() { - It("does not interpolate attacker-controlled role into ORDER BY", func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) - payload := "total') OR 1=1--" - _, err := repo.ReadAll(rest.QueryOptions{ - Sort: "songCount", - Order: "ASC", - Filters: map[string]any{"role": payload}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'total'->>'m')")) - Expect(repo.sortMappings["song_count"]).ToNot(ContainSubstring(payload)) + Describe("ReadAll role sort", func() { + payload := "total') OR 1=1--" + + songCountSortFor := func(role any) string { + repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) + return repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": role}})["song_count"] + } + + It("falls back to the total stats role for an attacker-controlled role", func() { + Expect(songCountSortFor(payload)).To(Equal("sum(stats->>'total'->>'m')")) + Expect(songCountSortFor("bogus")).To(Equal("sum(stats->>'total'->>'m')")) + Expect(songCountSortFor(42)).To(Equal("sum(stats->>'total'->>'m')")) }) It("keeps valid role sort paths", func() { + Expect(songCountSortFor("composer")).To(Equal("sum(stats->>'composer'->>'m')")) + Expect(songCountSortFor("albumartist")).To(Equal("sum(stats->>'albumartist'->>'m')")) + }) + + It("leaves the shared mappings untouched", func() { + repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) + Expect(repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": "composer"}})). + ToNot(Equal(repo.sortMappings)) + Expect(repo.sortMappings["song_count"]).To(Equal("stats->>'total'->>'m'")) + }) + + It("orders by the requested role's stats, not the total", func() { ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) - _, err := repo.ReadAll(rest.QueryOptions{ + repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) + // Composer and total counts rank the two artists in opposite orders, so the + // resulting order alone proves which mapping the sort used. + seed := func(artistID, stats string) { + _, err := repo.executeSQL(ctx, squirrel.Insert("library_artist"). + Columns("library_id", "artist_id", "stats"). + Values(1, artistID, stats). + Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _, _ = repo.executeSQL(ctx, squirrel.Update("library_artist").Set("stats", "{}"). + Where(squirrel.Eq{"library_id": 1, "artist_id": artistID})) + }) + } + seed(artistBeatles.ID, `{"composer": {"s": 1, "m": 1, "a": 1}, "total": {"s": 1, "m": 100, "a": 1}}`) + seed(artistKraftwerk.ID, `{"composer": {"s": 1, "m": 9, "a": 1}, "total": {"s": 1, "m": 2, "a": 1}}`) + + res, err := repo.ReadAll(ctx, rest.QueryOptions{ Sort: "songCount", Order: "DESC", Filters: map[string]any{"role": "composer"}, }) Expect(err).ToNot(HaveOccurred()) - Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'composer'->>'m')")) + Expect(slice.Map(res, func(a model.Artist) string { return a.ID })). + To(Equal([]string{artistKraftwerk.ID, artistBeatles.ID})) + }) + + It("still returns results when the role is an injection payload", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) + _, err := repo.ReadAll(ctx, rest.QueryOptions{ + Sort: "songCount", + Order: "ASC", + Filters: map[string]any{"role": payload}, + }) + Expect(err).ToNot(HaveOccurred()) }) }) @@ -163,8 +208,8 @@ var _ = Describe("ArtistRepository", func() { // the way Search() does, for a repo whose context carries the given user. scope := func(user model.User, filter squirrel.Sqlizer) []int { ctx := request.WithUser(GinkgoT().Context(), user) - r := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) - return r.searchScope(filter) + r := NewArtistRepository(GetDBXBuilder()).(*artistRepository) + return r.searchScope(ctx, filter) } subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}} @@ -185,7 +230,7 @@ var _ = Describe("ArtistRepository", func() { // A restricted user (strictly fewer libs than exist) with no musicFolderId is still // confined to their granted libs. Build the user with total-1 libraries derived from // the real DB total, so the "sees all" fast-path can't kick in regardless of count. - total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + total, err := NewLibraryRepository(GetDBXBuilder()).CountAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(total).To(BeNumerically(">", 0)) libs := make(model.Libraries, 0, total-1) @@ -201,8 +246,8 @@ var _ = Describe("ArtistRepository", func() { // Admins see every library, so the visible set is the whole library table — derive // it from the DB rather than assuming a count. var allLibs []int - Expect(NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).(*libraryRepository). - queryAllSlice(squirrel.Select("id").From("library"), &allLibs)).To(Succeed()) + Expect(NewLibraryRepository(GetDBXBuilder()).(*libraryRepository). + queryAllSlice(ctx, squirrel.Select("id").From("library"), &allLibs)).To(Succeed()) admin := model.User{ID: "a", IsAdmin: true} Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil()) Expect(scope(admin, nil)).To(BeNil()) @@ -310,33 +355,54 @@ var _ = Describe("ArtistRepository", func() { var repo model.ArtistRepository BeforeEach(func() { - ctx := GinkgoT().Context() ctx = request.WithUser(ctx, adminUser) - repo = NewArtistRepository(ctx, GetDBXBuilder()) + repo = NewArtistRepository(GetDBXBuilder()) + }) + + Describe("ReadAll with role sort", func() { + It("does not change the shared sort mappings", func() { + original := repo.(*artistRepository).sortMappings["song_count"] + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + role := "artist" + if i%2 == 1 { + role = "composer" + } + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + _, err := repo.ReadAll(ctx, rest.QueryOptions{Sort: "song_count", Filters: map[string]any{"role": role}}) + Expect(err).ToNot(HaveOccurred()) + }() + } + wg.Wait() + Expect(repo.(*artistRepository).sortMappings["song_count"]).To(Equal(original)) + }) }) Describe("GetCursor", func() { It("yields the same artists as GetAll", func() { opts := model.QueryOptions{Sort: "name"} - want, err := repo.GetAll(opts) + want, err := repo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want))) }) It("honors Max/Offset like GetAll", func() { opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} - want, err := repo.GetAll(opts) + want, err := repo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want))) }) }) Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { - want, err := repo.GetAll() + want, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.(*artistRepository).getAllIDs() + ids, err := repo.(*artistRepository).getAllIDs(ctx) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) }) @@ -345,12 +411,12 @@ var _ = Describe("ArtistRepository", func() { Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { - Expect(repo.CountAll()).To(Equal(int64(4))) + Expect(repo.CountAll(ctx)).To(Equal(int64(4))) }) It("counts starred artists when an annotation filter is present", func() { // The Beatles (id 3) is starred for the admin user in the seed data - count, err := repo.CountAll(model.QueryOptions{ + count, err := repo.CountAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -358,7 +424,7 @@ var _ = Describe("ArtistRepository", func() { }) It("counts with has_rating=false without a 'no such column' error (join kept)", func() { - count, err := repo.CountAll(model.QueryOptions{ + count, err := repo.CountAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "false"), }) Expect(err).ToNot(HaveOccurred()) @@ -368,16 +434,16 @@ var _ = Describe("ArtistRepository", func() { Describe("Exists", func() { It("returns true for an artist that is in the DB", func() { - Expect(repo.Exists("3")).To(BeTrue()) + Expect(repo.Exists(ctx, "3")).To(BeTrue()) }) It("returns false for an artist that is NOT in the DB", func() { - Expect(repo.Exists("666")).To(BeFalse()) + Expect(repo.Exists(ctx, "666")).To(BeFalse()) }) }) Describe("Get", func() { It("retrieves existing artist data", func() { - artist, err := repo.Get("2") + artist, err := repo.Get(ctx, "2") Expect(err).ToNot(HaveOccurred()) Expect(artist.Name).To(Equal(artistKraftwerk.Name)) }) @@ -392,10 +458,10 @@ var _ = Describe("ArtistRepository", func() { It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() { // Set SortArtistName to "Foo" for Beatles artistBeatles.SortArtistName = "Foo" - er := repo.Put(&artistBeatles) + er := repo.Put(ctx, &artistBeatles) Expect(er).To(BeNil()) - idx, err := repo.GetIndex(false, []int{1}) + idx, err := repo.GetIndex(ctx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("F")) @@ -413,13 +479,13 @@ var _ = Describe("ArtistRepository", func() { // Restore the original value artistBeatles.SortArtistName = "" - er = repo.Put(&artistBeatles) + er = repo.Put(ctx, &artistBeatles) Expect(er).To(BeNil()) }) // BFR Empty SortArtistName is not saved in the DB anymore XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { - idx, err := repo.GetIndex(false, []int{1}) + idx, err := repo.GetIndex(ctx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) @@ -444,10 +510,10 @@ var _ = Describe("ArtistRepository", func() { It("returns the index when SortArtistName is NOT empty", func() { // Set SortArtistName to "Foo" for Beatles artistBeatles.SortArtistName = "Foo" - er := repo.Put(&artistBeatles) + er := repo.Put(ctx, &artistBeatles) Expect(er).To(BeNil()) - idx, err := repo.GetIndex(false, []int{1}) + idx, err := repo.GetIndex(ctx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) @@ -465,12 +531,12 @@ var _ = Describe("ArtistRepository", func() { // Restore the original value artistBeatles.SortArtistName = "" - er = repo.Put(&artistBeatles) + er = repo.Put(ctx, &artistBeatles) Expect(er).To(BeNil()) }) It("returns the index when SortArtistName is empty", func() { - idx, err := repo.GetIndex(false, []int{1}) + idx, err := repo.GetIndex(ctx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) @@ -498,14 +564,14 @@ var _ = Describe("ArtistRepository", func() { producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}` // Set Beatles as composer in library 1 - _, err := raw.executeSQL(squirrel.Insert("library_artist"). + _, err := raw.executeSQL(ctx, squirrel.Insert("library_artist"). Columns("library_id", "artist_id", "stats"). Values(1, artistBeatles.ID, composerStats). Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) Expect(err).ToNot(HaveOccurred()) // Set Kraftwerk as producer in library 1 - _, err = raw.executeSQL(squirrel.Insert("library_artist"). + _, err = raw.executeSQL(ctx, squirrel.Insert("library_artist"). Columns("library_id", "artist_id", "stats"). Values(1, artistKraftwerk.ID, producerStats). Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) @@ -514,16 +580,16 @@ var _ = Describe("ArtistRepository", func() { AfterEach(func() { // Clean up stats from library_artist table - _, _ = raw.executeSQL(squirrel.Update("library_artist"). + _, _ = raw.executeSQL(ctx, squirrel.Update("library_artist"). Set("stats", "{}"). Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1})) - _, _ = raw.executeSQL(squirrel.Update("library_artist"). + _, _ = raw.executeSQL(ctx, squirrel.Update("library_artist"). Set("stats", "{}"). Where(squirrel.Eq{"artist_id": artistKraftwerk.ID, "library_id": 1})) }) It("returns only artists with the specified role", func() { - idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer) + idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(1)) Expect(idx[0].ID).To(Equal("B")) @@ -532,7 +598,7 @@ var _ = Describe("ArtistRepository", func() { }) It("returns artists with any of the specified roles", func() { - idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer, model.RoleProducer) + idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer, model.RoleProducer) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(2)) @@ -553,7 +619,7 @@ var _ = Describe("ArtistRepository", func() { }) It("returns empty index when no artists have the specified role", func() { - idx, err := repo.GetIndex(false, []int{1}, model.RoleDirector) + idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleDirector) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) @@ -561,19 +627,19 @@ var _ = Describe("ArtistRepository", func() { When("validating library IDs", func() { It("returns nil when no library IDs are provided", func() { - idx, err := repo.GetIndex(false, []int{}) + idx, err := repo.GetIndex(ctx, false, []int{}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(BeNil()) }) It("returns artists when library IDs are provided (admin user sees all content)", func() { // Admin users can see all content when valid library IDs are provided - idx, err := repo.GetIndex(false, []int{1}) + idx, err := repo.GetIndex(ctx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) // With non-existent library ID, admin users see no content because no artists are associated with that library - idx, err = repo.GetIndex(false, []int{999}) + idx, err = repo.GetIndex(ctx, false, []int{999}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations }) @@ -586,23 +652,23 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { // Create artist without any annotation artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"} - err := createArtistWithLibrary(repo, &artistWithoutAnnotation, 1) + err := createArtistWithLibrary(ctx, repo, &artistWithoutAnnotation, 1) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID})) } }) Describe("starred", func() { It("false includes items without annotations", func() { - res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := repo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - artists := res.(model.Artists) + artists := res var found bool for _, a := range artists { @@ -615,11 +681,11 @@ var _ = Describe("ArtistRepository", func() { }) It("true excludes items without annotations", func() { - res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := repo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - artists := res.(model.Artists) + artists := res for _, a := range artists { Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID)) @@ -631,57 +697,63 @@ var _ = Describe("ArtistRepository", func() { Describe("MBID and Text Search", func() { var lib2 model.Library var lr model.LibraryRepository + var lrCtx context.Context var restrictedUser model.User var restrictedRepo model.ArtistRepository + var restrictedCtx context.Context var headlessRepo model.ArtistRepository + var headlessCtx context.Context BeforeEach(func() { // Set up headless repo (no user context) - headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder()) + headlessCtx = GinkgoT().Context() + headlessRepo = NewArtistRepository(GetDBXBuilder()) // Create library for testing access restrictions lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} - lr = NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - err := lr.Put(&lib2) + lrCtx = request.WithUser(ctx, adminUser) + lr = NewLibraryRepository(GetDBXBuilder()) + err := lr.Put(lrCtx, &lib2) Expect(err).ToNot(HaveOccurred()) // Create a user with access to only library 1 restrictedUser = createUserWithLibraries("search_user", []int{1}) // Create repository context for the restricted user - ctx := request.WithUser(GinkgoT().Context(), restrictedUser) - restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) + restrictedCtx = request.WithUser(ctx, restrictedUser) + restrictedRepo = NewArtistRepository(GetDBXBuilder()) // Ensure both test artists are associated with library 1 - err = lr.AddArtist(1, artistBeatles.ID) + err = lr.AddArtist(lrCtx, 1, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) - err = lr.AddArtist(1, artistKraftwerk.ID) + err = lr.AddArtist(lrCtx, 1, artistKraftwerk.ID) Expect(err).ToNot(HaveOccurred()) // Create the restricted user in the database - ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - err = ur.Put(&restrictedUser) + urCtx := request.WithUser(ctx, adminUser) + ur := NewUserRepository(GetDBXBuilder()) + err = ur.Put(urCtx, &restrictedUser) Expect(err).ToNot(HaveOccurred()) - err = ur.SetUserLibraries(restrictedUser.ID, []int{1}) + err = ur.SetUserLibraries(urCtx, restrictedUser.ID, []int{1}) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up library 2 - lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID}) + lr := NewLibraryRepository(GetDBXBuilder()) + _ = lr.(*libraryRepository).delete(ctx, squirrel.Eq{"id": lib2.ID}) }) DescribeTable("MBID search behavior across different user types", - func(testRepo *model.ArtistRepository, shouldFind bool, testDesc string) { + func(testRepo *model.ArtistRepository, testCtx *context.Context, shouldFind bool, testDesc string) { // Create test artist with MBID artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010") - err := createArtistWithLibrary(*testRepo, &artistWithMBID, 1) + err := createArtistWithLibrary(*testCtx, *testRepo, &artistWithMBID, 1) Expect(err).ToNot(HaveOccurred()) // Test the search - results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10}) + results, err := (*testRepo).Search(*testCtx, "550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) if shouldFind { @@ -693,43 +765,43 @@ var _ = Describe("ArtistRepository", func() { // Clean up if raw, ok := (*testRepo).(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) } }, - Entry("Admin user can find artist by MBID", &repo, true, "Admin should find MBID artist"), - Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, true, "Restricted user should find MBID artist in accessible library"), - Entry("Headless process can find artist by MBID", &headlessRepo, true, "Headless process should find MBID artist"), + Entry("Admin user can find artist by MBID", &repo, &ctx, true, "Admin should find MBID artist"), + Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, &restrictedCtx, true, "Restricted user should find MBID artist in accessible library"), + Entry("Headless process can find artist by MBID", &headlessRepo, &headlessCtx, true, "Headless process should find MBID artist"), ) It("prevents restricted user from finding artist by MBID when not in accessible library", func() { // Create an artist in library 2 (not accessible to restricted user) inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711") - err := repo.Put(&inaccessibleArtist) + err := repo.Put(ctx, &inaccessibleArtist) Expect(err).ToNot(HaveOccurred()) // Add to library 2 (not accessible to restricted user) - err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID) Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) + results, err := restrictedRepo.Search(restrictedCtx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // But admin should find it - results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) + results, err = repo.Search(ctx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) // Clean up if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) } }) Context("Text Search", func() { It("allows admin to find artists by name regardless of library", func() { - results, err := repo.Search("Beatles", model.QueryOptions{Max: 10}) + results, err := repo.Search(ctx, "Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("The Beatles")) @@ -741,21 +813,21 @@ var _ = Describe("ArtistRepository", func() { ID: "inaccessible-text-artist", Name: "Unique Search Name Artist", } - err := repo.Put(&inaccessibleArtist) + err := repo.Put(ctx, &inaccessibleArtist) Expect(err).ToNot(HaveOccurred()) // Add to library 2 (not accessible to restricted user) - err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID) Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10}) + results, err := restrictedRepo.Search(restrictedCtx, "Unique Search Name", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "Text search should respect library filtering") // Clean up if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) } }) }) @@ -764,15 +836,15 @@ var _ = Describe("ArtistRepository", func() { It("does not duplicate artists that belong to multiple libraries", func() { // An artist in two libraries has two library_artist rows; pagination // must still enumerate it exactly once, at a stable offset. - Expect(lr.AddArtist(lib2.ID, artistBeatles.ID)).To(Succeed()) + Expect(lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)).To(Succeed()) - all, err := repo.Search("", model.QueryOptions{Max: 1000}) + all, err := repo.Search(ctx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) seen := map[string]bool{} var paged model.Artists for offset := range len(all) { - page, err := repo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + page, err := repo.Search(ctx, "", model.QueryOptions{Max: 1, Offset: offset}) Expect(err).ToNot(HaveOccurred()) for _, a := range page { Expect(seen[a.ID]).To(BeFalse(), fmt.Sprintf("artist %s returned twice", a.ID)) @@ -784,14 +856,14 @@ var _ = Describe("ArtistRepository", func() { }) It("paginates all artists in natural order without overlaps or gaps", func() { - all, err := repo.Search("", model.QueryOptions{Max: 1000}) + all, err := repo.Search(ctx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(len(all)).To(BeNumerically(">", 1)) var paged model.Artists pageSize := 2 for offset := 0; offset < len(all); offset += pageSize { - page, err := repo.Search("", model.QueryOptions{Max: pageSize, Offset: offset}) + page, err := repo.Search(ctx, "", model.QueryOptions{Max: pageSize, Offset: offset}) Expect(err).ToNot(HaveOccurred()) paged = append(paged, page...) } @@ -804,10 +876,10 @@ var _ = Describe("ArtistRepository", func() { It("respects library filtering for restricted users", func() { // Create an artist only in library 2 (not accessible to restricted user) lib2Artist := model.Artist{ID: "empty-query-lib2-artist", Name: "Empty Query Lib2 Artist"} - Expect(repo.Put(&lib2Artist)).To(Succeed()) - Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + Expect(repo.Put(ctx, &lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed()) - results, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + results, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) for _, a := range results { Expect(a.ID).ToNot(Equal(lib2Artist.ID), "Empty query search should respect library filtering") @@ -815,7 +887,7 @@ var _ = Describe("ArtistRepository", func() { // Clean up if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) } }) @@ -823,15 +895,15 @@ var _ = Describe("ArtistRepository", func() { // ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands // inside the restricted user's visible range — exercising the no-gap guarantee. lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"} - Expect(repo.Put(&lib2Artist)).To(Succeed()) - Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + Expect(repo.Put(ctx, &lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed()) DeferCleanup(func() { if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) } }) - all, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + all, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(len(all)).To(BeNumerically(">", 1)) for _, a := range all { @@ -840,7 +912,7 @@ var _ = Describe("ArtistRepository", func() { var paged model.Artists for offset := range len(all) { - page, err := restrictedRepo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + page, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1, Offset: offset}) Expect(err).ToNot(HaveOccurred()) Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset)) paged = append(paged, page...) @@ -855,11 +927,11 @@ var _ = Describe("ArtistRepository", func() { Context("Headless Processes (No User Context)", func() { It("should see all artists from all libraries when no user is in context", func() { // Add artists to different libraries - err := lr.AddArtist(lib2.ID, artistBeatles.ID) + err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) // Headless processes should see all artists regardless of library - artists, err := headlessRepo.GetAll() + artists, err := headlessRepo.GetAll(headlessCtx) Expect(err).ToNot(HaveOccurred()) // Should see all artists from all libraries @@ -875,11 +947,11 @@ var _ = Describe("ArtistRepository", func() { It("should allow headless processes to apply explicit library_id filters", func() { // Add artists to different libraries - err := lr.AddArtist(lib2.ID, artistBeatles.ID) + err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) // Filter by specific library - artists, err := headlessRepo.GetAll(model.QueryOptions{ + artists, err := headlessRepo.GetAll(headlessCtx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -895,11 +967,11 @@ var _ = Describe("ArtistRepository", func() { It("should get individual artists when no user is in context", func() { // Add artist to a library - err := lr.AddArtist(lib2.ID, artistBeatles.ID) + err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the artist - artist, err := headlessRepo.Get(artistBeatles.ID) + artist, err := headlessRepo.Get(headlessCtx, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(artist.ID).To(Equal(artistBeatles.ID)) }) @@ -908,15 +980,15 @@ var _ = Describe("ArtistRepository", func() { Describe("Admin User Library Access", func() { It("sees all artists regardless of library permissions", func() { - count, err := repo.CountAll() + count, err := repo.CountAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) - artists, err := repo.GetAll() + artists, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(4)) - exists, err := repo.Exists(artistBeatles.ID) + exists, err := repo.Exists(ctx, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) }) @@ -931,25 +1003,25 @@ var _ = Describe("ArtistRepository", func() { missingArtist = model.Artist{ID: "missing_test", Name: "Missing Artist", OrderArtistName: "missing artist"} // Create and mark as missing - err := createArtistWithLibrary(repo, &missingArtist, 1) + err := createArtistWithLibrary(ctx, repo, &missingArtist, 1) Expect(err).ToNot(HaveOccurred()) - _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) + _, err = raw.executeSQL(ctx, squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) }) It("missing artists are never returned by search", func() { // Should see missing artist in GetAll by default for admin users - artists, err := repo.GetAll() + artists, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(5)) // Including the missing artist // Search never returns missing artists (hardcoded behavior) - results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10}) + results, err := repo.Search(ctx, "Missing Artist", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -958,6 +1030,7 @@ var _ = Describe("ArtistRepository", func() { Context("Regular User Operations", func() { var restrictedRepo model.ArtistRepository + var restrictedCtx context.Context var unauthorizedUser model.User BeforeEach(func() { @@ -965,55 +1038,54 @@ var _ = Describe("ArtistRepository", func() { unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false} // Create repository context for the unauthorized user - ctx := GinkgoT().Context() - ctx = request.WithUser(ctx, unauthorizedUser) - restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) + restrictedCtx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(GetDBXBuilder()) }) Describe("Library Access Restrictions", func() { It("CountAll returns 0 for users without library access", func() { - count, err := restrictedRepo.CountAll() + count, err := restrictedRepo.CountAll(restrictedCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(0))) }) It("GetAll returns empty list for users without library access", func() { - artists, err := restrictedRepo.GetAll() + artists, err := restrictedRepo.GetAll(restrictedCtx) Expect(err).ToNot(HaveOccurred()) Expect(artists).To(BeEmpty()) }) It("Exists returns false for existing artists when user has no library access", func() { // These artists exist in the DB but the user has no access to them - exists, err := restrictedRepo.Exists(artistBeatles.ID) + exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) - exists, err = restrictedRepo.Exists(artistKraftwerk.ID) + exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) }) It("Get returns ErrNotFound for existing artists when user has no library access", func() { - _, err := restrictedRepo.Get(artistBeatles.ID) + _, err := restrictedRepo.Get(restrictedCtx, artistBeatles.ID) Expect(err).To(Equal(model.ErrNotFound)) - _, err = restrictedRepo.Get(artistKraftwerk.ID) + _, err = restrictedRepo.Get(restrictedCtx, artistKraftwerk.ID) Expect(err).To(Equal(model.ErrNotFound)) }) It("Search returns empty results for users without library access", func() { - results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10}) + results, err := restrictedRepo.Search(restrictedCtx, "Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) - results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10}) + results, err = restrictedRepo.Search(restrictedCtx, "Kraftwerk", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("GetIndex returns empty index for users without library access", func() { - idx, err := restrictedRepo.GetIndex(false, []int{1}) + idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) @@ -1021,42 +1093,43 @@ var _ = Describe("ArtistRepository", func() { Context("when user gains library access", func() { BeforeEach(func() { - ctx := GinkgoT().Context() // Give the user access to library 1 - ur := NewUserRepository(request.WithUser(ctx, adminUser), GetDBXBuilder()) + urCtx := request.WithUser(ctx, adminUser) + ur := NewUserRepository(GetDBXBuilder()) // First create the user if not exists - err := ur.Put(&unauthorizedUser) + err := ur.Put(urCtx, &unauthorizedUser) Expect(err).ToNot(HaveOccurred()) // Then add library access - err = ur.SetUserLibraries(unauthorizedUser.ID, []int{1}) + err = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{1}) Expect(err).ToNot(HaveOccurred()) // Update the user object with the libraries to simulate middleware behavior - libraries, err := ur.GetUserLibraries(unauthorizedUser.ID) + libraries, err := ur.GetUserLibraries(urCtx, unauthorizedUser.ID) Expect(err).ToNot(HaveOccurred()) unauthorizedUser.Libraries = libraries // Recreate repository context with updated user - ctx = request.WithUser(ctx, unauthorizedUser) - restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) + restrictedCtx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(GetDBXBuilder()) }) AfterEach(func() { // Clean up: remove the user's library access - ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - _ = ur.SetUserLibraries(unauthorizedUser.ID, []int{}) + urCtx := request.WithUser(ctx, adminUser) + ur := NewUserRepository(GetDBXBuilder()) + _ = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{}) }) It("CountAll returns correct count after gaining access", func() { - count, err := restrictedRepo.CountAll() + count, err := restrictedRepo.CountAll(restrictedCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots }) It("GetAll returns artists after gaining access", func() { - artists, err := restrictedRepo.GetAll() + artists, err := restrictedRepo.GetAll(restrictedCtx) Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(4)) @@ -1068,23 +1141,23 @@ var _ = Describe("ArtistRepository", func() { }) It("Exists returns true for accessible artists", func() { - exists, err := restrictedRepo.Exists(artistBeatles.ID) + exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) - exists, err = restrictedRepo.Exists(artistKraftwerk.ID) + exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) }) It("GetIndex returns artists with proper library filtering", func() { // With valid library access, should see artists - idx, err := restrictedRepo.GetIndex(false, []int{1}) + idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) // With non-existent library ID, should see nothing (non-admin user) - idx, err = restrictedRepo.GetIndex(false, []int{999}) + idx, err = restrictedRepo.GetIndex(restrictedCtx, false, []int{999}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) @@ -1092,11 +1165,12 @@ var _ = Describe("ArtistRepository", func() { It("takes the unfiltered fast-path when the user can access every library", func() { // The fixture DB has a single library and the user was granted it, so it has access // to all libraries: search results must match what an admin sees. - adminRepo := NewArtistRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - adminAll, err := adminRepo.Search("", model.QueryOptions{Max: 1000}) + adminCtx := request.WithUser(ctx, adminUser) + adminRepo := NewArtistRepository(GetDBXBuilder()) + adminAll, err := adminRepo.Search(adminCtx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) - userAll, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + userAll, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) ids := func(artists model.Artists) []string { @@ -1115,7 +1189,7 @@ var _ = Describe("ArtistRepository", func() { // visible-library count reaches the DB total. Derive the total from the DB so the // assertion doesn't depend on how many libraries other specs left behind. raw := restrictedRepo.(*artistRepository) // context carries a non-admin user - total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + total, err := NewLibraryRepository(GetDBXBuilder()).CountAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(total).To(BeNumerically(">", 0)) @@ -1123,9 +1197,9 @@ var _ = Describe("ArtistRepository", func() { for i := range allLibs { allLibs[i] = i + 1 } - Expect(raw.userSeesAllLibraries(allLibs)).To(BeTrue()) - Expect(raw.userSeesAllLibraries(allLibs[:total-1])).To(BeFalse()) - Expect(raw.userSeesAllLibraries([]int{})).To(BeFalse()) + Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs)).To(BeTrue()) + Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs[:total-1])).To(BeFalse()) + Expect(raw.userSeesAllLibraries(restrictedCtx, []int{})).To(BeFalse()) }) }) }) @@ -1139,8 +1213,8 @@ var _ = Describe("ArtistRepository", func() { tmpDir = GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + ctx = request.WithUser(ctx, adminUser) + repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) }) // Helper to create an artist image file on disk and return its path @@ -1155,13 +1229,13 @@ var _ = Describe("ArtistRepository", func() { It("removes uploaded image files for purged artists", func() { // Create an orphan artist (not in album_artists) with an uploaded image orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"} - Expect(repo.Put(&orphanArtist)).To(Succeed()) + Expect(repo.Put(ctx, &orphanArtist)).To(Succeed()) imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg") - Expect(repo.purgeEmpty()).To(Succeed()) + Expect(repo.purgeEmpty(ctx)).To(Succeed()) // Artist should be gone from DB - exists, err := repo.Exists("orphan-with-image") + exists, err := repo.Exists(ctx, "orphan-with-image") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) @@ -1173,12 +1247,12 @@ var _ = Describe("ArtistRepository", func() { It("handles missing image files gracefully", func() { // Artist has UploadedImage set but no actual file on disk orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"} - Expect(repo.Put(&orphanArtist)).To(Succeed()) + Expect(repo.Put(ctx, &orphanArtist)).To(Succeed()) - Expect(repo.purgeEmpty()).To(Succeed()) + Expect(repo.purgeEmpty(ctx)).To(Succeed()) // Artist should be gone from DB - exists, err := repo.Exists("orphan-no-file") + exists, err := repo.Exists(ctx, "orphan-no-file") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) }) @@ -1186,24 +1260,24 @@ var _ = Describe("ArtistRepository", func() { It("does not delete images for artists that are kept", func() { // Create an artist with an uploaded image AND an album_artists entry so it won't be purged keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"} - Expect(repo.Put(&keptArtist)).To(Succeed()) + Expect(repo.Put(ctx, &keptArtist)).To(Succeed()) imgPath := createImageFile("kept-artist_Kept_Artist.jpg") // Insert an album_artists record to keep this artist from being purged - _, err := repo.executeSQL(squirrel.Insert("album_artists"). + _, err := repo.executeSQL(ctx, squirrel.Insert("album_artists"). SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""})) Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { - _, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) - _ = repo.delete(squirrel.Eq{"id": "kept-artist"}) + _, _ = repo.executeSQL(ctx, squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) + _ = repo.delete(ctx, squirrel.Eq{"id": "kept-artist"}) }) - Expect(repo.purgeEmpty()).To(Succeed()) + Expect(repo.purgeEmpty(ctx)).To(Succeed()) // Artist should still exist (check directly, bypassing library filter) var ids []string - err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) + err = repo.queryAllSlice(ctx, squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(HaveLen(1)) @@ -1218,37 +1292,37 @@ var _ = Describe("ArtistRepository", func() { missing := func(id string) bool { var vals []bool - Expect(repo.queryAllSlice(squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed()) + Expect(repo.queryAllSlice(ctx, squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed()) Expect(vals).To(HaveLen(1)) return vals[0] } BeforeEach(func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + ctx = request.WithUser(ctx, adminUser) + repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) }) It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() { // A library_artist row with stats '{}' (no content) gets deleted by the cleanup, // which would orphan this non-missing artist. emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"} - Expect(repo.Put(&emptyArtist)).To(Succeed()) - _, err := repo.executeSQL(squirrel.Insert("library_artist"). + Expect(repo.Put(ctx, &emptyArtist)).To(Succeed()) + _, err := repo.executeSQL(ctx, squirrel.Insert("library_artist"). SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"})) Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { - _, _ = repo.executeSQL(squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) - _ = repo.delete(squirrel.Eq{"id": emptyArtist.ID}) + _, _ = repo.executeSQL(ctx, squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) + _ = repo.delete(ctx, squirrel.Eq{"id": emptyArtist.ID}) }) Expect(missing(emptyArtist.ID)).To(BeFalse()) - _, err = repo.RefreshStats(true) + _, err = repo.RefreshStats(ctx, true) Expect(err).ToNot(HaveOccurred()) Expect(missing(emptyArtist.ID)).To(BeTrue()) var orphanIDs []string - Expect(repo.queryAllSlice(squirrel.Select("id").From("artist"). + Expect(repo.queryAllSlice(ctx, squirrel.Select("id").From("artist"). Where("missing = false"). Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed()) Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID)) @@ -1259,14 +1333,14 @@ var _ = Describe("ArtistRepository", func() { // all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still // reconcile it. legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"} - Expect(repo.Put(&legacyOrphan)).To(Succeed()) + Expect(repo.Put(ctx, &legacyOrphan)).To(Succeed()) DeferCleanup(func() { - _ = repo.delete(squirrel.Eq{"id": legacyOrphan.ID}) + _ = repo.delete(ctx, squirrel.Eq{"id": legacyOrphan.ID}) }) Expect(missing(legacyOrphan.ID)).To(BeFalse()) - _, err := repo.RefreshStats(true) + _, err := repo.RefreshStats(ctx, true) Expect(err).ToNot(HaveOccurred()) Expect(missing(legacyOrphan.ID)).To(BeTrue()) @@ -1276,13 +1350,13 @@ var _ = Describe("ArtistRepository", func() { // Helper function to create an artist with proper library association. // This ensures test artists always have library_artist associations to avoid orphaned artists in tests. -func createArtistWithLibrary(repo model.ArtistRepository, artist *model.Artist, libraryID int) error { - err := repo.Put(artist) +func createArtistWithLibrary(ctx context.Context, repo model.ArtistRepository, artist *model.Artist, libraryID int) error { + err := repo.Put(ctx, artist) if err != nil { return err } // Add the artist to the specified library - lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - return lr.AddArtist(libraryID, artist.ID) + lr := NewLibraryRepository(GetDBXBuilder()) + return lr.AddArtist(request.WithUser(ctx, adminUser), libraryID, artist.ID) } diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go index fa9920f27..76646ec74 100644 --- a/persistence/artwork_hydration.go +++ b/persistence/artwork_hydration.go @@ -59,7 +59,7 @@ func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids if len(ids) == 0 { return map[string]model.ItemArtworkInfo{} } - infos, err := NewArtworkRepository(ctx, db).GetInfoForItems(kind, ids) + infos, err := NewArtworkRepository(db).GetInfoForItems(ctx, kind, ids) if err != nil { log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) return map[string]model.ItemArtworkInfo{} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index bb9cda04d..89182d6cc 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -45,7 +45,7 @@ var _ = Describe("Artwork hydration", func() { var aw model.ArtworkRepository putInfo := func(kind, id, hash string) { - Expect(aw.PutItemArtwork(&model.ItemArtwork{ + Expect(aw.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, })).To(Succeed()) } @@ -54,19 +54,19 @@ var _ = Describe("Artwork hydration", func() { clearArtworkTables() DeferCleanup(clearArtworkTables) ctx = request.WithUser(log.NewContext(context.Background()), adminUser) - aw = NewArtworkRepository(ctx, GetDBXBuilder()) + aw = NewArtworkRepository(GetDBXBuilder()) }) Describe("albums", func() { var repo model.AlbumRepository - BeforeEach(func() { repo = NewAlbumRepository(ctx, GetDBXBuilder()) }) + BeforeEach(func() { repo = NewAlbumRepository(GetDBXBuilder()) }) It("hydrates the found / known-absent / unresolved states", func() { putInfo("al", albumSgtPeppers.ID, "althash11111111") putInfo("al", albumAbbeyRoad.ID, "") // albumRadioactivity: no row -> unresolved - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) byID := slice.ToMap(all, func(a model.Album) (string, model.Album) { return a.ID, a }) @@ -80,14 +80,14 @@ var _ = Describe("Artwork hydration", func() { It("hydrates Get", func() { putInfo("al", albumSgtPeppers.ID, "gethash22222222") - got, err := repo.Get(albumSgtPeppers.ID) + got, err := repo.Get(ctx, albumSgtPeppers.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.ImageHash).To(Equal("gethash22222222")) }) It("hydrates Search", func() { putInfo("al", albumSgtPeppers.ID, "srchash33333333") - res, err := repo.Search("Peppers", model.QueryOptions{}) + res, err := repo.Search(ctx, "Peppers", model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeEmpty()) Expect(res[0].ImageHash).To(Equal("srchash33333333")) @@ -97,9 +97,9 @@ var _ = Describe("Artwork hydration", func() { al := albumSgtPeppers al.ImageHash = "shouldnotpersist" al.ImageAbsent = true - Expect(repo.(*albumRepository).Put(&al)).To(Succeed()) + Expect(repo.(*albumRepository).Put(ctx, &al)).To(Succeed()) - got, err := repo.Get(al.ID) + got, err := repo.Get(ctx, al.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.ImageHash).To(BeEmpty()) Expect(got.ImageAbsent).To(BeFalse()) @@ -108,14 +108,14 @@ var _ = Describe("Artwork hydration", func() { Describe("artists", func() { var repo model.ArtistRepository - BeforeEach(func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + BeforeEach(func() { repo = NewArtistRepository(GetDBXBuilder()) }) It("hydrates the found / known-absent / unresolved states", func() { putInfo("ar", artistBeatles.ID, "arhash444444444") putInfo("ar", artistKraftwerk.ID, "") // artistCJK: no row -> unresolved - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) byID := slice.ToMap(all, func(a model.Artist) (string, model.Artist) { return a.ID, a }) @@ -129,14 +129,14 @@ var _ = Describe("Artwork hydration", func() { It("hydrates Get", func() { putInfo("ar", artistBeatles.ID, "arget5555555555") - got, err := repo.Get(artistBeatles.ID) + got, err := repo.Get(ctx, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.ImageHash).To(Equal("arget5555555555")) }) It("hydrates Search", func() { putInfo("ar", artistBeatles.ID, "arsrch666666666") - res, err := repo.Search("Beatles", model.QueryOptions{}) + res, err := repo.Search(ctx, "Beatles", model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeEmpty()) Expect(res[0].ImageHash).To(Equal("arsrch666666666")) @@ -145,13 +145,13 @@ var _ = Describe("Artwork hydration", func() { Describe("playlists", func() { var repo model.PlaylistRepository - BeforeEach(func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + BeforeEach(func() { repo = NewPlaylistRepository(GetDBXBuilder()) }) It("hydrates the found / known-absent states", func() { putInfo("pl", plsBest.ID, "plhash777777777") putInfo("pl", plsCool.ID, "") - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) byID := slice.ToMap(all, func(p model.Playlist) (string, model.Playlist) { return p.ID, p }) @@ -163,16 +163,16 @@ var _ = Describe("Artwork hydration", func() { It("hydrates Get", func() { putInfo("pl", plsBest.ID, "plget8888888888") - got, err := repo.Get(plsBest.ID) + got, err := repo.Get(ctx, plsBest.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.ImageHash).To(Equal("plget8888888888")) }) It("hydrates the tracks reached through a playlist", func() { - Expect(aw.PutImage(&model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed()) + Expect(aw.PutImage(ctx, &model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed()) putInfo("al", songDayInALife.AlbumID, "pltrackhash1234") - pls, err := repo.GetWithTracks(plsBest.ID, true, false) + pls, err := repo.GetWithTracks(ctx, plsBest.ID, true, false) Expect(err).ToNot(HaveOccurred()) tracks := pls.Tracks Expect(tracks).ToNot(BeEmpty()) @@ -181,7 +181,7 @@ var _ = Describe("Artwork hydration", func() { Expect(byID[songDayInALife.ID].AlbumImage.ImageHash).To(Equal("pltrackhash1234")) Expect(byID[songDayInALife.ID].BlurHash).To(Equal("LPLBLURhash")) - cursor, err := repo.Tracks(plsBest.ID, true).GetCursor() + cursor, err := repo.Tracks(ctx, plsBest.ID, true).GetCursor(ctx) Expect(err).ToNot(HaveOccurred()) var streamed *model.PlaylistTrack for t, err := range cursor { @@ -198,13 +198,13 @@ var _ = Describe("Artwork hydration", func() { Describe("radios", func() { var repo model.RadioRepository - BeforeEach(func() { repo = NewRadioRepository(ctx, GetDBXBuilder()) }) + BeforeEach(func() { repo = NewRadioRepository(GetDBXBuilder()) }) It("hydrates the found / known-absent states", func() { putInfo("ra", radioWithHomePage.ID, "rahash999999999") putInfo("ra", radioWithoutHomePage.ID, "") - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) byID := slice.ToMap(all, func(rd model.Radio) (string, model.Radio) { return rd.ID, rd }) @@ -216,7 +216,7 @@ var _ = Describe("Artwork hydration", func() { It("hydrates Get", func() { putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") - got, err := repo.Get(radioWithHomePage.ID) + got, err := repo.Get(ctx, radioWithHomePage.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) }) @@ -232,13 +232,13 @@ var _ = Describe("Artwork hydration", func() { } getByID := func() map[string]model.MediaFile { - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf }) } BeforeEach(func() { - repo = NewMediaFileRepository(ctx, GetDBXBuilder()) + repo = NewMediaFileRepository(GetDBXBuilder()) DeferCleanup(configtest.SetupConfig()) conf.Server.EnableMediaFileCoverArt = true }) @@ -293,8 +293,8 @@ var _ = Describe("Artwork hydration", func() { setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch DeferCleanup(func() { setCover("1001", false) }) - Expect(aw.PutImage(&model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) - Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) + Expect(aw.PutImage(ctx, &model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) + Expect(aw.PutImage(ctx, &model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) putInfo("mf", "1001", "mfh1001blurxxxxx") putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch @@ -374,7 +374,7 @@ var _ = Describe("Artwork hydration", func() { It("hydrates Search", func() { putInfo("al", "101", "alsrchhhhhhhhhhh") - res, err := repo.Search("A Day In A Life", model.QueryOptions{}) + res, err := repo.Search(ctx, "A Day In A Life", model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeEmpty()) Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) @@ -397,9 +397,9 @@ var _ = Describe("Artwork hydration", func() { } BeforeEach(func() { - albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()) - artistRepo = NewArtistRepository(ctx, GetDBXBuilder()) - playlistRepo = NewPlaylistRepository(ctx, GetDBXBuilder()) + albumRepo = NewAlbumRepository(GetDBXBuilder()) + artistRepo = NewArtistRepository(GetDBXBuilder()) + playlistRepo = NewPlaylistRepository(GetDBXBuilder()) // Other specs leave rows behind, so scope every cursor spec to the fixtures. onlyAlbums = squirrel.Eq{"album.id": []string{albumSgtPeppers.ID, albumAbbeyRoad.ID, albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}} @@ -408,8 +408,8 @@ var _ = Describe("Artwork hydration", func() { // Both fixture playlists share an owner, leaving the owner_name sort a single value to // order by; this one is also private, which the non-admin visibility spec needs. foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName} - Expect(playlistRepo.Put(&foreign)).To(Succeed()) - DeferCleanup(func() { Expect(playlistRepo.Delete(foreign.ID)).To(Succeed()) }) + Expect(playlistRepo.Put(ctx, &foreign)).To(Succeed()) + DeferCleanup(func() { Expect(playlistRepo.Delete(ctx, foreign.ID)).To(Succeed()) }) onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}} // The suite annotates a single album and artist, leaving the annotation-backed sorts @@ -417,7 +417,7 @@ var _ = Describe("Artwork hydration", func() { seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID) seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID) - Expect(aw.PutImage(&model.Artwork{ + Expect(aw.PutImage(ctx, &model.Artwork{ Hash: "curhash11111111", Mime: "image/jpeg", BlurHash: "LEHV6nWB2yk8", })).To(Succeed()) putInfo("al", albumSgtPeppers.ID, "curhash11111111") @@ -428,10 +428,10 @@ var _ = Describe("Artwork hydration", func() { It("hydrates every streamed album, like GetAll", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums} - want, err := albumRepo.GetAll(opts) + want, err := albumRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - got := collectCursor(albumRepo.GetCursor(opts)) + got := collectCursor(albumRepo.GetCursor(ctx, opts)) Expect(got).To(ConsistOf(want)) byID := map[string]model.Album{} @@ -447,10 +447,10 @@ var _ = Describe("Artwork hydration", func() { It("hydrates every streamed artist, like GetAll", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyArtists} - want, err := artistRepo.GetAll(opts) + want, err := artistRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - got := collectCursor(artistRepo.GetCursor(opts)) + got := collectCursor(artistRepo.GetCursor(ctx, opts)) Expect(got).To(ConsistOf(want)) Expect(slice.Map(got, func(a model.Artist) string { return a.ImageHash })). @@ -459,10 +459,10 @@ var _ = Describe("Artwork hydration", func() { It("hydrates every streamed playlist, like GetAll", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} - want, err := playlistRepo.GetAll(opts) + want, err := playlistRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - got := collectCursor(playlistRepo.GetCursor(opts)) + got := collectCursor(playlistRepo.GetCursor(ctx, opts)) Expect(got).To(ConsistOf(want)) Expect(slice.Map(got, func(p model.Playlist) string { return p.ImageHash })). @@ -471,11 +471,11 @@ var _ = Describe("Artwork hydration", func() { It("honors Max and Offset exactly once", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums, Max: 2, Offset: 1} - want, err := albumRepo.GetAll(opts) + want, err := albumRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).To(HaveLen(2)) - got := collectCursor(albumRepo.GetCursor(opts)) + got := collectCursor(albumRepo.GetCursor(ctx, opts)) Expect(slice.Map(got, func(a model.Album) string { return a.ID })). To(Equal(slice.Map(want, func(a model.Album) string { return a.ID }))) @@ -485,11 +485,11 @@ var _ = Describe("Artwork hydration", func() { DescribeTable("orders albums like GetAll", func(opts model.QueryOptions, key func(model.Album) string) { opts = scoped(opts, onlyAlbums) - want, err := albumRepo.GetAll(opts) + want, err := albumRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - got := collectCursor(albumRepo.GetCursor(opts)) + got := collectCursor(albumRepo.GetCursor(ctx, opts)) Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) Expect(slice.Map(got, func(a model.Album) string { return a.ID })). @@ -517,11 +517,11 @@ var _ = Describe("Artwork hydration", func() { DescribeTable("orders artists like GetAll", func(opts model.QueryOptions, key func(model.Artist) string) { opts = scoped(opts, onlyArtists) - want, err := artistRepo.GetAll(opts) + want, err := artistRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - got := collectCursor(artistRepo.GetCursor(opts)) + got := collectCursor(artistRepo.GetCursor(ctx, opts)) Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) Expect(slice.Map(got, func(a model.Artist) string { return a.ID })). @@ -543,11 +543,11 @@ var _ = Describe("Artwork hydration", func() { DescribeTable("orders playlists like GetAll", func(opts model.QueryOptions, key func(model.Playlist) string) { opts = scoped(opts, onlyPlaylists) - want, err := playlistRepo.GetAll(opts) + want, err := playlistRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - got := collectCursor(playlistRepo.GetCursor(opts)) + got := collectCursor(playlistRepo.GetCursor(ctx, opts)) Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) Expect(slice.Map(got, func(p model.Playlist) string { return p.ID })). @@ -563,10 +563,10 @@ var _ = Describe("Artwork hydration", func() { It("streams every album exactly once when sorted randomly", func() { opts := model.QueryOptions{Sort: "random", Filters: onlyAlbums} - want, err := albumRepo.GetAll(opts) + want, err := albumRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - got := collectCursor(albumRepo.GetCursor(opts)) + got := collectCursor(albumRepo.GetCursor(ctx, opts)) Expect(slice.Map(got, func(a model.Album) string { return a.ID })). To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) @@ -574,16 +574,16 @@ var _ = Describe("Artwork hydration", func() { It("keeps a non-admin from streaming another user's private playlists", func() { otherCtx := request.WithUser(log.NewContext(context.Background()), regularUser) - repo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + repo := NewPlaylistRepository(GetDBXBuilder()) opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} // Both phases must filter on their own: the id pre-pass and the chunk fetch. - Expect(repo.(*playlistRepository).getAllIDs(opts)).To(ConsistOf(plsBest.ID)) - all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists}) + Expect(repo.(*playlistRepository).getAllIDs(otherCtx, opts)).To(ConsistOf(plsBest.ID)) + all, err := repo.GetAll(otherCtx, model.QueryOptions{Filters: onlyPlaylists}) Expect(err).ToNot(HaveOccurred()) Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) - got := collectCursor(repo.GetCursor(opts)) + got := collectCursor(repo.GetCursor(otherCtx, opts)) Expect(slice.Map(got, func(p model.Playlist) string { return p.Name })). To(ConsistOf(plsBest.Name)) @@ -595,7 +595,7 @@ var _ = Describe("Artwork hydration", func() { var onlySongs squirrel.Eq BeforeEach(func() { - mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + mfRepo = NewMediaFileRepository(GetDBXBuilder()) putInfo("al", albumSgtPeppers.ID, "curhash11111111") // Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which // would make the positional comparisons against GetAll pass by tie-order coincidence. @@ -605,11 +605,11 @@ var _ = Describe("Artwork hydration", func() { It("hydrates artwork onto every streamed track, unlike GetCursor", func() { opts := model.QueryOptions{Sort: "title", Filters: onlySongs} - want, err := mfRepo.GetAll(opts) + want, err := mfRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - cursor, err := mfRepo.GetCursorWithArtwork(opts) + cursor, err := mfRepo.GetCursorWithArtwork(ctx, opts) Expect(err).ToNot(HaveOccurred()) var got model.MediaFiles cursor(func(mf model.MediaFile, err error) bool { @@ -631,7 +631,7 @@ var _ = Describe("Artwork hydration", func() { }) It("leaves the scanner's GetCursor unhydrated", func() { - cursor, err := mfRepo.GetCursor(model.QueryOptions{Sort: "title"}) + cursor, err := mfRepo.GetCursor(ctx, model.QueryOptions{Sort: "title"}) Expect(err).ToNot(HaveOccurred()) var seen int cursor(func(mf model.MediaFile, err error) bool { @@ -645,11 +645,11 @@ var _ = Describe("Artwork hydration", func() { It("streams the same ids in the same order as GetAll", func() { opts := model.QueryOptions{Sort: "title", Filters: onlySongs} - want, err := mfRepo.GetAll(opts) + want, err := mfRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - got := collectCursor(mfRepo.GetCursorWithArtwork(opts)) + got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, opts)) Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) @@ -657,11 +657,11 @@ var _ = Describe("Artwork hydration", func() { It("honors Max and Offset exactly once", func() { opts := model.QueryOptions{Sort: "title", Filters: onlySongs, Max: 2, Offset: 1} - want, err := mfRepo.GetAll(opts) + want, err := mfRepo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).To(HaveLen(2)) - got := collectCursor(mfRepo.GetCursorWithArtwork(opts)) + got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, opts)) Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 88b6f6f80..321fe8a95 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -25,17 +25,16 @@ type artworkQueueRepository struct { sqlRepository } -func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.ArtworkQueueRepository { +func NewArtworkQueueRepository(db dbx.Builder) model.ArtworkQueueRepository { r := &artworkQueueRepository{} - r.ctx = ctx r.db = db r.tableName = "artwork_queue" return r } -func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { +func (r *artworkQueueRepository) Get(ctx context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { var res model.ArtworkQueueItem - err := r.queryOne(Select("*").From(r.tableName). + err := r.queryOne(ctx, Select("*").From(r.tableName). Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res) if err != nil { return nil, err @@ -45,30 +44,30 @@ func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*mo // Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old // row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0). -func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { - return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET +func (r *artworkQueueRepository) Enqueue(ctx context.Context, items ...model.ArtworkQueueItem) error { + return r.enqueue(ctx, `ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items) } -func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { - return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET +func (r *artworkQueueRepository) EnqueuePreservingBackoff(ctx context.Context, items ...model.ArtworkQueueItem) error { + return r.enqueue(ctx, `ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { +func (r *artworkQueueRepository) EnqueueAllMissing(ctx context.Context, kind model.Kind, priority int) (int64, error) { entityTable, ok := artworkOwnerTables[kind] if !ok { return 0, fmt.Errorf("artwork queue: no entity table for kind %q", kind.Prefix()) } now := time.Now() - return r.insertIfNotQueued("", `SELECT ?, id, ?, ?, 0, ?, ? + return r.insertIfNotQueued(ctx, "", `SELECT ?, id, ?, ?, 0, ?, ? FROM `+entityTable+` WHERE id NOT IN (SELECT item_id FROM `+itemArtworkTable+` WHERE item_kind = ?)`, kind.Prefix(), model.ImageTypePrimary, priority, now, now, kind.Prefix()) } -func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueItem) error { +func (r *artworkQueueRepository) EnqueueIfMissing(ctx context.Context, items ...model.ArtworkQueueItem) error { now := time.Now() for chunk := range slices.Chunk(items, enqueueChunkSize) { rows := make([]string, 0, len(chunk)) @@ -78,7 +77,7 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte args = append(args, it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority) } args = append(args, now, now) - _, err := r.insertIfNotQueued( + _, err := r.insertIfNotQueued(ctx, `WITH new_items(item_kind, item_id, image_type, priority) AS (VALUES `+strings.Join(rows, ",")+`) `, `SELECT n.item_kind, n.item_id, n.image_type, n.priority, 0, ?, ? FROM new_items n @@ -97,8 +96,8 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING` // insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. -func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) { - return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+ +func (r *artworkQueueRepository) insertIfNotQueued(ctx context.Context, with, sql string, args ...any) (int64, error) { + return r.executeSQL(ctx, Expr(with+`INSERT INTO `+r.tableName+ ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) } @@ -121,16 +120,16 @@ func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { return append(f, match) } -func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) { +func (r *artworkQueueRepository) CountBySource(ctx context.Context, kind model.Kind, sources []string) (int64, error) { var res struct{ Count int64 } - err := r.queryOne(Select("count(*) as count").From(itemArtworkTable). + err := r.queryOne(ctx, Select("count(*) as count").From(itemArtworkTable). Where(artworkSourceFilter(kind, sources)), &res) return res.Count, err } -func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) { +func (r *artworkQueueRepository) SourcesInUse(ctx context.Context, kind model.Kind) ([]string, error) { var res []struct{ Source string } - err := r.queryAll(Select("distinct source").From(itemArtworkTable). + err := r.queryAll(ctx, Select("distinct source").From(itemArtworkTable). Where(Eq{"item_kind": kind.Prefix()}), &res) if err != nil { return nil, err @@ -140,15 +139,15 @@ func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) // EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the // library's artwork until every item is resolved again. -func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { +func (r *artworkQueueRepository) EnqueueBySource(ctx context.Context, kind model.Kind, sources []string, priority int) (int64, error) { now := time.Now() sel := Select("item_kind", "item_id", "image_type"). Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)). From(itemArtworkTable).Where(artworkSourceFilter(kind, sources)) - return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued)) + return r.executeSQL(ctx, Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued)) } -func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { +func (r *artworkQueueRepository) enqueue(ctx context.Context, conflict string, items []model.ArtworkQueueItem) error { now := time.Now() for chunk := range slices.Chunk(items, enqueueChunkSize) { ins := Insert(r.tableName).Columns(enqueueColumns...) @@ -156,14 +155,14 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ ins = ins.Values(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority, 0, now, now) } ins = ins.Suffix(conflict) - if _, err := r.executeSQL(ins); err != nil { + if _, err := r.executeSQL(ctx, ins); err != nil { return err } } return nil } -func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { +func (r *artworkQueueRepository) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { sel := Select(enqueueColumns...).From(r.tableName). Where(LtOrEq{"retry_at": time.Now()}). OrderBy("priority DESC", "enqueued_at ASC"). @@ -172,26 +171,26 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A sel = sel.Where(Eq{"item_kind": kinds}) } var res []model.ArtworkQueueItem - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) return res, err } -func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { +func (r *artworkQueueRepository) MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { upd := Update(r.tableName). Set("attempts", Expr("attempts + 1")). Set("retry_at", retryAt). Set("trace", trace). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) - _, err := r.executeSQL(upd) + _, err := r.executeSQL(ctx, upd) return err } -func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { - return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) +func (r *artworkQueueRepository) DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error { + return r.delete(ctx, Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) } -func (r *artworkQueueRepository) PurgeDangling() (int64, error) { - return purgeDangling(r.sqlRepository) +func (r *artworkQueueRepository) PurgeDangling(ctx context.Context) (int64, error) { + return purgeDangling(ctx, r.sqlRepository) } // artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps @@ -208,28 +207,28 @@ func artworkQueueFilter(kinds []model.Kind, priorities []int) And { } // CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses. -func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { +func (r *artworkQueueRepository) CountQueued(ctx context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName). GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc") if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { sel = sel.Where(f) } var res []model.ArtworkQueueStat - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) return res, err } -func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { +func (r *artworkQueueRepository) PurgeQueued(ctx context.Context, kinds []model.Kind, priorities []int) (int64, error) { del := Delete(r.tableName) if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { del = del.Where(f) } - return r.executeSQL(del) + return r.executeSQL(ctx, del) } -func (r *artworkQueueRepository) Count() (int64, error) { +func (r *artworkQueueRepository) Count(ctx context.Context) (int64, error) { var res struct{ Count int64 } - err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) + err := r.queryOne(ctx, Select("count(*) as count").From(r.tableName), &res) return res.Count, err } diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 0481d4193..6ce4be8e2 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -14,6 +14,7 @@ import ( var _ = Describe("ArtworkQueueRepository", func() { var repo model.ArtworkQueueRepository + var ctx context.Context item := func(kind, id string, prio int) model.ArtworkQueueItem { return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, @@ -25,7 +26,7 @@ var _ = Describe("ArtworkQueueRepository", func() { backOff := func(kind, id string, retryAt time.Time) { GinkgoHelper() r := repo.(*artworkQueueRepository) - _, err := r.executeSQL(squirrel.Update(r.tableName). + _, err := r.executeSQL(ctx, squirrel.Update(r.tableName). Set("attempts", squirrel.Expr("attempts + 1")). Set("retry_at", retryAt). Where(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})) @@ -35,30 +36,31 @@ var _ = Describe("ArtworkQueueRepository", func() { remove := func(kind, id string) { GinkgoHelper() r := repo.(*artworkQueueRepository) - Expect(r.delete(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})).To(Succeed()) + Expect(r.delete(ctx, squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})).To(Succeed()) } BeforeEach(func() { + ctx = GinkgoT().Context() clearArtworkTables() DeferCleanup(clearArtworkTables) - repo = NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + repo = NewArtworkQueueRepository(GetDBXBuilder()) }) It("enqueues and dequeues by priority then FIFO", func() { - Expect(repo.Enqueue(item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) - got, err := repo.DequeueBatch(10) + got, err := repo.DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(got).To(HaveLen(2)) Expect(got[0].ItemID).To(Equal("high")) }) It("Get returns a queued row, including one still backing off", func() { - Expect(repo.Enqueue(item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed()) backOff("ar", "g1", time.Now().Add(time.Hour)) - got, err := repo.Get(model.KindArtistArtwork, "g1", model.ImageTypePrimary) + got, err := repo.Get(ctx, model.KindArtistArtwork, "g1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(got.Priority).To(Equal(model.ArtworkPriorityScan)) Expect(got.Attempts).To(Equal(1)) @@ -66,114 +68,114 @@ var _ = Describe("ArtworkQueueRepository", func() { }) It("Get reports ErrNotFound when the item is not queued", func() { - _, err := repo.Get(model.KindArtistArtwork, "nope", model.ImageTypePrimary) + _, err := repo.Get(ctx, model.KindArtistArtwork, "nope", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) It("keeps the higher priority on duplicate enqueue", func() { - Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) - got, _ := repo.DequeueBatch(10) + Expect(repo.Enqueue(ctx, item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + got, _ := repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) }) It("EnqueuePreservingBackoff raises priority without resetting a backing-off row's retry_at", func() { - Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) backOff("al", "b1", time.Now().Add(time.Hour)) - Expect(repo.DequeueBatch(10)).To(BeEmpty()) + Expect(repo.DequeueBatch(ctx, 10)).To(BeEmpty()) - Expect(repo.EnqueuePreservingBackoff(item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.DequeueBatch(10)).To(BeEmpty(), "bump must not reset retry_at") + Expect(repo.EnqueuePreservingBackoff(ctx, item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.DequeueBatch(ctx, 10)).To(BeEmpty(), "bump must not reset retry_at") // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. - Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - got, _ := repo.DequeueBatch(10) + Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + got, _ := repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") }) It("EnqueuePreservingBackoff inserts a brand-new row eligible immediately", func() { - Expect(repo.EnqueuePreservingBackoff(item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) - got, _ := repo.DequeueBatch(10) + Expect(repo.EnqueuePreservingBackoff(ctx, item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) + got, _ := repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) Expect(got[0].ItemID).To(Equal("n1")) }) It("hides failed items until retry_at", func() { - Expect(repo.Enqueue(item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) backOff("al", "f1", time.Now().Add(time.Hour)) - got, err := repo.DequeueBatch(10) + got, err := repo.DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(got).To(BeEmpty()) backOff("al", "f1", time.Now().Add(-time.Minute)) - got, _ = repo.DequeueBatch(10) + got, _ = repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) Expect(got[0].Attempts).To(Equal(2)) }) It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { - Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) // Anchor retry_at in the past so it can never collide with the re-enqueue's now. backOff("al", "m1", time.Now().Add(-time.Hour)) - got, err := repo.DequeueBatch(10) + got, err := repo.DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(got).To(HaveLen(1)) original := got[0].RetryAt // A concurrent scan re-enqueues, resetting retry_at to now. - Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed()) - got, _ = repo.DequeueBatch(10) + Expect(repo.MarkFailedIfUnchanged(ctx, "al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed()) + got, _ = repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") current := got[0].RetryAt - Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) - got, _ = repo.DequeueBatch(10) + Expect(repo.MarkFailedIfUnchanged(ctx, "al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) + got, _ = repo.DequeueBatch(ctx, 10) Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") - all, _ := repo.Count() + all, _ := repo.Count(ctx) Expect(all).To(Equal(int64(1))) }) It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() { // Fail an attempt so the queue row carries a failure trace. - Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) backOff("al", "t1", time.Now().Add(-time.Hour)) - got, _ := repo.DequeueBatch(10) + got, _ := repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) + Expect(repo.MarkFailedIfUnchanged(ctx, "al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) // A continuation of the same lifecycle must retain the trace. - Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) - kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(repo.EnqueuePreservingBackoff(ctx, item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) + kept, err := repo.Get(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`)) // A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it. - Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) - fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(repo.Enqueue(ctx, item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + fresh, err := repo.Get(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(fresh.Attempts).To(BeZero()) Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace") }) It("Enqueue restarts the retry budget an existing row had spent", func() { - Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) backOff("al", "e1", time.Now().Add(-time.Hour)) stale := time.Now().Add(-48 * time.Hour) _, err := GetDBXBuilder().NewQuery("UPDATE artwork_queue SET enqueued_at = {:t} WHERE item_id = 'e1'"). Bind(dbx.Params{"t": stale}).Execute() Expect(err).ToNot(HaveOccurred()) - Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) - got, err := repo.DequeueBatch(10) + got, err := repo.DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(got).To(HaveLen(1)) Expect(got[0].EnqueuedAt).To(BeTemporally("~", time.Now(), time.Minute), @@ -182,40 +184,40 @@ var _ = Describe("ArtworkQueueRepository", func() { }) It("deletes on completion and counts", func() { - Expect(repo.Enqueue(item("al", "c1", 0))).To(Succeed()) - n, _ := repo.Count() + Expect(repo.Enqueue(ctx, item("al", "c1", 0))).To(Succeed()) + n, _ := repo.Count(ctx) Expect(n).To(Equal(int64(1))) remove("al", "c1") - n, _ = repo.Count() + n, _ = repo.Count(ctx) Expect(n).To(BeZero()) }) It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { - Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) // Anchor retry_at in the past so it can never collide with the re-enqueue's now. backOff("al", "d1", time.Now().Add(-time.Hour)) - got, err := repo.DequeueBatch(10) + got, err := repo.DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(got).To(HaveLen(1)) original := got[0].RetryAt // A concurrent scan re-enqueues, resetting retry_at to now. - Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. - Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, original)).To(Succeed()) - n, _ := repo.Count() + Expect(repo.DeleteIfUnchanged(ctx, "al", "d1", model.ImageTypePrimary, original)).To(Succeed()) + n, _ := repo.Count(ctx) Expect(n).To(Equal(int64(1))) - got, _ = repo.DequeueBatch(10) + got, _ = repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) - Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) - n, _ = repo.Count() + Expect(repo.DeleteIfUnchanged(ctx, "al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) + n, _ = repo.Count(ctx) Expect(n).To(BeZero()) }) It("purges queue rows whose entity no longer exists, per kind", func() { - Expect(repo.Enqueue( + Expect(repo.Enqueue(ctx, item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), item("al", "no-such-album", model.ArtworkPriorityScan), item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), @@ -228,25 +230,25 @@ var _ = Describe("ArtworkQueueRepository", func() { item("mf", "no-such-mediafile", model.ArtworkPriorityScan), )).To(Succeed()) - purged, err := repo.PurgeDangling() + purged, err := repo.PurgeDangling(ctx) Expect(err).ToNot(HaveOccurred()) Expect(purged).To(Equal(int64(5))) - got, _ := repo.DequeueBatch(100) + got, _ := repo.DequeueBatch(ctx, 100) ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) }) It("enqueues entities that have no item_artwork row at all", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + awRepo := NewArtworkRepository(GetDBXBuilder()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - n, err := repo.EnqueueAllMissing(model.KindAlbumArtwork, model.ArtworkPriorityRecheck) + n, err := repo.EnqueueAllMissing(ctx, model.KindAlbumArtwork, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) Expect(n).To(BeNumerically(">=", 1)) - got, err := repo.DequeueBatch(1000) + got, err := repo.DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) ids := make([]string, 0, len(got)) for _, it := range got { @@ -260,122 +262,122 @@ var _ = Describe("ArtworkQueueRepository", func() { }) It("EnqueueIfMissing skips items that already have an item_artwork row", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "resolved", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "absent", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + awRepo := NewArtworkRepository(GetDBXBuilder()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "resolved", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "absent", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - Expect(repo.EnqueueIfMissing( + Expect(repo.EnqueueIfMissing(ctx, item("al", "resolved", model.ArtworkPriorityScan), item("al", "absent", model.ArtworkPriorityScan), item("al", "brandnew", model.ArtworkPriorityScan), )).To(Succeed()) - got, err := repo.DequeueBatch(100) + got, err := repo.DequeueBatch(ctx, 100) Expect(err).ToNot(HaveOccurred()) ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) Expect(ids).To(ConsistOf("brandnew"), "only an item with no state row may be enqueued") }) It("EnqueueIfMissing leaves an already-queued row untouched", func() { - Expect(repo.Enqueue(item("al", "queued", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "queued", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.EnqueueIfMissing(item("al", "queued", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.EnqueueIfMissing(ctx, item("al", "queued", model.ArtworkPriorityScan))).To(Succeed()) - got, _ := repo.DequeueBatch(100) + got, _ := repo.DequeueBatch(ctx, 100) Expect(got).To(HaveLen(1)) Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") }) Describe("EnqueueBySource", func() { BeforeEach(func() { - artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + artRepo := NewArtworkRepository(GetDBXBuilder()) for _, ia := range []model.ItemArtwork{ {ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"}, {ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"}, {ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""}, {ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"}, } { - Expect(artRepo.PutItemArtwork(&ia)).To(Succeed()) + Expect(artRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) } }) It("enqueues only the matching source within the kind", func() { - n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched") - got, err := repo.DequeueBatch(10) + got, err := repo.DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1")) }) It("treats the empty source as absent", func() { - n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck) + n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(int64(1))) - got, _ := repo.DequeueBatch(10) + got, _ := repo.DequeueBatch(ctx, 10) Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3")) }) It("enqueues every source when none is given", func() { - n, err := repo.EnqueueBySource(model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck) + n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(int64(3))) }) It("leaves the current artwork state in place", func() { - _, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + _, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) - artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + artRepo := NewArtworkRepository(GetDBXBuilder()) + ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced") Expect(ia.Source).To(Equal("external:deezer")) }) It("does not disturb an already-queued row", func() { - Expect(repo.Enqueue(item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed()) - n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) Expect(n).To(BeZero()) - got, _ := repo.DequeueBatch(10) + got, _ := repo.DequeueBatch(ctx, 10) Expect(got).To(HaveLen(1)) Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) }) It("counts without enqueueing", func() { - n, err := repo.CountBySource(model.KindArtistArtwork, []string{"external:deezer"}) + n, err := repo.CountBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(int64(1))) - queued, err := repo.Count() + queued, err := repo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(BeZero(), "CountBySource must not enqueue") }) It("counts the absent source and every source", func() { - Expect(repo.CountBySource(model.KindArtistArtwork, []string{""})).To(Equal(int64(1))) - Expect(repo.CountBySource(model.KindArtistArtwork, nil)).To(Equal(int64(3))) + Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{""})).To(Equal(int64(1))) + Expect(repo.CountBySource(ctx, model.KindArtistArtwork, nil)).To(Equal(int64(3))) }) It("lists the distinct sources in use by a kind", func() { - Expect(repo.SourcesInUse(model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm")) - Expect(repo.SourcesInUse(model.KindAlbumArtwork)).To(ConsistOf("external:deezer")) - Expect(repo.SourcesInUse(model.KindRadioArtwork)).To(BeEmpty()) + Expect(repo.SourcesInUse(ctx, model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm")) + Expect(repo.SourcesInUse(ctx, model.KindAlbumArtwork)).To(ConsistOf("external:deezer")) + Expect(repo.SourcesInUse(ctx, model.KindRadioArtwork)).To(BeEmpty()) }) }) It("does not disturb an already-queued entity when enqueueing missing rows", func() { - Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) - _, err := repo.EnqueueAllMissing(model.KindAlbumArtwork, model.ArtworkPriorityRecheck) + _, err := repo.EnqueueAllMissing(ctx, model.KindAlbumArtwork, model.ArtworkPriorityRecheck) Expect(err).ToNot(HaveOccurred()) - got, _ := repo.DequeueBatch(1000) + got, _ := repo.DequeueBatch(ctx, 1000) var count int for _, it := range got { if it.ItemID == albumRadioactivity.ID { @@ -388,12 +390,12 @@ var _ = Describe("ArtworkQueueRepository", func() { Describe("status counters", func() { It("groups queue rows by kind and priority", func() { - Expect(repo.Enqueue(item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - Expect(repo.CountQueued(nil, nil)).To(ConsistOf( + Expect(repo.CountQueued(ctx, nil, nil)).To(ConsistOf( model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, @@ -401,46 +403,46 @@ var _ = Describe("ArtworkQueueRepository", func() { }) It("selects only the absent states that gave up, not those a source answered", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + awRepo := NewArtworkRepository(GetDBXBuilder()) for _, ia := range []model.ItemArtwork{ {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, } { - Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) } - Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), + Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), "an item still serving art is not absent, however its last attempt went") // A later success rewrites the row, clearing the record. - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", + Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) - Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) }) It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + awRepo := NewArtworkRepository(GetDBXBuilder()) for _, ia := range []model.ItemArtwork{ {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, } { - Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + Expect(awRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) } failedAndAbsent := []string{model.ArtworkSourceFailed, ""} - Expect(repo.CountBySource(model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), + Expect(repo.CountBySource(ctx, model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), "failed is a subset of absent, so asking for both is asking for absent") - Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). + Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") }) It("reports a kind with nothing failed as zero", func() { - Expect(repo.CountBySource(model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + Expect(repo.CountBySource(ctx, model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) }) It("reports an empty queue as no rows", func() { - Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) + Expect(repo.CountQueued(ctx, nil, nil)).To(BeEmpty()) }) }) @@ -448,13 +450,13 @@ var _ = Describe("ArtworkQueueRepository", func() { Describe("PurgeQueued", func() { queuedIDs := func() []string { GinkgoHelper() - got, err := repo.DequeueBatch(100) + got, err := repo.DequeueBatch(ctx, 100) Expect(err).ToNot(HaveOccurred()) return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) } BeforeEach(func() { - Expect(repo.Enqueue( + Expect(repo.Enqueue(ctx, item("ar", "ar-backfill", model.ArtworkPriorityBackfill), item("ar", "ar-bump", model.ArtworkPriorityBump), item("al", "al-backfill", model.ArtworkPriorityBackfill), @@ -466,7 +468,7 @@ var _ = Describe("ArtworkQueueRepository", func() { // every selection must count exactly what it deletes. DescribeTable("selects the same rows to count and to delete", func(kinds []model.Kind, priorities []int, deleted int, remaining []string) { - counted, err := repo.CountQueued(kinds, priorities) + counted, err := repo.CountQueued(ctx, kinds, priorities) Expect(err).ToNot(HaveOccurred()) var total int64 for _, s := range counted { @@ -474,7 +476,7 @@ var _ = Describe("ArtworkQueueRepository", func() { } Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete") - Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted)) + Expect(repo.PurgeQueued(ctx, kinds, priorities)).To(BeNumerically("==", deleted)) Expect(queuedIDs()).To(ConsistOf(remaining)) }, Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil, @@ -496,8 +498,8 @@ var _ = Describe("ArtworkQueueRepository", func() { It("deletes a row that is still backing off", func() { backOff("ar", "ar-bump", time.Now().Add(time.Hour)) - Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) - Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). + Expect(repo.PurgeQueued(ctx, []model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) + Expect(repo.Get(ctx, model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). Error().To(MatchError(model.ErrNotFound)) }) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 89eb1d415..bd3a3d87c 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -21,27 +21,25 @@ type artworkRepository struct { items sqlRepository } -func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository { +func NewArtworkRepository(db dbx.Builder) model.ArtworkRepository { r := &artworkRepository{} - r.ctx = ctx r.db = db r.tableName = "artwork" - r.items.ctx = ctx r.items.db = db r.items.tableName = itemArtworkTable return r } -func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) { +func (r *artworkRepository) GetImage(ctx context.Context, hash string) (*model.Artwork, error) { sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) var res model.Artwork - if err := r.queryOne(sel, &res); err != nil { + if err := r.queryOne(ctx, sel, &res); err != nil { return nil, err } return &res, nil } -func (r *artworkRepository) PutImage(a *model.Artwork) error { +func (r *artworkRepository) PutImage(ctx context.Context, a *model.Artwork) error { // created_at is the last-acquisition-write time the prune grace window keys on. a.CreatedAt = time.Now() values, err := toSQLArgs(*a) @@ -53,17 +51,17 @@ func (r *artworkRepository) PutImage(a *model.Artwork) error { height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, thumb_hash=excluded.thumb_hash, dominant_color=excluded.dominant_color, created_at=excluded.created_at`) - _, err = r.executeSQL(ins) + _, err = r.executeSQL(ctx, ins) return err } -func (r *artworkRepository) GetMimeByHash() (map[string]string, error) { +func (r *artworkRepository) GetMimeByHash(ctx context.Context) (map[string]string, error) { sel := Select("hash", "mime").From(r.tableName) var rows []struct { Hash string Mime string } - if err := r.queryAll(sel, &rows); err != nil { + if err := r.queryAll(ctx, sel, &rows); err != nil { return nil, err } res := make(map[string]string, len(rows)) @@ -73,12 +71,12 @@ func (r *artworkRepository) GetMimeByHash() (map[string]string, error) { return res, nil } -func (r *artworkRepository) PurgeOrphans(createdBefore time.Time) (int64, error) { +func (r *artworkRepository) PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error) { del := Delete(r.tableName).Where(And{ Lt{"created_at": createdBefore}, Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), }) - return r.executeSQL(del) + return r.executeSQL(ctx, del) } // artworkOwnerTables maps an artwork kind to the table that owns the entity. @@ -91,14 +89,14 @@ var artworkOwnerTables = map[model.Kind]string{ } // purgeDangling deletes rows in r's table whose owning entity is gone, one statement per kind. -func purgeDangling(r sqlRepository) (int64, error) { +func purgeDangling(ctx context.Context, r sqlRepository) (int64, error) { var total int64 for kind, entityTable := range artworkOwnerTables { del := Delete(r.tableName).Where(And{ Eq{"item_kind": kind.Prefix()}, Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), }) - c, err := r.executeSQL(del) + c, err := r.executeSQL(ctx, del) if err != nil { return total, err } @@ -107,21 +105,21 @@ func purgeDangling(r sqlRepository) (int64, error) { return total, nil } -func (r *artworkRepository) PurgeDanglingItems() (int64, error) { - return purgeDangling(r.items) +func (r *artworkRepository) PurgeDanglingItems(ctx context.Context) (int64, error) { + return purgeDangling(ctx, r.items) } -func (r *artworkRepository) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { +func (r *artworkRepository) GetItemArtwork(ctx context.Context, kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { sel := Select("*").From(itemArtworkTable). Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) var res model.ItemArtwork - if err := r.items.queryOne(sel, &res); err != nil { + if err := r.items.queryOne(ctx, sel, &res); err != nil { return nil, err } return &res, nil } -func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { +func (r *artworkRepository) PutItemArtwork(ctx context.Context, ia *model.ItemArtwork) error { ia.ImageType = cmp.Or(ia.ImageType, model.ImageTypePrimary) ia.UpdatedAt = time.Now() // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. @@ -136,29 +134,29 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, trace=excluded.trace, last_failure=excluded.last_failure, attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) - _, err = r.items.executeSQL(ins) + _, err = r.items.executeSQL(ctx, ins) return err } // PutLastFailure records why an item exhausted its retry budget. It only updates an existing row: // inserting one would write an empty hash, which the rest of the system reads as a settled absent. -func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error { +func (r *artworkRepository) PutLastFailure(ctx context.Context, kind model.Kind, id, imageType, trace string) error { upd := Update(itemArtworkTable).Set("last_failure", trace). Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) - _, err := r.items.executeSQL(upd) + _, err := r.items.executeSQL(ctx, upd) return err } -func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error { +func (r *artworkRepository) DeleteForItems(ctx context.Context, kind model.Kind, ids []string) error { for chunk := range slices.Chunk(ids, artworkBatchSize) { - if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { + if err := r.items.delete(ctx, Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { return err } } return nil } -func (r *artworkRepository) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { +func (r *artworkRepository) GetInfoForItems(ctx context.Context, kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { res := map[string]model.ItemArtworkInfo{} for chunk := range slices.Chunk(ids, artworkBatchSize) { sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash", @@ -173,7 +171,7 @@ func (r *artworkRepository) GetInfoForItems(kind model.Kind, ids []string) (map[ Eq{"ia.item_id": chunk}, }) var rows []model.ItemArtworkInfo - if err := r.items.queryAll(sel, &rows); err != nil { + if err := r.items.queryAll(ctx, sel, &rows); err != nil { return nil, err } for _, row := range rows { diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index a9687f76b..338a7c3ab 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -20,55 +20,57 @@ func clearArtworkTables() { } var _ = Describe("ArtworkRepository", func() { + var ctx context.Context var repo model.ArtworkRepository BeforeEach(func() { + ctx = GinkgoT().Context() clearArtworkTables() DeferCleanup(clearArtworkTables) - repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) + repo = NewArtworkRepository(GetDBXBuilder()) }) Context("resolution traces", func() { const traceJSON = `[{"c":"cover.*","o":"hit"}]` It("round-trips the trace with the state row", func() { - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1", + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t1", ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed()) - got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + got, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(got.Trace).To(Equal(traceJSON)) Expect(got.LastFailure).To(BeEmpty()) }) It("replaces the trace when the item is resolved again", func() { - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t2", ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t2", ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed()) - got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary) + got, _ := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t2", model.ImageTypePrimary) Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`)) }) It("records a last failure on an existing row", func() { - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3", + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t3", ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed()) - Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary, + Expect(repo.PutLastFailure(ctx, model.KindAlbumArtwork, "t3", model.ImageTypePrimary, `[{"c":"decode","o":"error"}]`)).To(Succeed()) - got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary) + got, _ := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t3", model.ImageTypePrimary) Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`)) Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork") }) // Inserting here would write hash='', which every reader treats as a settled absent. It("never creates a row for an item that has no state", func() { - Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, + Expect(repo.PutLastFailure(ctx, model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, `[{"c":"decode","o":"error"}]`)).To(Succeed()) - _, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) + _, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -76,9 +78,9 @@ var _ = Describe("ArtworkRepository", func() { Context("image identity", func() { It("stores and retrieves an artwork by hash", func() { a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} - Expect(repo.PutImage(a)).To(Succeed()) + Expect(repo.PutImage(ctx, a)).To(Succeed()) - got, err := repo.GetImage("abc123") + got, err := repo.GetImage(ctx, "abc123") Expect(err).ToNot(HaveOccurred()) Expect(got.Mime).To(Equal("image/jpeg")) Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) @@ -88,9 +90,9 @@ var _ = Describe("ArtworkRepository", func() { It("round-trips the thumbhash alongside the blurhash", func() { a := &model.Artwork{Hash: "both1", Mime: "image/jpeg", BlurHash: "LKO2?U%2Tw=w", ThumbHash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", DominantColor: "#336699"} - Expect(repo.PutImage(a)).To(Succeed()) + Expect(repo.PutImage(ctx, a)).To(Succeed()) - got, err := repo.GetImage("both1") + got, err := repo.GetImage(ctx, "both1") Expect(err).ToNot(HaveOccurred()) Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) Expect(got.ThumbHash).To(Equal("1QcSHQRnh493V4dIh4eXh1h4kJUI")) @@ -98,10 +100,10 @@ var _ = Describe("ArtworkRepository", func() { }) It("overwrites the thumbhash on re-acquisition", func() { - Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first", DominantColor: "#111111"})).To(Succeed()) - Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second", DominantColor: "#222222"})).To(Succeed()) + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first", DominantColor: "#111111"})).To(Succeed()) + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second", DominantColor: "#222222"})).To(Succeed()) - got, err := repo.GetImage("th2") + got, err := repo.GetImage(ctx, "th2") Expect(err).ToNot(HaveOccurred()) Expect(got.ThumbHash).To(Equal("second")) Expect(got.DominantColor).To(Equal("#222222")) @@ -109,76 +111,76 @@ var _ = Describe("ArtworkRepository", func() { It("is idempotent on Put (upsert by hash)", func() { a := &model.Artwork{Hash: "dup1", Mime: "image/png"} - Expect(repo.PutImage(a)).To(Succeed()) + Expect(repo.PutImage(ctx, a)).To(Succeed()) a.BlurHash = "XYZ" - Expect(repo.PutImage(a)).To(Succeed()) - got, _ := repo.GetImage("dup1") + Expect(repo.PutImage(ctx, a)).To(Succeed()) + got, _ := repo.GetImage(ctx, "dup1") Expect(got.BlurHash).To(Equal("XYZ")) }) It("refreshes created_at when reacquiring an existing hash", func() { - Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() Expect(err).ToNot(HaveOccurred()) - Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) - got, err := repo.GetImage("reacq") + got, err := repo.GetImage(ctx, "reacq") Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) }) It("returns ErrNotFound for a missing hash", func() { - _, err := repo.GetImage("nope") + _, err := repo.GetImage(ctx, "nope") Expect(err).To(MatchError(model.ErrNotFound)) }) It("returns every stored hash with its current mime", func() { - Expect(repo.PutImage(&model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutImage(&model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) - mimes, err := repo.GetMimeByHash() + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) + mimes, err := repo.GetMimeByHash(ctx) Expect(err).ToNot(HaveOccurred()) Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) }) It("deletes only unreferenced rows older than the cutoff, reporting the count", func() { - Expect(repo.PutImage(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutImage(&model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1", ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) - Expect(repo.PurgeOrphans(time.Now().Add(time.Minute))).To(BeNumerically("==", 1)) + Expect(repo.PurgeOrphans(ctx, time.Now().Add(time.Minute))).To(BeNumerically("==", 1)) - _, err := repo.GetImage("d1") + _, err := repo.GetImage(ctx, "d1") Expect(err).To(MatchError(model.ErrNotFound)) - _, err = repo.GetImage("dref") + _, err = repo.GetImage(ctx, "dref") Expect(err).ToNot(HaveOccurred()) }) It("spares an unreferenced row younger than the cutoff", func() { - Expect(repo.PutImage(&model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PurgeOrphans(time.Now().Add(-time.Hour))).To(BeNumerically("==", 0)) - _, err := repo.GetImage("young") + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PurgeOrphans(ctx, time.Now().Add(-time.Hour))).To(BeNumerically("==", 0)) + _, err := repo.GetImage(ctx, "young") Expect(err).ToNot(HaveOccurred()) }) }) Context("dangling state cleanup", func() { It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) - purged, err := repo.PurgeDanglingItems() + purged, err := repo.PurgeDanglingItems(ctx) Expect(err).ToNot(HaveOccurred()) Expect(purged).To(Equal(int64(5))) @@ -190,7 +192,7 @@ var _ = Describe("ArtworkRepository", func() { {ItemKind: "mf", ItemID: songDayInALife.ID}, } { k, _ := model.ParseKind(kept.ItemKind) - _, err := repo.GetItemArtwork(k, kept.ItemID, model.ImageTypePrimary) + _, err := repo.GetItemArtwork(ctx, k, kept.ItemID, model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) } for _, gone := range []model.ItemArtwork{ @@ -201,7 +203,7 @@ var _ = Describe("ArtworkRepository", func() { {ItemKind: "mf", ItemID: "no-such-mediafile"}, } { k, _ := model.ParseKind(gone.ItemKind) - _, err := repo.GetItemArtwork(k, gone.ItemID, model.ImageTypePrimary) + _, err := repo.GetItemArtwork(ctx, k, gone.ItemID, model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) } }) @@ -211,13 +213,13 @@ var _ = Describe("ArtworkRepository", func() { It("upserts and reads state, including per-item provenance", func() { ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} - Expect(repo.PutItemArtwork(ia)).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, ia)).To(Succeed()) ia.Source = "embedded" ia.SourcePath = "/music/a/track.mp3" ia.RefMtime = 222 - Expect(repo.PutItemArtwork(ia)).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, ia)).To(Succeed()) - got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + got, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(got.Source).To(Equal("embedded")) Expect(got.SourcePath).To(Equal("/music/a/track.mp3")) @@ -227,28 +229,28 @@ var _ = Describe("ArtworkRepository", func() { It("defaults attempted_at to now when unset", func() { before := time.Now().Add(-time.Second) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) - got, err := repo.GetItemArtwork(model.KindArtistArtwork, "noattempt", model.ImageTypePrimary) + got, err := repo.GetItemArtwork(ctx, model.KindArtistArtwork, "noattempt", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(got.AttemptedAt).To(BeTemporally(">", before)) }) It("represents known-absent as empty hash", func() { - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - got, err := repo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + got, err := repo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(got.Hash).To(BeEmpty()) }) It("hydrates a page in one batch, including blurhash, dimensions and absence", func() { - Expect(repo.PutImage(&model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9", + Expect(repo.PutImage(ctx, &model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9", DominantColor: "#abcdef", Width: 1200, Height: 800})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) - info, err := repo.GetInfoForItems(model.KindAlbumArtwork, []string{"x1", "x2", "x3"}) + info, err := repo.GetInfoForItems(ctx, model.KindAlbumArtwork, []string{"x1", "x2", "x3"}) Expect(err).ToNot(HaveOccurred()) Expect(info).To(HaveLen(2)) Expect(info["x1"].Hash).To(Equal("h9")) @@ -263,9 +265,9 @@ var _ = Describe("ArtworkRepository", func() { }) It("deletes all rows for a single item", func() { - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - Expect(repo.DeleteForItems(model.KindPlaylistArtwork, []string{"p1"})).To(Succeed()) - _, err := repo.GetItemArtwork(model.KindPlaylistArtwork, "p1", model.ImageTypePrimary) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.DeleteForItems(ctx, model.KindPlaylistArtwork, []string{"p1"})).To(Succeed()) + _, err := repo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "p1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -275,17 +277,17 @@ var _ = Describe("ArtworkRepository", func() { for i := range n { id := fmt.Sprintf("mf-%d", i) ids[i] = id - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) } - Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - Expect(repo.DeleteForItems(model.KindMediaFileArtwork, ids)).To(Succeed()) + Expect(repo.DeleteForItems(ctx, model.KindMediaFileArtwork, ids)).To(Succeed()) for _, id := range ids { - _, err := repo.GetItemArtwork(model.KindMediaFileArtwork, id, model.ImageTypePrimary) + _, err := repo.GetItemArtwork(ctx, model.KindMediaFileArtwork, id, model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) } - kept, err := repo.GetItemArtwork(model.KindMediaFileArtwork, "keep", model.ImageTypePrimary) + kept, err := repo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "keep", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(kept.ItemID).To(Equal("keep")) }) diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go index 523a5d825..30c1ba100 100644 --- a/persistence/criteria_sql_benchmark_test.go +++ b/persistence/criteria_sql_benchmark_test.go @@ -189,11 +189,11 @@ func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model. sqlDB := db.Db() - ur := NewUserRepository(ctx, conn) - if err := ur.Put(&user); err != nil { + ur := NewUserRepository(conn) + if err := ur.Put(ctx, &user); err != nil { b.Fatal(err) } - if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil { + if err := ur.SetUserLibraries(ctx, user.ID, []int{1}); err != nil { b.Fatal(err) } diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index 2b617f5b0..a757901d3 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -143,7 +143,7 @@ func buildTestFS() { } func findMediaFileByTitle(title string) string { - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"media_file.title": title}, }) Expect(err).ToNot(HaveOccurred()) @@ -178,10 +178,10 @@ func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string { OwnerID: owner.ID, Rules: &rules, } - err = ds.Playlist(userCtx).Put(pls) + err = ds.Playlist().Put(userCtx, pls) Expect(err).ToNot(HaveOccurred()) - loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false) + loaded, err := ds.Playlist().GetWithTracks(userCtx, pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) titles := make([]string, len(loaded.Tracks)) @@ -201,7 +201,7 @@ func createPlaylist(owner model.User, public bool, titles ...string) string { mfID := findMediaFileByTitle(title) pls.AddMediaFilesByID([]string{mfID}) } - Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) return pls.ID } @@ -230,7 +230,7 @@ func createSmartPlaylist(owner model.User, public bool, jsonRule string) string Public: public, Rules: &rules, } - Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) return pls.ID } @@ -252,22 +252,22 @@ var _ = BeforeSuite(func() { userWithPass := adminUser userWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) + Expect(initDS.User().Put(ctx, &userWithPass)).To(Succeed()) regularUserWithPass := regularUser regularUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) + Expect(initDS.User().Put(ctx, ®ularUserWithPass)).To(Succeed()) lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.Library().Put(ctx, &lib)).To(Succeed()) + Expect(initDS.User().SetUserLibraries(ctx, adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User().SetUserLibraries(ctx, regularUser.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) + loadedUser, err := initDS.User().FindByUsername(ctx, adminUser.UserName) Expect(err).ToNot(HaveOccurred()) adminUser.Libraries = loadedUser.Libraries - loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName) + loadedOther, err := initDS.User().FindByUsername(ctx, regularUser.UserName) Expect(err).ToNot(HaveOccurred()) regularUser.Libraries = loadedOther.Libraries @@ -282,14 +282,14 @@ var _ = BeforeSuite(func() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} comeTogetherID := findMediaFileByTitle("Come Together") - Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed()) - Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed()) - Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) - Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) + Expect(ds.MediaFile().SetStar(ctx, true, comeTogetherID)).To(Succeed()) + Expect(ds.MediaFile().SetStar(ctx, true, findMediaFileByTitle("So What"))).To(Succeed()) + Expect(ds.MediaFile().SetRating(ctx, 3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) + Expect(ds.MediaFile().SetRating(ctx, 5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) for range 10 { - Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed()) + Expect(ds.MediaFile().IncPlayCount(ctx, comeTogetherID, time.Now())).To(Succeed()) } - Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) + Expect(ds.MediaFile().IncPlayCount(ctx, findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index b96605d09..51442c822 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -317,12 +317,12 @@ var _ = Describe("Smart Playlists", func() { smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) - loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false) + loadedA, err := ds.Playlist().GetWithTracks(ctx, smartAID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(loadedA.Tracks).To(BeEmpty()) Expect(loadedA.EvaluatedAt).To(BeNil()) - loadedB, err := ds.Playlist(ctx).Get(smartBID) + loadedB, err := ds.Playlist().Get(ctx, smartBID) Expect(err).ToNot(HaveOccurred()) Expect(loadedB.EvaluatedAt).To(BeNil()) }) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index a1136ad8d..b4f7c9069 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -64,55 +64,54 @@ func (fs dbFolders) toModels() []model.Folder { return slice.Map(fs, func(f dbFolder) model.Folder { return *f.Folder }) } -func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderRepository { +func newFolderRepository(db dbx.Builder) model.FolderRepository { r := &folderRepository{} - r.ctx = ctx r.db = db r.tableName = "folder" return r } -func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path"). +func (r folderRepository) selectFolder(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(ctx, options...).Columns("folder.*", "library.path as library_path"). Join("library on library.id = folder.library_id") - return r.applyLibraryFilter(sql) + return r.applyLibraryFilter(ctx, sql) } -func (r folderRepository) Get(id string) (*model.Folder, error) { - sq := r.selectFolder().Where(Eq{"folder.id": id}) +func (r folderRepository) Get(ctx context.Context, id string) (*model.Folder, error) { + sq := r.selectFolder(ctx).Where(Eq{"folder.id": id}) var res dbFolder - err := r.queryOne(sq, &res) + err := r.queryOne(ctx, sq, &res) return res.Folder, err } -func (r folderRepository) GetByPath(lib model.Library, path string) (*model.Folder, error) { +func (r folderRepository) GetByPath(ctx context.Context, lib model.Library, path string) (*model.Folder, error) { id := model.NewFolder(lib, path).ID - return r.Get(id) + return r.Get(ctx, id) } -func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, error) { - sq := r.selectFolder(opt...) +func (r folderRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) ([]model.Folder, error) { + sq := r.selectFolder(ctx, opt...) var res dbFolders - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) return res.toModels(), err } -func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { - query := r.newSelect(opt...).Columns("count(*)") - query = r.applyLibraryFilter(query) - return r.count(query) +func (r folderRepository) CountAll(ctx context.Context, opt ...model.QueryOptions) (int64, error) { + query := r.newSelect(ctx, opt...).Columns("count(*)") + query = r.applyLibraryFilter(ctx, query) + return r.count(ctx, query) } -func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { +func (r folderRepository) GetFolderUpdateInfo(ctx context.Context, lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { // If no specific paths, return all folders in the library if len(targetPaths) == 0 { - return r.getFolderUpdateInfoAll(lib) + return r.getFolderUpdateInfoAll(ctx, lib) } // Check if any path is root (return all folders) for _, targetPath := range targetPaths { if targetPath == "" || targetPath == "." { - return r.getFolderUpdateInfoAll(lib) + return r.getFolderUpdateInfoAll(ctx, lib) } } @@ -122,7 +121,7 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ... result := make(map[string]model.FolderUpdateInfo) for batch := range slices.Chunk(targetPaths, batchSize) { - batchResult, err := r.getFolderUpdateInfoBatch(lib, batch) + batchResult, err := r.getFolderUpdateInfoBatch(ctx, lib, batch) if err != nil { return nil, err } @@ -133,16 +132,16 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ... } // getFolderUpdateInfoAll returns update info for all non-missing folders in the library -func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) { +func (r folderRepository) getFolderUpdateInfoAll(ctx context.Context, lib model.Library) (map[string]model.FolderUpdateInfo, error) { where := And{ Eq{"library_id": lib.ID}, Eq{"missing": false}, } - return r.queryFolderUpdateInfo(where) + return r.queryFolderUpdateInfo(ctx, where) } // getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants -func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) { +func (r folderRepository) getFolderUpdateInfoBatch(ctx context.Context, lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) { where := And{ Eq{"library_id": lib.ID}, Eq{"missing": false}, @@ -172,12 +171,12 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath where = append(where, pathConditions) } - return r.queryFolderUpdateInfo(where) + return r.queryFolderUpdateInfo(ctx, where) } // queryFolderUpdateInfo executes the query and returns the result map -func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) { - sq := r.newSelect().Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where) +func (r folderRepository) queryFolderUpdateInfo(ctx context.Context, where And) (map[string]model.FolderUpdateInfo, error) { + sq := r.newSelect(ctx).Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where) var res []struct { ID string UpdatedAt time.Time @@ -185,7 +184,7 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol ImageFiles string ImagesUpdatedAt time.Time } - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) if err != nil { return nil, err } @@ -230,12 +229,12 @@ func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer { // (including parent itself) contains audio files and is not one of the given // folder IDs. LIKE wildcards in the parent path are escaped, so it is always // matched as a literal prefix. -func (r folderRepository) HasAudioOutsideFolders(parent model.Folder, excludeFolderIDs []string) (bool, error) { +func (r folderRepository) HasAudioOutsideFolders(ctx context.Context, parent model.Folder, excludeFolderIDs []string) (bool, error) { if parent.NumAudioFiles > 0 { return true, nil } parentPath := strings.TrimPrefix(path.Join(parent.Path, parent.Name), "/") - return r.exists(And{ + return r.exists(ctx, And{ Eq{"library_id": parent.LibraryID, "missing": false}, Gt{"num_audio_files": 0}, NotEq{"id": excludeFolderIDs}, @@ -253,20 +252,20 @@ func escapeLikePrefix(s string) string { return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) } -func (r folderRepository) Put(f *model.Folder) error { +func (r folderRepository) Put(ctx context.Context, f *model.Folder) error { dbf := dbFolder{Folder: f} - _, err := r.put(dbf.ID, &dbf) + _, err := r.put(ctx, dbf.ID, &dbf) return err } -func (r folderRepository) MarkMissing(missing bool, ids ...string) error { - log.Debug(r.ctx, "Marking folders as missing", "ids", ids, "missing", missing) +func (r folderRepository) MarkMissing(ctx context.Context, missing bool, ids ...string) error { + log.Debug(ctx, "Marking folders as missing", "ids", ids, "missing", missing) for chunk := range slices.Chunk(ids, 200) { sq := Update(r.tableName). Set("missing", missing). Set("updated_at", time.Now()). Where(Eq{"id": chunk}) - _, err := r.executeSQL(sq) + _, err := r.executeSQL(ctx, sq) if err != nil { return err } @@ -274,25 +273,25 @@ func (r folderRepository) MarkMissing(missing bool, ids ...string) error { return nil } -func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { - query := r.selectFolder().Where(And{ +func (r folderRepository) GetTouchedWithPlaylists(ctx context.Context) (model.FolderCursor, error) { + query := r.selectFolder(ctx).Where(And{ Eq{"missing": false}, Gt{"num_playlists": 0}, ConcatExpr("folder.updated_at > library.last_scan_at"), }) - cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) + cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query) if err != nil { return nil, err } return wrapFolderCursor(cursor), nil } -func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { - query := r.selectFolder().Where(And{ +func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.FolderCursor, error) { + query := r.selectFolder(ctx).Where(And{ Eq{"missing": false}, Gt{"num_playlists": 0}, }) - cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) + cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query) if err != nil { return nil, err } @@ -303,7 +302,7 @@ func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) } -func (r folderRepository) purgeEmpty(libraryIDs ...int) error { +func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error { sq := Delete(r.tableName).Where(And{ Eq{"num_audio_files": 0}, Eq{"num_playlists": 0}, @@ -315,12 +314,12 @@ func (r folderRepository) purgeEmpty(libraryIDs ...int) error { if len(libraryIDs) > 0 { sq = sq.Where(Eq{"library_id": libraryIDs}) } - c, err := r.executeSQL(sq) + c, err := r.executeSQL(ctx, sq) if err != nil { return fmt.Errorf("purging empty folders: %w", err) } if c > 0 { - log.Debug(r.ctx, "Purging empty folders", "totalDeleted", c) + log.Debug(ctx, "Purging empty folders", "totalDeleted", c) } return nil } diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index b7bc52751..4b1c54a3d 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -23,17 +23,17 @@ var _ = Describe("FolderRepository", func() { BeforeEach(func() { ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) conn = GetDBXBuilder() - repo = newFolderRepository(ctx, conn) + repo = newFolderRepository(conn) // Use existing library ID 1 from test fixtures - libRepo := NewLibraryRepository(ctx, conn) - lib, err := libRepo.Get(1) + libRepo := NewLibraryRepository(conn) + lib, err := libRepo.Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) testLib = *lib // Create a second library with its own folder to verify isolation otherLib = model.Library{Name: "Other Library", Path: "/other/path"} - Expect(libRepo.Put(&otherLib)).To(Succeed()) + Expect(libRepo.Put(ctx, &otherLib)).To(Succeed()) }) AfterEach(func() { @@ -48,7 +48,7 @@ var _ = Describe("FolderRepository", func() { matching := func(paths ...string) []string { GinkgoHelper() - folders, err := repo.GetAll(model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)}) + folders, err := repo.GetAll(ctx, model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)}) Expect(err).ToNot(HaveOccurred()) return slice.Map(folders, func(f model.Folder) string { return f.ID }) } @@ -59,7 +59,7 @@ var _ = Describe("FolderRepository", func() { grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild") other = model.NewFolder(testLib, "TestSubtreeOther") for _, f := range []*model.Folder{parent, child, grandchild, other} { - Expect(repo.Put(f)).To(Succeed()) + Expect(repo.Put(ctx, f)).To(Succeed()) } DeferCleanup(func() { _, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute() @@ -86,17 +86,17 @@ var _ = Describe("FolderRepository", func() { folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1") folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2") - err := repo.Put(folder1) + err := repo.Put(ctx, folder1) Expect(err).ToNot(HaveOccurred()) - err = repo.Put(folder2) + err = repo.Put(ctx, folder2) Expect(err).ToNot(HaveOccurred()) otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder") - err = repo.Put(otherFolder) + err = repo.Put(ctx, otherFolder) Expect(err).ToNot(HaveOccurred()) // Query all folders (no target paths) - should only return folders from testLib - results, err := repo.GetFolderUpdateInfo(testLib) + results, err := repo.GetFolderUpdateInfo(ctx, testLib) Expect(err).ToNot(HaveOccurred()) // Should include folders from testLib Expect(results).To(HaveKey(folder1.ID)) @@ -113,15 +113,15 @@ var _ = Describe("FolderRepository", func() { folder2 := model.NewFolder(testLib, "TestSpecific/Jazz") folder3 := model.NewFolder(testLib, "TestSpecific/Classical") - err := repo.Put(folder1) + err := repo.Put(ctx, folder1) Expect(err).ToNot(HaveOccurred()) - err = repo.Put(folder2) + err = repo.Put(ctx, folder2) Expect(err).ToNot(HaveOccurred()) - err = repo.Put(folder3) + err = repo.Put(ctx, folder3) Expect(err).ToNot(HaveOccurred()) // Query specific paths - results, err := repo.GetFolderUpdateInfo(testLib, "TestSpecific/Rock", "TestSpecific/Classical") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestSpecific/Rock", "TestSpecific/Classical") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) @@ -142,12 +142,12 @@ var _ = Describe("FolderRepository", func() { child2 := model.NewFolder(testLib, "TestParent/Music/Jazz") otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz") - Expect(repo.Put(parent)).To(Succeed()) - Expect(repo.Put(child1)).To(Succeed()) - Expect(repo.Put(child2)).To(Succeed()) + Expect(repo.Put(ctx, parent)).To(Succeed()) + Expect(repo.Put(ctx, child1)).To(Succeed()) + Expect(repo.Put(ctx, child2)).To(Succeed()) // Query the parent folder - should return parent and all children - results, err := repo.GetFolderUpdateInfo(testLib, "TestParent/Music") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestParent/Music") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results).To(HaveKey(parent.ID)) @@ -161,18 +161,18 @@ var _ = Describe("FolderRepository", func() { parent := model.NewFolder(testLib, "TestIsolation/Parent") child := model.NewFolder(testLib, "TestIsolation/Parent/Child") - Expect(repo.Put(parent)).To(Succeed()) - Expect(repo.Put(child)).To(Succeed()) + Expect(repo.Put(ctx, parent)).To(Succeed()) + Expect(repo.Put(ctx, child)).To(Succeed()) // Create similar path in other library otherParent := model.NewFolder(otherLib, "TestIsolation/Parent") otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child") - Expect(repo.Put(otherParent)).To(Succeed()) - Expect(repo.Put(otherChild)).To(Succeed()) + Expect(repo.Put(ctx, otherParent)).To(Succeed()) + Expect(repo.Put(ctx, otherChild)).To(Succeed()) // Query should only return folders from testLib - results, err := repo.GetFolderUpdateInfo(testLib, "TestIsolation/Parent") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestIsolation/Parent") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect(results).To(HaveKey(parent.ID)) @@ -188,12 +188,12 @@ var _ = Describe("FolderRepository", func() { child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2") child2.Missing = true - Expect(repo.Put(parent)).To(Succeed()) - Expect(repo.Put(child1)).To(Succeed()) - Expect(repo.Put(child2)).To(Succeed()) + Expect(repo.Put(ctx, parent)).To(Succeed()) + Expect(repo.Put(ctx, child1)).To(Succeed()) + Expect(repo.Put(ctx, child2)).To(Succeed()) // Query parent - should only return parent and non-missing child - results, err := repo.GetFolderUpdateInfo(testLib, "TestMissingChild/Parent") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissingChild/Parent") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect(results).To(HaveKey(parent.ID)) @@ -206,11 +206,11 @@ var _ = Describe("FolderRepository", func() { existingParent := model.NewFolder(testLib, "TestMixed/Exists") existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") - Expect(repo.Put(existingParent)).To(Succeed()) - Expect(repo.Put(existingChild)).To(Succeed()) + Expect(repo.Put(ctx, existingParent)).To(Succeed()) + Expect(repo.Put(ctx, existingChild)).To(Succeed()) // Query both existing and non-existing paths - results, err := repo.GetFolderUpdateInfo(testLib, "TestMixed/Exists", "TestMixed/DoesNotExist") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMixed/Exists", "TestMixed/DoesNotExist") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect(results).To(HaveKey(existingParent.ID)) @@ -221,7 +221,7 @@ var _ = Describe("FolderRepository", func() { // Test querying for root folder without creating it (fixtures should have one) rootFolderID := model.FolderID(testLib, ".") - results, err := repo.GetFolderUpdateInfo(testLib, "") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "") Expect(err).ToNot(HaveOccurred()) // Should return the root folder if it exists if len(results) > 0 { @@ -230,7 +230,7 @@ var _ = Describe("FolderRepository", func() { }) It("returns empty map for non-existent folders", func() { - results, err := repo.GetFolderUpdateInfo(testLib, "NonExistent/Path") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "NonExistent/Path") Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -239,10 +239,10 @@ var _ = Describe("FolderRepository", func() { // Create a folder and mark it as missing folder := model.NewFolder(testLib, "TestMissing/Folder") folder.Missing = true - err := repo.Put(folder) + err := repo.Put(ctx, folder) Expect(err).ToNot(HaveOccurred()) - results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder") + results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissing/Folder") Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -262,51 +262,51 @@ var _ = Describe("FolderRepository", func() { disc2 = model.NewFolder(testLib, "TestHasAudio/Album/CD2") disc2.NumAudioFiles = 5 for _, f := range []*model.Folder{albumRoot, disc1, disc2} { - Expect(repo.Put(f)).To(Succeed()) + Expect(repo.Put(ctx, f)).To(Succeed()) } }) It("returns false when all audio under the parent belongs to the given folders", func() { - Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) }) It("returns true when another folder under the parent has audio", func() { bonus := model.NewFolder(testLib, "TestHasAudio/Album/Bonus") bonus.NumAudioFiles = 1 - Expect(repo.Put(bonus)).To(Succeed()) + Expect(repo.Put(ctx, bonus)).To(Succeed()) - Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) + Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) }) It("returns true when the parent itself contains audio files", func() { albumRoot.NumAudioFiles = 2 - Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) + Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) }) It("ignores audio outside the parent's subtree", func() { other := model.NewFolder(testLib, "TestHasAudio/Other Album") other.NumAudioFiles = 10 - Expect(repo.Put(other)).To(Succeed()) + Expect(repo.Put(ctx, other)).To(Succeed()) - Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) }) It("ignores missing folders", func() { gone := model.NewFolder(testLib, "TestHasAudio/Album/Gone") gone.NumAudioFiles = 3 gone.Missing = true - Expect(repo.Put(gone)).To(Succeed()) + Expect(repo.Put(ctx, gone)).To(Succeed()) - Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) }) It("does not treat LIKE wildcards in the parent path as patterns", func() { // "TestHas_udio" would LIKE-match "TestHasAudio" if "_" were not escaped wildcardRoot := model.NewFolder(testLib, "TestHas_udio/Album") - Expect(repo.Put(wildcardRoot)).To(Succeed()) + Expect(repo.Put(ctx, wildcardRoot)).To(Succeed()) - Expect(repo.HasAudioOutsideFolders(*wildcardRoot, []string{"none"})).To(BeFalse()) + Expect(repo.HasAudioOutsideFolders(ctx, *wildcardRoot, []string{"none"})).To(BeFalse()) }) }) @@ -367,9 +367,9 @@ var _ = Describe("FolderRepository", func() { missingWithPls.NumPlaylists = 1 missingWithPls.Missing = true - Expect(repo.Put(withPls)).To(Succeed()) - Expect(repo.Put(noPls)).To(Succeed()) - Expect(repo.Put(missingWithPls)).To(Succeed()) + Expect(repo.Put(ctx, withPls)).To(Succeed()) + Expect(repo.Put(ctx, noPls)).To(Succeed()) + Expect(repo.Put(ctx, missingWithPls)).To(Succeed()) // Force the folder's updated_at to the past so GetTouchedWithPlaylists // (which gates on updated_at > last_scan_at) would NOT return it. @@ -378,7 +378,7 @@ var _ = Describe("FolderRepository", func() { Expect(err).ToNot(HaveOccurred()) var ids []string - cursor, err := repo.GetAllWithPlaylists() + cursor, err := repo.GetAllWithPlaylists(ctx) Expect(err).ToNot(HaveOccurred()) for f, err := range cursor { Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 0bb22c21b..d88cb8672 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -13,43 +13,39 @@ type genreRepository struct { *baseTagRepository } -func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { +func NewGenreRepository(db dbx.Builder) model.GenreRepository { return &genreRepository{ - baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)), + baseTagRepository: newBaseTagRepository(db, new(model.TagGenre)), } } -func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder { - return r.newSelect(opt...).Columns("tag.tag_value as name") +func (r *genreRepository) selectGenre(ctx context.Context, opt ...model.QueryOptions) SelectBuilder { + return r.newSelect(ctx, opt...).Columns("tag.tag_value as name") } -func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { - sq := r.selectGenre(opt...) +func (r *genreRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) (model.Genres, error) { + sq := r.selectGenre(ctx, opt...) res := model.Genres{} - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) return res, err } -func (r *genreRepository) Get(id string) (*model.Genre, error) { - sel := r.selectGenre().Where(Eq{"tag.id": id}) +func (r *genreRepository) Get(ctx context.Context, id string) (*model.Genre, error) { + sel := r.selectGenre(ctx).Where(Eq{"tag.id": id}) var res model.Genre - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -// Override ResourceRepository methods to return Genre objects instead of Tag objects +// Override the base tag REST methods to return Genre objects instead of Tag objects -func (r *genreRepository) Read(id string) (any, error) { - return r.Get(id) +func (r *genreRepository) Read(ctx context.Context, id string) (*model.Genre, error) { + return r.Get(ctx, id) } -func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *genreRepository) NewInstance() any { - return &model.Genre{} +func (r *genreRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Genre, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } var _ model.GenreRepository = (*genreRepository)(nil) -var _ model.ResourceRepository = (*genreRepository)(nil) +var _ rest.Repository[model.Genre] = (*genreRepository)(nil) diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go index e3779725c..52bebf446 100644 --- a/persistence/genre_repository_test.go +++ b/persistence/genre_repository_test.go @@ -16,16 +16,16 @@ import ( var _ = Describe("GenreRepository", func() { var repo model.GenreRepository - var restRepo model.ResourceRepository + var restRepo rest.Repository[model.Genre] var tagRepo model.TagRepository var ctx context.Context BeforeEach(func() { ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - genreRepo := NewGenreRepository(ctx, GetDBXBuilder()) + genreRepo := NewGenreRepository(GetDBXBuilder()) repo = genreRepo - restRepo = genreRepo.(model.ResourceRepository) - tagRepo = NewTagRepository(ctx, GetDBXBuilder()) + restRepo = genreRepo + tagRepo = NewTagRepository(GetDBXBuilder()) // Clear any existing tags to ensure test isolation db := GetDBXBuilder() @@ -43,7 +43,7 @@ var _ = Describe("GenreRepository", func() { return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} } - err = tagRepo.Add(1, + err = tagRepo.Add(ctx, 1, newTag("genre", "rock"), newTag("genre", "pop"), newTag("genre", "jazz"), @@ -65,7 +65,7 @@ var _ = Describe("GenreRepository", func() { Describe("GetAll", func() { It("should return all genres", func() { - genres, err := repo.GetAll() + genres, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(12)) @@ -83,7 +83,7 @@ var _ = Describe("GenreRepository", func() { It("should support query options", func() { // Test with limiting results - genres, err := repo.GetAll(model.QueryOptions{Max: 1}) + genres, err := repo.GetAll(ctx, model.QueryOptions{Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(1)) }) @@ -93,7 +93,7 @@ var _ = Describe("GenreRepository", func() { _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() Expect(err).ToNot(HaveOccurred()) - genres, err := repo.GetAll() + genres, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(BeEmpty()) }) @@ -103,7 +103,7 @@ var _ = Describe("GenreRepository", func() { options := model.QueryOptions{ Filters: squirrel.Like{"tag_value": "%rock%"}, // Direct field access } - genres, err := repo.GetAll(options) + genres, err := repo.GetAll(ctx, options) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(2)) // Should match "rock" and "Alternative Rock" @@ -119,7 +119,7 @@ var _ = Describe("GenreRepository", func() { Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e" Sort: "name", } - genres, err := repo.GetAll(options) + genres, err := repo.GetAll(ctx, options) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(7)) @@ -135,7 +135,7 @@ var _ = Describe("GenreRepository", func() { Sort: "name", Order: "desc", } - genres, err := repo.GetAll(options) + genres, err := repo.GetAll(ctx, options) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(7)) @@ -148,7 +148,7 @@ var _ = Describe("GenreRepository", func() { Describe("Count", func() { It("should return correct count of genres", func() { - count, err := restRepo.Count() + count, err := restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(12))) // We have 12 genre tags }) @@ -158,7 +158,7 @@ var _ = Describe("GenreRepository", func() { _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() Expect(err).ToNot(HaveOccurred()) - count, err := restRepo.Count() + count, err := restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeZero()) }) @@ -170,10 +170,10 @@ var _ = Describe("GenreRepository", func() { TagName: "mood", TagValue: "energetic", } - err := tagRepo.Add(1, nonGenreTag) + err := tagRepo.Add(ctx, 1, nonGenreTag) Expect(err).ToNot(HaveOccurred()) - count, err := restRepo.Count() + count, err := restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) // Count should not include the mood tag Expect(count).To(Equal(int64(12))) // Should still be 12 genre tags @@ -184,7 +184,7 @@ var _ = Describe("GenreRepository", func() { options := rest.QueryOptions{ Filters: map[string]any{"name": "%rock%"}, } - count, err := restRepo.Count(options) + count, err := restRepo.Count(ctx, options) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) }) @@ -194,30 +194,28 @@ var _ = Describe("GenreRepository", func() { It("should return existing genre", func() { // Use one of the existing genres from our consolidated dataset genreID := id.NewTagID("genre", "rock") - result, err := restRepo.Read(genreID) + genre, err := restRepo.Read(ctx, genreID) Expect(err).ToNot(HaveOccurred()) - genre := result.(*model.Genre) Expect(genre.ID).To(Equal(genreID)) Expect(genre.Name).To(Equal("rock")) }) It("should return error for non-existent genre", func() { - _, err := restRepo.Read("non-existent-id") + _, err := restRepo.Read(ctx, "non-existent-id") Expect(err).To(HaveOccurred()) }) It("should not return non-genre tags", func() { moodID := id.NewTagID("mood", "happy") // This exists as a mood tag, not genre - _, err := restRepo.Read(moodID) + _, err := restRepo.Read(ctx, moodID) Expect(err).To(HaveOccurred()) // Should not find it as a genre }) }) Describe("ReadAll", func() { It("should return all genres through ReadAll", func() { - result, err := restRepo.ReadAll() + genres, err := restRepo.ReadAll(ctx) Expect(err).ToNot(HaveOccurred()) - genres := result.(model.Genres) Expect(genres).To(HaveLen(12)) // We have 12 genre tags genreNames := make([]string, len(genres)) @@ -231,7 +229,7 @@ var _ = Describe("GenreRepository", func() { }) It("should support rest query options", func() { - result, err := restRepo.ReadAll() + result, err := restRepo.ReadAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) }) @@ -240,13 +238,15 @@ var _ = Describe("GenreRepository", func() { Describe("Library Filtering", func() { Context("Headless Processes (No User Context)", func() { var headlessRepo model.GenreRepository - var headlessRestRepo model.ResourceRepository + var headlessRestRepo rest.Repository[model.Genre] + var headlessCtx context.Context BeforeEach(func() { + headlessCtx = GinkgoT().Context() // Create a repository with no user context (headless) - headlessGenreRepo := NewGenreRepository(context.Background(), GetDBXBuilder()) + headlessGenreRepo := NewGenreRepository(GetDBXBuilder()) headlessRepo = headlessGenreRepo - headlessRestRepo = headlessGenreRepo.(model.ResourceRepository) + headlessRestRepo = headlessGenreRepo // Add genres to different libraries db := GetDBXBuilder() @@ -258,13 +258,13 @@ var _ = Describe("GenreRepository", func() { return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} } - err = tagRepo.Add(2, newTag("genre", "jazz")) + err = tagRepo.Add(ctx, 2, newTag("genre", "jazz")) Expect(err).ToNot(HaveOccurred()) }) It("should see all genres from all libraries when no user is in context", func() { // Headless processes should see all genres regardless of library - genres, err := headlessRepo.GetAll() + genres, err := headlessRepo.GetAll(headlessCtx) Expect(err).ToNot(HaveOccurred()) // Should see genres from all libraries @@ -279,7 +279,7 @@ var _ = Describe("GenreRepository", func() { }) It("should count all genres from all libraries when no user is in context", func() { - count, err := headlessRestRepo.Count() + count, err := headlessRestRepo.Count(headlessCtx) Expect(err).ToNot(HaveOccurred()) // Should count all genres from all libraries @@ -288,12 +288,11 @@ var _ = Describe("GenreRepository", func() { It("should allow headless processes to apply explicit library_id filters", func() { // Filter by specific library - genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{ + genreList, err := headlessRestRepo.ReadAll(headlessCtx, rest.QueryOptions{ Filters: map[string]any{"library_id": 2}, }) Expect(err).ToNot(HaveOccurred()) - genreList := genres.(model.Genres) // Should see only genres from library 2 Expect(genreList).To(HaveLen(1)) Expect(genreList[0].Name).To(Equal("jazz")) @@ -301,29 +300,15 @@ var _ = Describe("GenreRepository", func() { It("should get individual genres when no user is in context", func() { // Get all genres first to find an ID - genres, err := headlessRepo.GetAll() + genres, err := headlessRepo.GetAll(headlessCtx) Expect(err).ToNot(HaveOccurred()) Expect(genres).ToNot(BeEmpty()) // Headless process should be able to get the genre - genre, err := headlessRestRepo.Read(genres[0].ID) + genre, err := headlessRestRepo.Read(headlessCtx, genres[0].ID) Expect(err).ToNot(HaveOccurred()) Expect(genre).ToNot(BeNil()) }) }) }) - - Describe("EntityName", func() { - It("should return correct entity name", func() { - name := restRepo.EntityName() - Expect(name).To(Equal("tag")) // Genre repository uses tag table - }) - }) - - Describe("NewInstance", func() { - It("should return new genre instance", func() { - instance := restRepo.NewInstance() - Expect(instance).To(BeAssignableToTypeOf(&model.Genre{})) - }) - }) }) diff --git a/persistence/item_tags_test.go b/persistence/item_tags_test.go index a2317a1a5..1106c7e9c 100644 --- a/persistence/item_tags_test.go +++ b/persistence/item_tags_test.go @@ -1,6 +1,8 @@ package persistence import ( + "context" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -14,17 +16,18 @@ var _ = Describe("item genre tag indexes", func() { var mr model.MediaFileRepository var ar model.AlbumRepository var rock, jazz model.Tag + var ctx context.Context BeforeEach(func() { - ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}) conn = GetDBXBuilder() - mr = NewMediaFileRepository(ctx, conn) - ar = NewAlbumRepository(ctx, conn) + mr = NewMediaFileRepository(conn) + ar = NewAlbumRepository(conn) // Test-only genre values, so they can't collide with the golden fixtures. rock = model.NewTag(model.TagGenre, "GenreIdxRock") jazz = model.NewTag(model.TagGenre, "GenreIdxJazz") // The join tables FK to tag(id); the scanner adds tags before saving items. - Expect(NewTagRepository(ctx, conn).Add(1, rock, jazz)).To(Succeed()) + Expect(NewTagRepository(conn).Add(ctx, 1, rock, jazz)).To(Succeed()) // The suite shares one golden DB with no per-test restore, so undo the rows we add // (media_file/album deletes cascade to the *_tags join rows; tag deletes cascade too). DeferCleanup(func() { @@ -53,25 +56,25 @@ var _ = Describe("item genre tag indexes", func() { It("writes a media_file_tags row for each genre when the track is saved", func() { mf := model.MediaFile{ID: "mf-g1", LibraryID: 1, Path: "/m/g1.mp3", Title: "G1", Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g1")).To(ConsistOf(rock.ID, jazz.ID)) }) It("replaces the rows when the genres change", func() { mf := model.MediaFile{ID: "mf-g2", LibraryID: 1, Path: "/m/g2.mp3", Title: "G2", Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) mf.Tags = model.Tags{model.TagGenre: []string{jazz.TagValue}} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g2")).To(ConsistOf(jazz.ID)) }) It("clears the rows when all genres are removed", func() { mf := model.MediaFile{ID: "mf-g3", LibraryID: 1, Path: "/m/g3.mp3", Title: "G3", Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) mf.Tags = model.Tags{} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g3")).To(BeEmpty()) }) }) @@ -80,7 +83,7 @@ var _ = Describe("item genre tag indexes", func() { It("writes an album_tags row for each genre when the album is saved", func() { al := model.Album{ID: "al-g1", LibraryID: 1, Name: "AG1", Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} - Expect(ar.Put(&al)).To(Succeed()) + Expect(ar.Put(ctx, &al)).To(Succeed()) Expect(tagIDsFor("album_tags", "album_id", "al-g1")).To(ConsistOf(rock.ID, jazz.ID)) }) }) @@ -90,19 +93,19 @@ var _ = Describe("item genre tag indexes", func() { It("filters media files by genre_id", func() { mf := model.MediaFile{ID: "mf-nat1", LibraryID: 1, Path: "/m/nat1.mp3", Title: "Nat1", Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(&mf)).To(Succeed()) - res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) + Expect(mr.Put(ctx, &mf)).To(Succeed()) + res, err := mr.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) Expect(err).ToNot(HaveOccurred()) - Expect(res.(model.MediaFiles)).To(ContainElement(HaveField("ID", "mf-nat1"))) + Expect(res).To(ContainElement(HaveField("ID", "mf-nat1"))) }) It("filters albums by genre_id", func() { al := model.Album{ID: "al-nat1", LibraryID: 1, Name: "ANat1", Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(ar.Put(&al)).To(Succeed()) - res, err := ar.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) + Expect(ar.Put(ctx, &al)).To(Succeed()) + res, err := ar.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) Expect(err).ToNot(HaveOccurred()) - Expect(res.(model.Albums)).To(ContainElement(HaveField("ID", "al-nat1"))) + Expect(res).To(ContainElement(HaveField("ID", "al-nat1"))) }) }) }) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 2e8feea7a..bf6b8995e 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -25,22 +25,21 @@ var ( libLock sync.RWMutex ) -func NewLibraryRepository(ctx context.Context, db dbx.Builder) model.LibraryRepository { +func NewLibraryRepository(db dbx.Builder) model.LibraryRepository { r := &libraryRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Library{}, nil) return r } -func (r *libraryRepository) Get(id int) (*model.Library, error) { - sq := r.newSelect().Columns("*").Where(Eq{"id": id}) +func (r *libraryRepository) Get(ctx context.Context, id int) (*model.Library, error) { + sq := r.newSelect(ctx).Columns("*").Where(Eq{"id": id}) var res model.Library - err := r.queryOne(sq, &res) + err := r.queryOne(ctx, sq, &res) return &res, err } -func (r *libraryRepository) GetPath(id int) (string, error) { +func (r *libraryRepository) GetPath(ctx context.Context, id int) (string, error) { l := func() string { libLock.RLock() defer libLock.RUnlock() @@ -55,9 +54,9 @@ func (r *libraryRepository) GetPath(id int) (string, error) { libLock.Lock() defer libLock.Unlock() - libs, err := r.GetAll() + libs, err := r.GetAll(ctx) if err != nil { - log.Error(r.ctx, "Error loading libraries from DB", err) + log.Error(ctx, "Error loading libraries from DB", err) return "", err } for _, l := range libs { @@ -70,9 +69,9 @@ func (r *libraryRepository) GetPath(id int) (string, error) { } } -func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error { +func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpdate ...string) error { if l.ID == model.DefaultLibraryID { - currentLib, err := r.Get(1) + currentLib, err := r.Get(ctx, 1) // if we are creating it, it's ok. if err == nil { // it exists, so we are updating it if currentLib.Path != l.Path { @@ -97,7 +96,7 @@ func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error }, colsToUpdate...) cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) - rowsAffected, updateErr := r.executeSQL(sq) + rowsAffected, updateErr := r.executeSQL(ctx, sq) if updateErr != nil { return updateErr } @@ -122,7 +121,7 @@ CROSS JOIN library l WHERE u.is_admin = true ON CONFLICT (user_id, library_id) DO NOTHING;`, ) - if _, err = r.executeSQL(sql); err != nil { + if _, err = r.executeSQL(ctx, sql); err != nil { return fmt.Errorf("failed to assign library to admin users: %w", err) } @@ -134,12 +133,12 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`, // TODO Remove this method when we have a proper UI to add libraries // This is a temporary method to store the music folder path from the config in the DB -func (r *libraryRepository) StoreMusicFolder() error { +func (r *libraryRepository) StoreMusicFolder(ctx context.Context) error { sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). Set("updated_at", time.Now()). Where(Eq{"id": model.DefaultLibraryID}). Where(NotEq{"path": conf.Server.MusicFolder}) - rowsAffected, err := r.executeSQL(sq) + rowsAffected, err := r.executeSQL(ctx, sq) if err == nil && rowsAffected > 0 { libLock.Lock() defer libLock.Unlock() @@ -148,77 +147,77 @@ func (r *libraryRepository) StoreMusicFolder() error { return err } -func (r *libraryRepository) AddArtist(id int, artistID string) error { +func (r *libraryRepository) AddArtist(ctx context.Context, id int, artistID string) error { sq := Insert("library_artist").Columns("library_id", "artist_id").Values(id, artistID). Suffix(`on conflict(library_id, artist_id) do nothing`) - _, err := r.executeSQL(sq) + _, err := r.executeSQL(ctx, sq) if err != nil { return err } return nil } -func (r *libraryRepository) ScanBegin(id int, fullScan bool) error { +func (r *libraryRepository) ScanBegin(ctx context.Context, id int, fullScan bool) error { sq := Update(r.tableName). Set("last_scan_started_at", time.Now()). Set("full_scan_in_progress", fullScan). Where(Eq{"id": id}) - _, err := r.executeSQL(sq) + _, err := r.executeSQL(ctx, sq) return err } -func (r *libraryRepository) ScanEnd(id int) error { +func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error { sq := Update(r.tableName). Set("last_scan_at", time.Now()). Set("full_scan_in_progress", false). Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) - _, err := r.executeSQL(sq) + _, err := r.executeSQL(ctx, sq) return err } -func (r *libraryRepository) ScanInProgress() (bool, error) { - query := r.newSelect().Where(NotEq{"last_scan_started_at": time.Time{}}) - count, err := r.count(query) +func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) { + query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) + count, err := r.count(ctx, query) return count > 0, err } -func (r *libraryRepository) RefreshStats(id int) error { +func (r *libraryRepository) RefreshStats(ctx context.Context, id int) error { var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } var sizeRes struct{ Sum int64 } var durationRes struct{ Sum float64 } err := run.Parallel( func() error { - return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) + return r.queryOne(ctx, Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) }, func() error { - return r.queryOne(Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) + return r.queryOne(ctx, Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) }, func() error { - return r.queryOne(Select("count(*) as count").From("library_artist la"). + return r.queryOne(ctx, Select("count(*) as count").From("library_artist la"). Join("artist a on la.artist_id = a.id"). Where(Eq{"la.library_id": id, "a.missing": false}), &artistsRes) }, func() error { - return r.queryOne(Select("count(*) as count").From("folder"). + return r.queryOne(ctx, Select("count(*) as count").From("folder"). Where(And{ Eq{"library_id": id, "missing": false}, Gt{"num_audio_files": 0}, }), &foldersRes) }, func() error { - return r.queryOne(Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count"). + return r.queryOne(ctx, Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count"). From("folder").Where(Eq{"library_id": id, "missing": false}), &filesRes) }, func() error { - return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) + return r.queryOne(ctx, Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) }, func() error { - return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) + return r.queryOne(ctx, Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) }, func() error { - return r.queryOne(Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes) + return r.queryOne(ctx, Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes) }, )() if err != nil { @@ -236,25 +235,25 @@ func (r *libraryRepository) RefreshStats(id int) error { Set("total_duration", durationRes.Sum). Set("updated_at", time.Now()). Where(Eq{"id": id}) - _, err = r.executeSQL(sq) + _, err = r.executeSQL(ctx, sq) return err } -func (r *libraryRepository) Delete(id int) error { - if !loggedUser(r.ctx).IsAdmin { +func (r *libraryRepository) Delete(ctx context.Context, id int) error { + if !loggedUser(ctx).IsAdmin { return model.ErrNotAuthorized } if id == 1 { return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation) } - err := r.delete(Eq{"id": id}) + err := r.delete(ctx, Eq{"id": id}) if err != nil { return err } // The cascade above can drop an artist's last library_artist row; reconcile any such orphans. - if err := NewArtistRepository(r.ctx, r.db).(*artistRepository).markOrphansMissing(); err != nil { + if err := NewArtistRepository(r.db).(*artistRepository).markOrphansMissing(ctx); err != nil { return fmt.Errorf("marking orphaned artists missing after deleting library %d: %w", id, err) } @@ -265,26 +264,26 @@ func (r *libraryRepository) Delete(id int) error { // Clean up orphaned plugin references for the deleted library if err := cleanupPluginLibraryReferences(r.db, id); err != nil { - log.Error(r.ctx, "Failed to cleanup plugin library references", "libraryID", id, err) + log.Error(ctx, "Failed to cleanup plugin library references", "libraryID", id, err) } return nil } -func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) { - sq := r.newSelect(ops...).Columns("*") +func (r *libraryRepository) GetAll(ctx context.Context, ops ...model.QueryOptions) (model.Libraries, error) { + sq := r.newSelect(ctx, ops...).Columns("*") res := model.Libraries{} - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) return res, err } -func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) { - sq := r.newSelect(ops...) - return r.count(sq) +func (r *libraryRepository) CountAll(ctx context.Context, ops ...model.QueryOptions) (int64, error) { + sq := r.newSelect(ctx, ops...) + return r.count(ctx, sq) } // User-library association methods -func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) { +func (r *libraryRepository) GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (model.Users, error) { sel := Select("u.*"). From("user u"). Join("user_library ul ON u.id = ul.user_id"). @@ -292,57 +291,28 @@ func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.User OrderBy("u.name") var res model.Users - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) return res, err } // REST interface methods -func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *libraryRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *libraryRepository) Read(id string) (any, error) { +func (r *libraryRepository) Read(ctx context.Context, id string) (*model.Library, error) { idInt, err := strconv.Atoi(id) if err != nil { - log.Trace(r.ctx, "invalid library id: %s", id, err) + log.Trace(ctx, "invalid library id: %s", id, err) return nil, rest.ErrNotFound } - return r.Get(idInt) + return r.Get(ctx, idInt) } -func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *libraryRepository) EntityName() string { - return "library" -} - -func (r *libraryRepository) NewInstance() any { - return &model.Library{} -} - -func (r *libraryRepository) Save(entity any) (string, error) { - lib := entity.(*model.Library) - lib.ID = 0 // Reset ID to ensure we create a new library - err := r.Put(lib) - if err != nil { - return "", err - } - return strconv.Itoa(lib.ID), nil -} - -func (r *libraryRepository) Update(id string, entity any, cols ...string) error { - lib := entity.(*model.Library) - idInt, err := strconv.Atoi(id) - if err != nil { - return fmt.Errorf("invalid library ID: %s", id) - } - - lib.ID = idInt - return r.Put(lib, cols...) +func (r *libraryRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Library, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } var _ model.LibraryRepository = (*libraryRepository)(nil) -var _ rest.Repository = (*libraryRepository)(nil) +var _ rest.Repository[model.Library] = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 6aede8c4e..0ff470861 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -23,7 +23,7 @@ var _ = Describe("LibraryRepository", func() { BeforeEach(func() { ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) conn = GetDBXBuilder() - repo = NewLibraryRepository(ctx, conn) + repo = NewLibraryRepository(conn) }) AfterEach(func() { @@ -40,14 +40,14 @@ var _ = Describe("LibraryRepository", func() { Path: "/music/test", } - err := repo.Put(lib) + err := repo.Put(ctx, lib) Expect(err).ToNot(HaveOccurred()) Expect(lib.ID).To(BeNumerically(">", 0)) Expect(lib.CreatedAt).ToNot(BeZero()) Expect(lib.UpdatedAt).ToNot(BeZero()) // Verify it was inserted - savedLib, err := repo.Get(lib.ID) + savedLib, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(savedLib.Name).To(Equal("Test Library")) Expect(savedLib.Path).To(Equal("/music/test")) @@ -62,11 +62,11 @@ var _ = Describe("LibraryRepository", func() { RemotePath: "/remote/original", DefaultNewUsers: true, } - Expect(repo.Put(lib)).To(Succeed()) + Expect(repo.Put(ctx, lib)).To(Succeed()) - Expect(repo.Put(&model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) + Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) - saved, err := repo.Get(lib.ID) + saved, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(saved.Name).To(Equal("Renamed")) Expect(saved.RemotePath).To(Equal("/remote/original")) @@ -82,7 +82,7 @@ var _ = Describe("LibraryRepository", func() { Name: "Original Library", Path: "/music/original", } - err := repo.Put(lib) + err := repo.Put(ctx, lib) Expect(err).ToNot(HaveOccurred()) originalID := lib.ID @@ -96,7 +96,7 @@ var _ = Describe("LibraryRepository", func() { // Now update it lib.Name = "Updated Library" lib.Path = "/music/updated" - err = repo.Put(lib) + err = repo.Put(ctx, lib) Expect(err).ToNot(HaveOccurred()) // Verify it was updated, not inserted @@ -105,7 +105,7 @@ var _ = Describe("LibraryRepository", func() { Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt)) // Verify the changes were saved - savedLib, err := repo.Get(lib.ID) + savedLib, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(savedLib.Name).To(Equal("Updated Library")) Expect(savedLib.Path).To(Equal("/music/updated")) @@ -121,18 +121,18 @@ var _ = Describe("LibraryRepository", func() { } // Ensure the record doesn't exist - _, err := repo.Get(999) + _, err := repo.Get(ctx, 999) Expect(err).To(HaveOccurred()) // Put should insert it - err = repo.Put(lib) + err = repo.Put(ctx, lib) Expect(err).ToNot(HaveOccurred()) Expect(lib.ID).To(Equal(999)) Expect(lib.CreatedAt).ToNot(BeZero()) Expect(lib.UpdatedAt).ToNot(BeZero()) // Verify it was inserted with the correct ID - savedLib, err := repo.Get(999) + savedLib, err := repo.Get(ctx, 999) Expect(err).ToNot(HaveOccurred()) Expect(savedLib.ID).To(Equal(999)) Expect(savedLib.Name).To(Equal("New Library with ID")) @@ -146,7 +146,7 @@ var _ = Describe("LibraryRepository", func() { BeforeEach(func() { var err error - libBefore, err = repo.Get(model.DefaultLibraryID) + libBefore, err = repo.Get(ctx, model.DefaultLibraryID) Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) @@ -162,9 +162,9 @@ var _ = Describe("LibraryRepository", func() { It("skips updating the default library when the configured path is unchanged", func() { conf.Server.MusicFolder = libBefore.Path - Expect(repo.StoreMusicFolder()).To(Succeed()) + Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) - libAfter, err := repo.Get(model.DefaultLibraryID) + libAfter, err := repo.Get(ctx, model.DefaultLibraryID) Expect(err).ToNot(HaveOccurred()) Expect(libAfter.Path).To(Equal(libBefore.Path)) Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) @@ -172,9 +172,9 @@ var _ = Describe("LibraryRepository", func() { It("updates the default library only when the configured path changes", func() { conf.Server.MusicFolder = libBefore.Path + "-updated" - Expect(repo.StoreMusicFolder()).To(Succeed()) + Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) - libAfter, err := repo.Get(model.DefaultLibraryID) + libAfter, err := repo.Get(ctx, model.DefaultLibraryID) Expect(err).ToNot(HaveOccurred()) Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder)) Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt)) @@ -182,10 +182,10 @@ var _ = Describe("LibraryRepository", func() { }) It("refreshes stats", func() { - libBefore, err := repo.Get(1) + libBefore, err := repo.Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) - Expect(repo.RefreshStats(1)).To(Succeed()) - libAfter, err := repo.Get(1) + Expect(repo.RefreshStats(ctx, 1)).To(Succeed()) + libAfter, err := repo.Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) Expect(libAfter.UpdatedAt).To(BeTemporally(">", libBefore.UpdatedAt)) @@ -221,16 +221,16 @@ var _ = Describe("LibraryRepository", func() { Name: "Test Scan Library", Path: "/music/test-scan", } - err := repo.Put(lib) + err := repo.Put(ctx, lib) Expect(err).ToNot(HaveOccurred()) }) DescribeTable("ScanBegin", func(fullScan bool, expectedFullScanInProgress bool) { - err := repo.ScanBegin(lib.ID, fullScan) + err := repo.ScanBegin(ctx, lib.ID, fullScan) Expect(err).ToNot(HaveOccurred()) - updatedLib, err := repo.Get(lib.ID) + updatedLib, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(updatedLib.LastScanStartedAt).ToNot(BeZero()) Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress)) @@ -241,15 +241,15 @@ var _ = Describe("LibraryRepository", func() { Context("ScanEnd", func() { BeforeEach(func() { - err := repo.ScanBegin(lib.ID, true) + err := repo.ScanBegin(ctx, lib.ID, true) Expect(err).ToNot(HaveOccurred()) }) It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() { - err := repo.ScanEnd(lib.ID) + err := repo.ScanEnd(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) - updatedLib, err := repo.Get(lib.ID) + updatedLib, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(updatedLib.LastScanAt).ToNot(BeZero()) Expect(updatedLib.FullScanInProgress).To(BeFalse()) @@ -257,13 +257,13 @@ var _ = Describe("LibraryRepository", func() { }) It("sets LastScanAt to be after LastScanStartedAt", func() { - libBefore, err := repo.Get(lib.ID) + libBefore, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) - err = repo.ScanEnd(lib.ID) + err = repo.ScanEnd(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) - libAfter, err := repo.Get(lib.ID) + libAfter, err := repo.Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt)) }) @@ -273,6 +273,7 @@ var _ = Describe("LibraryRepository", func() { Describe("Delete", func() { var adminRepo model.LibraryRepository var artistRepo model.ArtistRepository + var adminCtx context.Context artistMissing := func(id string) bool { var missing bool @@ -283,32 +284,32 @@ var _ = Describe("LibraryRepository", func() { } BeforeEach(func() { - adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - adminRepo = NewLibraryRepository(adminCtx, conn) - artistRepo = NewArtistRepository(adminCtx, conn) + adminCtx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo = NewLibraryRepository(conn) + artistRepo = NewArtistRepository(conn) }) It("marks artists orphaned by the delete as missing", func() { lib := model.Library{Name: "Doomed Library", Path: "/doomed"} - Expect(adminRepo.Put(&lib)).To(Succeed()) + Expect(adminRepo.Put(adminCtx, &lib)).To(Succeed()) orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"} sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"} - Expect(artistRepo.Put(&orphanArtist)).To(Succeed()) - Expect(artistRepo.Put(&sharedArtist)).To(Succeed()) - Expect(adminRepo.AddArtist(lib.ID, orphanArtist.ID)).To(Succeed()) - Expect(adminRepo.AddArtist(lib.ID, sharedArtist.ID)).To(Succeed()) - Expect(adminRepo.AddArtist(1, sharedArtist.ID)).To(Succeed()) + Expect(artistRepo.Put(adminCtx, &orphanArtist)).To(Succeed()) + Expect(artistRepo.Put(adminCtx, &sharedArtist)).To(Succeed()) + Expect(adminRepo.AddArtist(adminCtx, lib.ID, orphanArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(adminCtx, lib.ID, sharedArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(adminCtx, 1, sharedArtist.ID)).To(Succeed()) DeferCleanup(func() { if raw, ok := artistRepo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete("artist"). + _, _ = raw.executeSQL(adminCtx, squirrel.Delete("artist"). Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}})) } }) Expect(artistMissing(orphanArtist.ID)).To(BeFalse()) - Expect(adminRepo.Delete(lib.ID)).To(Succeed()) + Expect(adminRepo.Delete(adminCtx, lib.ID)).To(Succeed()) Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing") Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible") diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 835c39b1e..b9a5c3374 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -85,9 +85,8 @@ func (m dbMediaFiles) toModels() model.MediaFiles { return slice.Map(m, func(mf dbMediaFile) model.MediaFile { return *mf.MediaFile }) } -func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFileRepository { +func NewMediaFileRepository(db dbx.Builder) model.MediaFileRepository { r := &mediaFileRepository{} - r.ctx = ctx r.db = db r.tableName = "media_file" r.registerModel(&model.MediaFile{}, mediaFileFilter()) @@ -147,25 +146,25 @@ func mediaFileRecentlyAddedSort() string { return "media_file.created_at, media_file.id" } -func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { - query := r.newSelect() - query = r.applyLibraryFilter(query) +func (r *mediaFileRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + query := r.newSelect(ctx) + query = r.applyLibraryFilter(ctx, query) // The annotation join is expensive with count(distinct) and pointless unless a filter uses it. if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(query, "media_file.id") + query = r.withAnnotation(ctx, query, "media_file.id") } - return r.count(query, options...) + return r.count(ctx, query, options...) } -func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[string]int64, error) { - sel := r.newSelect(options...). +func (r *mediaFileRepository) CountBySuffix(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) { + sel := r.newSelect(ctx, options...). Columns("lower(suffix) as suffix", "count(*) as count"). GroupBy("lower(suffix)") var res []struct { Suffix string Count int64 } - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } @@ -176,42 +175,42 @@ func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[ return counts, nil } -func (r *mediaFileRepository) Exists(id string) (bool, error) { +func (r *mediaFileRepository) Exists(ctx context.Context, id string) (bool, error) { // The exists() helper applies no library filter, so it would report rows the caller cannot see. - c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"media_file.id": id}))) + c, err := r.count(ctx, r.applyLibraryFilter(ctx, r.newSelect(ctx).Where(Eq{"media_file.id": id}))) return c > 0, err } -func (r *mediaFileRepository) Put(m *model.MediaFile) error { +func (r *mediaFileRepository) Put(ctx context.Context, m *model.MediaFile) error { 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}) + id, err := r.putByMatch(ctx, Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m}) if err != nil { return err } m.ID = id - if err := r.updateParticipants(m.ID, m.Participants); err != nil { + if err := r.updateParticipants(ctx, m.ID, m.Participants); err != nil { return err } - return r.updateTags(m.ID, m.Tags) + return r.updateTags(ctx, m.ID, m.Tags) } -func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { - _, err := r.executeSQL(Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) +func (r *mediaFileRepository) UpdateProbeData(ctx context.Context, id string, data string) error { + _, err := r.executeSQL(ctx, Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) return err } -func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). +func (r *mediaFileRepository) selectMediaFile(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(ctx, options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id") - sql = r.withAnnotation(sql, "media_file.id") - sql = r.withBookmark(sql, "media_file.id") - return r.applyLibraryFilter(sql) + sql = r.withAnnotation(ctx, sql, "media_file.id") + sql = r.withBookmark(ctx, sql, "media_file.id") + return r.applyLibraryFilter(ctx, sql) } -func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { - res, err := r.GetAll(model.QueryOptions{Filters: Eq{"media_file.id": id}}) +func (r *mediaFileRepository) Get(ctx context.Context, id string) (*model.MediaFile, error) { + res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"media_file.id": id}}) if err != nil { return nil, err } @@ -221,34 +220,34 @@ func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { return &res[0], nil } -func (r *mediaFileRepository) GetWithParticipants(id string) (*model.MediaFile, error) { - m, err := r.Get(id) +func (r *mediaFileRepository) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) { + m, err := r.Get(ctx, id) if err != nil { return nil, err } - m.Participants, err = r.getParticipants(m) + m.Participants, err = r.getParticipants(ctx, m) return m, err } -func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { - sq := r.selectMediaFile(options...) +func (r *mediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { + sq := r.selectMediaFile(ctx, options...) var res dbMediaFiles - err := r.queryAll(sq, &res, options...) + err := r.queryAll(ctx, sq, &res, options...) if err != nil { return nil, err } mfs := res.toModels() - r.hydrateArtwork(mfs) + r.hydrateArtwork(ctx, mfs) return mfs, nil } -func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { - hydrateMediaFileArtwork(r.ctx, r.db, mfs) +func (r *mediaFileRepository) hydrateArtwork(ctx context.Context, mfs model.MediaFiles) { + hydrateMediaFileArtwork(ctx, r.db, mfs) } // GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the // wide media_file row: pick random rowids first, then hydrate only those. -func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { +func (r *mediaFileRepository) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { var opt model.QueryOptions if len(options) > 0 { opt = options[0] @@ -256,14 +255,14 @@ func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.Me rowidQuery := Select("media_file.rowid").From(r.tableName) rowidQuery = r.applyFilters(rowidQuery, model.QueryOptions{Filters: opt.Filters}) - rowidQuery = r.applyLibraryFilter(rowidQuery) + rowidQuery = r.applyLibraryFilter(ctx, rowidQuery) rowidQuery = rowidQuery.OrderBy("random()") if opt.Max > 0 { rowidQuery = rowidQuery.Limit(uint64(opt.Max)) } var rowids []int64 - if err := r.queryAllSlice(rowidQuery, &rowids); err != nil { + if err := r.queryAllSlice(ctx, rowidQuery, &rowids); err != nil { return nil, err } if len(rowids) == 0 { @@ -272,17 +271,17 @@ func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.Me // Re-shuffle in Phase 2: `WHERE rowid IN (...)` returns rows in ascending rowid order, not // the random order from Phase 1. Sorting only the (<=Max) hydrated rows is negligible. - sq := r.selectMediaFile().Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") + sq := r.selectMediaFile(ctx).Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") var res dbMediaFiles - if err := r.queryAll(sq, &res); err != nil { + if err := r.queryAll(ctx, sq, &res); err != nil { return nil, err } mfs := res.toModels() - r.hydrateArtwork(mfs) + r.hydrateArtwork(ctx, mfs) return mfs, nil } -func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { +func (r *mediaFileRepository) GetAllByTags(ctx context.Context, tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { placeholders := make([]string, len(values)) args := make([]any, len(values)) for i, v := range values { @@ -304,12 +303,12 @@ func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, o } else { opts.Filters = tagFilter } - return r.GetAll(opts) + return r.GetAll(ctx, opts) } -func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) { - sq := r.selectMediaFile(options...) - cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq) +func (r *mediaFileRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { + sq := r.selectMediaFile(ctx, options...) + cursor, err := queryWithStableResults[dbMediaFile](ctx, r.sqlRepository, sq) if err != nil { return nil, err } @@ -317,17 +316,17 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me } // getAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. -func (r *mediaFileRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) +func (r *mediaFileRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(ctx, r.newSelect(ctx, options...).Columns("media_file.id")) if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(sq, "media_file.id") + sq = r.withAnnotation(ctx, sq, "media_file.id") } ids := []string{} - err := r.queryAllSlice(sq, &ids) + err := r.queryAllSlice(ctx, sq, &ids) return ids, err } -func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs ...string) ([]string, error) { +func (r *mediaFileRepository) GetAlbumIDsByFolder(ctx context.Context, lib model.Library, folderIDs ...string) ([]string, error) { ids := []string{} for chunk := range slices.Chunk(folderIDs, 200) { // A folder's own cover also covers albums whose tracks sit in its disc subfolders. @@ -339,7 +338,7 @@ func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs . sq := Select("distinct album_id").From("media_file"). Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")}) var chunkIDs []string - if err := r.queryAllSlice(sq, &chunkIDs); err != nil { + if err := r.queryAllSlice(ctx, sq, &chunkIDs); err != nil { return nil, err } ids = append(ids, chunkIDs...) @@ -348,14 +347,14 @@ func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs . } // GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. -func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { - ids, err := r.getAllIDs(options...) +func (r *mediaFileRepository) GetCursorWithArtwork(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { + ids, err := r.getAllIDs(ctx, options...) if err != nil { return nil, err } opts := chunkOptions(options, "media_file.id") return model.MediaFileCursor(streamByIDs(ids, func(chunk []string) (model.MediaFiles, error) { - return r.GetAll(opts(chunk)) + return r.GetAll(ctx, opts(chunk)) })), nil } @@ -363,7 +362,7 @@ func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions // The paths can be library-qualified (format: "libraryID:path") or unqualified ("path"). // Library-qualified paths search within the specified library, while unqualified paths // search across all libraries for backward compatibility. -func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { +func (r *mediaFileRepository) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { // One IN list per library instead of one OR term per path: SQLite abandons the // path index at just two OR-ed equality terms and scans the whole table. byLibrary := map[int][]string{} @@ -395,57 +394,57 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err return model.MediaFiles{}, nil } - sel := r.applyLibraryFilter(r.newSelect().Columns("*").Where(query)) + sel := r.applyLibraryFilter(ctx, r.newSelect(ctx).Columns("*").Where(query)) var res dbMediaFiles - if err := r.queryAll(sel, &res); err != nil { + if err := r.queryAll(ctx, sel, &res); err != nil { return nil, err } return res.toModels(), nil } -func (r *mediaFileRepository) Delete(id string) error { - return r.delete(Eq{"id": id}) +func (r *mediaFileRepository) Delete(ctx context.Context, id string) error { + return r.delete(ctx, Eq{"id": id}) } -func (r *mediaFileRepository) ReassignReferences(prevID, newID string) error { - if err := r.ReassignAnnotation(prevID, newID); err != nil { +func (r *mediaFileRepository) ReassignReferences(ctx context.Context, prevID, newID string) error { + if err := r.ReassignAnnotation(ctx, prevID, newID); err != nil { return fmt.Errorf("reassigning annotations: %w", err) } - if err := r.reassignBookmark(prevID, newID); err != nil { + if err := r.reassignBookmark(ctx, prevID, newID); err != nil { return fmt.Errorf("reassigning bookmarks: %w", err) } upd := Update("playlist_tracks").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) - if _, err := r.executeSQL(upd); err != nil { + if _, err := r.executeSQL(ctx, upd); err != nil { return fmt.Errorf("reassigning playlist tracks: %w", err) } upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) - if _, err := r.executeSQL(upd); err != nil { + if _, err := r.executeSQL(ctx, upd); err != nil { return fmt.Errorf("reassigning scrobbles: %w", err) } // OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time) buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID) - if _, err := r.executeSQL(buf); err != nil { + if _, err := r.executeSQL(ctx, buf); err != nil { return fmt.Errorf("reassigning buffered scrobbles: %w", err) } return nil } -func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { - user := loggedUser(r.ctx) +func (r *mediaFileRepository) DeleteAllMissing(ctx context.Context) (int64, error) { + user := loggedUser(ctx) if !user.IsAdmin { return 0, rest.ErrPermissionDenied } del := Delete(r.tableName).Where(Eq{"missing": true}) - return r.executeSQL(del) + return r.executeSQL(ctx, del) } -func (r *mediaFileRepository) DeleteMissing(ids []string) error { - user := loggedUser(r.ctx) +func (r *mediaFileRepository) DeleteMissing(ctx context.Context, ids []string) error { + user := loggedUser(ctx) if !user.IsAdmin { return rest.ErrPermissionDenied } - return r.delete( + return r.delete(ctx, And{ Eq{"missing": true}, Eq{"id": ids}, @@ -453,24 +452,24 @@ func (r *mediaFileRepository) DeleteMissing(ids []string) error { ) } -func (r *mediaFileRepository) MarkMissing(missing bool, mfs ...*model.MediaFile) error { +func (r *mediaFileRepository) MarkMissing(ctx context.Context, missing bool, mfs ...*model.MediaFile) error { ids := slice.SeqFunc(mfs, func(m *model.MediaFile) string { return m.ID }) for chunk := range slice.CollectChunks(ids, 200) { upd := Update(r.tableName). Set("missing", missing). Set("updated_at", time.Now()). Where(Eq{"id": chunk}) - c, err := r.executeSQL(upd) + c, err := r.executeSQL(ctx, upd) if err != nil || c == 0 { - log.Error(r.ctx, "Error setting mediafile missing flag", "ids", chunk, err) + log.Error(ctx, "Error setting mediafile missing flag", "ids", chunk, err) return err } - log.Debug(r.ctx, "Marked missing mediafiles", "total", c, "ids", chunk) + log.Debug(ctx, "Marked missing mediafiles", "total", c, "ids", chunk) } return nil } -func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...string) error { +func (r *mediaFileRepository) MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error { for chunk := range slices.Chunk(folderIDs, 200) { upd := Update(r.tableName). Set("missing", missing). @@ -479,12 +478,12 @@ func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...str Eq{"folder_id": chunk}, Eq{"missing": !missing}, }) - c, err := r.executeSQL(upd) + c, err := r.executeSQL(ctx, upd) if err != nil { - log.Error(r.ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err) + log.Error(ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err) return err } - log.Debug(r.ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk) + log.Debug(ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk) } return nil } @@ -492,8 +491,8 @@ func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...str // GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs) // that were added/updated after the last scan started. The result is ordered by PID. // It does not need to load bookmarks, annotations and participants, as they are not used by the scanner. -func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { - subQ := r.newSelect().Columns("pid"). +func (r *mediaFileRepository) GetMissingAndMatching(ctx context.Context, libId int) (model.MediaFileCursor, error) { + subQ := r.newSelect(ctx).Columns("pid"). Where(And{ Eq{"media_file.missing": true}, Eq{"library_id": libId}, @@ -502,7 +501,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC if err != nil { return nil, err } - sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). + sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where("pid in ("+subQText+")", subQArgs...). Where(Or{ @@ -510,7 +509,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC ConcatExpr("media_file.created_at > library.last_scan_started_at"), }). OrderBy("pid") - cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sel) + cursor, err := queryWithStableResults[dbMediaFile](ctx, r.sqlRepository, sel) if err != nil { return nil, err } @@ -523,8 +522,8 @@ func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCu // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries // It uses a lightweight query without annotation/bookmark joins since those are not needed for matching -func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). +func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(ctx context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where(And{ NotEq{"media_file.library_id": missing.LibraryID}, @@ -536,7 +535,7 @@ func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFil }).OrderBy("media_file.created_at DESC") var res dbMediaFiles - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } @@ -545,8 +544,8 @@ func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFil // FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries // It uses a lightweight query without annotation/bookmark joins since those are not needed for matching -func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). +func (r *mediaFileRepository) FindRecentFilesByProperties(ctx context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where(And{ NotEq{"media_file.library_id": missing.LibraryID}, @@ -562,7 +561,7 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil }).OrderBy("media_file.created_at DESC") var res dbMediaFiles - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } @@ -575,8 +574,8 @@ var mediaFileSearchConfig = searchConfig{ MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"}, } -func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (bool, error) { - usr := loggedUser(r.ctx) +func (r *mediaFileRepository) MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error) { + usr := loggedUser(ctx) rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr)) cond, err := rulesSQL.where() if err != nil { @@ -586,46 +585,38 @@ func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (b sq = rulesSQL.applyExpressionJoins(sq, usr.ID) sq = sq.Where(And{Eq{"media_file.id": id}, cond}) var res struct{ Count int64 } - if err := r.queryOne(sq, &res); err != nil { + if err := r.queryOne(ctx, sq, &res); err != nil { return false, err } return res.Count > 0, nil } -func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { +func (r *mediaFileRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.MediaFiles, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } var res dbMediaFiles - err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts) + err := r.doSearch(ctx, r.selectMediaFile(ctx, options...), q, &res, mediaFileSearchConfig, opts) if err != nil { return nil, fmt.Errorf("searching media_file %q: %w", q, err) } mfs := res.toModels() - r.hydrateArtwork(mfs) + r.hydrateArtwork(ctx, mfs) return mfs, nil } -func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *mediaFileRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *mediaFileRepository) Read(id string) (any, error) { - return r.Get(id) +func (r *mediaFileRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) { + return r.Get(ctx, id) } -func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *mediaFileRepository) EntityName() string { - return "mediafile" -} - -func (r *mediaFileRepository) NewInstance() any { - return &model.MediaFile{} +func (r *mediaFileRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } var _ model.MediaFileRepository = (*mediaFileRepository)(nil) -var _ model.ResourceRepository = (*mediaFileRepository)(nil) +var _ rest.Repository[model.MediaFile] = (*mediaFileRepository)(nil) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index d81b5e9ee..14590090c 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -24,11 +24,11 @@ import ( var _ = Describe("MediaRepository", func() { var mr model.MediaFileRepository + var ctx context.Context BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid"}) - mr = NewMediaFileRepository(ctx, GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) + mr = NewMediaFileRepository(GetDBXBuilder()) }) Describe("GetAlbumIDsByFolder", func() { @@ -37,22 +37,22 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - libPtr, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + libPtr, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) Expect(err).ToNot(HaveOccurred()) lib = *libPtr - folderRepo := newFolderRepository(ctx, GetDBXBuilder()) + folderRepo := newFolderRepository(GetDBXBuilder()) albumRoot = model.NewFolder(lib, "ByFolder/Album") disc1 = model.NewFolder(lib, "ByFolder/Album/CD1") sibling = model.NewFolder(lib, "ByFolder/Other") for _, f := range []*model.Folder{albumRoot, disc1, sibling} { - Expect(folderRepo.Put(f)).To(Succeed()) + Expect(folderRepo.Put(ctx, f)).To(Succeed()) } // Tracks live in the disc subfolder; the sibling album is the negative control. - Expect(mr.Put(&model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) - Expect(mr.Put(&model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) - Expect(mr.Put(&model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) - Expect(mr.Put(&model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute() _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute() @@ -60,20 +60,20 @@ var _ = Describe("MediaRepository", func() { }) It("returns the distinct album IDs of non-missing tracks in the folder", func() { - ids, err := mr.GetAlbumIDsByFolder(lib, disc1.ID) + ids, err := mr.GetAlbumIDsByFolder(ctx, lib, disc1.ID) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf("fol-al-1")) }) It("also matches albums whose tracks are in a direct child of the folder", func() { // A cover in the album root must reach the album whose tracks sit in CD1 - ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID) + ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf("fol-al-1")) }) It("does not match albums outside the folder", func() { - ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID) + ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) Expect(err).ToNot(HaveOccurred()) Expect(ids).ToNot(ContainElement("fol-al-2")) }) @@ -82,46 +82,47 @@ var _ = Describe("MediaRepository", func() { Describe("GetCursor", func() { It("yields the same media files as GetAll", func() { opts := model.QueryOptions{Sort: "title"} - want, err := mr.GetAll(opts) + want, err := mr.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) }) It("honors Max/Offset like GetAll", func() { opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} - want, err := mr.GetAll(opts) + want, err := mr.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) }) }) It("gets mediafile from the DB", func() { - actual, err := mr.Get("1004") + actual, err := mr.Get(ctx, "1004") Expect(err).ToNot(HaveOccurred()) actual.CreatedAt = time.Time{} Expect(actual).To(Equal(&songAntenna)) }) It("returns ErrNotFound", func() { - _, err := mr.Get("56") + _, err := mr.Get(ctx, "56") Expect(err).To(MatchError(model.ErrNotFound)) }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll()).To(Equal(int64(13))) + Expect(mr.CountAll(ctx)).To(Equal(int64(13))) }) Describe("CountAll annotation-join gating", func() { var adminRepo model.MediaFileRepository + var adminCtx context.Context BeforeEach(func() { - adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) - adminRepo = NewMediaFileRepository(adminCtx, GetDBXBuilder()) + adminCtx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) + adminRepo = NewMediaFileRepository(GetDBXBuilder()) }) It("counts starred songs when an annotation filter is present", func() { // Come Together (id 1002) is starred for the admin user in the seed data - count, err := adminRepo.CountAll(model.QueryOptions{ + count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -129,7 +130,7 @@ var _ = Describe("MediaRepository", func() { }) It("counts with starred=false without a 'no such column' error (join kept)", func() { - count, err := adminRepo.CountAll(model.QueryOptions{ + count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "false"), }) Expect(err).ToNot(HaveOccurred()) @@ -138,7 +139,7 @@ var _ = Describe("MediaRepository", func() { }) It("counts unfiltered with the join dropped", func() { - Expect(adminRepo.CountAll()).To(Equal(int64(13))) + Expect(adminRepo.CountAll(adminCtx)).To(Equal(int64(13))) }) }) @@ -151,21 +152,21 @@ var _ = Describe("MediaRepository", func() { flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"} flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"} - Expect(mr.Put(&mp3File)).To(Succeed()) - Expect(mr.Put(&flacFile1)).To(Succeed()) - Expect(mr.Put(&flacFile2)).To(Succeed()) - Expect(mr.Put(&flacUpperFile)).To(Succeed()) + Expect(mr.Put(ctx, &mp3File)).To(Succeed()) + Expect(mr.Put(ctx, &flacFile1)).To(Succeed()) + Expect(mr.Put(ctx, &flacFile2)).To(Succeed()) + Expect(mr.Put(ctx, &flacUpperFile)).To(Succeed()) }) AfterEach(func() { - _ = mr.Delete(mp3File.ID) - _ = mr.Delete(flacFile1.ID) - _ = mr.Delete(flacFile2.ID) - _ = mr.Delete(flacUpperFile.ID) + _ = mr.Delete(ctx, mp3File.ID) + _ = mr.Delete(ctx, flacFile1.ID) + _ = mr.Delete(ctx, flacFile2.ID) + _ = mr.Delete(ctx, flacUpperFile.ID) }) It("counts media files grouped by suffix with lowercase normalization", func() { - counts, err := mr.CountBySuffix() + counts, err := mr.CountBySuffix(ctx) Expect(err).ToNot(HaveOccurred()) // Should have lowercase keys only @@ -182,7 +183,7 @@ var _ = Describe("MediaRepository", func() { It("returns songs ordered by lyrics with a specific title/artist", func() { // attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items - results, err := mr.GetAll(model.QueryOptions{ + results, err := mr.GetAll(ctx, model.QueryOptions{ Sort: "lyrics, updated_at", Order: "desc", Filters: squirrel.And{ @@ -206,14 +207,14 @@ var _ = Describe("MediaRepository", func() { Describe("GetRandom", func() { It("returns the requested number of distinct, fully-hydrated media files", func() { - results, err := mr.GetRandom(model.QueryOptions{Max: 5}) + results, err := mr.GetRandom(ctx, model.QueryOptions{Max: 5}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(5)) // Each returned row must match its GetAll counterpart exactly — proves Phase 2 // hydrates full rows (not bare rowids) — and ids must be distinct. byID := map[string]model.MediaFile{} - all, err := mr.GetAll() + all, err := mr.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) for _, mf := range all { byID[mf.ID] = mf @@ -229,13 +230,13 @@ var _ = Describe("MediaRepository", func() { }) It("returns all matching files when Max exceeds the total", func() { - results, err := mr.GetRandom(model.QueryOptions{Max: 1000}) + results, err := mr.GetRandom(ctx, model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(13)) }) It("honors filters", func() { - results, err := mr.GetRandom(model.QueryOptions{ + results, err := mr.GetRandom(ctx, model.QueryOptions{ Max: 10, Filters: squirrel.Eq{"media_file.title": "Antenna"}, }) @@ -248,7 +249,7 @@ var _ = Describe("MediaRepository", func() { It("returns varying results across calls", func() { // Retry a few times: two random draws of 5 from 13 rows differ with near-certainty. - first, err := mr.GetRandom(model.QueryOptions{Max: 5}) + first, err := mr.GetRandom(ctx, model.QueryOptions{Max: 5}) Expect(err).ToNot(HaveOccurred()) firstIDs := func() []string { ids := make([]string, len(first)) @@ -259,7 +260,7 @@ var _ = Describe("MediaRepository", func() { }() differed := false for range 10 { - next, err := mr.GetRandom(model.QueryOptions{Max: 5}) + next, err := mr.GetRandom(ctx, model.QueryOptions{Max: 5}) Expect(err).ToNot(HaveOccurred()) nextIDs := make([]string, len(next)) for i, mf := range next { @@ -276,7 +277,7 @@ var _ = Describe("MediaRepository", func() { It("randomizes order even when Max exceeds the total", func() { // Same set of rows every time (all 13), but the order must still be shuffled — // guards against Phase 2's `rowid IN (...)` returning rows in rowid order. - first, err := mr.GetRandom(model.QueryOptions{Max: 100}) + first, err := mr.GetRandom(ctx, model.QueryOptions{Max: 100}) Expect(err).ToNot(HaveOccurred()) Expect(first).To(HaveLen(13)) firstIDs := make([]string, len(first)) @@ -285,7 +286,7 @@ var _ = Describe("MediaRepository", func() { } differed := false for range 10 { - next, err := mr.GetRandom(model.QueryOptions{Max: 100}) + next, err := mr.GetRandom(ctx, model.QueryOptions{Max: 100}) Expect(err).ToNot(HaveOccurred()) nextIDs := make([]string, len(next)) for i, mf := range next { @@ -304,13 +305,13 @@ var _ = Describe("MediaRepository", 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()) + Expect(mr.Put(ctx, &newFile)).To(Succeed()) - retrieved, err := mr.Get(newFile.ID) + retrieved, err := mr.Get(ctx, newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally(">", before)) - _ = mr.Delete(newFile.ID) + _ = mr.Delete(ctx, newFile.ID) }) It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() { @@ -321,13 +322,13 @@ var _ = Describe("MediaRepository", func() { Path: "test/created-at-preserved.mp3", CreatedAt: originalTime, } - Expect(mr.Put(&newFile)).To(Succeed()) + Expect(mr.Put(ctx, &newFile)).To(Succeed()) - retrieved, err := mr.Get(newFile.ID) + retrieved, err := mr.Get(ctx, newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) - _ = mr.Delete(newFile.ID) + _ = mr.Delete(ctx, newFile.ID) }) It("does not reset CreatedAt when updating an existing file", func() { @@ -340,7 +341,7 @@ var _ = Describe("MediaRepository", func() { Title: "Original Title", CreatedAt: originalTime, } - Expect(mr.Put(&newFile)).To(Succeed()) + Expect(mr.Put(ctx, &newFile)).To(Succeed()) // Update the file with a new title but zero CreatedAt updatedFile := model.MediaFile{ @@ -350,66 +351,66 @@ var _ = Describe("MediaRepository", func() { Title: "Updated Title", // CreatedAt is zero - should NOT overwrite the stored value } - Expect(mr.Put(&updatedFile)).To(Succeed()) + Expect(mr.Put(ctx, &updatedFile)).To(Succeed()) - retrieved, err := mr.Get(fileID) + retrieved, err := mr.Get(ctx, 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) + _ = mr.Delete(ctx, fileID) }) }) It("checks existence of mediafiles in the DB", func() { - Expect(mr.Exists(songAntenna.ID)).To(BeTrue()) - Expect(mr.Exists("666")).To(BeFalse()) + Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue()) + Expect(mr.Exists(ctx, "666")).To(BeFalse()) }) It("delete tracks by id", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) - Expect(mr.Delete(newID)).To(Succeed()) + Expect(mr.Delete(ctx, newID)).To(Succeed()) - _, err := mr.Get(newID) + _, err := mr.Get(ctx, newID) Expect(err).To(MatchError(model.ErrNotFound)) }) It("deletes all missing files", func() { new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} - Expect(mr.Put(&new1)).To(Succeed()) - Expect(mr.Put(&new2)).To(Succeed()) - Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed()) + Expect(mr.Put(ctx, &new1)).To(Succeed()) + Expect(mr.Put(ctx, &new2)).To(Succeed()) + Expect(mr.MarkMissing(ctx, true, &new1, &new2)).To(Succeed()) adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) - adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + adminRepo := NewMediaFileRepository(GetDBXBuilder()) // Ensure the files are marked as missing and we have 2 of them - count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) + count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) Expect(count).To(BeNumerically("==", 2)) Expect(err).ToNot(HaveOccurred()) - count, err = adminRepo.DeleteAllMissing() + count, err = adminRepo.DeleteAllMissing(adminCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) - _, err = mr.Get(new1.ID) + _, err = mr.Get(ctx, new1.ID) Expect(err).To(MatchError(model.ErrNotFound)) - _, err = mr.Get(new2.ID) + _, err = mr.Get(ctx, new2.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) + Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) - mf, err := mr.Get(id) + mf, err := mr.Get(ctx, id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) @@ -425,85 +426,85 @@ var _ = Describe("MediaRepository", func() { It("returns 0 when no ratings exist", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) - mf, err := mr.Get(newID) + mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(0.0)) - _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) - Expect(mr.SetRating(5, newID)).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) + Expect(mr.SetRating(ctx, 5, newID)).To(Succeed()) - mf, err := mr.Get(newID) + mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(5.0)) - _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("calculates average across multiple users", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) - Expect(mr.SetRating(3, newID)).To(Succeed()) + Expect(mr.SetRating(ctx, 3, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder()) - Expect(user2Repo.SetRating(5, newID)).To(Succeed()) + user2Repo := NewMediaFileRepository(GetDBXBuilder()) + Expect(user2Repo.SetRating(user2Ctx, 5, newID)).To(Succeed()) - mf, err := mr.Get(newID) + mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) - _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) - Expect(mr.SetRating(4, newID)).To(Succeed()) + Expect(mr.SetRating(ctx, 4, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder()) - Expect(user2Repo.SetRating(0, newID)).To(Succeed()) + user2Repo := NewMediaFileRepository(GetDBXBuilder()) + Expect(user2Repo.SetRating(user2Ctx, 0, newID)).To(Succeed()) - mf, err := mr.Get(newID) + mf, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) - _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) }) It("preserves play date if and only if provided date is older", func() { id := "incplay.playdate" - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) - mf, err := mr.Get(id) + Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) + mf, err := mr.Get(ctx, id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) playDateLate := playDate.AddDate(0, 0, 1) - Expect(mr.IncPlayCount(id, playDateLate)).To(BeNil()) - mf, err = mr.Get(id) + Expect(mr.IncPlayCount(ctx, id, playDateLate)).To(BeNil()) + mf, err = mr.Get(ctx, id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(2))) playDateEarly := playDate.AddDate(0, 0, -1) - Expect(mr.IncPlayCount(id, playDateEarly)).To(BeNil()) - mf, err = mr.Get(id) + Expect(mr.IncPlayCount(ctx, id, playDateEarly)).To(BeNil()) + mf, err = mr.Get(ctx, id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(3))) @@ -511,12 +512,12 @@ var _ = Describe("MediaRepository", func() { It("increments play count on newly starred items", func() { id := "star.incplay" - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) - Expect(mr.SetStar(true, id)).To(BeNil()) + Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.SetStar(ctx, true, id)).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) + Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) - mf, err := mr.Get(id) + mf, err := mr.Get(ctx, id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) @@ -555,7 +556,7 @@ var _ = Describe("MediaRepository", func() { // Insert test data first for i := range testMediaFiles { - Expect(mr.Put(&testMediaFiles[i])).To(Succeed()) + Expect(mr.Put(ctx, &testMediaFiles[i])).To(Succeed()) } // Then manually update timestamps using direct SQL to bypass the repository logic @@ -597,7 +598,7 @@ var _ = Describe("MediaRepository", func() { AfterEach(func() { // Clean up test data for _, mf := range testMediaFiles { - _ = mr.Delete(mf.ID) + _ = mr.Delete(ctx, mf.ID) } }) @@ -607,14 +608,12 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { conf.Server.RecentlyAddedByModTime = false // Create repository AFTER setting config - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid"}) - testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + testRepo = NewMediaFileRepository(GetDBXBuilder()) }) It("sorts by created_at", func() { // Get results sorted by recently_added (should use created_at) - results, err := testRepo.GetAll(model.QueryOptions{ + results, err := testRepo.GetAll(ctx, model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, @@ -630,7 +629,7 @@ var _ = Describe("MediaRepository", func() { It("sorts in ascending order when specified", func() { // Get results sorted by recently_added in ascending order - results, err := testRepo.GetAll(model.QueryOptions{ + results, err := testRepo.GetAll(ctx, model.QueryOptions{ Sort: "recently_added", Order: "asc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, @@ -651,14 +650,12 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { conf.Server.RecentlyAddedByModTime = true // Create repository AFTER setting config - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid"}) - testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + testRepo = NewMediaFileRepository(GetDBXBuilder()) }) It("sorts by updated_at", func() { // Get results sorted by recently_added (should use updated_at) - results, err := testRepo.GetAll(model.QueryOptions{ + results, err := testRepo.GetAll(ctx, model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, @@ -677,7 +674,7 @@ var _ = Describe("MediaRepository", func() { conf.Server.RecentlyAddedByModTime = false ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid"}) - repo := NewMediaFileRepository(ctx, GetDBXBuilder()) + repo := NewMediaFileRepository(GetDBXBuilder()) ids := []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID} sameTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) @@ -687,7 +684,7 @@ var _ = Describe("MediaRepository", func() { Expect(err).ToNot(HaveOccurred()) order := func() []string { - res, err := repo.GetAll(model.QueryOptions{ + res, err := repo.GetAll(ctx, model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": ids}}) Expect(err).ToNot(HaveOccurred()) @@ -709,20 +706,20 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"} - Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed()) + Expect(mr.Put(ctx, &mfWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { - _ = mr.Delete(mfWithoutAnnotation.ID) + _ = mr.Delete(ctx, mfWithoutAnnotation.ID) }) Describe("starred", func() { It("false includes items without annotations", func() { - res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := mr.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - files := res.(model.MediaFiles) + files := res var found bool for _, f := range files { @@ -735,11 +732,11 @@ var _ = Describe("MediaRepository", func() { }) It("true excludes items without annotations", func() { - res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := mr.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - files := res.(model.MediaFiles) + files := res for _, f := range files { Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID)) @@ -749,11 +746,11 @@ var _ = Describe("MediaRepository", func() { Describe("path", func() { It("matches files whose path starts with the given prefix", func() { - res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := mr.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"path": "test/"}, }) Expect(err).ToNot(HaveOccurred()) - files := res.(model.MediaFiles) + files := res var found bool for _, f := range files { @@ -766,11 +763,11 @@ var _ = Describe("MediaRepository", func() { }) It("excludes files whose path does not start with the given prefix", func() { - res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := mr.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"path": "no-such-prefix/"}, }) Expect(err).ToNot(HaveOccurred()) - files := res.(model.MediaFiles) + files := res Expect(files).To(BeEmpty()) }) }) @@ -779,7 +776,7 @@ var _ = Describe("MediaRepository", func() { Describe("Search", func() { Context("text search", func() { It("finds media files by title", func() { - results, err := mr.Search("Antenna", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "Antenna", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 for _, result := range results { @@ -788,7 +785,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media files case insensitively", func() { - results, err := mr.Search("antenna", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "antenna", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, result := range results { @@ -797,7 +794,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when no matches found", func() { - results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "nonexistent", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -820,17 +817,17 @@ var _ = Describe("MediaRepository", func() { } // Insert the test media file into the database - err := mr.Put(&mediaFileWithMBID) + err := mr.Put(ctx, &mediaFileWithMBID) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up test data using direct SQL - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) }) It("finds media file by mbz_recording_id", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -838,7 +835,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media file by mbz_release_track_id", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -846,7 +843,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when MBID is not found", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -862,22 +859,22 @@ var _ = Describe("MediaRepository", func() { Missing: true, } - err := mr.Put(&missingMediaFile) + err := mr.Put(ctx, &missingMediaFile) Expect(err).ToNot(HaveOccurred()) // Search never returns missing media files (hardcoded behavior) - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // Clean up - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) + _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) }) }) Context("empty query (natural order pagination)", func() { It("returns all non-missing files in natural order", func() { - results, err := mr.Search("", model.QueryOptions{Max: 1000}) + results, err := mr.Search(ctx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(results).ToNot(BeEmpty()) for _, result := range results { @@ -886,22 +883,22 @@ var _ = Describe("MediaRepository", func() { }) It(`treats quoted empty query ("") the same as empty`, func() { - all, err := mr.Search("", model.QueryOptions{Max: 1000}) + all, err := mr.Search(ctx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) - quoted, err := mr.Search(`""`, model.QueryOptions{Max: 1000}) + quoted, err := mr.Search(ctx, `""`, model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(quoted).To(HaveLen(len(all))) }) It("paginates without overlaps or gaps", func() { - all, err := mr.Search("", model.QueryOptions{Max: 1000}) + all, err := mr.Search(ctx, "", model.QueryOptions{Max: 1000}) Expect(err).ToNot(HaveOccurred()) Expect(len(all)).To(BeNumerically(">", 3)) var paged model.MediaFiles pageSize := 3 for offset := 0; offset < len(all); offset += pageSize { - page, err := mr.Search("", model.QueryOptions{Max: pageSize, Offset: offset}) + page, err := mr.Search(ctx, "", model.QueryOptions{Max: pageSize, Offset: offset}) Expect(err).ToNot(HaveOccurred()) paged = append(paged, page...) } @@ -912,7 +909,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty page when offset is beyond the total", func() { - results, err := mr.Search("", model.QueryOptions{Max: 10, Offset: 100000}) + results, err := mr.Search(ctx, "", model.QueryOptions{Max: 10, Offset: 100000}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -926,41 +923,41 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - pr = NewPlaylistRepository(ctx, GetDBXBuilder()) + pr = NewPlaylistRepository(GetDBXBuilder()) prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} - Expect(mr.Put(&prev)).To(Succeed()) - Expect(mr.Put(&next)).To(Succeed()) + Expect(mr.Put(ctx, &prev)).To(Succeed()) + Expect(mr.Put(ctx, &next)).To(Succeed()) pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} pls.AddMediaFilesByID([]string{prev.ID}) - Expect(pr.Put(&pls)).To(Succeed()) + Expect(pr.Put(ctx, &pls)).To(Succeed()) }) AfterEach(func() { - _ = pr.Delete(pls.ID) - _ = mr.Delete(prev.ID) - _ = mr.Delete(next.ID) - _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + _ = pr.Delete(ctx, pls.ID) + _ = mr.Delete(ctx, prev.ID) + _ = mr.Delete(ctx, next.ID) + _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) }) It("moves annotations, bookmarks and playlist entries onto the new id", func() { - Expect(mr.SetRating(5, prev.ID)).To(Succeed()) - Expect(mr.AddBookmark(prev.ID, "here", 42)).To(Succeed()) + Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) + Expect(mr.AddBookmark(ctx, prev.ID, "here", 42)).To(Succeed()) - Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - got, err := mr.Get(next.ID) + got, err := mr.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Rating).To(Equal(5)) - bookmarks, err := mr.GetBookmarks() + bookmarks, err := mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) - withTracks, err := pr.GetWithTracks(pls.ID, false, false) + withTracks, err := pr.GetWithTracks(ctx, pls.ID, false, false) Expect(err).ToNot(HaveOccurred()) Expect(withTracks.Tracks).To(HaveLen(1)) Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) @@ -968,51 +965,52 @@ var _ = Describe("MediaRepository", func() { It("moves scrobbles and buffered scrobbles onto the new id", func() { ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - scrobbles := NewScrobbleRepository(ctx, GetDBXBuilder()) - buffer := NewScrobbleBufferRepository(ctx, GetDBXBuilder()) - Expect(scrobbles.RecordScrobble(prev.ID, time.Now())).To(Succeed()) - Expect(buffer.Enqueue("lastfm", "userid", prev.ID, time.Now())).To(Succeed()) + scrobbles := NewScrobbleRepository(GetDBXBuilder()) + buffer := NewScrobbleBufferRepository(GetDBXBuilder()) + Expect(scrobbles.RecordScrobble(ctx, prev.ID, time.Now())).To(Succeed()) + Expect(buffer.Enqueue(ctx, "lastfm", "userid", prev.ID, time.Now())).To(Succeed()) - Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) - Expect(mr.Delete(prev.ID)).To(Succeed()) + Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) + Expect(mr.Delete(ctx, prev.ID)).To(Succeed()) - all, err := scrobbles.GetAll() + all, err := scrobbles.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID }), func(sc model.Scrobble) string { return sc.MediaFileID }) Expect(mine).To(ConsistOf(next.ID)) - entry, err := buffer.Next("lastfm", "userid") + entry, err := buffer.Next(ctx, "lastfm", "userid") Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) Expect(entry.MediaFile.ID).To(Equal(next.ID)) }) It("recomputes the average rating after merging another user's annotation", func() { - other := NewMediaFileRepository(request.WithUser(log.NewContext(context.TODO()), model.User{ID: "2222"}), GetDBXBuilder()) - Expect(mr.SetRating(5, next.ID)).To(Succeed()) - Expect(other.SetRating(3, prev.ID)).To(Succeed()) + otherCtx := request.WithUser(ctx, model.User{ID: "2222"}) + other := NewMediaFileRepository(GetDBXBuilder()) + Expect(mr.SetRating(ctx, 5, next.ID)).To(Succeed()) + Expect(other.SetRating(otherCtx, 3, prev.ID)).To(Succeed()) - Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - got, err := mr.Get(next.ID) + got, err := mr.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.AverageRating).To(Equal(4.0)) }) It("keeps the new id's own annotation and bookmark when both exist", func() { - Expect(mr.SetRating(5, prev.ID)).To(Succeed()) - Expect(mr.SetRating(1, next.ID)).To(Succeed()) - Expect(mr.AddBookmark(prev.ID, "prev", 42)).To(Succeed()) - Expect(mr.AddBookmark(next.ID, "next", 7)).To(Succeed()) + Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) + Expect(mr.SetRating(ctx, 1, next.ID)).To(Succeed()) + Expect(mr.AddBookmark(ctx, prev.ID, "prev", 42)).To(Succeed()) + Expect(mr.AddBookmark(ctx, next.ID, "next", 7)).To(Succeed()) - Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - got, err := mr.Get(next.ID) + got, err := mr.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Rating).To(Equal(1)) - bookmarks, err := mr.GetBookmarks() + bookmarks, err := mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) }) @@ -1034,39 +1032,39 @@ var _ = Describe("MediaRepository", func() { {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, } for _, mf := range testFiles { - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) } }) AfterEach(func() { for _, mf := range testFiles { - _ = mr.Delete(mf.ID) + _ = mr.Delete(ctx, mf.ID) } }) It("treats a path whose prefix is not a library id as unqualified", func() { - results, err := mr.FindByPaths([]string{"Bach: Goldberg Variations/01.mp3"}) + results, err := mr.FindByPaths(ctx, []string{"Bach: Goldberg Variations/01.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-5")) }) It("finds a plain path whose colon prefix looks like a library id", func() { - results, err := mr.FindByPaths([]string{"1999: A Different Life/01.mp3"}) + results, err := mr.FindByPaths(ctx, []string{"1999: A Different Life/01.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-6")) }) It("splits only the first colon of a library-qualified path", func() { - results, err := mr.FindByPaths([]string{"1:Bach: Goldberg Variations/01.mp3"}) + results, err := mr.FindByPaths(ctx, []string{"1:Bach: Goldberg Variations/01.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-5")) }) It("finds files by exact path", func() { - results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) + results, err := mr.FindByPaths(ctx, []string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-1")) @@ -1074,7 +1072,7 @@ var _ = Describe("MediaRepository", func() { It("finds files case-insensitively for ASCII characters (NOCASE)", func() { // SQLite's COLLATE NOCASE handles ASCII case-insensitivity - results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"}) + results, err := mr.FindByPaths(ctx, []string{"1:ARTIST/ALBUM/TRACK.MP3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-1")) @@ -1083,20 +1081,20 @@ var _ = Describe("MediaRepository", func() { It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() { // SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence // The DB has fullwidth uppercase ACROSS, searching with exact match should work - results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"}) + results, err := mr.FindByPaths(ctx, []string{"1:plex/02 - ACROSS.flac"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-3")) // Searching with fullwidth lowercase across should NOT match // (this is the SQLite limitation that requires exact matching for non-ASCII) - results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"}) + results, err = mr.FindByPaths(ctx, []string{"1:plex/02 - across.flac"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("returns multiple files when querying multiple paths", func() { - results, err := mr.FindByPaths([]string{ + results, err := mr.FindByPaths(ctx, []string{ "1:artist/Album/track.mp3", "1:artist/Album/UPPER.mp3", }) @@ -1105,25 +1103,25 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty slice for non-existent paths", func() { - results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"}) + results, err := mr.FindByPaths(ctx, []string{"1:nonexistent/path.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("returns empty slice for empty input", func() { - results, err := mr.FindByPaths([]string{}) + results, err := mr.FindByPaths(ctx, []string{}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("handles library-qualified paths correctly", func() { // Library 1 should find the file - results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) + results, err := mr.FindByPaths(ctx, []string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) // Library 2 should NOT find it (file is in library 1) - results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"}) + results, err = mr.FindByPaths(ctx, []string{"2:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -1134,54 +1132,57 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { adminCtx := request.WithUser(GinkgoT().Context(), adminUser) - lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + lr := NewLibraryRepository(GetDBXBuilder()) // A second library the restricted user has no access to otherLib = model.Library{ID: 0, Name: "Other Library", Path: "/other/lib"} - Expect(lr.Put(&otherLib)).To(Succeed()) + Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) // A track that lives only in the other library (created as admin) - adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) - Expect(adminMr.Put(&model.MediaFile{ + adminMr := NewMediaFileRepository(GetDBXBuilder()) + Expect(adminMr.Put(adminCtx, &model.MediaFile{ ID: "otherlib-track", LibraryID: otherLib.ID, Path: "hidden/test.mp3", Title: "Hidden", })).To(Succeed()) // Non-admin user with access to library 1 ONLY restrictedUser = createUserWithLibraries("restricted-finder", []int{1}) - ur := NewUserRepository(adminCtx, GetDBXBuilder()) - Expect(ur.Put(&restrictedUser)).To(Succeed()) - Expect(ur.SetUserLibraries(restrictedUser.ID, []int{1})).To(Succeed()) + ur := NewUserRepository(GetDBXBuilder()) + Expect(ur.Put(adminCtx, &restrictedUser)).To(Succeed()) + Expect(ur.SetUserLibraries(adminCtx, restrictedUser.ID, []int{1})).To(Succeed()) }) AfterEach(func() { adminCtx := request.WithUser(GinkgoT().Context(), adminUser) - _ = NewMediaFileRepository(adminCtx, GetDBXBuilder()).Delete("otherlib-track") - lr := NewLibraryRepository(adminCtx, GetDBXBuilder()).(*libraryRepository) - _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(adminCtx, GetDBXBuilder()).Delete(restrictedUser.ID) + _ = NewMediaFileRepository(GetDBXBuilder()).Delete(adminCtx, "otherlib-track") + lr := NewLibraryRepository(GetDBXBuilder()).(*libraryRepository) + _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(GetDBXBuilder()).Delete(adminCtx, restrictedUser.ID) }) It("does not resolve paths in libraries the user cannot access", func() { - userMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), restrictedUser), GetDBXBuilder()) + userCtx := request.WithUser(ctx, restrictedUser) + userMr := NewMediaFileRepository(GetDBXBuilder()) qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) - results, err := userMr.FindByPaths([]string{qualified}) + results, err := userMr.FindByPaths(userCtx, []string{qualified}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "a track outside the user's libraries must not be resolvable") }) It("still resolves the path for an admin", func() { - adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + adminCtx := request.WithUser(ctx, adminUser) + adminMr := NewMediaFileRepository(GetDBXBuilder()) qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) - results, err := adminMr.FindByPaths([]string{qualified}) + results, err := adminMr.FindByPaths(adminCtx, []string{qualified}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("otherlib-track")) }) It("resolves paths from multiple libraries in a single call", func() { - adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) - results, err := adminMr.FindByPaths([]string{ + adminCtx := request.WithUser(ctx, adminUser) + adminMr := NewMediaFileRepository(GetDBXBuilder()) + results, err := adminMr.FindByPaths(adminCtx, []string{ "1:artist/Album/track.mp3", fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), }) @@ -1191,9 +1192,10 @@ var _ = Describe("MediaRepository", func() { }) It("keeps each path scoped to its own library when several are queried", func() { - adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + adminCtx := request.WithUser(ctx, adminUser) + adminMr := NewMediaFileRepository(GetDBXBuilder()) // Each path exists, but under the other library's ID, so neither must match. - results, err := adminMr.FindByPaths([]string{ + results, err := adminMr.FindByPaths(adminCtx, []string{ fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), "1:hidden/test.mp3", }) @@ -1254,9 +1256,9 @@ var _ = Describe("MediaRepository", func() { It("stores nil BPM and BitDepth as NULL and retrieves them as nil", func() { newID := id.NewRandom() mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-nil.mp3"} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) - retrieved, err := mr.Get(newID) + retrieved, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.BPM).To(BeNil()) Expect(retrieved.BitDepth).To(BeNil()) @@ -1274,7 +1276,7 @@ var _ = Describe("MediaRepository", func() { Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database") Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database") - _ = mr.Delete(newID) + _ = mr.Delete(ctx, newID) }) It("stores non-nil BPM and BitDepth and retrieves correct values", func() { @@ -1282,16 +1284,16 @@ var _ = Describe("MediaRepository", func() { bpm := 120 bitDepth := 24 mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth} - Expect(mr.Put(&mf)).To(Succeed()) + Expect(mr.Put(ctx, &mf)).To(Succeed()) - retrieved, err := mr.Get(newID) + retrieved, err := mr.Get(ctx, newID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.BPM).ToNot(BeNil()) Expect(*retrieved.BPM).To(Equal(120)) Expect(retrieved.BitDepth).ToNot(BeNil()) Expect(*retrieved.BitDepth).To(Equal(24)) - _ = mr.Delete(newID) + _ = mr.Delete(ctx, newID) }) }) @@ -1326,34 +1328,34 @@ var _ = Describe("MediaRepository", func() { restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"} rctx := request.WithUser(GinkgoT().Context(), restricted) - Expect(mr.Exists(songAntenna.ID)).To(BeTrue(), "admin sees it") - Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse()) + Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue(), "admin sees it") + Expect(NewMediaFileRepository(GetDBXBuilder()).Exists(rctx, songAntenna.ID)).To(BeFalse()) }) }) Describe("MatchesCriteria", func() { It("returns true when the track matches", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}} - match, err := mr.MatchesCriteria(songDayInALife.ID, c) + match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).ToNot(HaveOccurred()) Expect(match).To(BeTrue()) }) It("returns false when the track does not match", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}} - match, err := mr.MatchesCriteria(songDayInALife.ID, c) + match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).ToNot(HaveOccurred()) Expect(match).To(BeFalse()) }) It("treats missing annotations as their COALESCE default", func() { // unrated track: rating coalesces to 0, so "rating < 4" matches c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}} - match, err := mr.MatchesCriteria(songDayInALife.ID, c) + match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).ToNot(HaveOccurred()) Expect(match).To(BeTrue()) }) It("returns an error for an invalid field", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}} - _, err := mr.MatchesCriteria(songDayInALife.ID, c) + _, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) Expect(err).To(HaveOccurred()) }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 589812266..44e944bff 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -4,7 +4,7 @@ import ( "context" "database/sql" "fmt" - "reflect" + "sync" "time" "github.com/navidrome/navidrome/db" @@ -15,128 +15,144 @@ import ( ) type SQLStore struct { - db dbx.Builder + db dbx.Builder + library func() model.LibraryRepository + folder func() model.FolderRepository + album func() model.AlbumRepository + artist func() model.ArtistRepository + mediaFile func() model.MediaFileRepository + genre func() model.GenreRepository + tag func() model.TagRepository + playlist func() model.PlaylistRepository + playQueue func() model.PlayQueueRepository + transcoding func() model.TranscodingRepository + player func() model.PlayerRepository + radio func() model.RadioRepository + share func() model.ShareRepository + property func() model.PropertyRepository + user func() model.UserRepository + userProps func() model.UserPropsRepository + scrobbleBuf func() model.ScrobbleBufferRepository + scrobble func() model.ScrobbleRepository + plugin func() model.PluginRepository + artwork func() model.ArtworkRepository + artworkQueue func() model.ArtworkQueueRepository +} + +// Repositories are built on first use, so a transaction store only pays for the ones its block touches. +func newSQLStore(db dbx.Builder) *SQLStore { + return &SQLStore{ + db: db, + library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }), + folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }), + album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }), + artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }), + mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }), + genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }), + tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }), + playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }), + playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }), + transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }), + player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }), + radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }), + share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }), + property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }), + user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }), + userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }), + scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }), + scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }), + plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }), + artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }), + artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }), + } } func New(conn *sql.DB) model.DataStore { - return &SQLStore{db: dbx.NewFromDB(conn, db.Driver)} + return newSQLStore(dbx.NewFromDB(conn, db.Driver)) } -func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository { - return NewAlbumRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Album() model.AlbumRepository { + return s.album() } -func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository { - return NewArtistRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Artist() model.ArtistRepository { + return s.artist() } -func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository { - return NewMediaFileRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) MediaFile() model.MediaFileRepository { + return s.mediaFile() } -func (s *SQLStore) Library(ctx context.Context) model.LibraryRepository { - return NewLibraryRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Library() model.LibraryRepository { + return s.library() } -func (s *SQLStore) Folder(ctx context.Context) model.FolderRepository { - return newFolderRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Folder() model.FolderRepository { + return s.folder() } -func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository { - return NewGenreRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Genre() model.GenreRepository { + return s.genre() } -func (s *SQLStore) Tag(ctx context.Context) model.TagRepository { - return NewTagRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Tag() model.TagRepository { + return s.tag() } -func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { - return NewPlayQueueRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) PlayQueue() model.PlayQueueRepository { + return s.playQueue() } -func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository { - return NewPlaylistRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Playlist() model.PlaylistRepository { + return s.playlist() } -func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository { - return NewPropertyRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Property() model.PropertyRepository { + return s.property() } -func (s *SQLStore) Radio(ctx context.Context) model.RadioRepository { - return NewRadioRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Radio() model.RadioRepository { + return s.radio() } -func (s *SQLStore) UserProps(ctx context.Context) model.UserPropsRepository { - return NewUserPropsRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) UserProps() model.UserPropsRepository { + return s.userProps() } -func (s *SQLStore) Share(ctx context.Context) model.ShareRepository { - return NewShareRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Share() model.ShareRepository { + return s.share() } -func (s *SQLStore) User(ctx context.Context) model.UserRepository { - return NewUserRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) User() model.UserRepository { + return s.user() } -func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository { - return NewTranscodingRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Transcoding() model.TranscodingRepository { + return s.transcoding() } -func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository { - return NewPlayerRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Player() model.PlayerRepository { + return s.player() } -func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { - return NewScrobbleBufferRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) ScrobbleBuffer() model.ScrobbleBufferRepository { + return s.scrobbleBuf() } -func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository { - return NewScrobbleRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Scrobble() model.ScrobbleRepository { + return s.scrobble() } -func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { - return NewPluginRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Plugin() model.PluginRepository { + return s.plugin() } -func (s *SQLStore) Artwork(ctx context.Context) model.ArtworkRepository { - return NewArtworkRepository(ctx, s.getDBXBuilder()) +func (s *SQLStore) Artwork() model.ArtworkRepository { + return s.artwork() } -func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { - return NewArtworkQueueRepository(ctx, s.getDBXBuilder()) -} - -func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { - switch m.(type) { - case model.User: - return s.User(ctx).(model.ResourceRepository) - case model.Transcoding: - return s.Transcoding(ctx).(model.ResourceRepository) - case model.Player: - return s.Player(ctx).(model.ResourceRepository) - case model.Artist: - return s.Artist(ctx).(model.ResourceRepository) - case model.Album: - return s.Album(ctx).(model.ResourceRepository) - case model.MediaFile: - return s.MediaFile(ctx).(model.ResourceRepository) - case model.Genre: - return s.Genre(ctx).(model.ResourceRepository) - case model.Playlist: - return s.Playlist(ctx).(model.ResourceRepository) - case model.Radio: - return s.Radio(ctx).(model.ResourceRepository) - case model.Share: - return s.Share(ctx).(model.ResourceRepository) - case model.Tag: - return s.Tag(ctx).(model.ResourceRepository) - case model.Plugin: - return s.Plugin(ctx).(model.ResourceRepository) - case model.Scrobble: - return s.Scrobble(ctx).(model.ResourceRepository) - } - log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) - return nil +func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository { + return s.artworkQueue() } func scopeLabel(scope []string) string { @@ -157,7 +173,7 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) log.Trace("Transaction started", "scope", msg) } return conn.Transactional(func(tx *dbx.Tx) error { - newDb := &SQLStore{db: tx} + newDb := newSQLStore(tx) err := block(newDb) if !inTx { log.Trace("Nested Transaction finished", "scope", msg, "elapsed", time.Since(start), err) @@ -173,9 +189,9 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope . return s.WithTx(func(tx model.DataStore) error { // Workaround to force the transaction to be upgraded to immediate mode to avoid deadlocks // See https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/ - _ = tx.Property(ctx).Put("tmp_lock_flag", "") + _ = tx.Property().Put(ctx, "tmp_lock_flag", "") defer func() { - _ = tx.Property(ctx).Delete("tmp_lock_flag") + _ = tx.Property().Delete(ctx, "tmp_lock_flag") }() return block(tx) @@ -244,27 +260,20 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { } err := run.Sequentially( - trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }), - trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }), - trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }), - trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }), - trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), - trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), - trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), - trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }), - trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }), - trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }), - trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), + trace(ctx, "purge empty albums", func() error { return s.album().(*albumRepository).purgeEmpty(ctx, libraryIDs...) }), + trace(ctx, "purge empty artists", func() error { return s.artist().(*artistRepository).purgeEmpty(ctx) }), + trace(ctx, "mark missing artists", func() error { return s.artist().(*artistRepository).markMissing(ctx) }), + trace(ctx, "purge empty folders", func() error { return s.folder().(*folderRepository).purgeEmpty(ctx, libraryIDs...) }), + trace(ctx, "clean album annotations", func() error { return s.album().(*albumRepository).cleanAnnotations(ctx) }), + trace(ctx, "clean artist annotations", func() error { return s.artist().(*artistRepository).cleanAnnotations(ctx) }), + trace(ctx, "clean media file annotations", func() error { return s.mediaFile().(*mediaFileRepository).cleanAnnotations(ctx) }), + trace(ctx, "clean playlist annotations", func() error { return s.playlist().(*playlistRepository).cleanAnnotations(ctx) }), + trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }), + trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), + trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), ) if err != nil { return fmt.Errorf("tidying up database: %w", err) } return nil } - -func (s *SQLStore) getDBXBuilder() dbx.Builder { - if s.db == nil { - return dbx.NewFromDB(db.Db(), db.Driver) - } - return s.db -} diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 644284c0b..ee2794454 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -176,17 +176,17 @@ func restrictedFixture(name string) (context.Context, model.Library, model.User) db := GetDBXBuilder() lib := model.Library{Name: name + " Library", Path: "/" + name} - lr := NewLibraryRepository(adminCtx, db) - Expect(lr.Put(&lib)).To(Succeed()) + lr := NewLibraryRepository(db) + Expect(lr.Put(adminCtx, &lib)).To(Succeed()) user := createUserWithLibraries(name+"-restricted", []int{1}) - ur := NewUserRepository(adminCtx, db) - Expect(ur.Put(&user)).To(Succeed()) - Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed()) + ur := NewUserRepository(db) + Expect(ur.Put(adminCtx, &user)).To(Succeed()) + Expect(ur.SetUserLibraries(adminCtx, user.ID, []int{1})).To(Succeed()) DeferCleanup(func() { - _ = NewUserRepository(adminCtx, db).Delete(user.ID) - _ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID}) + _ = NewUserRepository(db).Delete(adminCtx, user.ID) + _ = NewLibraryRepository(db).(*libraryRepository).delete(adminCtx, squirrel.Eq{"id": lib.ID}) }) return adminCtx, lib, user } @@ -196,9 +196,9 @@ var _ = BeforeSuite(func() { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, adminUser) - ur := NewUserRepository(ctx, conn) + ur := NewUserRepository(conn) for i := range testUsers { - err := ur.Put(&testUsers[i]) + err := ur.Put(ctx, &testUsers[i]) if err != nil { panic(err) } @@ -206,32 +206,32 @@ var _ = BeforeSuite(func() { // Associate users with library 1 (default test library) for i := range testUsers { - err := ur.SetUserLibraries(testUsers[i].ID, []int{1}) + err := ur.SetUserLibraries(ctx, testUsers[i].ID, []int{1}) if err != nil { panic(err) } } - alr := NewAlbumRepository(ctx, conn).(*albumRepository) + alr := NewAlbumRepository(conn).(*albumRepository) for i := range testAlbums { - err := alr.Put(new(testAlbums[i])) + err := alr.Put(ctx, new(testAlbums[i])) if err != nil { panic(err) } } - arr := NewArtistRepository(ctx, conn) + arr := NewArtistRepository(conn) for i := range testArtists { - err := arr.Put(new(testArtists[i])) + err := arr.Put(ctx, new(testArtists[i])) if err != nil { panic(err) } } // Associate artists with library 1 (default test library) - lr := NewLibraryRepository(ctx, conn) + lr := NewLibraryRepository(conn) for i := range testArtists { - err := lr.AddArtist(1, testArtists[i].ID) + err := lr.AddArtist(ctx, 1, testArtists[i].ID) if err != nil { panic(err) } @@ -247,7 +247,7 @@ var _ = BeforeSuite(func() { if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] { continue } - _, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{ + _, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{ "album_id": a.ID, "artist_id": a.AlbumArtistID, "role": "artist", @@ -258,17 +258,17 @@ var _ = BeforeSuite(func() { } } - mr := NewMediaFileRepository(ctx, conn) + mr := NewMediaFileRepository(conn) for i := range testSongs { - err := mr.Put(&testSongs[i]) + err := mr.Put(ctx, &testSongs[i]) if err != nil { panic(err) } } - rar := NewRadioRepository(ctx, conn) + rar := NewRadioRepository(conn) for i := range testRadios { - err := rar.Put(new(testRadios[i])) + err := rar.Put(ctx, new(testRadios[i])) if err != nil { panic(err) } @@ -287,19 +287,19 @@ var _ = BeforeSuite(func() { plsCool.AddMediaFilesByID([]string{"1004"}) testPlaylists = []*model.Playlist{&plsBest, &plsCool} - pr := NewPlaylistRepository(ctx, conn) + pr := NewPlaylistRepository(conn) for i := range testPlaylists { - err := pr.Put(testPlaylists[i]) + err := pr.Put(ctx, testPlaylists[i]) if err != nil { panic(err) } } // Prepare annotations - if err := arr.SetStar(true, artistBeatles.ID); err != nil { + if err := arr.SetStar(ctx, true, artistBeatles.ID); err != nil { panic(err) } - ar, err := arr.Get(artistBeatles.ID) + ar, err := arr.Get(ctx, artistBeatles.ID) if err != nil { panic(err) } @@ -310,10 +310,10 @@ var _ = BeforeSuite(func() { artistBeatles.StarredAt = ar.StarredAt testArtists[1] = artistBeatles - if err := alr.SetStar(true, albumRadioactivity.ID); err != nil { + if err := alr.SetStar(ctx, true, albumRadioactivity.ID); err != nil { panic(err) } - al, err := alr.Get(albumRadioactivity.ID) + al, err := alr.Get(ctx, albumRadioactivity.ID) if err != nil { panic(err) } @@ -324,10 +324,10 @@ var _ = BeforeSuite(func() { albumRadioactivity.StarredAt = al.StarredAt testAlbums[2] = albumRadioactivity - if err := mr.SetStar(true, songComeTogether.ID); err != nil { + if err := mr.SetStar(ctx, true, songComeTogether.ID); err != nil { panic(err) } - mf, err := mr.Get(songComeTogether.ID) + mf, err := mr.Get(ctx, songComeTogether.ID) if err != nil { panic(err) } @@ -335,9 +335,9 @@ var _ = BeforeSuite(func() { songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether - scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository) + scrobbleRepo := NewScrobbleRepository(conn).(*scrobbleRepository) for _, s := range scrobbles { - _, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{ + _, err := scrobbleRepo.executeSQL(ctx, squirrel.Insert("scrobbles").SetMap(map[string]any{ "media_file_id": s.MediaFileID, "user_id": s.UserID, "submission_time": s.SubmissionTime, diff --git a/persistence/persistence_test.go b/persistence/persistence_test.go index e13f6a231..43d2e81ed 100644 --- a/persistence/persistence_test.go +++ b/persistence/persistence_test.go @@ -23,37 +23,37 @@ var _ = Describe("SQLStore", func() { Context("When block returns nil", func() { It("commits changes to the DB", func() { err := ds.WithTx(func(tx model.DataStore) error { - pl := tx.Player(ctx) - err := pl.Put(&model.Player{ID: "666", UserId: "userid"}) + pl := tx.Player() + err := pl.Put(ctx, &model.Player{ID: "666", UserId: "userid"}) Expect(err).ToNot(HaveOccurred()) - pr := tx.Property(ctx) - err = pr.Put("777", "value") + pr := tx.Property() + err = pr.Put(ctx, "777", "value") Expect(err).ToNot(HaveOccurred()) return nil }) Expect(err).ToNot(HaveOccurred()) - Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"})) - Expect(ds.Property(ctx).Get("777")).To(Equal("value")) + Expect(ds.Player().Get(ctx, "666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"})) + Expect(ds.Property().Get(ctx, "777")).To(Equal("value")) }) }) Context("When block returns an error", func() { It("rollbacks changes to the DB", func() { err := ds.WithTx(func(tx model.DataStore) error { - pr := tx.Property(ctx) - err := pr.Put("999", "value") + pr := tx.Property() + err := pr.Put(ctx, "999", "value") Expect(err).ToNot(HaveOccurred()) // Will fail as it is missing the UserName - pl := tx.Player(ctx) - err = pl.Put(&model.Player{ID: "888"}) + pl := tx.Player() + err = pl.Put(ctx, &model.Player{ID: "888"}) Expect(err).To(HaveOccurred()) return err }) Expect(err).To(HaveOccurred()) - _, err = ds.Property(ctx).Get("999") + _, err = ds.Property().Get(ctx, "999") Expect(err).To(MatchError(model.ErrNotFound)) - _, err = ds.Player(ctx).Get("888") + _, err = ds.Player().Get(ctx, "888") Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -70,7 +70,7 @@ var _ = Describe("SQLStore", func() { var attempts []bool err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { attempts = append(attempts, hasBusyRetry(ctx)) - Expect(tx.Property(ctx).Put("retry-key", "attempt")).To(Succeed()) + Expect(tx.Property().Put(ctx, "retry-key", "attempt")).To(Succeed()) if len(attempts) < 3 { return busy } @@ -78,7 +78,7 @@ var _ = Describe("SQLStore", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(attempts).To(Equal([]bool{true, true, true})) - Expect(ds.Property(ctx).Get("retry-key")).To(Equal("attempt")) + Expect(ds.Property().Get(ctx, "retry-key")).To(Equal("attempt")) }) It("gives up after the last retry, which is not marked as retried", func() { @@ -116,15 +116,15 @@ var _ = Describe("SQLStore", func() { It("joins the enclosing transaction instead of opening another", func() { rollback := errors.New("rollback") err := ds.WithTx(func(tx model.DataStore) error { - Expect(tx.Property(ctx).Put("outer-key", "v")).To(Succeed()) + Expect(tx.Property().Put(ctx, "outer-key", "v")).To(Succeed()) Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error { - Expect(inner.Property(ctx).Get("outer-key")).To(Equal("v")) - return inner.Property(ctx).Put("inner-key", "v") + Expect(inner.Property().Get(ctx, "outer-key")).To(Equal("v")) + return inner.Property().Put(ctx, "inner-key", "v") })).To(Succeed()) return rollback }) Expect(err).To(MatchError(rollback)) - _, err = ds.Property(ctx).Get("inner-key") + _, err = ds.Property().Get(ctx, "inner-key") Expect(err).To(MatchError(model.ErrNotFound)) }) }) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index a2114b060..e46a8d82d 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -13,9 +13,8 @@ type playerRepository struct { sqlRepository } -func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository { +func NewPlayerRepository(db dbx.Builder) model.PlayerRepository { r := &playerRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Player{}, map[string]filterFunc{ "name": containsFilter("player.name"), @@ -26,43 +25,43 @@ func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerReposi return r } -func (r *playerRepository) Put(p *model.Player) error { - _, err := r.put(p.ID, p) +func (r *playerRepository) Put(ctx context.Context, p *model.Player) error { + _, err := r.put(ctx, p.ID, p) return err } -func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...). +func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + return r.newSelect(ctx, options...). Columns("player.*"). Join("user ON player.user_id = user.id"). Columns("user.user_name username") } -func (r *playerRepository) Get(id string) (*model.Player, error) { - sel := r.selectPlayer().Where(Eq{"player.id": id}) +func (r *playerRepository) Get(ctx context.Context, id string) (*model.Player, error) { + sel := r.selectPlayer(ctx).Where(Eq{"player.id": id}) var res model.Player - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) { - sel := r.selectPlayer().Where(And{ +func (r *playerRepository) FindMatch(ctx context.Context, userId, client, userAgent string) (*model.Player, error) { + sel := r.selectPlayer(ctx).Where(And{ Eq{"client": client}, Eq{"user_agent": userAgent}, Eq{"user_id": userId}, }) var res model.Player - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder { - s := r.selectPlayer(options...) - return s.Where(r.addRestriction()) +func (r *playerRepository) newRestSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + s := r.selectPlayer(ctx, options...) + return s.Where(r.addRestriction(ctx)) } -func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { - sel := r.newSelect(options...). +func (r *playerRepository) CountByClient(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) { + sel := r.newSelect(ctx, options...). Columns( "case when client = 'NavidromeUI' then name else client end as player", "count(*) as count", @@ -71,7 +70,7 @@ func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[str Player string Count int64 } - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } @@ -82,63 +81,54 @@ func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[str return counts, nil } -func (r *playerRepository) CountAll(options ...model.QueryOptions) (int64, error) { - return r.count(r.newRestSelect(), options...) +func (r *playerRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + return r.count(ctx, r.newRestSelect(ctx), options...) } -func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *playerRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *playerRepository) Read(id string) (any, error) { - sel := r.newRestSelect().Where(Eq{"player.id": id}) +func (r *playerRepository) Read(ctx context.Context, id string) (*model.Player, error) { + sel := r.newRestSelect(ctx).Where(Eq{"player.id": id}) var res model.Player - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...)) +func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Player, error) { + sel := r.newRestSelect(ctx, r.parseRestOptions(ctx, options...)) res := model.Players{} - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) return res, err } -func (r *playerRepository) EntityName() string { - return "player" -} - -func (r *playerRepository) NewInstance() any { - return &model.Player{} -} - // isPermitted authorizes creating a new record, based on the owner declared in the request body. // This is only safe for inserts: there is no stored row yet, and a non-admin may only create a // player they own. Updates must not use this (the body owner is attacker-controlled); they go // through updateOwned, which authorizes against the persisted user_id in the WHERE clause. -func (r *playerRepository) isPermitted(p *model.Player) bool { - u := loggedUser(r.ctx) +func (r *playerRepository) isPermitted(ctx context.Context, p *model.Player) bool { + u := loggedUser(ctx) return u.IsAdmin || p.UserId == u.ID } -func (r *playerRepository) Save(entity any) (string, error) { - t := entity.(*model.Player) - if !r.isPermitted(t) { +func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) { + if !r.isPermitted(ctx, t) { return "", rest.ErrPermissionDenied } - return r.put("", t) // Save only creates; edits go through the owner-scoped Update + return r.put(ctx, "", t) // Save only creates; edits go through the owner-scoped Update } -func (r *playerRepository) Update(id string, entity any, cols ...string) error { - t := entity.(*model.Player) +func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error { + t := &entity t.ID = id - return r.updateOwned(id, t, cols...) + return r.updateOwned(ctx, id, t, cols...) } -func (r *playerRepository) Delete(id string) error { - return r.deleteOwned(id) +func (r *playerRepository) Delete(ctx context.Context, ids ...string) error { + return r.deleteOwnedAll(ctx, ids...) } var _ model.PlayerRepository = (*playerRepository)(nil) -var _ rest.Repository = (*playerRepository)(nil) -var _ rest.Persistable = (*playerRepository)(nil) +var _ rest.Repository[model.Player] = (*playerRepository)(nil) +var _ rest.Persistable[model.Player] = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index 4a7701ac2..f12f3e74e 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -15,6 +15,7 @@ import ( var _ = Describe("PlayerRepository", func() { var adminRepo *playerRepository var database *dbx.DB + var ctx context.Context var ( adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} @@ -25,77 +26,69 @@ var _ = Describe("PlayerRepository", func() { ) BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) database = GetDBXBuilder() - adminRepo = NewPlayerRepository(ctx, database).(*playerRepository) + adminRepo = NewPlayerRepository(database).(*playerRepository) for idx := range players { - err := adminRepo.Put(&players[idx]) + err := adminRepo.Put(ctx, &players[idx]) Expect(err).To(BeNil()) } }) AfterEach(func() { - items, err := adminRepo.ReadAll() + players, err := adminRepo.ReadAll(ctx) Expect(err).To(BeNil()) - players, ok := items.(model.Players) - Expect(ok).To(BeTrue()) for i := range players { - err = adminRepo.Delete(players[i].ID) + err = adminRepo.Delete(ctx, players[i].ID) Expect(err).To(BeNil()) } }) - Describe("EntityName", func() { - It("returns the right name", func() { - Expect(adminRepo.EntityName()).To(Equal("player")) - }) - }) - Describe("FindMatch", func() { It("finds existing match", func() { - player, err := adminRepo.FindMatch(adminUser.ID, "NavidromeUI", "Firefox/Linux") + player, err := adminRepo.FindMatch(ctx, adminUser.ID, "NavidromeUI", "Firefox/Linux") Expect(err).To(BeNil()) Expect(*player).To(Equal(adminPlayer1)) }) It("doesn't find bad match", func() { - _, err := adminRepo.FindMatch(regularUser.ID, "NavidromeUI", "Firefox/Linux") + _, err := adminRepo.FindMatch(ctx, regularUser.ID, "NavidromeUI", "Firefox/Linux") Expect(err).To(Equal(model.ErrNotFound)) }) }) Describe("Get", func() { It("Gets an existing item from user", func() { - player, err := adminRepo.Get(adminPlayer1.ID) + player, err := adminRepo.Get(ctx, adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*player).To(Equal(adminPlayer1)) }) It("Gets an existing item from another user", func() { - player, err := adminRepo.Get(regularPlayer.ID) + player, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(*player).To(Equal(regularPlayer)) }) It("does not get nonexistent item", func() { - _, err := adminRepo.Get("i don't exist") + _, err := adminRepo.Get(ctx, "i don't exist") Expect(err).To(Equal(model.ErrNotFound)) }) }) DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) { var repo *playerRepository + var repoCtx context.Context BeforeEach(func() { + repoCtx = ctx if admin { repo = adminRepo } else { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, regularUser) - repo = NewPlayerRepository(ctx, database).(*playerRepository) + repoCtx = request.WithUser(ctx, regularUser) + repo = NewPlayerRepository(database).(*playerRepository) } }) @@ -103,7 +96,7 @@ var _ = Describe("PlayerRepository", func() { Describe("Count", func() { It("should return all", func() { - count, err := repo.Count() + count, err := repo.Count(repoCtx) Expect(err).To(BeNil()) Expect(count).To(Equal(baseCount)) }) @@ -111,53 +104,53 @@ var _ = Describe("PlayerRepository", func() { Describe("Delete", func() { It("deletes a player owned by the current user", func() { - err := repo.Delete(userPlayer.ID) + err := repo.Delete(repoCtx, userPlayer.ID) Expect(err).To(BeNil()) - count, err := repo.Count() + count, err := repo.Count(repoCtx) Expect(err).To(BeNil()) Expect(count).To(Equal(baseCount - 1)) - _, err = repo.Get(userPlayer.ID) + _, err = repo.Get(repoCtx, userPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) }) It("does not delete another user's player when not admin", func() { - err := repo.Delete(otherPlayer.ID) + err := repo.Delete(repoCtx, otherPlayer.ID) if admin { // Admins may delete any player. Expect(err).To(BeNil()) - Expect(repo.Count()).To(Equal(baseCount - 1)) - _, err = repo.Get(otherPlayer.ID) + Expect(repo.Count(repoCtx)).To(Equal(baseCount - 1)) + _, err = repo.Get(repoCtx, otherPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) } else { // The ownership-restricted delete matches no owned row, so it reports // permission-denied and leaves the other user's player untouched. Expect(err).To(Equal(rest.ErrPermissionDenied)) - Expect(repo.Count()).To(Equal(baseCount)) - item, err := repo.Get(otherPlayer.ID) + Expect(repo.Count(repoCtx)).To(Equal(baseCount)) + item, err := repo.Get(repoCtx, otherPlayer.ID) Expect(err).To(BeNil()) Expect(*item).To(Equal(otherPlayer)) } }) It("returns not-found for a nonexistent player", func() { - err := repo.Delete("i don't exist") + err := repo.Delete(repoCtx, "i don't exist") Expect(err).To(Equal(rest.ErrNotFound)) - Expect(repo.Count()).To(Equal(baseCount)) + Expect(repo.Count(repoCtx)).To(Equal(baseCount)) }) }) Describe("Read", func() { It("can read from current user", func() { - player, err := repo.Read(userPlayer.ID) + player, err := repo.Read(repoCtx, userPlayer.ID) Expect(err).To(BeNil()) Expect(player).To(Equal(&userPlayer)) }) It("can read from other user or fail if not admin", func() { - player, err := repo.Read(otherPlayer.ID) + player, err := repo.Read(repoCtx, otherPlayer.ID) if admin { Expect(err).To(BeNil()) Expect(player).To(Equal(&otherPlayer)) @@ -167,16 +160,16 @@ var _ = Describe("PlayerRepository", func() { }) It("does not get nonexistent item", func() { - _, err := repo.Read("i don't exist") + _, err := repo.Read(repoCtx, "i don't exist") Expect(err).To(Equal(model.ErrNotFound)) }) }) Describe("ReadAll", func() { It("should get all items", func() { - data, err := repo.ReadAll() + data, err := repo.ReadAll(repoCtx) Expect(err).To(BeNil()) - Expect(data).To(Equal(players)) + Expect(model.Players(data)).To(Equal(players)) }) }) @@ -185,7 +178,7 @@ var _ = Describe("PlayerRepository", func() { clone := player clone.ID = "" clone.IP = "192.168.1.1" - id, err := repo.Save(&clone) + id, err := repo.Save(repoCtx, &clone) if clone.UserId == "" { Expect(err).To(HaveOccurred()) @@ -197,11 +190,11 @@ var _ = Describe("PlayerRepository", func() { Expect(id).ToNot(BeEmpty()) } - count, err := repo.Count() + count, err := repo.Count(repoCtx) Expect(err).To(BeNil()) clone.ID = id - newItem, err := repo.Get(id) + newItem, err := repo.Get(repoCtx, id) if clone.UserId == "" { Expect(count).To(Equal(baseCount)) @@ -223,7 +216,7 @@ var _ = Describe("PlayerRepository", func() { clone := player clone.IP = "192.168.1.1" clone.MaxBitRate = 10000 - err := repo.Update(clone.ID, &clone, "ip") + err := repo.Update(repoCtx, clone.ID, clone, "ip") if player.UserId == "" { Expect(err).To(HaveOccurred()) @@ -238,7 +231,7 @@ var _ = Describe("PlayerRepository", func() { } clone.MaxBitRate = player.MaxBitRate - newItem, err := repo.Get(clone.ID) + newItem, err := repo.Get(repoCtx, clone.ID) if player.UserId == "" { Expect(err).To(Equal(model.ErrNotFound)) @@ -260,11 +253,11 @@ var _ = Describe("PlayerRepository", func() { Describe("Ownership enforcement (cross-tenant write protection)", func() { var regularRepo *playerRepository + var regularCtx context.Context BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, regularUser) - regularRepo = NewPlayerRepository(ctx, database).(*playerRepository) + regularCtx = request.WithUser(ctx, regularUser) + regularRepo = NewPlayerRepository(database).(*playerRepository) }) It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { @@ -279,11 +272,11 @@ var _ = Describe("PlayerRepository", func() { // The ownership-restricted update matches no row owned by the attacker, so the write // targets nothing and reports permission-denied rather than overwriting the victim's row. - err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate") + err := regularRepo.Update(regularCtx, adminPlayer1.ID, spoofed, "name", "user_id", "max_bit_rate") Expect(err).To(Equal(rest.ErrPermissionDenied)) // The victim's player must remain untouched. - stored, err := adminRepo.Get(adminPlayer1.ID) + stored, err := adminRepo.Get(ctx, adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*stored).To(Equal(adminPlayer1)) }) @@ -296,15 +289,15 @@ var _ = Describe("PlayerRepository", func() { ReportRealPath: true, } - id, err := regularRepo.Save(&spoofed) + id, err := regularRepo.Save(regularCtx, &spoofed) Expect(err).To(BeNil()) Expect(id).ToNot(Equal(adminPlayer1.ID)) - stored, err := adminRepo.Get(adminPlayer1.ID) + stored, err := adminRepo.Get(ctx, adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*stored).To(Equal(adminPlayer1)) - created, err := adminRepo.Get(id) + created, err := adminRepo.Get(ctx, id) Expect(err).To(BeNil()) Expect(created.UserId).To(Equal(regularUser.ID)) }) @@ -316,11 +309,11 @@ var _ = Describe("PlayerRepository", func() { reassign.UserId = adminUser.ID reassign.Name = "given-away" - err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + err := regularRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id") Expect(err).To(BeNil()) // Ownership must not have changed. - stored, err := adminRepo.Get(regularPlayer.ID) + stored, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(stored.UserId).To(Equal(regularUser.ID)) }) @@ -331,11 +324,11 @@ var _ = Describe("PlayerRepository", func() { reassign.UserId = adminUser.ID reassign.Name = "admin-renamed" - err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + err := adminRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id") Expect(err).To(BeNil()) // The name change applies, but ownership must not have moved. - stored, err := adminRepo.Get(regularPlayer.ID) + stored, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(stored.Name).To(Equal("admin-renamed")) Expect(stored.UserId).To(Equal(regularUser.ID)) @@ -345,10 +338,10 @@ var _ = Describe("PlayerRepository", func() { update := regularPlayer update.Name = "renamed-by-owner" - err := regularRepo.Update(regularPlayer.ID, &update, "name") + err := regularRepo.Update(regularCtx, regularPlayer.ID, update, "name") Expect(err).To(BeNil()) - stored, err := adminRepo.Get(regularPlayer.ID) + stored, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(stored.Name).To(Equal("renamed-by-owner")) Expect(stored.UserId).To(Equal(regularUser.ID)) @@ -356,7 +349,7 @@ var _ = Describe("PlayerRepository", func() { It("returns not found when updating a nonexistent player", func() { ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} - err := regularRepo.Update("does-not-exist", &ghost, "name") + err := regularRepo.Update(regularCtx, "does-not-exist", ghost, "name") Expect(err).To(Equal(rest.ErrNotFound)) }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 2afef9f45..41b75266c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -50,9 +50,8 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error { return nil } -func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRepository { +func NewPlaylistRepository(db dbx.Builder) model.PlaylistRepository { r := &playlistRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Playlist{}, map[string]filterFunc{ "id": idFilter("playlist"), @@ -81,8 +80,8 @@ func smartPlaylistFilter(string, any) Sqlizer { } } -func (r *playlistRepository) userFilter() Sqlizer { - user := loggedUser(r.ctx) +func (r *playlistRepository) userFilter(ctx context.Context) Sqlizer { + user := loggedUser(ctx) if user.IsAdmin { return And{} } @@ -92,29 +91,29 @@ func (r *playlistRepository) userFilter() Sqlizer { } } -func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { - query := Select().Where(r.userFilter()) +func (r *playlistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + query := Select().Where(r.userFilter(ctx)) if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(query, "playlist.id") + query = r.withAnnotation(ctx, query, "playlist.id") } - return r.count(query, options...) + return r.count(ctx, query, options...) } -func (r *playlistRepository) Exists(id string) (bool, error) { - return r.exists(And{Eq{"id": id}, r.userFilter()}) +func (r *playlistRepository) Exists(ctx context.Context, id string) (bool, error) { + return r.exists(ctx, And{Eq{"id": id}, r.userFilter(ctx)}) } -func (r *playlistRepository) Delete(id string) error { - return r.delete(And{Eq{"id": id}, r.userFilter()}) +func (r *playlistRepository) Delete(ctx context.Context, ids ...string) error { + return r.delete(ctx, And{Eq{"id": ids}, r.userFilter(ctx)}) } -func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { +func (r *playlistRepository) Put(ctx context.Context, p *model.Playlist, cols ...string) error { pls := dbPlaylist{Playlist: *p} if len(cols) > 0 { if pls.ID == "" { return errors.New("playlist id is required for partial update") } - _, err := r.put(pls.ID, pls, cols...) + _, err := r.put(ctx, pls.ID, pls, cols...) return err } isNew := pls.ID == "" @@ -123,7 +122,7 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { } pls.UpdatedAt = time.Now() - id, err := r.put(pls.ID, pls) + id, err := r.put(ctx, pls.ID, pls) if err != nil { return err } @@ -135,48 +134,48 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { } // Only update tracks if they were specified if len(pls.Tracks) > 0 { - return r.updateTracks(id, p.MediaFiles()) + return r.updateTracks(ctx, id, p.MediaFiles()) } pls.ID = id // r.put assigns the generated id to p, not to this copy if isNew { // Even a trackless new playlist has art to find (an imported m3u can carry an // ExternalImageURL); an update landing here changed only metadata, so leave its cover be. - r.enqueueCoverRebuild(id) + r.enqueueCoverRebuild(ctx, id) } - return r.refreshCounters(&pls.Playlist) + return r.refreshCounters(ctx, &pls.Playlist) } -func (r *playlistRepository) Get(id string) (*model.Playlist, error) { - return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()}) +func (r *playlistRepository) Get(ctx context.Context, id string) (*model.Playlist, error) { + return r.findBy(ctx, And{Eq{"playlist.id": id}, r.userFilter(ctx)}) } -func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) { - pls, err := r.Get(id) +func (r *playlistRepository) GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) { + pls, err := r.Get(ctx, id) if err != nil { return nil, err } if refreshSmartPlaylist { - r.refreshSmartPlaylist(pls) + r.refreshSmartPlaylist(ctx, pls) } - tracks, err := r.loadTracks(Select().From("playlist_tracks"). + tracks, err := r.loadTracks(ctx, Select().From("playlist_tracks"). Where(Eq{"missing": false}). OrderBy("playlist_tracks.id"), id) if err != nil { - log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) + log.Error(ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) return nil, err } pls.SetTracks(tracks) return pls, nil } -func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { - return r.findBy(Eq{"path": path}) +func (r *playlistRepository) FindByPath(ctx context.Context, path string) (*model.Playlist, error) { + return r.findBy(ctx, Eq{"path": path}) } -func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { - sel := r.selectPlaylist().Where(sql) +func (r *playlistRepository) findBy(ctx context.Context, sql Sqlizer) (*model.Playlist, error) { + sel := r.selectPlaylist(ctx).Where(sql) var pls []dbPlaylist - err := r.queryAll(sel, &pls) + err := r.queryAll(ctx, sel, &pls) if err != nil { return nil, err } @@ -185,19 +184,19 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { } list := model.Playlists{pls[0].Playlist} - r.hydrateArtwork(list) + r.hydrateArtwork(ctx, list) return &list[0], nil } -func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) { - hydrateItems(r.ctx, r.db, model.KindPlaylistArtwork, playlists, +func (r *playlistRepository) hydrateArtwork(ctx context.Context, playlists model.Playlists) { + hydrateItems(ctx, r.db, model.KindPlaylistArtwork, playlists, func(p *model.Playlist) (string, *model.ItemImage) { return p.ID, &p.ItemImage }) } -func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { - sel := r.selectPlaylist(options...).Where(r.userFilter()) +func (r *playlistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { + sel := r.selectPlaylist(ctx, options...).Where(r.userFilter(ctx)) var res []dbPlaylist - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } @@ -205,42 +204,42 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli for i, p := range res { playlists[i] = p.Playlist } - r.hydrateArtwork(playlists) + r.hydrateArtwork(ctx, playlists) return playlists, err } // getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. -func (r *playlistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { +func (r *playlistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY // on the playlist's own ambiguous. - sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name"). - Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter()) + sq := r.newSelect(ctx, options...).Columns("playlist.id", "user.user_name as owner_name"). + Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter(ctx)) if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(sq, "playlist.id") + sq = r.withAnnotation(ctx, sq, "playlist.id") } ids := []string{} - err := r.queryAllSlice(sq, &ids) + err := r.queryAllSlice(ctx, sq, &ids) return ids, err } -func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { +func (r *playlistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistCursor, error) { // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.getAllIDs(options...) + ids, err := r.getAllIDs(ctx, options...) if err != nil { return nil, err } opts := chunkOptions(options, "playlist.id") return model.PlaylistCursor(streamByIDs(ids, func(chunk []string) (model.Playlists, error) { - return r.GetAll(opts(chunk)) + return r.GetAll(ctx, opts(chunk)) })), nil } -func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { - sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). +func (r *playlistRepository) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { + sel := r.selectPlaylist(ctx, model.QueryOptions{Sort: "name"}). Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). - Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()}) + Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter(ctx)}) var res []dbPlaylist - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.Playlists{}, nil @@ -251,40 +250,40 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, for i, p := range res { playlists[i] = p.Playlist } - r.hydrateArtwork(playlists) + r.hydrateArtwork(ctx, playlists) return playlists, nil } -func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { - sel := r.newSelect(options...).Join("user on user.id = owner_id"). +func (r *playlistRepository) selectPlaylist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + sel := r.newSelect(ctx, options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") - return r.withAnnotation(sel, r.tableName+".id") + return r.withAnnotation(ctx, sel, r.tableName+".id") } -func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { +func (r *playlistRepository) updateTracks(ctx context.Context, id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { ids[i] = tracks[i].ID } - return r.updatePlaylist(id, ids) + return r.updatePlaylist(ctx, id, ids) } -func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error { +func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId string, mediaFileIds []string) error { // Remove old tracks del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId}) - _, err := r.executeSQL(del) + _, err := r.executeSQL(ctx, del) if err != nil { return err } - _, err = r.addTracks(playlistId, 1, mediaFileIds) + _, err = r.addTracks(ctx, playlistId, 1, mediaFileIds) return err } // addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns // the library check: every caller, including a full replace through Put, goes through it. -func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) { - mediaFileIds, err := r.keepAccessible(mediaFileIds) +func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) { + mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds) if err != nil { return 0, err } @@ -297,26 +296,26 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media ins = ins.Values(playlistId, t, pos) pos++ } - if _, err := r.executeSQL(ins); err != nil { + if _, err := r.executeSQL(ctx, ins); err != nil { return 0, err } } - r.enqueueCoverRebuild(playlistId) - return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId}) + r.enqueueCoverRebuild(ctx, playlistId) + return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId}) } // keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked // because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. -func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) { - if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { +func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) { + if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { return mediaFileIds, nil } accessible := make(map[string]struct{}, len(mediaFileIds)) for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { - sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") + sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") var found []string - if err := r.queryAllSlice(sq, &found); err != nil { + if err := r.queryAllSlice(ctx, sq, &found); err != nil { return nil, err } for _, id := range found { @@ -330,7 +329,7 @@ func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, er } // refreshCounters updates total playlist duration, size and count -func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { +func (r *playlistRepository) refreshCounters(ctx context.Context, pls *model.Playlist) error { statsSql := Select( "coalesce(sum(duration), 0) as duration", "coalesce(sum(size), 0) as size", @@ -340,7 +339,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { Join("playlist_tracks f on f.media_file_id = media_file.id"). Where(Eq{"playlist_id": pls.ID}) var res struct{ Duration, Size, Count float32 } - err := r.queryOne(statsSql, &res) + err := r.queryOne(ctx, statsSql, &res) if err != nil { return err } @@ -353,7 +352,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { Set("song_count", res.Count). Set("updated_at", now). Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(upd) + _, err = r.executeSQL(ctx, upd) if err != nil { return err } @@ -366,18 +365,18 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { // enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes: // the grid samples albums at random, so rebuilding after a mere rename would change the cover. -func (r *playlistRepository) enqueueCoverRebuild(id string) { +func (r *playlistRepository) enqueueCoverRebuild(ctx context.Context, id string) { item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} - if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { - log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", id, err) + if err := NewArtworkQueueRepository(r.db).Enqueue(ctx, item); err != nil { + log.Warn(ctx, "could not enqueue playlist artwork after content change", "id", id, err) } } // tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. -func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder { - query = r.applyLibraryFilter(query, "f") - userID := loggedUser(r.ctx).ID +func (r *playlistRepository) tracksQuery(ctx context.Context, query SelectBuilder, id string) SelectBuilder { + query = r.applyLibraryFilter(ctx, query, "f") + userID := loggedUser(ctx).ID return query. Columns( "coalesce(starred, 0) as starred", @@ -400,56 +399,47 @@ func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectB Where(Eq{"playlist_id": id}) } -func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) { +func (r *playlistRepository) loadTracks(ctx context.Context, query SelectBuilder, id string) (model.PlaylistTracks, error) { tracks := dbPlaylistTracks{} - err := r.queryAll(r.tracksQuery(query, id), &tracks) + err := r.queryAll(ctx, r.tracksQuery(ctx, query, id), &tracks) if err != nil { return nil, err } res := tracks.toModels() - hydratePlaylistTrackArtwork(r.ctx, r.db, res) + hydratePlaylistTrackArtwork(ctx, r.db, res) return res, err } -func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *playlistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *playlistRepository) Read(id string) (any, error) { - return r.Get(id) +func (r *playlistRepository) Read(ctx context.Context, id string) (*model.Playlist, error) { + return r.Get(ctx, id) } -func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) +func (r *playlistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Playlist, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *playlistRepository) EntityName() string { - return "playlist" -} - -func (r *playlistRepository) NewInstance() any { - return &model.Playlist{} -} - -func (r *playlistRepository) Save(entity any) (string, error) { - pls := entity.(*model.Playlist) +func (r *playlistRepository) Save(ctx context.Context, pls *model.Playlist) (string, error) { pls.ID = "" // Force new creation - err := r.Put(pls) + err := r.Put(ctx, pls) if err != nil { return "", err } return pls.ID, err } -func (r *playlistRepository) Update(id string, entity any, cols ...string) error { - pls := dbPlaylist{Playlist: *entity.(*model.Playlist)} +func (r *playlistRepository) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error { + pls := dbPlaylist{Playlist: entity} pls.ID = id pls.UpdatedAt = time.Now() - _, err := r.put(id, pls, append(cols, "updatedAt")...) + _, err := r.put(ctx, id, pls, append(cols, "updatedAt")...) return err } -func (r *playlistRepository) removeOrphans() error { +func (r *playlistRepository) removeOrphans(ctx context.Context) error { sel := Select("playlist_tracks.playlist_id as id", "p.name").From("playlist_tracks"). Join("playlist p on playlist_tracks.playlist_id = p.id"). LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id"). @@ -457,25 +447,25 @@ func (r *playlistRepository) removeOrphans() error { GroupBy("playlist_tracks.playlist_id") var pls []struct{ Id, Name string } - err := r.queryAll(sel, &pls) + err := r.queryAll(ctx, sel, &pls) if err != nil { return fmt.Errorf("fetching playlists with orphan tracks: %w", err) } for _, pl := range pls { - log.Debug(r.ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name) + log.Debug(ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name) del := Delete("playlist_tracks").Where(And{ ConcatExpr("media_file_id not in (select id from media_file)"), Eq{"playlist_id": pl.Id}, }) - n, err := r.executeSQL(del) + n, err := r.executeSQL(ctx, del) if n == 0 || err != nil { return fmt.Errorf("deleting orphan tracks from playlist %s: %w", pl.Name, err) } - log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n) + log.Debug(ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n) // Renumber the playlist if any track was removed - if err := r.renumber(pl.Id); err != nil { + if err := r.renumber(ctx, pl.Id); err != nil { return fmt.Errorf("renumbering playlist %s: %w", pl.Name, err) } } @@ -485,9 +475,9 @@ func (r *playlistRepository) removeOrphans() error { // renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their // current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering. // The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id). -func (r *playlistRepository) renumber(id string) error { +func (r *playlistRepository) renumber(ctx context.Context, id string) error { // Step 1: Negate all IDs to clear the positive ID space - _, err := r.executeSQL(Expr( + _, err := r.executeSQL(ctx, Expr( `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id)) if err != nil { return err @@ -495,7 +485,7 @@ func (r *playlistRepository) renumber(id string) error { // Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE. // The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues. // ORDER BY id DESC restores original order since IDs are now negative. - _, err = r.executeSQL(Expr( + _, err = r.executeSQL(ctx, Expr( `WITH new_ids AS ( SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id FROM playlist_tracks WHERE playlist_id = ? @@ -506,10 +496,10 @@ func (r *playlistRepository) renumber(id string) error { if err != nil { return err } - r.enqueueCoverRebuild(id) - return r.refreshCounters(&model.Playlist{ID: id}) + r.enqueueCoverRebuild(ctx, id) + return r.refreshCounters(ctx, &model.Playlist{ID: id}) } var _ model.PlaylistRepository = (*playlistRepository)(nil) -var _ rest.Repository = (*playlistRepository)(nil) -var _ rest.Persistable = (*playlistRepository)(nil) +var _ rest.Repository[model.Playlist] = (*playlistRepository)(nil) +var _ rest.Persistable[model.Playlist] = (*playlistRepository)(nil) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 60263807a..93d37c928 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "slices" "github.com/Masterminds/squirrel" @@ -19,11 +20,11 @@ import ( var _ = Describe("PlaylistRepository", func() { var repo model.PlaylistRepository + var ctx context.Context BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(GetDBXBuilder()) }) Describe("natural sorting", func() { @@ -34,23 +35,23 @@ var _ = Describe("PlaylistRepository", func() { conf.Server.EnableNaturalSorting = true ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + repo = NewPlaylistRepository(GetDBXBuilder()) ids = nil for _, n := range []string{"mix 1", "mix 10", "mix 2"} { pls := model.Playlist{Name: n, OwnerID: "userid"} - Expect(repo.Put(&pls)).To(Succeed()) + Expect(repo.Put(ctx, &pls)).To(Succeed()) ids = append(ids, pls.ID) } DeferCleanup(func() { for _, id := range ids { - _ = repo.Delete(id) + _ = repo.Delete(ctx, id) } }) }) It("sorts playlist names by number value", func() { - all, err := repo.GetAll(model.QueryOptions{ + all, err := repo.GetAll(ctx, model.QueryOptions{ Sort: "name", Filters: squirrel.Eq{"playlist.id": ids}, }) Expect(err).ToNot(HaveOccurred()) @@ -61,25 +62,25 @@ var _ = Describe("PlaylistRepository", func() { Describe("Count", func() { It("returns the number of playlists in the DB", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll(ctx)).To(Equal(int64(2))) }) }) Describe("GetCursor", func() { It("yields the same playlists as GetAll", func() { opts := model.QueryOptions{Sort: "name"} - want, err := repo.GetAll(opts) + want, err := repo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want))) + Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Playlist(want))) }) }) Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { - want, err := repo.GetAll() + want, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.(*playlistRepository).getAllIDs() + ids, err := repo.(*playlistRepository).getAllIDs(ctx) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) }) @@ -87,16 +88,16 @@ var _ = Describe("PlaylistRepository", func() { Describe("Exists", func() { It("returns true for an existing playlist", func() { - Expect(repo.Exists(plsCool.ID)).To(BeTrue()) + Expect(repo.Exists(ctx, plsCool.ID)).To(BeTrue()) }) It("returns false for a non-existing playlist", func() { - Expect(repo.Exists("666")).To(BeFalse()) + Expect(repo.Exists(ctx, "666")).To(BeFalse()) }) }) Describe("Get", func() { It("returns an existing playlist", func() { - p, err := repo.Get(plsBest.ID) + p, err := repo.Get(ctx, plsBest.ID) Expect(err).To(BeNil()) // Compare all but Tracks and timestamps p2 := *p @@ -110,11 +111,11 @@ var _ = Describe("PlaylistRepository", func() { } }) It("returns ErrNotFound for a non-existing playlist", func() { - _, err := repo.Get("666") + _, err := repo.Get(ctx, "666") Expect(err).To(MatchError(model.ErrNotFound)) }) It("returns all tracks", func() { - pls, err := repo.GetWithTracks(plsBest.ID, true, false) + pls, err := repo.GetWithTracks(ctx, plsBest.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal(plsBest.Name)) Expect(pls.Tracks).To(HaveLen(2)) @@ -138,7 +139,7 @@ var _ = Describe("PlaylistRepository", func() { BeforeEach(func() { pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} - Expect(repo.Put(&pls)).To(Succeed()) + Expect(repo.Put(ctx, &pls)).To(Succeed()) plsID = pls.ID }) @@ -151,18 +152,18 @@ var _ = Describe("PlaylistRepository", func() { } It("stores and reads back starred", func() { - Expect(repo.SetStar(true, plsID)).To(Succeed()) + Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - p, err := repo.Get(plsID) + p, err := repo.Get(ctx, plsID) Expect(err).ToNot(HaveOccurred()) Expect(p.Starred).To(BeTrue()) Expect(p.StarredAt).ToNot(BeNil()) }) It("stores and reads back rating and average_rating", func() { - Expect(repo.SetRating(4, plsID)).To(Succeed()) + Expect(repo.SetRating(ctx, 4, plsID)).To(Succeed()) - p, err := repo.Get(plsID) + p, err := repo.Get(ctx, plsID) Expect(err).ToNot(HaveOccurred()) Expect(p.Rating).To(Equal(4)) Expect(p.RatedAt).ToNot(BeNil()) @@ -170,21 +171,21 @@ var _ = Describe("PlaylistRepository", func() { }) It("keeps annotations isolated per user", func() { - Expect(repo.SetStar(true, plsID)).To(Succeed()) + Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) - otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + otherRepo := NewPlaylistRepository(GetDBXBuilder()) - p, err := otherRepo.Get(plsID) + p, err := otherRepo.Get(otherCtx, plsID) Expect(err).ToNot(HaveOccurred()) Expect(p.Starred).To(BeFalse()) }) It("reads starred back through GetAll", func() { - Expect(repo.SetStar(true, plsID)).To(Succeed()) + Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) Expect(idx).To(BeNumerically(">=", 0)) @@ -192,44 +193,43 @@ var _ = Describe("PlaylistRepository", func() { }) It("counts playlists using annotation filters", func() { - Expect(repo.SetStar(true, plsID)).To(Succeed()) + Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} - starred, err := repo.GetAll(options) + starred, err := repo.GetAll(ctx, options) Expect(err).ToNot(HaveOccurred()) Expect(starred).To(ContainElement(HaveField("ID", plsID))) - count, err := repo.CountAll(options) + count, err := repo.CountAll(ctx, options) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(len(starred)))) }) It("filters starred playlists through the registered REST filter", func() { - Expect(repo.SetStar(true, plsID)).To(Succeed()) + Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := repo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - starred := res.(model.Playlists) - Expect(starred).To(ContainElement(HaveField("ID", plsID))) - for _, p := range starred { + Expect(res).To(ContainElement(HaveField("ID", plsID))) + for _, p := range res { Expect(p.Starred).To(BeTrue()) } - res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err = repo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID))) + Expect(res).ToNot(ContainElement(HaveField("ID", plsID))) }) It("reads a playlist by id through the REST id filter without ambiguity", func() { - res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + res, err := repo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"id": plsID}, }) Expect(err).ToNot(HaveOccurred()) - Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID))) + Expect(res).To(ContainElement(HaveField("ID", plsID))) }) It("does not leak an annotation row of another item_type sharing the playlist id", func() { @@ -240,11 +240,11 @@ var _ = Describe("PlaylistRepository", func() { Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() Expect(err).ToNot(HaveOccurred()) - p, err := repo.Get(plsID) + p, err := repo.Get(ctx, plsID) Expect(err).ToNot(HaveOccurred()) Expect(p.Starred).To(BeFalse()) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) matches := 0 for _, pl := range all { @@ -256,12 +256,12 @@ var _ = Describe("PlaylistRepository", func() { }) It("relies on the annotation sweep, not Delete, to clean up annotations", func() { - Expect(repo.SetStar(true, plsID)).To(Succeed()) + Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - Expect(repo.Delete(plsID)).To(Succeed()) + Expect(repo.Delete(ctx, plsID)).To(Succeed()) Expect(countAnnotations()).To(Equal(1)) - Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed()) + Expect(repo.(*playlistRepository).cleanAnnotations(ctx)).To(Succeed()) Expect(countAnnotations()).To(Equal(0)) }) }) @@ -271,8 +271,8 @@ var _ = Describe("PlaylistRepository", func() { pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.All{criteria.Contains{"title": "love"}}, }} - Expect(repo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { Expect(repo.Delete(pls.ID)).To(Succeed()) }) + Expect(repo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) }) // Simulate a previous evaluation having stored the counters _, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}"). @@ -282,9 +282,9 @@ var _ = Describe("PlaylistRepository", func() { pls.SongCount = 0 pls.Duration = 0 pls.Size = 0 - Expect(repo.Put(&pls)).To(Succeed()) + Expect(repo.Put(ctx, &pls)).To(Succeed()) - saved, err := repo.Get(pls.ID) + saved, err := repo.Get(ctx, pls.ID) Expect(err).ToNot(HaveOccurred()) Expect(saved.SongCount).To(Equal(42)) Expect(saved.Duration).To(Equal(float32(123))) @@ -298,35 +298,35 @@ var _ = Describe("PlaylistRepository", func() { newPls.AddMediaFilesByID([]string{"1004", "1003"}) By("saves the playlist to the DB") - Expect(repo.Put(&newPls)).To(BeNil()) + Expect(repo.Put(ctx, &newPls)).To(BeNil()) By("adds repeated songs to a playlist and keeps the order") newPls.AddMediaFilesByID([]string{"1004"}) - Expect(repo.Put(&newPls)).To(BeNil()) - saved, _ := repo.GetWithTracks(newPls.ID, true, false) + Expect(repo.Put(ctx, &newPls)).To(BeNil()) + saved, _ := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(saved.Tracks).To(HaveLen(3)) Expect(saved.Tracks[0].MediaFileID).To(Equal("1004")) Expect(saved.Tracks[1].MediaFileID).To(Equal("1003")) Expect(saved.Tracks[2].MediaFileID).To(Equal("1004")) By("returns the newly created playlist") - Expect(repo.Exists(newPls.ID)).To(BeTrue()) + Expect(repo.Exists(ctx, newPls.ID)).To(BeTrue()) By("returns deletes the playlist") - Expect(repo.Delete(newPls.ID)).To(BeNil()) + Expect(repo.Delete(ctx, newPls.ID)).To(BeNil()) By("returns error if tries to retrieve the deleted playlist") - Expect(repo.Exists(newPls.ID)).To(BeFalse()) + Expect(repo.Exists(ctx, newPls.ID)).To(BeFalse()) }) It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) Expect(newPls.ID).ToNot(BeEmpty()) - DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + queued, err := NewArtworkQueueRepository(GetDBXBuilder()).DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") @@ -336,23 +336,23 @@ var _ = Describe("PlaylistRepository", func() { It("does not enqueue artwork when only metadata changes", func() { ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) newPls := model.Playlist{Name: "Rename Me", OwnerID: "userid"} - Expect(repo.Put(&newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) // Clear the row creation just enqueued, so anything present afterwards came from the update. - queueRepo := NewArtworkQueueRepository(ctx, GetDBXBuilder()) - queued, err := queueRepo.DequeueBatch(1000) + queueRepo := NewArtworkQueueRepository(GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) for _, q := range queued { if q.ItemID == newPls.ID { - Expect(queueRepo.DeleteIfUnchanged(q.ItemKind, q.ItemID, q.ImageType, q.RetryAt)).To(Succeed()) + Expect(queueRepo.DeleteIfUnchanged(ctx, q.ItemKind, q.ItemID, q.ImageType, q.RetryAt)).To(Succeed()) } } newPls.Name = "Renamed" newPls.Comment = "edited" - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) - queued, err = queueRepo.DequeueBatch(1000) + queued, err = queueRepo.DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(queued).ToNot(ContainElement(HaveField("ItemID", newPls.ID))) }) @@ -361,10 +361,10 @@ var _ = Describe("PlaylistRepository", func() { ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002"}) - Expect(repo.Put(&newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + queued, err := NewArtworkQueueRepository(GetDBXBuilder()).DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "pl"), @@ -374,7 +374,7 @@ var _ = Describe("PlaylistRepository", func() { Describe("GetAll", func() { It("returns all playlists from DB", func() { - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(plsBest.ID)) Expect(all[1].ID).To(Equal(plsCool.ID)) @@ -383,14 +383,14 @@ var _ = Describe("PlaylistRepository", func() { Describe("GetPlaylists", func() { It("returns playlists for a track", func() { - pls, err := repo.GetPlaylists(songRadioactivity.ID) + pls, err := repo.GetPlaylists(ctx, songRadioactivity.ID) Expect(err).ToNot(HaveOccurred()) Expect(pls).To(HaveLen(1)) Expect(pls[0].ID).To(Equal(plsBest.ID)) }) It("returns empty when none", func() { - pls, err := repo.GetPlaylists("9999") + pls, err := repo.GetPlaylists(ctx, "9999") Expect(err).ToNot(HaveOccurred()) Expect(pls).To(HaveLen(0)) }) @@ -401,14 +401,14 @@ var _ = Describe("PlaylistRepository", func() { AfterEach(func() { if testPlaylistID != "" { - Expect(repo.Delete(testPlaylistID)).To(BeNil()) + Expect(repo.Delete(ctx, testPlaylistID)).To(BeNil()) testPlaylistID = "" } }) // helper to get track positions and media file IDs getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) { - pls, err := repo.GetWithTracks(playlistID, false, false) + pls, err := repo.GetWithTracks(ctx, playlistID, false, false) Expect(err).ToNot(HaveOccurred()) for _, t := range pls.Tracks { ids = append(ids, t.ID) @@ -421,12 +421,12 @@ var _ = Describe("PlaylistRepository", func() { By("creating a playlist with 4 tracks") newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"}) - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("deleting the second track (position 2)") - tracksRepo := repo.Tracks(newPls.ID, false) - Expect(tracksRepo.Delete("2")).To(Succeed()) + tracksRepo := repo.Tracks(ctx, newPls.ID, false) + Expect(tracksRepo.Delete(ctx, "2")).To(Succeed()) By("verifying remaining tracks are renumbered sequentially") ids, mediaFileIDs := getTrackInfo(newPls.ID) @@ -438,12 +438,12 @@ var _ = Describe("PlaylistRepository", func() { By("creating a playlist with 3 tracks") newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("deleting the first track (position 1)") - tracksRepo := repo.Tracks(newPls.ID, false) - Expect(tracksRepo.Delete("1")).To(Succeed()) + tracksRepo := repo.Tracks(ctx, newPls.ID, false) + Expect(tracksRepo.Delete(ctx, "1")).To(Succeed()) By("verifying remaining tracks are renumbered sequentially") ids, mediaFileIDs := getTrackInfo(newPls.ID) @@ -455,12 +455,12 @@ var _ = Describe("PlaylistRepository", func() { By("creating a playlist with 3 tracks") newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("deleting the last track (position 3)") - tracksRepo := repo.Tracks(newPls.ID, false) - Expect(tracksRepo.Delete("3")).To(Succeed()) + tracksRepo := repo.Tracks(ctx, newPls.ID, false) + Expect(tracksRepo.Delete(ctx, "3")).To(Succeed()) By("verifying remaining tracks are renumbered sequentially") ids, mediaFileIDs := getTrackInfo(newPls.ID) @@ -476,18 +476,18 @@ var _ = Describe("PlaylistRepository", func() { // "userid" is the fixture user; playlist.owner_id has a FK to user(id). owner := model.User{ID: "userid", UserName: "userid"} octx := request.WithUser(GinkgoT().Context(), owner) - ownerRepo := NewPlaylistRepository(octx, GetDBXBuilder()) + ownerRepo := NewPlaylistRepository(GetDBXBuilder()) pls := model.Playlist{Name: "Private One", OwnerID: owner.ID, Public: false} - Expect(ownerRepo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { _ = ownerRepo.Delete(pls.ID) }) + Expect(ownerRepo.Put(octx, &pls)).To(Succeed()) + DeferCleanup(func() { _ = ownerRepo.Delete(octx, pls.ID) }) - Expect(ownerRepo.Exists(pls.ID)).To(BeTrue(), "the owner sees it") + Expect(ownerRepo.Exists(octx, pls.ID)).To(BeTrue(), "the owner sees it") - anon := NewPlaylistRepository(GinkgoT().Context(), GetDBXBuilder()) - Expect(anon.Exists(pls.ID)).To(BeFalse(), "no user: userFilter hides it") + anon := NewPlaylistRepository(GetDBXBuilder()) + Expect(anon.Exists(GinkgoT().Context(), pls.ID)).To(BeFalse(), "no user: userFilter hides it") admin := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", IsAdmin: true}) - Expect(NewPlaylistRepository(admin, GetDBXBuilder()).Exists(pls.ID)).To(BeTrue(), + Expect(NewPlaylistRepository(GetDBXBuilder()).Exists(admin, pls.ID)).To(BeTrue(), "elevating is what the public image route relies on") }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 848b67be0..341aab8af 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "database/sql" "slices" @@ -40,11 +41,10 @@ func (t dbPlaylistTracks) toModels() model.PlaylistTracks { }) } -func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { +func (r *playlistRepository) Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { p := &playlistTrackRepository{} p.playlistRepo = r p.playlistId = playlistId - p.ctx = r.ctx p.db = r.db p.tableName = "playlist_tracks" p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{ @@ -67,37 +67,37 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool }, "f") // TODO I don't like this solution, but I won't change it now as it's not the focus of BFR. - pls, err := r.Get(playlistId) + pls, err := r.Get(ctx, playlistId) if err != nil { - log.Warn(r.ctx, "Error getting playlist's tracks", "playlistId", playlistId, err) + log.Warn(ctx, "Error getting playlist's tracks", "playlistId", playlistId, err) return nil } if refreshSmartPlaylist { - r.refreshSmartPlaylist(pls) + r.refreshSmartPlaylist(ctx, pls) } p.playlist = pls return p } -func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) { +func (r *playlistTrackRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { query := Select(). Join("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(query, "f") - return r.count(query, options...) + query = r.applyLibraryFilter(ctx, query, "f") + return r.count(ctx, query, options...) } -func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { +func (r *playlistTrackRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { query := Select(). LeftJoin("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(query, "f") - return r.count(query, r.parseRestOptions(r.ctx, options...)) + query = r.applyLibraryFilter(ctx, query, "f") + return r.count(ctx, query, r.parseRestOptions(ctx, options...)) } -func (r *playlistTrackRepository) Read(id string) (any, error) { - userID := loggedUser(r.ctx).ID - sel := r.newSelect(). +func (r *playlistTrackRepository) Read(ctx context.Context, id string) (*model.PlaylistTrack, error) { + userID := loggedUser(ctx).ID + sel := r.newSelect(ctx). LeftJoin("annotation on ("+ "annotation.item_id = media_file_id"+ " AND annotation.item_type = 'media_file'"+ @@ -114,23 +114,23 @@ func (r *playlistTrackRepository) Read(id string) (any, error) { ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) - sel = r.applyLibraryFilter(sel, "f") + sel = r.applyLibraryFilter(ctx, sel, "f") var trk dbPlaylistTrack - err := r.queryOne(sel, &trk) + err := r.queryOne(ctx, sel, &trk) return trk.PlaylistTrack, err } -func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { - tracks, err := r.playlistRepo.loadTracks(r.newSelect(options...), r.playlistId) +func (r *playlistTrackRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) { + tracks, err := r.playlistRepo.loadTracks(ctx, r.newSelect(ctx, options...), r.playlistId) if err != nil { return nil, err } return tracks, err } -func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { - sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId) - cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel) +func (r *playlistTrackRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + sel := r.playlistRepo.tracksQuery(ctx, r.newSelect(ctx, options...), r.playlistId) + cursor, err := queryWithStableResults[dbPlaylistTrack](ctx, r.sqlRepository, sel) if err != nil { return nil, err } @@ -138,114 +138,106 @@ func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (mode return t.PlaylistTrack }) return model.PlaylistTrackCursor(hydrateCursor(tracks, func(batch []model.PlaylistTrack) { - hydratePlaylistTrackArtwork(r.ctx, r.db, batch) + hydratePlaylistTrackArtwork(ctx, r.db, batch) })), nil } // GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. -func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(options...).Columns("media_file_id"). +func (r *playlistTrackRepository) GetMediaFileIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(ctx, options...).Columns("media_file_id"). Join("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(query, "f") + query = r.applyLibraryFilter(ctx, query, "f") var ids []string - if err := r.queryAllSlice(query, &ids); err != nil { + if err := r.queryAllSlice(ctx, query, &ids); err != nil { return nil, err } return ids, nil } -func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(options...).Columns("distinct mf.album_id"). +func (r *playlistTrackRepository) GetAlbumIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(ctx, options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(query, "mf") + query = r.applyLibraryFilter(ctx, query, "mf") var ids []string - err := r.queryAllSlice(query, &ids) + err := r.queryAllSlice(ctx, query, &ids) if err != nil { return nil, err } return ids, nil } -func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) +func (r *playlistTrackRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.PlaylistTrack, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *playlistTrackRepository) EntityName() string { - return "playlist_tracks" -} - -func (r *playlistTrackRepository) NewInstance() any { - return &model.PlaylistTrack{} -} - -func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { +func (r *playlistTrackRepository) Add(ctx context.Context, mediaFileIds []string) (int, error) { if len(mediaFileIds) > 0 { - log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) + log.Debug(ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) } else { return 0, nil } // Get next pos (ID) in playlist - sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) + sq := r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) var res struct{ Max sql.NullInt32 } - if err := r.queryOne(sq, &res); err != nil { + if err := r.queryOne(ctx, sq, &res); err != nil { return 0, err } - return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) + return r.playlistRepo.addTracks(ctx, r.playlistId, int(res.Max.Int32+1), mediaFileIds) } // Insert adds tracks before the 1-based position pos, shifting the following entries down; a // position past the end appends. Callers must run it in a transaction. -func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) { +func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error) { if len(mediaFileIds) == 0 { return 0, nil } pos = max(pos, 1) n := len(mediaFileIds) // Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index. - _, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) + _, err := r.executeSQL(ctx, Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) if err != nil { return 0, err } - res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) + res, err := r.executeSQL(ctx, Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) if err != nil { return 0, err } if res == 0 { - return r.Add(mediaFileIds) + return r.Add(ctx, mediaFileIds) } - inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) + inserted, err := r.playlistRepo.addTracks(ctx, r.playlistId, pos, mediaFileIds) if err != nil || inserted == n { return inserted, err } // The shift above reserved a slot per requested id, so ids dropped by the library filter // leave a hole. Close it. - return inserted, r.playlistRepo.renumber(r.playlistId) + return inserted, r.playlistRepo.renumber(ctx, r.playlistId) } -func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { +func (r *playlistTrackRepository) addMediaFileIds(ctx context.Context, cond Sqlizer) (int, error) { sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number") var ids []string - err := r.queryAllSlice(sq, &ids) + err := r.queryAllSlice(ctx, sq, &ids) if err != nil { - log.Error(r.ctx, "Error getting tracks to add to playlist", err) + log.Error(ctx, "Error getting tracks to add to playlist", err) return 0, err } - return r.Add(ids) + return r.Add(ctx, ids) } -func (r *playlistTrackRepository) AddAlbums(albumIds []string) (int, error) { - return r.addMediaFileIds(Eq{"album_id": albumIds}) +func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []string) (int, error) { + return r.addMediaFileIds(ctx, Eq{"album_id": albumIds}) } -func (r *playlistTrackRepository) AddArtists(artistIds []string) (int, error) { - return r.addMediaFileIds(Eq{"album_artist_id": artistIds}) +func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) { + return r.addMediaFileIds(ctx, Eq{"album_artist_id": artistIds}) } -func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { +func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) { if len(discs) == 0 { return 0, nil } @@ -253,36 +245,36 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { for _, d := range discs { clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"release_date": d.ReleaseDate}, Eq{"disc_number": d.DiscNumber}}) } - return r.addMediaFileIds(clauses) + return r.addMediaFileIds(ctx, clauses) } // deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks. const deleteChunkSize = 200 -func (r *playlistTrackRepository) Delete(ids ...string) error { +func (r *playlistTrackRepository) Delete(ctx context.Context, ids ...string) error { for chunk := range slices.Chunk(ids, deleteChunkSize) { - if err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil { + if err := r.delete(ctx, And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil { return err } } - return r.playlistRepo.renumber(r.playlistId) + return r.playlistRepo.renumber(ctx, r.playlistId) } -func (r *playlistTrackRepository) DeleteAll() error { - err := r.delete(Eq{"playlist_id": r.playlistId}) +func (r *playlistTrackRepository) DeleteAll(ctx context.Context) error { + err := r.delete(ctx, Eq{"playlist_id": r.playlistId}) if err != nil { return err } - return r.playlistRepo.renumber(r.playlistId) + return r.playlistRepo.renumber(ctx, r.playlistId) } // Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped // to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap. -func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { +func (r *playlistTrackRepository) Reorder(ctx context.Context, pos int, newPos int) error { var res struct{ Max sql.NullInt32 } - if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { + if err := r.queryOne(ctx, r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { return err } last := int(res.Max.Int32) @@ -296,7 +288,7 @@ func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { pid := r.playlistId // Step 1: Move the source track out of the way (temporary sentinel value) - _, err := r.executeSQL(Expr( + _, err := r.executeSQL(ctx, Expr( `UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos)) if err != nil { return err @@ -304,11 +296,11 @@ func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { // Step 2: Shift the affected range using negative values to avoid unique constraint violations if pos < newPos { - _, err = r.executeSQL(Expr( + _, err = r.executeSQL(ctx, Expr( `UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`, pid, pos, newPos)) } else { - _, err = r.executeSQL(Expr( + _, err = r.executeSQL(ctx, Expr( `UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`, pid, newPos, pos)) } @@ -317,14 +309,14 @@ func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { } // Step 3: Flip the shifted range back to positive - _, err = r.executeSQL(Expr( + _, err = r.executeSQL(ctx, Expr( `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid)) if err != nil { return err } // Step 4: Place the source track at its new position - _, err = r.executeSQL(Expr( + _, err = r.executeSQL(ctx, Expr( `UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid)) return err } diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 1a6bc9dc6..3c532c405 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -17,30 +17,31 @@ const sqliteMaxVariables = 32766 var _ = Describe("PlaylistTrackRepository", func() { var repo model.PlaylistTrackRepository + var ctx context.Context BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) + ctx = log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true) + repo = NewPlaylistRepository(GetDBXBuilder()).Tracks(ctx, plsBest.ID, true) }) Describe("GetCursor", func() { It("yields the same tracks as GetAll", func() { opts := model.QueryOptions{Sort: "id"} - want, err := repo.GetAll(opts) + want, err := repo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).To(HaveLen(2)) - Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want))) }) It("honors Max and Offset", func() { opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} - want, err := repo.GetAll(opts) + want, err := repo.GetAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) Expect(want).To(HaveLen(1)) - Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want))) }) }) @@ -48,11 +49,11 @@ var _ = Describe("PlaylistTrackRepository", func() { It("returns every row under a random sort, despite the integer id", func() { // playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to // TEXT, and it fails silently: no error, just no rows. - all, err := repo.GetAll(model.QueryOptions{Sort: "random"}) + all, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random"}) Expect(err).ToNot(HaveOccurred()) Expect(all).To(HaveLen(2), "a random sort must not silently drop rows") - got, err := repo.GetAll(model.QueryOptions{Sort: "random", Max: 1}) + got, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random", Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(got).To(HaveLen(1)) }) @@ -60,22 +61,22 @@ var _ = Describe("PlaylistTrackRepository", func() { Describe("CountAll", func() { It("returns the number of tracks in the playlist", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll(ctx)).To(Equal(int64(2))) }) It("ignores Max and Offset", func() { - Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) + Expect(repo.CountAll(ctx, model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) }) }) Describe("GetMediaFileIDs", func() { It("returns the song ids in playlist order", func() { - Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})). + Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"})). To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) }) It("honors Max and Offset", func() { - Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). + Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). To(Equal([]string{songRadioactivity.ID})) }) }) @@ -84,28 +85,26 @@ var _ = Describe("PlaylistTrackRepository", func() { var tracks model.PlaylistTrackRepository BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + plsRepo := NewPlaylistRepository(GetDBXBuilder()) pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - tracks = plsRepo.Tracks(pls.ID, false) - Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) + tracks = plsRepo.Tracks(ctx, pls.ID, false) + Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) }) order := func() []string { - ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"}) + ids, err := tracks.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"}) Expect(err).ToNot(HaveOccurred()) return ids } DescribeTable("inserts before a 1-based position, keeping the new tracks' order", func(pos int, want func() []string) { - Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) + Expect(tracks.Insert(ctx, []string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) Expect(order()).To(Equal(want())) - Expect(tracks.CountAll()).To(Equal(int64(4))) + Expect(tracks.CountAll(ctx)).To(Equal(int64(4))) }, Entry("in the middle", 2, func() []string { return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID} @@ -119,8 +118,8 @@ var _ = Describe("PlaylistTrackRepository", func() { ) It("renumbers positions contiguously", func() { - Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1)) - all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(tracks.Insert(ctx, []string{songComeTogether.ID}, 1)).To(Equal(1)) + all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) Expect(err).ToNot(HaveOccurred()) Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) }) @@ -130,19 +129,17 @@ var _ = Describe("PlaylistTrackRepository", func() { var tracks model.PlaylistTrackRepository BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + plsRepo := NewPlaylistRepository(GetDBXBuilder()) pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - tracks = plsRepo.Tracks(pls.ID, false) - Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) + tracks = plsRepo.Tracks(ctx, pls.ID, false) + Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) }) rows := func() ([]string, []string) { - all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) Expect(err).ToNot(HaveOccurred()) var ids, songs []string for _, t := range all { @@ -154,7 +151,7 @@ var _ = Describe("PlaylistTrackRepository", func() { DescribeTable("clamps the destination to the playlist", func(newPos int, want func() []string) { - Expect(tracks.Reorder(1, newPos)).To(Succeed()) + Expect(tracks.Reorder(ctx, 1, newPos)).To(Succeed()) ids, songs := rows() Expect(ids).To(Equal([]string{"1", "2", "3"})) Expect(songs).To(Equal(want())) @@ -169,7 +166,7 @@ var _ = Describe("PlaylistTrackRepository", func() { DescribeTable("rejects a source position outside the playlist, leaving rows untouched", func(pos int) { - Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound)) + Expect(tracks.Reorder(ctx, pos, 1)).To(MatchError(model.ErrNotFound)) ids, songs := rows() Expect(ids).To(Equal([]string{"1", "2", "3"})) Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})) @@ -192,35 +189,33 @@ var _ = Describe("PlaylistTrackRepository", func() { } BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + plsRepo := NewPlaylistRepository(GetDBXBuilder()) pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - tracks = plsRepo.Tracks(pls.ID, false) + tracks = plsRepo.Tracks(ctx, pls.ID, false) songIds := make([]string, numTracks) for i := range songIds { songIds[i] = songDayInALife.ID } - Expect(tracks.Add(songIds)).To(Equal(numTracks)) + Expect(tracks.Add(ctx, songIds)).To(Equal(numTracks)) }) It("removes positions spanning several chunks, and renumbers what is left", func() { - Expect(tracks.Delete(positionsUpTo(numTracks - 1)...)).To(Succeed()) + Expect(tracks.Delete(ctx, positionsUpTo(numTracks-1)...)).To(Succeed()) - Expect(tracks.CountAll()).To(Equal(int64(1))) - remaining, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(tracks.CountAll(ctx)).To(Equal(int64(1))) + remaining, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) Expect(err).ToNot(HaveOccurred()) Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1") }) It("accepts more ids than SQLite allows as bind variables", func() { - Expect(tracks.Delete(positionsUpTo(sqliteMaxVariables + 100)...)).To(Succeed()) + Expect(tracks.Delete(ctx, positionsUpTo(sqliteMaxVariables+100)...)).To(Succeed()) - Expect(tracks.CountAll()).To(BeZero()) + Expect(tracks.CountAll(ctx)).To(BeZero()) }) }) @@ -236,69 +231,69 @@ var _ = Describe("PlaylistTrackRepository", func() { userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) db := GetDBXBuilder() - adminMr := NewMediaFileRepository(adminCtx, db) - Expect(adminMr.Put(&model.MediaFile{ + adminMr := NewMediaFileRepository(db) + Expect(adminMr.Put(adminCtx, &model.MediaFile{ ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album", Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist", })).To(Succeed()) - DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") }) + DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "pls-otherlib-track") }) - adminPls := NewPlaylistRepository(adminCtx, db) + adminPls := NewPlaylistRepository(db) pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true} - Expect(adminPls.Put(&pls)).To(Succeed()) + Expect(adminPls.Put(adminCtx, &pls)).To(Succeed()) plsID = pls.ID - DeferCleanup(func() { _ = adminPls.Delete(plsID) }) - Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) + DeferCleanup(func() { _ = adminPls.Delete(adminCtx, plsID) }) + Expect(adminPls.Tracks(adminCtx, plsID, false).Add(adminCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) - userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false) + userTracks = NewPlaylistRepository(db).Tracks(userCtx, plsID, false) }) It("Read does not return a track outside the user's libraries", func() { - _, err := userTracks.Read("2") + _, err := userTracks.Read(userCtx, "2") Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access") }) It("Read still returns a track inside the user's libraries", func() { - trk, err := userTracks.Read("1") + trk, err := userTracks.Read(userCtx, "1") Expect(err).ToNot(HaveOccurred()) - Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID)) + Expect(trk.MediaFile.ID).To(Equal(songDayInALife.ID)) }) It("Count excludes tracks outside the user's libraries", func() { - Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing") + Expect(userTracks.Count(userCtx)).To(Equal(int64(1)), "Count must agree with the filtered listing") }) It("GetAlbumIDs excludes albums outside the user's libraries", func() { - Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album")) + Expect(userTracks.GetAlbumIDs(userCtx)).ToNot(ContainElement("pls-hidden-album")) }) Describe("Add", func() { var ownTracks model.PlaylistTrackRepository BeforeEach(func() { - userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + userPls := NewPlaylistRepository(GetDBXBuilder()) own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName} - Expect(userPls.Put(&own)).To(Succeed()) - DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) }) - ownTracks = userPls.Tracks(own.ID, false) + Expect(userPls.Put(userCtx, &own)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, own.ID) }) + ownTracks = userPls.Tracks(userCtx, own.ID, false) }) It("drops ids outside the user's libraries", func() { - Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) - Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID)) + Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) + Expect(ownTracks.GetMediaFileIDs(userCtx)).To(ConsistOf(songDayInALife.ID)) }) It("drops them when reached through AddAlbums", func() { - Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero()) + Expect(ownTracks.AddAlbums(userCtx, []string{"pls-hidden-album"})).To(BeZero()) }) It("drops them when reached through Insert", func() { - Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1)) + Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID})).To(Equal(1)) - Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) + Expect(ownTracks.Insert(userCtx, []string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) - Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) - trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(ownTracks.GetMediaFileIDs(userCtx)).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) + trks, err := ownTracks.GetAll(userCtx, model.QueryOptions{Sort: "id"}) Expect(err).ToNot(HaveOccurred()) Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}), "positions must stay contiguous when an id is dropped") @@ -307,7 +302,7 @@ var _ = Describe("PlaylistTrackRepository", func() { Describe("Put", func() { storedIDs := func(id string) []string { - ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs() + ids, err := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, id, false).GetMediaFileIDs(adminCtx) Expect(err).ToNot(HaveOccurred()) return ids } @@ -315,8 +310,8 @@ var _ = Describe("PlaylistTrackRepository", func() { pls.OwnerID = owner.ID pls.Tracks = nil pls.AddMediaFilesByID(ids) - Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed()) - DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) }) + Expect(NewPlaylistRepository(GetDBXBuilder()).Put(ctx, pls)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, pls.ID) }) return pls.ID } @@ -339,10 +334,10 @@ var _ = Describe("PlaylistTrackRepository", func() { hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track") unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track") - userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) - h, err := userPls.Get(hidden) + userPls := NewPlaylistRepository(GetDBXBuilder()) + h, err := userPls.Get(userCtx, hidden) Expect(err).ToNot(HaveOccurred()) - u, err := userPls.Get(unknown) + u, err := userPls.Get(userCtx, unknown) Expect(err).ToNot(HaveOccurred()) Expect(h.SongCount).To(Equal(u.SongCount)) Expect(h.Duration).To(Equal(u.Duration)) @@ -364,9 +359,9 @@ var _ = Describe("PlaylistTrackRepository", func() { }) It("still shows everything to an admin", func() { - adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false) - Expect(adminTracks.Count()).To(Equal(int64(2))) - _, err := adminTracks.Read("2") + adminTracks := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, plsID, false) + Expect(adminTracks.Count(adminCtx)).To(Equal(int64(2))) + _, err := adminTracks.Read(adminCtx, "2") Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index ba69ec746..9dc520c44 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -17,9 +17,8 @@ type playQueueRepository struct { sqlRepository } -func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueueRepository { +func NewPlayQueueRepository(db dbx.Builder) model.PlayQueueRepository { r := &playQueueRepository{} - r.ctx = ctx r.db = db r.tableName = "playqueue" return r @@ -36,13 +35,13 @@ type playQueue struct { UpdatedAt time.Time `structs:"updated_at"` } -func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) error { - u := loggedUser(r.ctx) +func (r *playQueueRepository) Store(ctx context.Context, q *model.PlayQueue, colNames ...string) error { + u := loggedUser(ctx) // Always find existing playqueue for this user - existingQueue, err := r.Retrieve(q.UserID) + existingQueue, err := r.Retrieve(ctx, q.UserID) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Error(r.ctx, "Error retrieving existing playqueue", "user", u.UserName, err) + log.Error(ctx, "Error retrieving existing playqueue", "user", u.UserName, err) return err } @@ -53,9 +52,9 @@ func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) erro // When no specific columns are provided, we replace the whole queue if len(colNames) == 0 { - err := r.clearPlayQueue(q.UserID) + err := r.clearPlayQueue(ctx, q.UserID) if err != nil { - log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err) + log.Error(ctx, "Error deleting previous playqueue", "user", u.UserName, err) return err } if len(q.Items) == 0 { @@ -68,27 +67,27 @@ func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) erro pq.CreatedAt = time.Now() } pq.UpdatedAt = time.Now() - _, err = r.put(pq.ID, pq, colNames...) + _, err = r.put(ctx, pq.ID, pq, colNames...) if err != nil { - log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err) + log.Error(ctx, "Error saving playqueue", "user", u.UserName, err) return err } return nil } -func (r *playQueueRepository) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { - sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) +func (r *playQueueRepository) RetrieveWithMediaFiles(ctx context.Context, userId string) (*model.PlayQueue, error) { + sel := r.newSelect(ctx).Columns("*").Where(Eq{"user_id": userId}) var res playQueue - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) q := r.toModel(&res) - q.Items = r.loadTracks(q.Items) + q.Items = r.loadTracks(ctx, q.Items) return &q, err } -func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) { - sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) +func (r *playQueueRepository) Retrieve(ctx context.Context, userId string) (*model.PlayQueue, error) { + sel := r.newSelect(ctx).Columns("*").Where(Eq{"user_id": userId}) var res playQueue - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return new(r.toModel(&res)), err } @@ -131,12 +130,12 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { // loadTracks loads the tracks from the database. It receives a list of track IDs and returns a list of MediaFiles // in the same order as the input list. -func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles { +func (r *playQueueRepository) loadTracks(ctx context.Context, tracks model.MediaFiles) model.MediaFiles { if len(tracks) == 0 { return nil } - mfRepo := NewMediaFileRepository(r.ctx, r.db) + mfRepo := NewMediaFileRepository(r.db) trackMap := map[string]model.MediaFile{} // Create an iterator to collect all track IDs @@ -145,10 +144,10 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil // Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit for chunk := range slice.CollectChunks(ids, 500) { idsFilter := Eq{"media_file.id": chunk} - tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) + tracks, err := mfRepo.GetAll(ctx, model.QueryOptions{Filters: idsFilter}) if err != nil { - u := loggedUser(r.ctx) - log.Error(r.ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err) + u := loggedUser(ctx) + log.Error(ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err) } for _, t := range tracks { trackMap[t.ID] = t @@ -166,12 +165,12 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil return newTracks } -func (r *playQueueRepository) clearPlayQueue(userId string) error { - return r.delete(Eq{"user_id": userId}) +func (r *playQueueRepository) clearPlayQueue(ctx context.Context, userId string) error { + return r.delete(ctx, Eq{"user_id": userId}) } -func (r *playQueueRepository) Clear(userId string) error { - return r.clearPlayQueue(userId) +func (r *playQueueRepository) Clear(ctx context.Context, userId string) error { + return r.clearPlayQueue(ctx, userId) } var _ model.PlayQueueRepository = (*playQueueRepository)(nil) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go index 2bcc88fd0..877faddfc 100644 --- a/persistence/playqueue_repository_test.go +++ b/persistence/playqueue_repository_test.go @@ -22,15 +22,15 @@ var _ = Describe("PlayQueueRepository", func() { DeferCleanup(configtest.SetupConfig()) ctx = log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlayQueueRepository(ctx, GetDBXBuilder()) + repo = NewPlayQueueRepository(GetDBXBuilder()) }) Describe("Store", func() { It("stores a complete playqueue", func() { expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(expected)).To(Succeed()) + Expect(repo.Store(ctx, expected)).To(Succeed()) - actual, err := repo.RetrieveWithMediaFiles("userid") + actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") Expect(err).ToNot(HaveOccurred()) AssertPlayQueue(expected, actual) Expect(countPlayQueues(repo, "userid")).To(Equal(1)) @@ -39,13 +39,13 @@ var _ = Describe("PlayQueueRepository", func() { It("replaces existing playqueue when storing without column names", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) By("Storing replacement playqueue") replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna) - Expect(repo.Store(replacement)).To(Succeed()) + Expect(repo.Store(ctx, replacement)).To(Succeed()) - actual, err := repo.RetrieveWithMediaFiles("userid") + actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") Expect(err).ToNot(HaveOccurred()) AssertPlayQueue(replacement, actual) Expect(countPlayQueues(repo, "userid")).To(Equal(1)) @@ -54,24 +54,24 @@ var _ = Describe("PlayQueueRepository", func() { It("clears playqueue when storing empty items", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) By("Storing empty playqueue") empty := aPlayQueue("userid", 0, 0) - Expect(repo.Store(empty)).To(Succeed()) + Expect(repo.Store(ctx, empty)).To(Succeed()) By("Verifying playqueue is cleared") - _, err := repo.Retrieve("userid") + _, err := repo.Retrieve(ctx, "userid") Expect(err).To(MatchError(model.ErrNotFound)) }) It("updates only current field when specified", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve("userid") + existing, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) By("Updating only current field") @@ -81,10 +81,10 @@ var _ = Describe("PlayQueueRepository", func() { Current: 1, ChangedBy: "test-update", } - Expect(repo.Store(update, "current")).To(Succeed()) + Expect(repo.Store(ctx, update, "current")).To(Succeed()) By("Verifying only current was updated") - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged @@ -94,10 +94,10 @@ var _ = Describe("PlayQueueRepository", func() { It("updates only position field when specified", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 1, 100, songComeTogether, songDayInALife) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve("userid") + existing, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) By("Updating only position field") @@ -107,10 +107,10 @@ var _ = Describe("PlayQueueRepository", func() { Position: 500, ChangedBy: "test-update", } - Expect(repo.Store(update, "position")).To(Succeed()) + Expect(repo.Store(ctx, update, "position")).To(Succeed()) By("Verifying only position was updated") - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Position).To(Equal(int64(500))) Expect(actual.Current).To(Equal(1)) // Should remain unchanged @@ -120,10 +120,10 @@ var _ = Describe("PlayQueueRepository", func() { It("updates multiple specified fields", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve("userid") + existing, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) By("Updating current and position fields") @@ -134,10 +134,10 @@ var _ = Describe("PlayQueueRepository", func() { Position: 300, ChangedBy: "test-update", } - Expect(repo.Store(update, "current", "position")).To(Succeed()) + Expect(repo.Store(ctx, update, "current", "position")).To(Succeed()) By("Verifying both fields were updated") - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) Expect(actual.Position).To(Equal(int64(300))) @@ -147,10 +147,10 @@ var _ = Describe("PlayQueueRepository", func() { It("preserves existing data when updating with empty items list and column names", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve("userid") + existing, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) By("Updating only position with empty items") @@ -161,10 +161,10 @@ var _ = Describe("PlayQueueRepository", func() { ChangedBy: "test-update", Items: []model.MediaFile{}, // Empty items } - Expect(repo.Store(update, "position")).To(Succeed()) + Expect(repo.Store(ctx, update, "position")).To(Succeed()) By("Verifying items are preserved") - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Position).To(Equal(int64(200))) Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged @@ -173,21 +173,21 @@ var _ = Describe("PlayQueueRepository", func() { It("ensures only one record per user by reusing existing record ID", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) initialCount := countPlayQueues(repo, "userid") Expect(initialCount).To(Equal(1)) By("Storing another playqueue with different ID but same user") different := aPlayQueue("userid", 1, 200, songDayInALife) different.ID = "different-id" // Force a different ID - Expect(repo.Store(different)).To(Succeed()) + Expect(repo.Store(ctx, different)).To(Succeed()) By("Verifying only one record exists for the user") finalCount := countPlayQueues(repo, "userid") Expect(finalCount).To(Equal(1)) By("Verifying the record was updated, not duplicated") - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) // Should be updated value Expect(actual.Position).To(Equal(int64(200))) // Should be updated value @@ -198,7 +198,7 @@ var _ = Describe("PlayQueueRepository", func() { It("ensures only one record per user even with partial updates", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(initial)).To(Succeed()) + Expect(repo.Store(ctx, initial)).To(Succeed()) initialCount := countPlayQueues(repo, "userid") Expect(initialCount).To(Equal(1)) @@ -209,14 +209,14 @@ var _ = Describe("PlayQueueRepository", func() { Current: 1, ChangedBy: "test-partial", } - Expect(repo.Store(partialUpdate, "current")).To(Succeed()) + Expect(repo.Store(ctx, partialUpdate, "current")).To(Succeed()) By("Verifying only one record still exists for the user") finalCount := countPlayQueues(repo, "userid") Expect(finalCount).To(Equal(1)) By("Verifying the existing record was updated with new current value") - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) // Should be updated value Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged @@ -226,7 +226,7 @@ var _ = Describe("PlayQueueRepository", func() { Describe("Retrieve", func() { It("returns notfound error if there's no playqueue for the user", func() { - _, err := repo.Retrieve("user999") + _, err := repo.Retrieve(ctx, "user999") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -234,9 +234,9 @@ var _ = Describe("PlayQueueRepository", func() { By("Storing a playqueue for the user") expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(expected)).To(Succeed()) + Expect(repo.Store(ctx, expected)).To(Succeed()) - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) // Basic playqueue properties should match @@ -263,19 +263,19 @@ var _ = Describe("PlayQueueRepository", func() { newSong := songRadioactivity newSong.ID = "temp-track" newSong.Path = "/new-path" - mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder()) + mfRepo := NewMediaFileRepository(GetDBXBuilder()) - Expect(mfRepo.Put(&newSong)).To(Succeed()) + Expect(mfRepo.Put(ctx, &newSong)).To(Succeed()) // Create a playqueue with the new song pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) - Expect(repo.Store(pq)).To(Succeed()) + Expect(repo.Store(ctx, pq)).To(Succeed()) // Delete the new song from the database - Expect(mfRepo.Delete("temp-track")).To(Succeed()) + Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed()) // Retrieve the playqueue with Retrieve method - actual, err := repo.Retrieve("userid") + actual, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should still contain both track IDs (including the deleted one) @@ -295,7 +295,7 @@ var _ = Describe("PlayQueueRepository", func() { Describe("RetrieveWithMediaFiles", func() { It("returns notfound error if there's no playqueue for the user", func() { - _, err := repo.RetrieveWithMediaFiles("user999") + _, err := repo.RetrieveWithMediaFiles(ctx, "user999") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -303,9 +303,9 @@ var _ = Describe("PlayQueueRepository", func() { By("Storing a playqueue for the user") expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(expected)).To(Succeed()) + Expect(repo.Store(ctx, expected)).To(Succeed()) - actual, err := repo.RetrieveWithMediaFiles("userid") + actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") Expect(err).ToNot(HaveOccurred()) AssertPlayQueue(expected, actual) @@ -316,26 +316,26 @@ var _ = Describe("PlayQueueRepository", func() { newSong := songRadioactivity newSong.ID = "temp-track" newSong.Path = "/new-path" - mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder()) + mfRepo := NewMediaFileRepository(GetDBXBuilder()) - Expect(mfRepo.Put(&newSong)).To(Succeed()) + Expect(mfRepo.Put(ctx, &newSong)).To(Succeed()) // Create a playqueue with the new song pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) - Expect(repo.Store(pq)).To(Succeed()) + Expect(repo.Store(ctx, pq)).To(Succeed()) // Retrieve the playqueue - actual, err := repo.RetrieveWithMediaFiles("userid") + actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should contain both tracks AssertPlayQueue(pq, actual) // Delete the new song - Expect(mfRepo.Delete("temp-track")).To(Succeed()) + Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed()) // Retrieve the playqueue - actual, err = repo.RetrieveWithMediaFiles("userid") + actual, err = repo.RetrieveWithMediaFiles(ctx, "userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should not contain the deleted track @@ -348,48 +348,48 @@ var _ = Describe("PlayQueueRepository", func() { It("clears an existing playqueue", func() { By("Storing a playqueue") expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(expected)).To(Succeed()) + Expect(repo.Store(ctx, expected)).To(Succeed()) By("Verifying playqueue exists") - _, err := repo.Retrieve("userid") + _, err := repo.Retrieve(ctx, "userid") Expect(err).ToNot(HaveOccurred()) By("Clearing the playqueue") - Expect(repo.Clear("userid")).To(Succeed()) + Expect(repo.Clear(ctx, "userid")).To(Succeed()) By("Verifying playqueue is cleared") - _, err = repo.Retrieve("userid") + _, err = repo.Retrieve(ctx, "userid") Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not error when clearing non-existent playqueue", func() { // Clear should not error even if no playqueue exists - Expect(repo.Clear("nonexistent-user")).To(Succeed()) + Expect(repo.Clear(ctx, "nonexistent-user")).To(Succeed()) }) It("only clears the specified user's playqueue", func() { By("Creating users in the database to avoid foreign key constraints") - userRepo := NewUserRepository(ctx, GetDBXBuilder()) + userRepo := NewUserRepository(GetDBXBuilder()) user1 := &model.User{ID: "user1", UserName: "user1", Name: "User 1", Email: "user1@test.com"} user2 := &model.User{ID: "user2", UserName: "user2", Name: "User 2", Email: "user2@test.com"} - Expect(userRepo.Put(user1)).To(Succeed()) - Expect(userRepo.Put(user2)).To(Succeed()) + Expect(userRepo.Put(ctx, user1)).To(Succeed()) + Expect(userRepo.Put(ctx, user2)).To(Succeed()) By("Storing playqueues for two users") user1Queue := aPlayQueue("user1", 0, 100, songComeTogether) user2Queue := aPlayQueue("user2", 1, 200, songDayInALife) - Expect(repo.Store(user1Queue)).To(Succeed()) - Expect(repo.Store(user2Queue)).To(Succeed()) + Expect(repo.Store(ctx, user1Queue)).To(Succeed()) + Expect(repo.Store(ctx, user2Queue)).To(Succeed()) By("Clearing only user1's playqueue") - Expect(repo.Clear("user1")).To(Succeed()) + Expect(repo.Clear(ctx, "user1")).To(Succeed()) By("Verifying user1's playqueue is cleared") - _, err := repo.Retrieve("user1") + _, err := repo.Retrieve(ctx, "user1") Expect(err).To(MatchError(model.ErrNotFound)) By("Verifying user2's playqueue still exists") - actual, err := repo.Retrieve("user2") + actual, err := repo.Retrieve(ctx, "user2") Expect(err).ToNot(HaveOccurred()) Expect(actual.UserID).To(Equal("user2")) Expect(actual.Current).To(Equal(1)) @@ -400,7 +400,7 @@ var _ = Describe("PlayQueueRepository", func() { func countPlayQueues(repo model.PlayQueueRepository, userId string) int { r := repo.(*playQueueRepository) - c, err := r.count(squirrel.Select().Where(squirrel.Eq{"user_id": userId})) + c, err := r.count(GinkgoT().Context(), squirrel.Select().Where(squirrel.Eq{"user_id": userId})) if err != nil { panic(err) } diff --git a/persistence/plugin_cleanup_test.go b/persistence/plugin_cleanup_test.go index bfe6d60ca..08959075d 100644 --- a/persistence/plugin_cleanup_test.go +++ b/persistence/plugin_cleanup_test.go @@ -1,6 +1,8 @@ package persistence import ( + "context" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" @@ -11,30 +13,47 @@ var _ = Describe("Plugin Cleanup", func() { var pluginRepo model.PluginRepository var userRepo model.UserRepository var libraryRepo model.LibraryRepository + var ctx context.Context BeforeEach(func() { - ctx := GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "admin", UserName: "admin", IsAdmin: true}) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin", UserName: "admin", IsAdmin: true}) db := GetDBXBuilder() - pluginRepo = NewPluginRepository(ctx, db) - userRepo = NewUserRepository(ctx, db) - libraryRepo = NewLibraryRepository(ctx, db) + pluginRepo = NewPluginRepository(db) + userRepo = NewUserRepository(db) + libraryRepo = NewLibraryRepository(db) // Clean up any existing plugins - all, _ := pluginRepo.GetAll() + all, _ := pluginRepo.GetAll(ctx) for _, p := range all { - _ = pluginRepo.Delete(p.ID) + _ = pluginRepo.Delete(ctx, p.ID) } }) AfterEach(func() { // Clean up after tests - all, _ := pluginRepo.GetAll() + all, _ := pluginRepo.GetAll(ctx) for _, p := range all { - _ = pluginRepo.Delete(p.ID) + _ = pluginRepo.Delete(ctx, p.ID) } }) + Describe("UserRepository.Delete", func() { + It("cleans up plugin references for users deleted before a later id fails", func() { + Expect(userRepo.Put(ctx, &model.User{ID: "bulk-1", UserName: "bulk-1", NewPassword: "x"})).To(Succeed()) + DeferCleanup(func() { _ = userRepo.Delete(ctx, "bulk-1") }) + Expect(pluginRepo.Put(ctx, &model.Plugin{ + ID: "bulk-plugin", Path: "/plugins/bulk.wasm", Manifest: `{"name":"bulk"}`, SHA256: "def456", + Users: `["bulk-1","other"]`, Enabled: true, + })).To(Succeed()) + + Expect(userRepo.Delete(ctx, "bulk-1", "does-not-exist")).To(MatchError(model.ErrNotFound)) + + updated, err := pluginRepo.Get(ctx, "bulk-plugin") + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Users).To(Equal(`["other"]`)) + }) + }) + Describe("cleanupPluginUserReferences", func() { It("removes user ID from plugin users array", func() { // Create a plugin with multiple users @@ -46,14 +65,14 @@ var _ = Describe("Plugin Cleanup", func() { Users: `["user1","user2","user3"]`, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Clean up user2 reference db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "user2")).To(Succeed()) // Verify user2 was removed - updated, err := pluginRepo.Get("test-plugin") + updated, err := pluginRepo.Get(ctx, "test-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`["user1","user3"]`)) Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled @@ -70,14 +89,14 @@ var _ = Describe("Plugin Cleanup", func() { AllUsers: false, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Remove the only user db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "only-user")).To(Succeed()) // Verify plugin was auto-disabled - updated, err := pluginRepo.Get("user-plugin") + updated, err := pluginRepo.Get(ctx, "user-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`[]`)) Expect(updated.Enabled).To(BeFalse()) @@ -93,14 +112,14 @@ var _ = Describe("Plugin Cleanup", func() { AllUsers: true, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Remove the user (but allUsers is true) db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed()) // Plugin should still be enabled because allUsers is true - updated, err := pluginRepo.Get("all-users-plugin") + updated, err := pluginRepo.Get(ctx, "all-users-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Enabled).To(BeTrue()) }) @@ -114,14 +133,14 @@ var _ = Describe("Plugin Cleanup", func() { Users: `["user1"]`, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Remove the user db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed()) // Plugin should still be enabled (no users permission requirement) - updated, err := pluginRepo.Get("no-users-perm") + updated, err := pluginRepo.Get(ctx, "no-users-perm") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`[]`)) Expect(updated.Enabled).To(BeTrue()) @@ -139,14 +158,14 @@ var _ = Describe("Plugin Cleanup", func() { Libraries: `[1,2,3]`, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Clean up library 2 reference db := GetDBXBuilder() Expect(cleanupPluginLibraryReferences(db, 2)).To(Succeed()) // Verify library 2 was removed - updated, err := pluginRepo.Get("lib-plugin") + updated, err := pluginRepo.Get(ctx, "lib-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Libraries).To(Equal(`[1,3]`)) }) @@ -162,14 +181,14 @@ var _ = Describe("Plugin Cleanup", func() { AllLibraries: false, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Remove the only library db := GetDBXBuilder() Expect(cleanupPluginLibraryReferences(db, 99)).To(Succeed()) // Verify plugin was auto-disabled - updated, err := pluginRepo.Get("lib-only-plugin") + updated, err := pluginRepo.Get(ctx, "lib-only-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Libraries).To(Equal(`[]`)) Expect(updated.Enabled).To(BeFalse()) @@ -185,14 +204,14 @@ var _ = Describe("Plugin Cleanup", func() { AllLibraries: true, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Remove the library (but allLibraries is true) db := GetDBXBuilder() Expect(cleanupPluginLibraryReferences(db, 1)).To(Succeed()) // Plugin should still be enabled - updated, err := pluginRepo.Get("all-libs-plugin") + updated, err := pluginRepo.Get(ctx, "all-libs-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Enabled).To(BeTrue()) }) @@ -207,7 +226,7 @@ var _ = Describe("Plugin Cleanup", func() { IsAdmin: false, } user.NewPassword = "password123" - Expect(userRepo.Put(user)).To(Succeed()) + Expect(userRepo.Put(ctx, user)).To(Succeed()) // Create a plugin referencing this user plugin := &model.Plugin{ @@ -218,13 +237,13 @@ var _ = Describe("Plugin Cleanup", func() { Users: `["test-delete-user","other-user"]`, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Delete the user - Expect(userRepo.Delete("test-delete-user")).To(Succeed()) + Expect(userRepo.Delete(ctx, "test-delete-user")).To(Succeed()) // Verify user was removed from plugin - updated, err := pluginRepo.Get("user-ref-plugin") + updated, err := pluginRepo.Get(ctx, "user-ref-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`["other-user"]`)) }) @@ -238,7 +257,7 @@ var _ = Describe("Plugin Cleanup", func() { Name: "Test Library", Path: "/tmp/test-lib", } - Expect(libraryRepo.Put(library)).To(Succeed()) + Expect(libraryRepo.Put(ctx, library)).To(Succeed()) // Create a plugin referencing this library plugin := &model.Plugin{ @@ -249,13 +268,13 @@ var _ = Describe("Plugin Cleanup", func() { Libraries: `[99,1]`, Enabled: true, } - Expect(pluginRepo.Put(plugin)).To(Succeed()) + Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) // Delete the library - Expect(libraryRepo.Delete(99)).To(Succeed()) + Expect(libraryRepo.Delete(ctx, 99)).To(Succeed()) // Verify library was removed from plugin - updated, err := pluginRepo.Get("lib-ref-plugin") + updated, err := pluginRepo.Get(ctx, "lib-ref-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Libraries).To(Equal(`[1]`)) }) diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 7d5781f49..1545ce2ba 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -15,9 +15,8 @@ type pluginRepository struct { sqlRepository } -func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository { +func NewPluginRepository(db dbx.Builder) model.PluginRepository { r := &pluginRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Plugin{}, map[string]filterFunc{ "id": idFilter("plugin"), @@ -26,13 +25,13 @@ func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginReposi return r } -func (r *pluginRepository) isPermitted() bool { - user := loggedUser(r.ctx) +func (r *pluginRepository) isPermitted(ctx context.Context) bool { + user := loggedUser(ctx) return user.IsAdmin } -func (r *pluginRepository) ClearErrors() error { - if !r.isPermitted() { +func (r *pluginRepository) ClearErrors(ctx context.Context) error { + if !r.isPermitted(ctx) { return rest.ErrPermissionDenied } // An UPDATE takes the write lock even when nothing matches, so only run it when there is an error to clear @@ -47,43 +46,43 @@ func (r *pluginRepository) ClearErrors() error { return err } -func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) { - if !r.isPermitted() { +func (r *pluginRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + if !r.isPermitted(ctx) { return 0, rest.ErrPermissionDenied } - sql := r.newSelect() - return r.count(sql, options...) + sql := r.newSelect(ctx) + return r.count(ctx, sql, options...) } -func (r *pluginRepository) Delete(id string) error { - if !r.isPermitted() { +func (r *pluginRepository) Delete(ctx context.Context, id string) error { + if !r.isPermitted(ctx) { return rest.ErrPermissionDenied } - return r.delete(Eq{"id": id}) + return r.delete(ctx, Eq{"id": id}) } -func (r *pluginRepository) Get(id string) (*model.Plugin, error) { - if !r.isPermitted() { +func (r *pluginRepository) Get(ctx context.Context, id string) (*model.Plugin, error) { + if !r.isPermitted(ctx) { return nil, rest.ErrPermissionDenied } - sel := r.newSelect().Where(Eq{"id": id}).Columns("*") + sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*") res := model.Plugin{} - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) { - if !r.isPermitted() { +func (r *pluginRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Plugins, error) { + if !r.isPermitted(ctx) { return nil, rest.ErrPermissionDenied } - sel := r.newSelect(options...).Columns("*") + sel := r.newSelect(ctx, options...).Columns("*") res := model.Plugins{} - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) return res, err } -func (r *pluginRepository) Put(plugin *model.Plugin) error { - if !r.isPermitted() { +func (r *pluginRepository) Put(ctx context.Context, plugin *model.Plugin) error { + if !r.isPermitted(ctx) { return rest.ErrPermissionDenied } @@ -129,25 +128,17 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error { return err } -func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *pluginRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *pluginRepository) EntityName() string { - return "plugin" +func (r *pluginRepository) Read(ctx context.Context, id string) (*model.Plugin, error) { + return r.Get(ctx, id) } -func (r *pluginRepository) NewInstance() any { - return &model.Plugin{} -} - -func (r *pluginRepository) Read(id string) (any, error) { - return r.Get(id) -} - -func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) +func (r *pluginRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Plugin, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } var _ model.PluginRepository = (*pluginRepository)(nil) -var _ rest.Repository = (*pluginRepository)(nil) +var _ rest.Repository[model.Plugin] = (*pluginRepository)(nil) diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go index 9b135057e..44330250b 100644 --- a/persistence/plugin_repository_test.go +++ b/persistence/plugin_repository_test.go @@ -13,50 +13,54 @@ import ( var _ = Describe("PluginRepository", func() { var repo model.PluginRepository + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) Describe("Admin User", func() { BeforeEach(func() { - ctx := GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPluginRepository(ctx, GetDBXBuilder()) + repo = NewPluginRepository(GetDBXBuilder()) // Clean up any existing plugins - all, _ := repo.GetAll() + all, _ := repo.GetAll(ctx) for _, p := range all { - _ = repo.Delete(p.ID) + _ = repo.Delete(ctx, p.ID) } }) AfterEach(func() { // Clean up after tests - all, _ := repo.GetAll() + all, _ := repo.GetAll(ctx) for _, p := range all { - _ = repo.Delete(p.ID) + _ = repo.Delete(ctx, p.ID) } }) Describe("CountAll", func() { It("returns 0 when no plugins exist", func() { - Expect(repo.CountAll()).To(Equal(int64(0))) + Expect(repo.CountAll(ctx)).To(Equal(int64(0))) }) It("returns the number of plugins in the DB", func() { - _ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"}) - _ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"}) + _ = repo.Put(ctx, &model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"}) + _ = repo.Put(ctx, &model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"}) - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll(ctx)).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("deletes existing item", func() { plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"} - _ = repo.Put(plugin) + _ = repo.Put(ctx, plugin) - err := repo.Delete(plugin.ID) + err := repo.Delete(ctx, plugin.ID) Expect(err).To(BeNil()) - _, err = repo.Get(plugin.ID) + _, err = repo.Get(ctx, plugin.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -64,9 +68,9 @@ var _ = Describe("PluginRepository", func() { Describe("Get", func() { It("returns an existing item", func() { plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"} - _ = repo.Put(plugin) + _ = repo.Put(ctx, plugin) - res, err := repo.Get(plugin.ID) + res, err := repo.Get(ctx, plugin.ID) Expect(err).To(BeNil()) Expect(res.ID).To(Equal(plugin.ID)) Expect(res.Path).To(Equal(plugin.Path)) @@ -74,31 +78,31 @@ var _ = Describe("PluginRepository", func() { }) It("errors when missing", func() { - _, err := repo.Get("notanid") + _, err := repo.Get(ctx, "notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) }) Describe("GetAll", func() { It("returns all items from the DB", func() { - _ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"}) - _ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"}) + _ = repo.Put(ctx, &model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"}) + _ = repo.Put(ctx, &model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"}) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) Expect(all).To(HaveLen(2)) }) It("supports pagination", func() { - _ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"}) - _ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"}) - _ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"}) + _ = repo.Put(ctx, &model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(ctx, &model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"}) + _ = repo.Put(ctx, &model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"}) - page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"}) + page1, err := repo.GetAll(ctx, model.QueryOptions{Max: 2, Offset: 0, Sort: "id"}) Expect(err).To(BeNil()) Expect(page1).To(HaveLen(2)) - page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"}) + page2, err := repo.GetAll(ctx, model.QueryOptions{Max: 2, Offset: 2, Sort: "id"}) Expect(err).To(BeNil()) Expect(page2).To(HaveLen(1)) }) @@ -115,10 +119,10 @@ var _ = Describe("PluginRepository", func() { Enabled: false, } - err := repo.Put(plugin) + err := repo.Put(ctx, plugin) Expect(err).To(BeNil()) - saved, err := repo.Get(plugin.ID) + saved, err := repo.Get(ctx, plugin.ID) Expect(err).To(BeNil()) Expect(saved.Path).To(Equal(plugin.Path)) Expect(saved.Manifest).To(Equal(plugin.Manifest)) @@ -136,15 +140,15 @@ var _ = Describe("PluginRepository", func() { SHA256: "original", Enabled: false, } - _ = repo.Put(plugin) + _ = repo.Put(ctx, plugin) plugin.Enabled = true plugin.Config = `{"new":"config"}` plugin.SHA256 = "updated" - err := repo.Put(plugin) + err := repo.Put(ctx, plugin) Expect(err).To(BeNil()) - saved, err := repo.Get(plugin.ID) + saved, err := repo.Get(ctx, plugin.ID) Expect(err).To(BeNil()) Expect(saved.Enabled).To(BeTrue()) Expect(saved.Config).To(Equal(`{"new":"config"}`)) @@ -159,10 +163,10 @@ var _ = Describe("PluginRepository", func() { SHA256: "hash", LastError: "failed to load: missing export", } - err := repo.Put(plugin) + err := repo.Put(ctx, plugin) Expect(err).To(BeNil()) - saved, err := repo.Get(plugin.ID) + saved, err := repo.Get(ctx, plugin.ID) Expect(err).To(BeNil()) Expect(saved.LastError).To(Equal("failed to load: missing export")) }) @@ -173,7 +177,7 @@ var _ = Describe("PluginRepository", func() { Manifest: "{}", SHA256: "hash", } - err := repo.Put(plugin) + err := repo.Put(ctx, plugin) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ID cannot be empty")) }) @@ -181,14 +185,14 @@ var _ = Describe("PluginRepository", func() { Describe("ClearErrors", func() { It("clears last_error on all plugins with errors", func() { - _ = repo.Put(&model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"}) - _ = repo.Put(&model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"}) - _ = repo.Put(&model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"}) + _ = repo.Put(ctx, &model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(ctx, &model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"}) + _ = repo.Put(ctx, &model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"}) - err := repo.ClearErrors() + err := repo.ClearErrors(ctx) Expect(err).To(BeNil()) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) for _, p := range all { Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID) @@ -196,14 +200,14 @@ var _ = Describe("PluginRepository", func() { }) It("succeeds when no plugins have errors", func() { - _ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) - err := repo.ClearErrors() + err := repo.ClearErrors(ctx) Expect(err).To(BeNil()) }) It("does not need the write lock when no plugins have errors", func() { - _ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) conn, err := db.Db().Conn(GinkgoT().Context()) Expect(err).ToNot(HaveOccurred()) DeferCleanup(conn.Close) @@ -211,49 +215,48 @@ var _ = Describe("PluginRepository", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") }) - Expect(repo.ClearErrors()).To(Succeed()) + Expect(repo.ClearErrors(ctx)).To(Succeed()) }) }) }) Describe("Regular User", func() { BeforeEach(func() { - ctx := GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) - repo = NewPluginRepository(ctx, GetDBXBuilder()) + repo = NewPluginRepository(GetDBXBuilder()) }) Describe("CountAll", func() { It("fails to count items", func() { - _, err := repo.CountAll() + _, err := repo.CountAll(ctx) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("Delete", func() { It("fails to delete items", func() { - err := repo.Delete("any-id") + err := repo.Delete(ctx, "any-id") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("Get", func() { It("fails to get items", func() { - _, err := repo.Get("any-id") + _, err := repo.Get(ctx, "any-id") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("GetAll", func() { It("fails to get all items", func() { - _, err := repo.GetAll() + _, err := repo.GetAll(ctx) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("Put", func() { It("fails to create/update item", func() { - err := repo.Put(&model.Plugin{ + err := repo.Put(ctx, &model.Plugin{ ID: "user-create", Path: "/plugins/create.wasm", Manifest: "{}", diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 14f9051f7..29bb2b564 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -13,17 +13,16 @@ type propertyRepository struct { sqlRepository } -func NewPropertyRepository(ctx context.Context, db dbx.Builder) model.PropertyRepository { +func NewPropertyRepository(db dbx.Builder) model.PropertyRepository { r := &propertyRepository{} - r.ctx = ctx r.db = db r.tableName = "property" return r } -func (r propertyRepository) Put(id string, value string) error { +func (r propertyRepository) Put(ctx context.Context, id string, value string) error { update := Update(r.tableName).Set("value", value).Where(Eq{"id": id}) - count, err := r.executeSQL(update) + count, err := r.executeSQL(ctx, update) if err != nil { return err } @@ -31,24 +30,24 @@ func (r propertyRepository) Put(id string, value string) error { return nil } insert := Insert(r.tableName).Columns("id", "value").Values(id, value) - _, err = r.executeSQL(insert) + _, err = r.executeSQL(ctx, insert) return err } -func (r propertyRepository) Get(id string) (string, error) { +func (r propertyRepository) Get(ctx context.Context, id string) (string, error) { sel := Select("value").From(r.tableName).Where(Eq{"id": id}) resp := struct { Value string }{} - err := r.queryOne(sel, &resp) + err := r.queryOne(ctx, sel, &resp) if err != nil { return "", err } return resp.Value, nil } -func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, error) { - value, err := r.Get(id) +func (r propertyRepository) DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) { + value, err := r.Get(ctx, id) if errors.Is(err, model.ErrNotFound) { return defaultValue, nil } @@ -58,6 +57,6 @@ func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, return value, nil } -func (r propertyRepository) Delete(id string) error { - return r.delete(Eq{"id": id}) +func (r propertyRepository) Delete(ctx context.Context, id string) error { + return r.delete(ctx, Eq{"id": id}) } diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 3a0495e9f..880b315ec 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -10,25 +10,27 @@ import ( ) var _ = Describe("Property Repository", func() { + var ctx context.Context var pr model.PropertyRepository BeforeEach(func() { - pr = NewPropertyRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + ctx = log.NewContext(GinkgoT().Context()) + pr = NewPropertyRepository(GetDBXBuilder()) }) It("saves and restore a new property", func() { id := "1" value := "a_value" - Expect(pr.Put(id, value)).To(BeNil()) - Expect(pr.Get(id)).To(Equal("a_value")) + Expect(pr.Put(ctx, id, value)).To(BeNil()) + Expect(pr.Get(ctx, id)).To(Equal("a_value")) }) It("updates a property", func() { - Expect(pr.Put("1", "another_value")).To(BeNil()) - Expect(pr.Get("1")).To(Equal("another_value")) + Expect(pr.Put(ctx, "1", "another_value")).To(BeNil()) + Expect(pr.Get(ctx, "1")).To(Equal("another_value")) }) It("returns a default value if property does not exist", func() { - Expect(pr.DefaultGet("2", "default")).To(Equal("default")) + Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default")) }) }) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 4584ceaed..b5d4f3a07 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -16,9 +16,8 @@ type radioRepository struct { sqlRepository } -func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioRepository { +func NewRadioRepository(db dbx.Builder) model.RadioRepository { r := &radioRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Radio{}, map[string]filterFunc{ "name": containsFilter("name"), @@ -26,60 +25,65 @@ func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioReposito return r } -func (r *radioRepository) isPermitted() bool { - user := loggedUser(r.ctx) +func (r *radioRepository) isPermitted(ctx context.Context) bool { + user := loggedUser(ctx) return user.IsAdmin } -func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sql := r.newSelect() - return r.count(sql, options...) +func (r *radioRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + sql := r.newSelect(ctx) + return r.count(ctx, sql, options...) } // Exists needs no library or ownership filter: radios are visible to every user. -func (r *radioRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"id": id}) +func (r *radioRepository) Exists(ctx context.Context, id string) (bool, error) { + return r.exists(ctx, Eq{"id": id}) } -func (r *radioRepository) Delete(id string) error { - if !r.isPermitted() { +func (r *radioRepository) Delete(ctx context.Context, ids ...string) error { + if !r.isPermitted(ctx) { return rest.ErrPermissionDenied } - return r.deleteByID(id) + for _, id := range ids { + if err := r.deleteByID(ctx, id); err != nil { + return err + } + } + return nil } -func (r *radioRepository) Get(id string) (*model.Radio, error) { - sel := r.newSelect().Where(Eq{"id": id}).Columns("*") +func (r *radioRepository) Get(ctx context.Context, id string) (*model.Radio, error) { + sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*") res := model.Radio{} - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) if err != nil { return &res, err } list := model.Radios{res} - r.hydrateArtwork(list) + r.hydrateArtwork(ctx, list) return &list[0], nil } -func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { - sel := r.newSelect(options...).Columns("*") +func (r *radioRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Radios, error) { + sel := r.newSelect(ctx, options...).Columns("*") res := model.Radios{} - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return res, err } - r.hydrateArtwork(res) + r.hydrateArtwork(ctx, res) return res, nil } // hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. -func (r *radioRepository) hydrateArtwork(radios model.Radios) { - hydrateItems(r.ctx, r.db, model.KindRadioArtwork, radios, +func (r *radioRepository) hydrateArtwork(ctx context.Context, radios model.Radios) { + hydrateItems(ctx, r.db, model.KindRadioArtwork, radios, func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) } -func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { - if !r.isPermitted() { +func (r *radioRepository) Put(ctx context.Context, radio *model.Radio, colsToUpdate ...string) error { + if !r.isPermitted(ctx) { return rest.ErrPermissionDenied } @@ -91,7 +95,7 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error if len(colsToUpdate) > 0 { colsToUpdate = append(colsToUpdate, "UpdatedAt") } - _, err := r.put(radio.ID, radio, colsToUpdate...) + _, err := r.put(ctx, radio.ID, radio, colsToUpdate...) if err != nil { return err } @@ -99,50 +103,41 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error // radio's cover resolves proactively. Never fails the save. item := model.ArtworkQueueItem{ItemKind: model.KindRadioArtwork.Prefix(), ItemID: radio.ID, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} - if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { - log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err) + if err := NewArtworkQueueRepository(r.db).Enqueue(ctx, item); err != nil { + log.Warn(ctx, "could not enqueue radio artwork", "id", radio.ID, err) } return nil } -func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *radioRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *radioRepository) EntityName() string { - return "radio" +func (r *radioRepository) Read(ctx context.Context, id string) (*model.Radio, error) { + return r.Get(ctx, id) } -func (r *radioRepository) NewInstance() any { - return &model.Radio{} +func (r *radioRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Radio, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *radioRepository) Read(id string) (any, error) { - return r.Get(id) -} - -func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *radioRepository) Save(entity any) (string, error) { - t := entity.(*model.Radio) - if !r.isPermitted() { +func (r *radioRepository) Save(ctx context.Context, t *model.Radio) (string, error) { + if !r.isPermitted(ctx) { return "", rest.ErrPermissionDenied } - err := r.Put(t) + err := r.Put(ctx, t) return t.ID, err } -func (r *radioRepository) Update(id string, entity any, cols ...string) error { - t := entity.(*model.Radio) +func (r *radioRepository) Update(ctx context.Context, id string, entity model.Radio, cols ...string) error { + t := &entity t.ID = id - if !r.isPermitted() { + if !r.isPermitted(ctx) { return rest.ErrPermissionDenied } - return r.Put(t, cols...) + return r.Put(ctx, t, cols...) } var _ model.RadioRepository = (*radioRepository)(nil) -var _ rest.Repository = (*radioRepository)(nil) -var _ rest.Persistable = (*radioRepository)(nil) +var _ rest.Repository[model.Radio] = (*radioRepository)(nil) +var _ rest.Persistable[model.Radio] = (*radioRepository)(nil) diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 8d4b685d9..aba776853 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -13,24 +13,28 @@ import ( var _ = Describe("RadioRepository", func() { var repo model.RadioRepository + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) Describe("Admin User", func() { BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewRadioRepository(ctx, GetDBXBuilder()) - _ = repo.Put(&radioWithHomePage) + ctx = request.WithUser(log.NewContext(ctx), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewRadioRepository(GetDBXBuilder()) + _ = repo.Put(ctx, &radioWithHomePage) }) AfterEach(func() { - all, _ := repo.GetAll() + all, _ := repo.GetAll(ctx) for _, radio := range all { - _ = repo.Delete(radio.ID) + _ = repo.Delete(ctx, radio.ID) } for i := range testRadios { - err := repo.Put(new(testRadios[i])) + err := repo.Put(ctx, new(testRadios[i])) if err != nil { panic(err) } @@ -39,35 +43,35 @@ var _ = Describe("RadioRepository", func() { Describe("Count", func() { It("returns the number of radios in the DB", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll(ctx)).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("deletes existing item", func() { - err := repo.Delete(radioWithHomePage.ID) + err := repo.Delete(ctx, radioWithHomePage.ID) Expect(err).To(BeNil()) - _, err = repo.Get(radioWithHomePage.ID) + _, err = repo.Get(ctx, radioWithHomePage.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) It("errors when missing", func() { - Expect(repo.Delete("notanid")).To(MatchError(model.ErrNotFound)) + Expect(repo.Delete(ctx, "notanid")).To(MatchError(model.ErrNotFound)) }) }) Describe("Get", func() { It("returns an existing item", func() { - res, err := repo.Get(radioWithHomePage.ID) + res, err := repo.Get(ctx, radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) It("errors when missing", func() { - _, err := repo.Get("notanid") + _, err := repo.Get(ctx, "notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -75,7 +79,7 @@ var _ = Describe("RadioRepository", func() { Describe("GetAll", func() { It("returns all items from the DB", func() { - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) @@ -84,7 +88,7 @@ var _ = Describe("RadioRepository", func() { Describe("Put", func() { It("successfully updates item", func() { - err := repo.Put(&model.Radio{ + err := repo.Put(ctx, &model.Radio{ ID: radioWithHomePage.ID, Name: "New Name", StreamUrl: "https://example.com:4533/app", @@ -92,39 +96,39 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) - item, err := repo.Get(radioWithHomePage.ID) + item, err := repo.Get(ctx, radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(item.HomePageUrl).To(Equal("")) }) It("successfully creates item", func() { - err := repo.Put(&model.Radio{ + err := repo.Put(ctx, &model.Radio{ Name: "New radio", StreamUrl: "https://example.com:4533/app", }) Expect(err).To(BeNil()) - Expect(repo.CountAll()).To(Equal(int64(3))) + Expect(repo.CountAll(ctx)).To(Equal(int64(3))) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) It("enqueues artwork resolution for the saved radio", func() { - err := repo.Put(&model.Radio{ + err := repo.Put(ctx, &model.Radio{ Name: "Artwork radio", StreamUrl: "https://example.com:4533/artwork", }) Expect(err).To(BeNil()) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) created := all[len(all)-1] - queueRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) - queued, err := queueRepo.DequeueBatch(1000) + queueRepo := NewArtworkQueueRepository(GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(ctx, 1000) Expect(err).To(BeNil()) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "ra"), @@ -138,12 +142,11 @@ var _ = Describe("RadioRepository", func() { It("only writes the columns sent by the client", func() { radio := radioWithHomePage radio.UploadedImage = "cover.png" - Expect(repo.Put(&radio)).To(Succeed()) + Expect(repo.Put(ctx, &radio)).To(Succeed()) - persistable := repo.(rest.Persistable) - Expect(persistable.Update(radio.ID, &model.Radio{Name: "Renamed"}, "name")).To(Succeed()) + Expect(repo.Update(ctx, radio.ID, model.Radio{Name: "Renamed"}, "name")).To(Succeed()) - item, err := repo.Get(radio.ID) + item, err := repo.Get(ctx, radio.ID) Expect(err).To(BeNil()) Expect(item.Name).To(Equal("Renamed")) Expect(item.UploadedImage).To(Equal("cover.png")) @@ -155,20 +158,19 @@ var _ = Describe("RadioRepository", func() { Describe("Regular User", func() { BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) - repo = NewRadioRepository(ctx, GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(ctx), model.User{ID: "userid", UserName: "userid", IsAdmin: false}) + repo = NewRadioRepository(GetDBXBuilder()) }) Describe("Count", func() { It("returns the number of radios in the DB", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll(ctx)).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("fails to delete items", func() { - err := repo.Delete(radioWithHomePage.ID) + err := repo.Delete(ctx, radioWithHomePage.ID) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) @@ -176,14 +178,14 @@ var _ = Describe("RadioRepository", func() { Describe("Get", func() { It("returns an existing item", func() { - res, err := repo.Get(radioWithHomePage.ID) + res, err := repo.Get(ctx, radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) It("errors when missing", func() { - _, err := repo.Get("notanid") + _, err := repo.Get(ctx, "notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -191,7 +193,7 @@ var _ = Describe("RadioRepository", func() { Describe("GetAll", func() { It("returns all items from the DB", func() { - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) @@ -200,7 +202,7 @@ var _ = Describe("RadioRepository", func() { Describe("Put", func() { It("fails to update item", func() { - err := repo.Put(&model.Radio{ + err := repo.Put(ctx, &model.Radio{ ID: radioWithHomePage.ID, Name: "New Name", StreamUrl: "https://example.com:4533/app", diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index cf54c664a..0daf2b0e7 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -29,15 +29,14 @@ func (t *dbScrobbleBuffer) PostScan() error { return nil } -func NewScrobbleBufferRepository(ctx context.Context, db dbx.Builder) model.ScrobbleBufferRepository { +func NewScrobbleBufferRepository(db dbx.Builder) model.ScrobbleBufferRepository { r := &scrobbleBufferRepository{} - r.ctx = ctx r.db = db r.tableName = "scrobble_buffer" return r } -func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) { +func (r *scrobbleBufferRepository) UserIDs(ctx context.Context, service string) ([]string, error) { sql := Select().Columns("user_id"). From(r.tableName). Where(And{ @@ -46,11 +45,11 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) { GroupBy("user_id"). OrderBy("count(*)") var userIds []string - err := r.queryAllSlice(sql, &userIds) + err := r.queryAllSlice(ctx, sql, &userIds) return userIds, err } -func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { +func (r *scrobbleBufferRepository) Enqueue(ctx context.Context, service, userId, mediaFileId string, playTime time.Time) error { ins := Insert(r.tableName).SetMap(map[string]any{ "id": id.NewRandom(), "user_id": userId, @@ -59,11 +58,11 @@ func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, "play_time": playTime, "enqueue_time": time.Now(), }) - _, err := r.executeSQL(ins) + _, err := r.executeSQL(ctx, ins) return err } -func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.ScrobbleEntry, error) { +func (r *scrobbleBufferRepository) Next(ctx context.Context, service string, userId string) (*model.ScrobbleEntry, error) { // Put `s.*` last or else m.id overrides s.id sql := Select().Columns("m.*, s.*"). From(r.tableName+" s"). @@ -75,30 +74,30 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S OrderBy("play_time", "s.rowid").Limit(1) var res dbScrobbleBuffer - err := r.queryOne(sql, &res) + err := r.queryOne(ctx, sql, &res) if errors.Is(err, model.ErrNotFound) { return nil, nil } if err != nil { return nil, err } - res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile) + res.ScrobbleEntry.Participants, err = r.getParticipants(ctx, &res.ScrobbleEntry.MediaFile) if err != nil { return nil, err } return res.ScrobbleEntry, nil } -func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { - return r.delete(Eq{"id": entry.ID}) +func (r *scrobbleBufferRepository) Dequeue(ctx context.Context, entry *model.ScrobbleEntry) error { + return r.delete(ctx, Eq{"id": entry.ID}) } -func (r *scrobbleBufferRepository) Discard(service string) error { - return r.delete(Eq{"service": service}) +func (r *scrobbleBufferRepository) Discard(ctx context.Context, service string) error { + return r.delete(ctx, Eq{"service": service}) } -func (r *scrobbleBufferRepository) Length() (int64, error) { - return r.count(Select()) +func (r *scrobbleBufferRepository) Length(ctx context.Context) (int64, error) { + return r.count(ctx, Select()) } var _ model.ScrobbleBufferRepository = (*scrobbleBufferRepository)(nil) diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index 3aa71070e..38b0e4e63 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -16,6 +16,7 @@ import ( var _ = Describe("ScrobbleBufferRepository", func() { var scrobble model.ScrobbleBufferRepository var rawRepo sqlRepository + var ctx context.Context enqueueTime := time.Date(2025, 01, 01, 00, 00, 00, 00, time.Local) var ids []string @@ -32,17 +33,16 @@ var _ = Describe("ScrobbleBufferRepository", func() { "play_time": playTime, "enqueue_time": enqueueTime, }) - _, err := rawRepo.executeSQL(ins) + _, err := rawRepo.executeSQL(ctx, ins) Expect(err).ToNot(HaveOccurred()) } BeforeEach(func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) db := GetDBXBuilder() - scrobble = NewScrobbleBufferRepository(ctx, db) + scrobble = NewScrobbleBufferRepository(db) rawRepo = sqlRepository{ - ctx: ctx, tableName: "scrobble_buffer", db: db, } @@ -51,14 +51,14 @@ var _ = Describe("ScrobbleBufferRepository", func() { AfterEach(func() { del := squirrel.Delete(rawRepo.tableName) - _, err := rawRepo.executeSQL(del) + _, err := rawRepo.executeSQL(ctx, del) Expect(err).ToNot(HaveOccurred()) }) Describe("Without data", func() { Describe("Count", func() { It("returns zero when empty", func() { - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeZero()) }) @@ -66,10 +66,10 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Dequeue", func() { It("is a no-op when deleting a nonexistent item", func() { - err := scrobble.Dequeue(&model.ScrobbleEntry{ID: "fake"}) + err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: "fake"}) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(0))) }) @@ -77,7 +77,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Next", func() { It("should not fail with no item for the service", func() { - entry, err := scrobble.Next("fake", "userid") + entry, err := scrobble.Next(ctx, "fake", "userid") Expect(entry).To(BeNil()) Expect(err).ToNot(HaveOccurred()) }) @@ -85,7 +85,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("UserIds", func() { It("should return empty list with no data", func() { - ids, err := scrobble.UserIDs("service") + ids, err := scrobble.UserIDs(ctx, "service") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(BeEmpty()) }) @@ -107,7 +107,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Count", func() { It("Returns count when populated", func() { - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) }) @@ -115,23 +115,23 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Dequeue", func() { It("is a no-op when deleting a nonexistent item", func() { - err := scrobble.Dequeue(&model.ScrobbleEntry{ID: "fake"}) + err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: "fake"}) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) }) It("deletes an item when specified properly", func() { - err := scrobble.Dequeue(&model.ScrobbleEntry{ID: ids[3]}) + err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: ids[3]}) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(3))) - entry, err := scrobble.Next("b", "2222") + entry, err := scrobble.Next(ctx, "b", "2222") Expect(err).ToNot(HaveOccurred()) Expect(entry).To(BeNil()) }) @@ -141,14 +141,14 @@ var _ = Describe("ScrobbleBufferRepository", func() { DescribeTable("enqueues an item properly", func(service, userId, fileId string, playTime time.Time) { now := time.Now() - err := scrobble.Enqueue(service, userId, fileId, playTime) + err := scrobble.Enqueue(ctx, service, userId, fileId, playTime) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(5))) - entry, err := scrobble.Next(service, userId) + entry, err := scrobble.Next(ctx, service, userId) Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) @@ -165,7 +165,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Next", func() { DescribeTable("Returns the next item when populated", func(service, id string, playTime time.Time, fileId, artistId string) { - entry, err := scrobble.Next(service, id) + entry, err := scrobble.Next(ctx, service, id) Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) @@ -193,21 +193,21 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Discard", func() { It("deletes all entries for a service, keeping other services intact", func() { - Expect(scrobble.Discard("a")).To(Succeed()) + Expect(scrobble.Discard(ctx, "a")).To(Succeed()) - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(1))) - entry, err := scrobble.Next("b", "2222") + entry, err := scrobble.Next(ctx, "b", "2222") Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) }) It("is a no-op for a service without entries", func() { - Expect(scrobble.Discard("nonexistent")).To(Succeed()) + Expect(scrobble.Discard(ctx, "nonexistent")).To(Succeed()) - count, err := scrobble.Length() + count, err := scrobble.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) }) @@ -215,13 +215,13 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("UserIds", func() { It("should return ordered list for services", func() { - ids, err := scrobble.UserIDs("a") + ids, err := scrobble.UserIDs(ctx, "a") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(Equal([]string{"2222", "userid"})) }) It("should return for a different service", func() { - ids, err := scrobble.UserIDs("b") + ids, err := scrobble.UserIDs(ctx, "b") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(Equal([]string{"2222"})) }) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 7cc60ae23..3d9882b99 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -22,17 +22,16 @@ func toTs(_ string, value any) Sqlizer { return LtOrEq{"scrobbles.submission_time": value} } -func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder { - user := loggedUser(r.ctx) +func (r *scrobbleRepository) baseQuery(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + user := loggedUser(ctx) - return r.newSelect(options...). + return r.newSelect(ctx, options...). Columns("id", "media_file_id", "submission_time"). Where(Eq{"scrobbles.user_id": user.ID}) } -func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { +func NewScrobbleRepository(db dbx.Builder) model.ScrobbleRepository { r := &scrobbleRepository{} - r.ctx = ctx r.db = db r.tableName = "scrobbles" r.registerModel(&model.Scrobble{}, map[string]filterFunc{ @@ -45,55 +44,47 @@ func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRe return r } -func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error { - userID := loggedUser(r.ctx).ID +func (r *scrobbleRepository) RecordScrobble(ctx context.Context, mediaFileID string, submissionTime time.Time) error { + userID := loggedUser(ctx).ID values := map[string]any{ "media_file_id": mediaFileID, "user_id": userID, "submission_time": submissionTime.Unix(), } insert := Insert(r.tableName).SetMap(values) - _, err := r.executeSQL(insert) + _, err := r.executeSQL(ctx, insert) return err } -func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { - return r.count(r.baseQuery(), options...) +func (r *scrobbleRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + return r.count(ctx, r.baseQuery(ctx), options...) } -func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *scrobbleRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { - sel := r.baseQuery().Where(Eq{"id": id}) +func (r *scrobbleRepository) Get(ctx context.Context, id string) (*model.Scrobble, error) { + sel := r.baseQuery(ctx).Where(Eq{"id": id}) var res model.Scrobble - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { - sel := r.baseQuery(options...) +func (r *scrobbleRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Scrobbles, error) { + sel := r.baseQuery(ctx, options...) var scrobbles model.Scrobbles - err := r.queryAll(sel, &scrobbles) + err := r.queryAll(ctx, sel, &scrobbles) return scrobbles, err } -func (r *scrobbleRepository) Read(id string) (any, error) { - return r.Get(id) +func (r *scrobbleRepository) Read(ctx context.Context, id string) (*model.Scrobble, error) { + return r.Get(ctx, id) } -func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *scrobbleRepository) EntityName() string { - return "scrobble" -} - -func (r *scrobbleRepository) NewInstance() any { - return &model.Scrobble{} +func (r *scrobbleRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Scrobble, error) { + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) -var _ model.ResourceRepository = (*scrobbleRepository)(nil) +var _ rest.Repository[model.Scrobble] = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index e9103b127..860b1ad02 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -28,10 +28,9 @@ var _ = Describe("ScrobbleRepository", func() { userID = id.NewRandom() ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) db := GetDBXBuilder() - repo = NewScrobbleRepository(ctx, db) + repo = NewScrobbleRepository(db) rawRepo = sqlRepository{ - ctx: ctx, tableName: "scrobbles", db: db, } @@ -65,7 +64,7 @@ var _ = Describe("ScrobbleRepository", func() { }).Execute() Expect(err).ToNot(HaveOccurred()) - err = repo.RecordScrobble(fileID, submissionTime) + err = repo.RecordScrobble(ctx, fileID, submissionTime) Expect(err).ToNot(HaveOccurred()) // Verify insertion @@ -87,22 +86,22 @@ var _ = Describe("ScrobbleRepository", func() { Context("admin user (id userid)", func() { BeforeEach(func() { ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) - repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + repo = NewScrobbleRepository(GetDBXBuilder()) }) Describe("Count", func() { It("Returns the number of scrobbles in the DB for admin user", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + Expect(repo.CountAll(ctx)).To(Equal(int64(2))) }) It("returns scrobbles in a range", func() { - Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) + Expect(repo.CountAll(ctx, model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) }) }) Describe("Get", func() { It("returns an existing scrobble for the user", func() { - scrobble, err := repo.Get("1") + scrobble, err := repo.Get(ctx, "1") Expect(err).To(BeNil()) Expect(scrobble.ID).To(Equal(int64(1))) Expect(scrobble.MediaFileID).To(Equal("1001")) @@ -111,19 +110,19 @@ var _ = Describe("ScrobbleRepository", func() { }) It("does not return a scrobble that exists for another user", func() { - _, err := repo.Get("2") + _, err := repo.Get(ctx, "2") Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not return a scrobble that does not exist", func() { - _, err := repo.Get("444") + _, err := repo.Get(ctx, "444") Expect(err).To(MatchError(model.ErrNotFound)) }) }) Describe("GetAll", func() { It("returns all scrobbles in reverse order", func() { - scrobbles, err := repo.GetAll(model.QueryOptions{ + scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ Sort: "submission_time", Order: "DESC", }) @@ -140,7 +139,7 @@ var _ = Describe("ScrobbleRepository", func() { }) It("returns scrobbles in a range", func() { - scrobbles, err := repo.GetAll(model.QueryOptions{ + scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ Filters: squirrel.GtOrEq{"submission_time": 1}}) Expect(err).To(BeNil()) @@ -156,22 +155,22 @@ var _ = Describe("ScrobbleRepository", func() { Context("non-admin user", func() { BeforeEach(func() { ctx = request.WithUser(log.NewContext(context.TODO()), regularUser) - repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + repo = NewScrobbleRepository(GetDBXBuilder()) }) Describe("Count", func() { It("Returns the number of scrobbles in the DB for admin user", func() { - Expect(repo.CountAll()).To(Equal(int64(1))) + Expect(repo.CountAll(ctx)).To(Equal(int64(1))) }) It("returns scrobbles in a range", func() { - Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) + Expect(repo.CountAll(ctx, model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) }) }) Describe("Get", func() { It("returns an existing scrobble for the user", func() { - scrobble, err := repo.Get("2") + scrobble, err := repo.Get(ctx, "2") Expect(err).To(BeNil()) Expect(scrobble.ID).To(Equal(int64(2))) Expect(scrobble.MediaFileID).To(Equal("1003")) @@ -179,19 +178,19 @@ var _ = Describe("ScrobbleRepository", func() { }) It("does not return a scrobble that exists for another user", func() { - _, err := repo.Get("1") + _, err := repo.Get(ctx, "1") Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not return a scrobble that does not exist", func() { - _, err := repo.Get("444") + _, err := repo.Get(ctx, "444") Expect(err).To(MatchError(model.ErrNotFound)) }) }) Describe("GetAll", func() { It("returns all scrobbles in reverse order", func() { - scrobbles, err := repo.GetAll(model.QueryOptions{ + scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ Sort: "submission_time", Order: "DESC", }) @@ -204,7 +203,7 @@ var _ = Describe("ScrobbleRepository", func() { }) It("returns scrobbles in a range", func() { - scrobbles, err := repo.GetAll(model.QueryOptions{ + scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ Filters: squirrel.GtOrEq{"submission_time": 1}}) Expect(err).To(BeNil()) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 0cf23068a..6dd9c3d85 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -18,9 +18,8 @@ type shareRepository struct { sqlRepository } -func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository { +func NewShareRepository(db dbx.Builder) model.ShareRepository { r := &shareRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Share{}, nil) r.setSortMappings(map[string]string{ @@ -29,40 +28,40 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito return r } -func (r *shareRepository) Delete(id string) error { - return r.deleteOwned(id) +func (r *shareRepository) Delete(ctx context.Context, ids ...string) error { + return r.deleteOwnedAll(ctx, ids...) } -func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user u on u.id = share.user_id"). +func (r *shareRepository) selectShare(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + return r.newSelect(ctx, options...).Join("user u on u.id = share.user_id"). Columns("share.*", "user_name as username"). - Where(r.addRestriction()) + Where(r.addRestriction(ctx)) } -func (r *shareRepository) Exists(id string) (bool, error) { - return r.exists(r.addRestriction(And{Eq{"id": id}})) +func (r *shareRepository) Exists(ctx context.Context, id string) (bool, error) { + return r.exists(ctx, r.addRestriction(ctx, And{Eq{"id": id}})) } -func (r *shareRepository) Get(id string) (*model.Share, error) { - sel := r.selectShare().Where(Eq{"share.id": id}) +func (r *shareRepository) Get(ctx context.Context, id string) (*model.Share, error) { + sel := r.selectShare(ctx).Where(Eq{"share.id": id}) var res model.Share - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) if err != nil { return nil, err } - err = r.loadMedia(&res) + err = r.loadMedia(ctx, &res) return &res, err } -func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, error) { - sq := r.selectShare(options...) +func (r *shareRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Shares, error) { + sq := r.selectShare(ctx, options...) res := model.Shares{} - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) if err != nil { return nil, err } for i := range res { - err = r.loadMedia(&res[i]) + err = r.loadMedia(ctx, &res[i]) if err != nil { return nil, fmt.Errorf("error loading media for share %s: %w", res[i].ID, err) } @@ -70,7 +69,7 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e return res, err } -func (r *shareRepository) loadMedia(share *model.Share) error { +func (r *shareRepository) loadMedia(ctx context.Context, share *model.Share) error { ids := strings.Split(share.ResourceIDs, ",") if len(ids) == 0 { return nil @@ -79,7 +78,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error { return And{cond, Eq{"missing": false}} } // Load as the share owner so their library access is applied, whoever renders the share. - ctx, err := r.ownerContext(share) + ownerCtx, err := r.ownerContext(ctx, share) if err != nil { return err } @@ -87,59 +86,59 @@ func (r *shareRepository) loadMedia(share *model.Share) error { case "artist": // Match by album-artist participation, not the deprecated album_artist_id // column (first album artist only), so co-album-artists are included too. - albumRepo := NewAlbumRepository(ctx, r.db) - share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) + albumRepo := NewAlbumRepository(r.db) + share.Albums, err = albumRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) if err != nil { return err } - mfRepo := NewMediaFileRepository(ctx, r.db) - share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) + mfRepo := NewMediaFileRepository(r.db) + share.Tracks, err = mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) return err case "album": - albumRepo := NewAlbumRepository(ctx, r.db) - share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) + albumRepo := NewAlbumRepository(r.db) + share.Albums, err = albumRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) if err != nil { return err } - mfRepo := NewMediaFileRepository(ctx, r.db) - share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) + mfRepo := NewMediaFileRepository(r.db) + share.Tracks, err = mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - plsRepo := NewPlaylistRepository(ctx, r.db) + plsRepo := NewPlaylistRepository(r.db) // Tracks returns nil when the playlist is no longer visible to the owner // (e.g. it was made private after the share was created); leave the share // with no tracks rather than exposing it. - trackRepo := plsRepo.Tracks(ids[0], true) + trackRepo := plsRepo.Tracks(ownerCtx, ids[0], true) if trackRepo == nil { return nil } - tracks, err := trackRepo.GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + tracks, err := trackRepo.GetAll(ownerCtx, model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) if err != nil { return err } share.Tracks = tracks.MediaFiles() return nil case "media_file": - mfRepo := NewMediaFileRepository(ctx, r.db) - tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) + mfRepo := NewMediaFileRepository(r.db) + tracks, err := mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) share.Tracks = sortByIdPosition(tracks, ids) return err } - log.Warn(r.ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) + log.Warn(ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) return nil } // ownerContext returns a context scoped to the share owner, so repository // queries apply the owner's library access when a public share is rendered. -func (r *shareRepository) ownerContext(share *model.Share) (context.Context, error) { - owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) +func (r *shareRepository) ownerContext(ctx context.Context, share *model.Share) (context.Context, error) { + owner, err := NewUserRepository(r.db).Get(ctx, share.UserID) if err != nil { return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err) } if owner == nil { return nil, fmt.Errorf("share owner %q not found", share.UserID) } - return request.WithUser(r.ctx, *owner), nil + return request.WithUser(ctx, *owner), nil } func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { @@ -156,60 +155,51 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { return sorted } -func (r *shareRepository) Update(id string, entity any, cols ...string) error { - s := entity.(*model.Share) +func (r *shareRepository) Update(ctx context.Context, id string, entity model.Share, cols ...string) error { + s := &entity s.ID = id s.UpdatedAt = time.Now() if len(cols) > 0 { cols = append(cols, "updated_at") } - return r.updateOwned(id, s, cols...) + return r.updateOwned(ctx, id, s, cols...) } -func (r *shareRepository) Save(entity any) (string, error) { - s := entity.(*model.Share) +func (r *shareRepository) Save(ctx context.Context, s *model.Share) (string, error) { // TODO Validate record // Owner is server-managed: for an authenticated request, never trust a // client-supplied UserID, as it drives the share's library-access context. - u := loggedUser(r.ctx) + u := loggedUser(ctx) if u.ID != invalidUserId || s.UserID == "" { s.UserID = u.ID } s.CreatedAt = time.Now() s.UpdatedAt = time.Now() - return r.put(s.ID, s) + return r.put(ctx, s.ID, s) } -func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) { - return r.count(r.selectShare(), options...) +func (r *shareRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { + return r.count(ctx, r.selectShare(ctx), options...) } -func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) +func (r *shareRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *shareRepository) EntityName() string { - return "share" -} - -func (r *shareRepository) NewInstance() any { - return &model.Share{} -} - -func (r *shareRepository) Read(id string) (any, error) { - sel := r.selectShare().Where(Eq{"share.id": id}) +func (r *shareRepository) Read(ctx context.Context, id string) (*model.Share, error) { + sel := r.selectShare(ctx).Where(Eq{"share.id": id}) var res model.Share - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - sq := r.selectShare(r.parseRestOptions(r.ctx, options...)) +func (r *shareRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Share, error) { + sq := r.selectShare(ctx, r.parseRestOptions(ctx, options...)) res := model.Shares{} - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) return res, err } var _ model.ShareRepository = (*shareRepository)(nil) -var _ rest.Repository = (*shareRepository)(nil) -var _ rest.Persistable = (*shareRepository)(nil) +var _ rest.Repository[model.Share] = (*shareRepository)(nil) +var _ rest.Persistable[model.Share] = (*shareRepository)(nil) diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index af8cf2ae9..33b5e2110 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -22,11 +22,11 @@ var _ = Describe("ShareRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo = NewShareRepository(ctx, GetDBXBuilder()) + repo = NewShareRepository(GetDBXBuilder()) // Insert the admin user into the database (required for foreign key constraint) - ur := NewUserRepository(ctx, GetDBXBuilder()) - err := ur.Put(&adminUser) + ur := NewUserRepository(GetDBXBuilder()) + err := ur.Put(ctx, &adminUser) Expect(err).ToNot(HaveOccurred()) // Clean up shares @@ -37,9 +37,15 @@ var _ = Describe("ShareRepository", func() { Describe("Headless Access", func() { Context("Repository creation and basic operations", func() { + var headlessCtx context.Context + + BeforeEach(func() { + headlessCtx = GinkgoT().Context() + }) + It("should create repository successfully with no user context", func() { // Create repository with no user context (headless) - headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GetDBXBuilder()) Expect(headlessRepo).ToNot(BeNil()) }) @@ -61,8 +67,8 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should see all shares - headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - shares, err := headlessRepo.GetAll() + headlessRepo := NewShareRepository(GetDBXBuilder()) + shares, err := headlessRepo.GetAll(headlessCtx) Expect(err).ToNot(HaveOccurred()) found := false @@ -93,8 +99,8 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the share - headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - share, err := headlessRepo.Get(shareID) + headlessRepo := NewShareRepository(GetDBXBuilder()) + share, err := headlessRepo.Get(headlessCtx, shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) Expect(share.Description).To(Equal("Headless Get Share")) @@ -125,7 +131,7 @@ var _ = Describe("ShareRepository", func() { // The Get operation should work without SQL ambiguity errors // even if no albums are found - share, err := repo.Get(shareID) + share, err := repo.Get(ctx, shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) // Albums array should be empty since we used non-existent album ID @@ -142,26 +148,26 @@ var _ = Describe("ShareRepository", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) // A second library the owner has no access to, plus a track in it - lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + lr := NewLibraryRepository(GetDBXBuilder()) otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"} - Expect(lr.Put(&otherLib)).To(Succeed()) - mr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) - Expect(mr.Put(&model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) - Expect(mr.Put(&model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed()) + Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) + mr := NewMediaFileRepository(GetDBXBuilder()) + Expect(mr.Put(adminCtx, &model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) + Expect(mr.Put(adminCtx, &model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed()) // Non-admin owner with access to library 1 only owner = createUserWithLibraries("share-owner", []int{1}) - ur := NewUserRepository(adminCtx, GetDBXBuilder()) - Expect(ur.Put(&owner)).To(Succeed()) - Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + ur := NewUserRepository(GetDBXBuilder()) + Expect(ur.Put(adminCtx, &owner)).To(Succeed()) + Expect(ur.SetUserLibraries(adminCtx, owner.ID, []int{1})).To(Succeed()) // Owner-owned playlist containing tracks from both libraries plsID = "share-scope-pls" ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), owner) - pr := NewPlaylistRepository(ownerCtx, GetDBXBuilder()) + pr := NewPlaylistRepository(GetDBXBuilder()) pls := &model.Playlist{ID: plsID, Name: "Scope Test", OwnerID: owner.ID} pls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}, {ID: "share-other"}}) - Expect(pr.Put(pls)).To(Succeed()) + Expect(pr.Put(ownerCtx, pls)).To(Succeed()) // Share row owned by the non-admin owner _, err := GetDBXBuilder().NewQuery(` @@ -178,20 +184,21 @@ var _ = Describe("ShareRepository", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) b := GetDBXBuilder() _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'share-scope'`).Execute() - pr := NewPlaylistRepository(adminCtx, b) - _ = pr.Delete(plsID) - mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) - _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) - lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) - _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + pr := NewPlaylistRepository(b) + _ = pr.Delete(adminCtx, plsID) + mr := NewMediaFileRepository(b).(*mediaFileRepository) + _, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) + lr := NewLibraryRepository(b).(*libraryRepository) + _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(b).Delete(adminCtx, owner.ID) }) It("excludes tracks the owner cannot access from the shared playlist", func() { // Read the share as admin (mimics the public-share render path, which uses // the share repository's own context). loadMedia must scope to the owner. - adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) - share, err := adminRepo.Get("share-scope") + adminCtx := request.WithUser(ctx, adminUser) + adminRepo := NewShareRepository(GetDBXBuilder()) + share, err := adminRepo.Get(adminCtx, "share-scope") Expect(err).ToNot(HaveOccurred()) Expect(share.Tracks).To(ContainElement(HaveField("ID", "share-ok"))) @@ -205,11 +212,11 @@ var _ = Describe("ShareRepository", func() { // instead of panicking. privatePlsID := "private-pls" adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - pr := NewPlaylistRepository(adminCtx, GetDBXBuilder()) + pr := NewPlaylistRepository(GetDBXBuilder()) privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false} privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}}) - Expect(pr.Put(privatePls)).To(Succeed()) - DeferCleanup(func() { _ = pr.Delete(privatePlsID) }) + Expect(pr.Put(adminCtx, privatePls)).To(Succeed()) + DeferCleanup(func() { _ = pr.Delete(adminCtx, privatePlsID) }) _, err := GetDBXBuilder().NewQuery(` INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) @@ -221,8 +228,8 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery(`DELETE FROM share WHERE id = 'share-private'`).Execute() }) - adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) - share, err := adminRepo.Get("share-private") + adminRepo := NewShareRepository(GetDBXBuilder()) + share, err := adminRepo.Get(adminCtx, "share-private") Expect(err).ToNot(HaveOccurred()) Expect(share.Tracks).To(BeEmpty()) }) @@ -239,13 +246,13 @@ var _ = Describe("ShareRepository", func() { b := GetDBXBuilder() // A second library the owner has no access to - lr := NewLibraryRepository(adminCtx, b) + lr := NewLibraryRepository(b) otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"} - Expect(lr.Put(&otherLib)).To(Succeed()) + Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) - ar := NewArtistRepository(adminCtx, b) - Expect(createArtistWithLibrary(ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed()) - Expect(createArtistWithLibrary(ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed()) + ar := NewArtistRepository(b) + Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed()) + Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed()) // Secondary is a co-album-artist (not the first): album_artist_id points at // primary, so the legacy-column filter would miss both tracks. @@ -253,19 +260,19 @@ var _ = Describe("ShareRepository", func() { {Artist: model.Artist{ID: primaryID, Name: "AA Primary"}}, {Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}}, }} - alr := NewAlbumRepository(adminCtx, b) - Expect(alr.Put(&model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) - Expect(alr.Put(&model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) + alr := NewAlbumRepository(b) + Expect(alr.Put(ctx, &model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) + Expect(alr.Put(ctx, &model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) - mr := NewMediaFileRepository(adminCtx, b) - Expect(mr.Put(&model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) - Expect(mr.Put(&model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) + mr := NewMediaFileRepository(b) + Expect(mr.Put(adminCtx, &model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) + Expect(mr.Put(adminCtx, &model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) // Non-admin owner with access to library 1 only owner = createUserWithLibraries("artist-share-owner", []int{1}) - ur := NewUserRepository(adminCtx, b) - Expect(ur.Put(&owner)).To(Succeed()) - Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + ur := NewUserRepository(b) + Expect(ur.Put(adminCtx, &owner)).To(Succeed()) + Expect(ur.SetUserLibraries(adminCtx, owner.ID, []int{1})).To(Succeed()) for _, s := range []struct{ id, typ, ids string }{ {"art-share", "artist", secondaryID}, @@ -287,22 +294,23 @@ var _ = Describe("ShareRepository", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) b := GetDBXBuilder() _, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute() - mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) - _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) - alr := NewAlbumRepository(adminCtx, b).(*albumRepository) - _, _ = alr.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}})) - ar := NewArtistRepository(adminCtx, b).(*artistRepository) - _, _ = ar.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}})) - lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) - _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + mr := NewMediaFileRepository(b).(*mediaFileRepository) + _, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) + alr := NewAlbumRepository(b).(*albumRepository) + _, _ = alr.executeSQL(adminCtx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}})) + ar := NewArtistRepository(b).(*artistRepository) + _, _ = ar.executeSQL(adminCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}})) + lr := NewLibraryRepository(b).(*libraryRepository) + _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(b).Delete(adminCtx, owner.ID) }) It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() { // Read as admin (mimics the public-share render path); loadMedia must still // scope to the owner's libraries. - adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) - share, err := adminRepo.Get("art-share") + adminCtx := request.WithUser(ctx, adminUser) + adminRepo := NewShareRepository(GetDBXBuilder()) + share, err := adminRepo.Get(adminCtx, "art-share") Expect(err).ToNot(HaveOccurred()) Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")), @@ -318,7 +326,7 @@ var _ = Describe("ShareRepository", func() { It("excludes albums and their tracks outside the owner's libraries from an album share", func() { // Public share rendering has no user in the context. - share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-album-share") + share, err := NewShareRepository(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "art-album-share") Expect(err).ToNot(HaveOccurred()) Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok"))) Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other"))) @@ -327,7 +335,7 @@ var _ = Describe("ShareRepository", func() { }) It("excludes tracks outside the owner's libraries from a media file share", func() { - share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-mf-share") + share, err := NewShareRepository(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "art-mf-share") Expect(err).ToNot(HaveOccurred()) Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) @@ -358,58 +366,59 @@ var _ = Describe("ShareRepository", func() { It("allows a non-admin user to delete their own share", func() { insertShare("own-share-del", ownerUser.ID) ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Delete("own-share-del") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Delete(ctx, "own-share-del") Expect(err).ToNot(HaveOccurred()) }) It("denies a non-admin user from deleting another user's share", func() { insertShare("other-share-del", ownerUser.ID) ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Delete("other-share-del") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Delete(ctx, "other-share-del") Expect(err).To(Equal(rest.ErrPermissionDenied)) // The share was not deleted: the owner can still read it. ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder()) - _, err = ownerRepo.(rest.Repository).Read("other-share-del") + ownerRepo := NewShareRepository(GetDBXBuilder()) + _, err = ownerRepo.Read(ownerCtx, "other-share-del") Expect(err).ToNot(HaveOccurred()) }) It("allows an admin to delete any user's share", func() { insertShare("admin-del-share", ownerUser.ID) ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Delete("admin-del-share") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Delete(ctx, "admin-del-share") Expect(err).ToNot(HaveOccurred()) }) It("allows headless context (no user) to delete a share", func() { insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - err := repo.(rest.Persistable).Delete("headless-del-share") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Delete(GinkgoT().Context(), "headless-del-share") Expect(err).ToNot(HaveOccurred()) }) }) Describe("Save", func() { It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { - ur := NewUserRepository(ctx, GetDBXBuilder()) - Expect(ur.Put(&ownerUser)).To(Succeed()) - Expect(ur.Put(&otherUser)).To(Succeed()) + ur := NewUserRepository(GetDBXBuilder()) + Expect(ur.Put(ctx, &ownerUser)).To(Succeed()) + Expect(ur.Put(ctx, &otherUser)).To(Succeed()) attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - attackerRepo := NewShareRepository(attackerCtx, GetDBXBuilder()).(rest.Persistable) + attackerRepo := NewShareRepository(GetDBXBuilder()) - id, err := attackerRepo.Save(&model.Share{ + id, err := attackerRepo.Save(attackerCtx, &model.Share{ ID: "spoof-save-share", UserID: otherUser.ID, ResourceType: "media_file", ResourceIDs: "1001", }) Expect(err).ToNot(HaveOccurred()) - adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) - got, err := adminRepo.Get(id) + adminCtx := request.WithUser(ctx, adminUser) + adminRepo := NewShareRepository(GetDBXBuilder()) + got, err := adminRepo.Get(adminCtx, id) Expect(err).ToNot(HaveOccurred()) Expect(got.UserID).To(Equal(ownerUser.ID)) }) @@ -419,53 +428,52 @@ var _ = Describe("ShareRepository", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Update(ctx, "own-share-upd", model.Share{Description: "Updated"}, "description") Expect(err).ToNot(HaveOccurred()) }) It("denies a non-admin user from updating another user's share", func() { insertShare("other-share-upd", ownerUser.ID) ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Update(ctx, "other-share-upd", model.Share{Description: "Hacked"}, "description") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) It("allows an admin to update any user's share", func() { insertShare("admin-upd-share", ownerUser.ID) ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Update(ctx, "admin-upd-share", model.Share{Description: "Admin Updated"}, "description") Expect(err).ToNot(HaveOccurred()) }) It("allows headless context (no user) to update a share", func() { insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Update(GinkgoT().Context(), "headless-upd-share", model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) It("returns not found when updating a nonexistent share", func() { ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Update(ctx, "does-not-exist", model.Share{Description: "Ghost"}, "description") Expect(err).To(Equal(rest.ErrNotFound)) }) It("updates all columns when no specific columns are given", func() { insertShare("all-cols-share", ownerUser.ID) ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) + repo := NewShareRepository(GetDBXBuilder()) // No cols: the update must write every column, not just updated_at. - err := repo.(rest.Persistable).Update("all-cols-share", - &model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) + err := repo.Update(ctx, "all-cols-share", + model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) Expect(err).ToNot(HaveOccurred()) - got, err := repo.(rest.Repository).Read("all-cols-share") + share, err := repo.Read(ctx, "all-cols-share") Expect(err).ToNot(HaveOccurred()) - share := got.(*model.Share) Expect(share.Description).To(Equal("All Updated")) Expect(share.MaxBitRate).To(Equal(192)) Expect(share.ResourceType).To(Equal("album")) @@ -474,24 +482,24 @@ var _ = Describe("ShareRepository", func() { It("does not let an owner reassign their share to another user", func() { insertShare("reassign-share", ownerUser.ID) ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) - repo := NewShareRepository(ctx, GetDBXBuilder()) - err := repo.(rest.Persistable).Update("reassign-share", - &model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") + repo := NewShareRepository(GetDBXBuilder()) + err := repo.Update(ctx, "reassign-share", + model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") Expect(err).ToNot(HaveOccurred()) // Ownership must not have moved, even though user_id was passed in the body and cols. - got, err := repo.(rest.Repository).Read("reassign-share") + got, err := repo.Read(ctx, "reassign-share") Expect(err).ToNot(HaveOccurred()) - Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) + Expect(got.UserID).To(Equal(ownerUser.ID)) }) }) Describe("Read scoping", func() { BeforeEach(func() { // Persist owner/other users so the JOIN in selectShare resolves. - ur := NewUserRepository(ctx, GetDBXBuilder()) - Expect(ur.Put(&ownerUser)).To(Succeed()) - Expect(ur.Put(&otherUser)).To(Succeed()) + ur := NewUserRepository(GetDBXBuilder()) + Expect(ur.Put(ctx, &ownerUser)).To(Succeed()) + Expect(ur.Put(ctx, &otherUser)).To(Succeed()) insertShare("share-owner-1", ownerUser.ID) insertShare("share-owner-2", ownerUser.ID) @@ -500,16 +508,15 @@ var _ = Describe("ShareRepository", func() { Context("non-admin user", func() { var nonAdminRepo model.ShareRepository - var nonAdminRest rest.Repository + var nonAdminCtx context.Context BeforeEach(func() { - nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder()) - nonAdminRest = nonAdminRepo.(rest.Repository) + nonAdminCtx = request.WithUser(ctx, ownerUser) + nonAdminRepo = NewShareRepository(GetDBXBuilder()) }) It("GetAll returns only own shares", func() { - shares, err := nonAdminRepo.GetAll() + shares, err := nonAdminRepo.GetAll(nonAdminCtx) Expect(err).ToNot(HaveOccurred()) ids := make([]string, len(shares)) for i, s := range shares { @@ -519,9 +526,8 @@ var _ = Describe("ShareRepository", func() { }) It("ReadAll returns only own shares", func() { - res, err := nonAdminRest.ReadAll() + shares, err := nonAdminRepo.ReadAll(nonAdminCtx) Expect(err).ToNot(HaveOccurred()) - shares := res.(model.Shares) ids := make([]string, len(shares)) for i, s := range shares { ids[i] = s.ID @@ -530,41 +536,41 @@ var _ = Describe("ShareRepository", func() { }) It("Get returns own share", func() { - s, err := nonAdminRepo.Get("share-owner-1") + s, err := nonAdminRepo.Get(nonAdminCtx, "share-owner-1") Expect(err).ToNot(HaveOccurred()) Expect(s.ID).To(Equal("share-owner-1")) }) It("Get returns ErrNotFound for another user's share", func() { - _, err := nonAdminRepo.Get("share-other-1") + _, err := nonAdminRepo.Get(nonAdminCtx, "share-other-1") Expect(err).To(MatchError(model.ErrNotFound)) }) It("Read returns ErrNotFound for another user's share", func() { - _, err := nonAdminRest.Read("share-other-1") + _, err := nonAdminRepo.Read(nonAdminCtx, "share-other-1") Expect(err).To(MatchError(model.ErrNotFound)) }) It("Exists returns true for own share", func() { - exists, err := nonAdminRepo.Exists("share-owner-1") + exists, err := nonAdminRepo.Exists(nonAdminCtx, "share-owner-1") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) }) It("Exists returns false for another user's share", func() { - exists, err := nonAdminRepo.Exists("share-other-1") + exists, err := nonAdminRepo.Exists(nonAdminCtx, "share-other-1") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) }) It("CountAll counts only own shares", func() { - count, err := nonAdminRepo.CountAll() + count, err := nonAdminRepo.CountAll(nonAdminCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) }) It("Count (rest) counts only own shares", func() { - count, err := nonAdminRest.Count() + count, err := nonAdminRepo.Count(nonAdminCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) }) @@ -573,8 +579,8 @@ var _ = Describe("ShareRepository", func() { Context("admin user", func() { It("GetAll returns all shares", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) - shares, err := adminRepo.GetAll() + adminRepo := NewShareRepository(GetDBXBuilder()) + shares, err := adminRepo.GetAll(adminCtx) Expect(err).ToNot(HaveOccurred()) ids := make([]string, len(shares)) for i, s := range shares { @@ -585,31 +591,37 @@ var _ = Describe("ShareRepository", func() { It("CountAll counts all shares", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) - count, err := adminRepo.CountAll() + adminRepo := NewShareRepository(GetDBXBuilder()) + count, err := adminRepo.CountAll(adminCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 3)) }) }) Context("headless context (public share route)", func() { + var headlessCtx context.Context + + BeforeEach(func() { + headlessCtx = GinkgoT().Context() + }) + It("GetAll returns all shares", func() { - headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - shares, err := headlessRepo.GetAll() + headlessRepo := NewShareRepository(GetDBXBuilder()) + shares, err := headlessRepo.GetAll(headlessCtx) Expect(err).ToNot(HaveOccurred()) Expect(shares).To(HaveLen(3)) }) It("Get returns another user's share", func() { - headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - s, err := headlessRepo.Get("share-other-1") + headlessRepo := NewShareRepository(GetDBXBuilder()) + s, err := headlessRepo.Get(headlessCtx, "share-other-1") Expect(err).ToNot(HaveOccurred()) Expect(s.ID).To(Equal("share-other-1")) }) It("Exists returns true for any share", func() { - headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) - exists, err := headlessRepo.Exists("share-other-1") + headlessRepo := NewShareRepository(GetDBXBuilder()) + exists, err := headlessRepo.Exists(headlessCtx, "share-other-1") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 24c6f5fc5..871f53dd2 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "slices" "time" @@ -18,76 +19,76 @@ import ( // configured refresh delay. // refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. -func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { - return r.refreshSmartPlaylistTree(pls, map[string]struct{}{}) +func (r *playlistRepository) refreshSmartPlaylist(ctx context.Context, pls *model.Playlist) bool { + return r.refreshSmartPlaylistTree(ctx, pls, map[string]struct{}{}) } // The visited set stops playlists that reference each other from recursing forever. -func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool { +func (r *playlistRepository) refreshSmartPlaylistTree(ctx context.Context, pls *model.Playlist, visited map[string]struct{}) bool { if _, seen := visited[pls.ID]; seen { - log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) + log.Trace(ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) return false } visited[pls.ID] = struct{}{} - usr := loggedUser(r.ctx) - if !r.shouldRefreshSmartPlaylist(pls, usr) { + usr := loggedUser(ctx) + if !r.shouldRefreshSmartPlaylist(ctx, pls, usr) { return false } - log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + log.Debug(ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) start := time.Now() del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) - if _, err := r.executeSQL(del); err != nil { - log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + if _, err := r.executeSQL(ctx, del); err != nil { + log.Error(ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) return false } rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) - if !r.refreshChildPlaylists(pls, rulesSQL, visited) { + if !r.refreshChildPlaylists(ctx, pls, rulesSQL, visited) { return false } - if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil { + if err := r.resolvePercentageLimit(ctx, pls, &rulesSQL, usr.ID); err != nil { return false } - sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID) + sq := r.buildSmartPlaylistQuery(ctx, pls, rulesSQL, usr.ID) sq, err := r.addCriteria(sq, rulesSQL) if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + log.Error(ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return false } insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) - if _, err = r.executeSQL(insSql); err != nil { - log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + if _, err = r.executeSQL(ctx, insSql); err != nil { + log.Error(ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) return false } - if err = r.refreshCounters(pls); err != nil { - log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + if err = r.refreshCounters(ctx, pls); err != nil { + log.Error(ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) return false } // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree now := pls.UpdatedAt updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - if _, err = r.executeSQL(updSql); err != nil { - log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) + if _, err = r.executeSQL(ctx, updSql); err != nil { + log.Error(ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) return false } pls.EvaluatedAt = &now - log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + log.Debug(ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) return true } // shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated // time, and ownership. -func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool { +func (r *playlistRepository) shouldRefreshSmartPlaylist(ctx context.Context, pls *model.Playlist, usr *model.User) bool { if !pls.IsSmartPlaylist() { return false } @@ -95,7 +96,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr return false } if pls.OwnerID != usr.ID { - log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + log.Trace(ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) return false } return true @@ -103,7 +104,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr // refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. // Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. -func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { +func (r *playlistRepository) refreshChildPlaylists(ctx context.Context, pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { childPlaylistIds := rulesSQL.ChildPlaylistIds() childPlaylistPaths := rulesSQL.ChildPlaylistPaths() if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 { @@ -119,9 +120,9 @@ func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL conditions = append(conditions, Eq{"playlist.path": lookupPaths}) } - childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions}) + childPlaylists, err := r.GetAll(ctx, model.QueryOptions{Filters: conditions}) if err != nil { - log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) + log.Error(ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) return false } @@ -131,58 +132,58 @@ func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL if childPlaylists[i].Path != "" { found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{} } - r.refreshSmartPlaylistTree(&childPlaylists[i], visited) + r.refreshSmartPlaylistTree(ctx, &childPlaylists[i], visited) } for _, id := range childPlaylistIds { if _, ok := found[id]; !ok { - log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + log.Warn(ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) } } for _, path := range childPlaylistPaths { if _, ok := found[norm.NFC.String(path)]; !ok { - log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID) + log.Warn(ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID) } } return true } // resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit. -func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { +func (r *playlistRepository) resolvePercentageLimit(ctx context.Context, pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { if !rulesSQL.IsPercentageLimit() { return nil } countSq := Select("count(*) as count").From("media_file") countSq = rulesSQL.applyExpressionJoins(countSq, userID) - countSq = r.applyLibraryFilter(countSq, "media_file") + countSq = r.applyLibraryFilter(ctx, countSq, "media_file") cond, err := rulesSQL.where() if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + log.Error(ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return err } countSq = countSq.Where(cond) var res struct{ Count int64 } - if err = r.queryOne(countSq, &res); err != nil { - log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + if err = r.queryOne(ctx, countSq, &res); err != nil { + log.Error(ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) return err } rulesSQL.ResolveLimit(res.Count) - log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) + log.Debug(ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) return nil } // buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, // including the joins its fields require and library filtering. -func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { +func (r *playlistRepository) buildSmartPlaylistQuery(ctx context.Context, pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { orderBy := rulesSQL.orderBy() sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file") sq = rulesSQL.applyRequiredJoins(sq, userID) - sq = r.applyLibraryFilter(sq, "media_file") + sq = r.applyLibraryFilter(ctx, sq, "media_file") return sq } diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 33da0c1b7..4d4c5edb0 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "path/filepath" "time" @@ -18,11 +19,11 @@ import ( var _ = Describe("PlaylistRepository - Smart Playlists", func() { var repo model.PlaylistRepository + var ctx context.Context BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(GetDBXBuilder()) }) Context("Smart Playlists", func() { @@ -37,10 +38,10 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Context("valid rules", func() { Specify("Put/Get", func() { newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - savedPls, err := repo.Get(newPls.ID) + savedPls, err := repo.Get(ctx, newPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(savedPls.Rules).To(Equal(rules)) }) @@ -49,13 +50,13 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Context("after an evaluation", func() { It("stamps updated_at and evaluated_at with the same instant", func() { newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - refreshed, err := repo.GetWithTracks(newPls.ID, true, false) + refreshed, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) - stored, err := repo.Get(newPls.ID) + stored, err := repo.Get(ctx, newPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(stored.EvaluatedAt).ToNot(BeNil()) Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) @@ -72,7 +73,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) + Expect(repo.Put(ctx, &newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) }) }) @@ -86,14 +87,14 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true} - Expect(repo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + Expect(repo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - evaluated, err := repo.GetWithTracks(pls.ID, true, false) + evaluated, err := repo.GetWithTracks(ctx, pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(evaluated.SongCount).To(BeNumerically(">", 0)) - stored, err := repo.Get(pls.ID) + stored, err := repo.Get(ctx, pls.ID) Expect(err).ToNot(HaveOccurred()) Expect(stored.SongCount).To(Equal(evaluated.SongCount)) @@ -101,9 +102,9 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules, Path: pls.Path, Sync: true, } - Expect(repo.Put(&reimported)).To(Succeed()) + Expect(repo.Put(ctx, &reimported)).To(Succeed()) - afterImport, err := repo.Get(pls.ID) + afterImport, err := repo.Get(ctx, pls.ID) Expect(err).ToNot(HaveOccurred()) Expect(afterImport.SongCount).To(Equal(stored.SongCount)) Expect(afterImport.Duration).To(Equal(stored.Duration)) @@ -126,8 +127,8 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + Expect(repo.Put(ctx, &nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, nestedPls.ID) }) childRules = &criteria.Criteria{ Expression: criteria.All{ @@ -135,8 +136,8 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules} - Expect(repo.Put(&nestedPathPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) }) + Expect(repo.Put(ctx, &nestedPathPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, nestedPathPls.ID) }) parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.Any{ @@ -144,16 +145,16 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.InPlaylist{"path": nestedPathPls.Path}, }, }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + Expect(repo.Put(ctx, &parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) }) // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(nestedPls.ID) + nestedPlsRead, err := repo.Get(ctx, nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) // Getting parent with refresh should recursively refresh the nested playlist - pls, err := repo.GetWithTracks(parentPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.EvaluatedAt).ToNot(BeNil()) Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) @@ -163,12 +164,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) // Nested playlists should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + nestedPlsAfterParentGet, err := repo.Get(ctx, nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID) + nestedPlsAfterParentGet, err = repo.Get(ctx, nestedPathPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) @@ -181,19 +182,19 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ Expression: criteria.All{criteria.Contains{"title": "Day"}}, }} - Expect(repo.Put(&plsA)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(plsA.ID) }) + Expect(repo.Put(ctx, &plsA)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, plsA.ID) }) plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}}, }} - Expect(repo.Put(&plsB)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(plsB.ID) }) + Expect(repo.Put(ctx, &plsB)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, plsB.ID) }) plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}} - Expect(repo.Put(&plsA)).To(Succeed()) + Expect(repo.Put(ctx, &plsA)).To(Succeed()) - _, err := repo.GetWithTracks(plsA.ID, true, false) + _, err := repo.GetWithTracks(ctx, plsA.ID, true, false) Expect(err).ToNot(HaveOccurred()) }) @@ -203,19 +204,19 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ Expression: criteria.All{criteria.Contains{"title": "Day"}}, }} - Expect(repo.Put(&bystander)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(bystander.ID) }) + Expect(repo.Put(ctx, &bystander)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, bystander.ID) }) parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ Expression: criteria.All{criteria.InPlaylist{"path": ""}}, }} - Expect(repo.Put(&parent)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + Expect(repo.Put(ctx, &parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) }) - _, err := repo.GetWithTracks(parent.ID, true, false) + _, err := repo.GetWithTracks(ctx, parent.ID, true, false) Expect(err).ToNot(HaveOccurred()) - reloaded, err := repo.Get(bystander.ID) + reloaded, err := repo.Get(ctx, bystander.ID) Expect(err).ToNot(HaveOccurred()) Expect(reloaded.EvaluatedAt).To(BeNil()) }) @@ -226,16 +227,16 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{ Expression: criteria.All{criteria.Contains{"title": "Day"}}, }} - Expect(repo.Put(&child)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(child.ID) }) + Expect(repo.Put(ctx, &child)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, child.ID) }) parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}}, }} - Expect(repo.Put(&parent)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + Expect(repo.Put(ctx, &parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) }) - pls, err := repo.GetWithTracks(parent.ID, true, false) + pls, err := repo.GetWithTracks(ctx, parent.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(1)) Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) @@ -252,8 +253,8 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + Expect(repo.Put(ctx, &nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, nestedPls.ID) }) // Parent has no EvaluatedAt, so it WILL refresh, but the child should not parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ @@ -261,14 +262,14 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.InPlaylist{"id": nestedPls.ID}, }, }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + Expect(repo.Put(ctx, &parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) }) - nestedPlsRead, err := repo.Get(nestedPls.ID) + nestedPlsRead, err := repo.Get(ctx, nestedPls.ID) Expect(err).ToNot(HaveOccurred()) // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(parentPls.ID, true, false) + parent, err := repo.GetWithTracks(ctx, parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) // Parent should have been refreshed (its EvaluatedAt was nil) @@ -276,7 +277,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) // Nested playlist should NOT have been refreshed (still within delay window) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + nestedPlsAfterParentGet, err := repo.Get(ctx, nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) @@ -297,10 +298,10 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { RefreshDelay: 24 * time.Hour, } pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} - Expect(repo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + Expect(repo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - got, err := repo.GetWithTracks(pls.ID, true, false) + got, err := repo.GetWithTracks(ctx, pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) // Not re-evaluated: EvaluatedAt unchanged, no tracks materialized Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) @@ -316,10 +317,10 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { RefreshDelay: 5 * time.Minute, } pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} - Expect(repo.Put(&pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + Expect(repo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - got, err := repo.GetWithTracks(pls.ID, true, false) + got, err := repo.GetWithTracks(ctx, pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) Expect(got.Tracks).To(HaveLen(1)) @@ -334,7 +335,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { AfterEach(func() { if testPlaylistID != "" { - Expect(repo.Delete(testPlaylistID)).To(BeNil()) + Expect(repo.Delete(ctx, testPlaylistID)).To(BeNil()) testPlaylistID = "" } }) @@ -344,12 +345,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} // Add tracks in intentionally scrambled order newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(newPls.ID, false) - tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + tracksRepo := repo.Tracks(ctx, newPls.ID, false) + tracks, err := tracksRepo.GetAll(ctx, model.QueryOptions{Sort: "album", Order: "asc"}) Expect(err).ToNot(HaveOccurred()) By("verifying tracks are sorted by disc number then track number") @@ -367,7 +368,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { AfterEach(func() { if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) + _ = repo.Delete(ctx, testPlaylistID) testPlaylistID = "" } }) @@ -381,11 +382,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -404,11 +405,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -429,11 +430,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -451,11 +452,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(BeEmpty()) @@ -471,11 +472,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -495,8 +496,8 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules} - Expect(repo.Put(&boolPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(boolPls.ID) }) + Expect(repo.Put(ctx, &boolPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, boolPls.ID) }) stringRules := &criteria.Criteria{ Expression: criteria.All{ @@ -506,13 +507,13 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules} - Expect(repo.Put(&stringPls)).To(Succeed()) + Expect(repo.Put(ctx, &stringPls)).To(Succeed()) testPlaylistID = stringPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - boolResult, err := repo.GetWithTracks(boolPls.ID, true, false) + boolResult, err := repo.GetWithTracks(ctx, boolPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) - stringResult, err := repo.GetWithTracks(stringPls.ID, true, false) + stringResult, err := repo.GetWithTracks(ctx, stringPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) boolIDs := make([]string, len(boolResult.Tracks)) @@ -535,10 +536,10 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { trackIDsOf := func(rules *criteria.Criteria) []string { newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID }) } @@ -569,7 +570,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { BeforeEach(func() { ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + mfRepo = NewMediaFileRepository(GetDBXBuilder()) // Register 'grouping' as a valid tag for smart playlists criteria.AddTagNames([]string{"grouping"}) @@ -590,7 +591,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { LibraryID: 1, Lyrics: "[]", } - Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + Expect(mfRepo.Put(ctx, &songWithGrouping)).To(Succeed()) // Create a song without the grouping tag songWithoutGrouping = model.MediaFile{ @@ -606,12 +607,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { LibraryID: 1, Lyrics: "[]", } - Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + Expect(mfRepo.Put(ctx, &songWithoutGrouping)).To(Succeed()) }) AfterEach(func() { if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) + _ = repo.Delete(ctx, testPlaylistID) testPlaylistID = "" } // Clean up test media files @@ -629,12 +630,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("refreshing the smart playlist") conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) By("verifying only the track with grouping tag is matched") @@ -650,12 +651,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) + Expect(repo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("refreshing the smart playlist") conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) + pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) By("verifying the track with grouping is NOT in the playlist") @@ -705,7 +706,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { // Create test media files in each library ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, db) + mfRepo = NewMediaFileRepository(db) // Song in library 1 (accessible by restricted user) songLib1 := model.MediaFile{ @@ -721,7 +722,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Tags: model.Tags{}, Lyrics: "[]", } - Expect(mfRepo.Put(&songLib1)).To(Succeed()) + Expect(mfRepo.Put(ctx, &songLib1)).To(Succeed()) // Song in library 2 (NOT accessible by restricted user) songLib2 := model.MediaFile{ @@ -737,13 +738,13 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Tags: model.Tags{}, Lyrics: "[]", } - Expect(mfRepo.Put(&songLib2)).To(Succeed()) + Expect(mfRepo.Put(ctx, &songLib2)).To(Succeed()) }) AfterEach(func() { db := GetDBXBuilder() if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) + _ = repo.Delete(ctx, testPlaylistID) testPlaylistID = "" } // Clean up test data @@ -761,7 +762,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { // Create the smart playlist as the restricted user restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo := NewPlaylistRepository(ctx, db) + restrictedRepo := NewPlaylistRepository(db) // Create a smart playlist that matches all songs rules := &criteria.Criteria{ @@ -770,12 +771,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} - Expect(restrictedRepo.Put(&newPls)).To(Succeed()) + Expect(restrictedRepo.Put(ctx, &newPls)).To(Succeed()) testPlaylistID = newPls.ID By("refreshing the smart playlist") conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) + pls, err := restrictedRepo.GetWithTracks(ctx, newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) By("verifying only the track from library 1 is in the playlist") diff --git a/persistence/sort_index_coverage_test.go b/persistence/sort_index_coverage_test.go index b5dea231d..138cb32c8 100644 --- a/persistence/sort_index_coverage_test.go +++ b/persistence/sort_index_coverage_test.go @@ -52,7 +52,7 @@ var _ = Describe("Sort index coverage", func() { { table: "media_file", newRepo: func(ctx context.Context) *sqlRepository { - return &NewMediaFileRepository(ctx, GetDBXBuilder()).(*mediaFileRepository).sqlRepository + return &NewMediaFileRepository(GetDBXBuilder()).(*mediaFileRepository).sqlRepository }, exceptions: map[string]string{ "random": "not a column sort", @@ -67,7 +67,7 @@ var _ = Describe("Sort index coverage", func() { { table: "album", newRepo: func(ctx context.Context) *sqlRepository { - return &NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository).sqlRepository + return &NewAlbumRepository(GetDBXBuilder()).(*albumRepository).sqlRepository }, exceptions: map[string]string{ "random": "not a column sort", @@ -79,7 +79,7 @@ var _ = Describe("Sort index coverage", func() { { table: "artist", newRepo: func(ctx context.Context) *sqlRepository { - return &NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository).sqlRepository + return &NewArtistRepository(GetDBXBuilder()).(*artistRepository).sqlRepository }, exceptions: map[string]string{ //nolint:gosec // G101 false positive, same as the artist sortMappings "starred_at": "sorts on annotation join columns", diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 27445b886..51c7dc146 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "database/sql" "errors" "fmt" @@ -59,8 +60,8 @@ func filtersNeedAnnotation(query SelectBuilder) bool { return annotationColumnRE().MatchString(sql) } -func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { - userID := loggedUser(r.ctx).ID +func (r sqlRepository) withAnnotation(ctx context.Context, query SelectBuilder, idField string) SelectBuilder { + userID := loggedUser(ctx).ID if userID == invalidUserId { return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName)) } @@ -102,8 +103,8 @@ func annotationBoolFilter(field string) func(string, any) Sqlizer { } } -func (r sqlRepository) annId(itemID ...string) And { - userID := loggedUser(r.ctx).ID +func (r sqlRepository) annId(ctx context.Context, itemID ...string) And { + userID := loggedUser(ctx).ID return And{ Eq{annotationTable + ".user_id": userID}, Eq{annotationTable + ".item_type": r.tableName}, @@ -111,20 +112,20 @@ func (r sqlRepository) annId(itemID ...string) And { } } -func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error { - upd := Update(annotationTable).Where(r.annId(itemIDs...)) +func (r sqlRepository) annUpsert(ctx context.Context, values map[string]any, itemIDs ...string) error { + upd := Update(annotationTable).Where(r.annId(ctx, itemIDs...)) for f, v := range values { upd = upd.Set(f, v) } - c, err := r.executeSQL(upd) + c, err := r.executeSQL(ctx, upd) if c == 0 || errors.Is(err, sql.ErrNoRows) { - userID := loggedUser(r.ctx).ID + userID := loggedUser(ctx).ID for _, itemID := range itemIDs { values["user_id"] = userID values["item_type"] = r.tableName values["item_id"] = itemID ins := Insert(annotationTable).SetMap(values) - _, err = r.executeSQL(ins) + _, err = r.executeSQL(ctx, ins) if err != nil { return err } @@ -133,39 +134,39 @@ func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error return err } -func (r sqlRepository) SetStar(starred bool, ids ...string) error { +func (r sqlRepository) SetStar(ctx context.Context, starred bool, ids ...string) error { starredAt := time.Now() - return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...) + return r.annUpsert(ctx, map[string]any{"starred": starred, "starred_at": starredAt}, ids...) } -func (r sqlRepository) SetRating(rating int, itemID string) error { +func (r sqlRepository) SetRating(ctx context.Context, rating int, itemID string) error { ratedAt := time.Now() - err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID) + err := r.annUpsert(ctx, map[string]any{"rating": rating, "rated_at": ratedAt}, itemID) if err != nil { return err } - return r.updateAvgRating(itemID) + return r.updateAvgRating(ctx, itemID) } -func (r sqlRepository) updateAvgRating(itemID string) error { +func (r sqlRepository) updateAvgRating(ctx context.Context, itemID string) error { upd := Update(r.tableName). Where(Eq{"id": itemID}). Set("average_rating", Expr( "coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)", itemID, r.tableName, )) - _, err := r.executeSQL(upd) + _, err := r.executeSQL(ctx, upd) return err } -func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { - upd := Update(annotationTable).Where(r.annId(itemID)). +func (r sqlRepository) IncPlayCount(ctx context.Context, itemID string, ts time.Time) error { + upd := Update(annotationTable).Where(r.annId(ctx, itemID)). Set("play_count", Expr("play_count+1")). Set("play_date", Expr("max(ifnull(play_date,''),?)", ts)) - c, err := r.executeSQL(upd) + c, err := r.executeSQL(ctx, upd) if c == 0 || errors.Is(err, sql.ErrNoRows) { - userID := loggedUser(r.ctx).ID + userID := loggedUser(ctx).ID values := map[string]any{} values["user_id"] = userID values["item_type"] = r.tableName @@ -173,7 +174,7 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { values["play_count"] = 1 values["play_date"] = ts ins := Insert(annotationTable).SetMap(values) - _, err = r.executeSQL(ins) + _, err = r.executeSQL(ctx, ins) if err != nil { return err } @@ -181,28 +182,28 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { return err } -func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error { +func (r sqlRepository) ReassignAnnotation(ctx context.Context, prevID string, newID string) error { if prevID == newID || prevID == "" || newID == "" { return nil } // OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?", newID, r.tableName, prevID) - if _, err := r.executeSQL(upd); err != nil { + if _, err := r.executeSQL(ctx, upd); err != nil { return err } // The moved rows change newID's rating population, so its cached average no longer matches - return r.updateAvgRating(newID) + return r.updateAvgRating(ctx, newID) } -func (r sqlRepository) cleanAnnotations() error { +func (r sqlRepository) cleanAnnotations(ctx context.Context) error { del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") - c, err := r.executeSQL(del) + c, err := r.executeSQL(ctx, del) if err != nil { return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err) } if c > 0 { - log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) + log.Debug(ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) } return nil } diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 79ad3c152..ec28c610a 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -15,19 +15,20 @@ var _ = Describe("Annotation Filters", func() { var ( albumRepo *albumRepository albumWithoutAnnotation model.Album + ctx context.Context ) BeforeEach(func() { - ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"}) - albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) // Create album without any annotation (no star, no rating) albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1} - Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed()) + Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) Describe("ReassignAnnotation", func() { @@ -36,42 +37,42 @@ var _ = Describe("Annotation Filters", func() { BeforeEach(func() { prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1} next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1} - Expect(albumRepo.Put(&prev)).To(Succeed()) - Expect(albumRepo.Put(&next)).To(Succeed()) + Expect(albumRepo.Put(ctx, &prev)).To(Succeed()) + Expect(albumRepo.Put(ctx, &next)).To(Succeed()) }) AfterEach(func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) }) It("moves the annotation when the new item has none", func() { - Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - got, err := albumRepo.Get(next.ID) + got, err := albumRepo.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Rating).To(Equal(4)) }) It("recomputes the new item's cached average rating", func() { - Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - got, err := albumRepo.Get(next.ID) + got, err := albumRepo.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.AverageRating).To(Equal(4.0)) }) It("keeps the new item's annotation when both exist", func() { - Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) - Expect(albumRepo.SetRating(2, next.ID)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 2, next.ID)).To(Succeed()) - Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - got, err := albumRepo.Get(next.ID) + got, err := albumRepo.Get(ctx, next.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Rating).To(Equal(2)) }) @@ -100,7 +101,7 @@ var _ = Describe("Annotation Filters", func() { Describe("starredFilter", func() { It("false includes items without annotations", func() { - albums, err := albumRepo.GetAll(model.QueryOptions{ + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "false"), }) Expect(err).ToNot(HaveOccurred()) @@ -116,7 +117,7 @@ var _ = Describe("Annotation Filters", func() { }) It("true excludes items without annotations", func() { - albums, err := albumRepo.GetAll(model.QueryOptions{ + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -129,7 +130,7 @@ var _ = Describe("Annotation Filters", func() { Describe("hasRatingFilter", func() { It("false includes items without annotations", func() { - albums, err := albumRepo.GetAll(model.QueryOptions{ + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "false"), }) Expect(err).ToNot(HaveOccurred()) @@ -145,7 +146,7 @@ var _ = Describe("Annotation Filters", func() { }) It("true excludes items without annotations", func() { - albums, err := albumRepo.GetAll(model.QueryOptions{ + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -158,14 +159,14 @@ var _ = Describe("Annotation Filters", func() { It("true includes items with rating > 0", func() { // Create album with rating 1 ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1} - Expect(albumRepo.Put(&ratedAlbum)).To(Succeed()) - Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed()) + Expect(albumRepo.Put(ctx, &ratedAlbum)).To(Succeed()) + Expect(albumRepo.SetRating(ctx, 1, ratedAlbum.ID)).To(Succeed()) defer func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID})) }() - albums, err := albumRepo.GetAll(model.QueryOptions{ + albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -182,11 +183,11 @@ var _ = Describe("Annotation Filters", func() { }) It("ignores invalid filter values (not strings)", func() { - res, err := albumRepo.ReadAll(rest.QueryOptions{ + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ Filters: map[string]any{"starred": 123}, }) Expect(err).ToNot(HaveOccurred()) - albums := res.(model.Albums) + albums := res var found bool for _, a := range albums { @@ -254,11 +255,11 @@ var _ = Describe("Annotation Filters", func() { Describe("CountAll annotation-join gating", func() { It("counts all items unfiltered (join dropped)", func() { - total, err := albumRepo.CountAll() + total, err := albumRepo.CountAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(total).To(BeNumerically(">=", int64(1))) - filtered, err := albumRepo.CountAll(model.QueryOptions{ + filtered, err := albumRepo.CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -267,16 +268,16 @@ var _ = Describe("Annotation Filters", func() { It("counts starred items correctly (named annotation filter keeps the join)", func() { starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1} - Expect(albumRepo.Put(&starredAlbum)).To(Succeed()) - Expect(albumRepo.SetStar(true, starredAlbum.ID)).To(Succeed()) + Expect(albumRepo.Put(ctx, &starredAlbum)).To(Succeed()) + Expect(albumRepo.SetStar(ctx, true, starredAlbum.ID)).To(Succeed()) defer func() { - _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID})) }() // Exactly two albums are starred for this user: the one created above and // albumRadioactivity (id 103) from the seed data. - count, err := albumRepo.CountAll(model.QueryOptions{ + count, err := albumRepo.CountAll(ctx, model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -284,7 +285,7 @@ var _ = Describe("Annotation Filters", func() { }) It("counts via a raw annotation filter without a 'no such column' error", func() { - count, err := albumRepo.CountAll(model.QueryOptions{ + count, err := albumRepo.CountAll(ctx, model.QueryOptions{ Filters: squirrel.Expr("COALESCE(rating, 0) > 0"), }) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 026e42b03..be88156d8 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -29,7 +29,7 @@ import ( // When creating a new repository using this base, you must: // // - Embed this struct. -// - Set ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request +// - Set the db field. // - Call registerModel with the model instance and any possible filters. // - If the model has a different table name than the default (lowercase of the model name), it should be set manually // using the tableName field. @@ -38,7 +38,6 @@ import ( // All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or // defined in the mappings will be allowed. type sqlRepository struct { - ctx context.Context tableName string db dbx.Builder @@ -65,8 +64,8 @@ func loggedUser(ctx context.Context) *model.User { // // The predicate uses an unqualified user_id, so it only works on queries where that column is // unambiguous (no join introducing a second user_id). -func (r sqlRepository) ownerFilter() Sqlizer { - if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { +func (r sqlRepository) ownerFilter(ctx context.Context) Sqlizer { + if usr := loggedUser(ctx); !usr.IsAdmin && usr.ID != invalidUserId { return Eq{"user_id": usr.ID} } return nil @@ -75,12 +74,12 @@ func (r sqlRepository) ownerFilter() Sqlizer { // addRestriction combines an optional caller predicate with the ownership filter, producing the // WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and // only the caller's predicate (if any) remains. -func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer { +func (r sqlRepository) addRestriction(ctx context.Context, sql ...Sqlizer) Sqlizer { s := And{} if len(sql) > 0 { s = append(s, sql[0]) } - if owner := r.ownerFilter(); owner != nil { + if owner := r.ownerFilter(ctx); owner != nil { s = append(s, owner) } return s @@ -121,10 +120,10 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName .. r.sortMappings = mappings } -func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { +func (r sqlRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { sq := Select().From(r.tableName) if len(options) > 0 { - r.resetSeededRandom(options) + r.resetSeededRandom(ctx, options) sq = r.applyOptions(sq, options...) sq = r.applyFilters(sq, options...) } @@ -243,8 +242,8 @@ func libraryIdFilter(_ string, value any) Sqlizer { // applyLibraryFilter adds library filtering to queries for tables that have a library_id column // This ensures users only see content from libraries they have access to -func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder { - user := loggedUser(r.ctx) +func (r sqlRepository) applyLibraryFilter(ctx context.Context, sq SelectBuilder, tableName ...string) SelectBuilder { + user := loggedUser(ctx) // If the user is an admin, or the user ID is invalid (e.g., when no user is logged in), skip the library filter if user.IsAdmin || user.ID == invalidUserId { @@ -253,7 +252,7 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) // A non-admin granted every library sees everything the subquery would return, so applying it is // pure overhead. Skip it in that case (same fast path admins get). - if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { return sq } @@ -270,65 +269,66 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) // userSeesAllLibraries reports whether the visible set already covers every library, so a // library filter would exclude nothing. -func (r sqlRepository) userSeesAllLibraries(visible []int) bool { - user := loggedUser(r.ctx) +func (r sqlRepository) userSeesAllLibraries(ctx context.Context, visible []int) bool { + user := loggedUser(ctx) if user.IsAdmin || user.ID == invalidUserId { return true // visible is the whole library table } - total, err := NewLibraryRepository(r.ctx, r.db).CountAll() - if err != nil || total == 0 { + var res struct{ Count int64 } + err := r.queryOne(ctx, Select("count(*) as count").From("library"), &res) + if err != nil || res.Count == 0 { return false } - return int64(len(visible)) == total + return int64(len(visible)) == res.Count } // visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and // headless processes, otherwise the user's granted libraries. -func (r sqlRepository) visibleLibraryIDs() ([]int, error) { - user := loggedUser(r.ctx) +func (r sqlRepository) visibleLibraryIDs(ctx context.Context) ([]int, error) { + user := loggedUser(ctx) if user.IsAdmin || user.ID == invalidUserId { var ids []int - err := r.queryAllSlice(Select("id").From("library"), &ids) + err := r.queryAllSlice(ctx, Select("id").From("library"), &ids) return ids, err } return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil } -func (r sqlRepository) seedKey() string { +func (r sqlRepository) seedKey(ctx context.Context) string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick - userIDHash := xxh3.Hash([]byte(loggedUser(r.ctx).ID)) + userIDHash := xxh3.Hash([]byte(loggedUser(ctx).ID)) return fmt.Sprintf("%s|%016x", r.tableName, userIDHash) } -func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { +func (r sqlRepository) resetSeededRandom(ctx context.Context, options []model.QueryOptions) { if len(options) == 0 || options[0].Sort != "random" { return } // CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to // SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha). - options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(), r.tableName) + options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(ctx), r.tableName) if options[0].Seed != "" { - hasher.SetSeed(r.seedKey(), options[0].Seed) + hasher.SetSeed(r.seedKey(ctx), options[0].Seed) return } if options[0].Offset == 0 { - hasher.Reseed(r.seedKey()) + hasher.Reseed(r.seedKey(ctx)) } } -func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) { +func (r sqlRepository) executeSQL(ctx context.Context, sq Sqlizer) (int64, error) { query, args, err := r.toSQL(sq) if err != nil { return 0, err } start := time.Now() var c int64 - res, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Execute() + res, err := r.db.NewQuery(query).Bind(args).WithContext(ctx).Execute() if res != nil { c, _ = res.RowsAffected() } - r.logSQL(query, args, err, c, start) + r.logSQL(ctx, query, args, err, c, start) if err != nil { if err.Error() != "LastInsertId is not supported by this driver" { return 0, err @@ -356,18 +356,18 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) { return result, params, nil } -func (r sqlRepository) queryOne(sq Sqlizer, response any) error { +func (r sqlRepository) queryOne(ctx context.Context, sq Sqlizer, response any) error { query, args, err := r.toSQL(sq) if err != nil { return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).One(response) + err = r.db.NewQuery(query).Bind(args).WithContext(ctx).One(response) if errors.Is(err, sql.ErrNoRows) { - r.logSQL(query, args, nil, 0, start) + r.logSQL(ctx, query, args, nil, 0, start) return model.ErrNotFound } - r.logSQL(query, args, err, 1, start) + r.logSQL(ctx, query, args, err, 1, start) return err } @@ -392,7 +392,7 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. -func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { +func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { if len(options) > 0 && options[0].Offset > 0 { sq = r.optimizePagination(sq, options[0]) } @@ -401,8 +401,8 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options .. return nil, err } start := time.Now() - rows, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Rows() - r.logSQL(query, args, err, -1, start) + rows, err := r.db.NewQuery(query).Bind(args).WithContext(ctx).Rows() + r.logSQL(ctx, query, args, err, -1, start) if err != nil { return nil, err } @@ -422,7 +422,7 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options .. }, nil } -func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error { +func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error { if len(options) > 0 && options[0].Offset > 0 { sq = r.optimizePagination(sq, options[0]) } @@ -431,28 +431,28 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).All(response) + err = r.db.NewQuery(query).Bind(args).WithContext(ctx).All(response) if errors.Is(err, sql.ErrNoRows) { - r.logSQL(query, args, nil, -1, start) + r.logSQL(ctx, query, args, nil, -1, start) return model.ErrNotFound } - r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) + r.logSQL(ctx, query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) return err } // queryAllSlice is a helper function to query a single column and return the result in a slice -func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error { +func (r sqlRepository) queryAllSlice(ctx context.Context, sq SelectBuilder, response any) error { query, args, err := r.toSQL(sq) if err != nil { return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Column(response) + err = r.db.NewQuery(query).Bind(args).WithContext(ctx).Column(response) if errors.Is(err, sql.ErrNoRows) { - r.logSQL(query, args, nil, -1, start) + r.logSQL(ctx, query, args, nil, -1, start) return model.ErrNotFound } - r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) + r.logSQL(ctx, query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) return err } @@ -469,10 +469,10 @@ func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryO return sq } -func (r sqlRepository) exists(cond Sqlizer) (bool, error) { +func (r sqlRepository) exists(ctx context.Context, cond Sqlizer) (bool, error) { existsQuery := Select("count(*) as exist").From(r.tableName).Where(cond) var res struct{ Exist int64 } - err := r.queryOne(existsQuery, &res) + err := r.queryOne(ctx, existsQuery, &res) return res.Exist > 0, err } @@ -487,20 +487,20 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) { // another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is // still atomic; the extra lookup happens only on the failure path (count == 0), where no write // occurred, so there is no TOCTOU on the update. -func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error { +func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsToUpdate ...string) error { values, err := toSQLArgs(m) if err != nil { return fmt.Errorf("error preparing values to write to DB: %w", err) } updateValues := filterUpdateValues(values, id, colsToUpdate...) delete(updateValues, "user_id") // ownership is immutable on update - update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues) - count, err := r.executeSQL(update) + update := Update(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})).SetMap(updateValues) + count, err := r.executeSQL(ctx, update) if err != nil { return err } if count == 0 { - return r.classifyOwnedWriteMiss(id) + return r.classifyOwnedWriteMiss(ctx, id) } return nil } @@ -510,13 +510,22 @@ func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) err // ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply // does not match and is left untouched. The failure path mirrors updateOwned (see // classifyOwnedWriteMiss), so there is no TOCTOU on the delete. -func (r sqlRepository) deleteOwned(id string) error { - count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id}))) +func (r sqlRepository) deleteOwned(ctx context.Context, id string) error { + count, err := r.executeSQL(ctx, Delete(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id}))) if err != nil { return err } if count == 0 { - return r.classifyOwnedWriteMiss(id) + return r.classifyOwnedWriteMiss(ctx, id) + } + return nil +} + +func (r sqlRepository) deleteOwnedAll(ctx context.Context, ids ...string) error { + for _, id := range ids { + if err := r.deleteOwned(ctx, id); err != nil { + return err + } } return nil } @@ -524,8 +533,8 @@ func (r sqlRepository) deleteOwned(id string) error { // classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched // no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise // rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred. -func (r sqlRepository) classifyOwnedWriteMiss(id string) error { - exists, err := r.exists(Eq{"id": id}) +func (r sqlRepository) classifyOwnedWriteMiss(ctx context.Context, id string) error { + exists, err := r.exists(ctx, Eq{"id": id}) if err != nil { return err } @@ -535,7 +544,7 @@ func (r sqlRepository) classifyOwnedWriteMiss(id string) error { return rest.ErrNotFound } -func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { +func (r sqlRepository) count(ctx context.Context, countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). RemoveOffset().RemoveLimit(). @@ -543,22 +552,22 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt From(r.tableName) countQuery = r.applyFilters(countQuery, options...) var res struct{ Count int64 } - err := r.queryOne(countQuery, &res) + err := r.queryOne(ctx, countQuery, &res) return res.Count, err } -func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) { +func (r sqlRepository) putByMatch(ctx context.Context, filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) { if id != "" { - return r.put(id, m, colsToUpdate...) + return r.put(ctx, id, m, colsToUpdate...) } - existsQuery := r.newSelect().Columns("id").From(r.tableName).Where(filter) + existsQuery := r.newSelect(ctx).Columns("id").From(r.tableName).Where(filter) var res struct{ ID string } - err := r.queryOne(existsQuery, &res) + err := r.queryOne(ctx, existsQuery, &res) if err != nil && !errors.Is(err, model.ErrNotFound) { return "", err } - return r.put(res.ID, m, colsToUpdate...) + return r.put(ctx, res.ID, m, colsToUpdate...) } // selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are @@ -588,7 +597,7 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string return updateValues } -func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { +func (r sqlRepository) put(ctx context.Context, id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { return "", fmt.Errorf("error preparing values to write to DB: %w", err) @@ -596,7 +605,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri // If there's an ID, try to update first if id != "" { update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) - count, err := r.executeSQL(update) + count, err := r.executeSQL(ctx, update) if err != nil { return "", err } @@ -610,18 +619,18 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri values["id"] = id } insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(insert) + _, err = r.executeSQL(ctx, insert) return id, err } -func (r sqlRepository) delete(cond Sqlizer) error { - _, err := r.executeSQL(Delete(r.tableName).Where(cond)) +func (r sqlRepository) delete(ctx context.Context, cond Sqlizer) error { + _, err := r.executeSQL(ctx, Delete(r.tableName).Where(cond)) return err } // deleteByID is for single-item deletes that must report a missing row; delete succeeds silently. -func (r sqlRepository) deleteByID(id string) error { - count, err := r.executeSQL(Delete(r.tableName).Where(Eq{"id": id})) +func (r sqlRepository) deleteByID(ctx context.Context, id string) error { + count, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id})) if err != nil { return err } @@ -631,9 +640,9 @@ func (r sqlRepository) deleteByID(id string) error { return nil } -func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { +func (r sqlRepository) logSQL(ctx context.Context, sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) - fields := []any{r.ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} + fields := []any{ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} if err == nil || errors.Is(err, context.Canceled) { log.Trace(append(fields, err)...) return @@ -643,7 +652,7 @@ func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffect if code, extended, ok := db.ErrorCodes(err); ok { fields = append(fields, "sqliteCode", code, "sqliteExtended", extended) } - if db.IsBusy(err) && hasBusyRetry(r.ctx) { + if db.IsBusy(err) && hasBusyRetry(ctx) { log.Warn(append(fields, err)...) return } diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 0f76eb6ab..33a8140f8 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -13,8 +13,9 @@ import ( var _ = Describe("sqlRepository", func() { var r sqlRepository + var ctx context.Context BeforeEach(func() { - r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"}) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "user-id"}) r.tableName = "table" }) @@ -88,19 +89,19 @@ var _ = Describe("sqlRepository", func() { When("sanitizing sort", func() { It("returns empty if the sort key is not found in the model nor in the mappings", func() { - sort, _ := r.sanitizeSort("unknown", "") + sort, _ := r.sanitizeSort(ctx, "unknown", "") Expect(sort).To(BeEmpty()) }) // Validation only: buildSortOrder resolves the mapping, so mapping here too would hand // sortMapping its own output and re-map values whose parts are themselves keys. It("accepts a known sort key without resolving it", func() { - sort, _ := r.sanitizeSort("sort1", "") + sort, _ := r.sanitizeSort(ctx, "sort1", "") Expect(sort).To(Equal("sort1")) }) It("is case insensitive", func() { - sort, _ := r.sanitizeSort("Sort1", "") + sort, _ := r.sanitizeSort(ctx, "Sort1", "") Expect(sort).To(Equal("sort1")) }) @@ -112,38 +113,38 @@ var _ = Describe("sqlRepository", func() { // must survive the round trip through sanitizeSort and buildSortOrder unduplicated. It("does not re-map a value whose parts are also keys", func() { r.sortMappings = map[string]string{"rating": "rating", "rated_at": "rating, rated_at"} - sort, _ := r.sanitizeSort("rated_at", "") + sort, _ := r.sanitizeSort(ctx, "rated_at", "") Expect(r.buildSortOrder(sort, "asc")).To(Equal("rating asc, rated_at asc")) }) It("returns the field if it is a valid field", func() { - sort, _ := r.sanitizeSort("field", "") + sort, _ := r.sanitizeSort(ctx, "field", "") Expect(sort).To(Equal("field")) }) It("is case insensitive for fields", func() { - sort, _ := r.sanitizeSort("FIELD", "") + sort, _ := r.sanitizeSort(ctx, "FIELD", "") Expect(sort).To(Equal("field")) }) }) When("sanitizing order", func() { It("returns 'asc' if order is empty", func() { - _, order := r.sanitizeSort("", "") + _, order := r.sanitizeSort(ctx, "", "") Expect(order).To(Equal("")) }) It("returns 'asc' if order is 'asc'", func() { - _, order := r.sanitizeSort("", "ASC") + _, order := r.sanitizeSort(ctx, "", "ASC") Expect(order).To(Equal("asc")) }) It("returns 'desc' if order is 'desc'", func() { - _, order := r.sanitizeSort("", "desc") + _, order := r.sanitizeSort(ctx, "", "desc") Expect(order).To(Equal("desc")) }) It("returns 'asc' if order is unknown", func() { - _, order := r.sanitizeSort("", "something") + _, order := r.sanitizeSort(ctx, "", "something") Expect(order).To(Equal("asc")) }) }) @@ -248,31 +249,31 @@ var _ = Describe("sqlRepository", func() { Describe("resetSeededRandom", func() { var id string BeforeEach(func() { - id = r.seedKey() + id = r.seedKey(ctx) hasher.SetSeed(id, "") }) It("does not reset seed if sort is not random", func() { var options []model.QueryOptions - r.resetSeededRandom(options) + r.resetSeededRandom(ctx, options) Expect(hasher.CurrentSeed(id)).To(BeEmpty()) }) It("resets seed if sort is random", func() { options := []model.QueryOptions{{Sort: "random"}} - r.resetSeededRandom(options) + r.resetSeededRandom(ctx, options) Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty()) }) It("resets seed if sort is random and seed is provided", func() { options := []model.QueryOptions{{Sort: "random", Seed: "seed"}} - r.resetSeededRandom(options) + r.resetSeededRandom(ctx, options) Expect(hasher.CurrentSeed(id)).To(Equal("seed")) }) It("keeps seed when paginating", func() { options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}} - r.resetSeededRandom(options) + r.resetSeededRandom(ctx, options) Expect(hasher.CurrentSeed(id)).To(Equal("seed")) options = []model.QueryOptions{{Sort: "random", Offset: 1}} - r.resetSeededRandom(options) + r.resetSeededRandom(ctx, options) Expect(hasher.CurrentSeed(id)).To(Equal("seed")) }) }) @@ -298,11 +299,11 @@ var _ = Describe("sqlRepository", func() { Context("Admin User", func() { BeforeEach(func() { - r.ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true}) + ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true}) }) It("should not apply library filter for admin users", func() { - result := r.applyLibraryFilter(sq) + result := r.applyLibraryFilter(ctx, sq) sql, _, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) @@ -312,13 +313,13 @@ var _ = Describe("sqlRepository", func() { Context("Regular User with a subset of libraries", func() { BeforeEach(func() { // Strict subset: granted lib 1, DB has libs 1 and 2, so the filter must apply. - r.ctx = request.WithUser(context.Background(), model.User{ + ctx = request.WithUser(ctx, model.User{ ID: "user123", IsAdmin: false, Libraries: model.Libraries{{ID: 1}}, }) }) It("should apply library filter for regular users", func() { - result := r.applyLibraryFilter(sq) + result := r.applyLibraryFilter(ctx, sq) sql, args, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) @@ -326,7 +327,7 @@ var _ = Describe("sqlRepository", func() { }) It("should use custom table name when provided", func() { - result := r.applyLibraryFilter(sq, "custom_table") + result := r.applyLibraryFilter(ctx, sq, "custom_table") sql, args, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("custom_table.library_id IN")) @@ -336,11 +337,11 @@ var _ = Describe("sqlRepository", func() { Context("Regular User with no libraries", func() { BeforeEach(func() { - r.ctx = request.WithUser(context.Background(), model.User{ID: "empty", IsAdmin: false}) + ctx = request.WithUser(ctx, model.User{ID: "empty", IsAdmin: false}) }) It("should apply the library filter (never skip on empty)", func() { - result := r.applyLibraryFilter(sq) + result := r.applyLibraryFilter(ctx, sq) sql, _, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) @@ -359,20 +360,20 @@ var _ = Describe("sqlRepository", func() { for _, id := range ids { libs = append(libs, model.Library{ID: id}) } - r.ctx = request.WithUser(context.Background(), model.User{ + ctx = request.WithUser(ctx, model.User{ ID: "alllibs", IsAdmin: false, Libraries: libs, }) }) It("should not apply the library filter (subquery would filter nothing)", func() { - result := r.applyLibraryFilter(sq) + result := r.applyLibraryFilter(ctx, sq) sql, _, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) It("should not apply the filter even with a custom table name", func() { - result := r.applyLibraryFilter(sq, "custom_table") + result := r.applyLibraryFilter(ctx, sq, "custom_table") sql, _, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) @@ -381,18 +382,18 @@ var _ = Describe("sqlRepository", func() { Context("Headless Process (No User Context)", func() { BeforeEach(func() { - r.ctx = context.Background() // No user context + ctx = GinkgoT().Context() // No user context }) It("should not apply library filter for headless processes", func() { - result := r.applyLibraryFilter(sq) + result := r.applyLibraryFilter(ctx, sq) sql, _, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) It("should not apply library filter even with custom table name", func() { - result := r.applyLibraryFilter(sq, "custom_table") + result := r.applyLibraryFilter(ctx, sq, "custom_table") sql, _, err := result.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index cff57dc9d..ddb63019d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "database/sql" "errors" "fmt" @@ -14,8 +15,8 @@ import ( const bookmarkTable = "bookmark" -func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder { - userID := loggedUser(r.ctx).ID +func (r sqlRepository) withBookmark(ctx context.Context, query SelectBuilder, idField string) SelectBuilder { + userID := loggedUser(ctx).ID if userID == invalidUserId { return query } @@ -26,17 +27,17 @@ func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectB Columns("coalesce(position, 0) as bookmark_position") } -func (r sqlRepository) bmkID(itemID ...string) And { +func (r sqlRepository) bmkID(ctx context.Context, itemID ...string) And { return And{ - Eq{bookmarkTable + ".user_id": loggedUser(r.ctx).ID}, + Eq{bookmarkTable + ".user_id": loggedUser(ctx).ID}, Eq{bookmarkTable + ".item_type": r.tableName}, Eq{bookmarkTable + ".item_id": itemID}, } } -func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error { - client, _ := request.ClientFrom(r.ctx) - user, _ := request.UserFrom(r.ctx) +func (r sqlRepository) bmkUpsert(ctx context.Context, itemID, comment string, position int64) error { + client, _ := request.ClientFrom(ctx) + user, _ := request.UserFrom(ctx) values := map[string]any{ "comment": comment, "position": position, @@ -44,10 +45,10 @@ func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error { "changed_by": client, } - upd := Update(bookmarkTable).Where(r.bmkID(itemID)).SetMap(values) - c, err := r.executeSQL(upd) + upd := Update(bookmarkTable).Where(r.bmkID(ctx, itemID)).SetMap(values) + c, err := r.executeSQL(ctx, upd) if err == nil { - log.Debug(r.ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + log.Debug(ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) } if c == 0 || errors.Is(err, sql.ErrNoRows) { values["user_id"] = user.ID @@ -56,31 +57,31 @@ func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error { values["created_at"] = time.Now() values["updated_at"] = time.Now() ins := Insert(bookmarkTable).SetMap(values) - _, err = r.executeSQL(ins) + _, err = r.executeSQL(ctx, ins) if err != nil { return err } - log.Debug(r.ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + log.Debug(ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) } return err } -func (r sqlRepository) AddBookmark(id, comment string, position int64) error { - user, _ := request.UserFrom(r.ctx) - err := r.bmkUpsert(id, comment, position) +func (r sqlRepository) AddBookmark(ctx context.Context, id, comment string, position int64) error { + user, _ := request.UserFrom(ctx) + err := r.bmkUpsert(ctx, id, comment, position) if err != nil { - log.Error(r.ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) + log.Error(ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) } return err } -func (r sqlRepository) DeleteBookmark(id string) error { - user, _ := request.UserFrom(r.ctx) - del := Delete(bookmarkTable).Where(r.bmkID(id)) - _, err := r.executeSQL(del) +func (r sqlRepository) DeleteBookmark(ctx context.Context, id string) error { + user, _ := request.UserFrom(ctx) + del := Delete(bookmarkTable).Where(r.bmkID(ctx, id)) + _, err := r.executeSQL(ctx, del) if err != nil { - log.Error(r.ctx, "Error removing bookmark", "id", id, "user", user.UserName) + log.Error(ctx, "Error removing bookmark", "id", id, "user", user.UserName) } return err } @@ -96,18 +97,18 @@ type bookmark struct { UpdatedAt time.Time `json:"updatedAt"` } -func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { - user, _ := request.UserFrom(r.ctx) +func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error) { + user, _ := request.UserFrom(ctx) idField := r.tableName + ".id" - sq := r.newSelect().Columns(r.tableName + ".*") - sq = r.withAnnotation(sq, idField) - sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) - sq = r.applyLibraryFilter(sq) + sq := r.newSelect(ctx).Columns(r.tableName + ".*") + sq = r.withAnnotation(ctx, sq, idField) + sq = r.withBookmark(ctx, sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) + sq = r.applyLibraryFilter(ctx, sq) var mfs dbMediaFiles // TODO Decouple from media_file - err := r.queryAll(sq, &mfs) + err := r.queryAll(ctx, sq, &mfs) if err != nil { - log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) + log.Error(ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) return nil, err } @@ -118,18 +119,18 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { mfMap[mf.ID] = i } - sq = Select("*").From(bookmarkTable).Where(r.bmkID(ids...)) + sq = Select("*").From(bookmarkTable).Where(r.bmkID(ctx, ids...)) var bmks []bookmark - err = r.queryAll(sq, &bmks) + err = r.queryAll(ctx, sq, &bmks) if err != nil { - log.Error(r.ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err) + log.Error(ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err) return nil, err } resp := make(model.Bookmarks, len(bmks)) for i, bmk := range bmks { if itemIdx, ok := mfMap[bmk.ItemID]; !ok { - log.Debug(r.ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) + log.Debug(ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) continue } else { resp[i] = model.Bookmark{ @@ -145,21 +146,21 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { return resp, nil } -func (r sqlRepository) reassignBookmark(prevID, newID string) error { +func (r sqlRepository) reassignBookmark(ctx context.Context, prevID, newID string) error { upd := Expr("update or ignore "+bookmarkTable+" set item_id = ? where item_type = ? and item_id = ?", newID, r.tableName, prevID) - _, err := r.executeSQL(upd) + _, err := r.executeSQL(ctx, upd) return err } -func (r sqlRepository) cleanBookmarks() error { +func (r sqlRepository) cleanBookmarks(ctx context.Context) error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") - c, err := r.executeSQL(del) + c, err := r.executeSQL(ctx, del) if err != nil { return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err) } if c > 0 { - log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) + log.Debug(ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) } return nil } diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index ae01a0e35..b50a35d5d 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -12,23 +12,23 @@ import ( var _ = Describe("sqlBookmarks", func() { var mr model.MediaFileRepository + var ctx context.Context BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid"}) - mr = NewMediaFileRepository(ctx, GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) + mr = NewMediaFileRepository(GetDBXBuilder()) }) Describe("Bookmarks", func() { It("returns an empty collection if there are no bookmarks", func() { - Expect(mr.GetBookmarks()).To(BeEmpty()) + Expect(mr.GetBookmarks(ctx)).To(BeEmpty()) }) It("saves and overrides bookmarks", func() { By("Saving the bookmark") - Expect(mr.AddBookmark(songAntenna.ID, "this is a comment", 123)).To(BeNil()) + Expect(mr.AddBookmark(ctx, songAntenna.ID, "this is a comment", 123)).To(BeNil()) - bms, err := mr.GetBookmarks() + bms, err := mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(1)) @@ -42,9 +42,9 @@ var _ = Describe("sqlBookmarks", func() { Expect(updated).To(BeTemporally(">=", created)) By("Overriding the bookmark") - Expect(mr.AddBookmark(songAntenna.ID, "another comment", 333)).To(BeNil()) + Expect(mr.AddBookmark(ctx, songAntenna.ID, "another comment", 333)).To(BeNil()) - bms, err = mr.GetBookmarks() + bms, err = mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) @@ -54,66 +54,66 @@ var _ = Describe("sqlBookmarks", func() { Expect(bms[0].UpdatedAt).To(BeTemporally(">=", updated)) By("Saving another bookmark") - Expect(mr.AddBookmark(songComeTogether.ID, "one more comment", 444)).To(BeNil()) - bms, err = mr.GetBookmarks() + Expect(mr.AddBookmark(ctx, songComeTogether.ID, "one more comment", 444)).To(BeNil()) + bms, err = mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(2)) By("Delete bookmark") - Expect(mr.DeleteBookmark(songAntenna.ID)).To(Succeed()) - bms, err = mr.GetBookmarks() + Expect(mr.DeleteBookmark(ctx, songAntenna.ID)).To(Succeed()) + bms, err = mr.GetBookmarks(ctx) Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(1)) Expect(bms[0].Item.ID).To(Equal(songComeTogether.ID)) Expect(bms[0].Item.Title).To(Equal(songComeTogether.Title)) - Expect(mr.DeleteBookmark(songComeTogether.ID)).To(Succeed()) - Expect(mr.GetBookmarks()).To(BeEmpty()) + Expect(mr.DeleteBookmark(ctx, songComeTogether.ID)).To(Succeed()) + Expect(mr.GetBookmarks(ctx)).To(BeEmpty()) }) }) Describe("library access", func() { var otherLib model.Library var restrictedUser model.User - var adminCtx context.Context + var adminCtx, userCtx context.Context var userMr model.MediaFileRepository BeforeEach(func() { adminCtx, otherLib, restrictedUser = restrictedFixture("bmk") - adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) - Expect(adminMr.Put(&model.MediaFile{ + adminMr := NewMediaFileRepository(GetDBXBuilder()) + Expect(adminMr.Put(adminCtx, &model.MediaFile{ ID: "bmk-otherlib-track", LibraryID: otherLib.ID, Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked", })).To(Succeed()) - DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") }) + DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "bmk-otherlib-track") }) - userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) - userMr = NewMediaFileRepository(userCtx, GetDBXBuilder()) + userCtx = request.WithUser(ctx, restrictedUser) + userMr = NewMediaFileRepository(GetDBXBuilder()) }) It("does not return bookmarks for tracks outside the user's libraries", func() { - Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed()) + Expect(userMr.AddBookmark(userCtx, "bmk-otherlib-track", "sneaky", 1)).To(Succeed()) - Expect(userMr.GetBookmarks()).To(BeEmpty()) + Expect(userMr.GetBookmarks(userCtx)).To(BeEmpty()) }) It("still returns the bookmark for an admin", func() { - adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) - Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed()) - DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") }) + adminMr := NewMediaFileRepository(GetDBXBuilder()) + Expect(adminMr.AddBookmark(adminCtx, "bmk-otherlib-track", "mine", 1)).To(Succeed()) + DeferCleanup(func() { _ = adminMr.DeleteBookmark(adminCtx, "bmk-otherlib-track") }) - bms, err := adminMr.GetBookmarks() + bms, err := adminMr.GetBookmarks(adminCtx) Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(1)) Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track")) }) It("keeps returning bookmarks for tracks inside the user's libraries", func() { - Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed()) - DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) }) + Expect(userMr.AddBookmark(userCtx, songAntenna.ID, "allowed", 5)).To(Succeed()) + DeferCleanup(func() { _ = userMr.DeleteBookmark(userCtx, songAntenna.ID) }) - bms, err := userMr.GetBookmarks() + bms, err := userMr.GetBookmarks(userCtx) Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(1)) Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index 746abed01..bc2af39e2 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "encoding/json" "fmt" @@ -74,12 +75,12 @@ func unmarshalParticipants(data string) (model.Participants, error) { return participants, nil } -func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error { +func (r sqlRepository) updateParticipants(ctx context.Context, itemID string, participants model.Participants) error { // Delete all existing participant entries for this item. // This ensures stale role associations are removed when an artist's role changes // (e.g., an artist was both albumartist and composer, but is now only composer). sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID}) - _, err := r.executeSQL(sqd) + _, err := r.executeSQL(ctx, sqd) if err != nil { return err } @@ -119,14 +120,14 @@ func (r sqlRepository) updateParticipants(itemID string, participants model.Part ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates `, r.tableName) - _, err = r.executeSQL(Expr(query, itemID, string(participantsJSON))) + _, err = r.executeSQL(ctx, Expr(query, itemID, string(participantsJSON))) return err } -func (r *sqlRepository) getParticipants(m *model.MediaFile) (model.Participants, error) { - ar := NewArtistRepository(r.ctx, r.db) +func (r *sqlRepository) getParticipants(ctx context.Context, m *model.MediaFile) (model.Participants, error) { + ar := NewArtistRepository(r.db) ids := m.Participants.AllIDs() - artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"artist.id": ids}}) + artists, err := ar.GetAll(ctx, model.QueryOptions{Filters: Eq{"artist.id": ids}}) if err != nil { return nil, fmt.Errorf("getting participants: %w", err) } diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index b1cfd2379..a758cb847 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -54,7 +54,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions { qo := model.QueryOptions{} if len(options) > 0 { - qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, options[0].Order) + qo.Sort, qo.Order = r.sanitizeSort(ctx, options[0].Sort, options[0].Order) qo.Max = options[0].Max qo.Offset = options[0].Offset if seed, ok := options[0].Filters["seed"].(string); ok { @@ -66,13 +66,13 @@ func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.Qu return qo } -func (r sqlRepository) sanitizeSort(sort, order string) (string, string) { +func (r sqlRepository) sanitizeSort(ctx context.Context, sort, order string) (string, string) { if sort != "" { sort = toSnakeCase(sort) // Validate only: buildSortOrder resolves the mapping later, and mapping here as well would // feed sortMapping its own output. if _, _, known := r.lookupSortMapping(sort); !known && !r.isFieldWhiteListed(sort) { - log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) + log.Warn(ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) sort = "" } } @@ -129,11 +129,9 @@ func idFilter(tableName string) func(string, any) Sqlizer { return func(field string, value any) Sqlizer { return Eq{tableName + ".id": value} } } -func invalidFilter(ctx context.Context) func(string, any) Sqlizer { - return func(field string, value any) Sqlizer { - log.Warn(ctx, "Invalid filter", "fieldName", field, "value", value) - return Eq{"1": "0"} - } +func invalidFilter(field string, value any) Sqlizer { + log.Warn("Invalid filter", "fieldName", field, "value", value) + return Eq{"1": "0"} } var ( diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 3049baae7..aa8707247 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "fmt" "strings" @@ -32,7 +33,7 @@ type searchConfig struct { // including FTS Phase 1 which builds its own query outside sq. type searchStrategy interface { Sqlizer - execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error + execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error } // getSearchStrategy returns the appropriate search strategy based on config and query content. @@ -51,7 +52,7 @@ func getSearchStrategy(tableName, query string) searchStrategy { // otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set // via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same // filters and pagination independently. -func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error { +func (r sqlRepository) doSearch(ctx context.Context, sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error { q = strings.TrimSpace(q) q = strings.TrimSuffix(q, "*") @@ -60,13 +61,13 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order. if q == "" || q == `""` { rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder) - return r.executeTwoPhase(sq, results, rowidCore, cfg, options) + return r.executeTwoPhase(ctx, sq, results, rowidCore, cfg, options) } // MBID search: if query is a valid UUID, search by MBID fields instead if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 { sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...)) - return r.queryAll(sq, results) + return r.queryAll(ctx, sq, results) } // Min-length guard: single-character queries are too broad for search3. @@ -81,7 +82,7 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea return nil } - return strategy.execute(r, sq, results, cfg, options) + return strategy.execute(ctx, r, sq, results, cfg, options) } // executeTwoPhase runs a search in two phases: @@ -91,7 +92,7 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea // covering index; with those JOINs, large offsets degrade to O(offset) join probes — // multi-second responses on 100k+ libraries. // - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid page. -func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error { +func (r sqlRepository) executeTwoPhase(ctx context.Context, sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error { rowidQuery := rowidCore. Where(Eq{r.tableName + ".missing": false}) if options.Max > 0 { @@ -103,17 +104,17 @@ func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore if cfg.LibraryFilter != nil { rowidQuery = cfg.LibraryFilter(rowidQuery) } else { - rowidQuery = r.applyLibraryFilter(rowidQuery) + rowidQuery = r.applyLibraryFilter(ctx, rowidQuery) } if options.Filters != nil { rowidQuery = rowidQuery.Where(options.Filters) } - return r.hydrateRowidPage(sq, rowidQuery, results) + return r.hydrateRowidPage(ctx, sq, rowidQuery, results) } // hydrateRowidPage joins sq to the ordered rowid set produced by rowidQuery, preserving its // ordering. rowidQuery must handle pagination itself; sq's LIMIT/OFFSET are stripped. -func (r sqlRepository) hydrateRowidPage(sq SelectBuilder, rowidQuery SelectBuilder, results any) error { +func (r sqlRepository) hydrateRowidPage(ctx context.Context, sq SelectBuilder, rowidQuery SelectBuilder, results any) error { rowidSQL, rowidArgs, err := rowidQuery.ToSql() if err != nil { return fmt.Errorf("building rowid query: %w", err) @@ -125,7 +126,7 @@ func (r sqlRepository) hydrateRowidPage(sq SelectBuilder, rowidQuery SelectBuild ) sq = sq.Join(rankedSubquery+" ON "+r.tableName+".rowid = _ranked._rid", rowidArgs...) sq = sq.OrderBy("_ranked._rn") - return r.queryAll(sq, results) + return r.queryAll(ctx, sq, results) } func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index fce77afbb..ee43680e6 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "fmt" "regexp" "strings" @@ -270,7 +271,7 @@ func (s *ftsSearch) ToSql() (string, []any, error) { // execute runs a two-phase FTS5 search (see executeTwoPhase): Phase 1 here contributes the // FTS MATCH join and BM25 rank ordering. Complex ORDER BY (function calls, aggregations) are // dropped from Phase 1. -func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { +func (s *ftsSearch) execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { qualifiedOrderBys := []string{s.rankExpr} for _, ob := range cfg.OrderBy { if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" { @@ -282,7 +283,7 @@ func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg sea From(s.tableName). Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr). OrderBy(qualifiedOrderBys...) - return r.executeTwoPhase(sq, dest, rowidCore, cfg, options) + return r.executeTwoPhase(ctx, sq, dest, rowidCore, cfg, options) } // qualifyOrderBy prepends tableName to a simple column name. Returns empty string for diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index d0b26e8d5..5d52cb1e8 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -317,20 +317,20 @@ var _ = Describe("FTS5 Integration Search", func() { mr model.MediaFileRepository alr model.AlbumRepository arr model.ArtistRepository + ctx context.Context ) BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) conn := GetDBXBuilder() - mr = NewMediaFileRepository(ctx, conn) - alr = NewAlbumRepository(ctx, conn) - arr = NewArtistRepository(ctx, conn) + mr = NewMediaFileRepository(conn) + alr = NewAlbumRepository(conn) + arr = NewArtistRepository(conn) }) Describe("MediaFile search", func() { It("finds media files by title", func() { - results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("Radioactivity")) @@ -338,7 +338,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds media files by artist name", func() { - results, err := mr.Search("Beatles", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, r := range results { @@ -349,7 +349,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Album search", func() { It("finds albums by name", func() { - results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10}) + results, err := alr.Search(ctx, "Sgt Peppers", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("Sgt Peppers")) @@ -357,7 +357,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds albums with multi-word search", func() { - results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10}) + results, err := alr.Search(ctx, "Abbey Road", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) }) @@ -365,7 +365,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Artist search", func() { It("finds artists by name", func() { - results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10}) + results, err := arr.Search(ctx, "Kraftwerk", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("Kraftwerk")) @@ -375,7 +375,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("CJK search", func() { It("finds media files by CJK title", func() { - results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "プラチナ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("プラチナ・ジェット")) @@ -383,14 +383,14 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds media files by CJK artist name", func() { - results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Artist).To(Equal("シートベルツ")) }) It("finds albums by CJK artist name", func() { - results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10}) + results, err := alr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("COWBOY BEBOP")) @@ -398,7 +398,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds artists by CJK name", func() { - results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10}) + results, err := arr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("シートベルツ")) @@ -408,7 +408,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Album version search", func() { It("finds albums by version tag via FTS", func() { - results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10}) + results, err := alr.Search(ctx, "Deluxe", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal(albumWithVersion.ID)) @@ -417,7 +417,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Punctuation-only search", func() { It("finds media files with punctuation-only title", func() { - results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "!!!!!!!", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("!!!!!!!")) @@ -427,7 +427,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Single-character search (doSearch min-length guard)", func() { It("returns empty results for single-char query via Search", func() { - results, err := mr.Search("a", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "a", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") }) @@ -435,7 +435,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() { It("returns results with Max=0", func() { - results, err := mr.Search("Beatles", model.QueryOptions{Max: 0}) + results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 0}) Expect(err).ToNot(HaveOccurred()) Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) @@ -456,19 +456,19 @@ var _ = Describe("FTS5 Integration Search", func() { {ID: "fts-rank-2", Name: "Modest Mouse", OrderArtistName: "modest mouse"}, {ID: "fts-rank-3", Name: "Morrissey", OrderArtistName: "morrissey"}, } { - Expect(createArtistWithLibrary(arr, &a, 1)).To(Succeed()) + Expect(createArtistWithLibrary(ctx, arr, &a, 1)).To(Succeed()) } }) It("ranks the exact transliterated match first for 'MO'", func() { - results, err := arr.Search("MO", model.QueryOptions{Max: 10}) + results, err := arr.Search(ctx, "MO", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Name).To(Equal("MØ"), "exact match via search_normalized must outrank prefix matches") }) It("ranks the exact match first for the accented query 'MØ'", func() { - results, err := arr.Search("MØ", model.QueryOptions{Max: 10}) + results, err := arr.Search(ctx, "MØ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).ToNot(BeEmpty()) Expect(results[0].Name).To(Equal("MØ")) diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go index 972545ac5..c7d94cc04 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "strings" . "github.com/Masterminds/squirrel" @@ -20,10 +21,10 @@ func (s *likeSearch) ToSql() (string, []any, error) { return s.filter.ToSql() } -func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { +func (s *likeSearch) execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { sq = sq.Where(s.filter) sq = sq.OrderBy(cfg.OrderBy...) - return r.queryAll(sq, dest, options) + return r.queryAll(ctx, sq, dest, options) } // newLegacySearch creates a LIKE search against the full_text column. diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go index 8ee4ef93c..2bfeba29b 100644 --- a/persistence/sql_search_like_test.go +++ b/persistence/sql_search_like_test.go @@ -102,32 +102,32 @@ var _ = Describe("likeSearchExpr", func() { var _ = Describe("Legacy Integration Search", func() { var mr model.MediaFileRepository + var ctx context.Context BeforeEach(func() { + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "legacy" - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) conn := GetDBXBuilder() - mr = NewMediaFileRepository(ctx, conn) + mr = NewMediaFileRepository(conn) }) It("returns results using legacy LIKE-based search", func() { - results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("Radioactivity")) }) It("returns empty results for single-char query (doSearch min-length guard)", func() { - results, err := mr.Search("a", model.QueryOptions{Max: 10}) + results, err := mr.Search(ctx, "a", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") }) It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() { - results, err := mr.Search("Beatles", model.QueryOptions{Max: 0}) + results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 0}) Expect(err).ToNot(HaveOccurred()) Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 11f9fe00e..7e1978453 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -54,9 +54,9 @@ var indexedTagNames = []model.TagName{model.TagGenre} // updateTags rewrites this item's _tags rows from its in-memory tags, mirroring // updateParticipants (delete-then-insert in the same Put; JOIN to tag skips not-yet-saved ids). -func (r sqlRepository) updateTags(itemID string, tags model.Tags) error { +func (r sqlRepository) updateTags(ctx context.Context, itemID string, tags model.Tags) error { del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID}) - if _, err := r.executeSQL(del); err != nil { + if _, err := r.executeSQL(ctx, del); err != nil { return err } var tagIDs []string @@ -77,7 +77,7 @@ func (r sqlRepository) updateTags(itemID string, tags model.Tags) error { SELECT ?, value FROM json_each(?) JOIN tag ON tag.id = value ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName) - _, err = r.executeSQL(Expr(query, itemID, string(idsJSON))) + _, err = r.executeSQL(ctx, Expr(query, itemID, string(idsJSON))) return err } @@ -146,11 +146,10 @@ type baseTagRepository struct { // newBaseTagRepository creates a new base tag repository with optional tag filtering. // If tagFilter is nil, the repository will work with all tags. // If tagFilter is provided, the repository will only work with tags of that specific name. -func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { +func newBaseTagRepository(db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { r := &baseTagRepository{ tagFilter: tagFilter, } - r.ctx = ctx r.db = db r.tableName = "tag" r.registerModel(&model.Tag{}, map[string]filterFunc{ @@ -164,12 +163,12 @@ func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model. } // applyLibraryFiltering adds the appropriate library joins based on user context -func (r *baseTagRepository) applyLibraryFiltering(sq SelectBuilder) SelectBuilder { +func (r *baseTagRepository) applyLibraryFiltering(ctx context.Context, sq SelectBuilder) SelectBuilder { // Add library_tag join sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id") // For authenticated users, also join with user_library to filter by accessible libraries - user := loggedUser(r.ctx) + user := loggedUser(ctx) if user.ID != invalidUserId { sq = sq.Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID) } @@ -178,8 +177,8 @@ func (r *baseTagRepository) applyLibraryFiltering(sq SelectBuilder) SelectBuilde } // newSelect overrides the base implementation to apply tag name filtering and library filtering. -func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder { - sq := r.sqlRepository.newSelect(options...) +func (r *baseTagRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + sq := r.sqlRepository.newSelect(ctx, options...) // Apply tag name filtering if specified if r.tagFilter != nil { @@ -187,7 +186,7 @@ func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuild } // Apply library filtering and set up aggregation columns - sq = r.applyLibraryFiltering(sq).Columns( + sq = r.applyLibraryFiltering(ctx, sq).Columns( "tag.id", "tag.tag_name", "tag.tag_value", @@ -198,9 +197,9 @@ func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuild return sq } -// ResourceRepository interface implementation +// REST interface methods -func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) { +func (r *baseTagRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { sq := Select("COUNT(DISTINCT tag.id)").From("tag") // Apply tag name filtering if specified @@ -209,32 +208,24 @@ func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) { } // Apply library filtering - sq = r.applyLibraryFiltering(sq) + sq = r.applyLibraryFiltering(ctx, sq) - return r.count(sq, r.parseRestOptions(r.ctx, options...)) + return r.count(ctx, sq, r.parseRestOptions(ctx, options...)) } -func (r *baseTagRepository) Read(id string) (any, error) { - query := r.newSelect().Where(Eq{"id": id}) +func (r *baseTagRepository) Read(ctx context.Context, id string) (*model.Tag, error) { + query := r.newSelect(ctx).Where(Eq{"id": id}) var res model.Tag - err := r.queryOne(query, &res) + err := r.queryOne(ctx, query, &res) return &res, err } -func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - query := r.newSelect(r.parseRestOptions(r.ctx, options...)) +func (r *baseTagRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Tag, error) { + query := r.newSelect(ctx, r.parseRestOptions(ctx, options...)) var res model.TagList - err := r.queryAll(query, &res) + err := r.queryAll(ctx, query, &res) return res, err } -func (r *baseTagRepository) EntityName() string { - return "tag" -} - -func (r *baseTagRepository) NewInstance() any { - return model.Tag{} -} - // Interface compliance check -var _ model.ResourceRepository = (*baseTagRepository)(nil) +var _ rest.Repository[model.Tag] = (*baseTagRepository)(nil) diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go index ddd897165..a4382dccf 100644 --- a/persistence/tag_library_filtering_test.go +++ b/persistence/tag_library_filtering_test.go @@ -78,11 +78,11 @@ var _ = Describe("Tag Library Filtering", func() { // Create test tags adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) + tagRepo := NewTagRepository(GetDBXBuilder()) createTag := func(libraryID int, name, value string) { tag := model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} - err := tagRepo.Add(libraryID, tag) + err := tagRepo.Add(adminCtx, libraryID, tag) Expect(err).ToNot(HaveOccurred()) } @@ -119,17 +119,16 @@ var _ = Describe("Tag Library Filtering", func() { ctx = context.Background() // Headless context } - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) + repo := NewTagRepository(GetDBXBuilder()) var opts rest.QueryOptions if len(filters) > 0 { opts = filters[0] } - tags, err := repo.ReadAll(opts) + tags, err := repo.ReadAll(ctx, opts) Expect(err).ToNot(HaveOccurred()) - return tags.(model.TagList) + return tags } // Helper to count tags @@ -141,10 +140,9 @@ var _ = Describe("Tag Library Filtering", func() { ctx = context.Background() } - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) + repo := NewTagRepository(GetDBXBuilder()) - count, err := repo.Count() + count, err := repo.Count(ctx) Expect(err).ToNot(HaveOccurred()) return count } diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index f2093c9d8..d13dece66 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -7,6 +7,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" @@ -16,20 +17,20 @@ type tagRepository struct { *baseTagRepository } -func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository { +func NewTagRepository(db dbx.Builder) model.TagRepository { return &tagRepository{ - baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags + baseTagRepository: newBaseTagRepository(db, nil), // nil = no filter, works with all tags } } -func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error { +func (r *tagRepository) Add(ctx context.Context, libraryID int, tags ...model.Tag) error { for chunk := range slices.Chunk(tags, 200) { sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value"). Suffix("on conflict (id) do nothing") for _, t := range chunk { sq = sq.Values(t.ID, t.TagName, t.TagValue) } - _, err := r.executeSQL(sq) + _, err := r.executeSQL(ctx, sq) if err != nil { return err } @@ -40,7 +41,7 @@ func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error { for _, t := range chunk { libSq = libSq.Values(t.ID, libraryID, 0, 0) } - _, err = r.executeSQL(libSq) + _, err = r.executeSQL(ctx, libSq) if err != nil { return fmt.Errorf("adding library_tag entries: %w", err) } @@ -50,7 +51,7 @@ func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error { // UpdateCounts updates the library_tag table with per-library statistics. // Only genres are being updated for now. -func (r *tagRepository) UpdateCounts() error { +func (r *tagRepository) UpdateCounts(ctx context.Context) error { template := ` INSERT INTO library_tag (tag_id, library_id, %[1]s_count) SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count @@ -65,8 +66,8 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; for _, table := range []string{"album", "media_file"} { start := time.Now() query := Expr(fmt.Sprintf(template, table)) - c, err := r.executeSQL(query) - log.Debug(r.ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c) + c, err := r.executeSQL(ctx, query) + log.Debug(ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c) if err != nil { return fmt.Errorf("updating %s library tag counts: %w", table, err) } @@ -74,14 +75,14 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } -func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) { - sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name}) +func (r *tagRepository) GetAll(ctx context.Context, name model.TagName, options ...model.QueryOptions) (model.TagList, error) { + sq := r.newSelect(ctx, options...).Where(Eq{"tag.tag_name": name}) res := model.TagList{} - err := r.queryAll(sq, &res) + err := r.queryAll(ctx, sq, &res) return res, err } -func (r *tagRepository) purgeUnused() error { +func (r *tagRepository) purgeUnused(ctx context.Context) error { del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt @@ -93,14 +94,14 @@ func (r *tagRepository) purgeUnused() error { where atom is not null and key = 'id') `) - c, err := r.executeSQL(del) + c, err := r.executeSQL(ctx, del) if err != nil { return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err) } if c > 0 { - log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) + log.Debug(ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) } return err } -var _ model.ResourceRepository = &tagRepository{} +var _ rest.Repository[model.Tag] = &tagRepository{} diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go index 9a019c30e..730d26357 100644 --- a/persistence/tag_repository_test.go +++ b/persistence/tag_repository_test.go @@ -18,15 +18,15 @@ import ( var _ = Describe("TagRepository", func() { var repo model.TagRepository - var restRepo model.ResourceRepository + var restRepo rest.Repository[model.Tag] var ctx context.Context BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + tagRepo := NewTagRepository(GetDBXBuilder()) repo = tagRepo - restRepo = tagRepo.(model.ResourceRepository) + restRepo = tagRepo // Clean the database before each test to ensure isolation db := GetDBXBuilder() @@ -48,7 +48,7 @@ var _ = Describe("TagRepository", func() { return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} } - err = repo.Add(1, + err = repo.Add(ctx, 1, // Genre tags newTag("genre", "rock"), newTag("genre", "pop"), @@ -84,17 +84,16 @@ var _ = Describe("TagRepository", func() { TagValue: "experimental", } - err := repo.Add(1, newTag) + err := repo.Add(ctx, 1, newTag) Expect(err).ToNot(HaveOccurred()) // Verify tag was added - result, err := restRepo.Read(newTag.ID) + resultTag, err := restRepo.Read(ctx, newTag.ID) Expect(err).ToNot(HaveOccurred()) - resultTag := result.(*model.Tag) Expect(resultTag.TagValue).To(Equal("experimental")) // Check count increased - count, err := restRepo.Count() + count, err := restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(21))) // 20 from dataset + 1 new }) @@ -107,15 +106,15 @@ var _ = Describe("TagRepository", func() { TagValue: "rock", } - count, err := restRepo.Count() + count, err := restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(20))) // Still 20 tags - err = repo.Add(1, duplicateTag) + err = repo.Add(ctx, 1, duplicateTag) Expect(err).ToNot(HaveOccurred()) // Should not error // Count should remain the same - count, err = restRepo.Count() + count, err = restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(20))) // Still 20 tags }) @@ -123,7 +122,7 @@ var _ = Describe("TagRepository", func() { Describe("UpdateCounts", func() { It("should update tag counts successfully", func() { - err := repo.UpdateCounts() + err := repo.UpdateCounts(ctx) Expect(err).ToNot(HaveOccurred()) }) @@ -133,7 +132,7 @@ var _ = Describe("TagRepository", func() { _, err := db.NewQuery("DELETE FROM tag").Execute() Expect(err).ToNot(HaveOccurred()) - err = repo.UpdateCounts() + err = repo.UpdateCounts(ctx) Expect(err).ToNot(HaveOccurred()) }) @@ -159,7 +158,7 @@ var _ = Describe("TagRepository", func() { Expect(err).ToNot(HaveOccurred()) // This should not fail with foreign key constraint error - err = repo.UpdateCounts() + err = repo.UpdateCounts(ctx) Expect(err).ToNot(HaveOccurred()) // Cleanup @@ -189,7 +188,7 @@ var _ = Describe("TagRepository", func() { Expect(err).ToNot(HaveOccurred()) // This should not fail with foreign key constraint error - err = repo.UpdateCounts() + err = repo.UpdateCounts(ctx) Expect(err).ToNot(HaveOccurred()) // Cleanup @@ -201,7 +200,7 @@ var _ = Describe("TagRepository", func() { Describe("Count", func() { It("should return correct count of tags", func() { - count, err := restRepo.Count() + count, err := restRepo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(20))) // From the test dataset }) @@ -210,25 +209,23 @@ var _ = Describe("TagRepository", func() { Describe("Read", func() { It("should return existing tag", func() { rockID := id.NewTagID("genre", "rock") - result, err := restRepo.Read(rockID) + resultTag, err := restRepo.Read(ctx, rockID) Expect(err).ToNot(HaveOccurred()) - resultTag := result.(*model.Tag) Expect(resultTag.ID).To(Equal(rockID)) Expect(resultTag.TagName).To(Equal(model.TagName("genre"))) Expect(resultTag.TagValue).To(Equal("rock")) }) It("should return error for non-existent tag", func() { - _, err := restRepo.Read("non-existent-id") + _, err := restRepo.Read(ctx, "non-existent-id") Expect(err).To(HaveOccurred()) }) }) Describe("ReadAll", func() { It("should return all tags from dataset", func() { - result, err := restRepo.ReadAll() + tags, err := restRepo.ReadAll(ctx) Expect(err).ToNot(HaveOccurred()) - tags := result.(model.TagList) Expect(tags).To(HaveLen(20)) }) @@ -236,9 +233,8 @@ var _ = Describe("TagRepository", func() { options := rest.QueryOptions{ Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock' } - result, err := restRepo.ReadAll(options) + tags, err := restRepo.ReadAll(ctx, options) Expect(err).ToNot(HaveOccurred()) - tags := result.(model.TagList) Expect(tags).To(HaveLen(2)) // "rock" and "Alternative Rock" // Verify all returned tags contain 'rock' in their value @@ -251,9 +247,8 @@ var _ = Describe("TagRepository", func() { options := rest.QueryOptions{ Filters: map[string]any{"name": "%e%"}, // Tags containing 'e' } - result, err := restRepo.ReadAll(options) + tags, err := restRepo.ReadAll(ctx, options) Expect(err).ToNot(HaveOccurred()) - tags := result.(model.TagList) Expect(tags).To(HaveLen(8)) // electronic, house, trance, energetic, Blues, decade x2, Alternative Rock // Verify all returned tags contain 'e' in their value @@ -268,9 +263,8 @@ var _ = Describe("TagRepository", func() { Sort: "name", Order: "asc", } - result, err := restRepo.ReadAll(options) + tags, err := restRepo.ReadAll(ctx, options) Expect(err).ToNot(HaveOccurred()) - tags := result.(model.TagList) Expect(tags).To(HaveLen(7)) Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { @@ -284,9 +278,8 @@ var _ = Describe("TagRepository", func() { Sort: "name", Order: "desc", } - result, err := restRepo.ReadAll(options) + tags, err := restRepo.ReadAll(ctx, options) Expect(err).ToNot(HaveOccurred()) - tags := result.(model.TagList) Expect(tags).To(HaveLen(7)) Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { @@ -294,18 +287,4 @@ var _ = Describe("TagRepository", func() { })) }) }) - - Describe("EntityName", func() { - It("should return correct entity name", func() { - name := restRepo.EntityName() - Expect(name).To(Equal("tag")) - }) - }) - - Describe("NewInstance", func() { - It("should return new tag instance", func() { - instance := restRepo.NewInstance() - Expect(instance).To(BeAssignableToTypeOf(model.Tag{})) - }) - }) }) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index fdf67806d..9db56e143 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -13,63 +13,62 @@ type transcodingRepository struct { sqlRepository } -func NewTranscodingRepository(ctx context.Context, db dbx.Builder) model.TranscodingRepository { +func NewTranscodingRepository(db dbx.Builder) model.TranscodingRepository { r := &transcodingRepository{} - r.ctx = ctx r.db = db r.registerModel(&model.Transcoding{}, nil) return r } -func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) { - sel := r.newSelect().Columns("*").Where(Eq{"id": id}) +func (r *transcodingRepository) Get(ctx context.Context, id string) (*model.Transcoding, error) { + sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": id}) var res model.Transcoding - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) { - return r.count(Select(), qo...) +func (r *transcodingRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) { + return r.count(ctx, Select(), qo...) } -func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) { - sel := r.newSelect().Columns("*").Where(Eq{"target_format": format}) +func (r *transcodingRepository) FindByFormat(ctx context.Context, format string) (*model.Transcoding, error) { + sel := r.newSelect(ctx).Columns("*").Where(Eq{"target_format": format}) var res model.Transcoding - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) return &res, err } -func (r *transcodingRepository) Put(t *model.Transcoding) error { - if !loggedUser(r.ctx).IsAdmin { +func (r *transcodingRepository) Put(ctx context.Context, t *model.Transcoding) error { + if !loggedUser(ctx).IsAdmin { return rest.ErrPermissionDenied } - _, err := r.put(t.ID, t) + _, err := r.put(ctx, t.ID, t) return err } -func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.count(Select(), r.parseRestOptions(r.ctx, options...)) +func (r *transcodingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.count(ctx, Select(), r.parseRestOptions(ctx, options...)) } -func (r *transcodingRepository) Read(id string) (any, error) { - res, err := r.Get(id) +func (r *transcodingRepository) Read(ctx context.Context, id string) (*model.Transcoding, error) { + res, err := r.Get(ctx, id) if err != nil { return nil, err } - if !loggedUser(r.ctx).IsAdmin { + if !loggedUser(ctx).IsAdmin { res.Command = "" } return res, nil } -func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") +func (r *transcodingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Transcoding, error) { + sel := r.newSelect(ctx, r.parseRestOptions(ctx, options...)).Columns("*") res := model.Transcodings{} - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } - if !loggedUser(r.ctx).IsAdmin { + if !loggedUser(ctx).IsAdmin { for i := range res { res[i].Command = "" } @@ -77,39 +76,35 @@ func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, erro return res, nil } -func (r *transcodingRepository) EntityName() string { - return "transcoding" -} - -func (r *transcodingRepository) NewInstance() any { - return &model.Transcoding{} -} - -func (r *transcodingRepository) Save(entity any) (string, error) { - if !loggedUser(r.ctx).IsAdmin { +func (r *transcodingRepository) Save(ctx context.Context, t *model.Transcoding) (string, error) { + if !loggedUser(ctx).IsAdmin { return "", rest.ErrPermissionDenied } - t := entity.(*model.Transcoding) - return r.put(t.ID, t) + return r.put(ctx, t.ID, t) } -func (r *transcodingRepository) Update(id string, entity any, cols ...string) error { - if !loggedUser(r.ctx).IsAdmin { +func (r *transcodingRepository) Update(ctx context.Context, id string, entity model.Transcoding, cols ...string) error { + if !loggedUser(ctx).IsAdmin { return rest.ErrPermissionDenied } - t := entity.(*model.Transcoding) + t := &entity t.ID = id - _, err := r.put(id, t) + _, err := r.put(ctx, id, t) return err } -func (r *transcodingRepository) Delete(id string) error { - if !loggedUser(r.ctx).IsAdmin { +func (r *transcodingRepository) Delete(ctx context.Context, ids ...string) error { + if !loggedUser(ctx).IsAdmin { return rest.ErrPermissionDenied } - return r.deleteByID(id) + for _, id := range ids { + if err := r.deleteByID(ctx, id); err != nil { + return err + } + } + return nil } var _ model.TranscodingRepository = (*transcodingRepository)(nil) -var _ rest.Repository = (*transcodingRepository)(nil) -var _ rest.Persistable = (*transcodingRepository)(nil) +var _ rest.Repository[model.Transcoding] = (*transcodingRepository)(nil) +var _ rest.Persistable[model.Transcoding] = (*transcodingRepository)(nil) diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index 3be46bae3..372b5801f 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -1,6 +1,8 @@ package persistence import ( + "context" + "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -11,83 +13,78 @@ import ( var _ = Describe("TranscodingRepository", func() { var repo model.TranscodingRepository - var adminRepo model.TranscodingRepository + var ctx, adminCtx context.Context BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, regularUser) - repo = NewTranscodingRepository(ctx, GetDBXBuilder()) - - adminCtx := log.NewContext(GinkgoT().Context()) - adminCtx = request.WithUser(adminCtx, adminUser) - adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) + adminCtx = request.WithUser(ctx, adminUser) + repo = NewTranscodingRepository(GetDBXBuilder()) }) AfterEach(func() { // Clean up any transcoding created during the tests - tc, err := adminRepo.FindByFormat("test_format") + tc, err := repo.FindByFormat(adminCtx, "test_format") if err == nil { - err = adminRepo.(*transcodingRepository).Delete(tc.ID) + err = repo.Delete(adminCtx, tc.ID) Expect(err).ToNot(HaveOccurred()) } }) Describe("Admin User", func() { It("creates a new transcoding", func() { - base, err := adminRepo.CountAll() + base, err := repo.CountAll(adminCtx) Expect(err).ToNot(HaveOccurred()) - err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"}) + err = repo.Put(adminCtx, &model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"}) Expect(err).ToNot(HaveOccurred()) - count, err := adminRepo.CountAll() + count, err := repo.CountAll(adminCtx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(base + 1)) }) It("updates an existing transcoding", func() { tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) tr.Name = "updated" - err := adminRepo.Put(tr) + err := repo.Put(adminCtx, tr) Expect(err).ToNot(HaveOccurred()) - res, err := adminRepo.FindByFormat("test_format") + res, err := repo.FindByFormat(adminCtx, "test_format") Expect(err).ToNot(HaveOccurred()) Expect(res.Name).To(Equal("updated")) }) It("deletes a transcoding", func() { - err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"}) + err := repo.Put(adminCtx, &model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"}) Expect(err).ToNot(HaveOccurred()) - err = adminRepo.(*transcodingRepository).Delete("to-delete") + err = repo.Delete(adminCtx, "to-delete") Expect(err).ToNot(HaveOccurred()) - _, err = adminRepo.Get("to-delete") + _, err = repo.Get(adminCtx, "to-delete") Expect(err).To(MatchError(model.ErrNotFound)) }) It("returns not found when deleting a missing transcoding", func() { - err := adminRepo.(*transcodingRepository).Delete("does-not-exist") + err := repo.(*transcodingRepository).Delete(adminCtx, "does-not-exist") Expect(err).To(MatchError(model.ErrNotFound)) }) It("reads the Command field via the REST Read method", func() { tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) - res, err := adminRepo.(*transcodingRepository).Read("adminread") + res, err := repo.Read(adminCtx, "adminread") Expect(err).ToNot(HaveOccurred()) - Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret")) + Expect(res.Command).To(Equal("ffmpeg -secret")) }) }) Describe("Regular User", func() { It("reads a transcoding but with the Command field redacted", func() { tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) - res, err := repo.(*transcodingRepository).Read("readreg") + t, err := repo.Read(ctx, "readreg") Expect(err).ToNot(HaveOccurred()) - t := res.(*model.Transcoding) Expect(t.Name).To(Equal("temp")) Expect(t.TargetFormat).To(Equal("test_format")) Expect(t.Command).To(BeEmpty()) @@ -95,11 +92,10 @@ var _ = Describe("TranscodingRepository", func() { It("lists transcodings but with the Command field redacted", func() { tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) - res, err := repo.(*transcodingRepository).ReadAll() + list, err := repo.ReadAll(ctx) Expect(err).ToNot(HaveOccurred()) - list := res.(model.Transcodings) Expect(list).ToNot(BeEmpty()) for _, t := range list { Expect(t.Command).To(BeEmpty()) @@ -107,16 +103,16 @@ var _ = Describe("TranscodingRepository", func() { }) It("counts transcodings", func() { - count, err := repo.(*transcodingRepository).Count() + count, err := repo.Count(ctx) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically(">=", 0)) }) It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() { tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) - res, err := repo.Get("streamreg") + res, err := repo.Get(ctx, "streamreg") Expect(err).ToNot(HaveOccurred()) Expect(res.ID).To(Equal("streamreg")) Expect(res.Command).To(Equal("ffmpeg -secret")) @@ -124,38 +120,34 @@ var _ = Describe("TranscodingRepository", func() { It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() { tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) - res, err := repo.FindByFormat("test_format") + res, err := repo.FindByFormat(ctx, "test_format") Expect(err).ToNot(HaveOccurred()) Expect(res.ID).To(Equal("fmtreg")) Expect(res.Command).To(Equal("ffmpeg -secret")) }) It("fails to create", func() { - err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) + err := repo.Put(ctx, &model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) It("fails to update", func() { tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) tr.Name = "bad" - err := repo.Put(tr) + err := repo.Put(ctx, tr) Expect(err).To(Equal(rest.ErrPermissionDenied)) - - //_ = adminRepo.(*transcodingRepository).Delete("updreg") }) It("fails to delete", func() { tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} - Expect(adminRepo.Put(tr)).To(Succeed()) + Expect(repo.Put(adminCtx, tr)).To(Succeed()) - err := repo.(*transcodingRepository).Delete("delreg") + err := repo.Delete(ctx, "delreg") Expect(err).To(Equal(rest.ErrPermissionDenied)) - - //_ = adminRepo.(*transcodingRepository).Delete("delreg") }) }) }) diff --git a/persistence/user_props_repository.go b/persistence/user_props_repository.go index 9307385a2..59d7d332f 100644 --- a/persistence/user_props_repository.go +++ b/persistence/user_props_repository.go @@ -13,17 +13,16 @@ type userPropsRepository struct { sqlRepository } -func NewUserPropsRepository(ctx context.Context, db dbx.Builder) model.UserPropsRepository { +func NewUserPropsRepository(db dbx.Builder) model.UserPropsRepository { r := &userPropsRepository{} - r.ctx = ctx r.db = db r.tableName = "user_props" return r } -func (r userPropsRepository) Put(userId, key string, value string) error { +func (r userPropsRepository) Put(ctx context.Context, userId, key string, value string) error { update := Update(r.tableName).Set("value", value).Where(And{Eq{"user_id": userId}, Eq{"key": key}}) - count, err := r.executeSQL(update) + count, err := r.executeSQL(ctx, update) if err != nil { return err } @@ -31,24 +30,24 @@ func (r userPropsRepository) Put(userId, key string, value string) error { return nil } insert := Insert(r.tableName).Columns("user_id", "key", "value").Values(userId, key, value) - _, err = r.executeSQL(insert) + _, err = r.executeSQL(ctx, insert) return err } -func (r userPropsRepository) Get(userId, key string) (string, error) { +func (r userPropsRepository) Get(ctx context.Context, userId, key string) (string, error) { sel := Select("value").From(r.tableName).Where(And{Eq{"user_id": userId}, Eq{"key": key}}) resp := struct { Value string }{} - err := r.queryOne(sel, &resp) + err := r.queryOne(ctx, sel, &resp) if err != nil { return "", err } return resp.Value, nil } -func (r userPropsRepository) DefaultGet(userId, key string, defaultValue string) (string, error) { - value, err := r.Get(userId, key) +func (r userPropsRepository) DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) { + value, err := r.Get(ctx, userId, key) if errors.Is(err, model.ErrNotFound) { return defaultValue, nil } @@ -58,6 +57,6 @@ func (r userPropsRepository) DefaultGet(userId, key string, defaultValue string) return value, nil } -func (r userPropsRepository) Delete(userId, key string) error { - return r.delete(And{Eq{"user_id": userId}, Eq{"key": key}}) +func (r userPropsRepository) Delete(ctx context.Context, userId, key string) error { + return r.delete(ctx, And{Eq{"user_id": userId}, Eq{"key": key}}) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9399bcb53..20b4e5125 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -53,25 +53,24 @@ var ( encKey []byte ) -func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository { +func NewUserRepository(db dbx.Builder) model.UserRepository { r := &userRepository{} - r.ctx = ctx r.db = db r.tableName = "user" r.registerModel(&model.User{}, map[string]filterFunc{ "id": idFilter(r.tableName), - "password": invalidFilter(ctx), + "password": invalidFilter, "name": startsWithFilter(r.tableName + ".name"), }) once.Do(func() { - _ = r.initPasswordEncryptionKey() + _ = r.initPasswordEncryptionKey(context.Background()) }) return r } // selectUserWithLibraries returns a SelectBuilder that includes library information -func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...). +func (r *userRepository) selectUserWithLibraries(ctx context.Context, options ...model.QueryOptions) SelectBuilder { + return r.newSelect(ctx, options...). Columns(`user.*`, `COALESCE(json_group_array(json_object( 'id', library.id, @@ -89,37 +88,37 @@ func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) GroupBy("user.id") } -func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { - return r.count(Select(), qo...) +func (r *userRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) { + return r.count(ctx, Select(), qo...) } -func (r *userRepository) Get(id string) (*model.User, error) { - sel := r.selectUserWithLibraries().Where(Eq{"user.id": id}) +func (r *userRepository) Get(ctx context.Context, id string) (*model.User, error) { + sel := r.selectUserWithLibraries(ctx).Where(Eq{"user.id": id}) var res dbUser - err := r.queryOne(sel, &res) + err := r.queryOne(ctx, sel, &res) if err != nil { return nil, err } return res.User, nil } -func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { - sel := r.selectUserWithLibraries(options...) +func (r *userRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Users, error) { + sel := r.selectUserWithLibraries(ctx, options...) var res dbUsers - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) if err != nil { return nil, err } return res.toModels(), nil } -func (r *userRepository) Put(u *model.User) error { +func (r *userRepository) Put(ctx context.Context, u *model.User) error { if u.ID == "" { u.ID = id.NewRandom() } u.UpdatedAt = time.Now() if u.NewPassword != "" { - _ = r.encryptPassword(u) + _ = r.encryptPassword(ctx, u) } values, err := toSQLArgs(*u) if err != nil { @@ -134,7 +133,7 @@ func (r *userRepository) Put(u *model.User) error { var epoch int if u.NewPassword != "" { var res struct{ TokenEpoch int } - err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")). + err = r.queryOne(ctx, update.Set("token_epoch", Expr("token_epoch + 1")). Suffix("RETURNING token_epoch"), &res) switch { case errors.Is(err, model.ErrNotFound): @@ -145,7 +144,7 @@ func (r *userRepository) Put(u *model.User) error { epoch = res.TokenEpoch } } else { - count, err := r.executeSQL(update) + count, err := r.executeSQL(ctx, update) if err != nil { return err } @@ -154,7 +153,7 @@ func (r *userRepository) Put(u *model.User) error { if isNewUser { values["created_at"] = time.Now() insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(insert) + _, err = r.executeSQL(ctx, insert) if err != nil { return err } @@ -166,7 +165,7 @@ func (r *userRepository) Put(u *model.User) error { "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library", u.ID, ) - if _, err := r.executeSQL(sql); err != nil { + if _, err := r.executeSQL(ctx, sql); err != nil { return fmt.Errorf("failed to assign all libraries to admin user: %w", err) } } else if isNewUser { // Only for new regular users @@ -175,117 +174,108 @@ func (r *userRepository) Put(u *model.User) error { "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true", u.ID, ) - if _, err := r.executeSQL(sql); err != nil { + if _, err := r.executeSQL(ctx, sql); err != nil { return fmt.Errorf("failed to assign default libraries to new user: %w", err) } } // Only the caller's own token can be refreshed in-flight; an admin resetting another // user must keep their own epoch. - if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID { - request.SetTokenEpoch(r.ctx, epoch) + if u.NewPassword != "" && !isNewUser && loggedUser(ctx).ID == u.ID { + request.SetTokenEpoch(ctx, epoch) } return nil } -func (r *userRepository) FindFirstAdmin() (*model.User, error) { - sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) +func (r *userRepository) FindFirstAdmin(ctx context.Context) (*model.User, error) { + sel := r.selectUserWithLibraries(ctx, model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) var usr dbUser - err := r.queryOne(sel, &usr) + err := r.queryOne(ctx, sel, &usr) if err != nil { return nil, err } return usr.User, nil } -func (r *userRepository) FindByUsername(username string) (*model.User, error) { - sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username)) +func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { + sel := r.selectUserWithLibraries(ctx).Where(Expr("user.user_name = ? COLLATE NOCASE", username)) var usr dbUser - err := r.queryOne(sel, &usr) + err := r.queryOne(ctx, sel, &usr) if err != nil { return nil, err } return usr.User, nil } -func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { - usr, err := r.FindByUsername(username) +func (r *userRepository) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { + usr, err := r.FindByUsername(ctx, username) if err != nil { return nil, err } - _ = r.decryptPassword(usr) + _ = r.decryptPassword(ctx, usr) return usr, nil } -func (r *userRepository) UpdateLastLoginAt(id string) error { +func (r *userRepository) UpdateLastLoginAt(ctx context.Context, id string) error { upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now()) - _, err := r.executeSQL(upd) + _, err := r.executeSQL(ctx, upd) return err } -func (r *userRepository) UpdateLastAccessAt(id string) error { +func (r *userRepository) UpdateLastAccessAt(ctx context.Context, id string) error { now := time.Now() upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now) - _, err := r.executeSQL(upd) + _, err := r.executeSQL(ctx, upd) return err } -func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) { - usr := loggedUser(r.ctx) +func (r *userRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + usr := loggedUser(ctx) if !usr.IsAdmin { return 0, rest.ErrPermissionDenied } - return r.CountAll(r.parseRestOptions(r.ctx, options...)) + return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *userRepository) Read(id string) (any, error) { - usr := loggedUser(r.ctx) +func (r *userRepository) Read(ctx context.Context, id string) (*model.User, error) { + usr := loggedUser(ctx) if !usr.IsAdmin && usr.ID != id { return nil, rest.ErrPermissionDenied } - return r.Get(id) + return r.Get(ctx, id) } -func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - usr := loggedUser(r.ctx) +func (r *userRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.User, error) { + usr := loggedUser(ctx) if !usr.IsAdmin { return nil, rest.ErrPermissionDenied } - return r.GetAll(r.parseRestOptions(r.ctx, options...)) + return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) } -func (r *userRepository) EntityName() string { - return "user" -} - -func (r *userRepository) NewInstance() any { - return &model.User{} -} - -func (r *userRepository) Save(entity any) (string, error) { - usr := loggedUser(r.ctx) +func (r *userRepository) Save(ctx context.Context, u *model.User) (string, error) { + usr := loggedUser(ctx) if !usr.IsAdmin { return "", rest.ErrPermissionDenied } - u := entity.(*model.User) - if err := validateUsernameUnique(r, u); err != nil { + if err := validateUsernameUnique(ctx, r, u); err != nil { return "", err } if err := validateScrobbleFilter(u); err != nil { return "", err } - err := r.Put(u) + err := r.Put(ctx, u) if err != nil { return "", err } return u.ID, err } -func (r *userRepository) Update(id string, entity any, _ ...string) error { - u := entity.(*model.User) +func (r *userRepository) Update(ctx context.Context, id string, entity model.User, _ ...string) error { + u := &entity u.ID = id - usr := loggedUser(r.ctx) + usr := loggedUser(ctx) if !usr.IsAdmin && usr.ID != u.ID { return rest.ErrPermissionDenied } @@ -298,19 +288,19 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error { } // Decrypt the user's existing password before validating. This is required otherwise the existing password entered by the user will never match. - if err := r.decryptPassword(usr); err != nil { + if err := r.decryptPassword(ctx, usr); err != nil { return err } if err := validatePasswordChange(u, usr); err != nil { return err } - if err := validateUsernameUnique(r, u); err != nil { + if err := validateUsernameUnique(ctx, r, u); err != nil { return err } if err := validateScrobbleFilter(u); err != nil { return err } - return r.Put(u) + return r.Put(ctx, u) } func validatePasswordChange(newUser *model.User, logged *model.User) error { @@ -339,8 +329,8 @@ func validatePasswordChange(newUser *model.User, logged *model.User) error { return nil } -func validateUsernameUnique(r model.UserRepository, u *model.User) error { - usr, err := r.FindByUsername(u.UserName) +func validateUsernameUnique(ctx context.Context, r model.UserRepository, u *model.User) error { + usr, err := r.FindByUsername(ctx, u.UserName) if errors.Is(err, model.ErrNotFound) { return nil } @@ -380,18 +370,18 @@ func invalidScrobbleFilter() error { }} } -func (r *userRepository) Delete(id string) error { - usr := loggedUser(r.ctx) +func (r *userRepository) Delete(ctx context.Context, ids ...string) error { + usr := loggedUser(ctx) if !usr.IsAdmin { return rest.ErrPermissionDenied } - if err := r.deleteByID(id); err != nil { - return err - } - - // Clean up orphaned plugin references for the deleted user - if err := cleanupPluginUserReferences(r.db, id); err != nil { - log.Error(r.ctx, "Failed to cleanup plugin user references", "userID", id, err) + for _, id := range ids { + if err := r.deleteByID(ctx, id); err != nil { + return err + } + if err := cleanupPluginUserReferences(r.db, id); err != nil { + log.Error(ctx, "Failed to cleanup plugin user references", "userID", id, err) + } } return nil } @@ -401,7 +391,7 @@ func keyTo32Bytes(input string) []byte { return data[0:] } -func (r *userRepository) initPasswordEncryptionKey() error { +func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { encKey = keyTo32Bytes(consts.DefaultEncryptionKey) if conf.Server.PasswordEncryptionKey == "" { return nil @@ -410,8 +400,8 @@ func (r *userRepository) initPasswordEncryptionKey() error { key := keyTo32Bytes(conf.Server.PasswordEncryptionKey) keySum := fmt.Sprintf("%x", sha256.Sum256(key)) - props := NewPropertyRepository(r.ctx, r.db) - savedKeySum, err := props.Get(consts.PasswordsEncryptedKey) + props := NewPropertyRepository(r.db) + savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey) // If passwords are already encrypted if err == nil { @@ -425,24 +415,24 @@ func (r *userRepository) initPasswordEncryptionKey() error { // if not, try to re-encrypt all current passwords with new encryption key, // assuming they were encrypted with the DefaultEncryptionKey - sql := r.newSelect().Columns("id", "user_name", "password") + sql := r.newSelect(ctx).Columns("id", "user_name", "password") users := model.Users{} - err = r.queryAll(sql, &users) + err = r.queryAll(ctx, sql, &users) if err != nil { log.Error("Could not encrypt all passwords", err) return err } log.Warn("New PasswordEncryptionKey set. Encrypting all passwords", "numUsers", len(users)) - if err = r.decryptAllPasswords(users); err != nil { + if err = r.decryptAllPasswords(ctx, users); err != nil { return err } encKey = key for i := range users { u := users[i] u.NewPassword = u.Password - if err := r.encryptPassword(&u); err == nil { + if err := r.encryptPassword(ctx, &u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - _, err = r.executeSQL(upd) + _, err = r.executeSQL(ctx, upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { @@ -451,7 +441,7 @@ func (r *userRepository) initPasswordEncryptionKey() error { } } - err = props.Put(consts.PasswordsEncryptedKey, keySum) + err = props.Put(ctx, consts.PasswordsEncryptedKey, keySum) if err != nil { log.Error("Could not flag passwords as encrypted. It will cause login errors", err) return err @@ -460,10 +450,10 @@ func (r *userRepository) initPasswordEncryptionKey() error { } // encrypts u.NewPassword -func (r *userRepository) encryptPassword(u *model.User) error { - encPassword, err := utils.Encrypt(r.ctx, encKey, u.NewPassword) +func (r *userRepository) encryptPassword(ctx context.Context, u *model.User) error { + encPassword, err := utils.Encrypt(ctx, encKey, u.NewPassword) if err != nil { - log.Error(r.ctx, "Error encrypting user's password", "user", u.UserName, err) + log.Error(ctx, "Error encrypting user's password", "user", u.UserName, err) return err } u.NewPassword = encPassword @@ -471,19 +461,19 @@ func (r *userRepository) encryptPassword(u *model.User) error { } // decrypts u.Password -func (r *userRepository) decryptPassword(u *model.User) error { - plaintext, err := utils.Decrypt(r.ctx, encKey, u.Password) +func (r *userRepository) decryptPassword(ctx context.Context, u *model.User) error { + plaintext, err := utils.Decrypt(ctx, encKey, u.Password) if err != nil { - log.Error(r.ctx, "Error decrypting user's password", "user", u.UserName, err) + log.Error(ctx, "Error decrypting user's password", "user", u.UserName, err) return err } u.Password = plaintext return nil } -func (r *userRepository) decryptAllPasswords(users model.Users) error { +func (r *userRepository) decryptAllPasswords(ctx context.Context, users model.Users) error { for i := range users { - if err := r.decryptPassword(&users[i]); err != nil { + if err := r.decryptPassword(ctx, &users[i]); err != nil { return err } } @@ -492,7 +482,7 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error { // Library association methods -func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) { +func (r *userRepository) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { sel := Select("l.*"). From("library l"). Join("user_library ul ON l.id = ul.library_id"). @@ -500,14 +490,14 @@ func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error OrderBy("l.name") var res model.Libraries - err := r.queryAll(sel, &res) + err := r.queryAll(ctx, sel, &res) return res, err } -func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error { +func (r *userRepository) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { // Remove existing associations delSql := Delete("user_library").Where(Eq{"user_id": userID}) - if _, err := r.executeSQL(delSql); err != nil { + if _, err := r.executeSQL(ctx, delSql); err != nil { return err } @@ -517,12 +507,12 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error for _, libID := range libraryIDs { insert = insert.Values(userID, libID) } - _, err := r.executeSQL(insert) + _, err := r.executeSQL(ctx, insert) return err } return nil } var _ model.UserRepository = (*userRepository)(nil) -var _ rest.Repository = (*userRepository)(nil) -var _ rest.Persistable = (*userRepository)(nil) +var _ rest.Repository[model.User] = (*userRepository)(nil) +var _ rest.Persistable[model.User] = (*userRepository)(nil) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 9a51b17dd..0e776fc3a 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -21,9 +21,11 @@ import ( var _ = Describe("UserRepository", func() { var repo model.UserRepository + var ctx context.Context BeforeEach(func() { - repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()) + ctx = log.NewContext(GinkgoT().Context()) + repo = NewUserRepository(GetDBXBuilder()) }) Describe("Put/Get/FindByUsername", func() { @@ -36,20 +38,20 @@ var _ = Describe("UserRepository", func() { IsAdmin: true, } It("saves the user to the DB", func() { - Expect(repo.Put(&usr)).To(BeNil()) + Expect(repo.Put(ctx, &usr)).To(BeNil()) }) It("returns the newly created user", func() { - actual, err := repo.Get("123") + actual, err := repo.Get(ctx, "123") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by case-insensitive username", func() { - actual, err := repo.FindByUsername("aDmIn") + actual, err := repo.FindByUsername(ctx, "aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by username and decrypts the password", func() { - actual, err := repo.FindByUsernameWithPassword("aDmIn") + actual, err := repo.FindByUsernameWithPassword(ctx, "aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) Expect(actual.Password).To(Equal("wordpass")) @@ -57,27 +59,27 @@ var _ = Describe("UserRepository", func() { It("updates the name and keep the same password", func() { usr.Name = "Jane Doe" usr.NewPassword = "" - Expect(repo.Put(&usr)).To(BeNil()) + Expect(repo.Put(ctx, &usr)).To(BeNil()) - actual, err := repo.FindByUsernameWithPassword("admin") + actual, err := repo.FindByUsernameWithPassword(ctx, "admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Jane Doe")) Expect(actual.Password).To(Equal("wordpass")) }) It("updates password if specified", func() { usr.NewPassword = "newpass" - Expect(repo.Put(&usr)).To(BeNil()) + Expect(repo.Put(ctx, &usr)).To(BeNil()) - actual, err := repo.FindByUsernameWithPassword("admin") + actual, err := repo.FindByUsernameWithPassword(ctx, "admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) It("persists and reads back the scrobble filter", func() { usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - saved, err := repo.Get("u-filter") + saved, err := repo.Get(ctx, "u-filter") Expect(err).ToNot(HaveOccurred()) Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`)) }) @@ -88,7 +90,7 @@ var _ = Describe("UserRepository", func() { "values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute() Expect(err).ToNot(HaveOccurred()) - saved, err := repo.Get("u-rawsql") + saved, err := repo.Get(ctx, "u-rawsql") Expect(err).ToNot(HaveOccurred()) Expect(saved.ScrobbleFilter).To(Equal("")) }) @@ -233,36 +235,34 @@ var _ = Describe("UserRepository", func() { Describe("Delete", func() { It("returns not found for a missing user", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewUserRepository(adminCtx, GetDBXBuilder()).(*userRepository) - Expect(adminRepo.Delete("does-not-exist")).To(MatchError(model.ErrNotFound)) + adminRepo := NewUserRepository(GetDBXBuilder()).(*userRepository) + Expect(adminRepo.Delete(adminCtx, "does-not-exist")).To(MatchError(model.ErrNotFound)) }) }) Describe("ReadAll name filter", func() { - var adminRepo model.ResourceRepository + var adminRepo model.UserRepository + var adminCtx context.Context BeforeEach(func() { - adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) - adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository) + adminCtx = request.WithUser(ctx, model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) + adminRepo = NewUserRepository(GetDBXBuilder()) for _, u := range []model.User{ {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, } { - Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed()) + Expect(adminRepo.Put(adminCtx, &u)).To(Succeed()) } }) AfterEach(func() { - ur := adminRepo.(model.UserRepository) - _ = ur.Delete("filter-alice") - _ = ur.Delete("filter-bob") + _ = adminRepo.Delete(adminCtx, "filter-alice", "filter-bob") }) It("matches users whose name starts with the given prefix", func() { - res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) + users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) Expect(err).ToNot(HaveOccurred()) - users := res.(model.Users) var names []string for _, u := range users { @@ -273,9 +273,8 @@ var _ = Describe("UserRepository", func() { }) It("does not match names by mid-string substring (startsWith, not contains)", func() { - res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) + users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) Expect(err).ToNot(HaveOccurred()) - users := res.(model.Users) for _, u := range users { Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), @@ -290,17 +289,17 @@ var _ = Describe("UserRepository", func() { BeforeEach(func() { existingUser = &model.User{ID: "1", UserName: "johndoe"} repo = tests.CreateMockUserRepo() - err := repo.Put(existingUser) + err := repo.Put(ctx, existingUser) Expect(err).ToNot(HaveOccurred()) }) It("allows unique usernames", func() { var newUser = &model.User{ID: "2", UserName: "unique_username"} - err := validateUsernameUnique(repo, newUser) + err := validateUsernameUnique(ctx, repo, newUser) Expect(err).ToNot(HaveOccurred()) }) It("returns ValidationError if username already exists", func() { var newUser = &model.User{ID: "2", UserName: "johndoe"} - err := validateUsernameUnique(repo, newUser) + err := validateUsernameUnique(ctx, repo, newUser) var verr *rest.ValidationError isValidationError := errors.As(err, &verr) @@ -311,7 +310,7 @@ var _ = Describe("UserRepository", func() { repo.Error = errors.New("fake error") var newUser = &model.User{ID: "2", UserName: "newuser"} - err := validateUsernameUnique(repo, newUser) + err := validateUsernameUnique(ctx, repo, newUser) Expect(err).To(MatchError("fake error")) }) }) @@ -330,39 +329,39 @@ var _ = Describe("UserRepository", func() { NewPassword: "password", IsAdmin: false, } - Expect(repo.Put(&testUser)).To(BeNil()) + Expect(repo.Put(ctx, &testUser)).To(BeNil()) userID = testUser.ID library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"} library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"} // Create test libraries - libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) - Expect(libRepo.Put(&library1)).To(BeNil()) - Expect(libRepo.Put(&library2)).To(BeNil()) + libRepo := NewLibraryRepository(GetDBXBuilder()) + Expect(libRepo.Put(ctx, &library1)).To(BeNil()) + Expect(libRepo.Put(ctx, &library2)).To(BeNil()) }) AfterEach(func() { // Clean up user-library associations to ensure test isolation - _ = repo.SetUserLibraries(userID, []int{}) + _ = repo.SetUserLibraries(ctx, userID, []int{}) // Clean up test libraries to ensure isolation between test groups - libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) - _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + libRepo := NewLibraryRepository(GetDBXBuilder()) + _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) }) Describe("GetUserLibraries", func() { It("returns empty list when user has no library associations", func() { - libraries, err := repo.GetUserLibraries("non-existent-user") + libraries, err := repo.GetUserLibraries(ctx, "non-existent-user") Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) It("returns user's associated libraries", func() { - err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(userID) + libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) @@ -374,24 +373,24 @@ var _ = Describe("UserRepository", func() { Describe("SetUserLibraries", func() { It("sets user's library associations", func() { libraryIDs := []int{library1.ID, library2.ID} - err := repo.SetUserLibraries(userID, libraryIDs) + err := repo.SetUserLibraries(ctx, userID, libraryIDs) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(userID) + libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) }) It("replaces existing associations", func() { // Set initial associations - err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) // Replace with just one library - err = repo.SetUserLibraries(userID, []int{library1.ID}) + err = repo.SetUserLibraries(ctx, userID, []int{library1.ID}) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(userID) + libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(library1.ID)) @@ -399,14 +398,14 @@ var _ = Describe("UserRepository", func() { It("removes all associations when passed empty slice", func() { // Set initial associations - err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) // Remove all - err = repo.SetUserLibraries(userID, []int{}) + err = repo.SetUserLibraries(ctx, userID, []int{}) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(userID) + libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) @@ -422,10 +421,10 @@ var _ = Describe("UserRepository", func() { ) BeforeEach(func() { - libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + libRepo = NewLibraryRepository(GetDBXBuilder()) // Count initial libraries - existingLibs, err := libRepo.GetAll() + existingLibs, err := libRepo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) initialLibCount = len(existingLibs) @@ -433,16 +432,16 @@ var _ = Describe("UserRepository", func() { library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"} // Create test libraries - Expect(libRepo.Put(&library1)).To(BeNil()) - Expect(libRepo.Put(&library2)).To(BeNil()) + Expect(libRepo.Put(ctx, &library1)).To(BeNil()) + Expect(libRepo.Put(ctx, &library2)).To(BeNil()) }) AfterEach(func() { // Clean up test libraries and their associations - _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) // Clean up user-library associations for these test libraries - _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) + _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) }) It("automatically assigns all libraries to admin users when created", func() { @@ -455,11 +454,11 @@ var _ = Describe("UserRepository", func() { IsAdmin: true, } - err := repo.Put(&adminUser) + err := repo.Put(ctx, &adminUser) Expect(err).ToNot(HaveOccurred()) // Admin should automatically have access to all libraries (including existing ones) - libraries, err := repo.GetUserLibraries(adminUser.ID) + libraries, err := repo.GetUserLibraries(ctx, adminUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries @@ -481,20 +480,20 @@ var _ = Describe("UserRepository", func() { IsAdmin: false, } - err := repo.Put(®ularUser) + err := repo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Give them access to just one library - err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID}) + err = repo.SetUserLibraries(ctx, regularUser.ID, []int{library1.ID}) Expect(err).ToNot(HaveOccurred()) // Promote to admin regularUser.IsAdmin = true - err = repo.Put(®ularUser) + err = repo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Should now have access to all libraries (including existing ones) - libraries, err := repo.GetUserLibraries(regularUser.ID) + libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries @@ -516,11 +515,11 @@ var _ = Describe("UserRepository", func() { IsAdmin: false, } - err := repo.Put(®ularUser) + err := repo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Regular user should be assigned to default libraries (library ID 1 from migration) - libraries, err := repo.GetUserLibraries(regularUser.ID) + libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(1)) @@ -537,13 +536,13 @@ var _ = Describe("UserRepository", func() { ) BeforeEach(func() { - libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + libRepo = NewLibraryRepository(GetDBXBuilder()) library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"} library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"} // Create test libraries - Expect(libRepo.Put(&library1)).To(BeNil()) - Expect(libRepo.Put(&library2)).To(BeNil()) + Expect(libRepo.Put(ctx, &library1)).To(BeNil()) + Expect(libRepo.Put(ctx, &library2)).To(BeNil()) // Create test user testUser = model.User{ @@ -554,23 +553,23 @@ var _ = Describe("UserRepository", func() { NewPassword: "password", IsAdmin: false, } - Expect(repo.Put(&testUser)).To(BeNil()) + Expect(repo.Put(ctx, &testUser)).To(BeNil()) // Assign libraries to user - Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) + Expect(repo.SetUserLibraries(ctx, testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) }) AfterEach(func() { // Clean up test libraries and their associations - _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) - _ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID}) + _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": testUser.ID}) // Clean up user-library associations for these test libraries - _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) + _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) }) It("populates Libraries field when getting a single user", func() { - user, err := repo.Get(testUser.ID) + user, err := repo.Get(ctx, testUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) @@ -591,7 +590,7 @@ var _ = Describe("UserRepository", func() { }) It("populates Libraries field when getting all users", func() { - users, err := repo.(*userRepository).GetAll() + users, err := repo.(*userRepository).GetAll(ctx) Expect(err).ToNot(HaveOccurred()) // Find our test user in the results @@ -607,7 +606,7 @@ var _ = Describe("UserRepository", func() { }) It("populates Libraries field when finding user by username", func() { - user, err := repo.FindByUsername(testUser.UserName) + user, err := repo.FindByUsername(ctx, testUser.UserName) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) @@ -625,10 +624,10 @@ var _ = Describe("UserRepository", func() { NewPassword: "password", IsAdmin: false, } - Expect(repo.Put(&userWithoutLibs)).To(BeNil()) - defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }() + Expect(repo.Put(ctx, &userWithoutLibs)).To(BeNil()) + defer func() { _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": userWithoutLibs.ID}) }() - user, err := repo.Get(userWithoutLibs.ID) + user, err := repo.Get(ctx, userWithoutLibs.ID) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).ToNot(BeNil()) // Regular users should be assigned to default libraries (library ID 1 from migration) @@ -686,8 +685,8 @@ var _ = Describe("UserRepository", func() { Describe("filters", func() { It("qualifies id filter with table name", func() { r := repo.(*userRepository) - qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) - sel := r.selectUserWithLibraries(qo) + qo := r.parseRestOptions(ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) + sel := r.selectUserWithLibraries(ctx, qo) query, _, err := r.toSQL(sel) Expect(err).NotTo(HaveOccurred()) Expect(query).To(ContainSubstring("user.id = {:p0}")) @@ -705,29 +704,28 @@ var _ = Describe("UserRepository", func() { } BeforeEach(func() { - ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) - repo = NewUserRepository(ctx, GetDBXBuilder()) + repo = NewUserRepository(GetDBXBuilder()) usr = newUser() - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) }) It("starts at zero for a new user", func() { - got, err := repo.Get(usr.ID) + got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) }) It("increments once per password change", func() { usr.NewPassword = "second" - Expect(repo.Put(&usr)).To(Succeed()) - got, err := repo.Get(usr.ID) + Expect(repo.Put(ctx, &usr)).To(Succeed()) + got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(1)) usr.NewPassword = "third" - Expect(repo.Put(&usr)).To(Succeed()) - got, err = repo.Get(usr.ID) + Expect(repo.Put(ctx, &usr)).To(Succeed()) + got, err = repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(2)) }) @@ -735,9 +733,9 @@ var _ = Describe("UserRepository", func() { It("leaves the epoch alone when the password is untouched", func() { usr.NewPassword = "" usr.Name = "Renamed" - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err := repo.Get(usr.ID) + got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) Expect(got.Name).To(Equal("Renamed")) @@ -754,11 +752,11 @@ var _ = Describe("UserRepository", func() { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: usr.ID}) ctx = request.WithTokenEpochHolder(ctx) - own := NewUserRepository(ctx, GetDBXBuilder()) + own := NewUserRepository(GetDBXBuilder()) u := usr u.NewPassword = "concurrent" - if err := own.Put(&u); err != nil { + if err := own.Put(ctx, &u); err != nil { return // the shared in-memory test DB can raise SQLITE_LOCKED } epoch, ok := request.TokenEpochFrom(ctx) @@ -778,73 +776,73 @@ var _ = Describe("UserRepository", func() { }) Describe("Put and the token epoch", func() { - newRepo := func(actingUserID string) model.UserRepository { + newRepo := func(actingUserID string) (context.Context, model.UserRepository) { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) ctx = request.WithTokenEpochHolder(ctx) - return NewUserRepository(ctx, GetDBXBuilder()) + return ctx, NewUserRepository(GetDBXBuilder()) } It("does not bump when creating a user", func() { - repo := newRepo("admin") + ctx, repo := newRepo("admin") usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err := repo.Get(usr.ID) + got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) }) It("bumps when the password changes", func() { - repo := newRepo("admin") + ctx, repo := newRepo("admin") usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "pw2" - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err := repo.Get(usr.ID) + got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(1)) }) It("does not bump on an edit that leaves the password alone", func() { - repo := newRepo("admin") + ctx, repo := newRepo("admin") usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "" usr.Name = "New Display Name" - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err := repo.Get(usr.ID) + got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) }) It("signals the new epoch when a user changes their own password", func() { userID := id.NewRandom() - repo := newRepo(userID) + ctx, repo := newRepo(userID) usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "pw2" - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + epoch, ok := request.TokenEpochFrom(ctx) Expect(ok).To(BeTrue()) Expect(epoch).To(Equal(1)) }) It("does not signal when an admin changes someone else's password", func() { - repo := newRepo("some-admin") + ctx, repo := newRepo("some-admin") usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "pw2" - Expect(repo.Put(&usr)).To(Succeed()) + Expect(repo.Put(ctx, &usr)).To(Succeed()) - _, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + _, ok := request.TokenEpochFrom(ctx) Expect(ok).To(BeFalse()) }) }) diff --git a/plugins/host_library.go b/plugins/host_library.go index 3d9f61b4f..25c14a813 100644 --- a/plugins/host_library.go +++ b/plugins/host_library.go @@ -37,7 +37,7 @@ func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Li return nil, fmt.Errorf("library not accessible: library ID %d is not in the allowed list", id) } - lib, err := s.ds.Library(ctx).Get(int(id)) + lib, err := s.ds.Library().Get(ctx, int(id)) if err != nil { return nil, fmt.Errorf("library not found: %w", err) } @@ -55,7 +55,7 @@ func (s *libraryServiceImpl) isLibraryAccessible(id int) bool { } func (s *libraryServiceImpl) GetAllLibraries(ctx context.Context) ([]host.Library, error) { - libs, err := s.ds.Library(ctx).GetAll() + libs, err := s.ds.Library().GetAll(ctx) if err != nil { return nil, fmt.Errorf("failed to get libraries: %w", err) } diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 00a953b24..edd4b546f 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -47,7 +47,7 @@ var _ = Describe("LibraryService", Ordered, func() { } lib.LastScanAt = lib.LastScanAt.Add(0) // Ensure time is set - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{*lib}) result, err := service.GetLibrary(ctx, 1) @@ -77,7 +77,7 @@ var _ = Describe("LibraryService", Ordered, func() { TotalDuration: 1800.0, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{*lib}) result, err := service.GetLibrary(ctx, 2) @@ -91,7 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() { It("should return error for non-existent library", func() { service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) _, err := service.GetLibrary(ctx, 999) @@ -109,7 +109,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -131,7 +131,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -154,7 +154,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 3, Name: "Classical", Path: "/music/classical", TotalSongs: 75}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -172,7 +172,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) // Requesting library 1 which is not in the allowed list @@ -189,7 +189,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) result, err := service.GetLibrary(ctx, 2) @@ -206,7 +206,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -222,7 +222,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -291,7 +291,7 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(manager.ds).ToNot(BeNil()) ctx := context.Background() - libs, err := manager.ds.Library(adminContext(ctx)).GetAll() + libs, err := manager.ds.Library().GetAll(adminContext(ctx)) Expect(err).ToNot(HaveOccurred()) Expect(libs).To(HaveLen(1)) Expect(libs[0].Path).To(Equal("/tmp/test-music")) diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go index f0967628c..7d3e47fae 100644 --- a/plugins/host_matcher_test.go +++ b/plugins/host_matcher_test.go @@ -193,7 +193,7 @@ var _ = Describe("MatcherService", Ordered, func() { mediaFileRepo.SetData(model.MediaFiles{mf}) userRepo = tests.CreateMockUserRepo() - Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + Expect(userRepo.Put(GinkgoT().Context(), &model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo, MockedUser: userRepo} }) @@ -269,7 +269,7 @@ var _ = Describe("MatcherService", Ordered, func() { _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{}) Expect(err).ToNot(HaveOccurred()) - usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + usr, ok := request.UserFrom(capturing.lastMediaFileCtx()) Expect(ok).To(BeTrue()) Expect(usr.IsAdmin).To(BeTrue()) Expect(usr.ID).ToNot(Equal("u-caller")) @@ -283,7 +283,7 @@ var _ = Describe("MatcherService", Ordered, func() { _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{Username: "alice"}) Expect(err).ToNot(HaveOccurred()) - usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + usr, ok := request.UserFrom(capturing.lastMediaFileCtx()) Expect(ok).To(BeTrue()) Expect(usr.ID).To(Equal("u-alice")) }) @@ -395,7 +395,7 @@ var _ = Describe("MatcherService Integration", Ordered, func() { mediaFileRepo.SetData(model.MediaFiles{hit}) userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + Expect(userRepo.Put(GinkgoT().Context(), &model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) dataStore := &tests.MockDataStore{ MockedPlugin: mockPluginRepo, @@ -475,14 +475,38 @@ var _ = Describe("MatcherService Integration", Ordered, func() { }) }) -// ctxCapturingDataStore records the context passed to MediaFile so tests can assert -// which user the matcher resolved before querying the library. +// ctxCapturingDataStore records the context the media file queries run with, so tests +// can assert which user the matcher resolved before querying the library. type ctxCapturingDataStore struct { *tests.MockDataStore - lastMediaFileCtx context.Context + repo *ctxCapturingMediaFileRepo } -func (d *ctxCapturingDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { - d.lastMediaFileCtx = ctx - return d.MockDataStore.MediaFile(ctx) +func (d *ctxCapturingDataStore) MediaFile() model.MediaFileRepository { + if d.repo == nil { + d.repo = &ctxCapturingMediaFileRepo{MediaFileRepository: d.MockDataStore.MediaFile()} + } + return d.repo +} + +func (d *ctxCapturingDataStore) lastMediaFileCtx() context.Context { + if d.repo == nil { + return nil + } + return d.repo.lastCtx +} + +type ctxCapturingMediaFileRepo struct { + model.MediaFileRepository + lastCtx context.Context +} + +func (r *ctxCapturingMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { + r.lastCtx = ctx + return r.MediaFileRepository.GetAll(ctx, options...) +} + +func (r *ctxCapturingMediaFileRepo) GetAllByTags(ctx context.Context, tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { + r.lastCtx = ctx + return r.MediaFileRepository.GetAllByTags(ctx, tag, values, options...) } diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go index 7417d7c50..780b0274d 100644 --- a/plugins/host_scrobbleretriever.go +++ b/plugins/host_scrobbleretriever.go @@ -41,7 +41,7 @@ func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, return nil, err } - scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) + scrobbles, err := s.ds.Scrobble().GetAll(ctx, model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) if err != nil { return nil, err } @@ -80,7 +80,7 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam // Fetch one more item than requested. The last item is the next timestamp to fetch lookahead := options.MaxItems + 1 - scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{ + scrobbles, err := s.ds.Scrobble().GetAll(ctx, model.QueryOptions{ Max: lookahead, Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), // The id tiebreak makes the order of equal timestamps stable, which is what @@ -142,7 +142,7 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, use return 0, err } - return s.ds.Scrobble(ctx).CountAll(model.QueryOptions{ + return s.ds.Scrobble().CountAll(ctx, model.QueryOptions{ Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), }) } diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index aa92c9eb5..6721c1487 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -75,36 +75,36 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - userRepo := dataStore.User(ctx) + userRepo := dataStore.User() // Add test users - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(ctx, &model.User{ ID: "user1", UserName: "testuser", IsAdmin: false, }) - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(ctx, &model.User{ ID: "admin1", UserName: "adminuser", IsAdmin: true, }) - err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "1", LibraryID: 1}) + err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "1", LibraryID: 1}) Expect(err).To(BeNil()) - err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "2", LibraryID: 1}) + err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "2", LibraryID: 1}) Expect(err).To(BeNil()) - err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "3", LibraryID: 1}) + err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "3", LibraryID: 1}) Expect(err).To(BeNil()) scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) - scrobbleRepo := dataStore.Scrobble(scrobbleCtx) - err = scrobbleRepo.RecordScrobble("1", time.Unix(0, 0)) + scrobbleRepo := dataStore.Scrobble() + err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(0, 0)) Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble("2", time.Unix(1, 0)) + err = scrobbleRepo.RecordScrobble(scrobbleCtx, "2", time.Unix(1, 0)) Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble("3", time.Unix(2, 0)) + err = scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(2, 0)) Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble("1", time.Unix(2, 0)) + err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(2, 0)) Expect(err).To(BeNil()) // Create and configure manager @@ -125,7 +125,7 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { dataStore.MockedPlugin = tests.CreateMockPluginRepo() - mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) mockPluginRepo.Permitted = true enabledPlugin := model.Plugin{ ID: "test-scrobble-retriever", @@ -300,10 +300,10 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { BeforeAll(func() { scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) - scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + scrobbleRepo := dataStore.Scrobble() for i := range 5 { - err := scrobbleRepo.RecordScrobble("3", time.Unix(100, 0)) + err := scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(100, 0)) Expect(err).To(BeNil()) scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100} diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go index 9fc58c396..9d8df23a0 100644 --- a/plugins/host_storage_test.go +++ b/plugins/host_storage_test.go @@ -85,7 +85,7 @@ var _ = Describe("Storage Host Function", Ordered, func() { } manager.SetSubsonicRouter(router) - mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) mockPluginRepo.Permitted = true // Setup config diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index dba58d795..a8ff12140 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -138,7 +138,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Look up the user by username to get their ID - usr, err := s.ds.User(ctx).FindByUsername(username) + usr, err := s.ds.User().FindByUsername(ctx, username) if err != nil { if errors.Is(err, model.ErrNotFound) { return fmt.Errorf("username %s not found", username) diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 4b941bc43..0d9c75ee7 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -1,6 +1,7 @@ package plugins import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -51,12 +52,12 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { dataStore = &tests.MockDataStore{MockedUser: userRepo} // Add test users - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(GinkgoT().Context(), &model.User{ ID: "user1", UserName: "testuser", IsAdmin: false, }) - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(GinkgoT().Context(), &model.User{ ID: "admin1", UserName: "adminuser", IsAdmin: true, @@ -77,7 +78,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { hash := sha256.Sum256(wasmData) hashHex := hex.EncodeToString(hash[:]) - mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) mockPluginRepo.Permitted = true enabledPlugin := model.Plugin{ ID: "test-subsonicapi-plugin", @@ -234,27 +235,29 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { var _ = Describe("SubsonicAPIService", func() { var ( + ctx context.Context router *fakeSubsonicRouter userRepo *tests.MockedUserRepo dataStore *tests.MockDataStore ) BeforeEach(func() { + ctx = GinkgoT().Context() router = &fakeSubsonicRouter{} userRepo = tests.CreateMockUserRepo() dataStore = &tests.MockDataStore{MockedUser: userRepo} - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(ctx, &model.User{ ID: "user1", UserName: "testuser", IsAdmin: false, }) - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(ctx, &model.User{ ID: "admin1", UserName: "adminuser", IsAdmin: true, }) - _ = userRepo.Put(&model.User{ + _ = userRepo.Put(ctx, &model.User{ ID: "user2", UserName: "alloweduser", IsAdmin: false, @@ -267,7 +270,6 @@ var _ = Describe("SubsonicAPIService", func() { // allowedUserIDs contains "user2", but testuser is "user1" service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not authorized")) @@ -277,7 +279,6 @@ var _ = Describe("SubsonicAPIService", func() { // allowedUserIDs contains "user2" which is "alloweduser" service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) - ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -287,7 +288,6 @@ var _ = Describe("SubsonicAPIService", func() { // allowedUserIDs only contains "user1" (testuser), not "admin1" service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not authorized")) @@ -297,7 +297,6 @@ var _ = Describe("SubsonicAPIService", func() { // allowedUserIDs contains "admin1" service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) - ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -308,7 +307,6 @@ var _ = Describe("SubsonicAPIService", func() { It("allows all users regardless of allowed list", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -317,7 +315,6 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when allUsers is true", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -328,7 +325,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error when no users are configured", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, false)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no users configured")) @@ -337,7 +333,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error for empty user list", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no users configured")) @@ -349,7 +344,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error for missing username parameter", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing required parameter")) @@ -358,7 +352,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error for invalid URL", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("invalid URL")) @@ -367,7 +360,6 @@ var _ = Describe("SubsonicAPIService", func() { It("extracts endpoint from path correctly", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") Expect(err).ToNot(HaveOccurred()) @@ -380,7 +372,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns binary data and content-type", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") Expect(err).ToNot(HaveOccurred()) Expect(contentType).To(Equal("image/png")) @@ -390,7 +381,6 @@ var _ = Describe("SubsonicAPIService", func() { It("does not set f=json parameter", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") Expect(err).ToNot(HaveOccurred()) @@ -402,7 +392,6 @@ var _ = Describe("SubsonicAPIService", func() { It("enforces permission checks", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) - ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not authorized")) @@ -411,7 +400,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error when username is missing", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing required parameter")) @@ -420,7 +408,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error when router is nil", func() { service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("router not available")) @@ -429,7 +416,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error for invalid URL", func() { service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("invalid URL")) @@ -440,7 +426,6 @@ var _ = Describe("SubsonicAPIService", func() { It("returns error when router is nil", func() { service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) - ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("router not available")) diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index 2f74c0aa4..b15cfaf56 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -71,7 +71,7 @@ type taskQueueServiceImpl struct { manager *Manager maxConcurrency int32 db *sql.DB - ctx context.Context + ctx context.Context //nolint:containedctx // service lifecycle ctx for the worker goroutines cancel context.CancelFunc wg sync.WaitGroup mu sync.Mutex diff --git a/plugins/host_users.go b/plugins/host_users.go index a56c8f866..28f4dc9b0 100644 --- a/plugins/host_users.go +++ b/plugins/host_users.go @@ -23,7 +23,7 @@ func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) h } func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) { - users, err := s.ds.User(ctx).GetAll() + users, err := s.ds.User().GetAll(ctx) if err != nil { return nil, err } diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 1721d3ee2..56edd9f67 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -35,21 +35,21 @@ var _ = Describe("UsersService", Ordered, func() { var mockUserRepo *tests.MockedUserRepo BeforeEach(func() { - mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo) + mockUserRepo = ds.User().(*tests.MockedUserRepo) // Add test users - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user1", UserName: "alice", Name: "Alice Admin", IsAdmin: true, }) - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user2", UserName: "bob", Name: "Bob User", IsAdmin: false, }) - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user3", UserName: "charlie", Name: "Charlie User", @@ -144,21 +144,21 @@ var _ = Describe("UsersService", Ordered, func() { var mockUserRepo *tests.MockedUserRepo BeforeEach(func() { - mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo) + mockUserRepo = ds.User().(*tests.MockedUserRepo) // Add test users - alice is admin, bob and charlie are not - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user1", UserName: "alice", Name: "Alice Admin", IsAdmin: true, }) - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user2", UserName: "bob", Name: "Bob User", IsAdmin: false, }) - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user3", UserName: "charlie", Name: "Charlie User", @@ -458,20 +458,20 @@ func setupTestUsersPlugin() (*testUsersSetup, error) { } // createTestUsers creates standard test users in the mock repo -func createTestUsers(mockUserRepo *tests.MockedUserRepo) { - _ = mockUserRepo.Put(&model.User{ +func createTestUsers(ctx context.Context, mockUserRepo *tests.MockedUserRepo) { + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user1", UserName: "alice", Name: "Alice Admin", IsAdmin: true, }) - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user2", UserName: "bob", Name: "Bob User", IsAdmin: false, }) - _ = mockUserRepo.Put(&model.User{ + _ = mockUserRepo.Put(ctx, &model.User{ ID: "user3", UserName: "charlie", Name: "Charlie User", @@ -560,7 +560,7 @@ func setupUsersIntegrationManagerWithEnabled(enabled, allUsers bool, allowedUser }}) mockUserRepo := tests.CreateMockUserRepo() - createTestUsers(mockUserRepo) + createTestUsers(GinkgoT().Context(), mockUserRepo) dataStore := &tests.MockDataStore{ MockedPlugin: mockPluginRepo, diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index d9d82665c..a58deb129 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -56,7 +56,7 @@ type wsConnection struct { // webSocketServiceImpl implements host.WebSocketService. // It provides plugins with WebSocket communication capabilities. type webSocketServiceImpl struct { - baseCtx context.Context // bounds the read loops, which outlive the Connect() call + baseCtx context.Context //nolint:containedctx // bounds the read loops, which outlive the Connect() call pluginName string manager *Manager requiredHosts []string diff --git a/plugins/manager.go b/plugins/manager.go index bab67e987..90247a75c 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -49,7 +49,7 @@ type PluginMetricsRecorder interface { type Manager struct { mu sync.RWMutex plugins map[string]*plugin - ctx context.Context + ctx context.Context //nolint:containedctx // manager lifecycle ctx, cancelled by Stop cancel context.CancelFunc cache wazero.CompilationCache stopped atomic.Bool // Set to true when Stop() is called @@ -134,7 +134,7 @@ func (m *Manager) Start(ctx context.Context) error { // Clear previous error states so plugins can be retried on restart adminCtx := adminContext(ctx) - if err := m.ds.Plugin(adminCtx).ClearErrors(); err != nil { + if err := m.ds.Plugin().ClearErrors(adminCtx); err != nil { log.Error(ctx, "Error clearing plugin errors", err) } @@ -323,9 +323,9 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin(adminCtx) + repo := m.ds.Plugin() - plugin, err := repo.Get(id) + plugin, err := repo.Get(adminCtx, id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -344,7 +344,7 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { // Store error and return plugin.LastError = err.Error() plugin.UpdatedAt = time.Now() - _ = repo.Put(plugin) + _ = repo.Put(adminCtx, plugin) return fmt.Errorf("loading plugin: %w", err) } @@ -352,7 +352,7 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { plugin.Enabled = true plugin.LastError = "" plugin.UpdatedAt = time.Now() - if err := repo.Put(plugin); err != nil { + if err := repo.Put(adminCtx, plugin); err != nil { // Unload since we couldn't update DB _ = m.unloadPlugin(id) return fmt.Errorf("updating plugin in DB: %w", err) @@ -371,9 +371,9 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin(adminCtx) + repo := m.ds.Plugin() - plugin, err := repo.Get(id) + plugin, err := repo.Get(adminCtx, id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -390,7 +390,7 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error { // Update DB plugin.Enabled = false plugin.UpdatedAt = time.Now() - if err := repo.Put(plugin); err != nil { + if err := repo.Put(adminCtx, plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } @@ -408,9 +408,9 @@ func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON strin } adminCtx := adminContext(ctx) - repo := m.ds.Plugin(adminCtx) + repo := m.ds.Plugin() - plugin, err := repo.Get(id) + plugin, err := repo.Get(adminCtx, id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -476,9 +476,9 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn } adminCtx := adminContext(ctx) - repo := m.ds.Plugin(adminCtx) + repo := m.ds.Plugin() - plugin, err := repo.Get(id) + plugin, err := repo.Get(adminCtx, id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -512,7 +512,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn log.Debug(ctx, "Plugin was not loaded", "plugin", id) } plugin.Enabled = false - if err := repo.Put(plugin); err != nil { + if err := repo.Put(adminCtx, plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id) @@ -520,7 +520,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn return nil } - if err := repo.Put(plugin); err != nil { + if err := repo.Put(adminCtx, plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } @@ -532,7 +532,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn if err := m.loadPluginWithConfig(plugin); err != nil { plugin.LastError = err.Error() plugin.Enabled = false - _ = repo.Put(plugin) + _ = repo.Put(adminCtx, plugin) return fmt.Errorf("reloading plugin: %w", err) } } @@ -586,10 +586,10 @@ func (m *Manager) UnloadDisabledPlugins(ctx context.Context) { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin(adminCtx) + repo := m.ds.Plugin() // Get all disabled plugins from the database - plugins, err := repo.GetAll(model.QueryOptions{ + plugins, err := repo.GetAll(adminCtx, model.QueryOptions{ Filters: squirrel.Eq{"enabled": false}, }) if err != nil { diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 024b14b72..cca87e5b0 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -219,9 +219,9 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin(adminCtx) + repo := m.ds.Plugin() - plugins, err := repo.GetAll() + plugins, err := repo.GetAll(adminCtx) if err != nil { return fmt.Errorf("reading plugins from DB: %w", err) } @@ -257,7 +257,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { plugin.LastError = err.Error() plugin.Enabled = false plugin.UpdatedAt = time.Now() - if putErr := repo.Put(&plugin); putErr != nil { + if putErr := repo.Put(adminCtx, &plugin); putErr != nil { log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) } } @@ -269,7 +269,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { if plugin.LastError != "" && m.transient == nil { plugin.LastError = "" plugin.UpdatedAt = time.Now() - if putErr := repo.Put(&plugin); putErr != nil { + if putErr := repo.Put(adminCtx, &plugin); putErr != nil { log.Error(ctx, "Failed to clear plugin error in DB", "plugin", plugin.ID, putErr) } } @@ -347,7 +347,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { if pkg.Manifest.HasLibraryFilesystemPermission() { adminCtx := adminContext(ctx) - libraries, err := m.ds.Library(adminCtx).GetAll() + libraries, err := m.ds.Library().GetAll(adminCtx) if err != nil { return fmt.Errorf("failed to get libraries for filesystem access: %w", err) } diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 13375a70f..015cd7f4c 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -126,7 +126,7 @@ func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username st if !a.allUsers && len(a.userIDSet) == 0 { return nil, fmt.Errorf("plugin is not authorized to scope by user") } - usr, err := ds.User(ctx).FindByUsername(username) + usr, err := ds.User().FindByUsername(ctx, username) if err != nil { if errors.Is(err, model.ErrNotFound) { return nil, fmt.Errorf("user %q not found", username) diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go index 019b14fbf..0c88459c0 100644 --- a/plugins/manager_readonly_test.go +++ b/plugins/manager_readonly_test.go @@ -1,6 +1,7 @@ package plugins import ( + "context" "os" "path/filepath" @@ -14,11 +15,16 @@ import ( var _ = Describe("Manager.LoadPlugins", func() { var ( + ctx context.Context mgr *Manager repo *tests.MockPluginRepo tmpDir string ) + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + // newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI // has none, and Start would log.Fatal on that. newManager := func(rows model.Plugins) *Manager { @@ -54,7 +60,7 @@ var _ = Describe("Manager.LoadPlugins", func() { It("detects capabilities without a Subsonic router configured", func() { mgr = newManager(nil) - Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent")) }) @@ -70,9 +76,9 @@ var _ = Describe("Manager.LoadPlugins", func() { It("leaves the stored row untouched", func() { mgr = newManager(brokenRows()) - Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - stored, err := repo.Get("broken") + stored, err := repo.Get(ctx, "broken") Expect(err).ToNot(HaveOccurred()) Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it") Expect(stored.LastError).To(BeEmpty()) @@ -83,9 +89,9 @@ var _ = Describe("Manager.LoadPlugins", func() { It("still disables it when not read-only", func() { mgr = newManager(brokenRows()) - Expect(mgr.loadEnabledPlugins(GinkgoT().Context())).To(Succeed()) + Expect(mgr.loadEnabledPlugins(ctx)).To(Succeed()) - stored, err := repo.Get("broken") + stored, err := repo.Get(ctx, "broken") Expect(err).ToNot(HaveOccurred()) Expect(stored.Enabled).To(BeFalse()) Expect(stored.LastError).ToNot(BeEmpty()) @@ -97,7 +103,7 @@ var _ = Describe("Manager.LoadPlugins", func() { It("does not load a plugin that is not in the agent list", func() { mgr = newManager(nil) - Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"some-other-agent"}, false)).To(Succeed()) + Expect(mgr.LoadPlugins(ctx, []string{"some-other-agent"}, false)).To(Succeed()) Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) }) @@ -105,7 +111,7 @@ var _ = Describe("Manager.LoadPlugins", func() { It("does nothing when no agents are configured", func() { mgr = newManager(nil) - Expect(mgr.LoadPlugins(GinkgoT().Context(), nil, false)).To(Succeed()) + Expect(mgr.LoadPlugins(ctx, nil, false)).To(Succeed()) Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) // Not even the wazero cache: with nothing to load there is nothing to compile. @@ -116,7 +122,7 @@ var _ = Describe("Manager.LoadPlugins", func() { mgr = newManager(nil) conf.Server.Plugins.Enabled = false - Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) }) diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 480fa1bb9..33b4f81d4 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -65,7 +65,7 @@ func (m *Manager) addPluginToDB(ctx context.Context, repo model.PluginRepository CreatedAt: now, UpdatedAt: now, } - if err := repo.Put(newPlugin); err != nil { + if err := repo.Put(ctx, newPlugin); err != nil { return fmt.Errorf("adding plugin to DB: %w", err) } log.Info(ctx, "Discovered new plugin", "plugin", name) @@ -88,7 +88,7 @@ func (m *Manager) updatePluginInDB(ctx context.Context, repo model.PluginReposit dbPlugin.Enabled = false dbPlugin.LastError = "" dbPlugin.UpdatedAt = time.Now() - if err := repo.Put(dbPlugin); err != nil { + if err := repo.Put(ctx, dbPlugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } log.Info(ctx, "Plugin file changed", "plugin", dbPlugin.ID, "wasEnabled", wasEnabled) @@ -105,7 +105,7 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos log.Debug(ctx, "Plugin not loaded during removal", "plugin", pluginID, err) } } - if err := repo.Delete(pluginID); err != nil { + if err := repo.Delete(ctx, pluginID); err != nil { return fmt.Errorf("deleting plugin from DB: %w", err) } // Discard any scrobbles still buffered for the removed plugin, so they are @@ -115,7 +115,7 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos // wipe the builtin Last.fm retry queue. if scrobbler.IsBuiltinScrobbler(pluginID) { log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID) - } else if err := m.ds.ScrobbleBuffer(ctx).Discard(pluginID); err != nil { + } else if err := m.ds.ScrobbleBuffer().Discard(ctx, pluginID); err != nil { log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, err) } log.Info(ctx, "Plugin removed", "plugin", pluginID) @@ -162,8 +162,8 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) // Get all plugins from DB - repo := m.ds.Plugin(adminCtx) - dbPlugins, err := repo.GetAll() + repo := m.ds.Plugin() + dbPlugins, err := repo.GetAll(adminCtx) if err != nil { return fmt.Errorf("reading plugins from DB: %w", err) } @@ -192,7 +192,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { if dbPlugin.Path != path { dbPlugin.Path = path dbPlugin.UpdatedAt = now - if err := repo.Put(dbPlugin); err != nil { + if err := repo.Put(adminCtx, dbPlugin); err != nil { log.Error(ctx, "Failed to update plugin path in DB", "plugin", name, err) } } @@ -215,7 +215,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { } dbPlugin.Enabled = false } - if putErr := repo.Put(dbPlugin); putErr != nil { + if putErr := repo.Put(adminCtx, dbPlugin); putErr != nil { log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err) } } @@ -225,12 +225,12 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { if !exists { // New plugin - add to DB as disabled - if err := m.addPluginToDB(ctx, repo, name, path, metadata); err != nil { + if err := m.addPluginToDB(adminCtx, repo, name, path, metadata); err != nil { log.Error(ctx, "Failed to add plugin to DB", "plugin", name, err) } } else { // Plugin changed - update DB - if err := m.updatePluginInDB(ctx, repo, dbPlugin, path, metadata); err != nil { + if err := m.updatePluginInDB(adminCtx, repo, dbPlugin, path, metadata); err != nil { log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err) } } @@ -240,7 +240,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { // Remove plugins no longer on disk for _, dbPlugin := range pluginsInDB { - if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil { + if err := m.removePluginFromDB(adminCtx, repo, dbPlugin); err != nil { log.Error(ctx, "Failed to delete plugin from DB", "plugin", dbPlugin.ID, err) } } diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index dd64dcd3f..e190abd34 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -13,11 +13,13 @@ import ( ) var _ = Describe("syncPlugins", func() { + var ctx context.Context var m *Manager var repo *tests.MockPluginRepo var folder string BeforeEach(func() { + ctx = GinkgoT().Context() folder = GinkgoT().TempDir() repo = tests.CreateMockPluginRepo() repo.SetData(model.Plugins{}) @@ -36,7 +38,7 @@ var _ = Describe("syncPlugins", func() { Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) - _, err := repo.Get("my-plugin") + _, err := repo.Get(ctx, "my-plugin") Expect(err).ToNot(HaveOccurred()) }) @@ -46,18 +48,23 @@ var _ = Describe("syncPlugins", func() { Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) - all, err := repo.GetAll() + all, err := repo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(all).To(BeEmpty()) }) }) var _ = Describe("removePluginFromDB", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + It("discards buffered scrobbles for the removed plugin", func() { - ctx := context.Background() buffer := tests.CreateMockedScrobbleBufferRepo() - Expect(buffer.Enqueue("my-plugin", "user1", "track1", time.Now())).To(Succeed()) - Expect(buffer.Enqueue("other-plugin", "user1", "track2", time.Now())).To(Succeed()) + Expect(buffer.Enqueue(ctx, "my-plugin", "user1", "track1", time.Now())).To(Succeed()) + Expect(buffer.Enqueue(ctx, "other-plugin", "user1", "track2", time.Now())).To(Succeed()) repo := tests.CreateMockPluginRepo() plugin := model.Plugin{ID: "my-plugin", Enabled: false} @@ -70,22 +77,21 @@ var _ = Describe("removePluginFromDB", func() { } Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) - _, err := repo.Get("my-plugin") + _, err := repo.Get(ctx, "my-plugin") Expect(err).To(MatchError(model.ErrNotFound)) - remaining, err := buffer.Length() + remaining, err := buffer.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(remaining).To(Equal(int64(1))) - entry, err := buffer.Next("other-plugin", "user1") + entry, err := buffer.Next(ctx, "other-plugin", "user1") Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil(), "entries of other services must be kept") }) It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() { - ctx := context.Background() scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) buffer := tests.CreateMockedScrobbleBufferRepo() - Expect(buffer.Enqueue("builtin-svc", "user1", "track1", time.Now())).To(Succeed()) + Expect(buffer.Enqueue(ctx, "builtin-svc", "user1", "track1", time.Now())).To(Succeed()) repo := tests.CreateMockPluginRepo() plugin := model.Plugin{ID: "builtin-svc", Enabled: false} @@ -96,7 +102,7 @@ var _ = Describe("removePluginFromDB", func() { } Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) - remaining, err := buffer.Length() + remaining, err := buffer.Length(ctx) Expect(err).ToNot(HaveOccurred()) Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped") }) diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index f7f658be9..ce9e25a9d 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -157,7 +157,7 @@ func (m *Manager) processPluginEvent(pluginName string) { log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action, "path", ndpPath) ctx := adminContext(m.ctx) - repo := m.ds.Plugin(ctx) + repo := m.ds.Plugin() switch action { case actionUpdate: @@ -168,7 +168,7 @@ func (m *Manager) processPluginEvent(pluginName string) { return } - dbPlugin, err := repo.Get(pluginName) + dbPlugin, err := repo.Get(ctx, pluginName) if err != nil { // Plugin not in DB yet, need full manifest extraction to add it metadata, extractErr := m.extractManifest(ndpPath) @@ -176,7 +176,7 @@ func (m *Manager) processPluginEvent(pluginName string) { log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, extractErr) return } - if addErr := m.addPluginToDB(m.ctx, repo, pluginName, ndpPath, metadata); addErr != nil { + if addErr := m.addPluginToDB(ctx, repo, pluginName, ndpPath, metadata); addErr != nil { log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr) } return @@ -198,23 +198,23 @@ func (m *Manager) processPluginEvent(pluginName string) { _ = m.unloadPlugin(pluginName) dbPlugin.Enabled = false } - _ = repo.Put(dbPlugin) + _ = repo.Put(ctx, dbPlugin) return } - if err := m.updatePluginInDB(m.ctx, repo, dbPlugin, ndpPath, metadata); err != nil { + if err := m.updatePluginInDB(ctx, repo, dbPlugin, ndpPath, metadata); err != nil { log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err) } case actionRemove: // File removed - unload if enabled, delete from DB - dbPlugin, err := repo.Get(pluginName) + dbPlugin, err := repo.Get(ctx, pluginName) if err != nil { log.Debug(m.ctx, "Removed plugin not in DB", "plugin", pluginName) return } - if err := m.removePluginFromDB(m.ctx, repo, dbPlugin); err != nil { + if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil { log.Error(m.ctx, "Failed to delete plugin from DB", "plugin", pluginName, err) } } diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 99326bde1..17d9489dc 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -31,7 +31,7 @@ var _ = Describe("Plugin Watcher", func() { _ = manager.unloadPlugin("test-metadata-agent") _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) // Also remove from DB so tests start with a clean slate - _ = manager.ds.Plugin(ctx).Delete("test-metadata-agent") + _ = manager.ds.Plugin().Delete(ctx, "test-metadata-agent") }) // Helper to copy test plugin into the temp folder @@ -51,7 +51,7 @@ var _ = Describe("Plugin Watcher", func() { // Clean up: unload plugin if loaded, remove copied file, delete from DB _ = manager.unloadPlugin("test-metadata-agent") _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) - _ = manager.ds.Plugin(ctx).Delete("test-metadata-agent") + _ = manager.ds.Plugin().Delete(ctx, "test-metadata-agent") }) It("adds plugin to DB when file exists", func() { @@ -62,8 +62,8 @@ var _ = Describe("Plugin Watcher", func() { Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // Verify it was added to DB - repo := manager.ds.Plugin(ctx) - plugin, err := repo.Get("test-metadata-agent") + repo := manager.ds.Plugin() + plugin, err := repo.Get(ctx, "test-metadata-agent") Expect(err).ToNot(HaveOccurred()) Expect(plugin.ID).To(Equal("test-metadata-agent")) Expect(plugin.Enabled).To(BeFalse()) @@ -80,11 +80,11 @@ var _ = Describe("Plugin Watcher", func() { // Modify the stored SHA256 in DB to simulate a file change // (In reality, the file would have different content) - repo := manager.ds.Plugin(ctx) - plugin, err := repo.Get("test-metadata-agent") + repo := manager.ds.Plugin() + plugin, err := repo.Get(ctx, "test-metadata-agent") Expect(err).ToNot(HaveOccurred()) plugin.SHA256 = "different-hash-to-simulate-change" - err = repo.Put(plugin) + err = repo.Put(ctx, plugin) Expect(err).ToNot(HaveOccurred()) // Simulate modification - the plugin should be disabled and unloaded @@ -94,7 +94,7 @@ var _ = Describe("Plugin Watcher", func() { Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // But still in DB (just disabled) - plugin, err = repo.Get("test-metadata-agent") + plugin, err = repo.Get(ctx, "test-metadata-agent") Expect(err).ToNot(HaveOccurred()) Expect(plugin.Enabled).To(BeFalse()) }) @@ -115,8 +115,8 @@ var _ = Describe("Plugin Watcher", func() { Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // And removed from DB - repo := manager.ds.Plugin(ctx) - _, err = repo.Get("test-metadata-agent") + repo := manager.ds.Plugin() + _, err = repo.Get(ctx, "test-metadata-agent") Expect(err).To(HaveOccurred()) }) }) diff --git a/scanner/controller.go b/scanner/controller.go index bfb396c6d..5eed6c58d 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -94,7 +94,7 @@ type scanner interface { } type controller struct { - rootCtx context.Context + rootCtx context.Context //nolint:containedctx // scanner lifecycle ctx ds model.DataStore broker events.Broker metrics metrics.Metrics @@ -108,7 +108,7 @@ type controller struct { // getLastScanTime returns the most recent scan time across all libraries func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) { - libs, err := s.ds.Library(ctx).GetAll(model.QueryOptions{ + libs, err := s.ds.Library().GetAll(ctx, model.QueryOptions{ Sort: "last_scan_at", Order: "desc", Max: 1, @@ -126,9 +126,9 @@ func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) { // getScanInfo retrieves scan status from the database func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) { - lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") - scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") - startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + lastErr, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") + scanType, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") + startTimeStr, _ := s.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") if startTimeStr != "" { startTime, err := time.Parse(time.RFC3339, startTimeStr) @@ -185,7 +185,7 @@ func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) { } func (s *controller) getCounters(ctx context.Context) (int64, int64, error) { - libs, err := s.ds.Library(ctx).GetAll() + libs, err := s.ds.Library().GetAll(ctx) if err != nil { return 0, 0, fmt.Errorf("library count: %w", err) } @@ -238,7 +238,7 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ } // Store scan error in database so it can be displayed in the UI if scanError != nil { - _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) + _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, scanError.Error()) } // Refresh the query-planner statistics after a successful full scan. This must run in the // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the @@ -324,7 +324,7 @@ func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []mod // anyIncludedLibrary reports whether any library included in the scan (all of them when targets is // empty) matches pred. func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { - libraries, err := ds.Library(ctx).GetAll() + libraries, err := ds.Library().GetAll(ctx) if err != nil { return false } diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 45d202904..bdcb99eda 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -35,7 +35,7 @@ var _ = Describe("Controller", func() { }) It("includes last scan error", func() { - Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.LastScanErrorKey, "boom")).To(Succeed()) status, err := ctrl.Status(ctx) Expect(err).ToNot(HaveOccurred()) Expect(status.LastError).To(Equal("boom")) @@ -43,8 +43,8 @@ var _ = Describe("Controller", func() { It("includes scan type and error in status", func() { // Set up test data in property repo - Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed()) - Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.LastScanErrorKey, "test error")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.LastScanTypeKey, "full")).To(Succeed()) // Get status and verify basic info status, err := ctrl.Status(ctx) diff --git a/scanner/image_changes.go b/scanner/image_changes.go index a9c4de365..3ea7ce57c 100644 --- a/scanner/image_changes.go +++ b/scanner/image_changes.go @@ -54,7 +54,7 @@ func (c *imageChangeCollector) enqueue(ctx context.Context) { if len(items) == 0 { continue } - if err := c.ds.ArtworkQueue(ctx).Enqueue(items...); err != nil { + if err := c.ds.ArtworkQueue().Enqueue(ctx, items...); err != nil { log.Warn(ctx, "Scanner: could not enqueue artwork for image changes", "lib", lib.Name, err) continue } @@ -77,7 +77,7 @@ func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library var items []model.ArtworkQueueItem - albumIDs, err := c.ds.MediaFile(ctx).GetAlbumIDsByFolder(lib, folderIDs...) + albumIDs, err := c.ds.MediaFile().GetAlbumIDsByFolder(ctx, lib, folderIDs...) if err != nil { return nil, err } @@ -90,7 +90,7 @@ func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library } // The resolver climbs to the library root, so the subtree below the folder is the affected set. // A failure here must not discard the album items already collected. - artistIDs, err := c.ds.Album(ctx).GetSoleAlbumArtistIDsInSubtrees(lib, artistFolderPaths...) + artistIDs, err := c.ds.Album().GetSoleAlbumArtistIDsInSubtrees(ctx, lib, artistFolderPaths...) if err != nil { log.Warn(ctx, "Scanner: could not map image changes to artists", "lib", lib.Name, err) return items, nil diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index feefde032..7b6a6b097 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -61,7 +61,7 @@ type scanJob struct { func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { // Get folder updates, optionally filtered to specific target folders - lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) + lastUpdates, err := ds.Folder().GetFolderUpdateInfo(ctx, lib, targetFolders...) if err != nil { return nil, fmt.Errorf("getting last updates: %w", err) } @@ -124,8 +124,8 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry { type phaseFolders struct { jobs []*scanJob ds model.DataStore - ctx context.Context - walkCtx context.Context // cancelled when a folder fails to persist, so the walk stops early + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx + walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early stopWalk context.CancelCauseFunc state *scanState prevAlbumPIDConf string @@ -139,7 +139,7 @@ func (p *phaseFolders) description() string { func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { return ppl.NewProducer(func(put func(entry *folderEntry)) error { var err error - p.prevAlbumPIDConf, err = p.ds.Property(p.ctx).DefaultGet(consts.PIDAlbumKey, "") + p.prevAlbumPIDConf, err = p.ds.Property().DefaultGet(p.ctx, consts.PIDAlbumKey, "") if err != nil { return fmt.Errorf("getting album PID conf: %w", err) } @@ -217,7 +217,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { } // Load children mediafiles from DB - cursor, err := p.ds.MediaFile(p.ctx).GetCursor(model.QueryOptions{ + cursor, err := p.ds.MediaFile().GetCursor(p.ctx, model.QueryOptions{ Filters: squirrel.And{squirrel.Eq{"folder_id": entry.id}}, }) if err != nil { @@ -367,34 +367,34 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en albumIDMap := maps.Clone(entry.albumIDMap) // Instantiate all repositories just once per folder - folderRepo := tx.Folder(ctx) - tagRepo := tx.Tag(ctx) - artistRepo := tx.Artist(ctx) - libraryRepo := tx.Library(ctx) - albumRepo := tx.Album(ctx) - mfRepo := tx.MediaFile(ctx) + folderRepo := tx.Folder() + tagRepo := tx.Tag() + artistRepo := tx.Artist() + libraryRepo := tx.Library() + albumRepo := tx.Album() + mfRepo := tx.MediaFile() // Save folder to DB folder := entry.toFolder() - err := folderRepo.Put(folder) + err := folderRepo.Put(ctx, folder) if err != nil { return fmt.Errorf("persisting folder: %w", err) } // Save all tags to DB - err = tagRepo.Add(entry.job.lib.ID, entry.tags...) + err = tagRepo.Add(ctx, entry.job.lib.ID, entry.tags...) if err != nil { return fmt.Errorf("persisting tags: %w", err) } // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later for i := range entry.artists { - err = artistRepo.Put(&entry.artists[i], "name", + err = artistRepo.Put(ctx, &entry.artists[i], "name", "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") if err != nil { return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err) } - err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID) + err = libraryRepo.AddArtist(ctx, entry.job.lib.ID, entry.artists[i].ID) if err != nil { return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err) } @@ -416,7 +416,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en // Save all tracks to DB for i := range entry.tracks { - err = mfRepo.Put(&entry.tracks[i]) + err = mfRepo.Put(ctx, &entry.tracks[i]) if err != nil { return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err) } @@ -425,14 +425,14 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en // A re-imported track returns to unresolved so new embedded art is picked up lazily. if len(entry.tracks) > 0 { trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) - if err := tx.Artwork(ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil { + if err := tx.Artwork().DeleteForItems(ctx, model.KindMediaFileArtwork, trackIDs); err != nil { log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err) } } // Mark all missing tracks as not available if len(entry.missingTracks) > 0 { - err = mfRepo.MarkMissing(true, entry.missingTracks...) + err = mfRepo.MarkMissing(ctx, true, entry.missingTracks...) if err != nil { return fmt.Errorf("marking missing tracks: %w", err) } @@ -442,7 +442,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en return mf.AlbumID, struct{}{} }) albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks)) - err = albumRepo.Touch(albumsToUpdate...) + err = albumRepo.Touch(ctx, albumsToUpdate...) if err != nil { return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err) } @@ -451,12 +451,12 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en // Enqueue artwork resolution for changed albums/artists. Never fails the scan. // A full scan re-imports every track, so a re-import is no evidence the art changed. if len(queueItems) > 0 { - queue := tx.ArtworkQueue(ctx) + queue := tx.ArtworkQueue() enqueue := queue.Enqueue if p.state.fullScan { enqueue = queue.EnqueueIfMissing } - if err := enqueue(queueItems...); err != nil { + if err := enqueue(ctx, queueItems...); err != nil { log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err) } } @@ -467,7 +467,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, idMap map[string]string) error { prevID := idMap[a.ID] log.Trace(p.ctx, "Persisting album", "album", a.Name, "albumArtist", a.AlbumArtist, "id", a.ID, "prevID", cmp.Or(prevID, "nil")) - if err := repo.Put(a); err != nil { + if err := repo.Put(p.ctx, a); err != nil { return fmt.Errorf("persisting album %s: %w", a.ID, err) } if prevID == "" { @@ -476,13 +476,13 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, // Reassign annotation from previous album to new album log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name) - if err := repo.ReassignAnnotation(prevID, a.ID); err != nil { + if err := repo.ReassignAnnotation(p.ctx, prevID, a.ID); err != nil { log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err) p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) } // Keep created_at field from previous instance of the album - if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil { + if err := repo.CopyAttributes(p.ctx, prevID, a.ID, "created_at"); err != nil { // Silently ignore when the previous album is not found if !errors.Is(err, model.ErrNotFound) { log.Warn(p.ctx, "Scanner: Could not copy fields", "from", prevID, "to", a.ID, "album", a.Name, err) @@ -520,14 +520,14 @@ func (p *phaseFolders) finalize(err error) error { continue } folderIDs := slices.Collect(maps.Keys(job.lastUpdates)) - if err := tx.Folder(ctx).MarkMissing(true, folderIDs...); err != nil { + if err := tx.Folder().MarkMissing(ctx, true, folderIDs...); err != nil { return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err) } - if err := tx.MediaFile(ctx).MarkMissingByFolder(true, folderIDs...); err != nil { + if err := tx.MediaFile().MarkMissingByFolder(ctx, true, folderIDs...); err != nil { return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err) } // Touch all albums that have missing folders, so they get refreshed in later phases - if _, err := tx.Album(ctx).TouchByMissingFolder(); err != nil { + if _, err := tx.Album().TouchByMissingFolder(ctx); err != nil { return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err) } } diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 6ccc9a46c..945257e47 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -33,7 +33,7 @@ type missingTracks struct { // 4. Updates the database with the new locations of the matched files and removes the old entries. // 5. Logs the results and finalizes the phase by reporting the total number of matched files. type phaseMissingTracks struct { - ctx context.Context + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx ds model.DataStore totalMatched atomic.Uint32 state *scanState @@ -71,7 +71,7 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { } for _, lib := range p.state.libraries { log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name) - cursor, err := p.ds.MediaFile(p.ctx).GetMissingAndMatching(lib.ID) + cursor, err := p.ds.MediaFile().GetMissingAndMatching(p.ctx, lib.ID) if err != nil { return fmt.Errorf("loading missing tracks for library %s: %w", lib.Name, err) } @@ -232,7 +232,7 @@ func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missi func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) { // First tier: Search by MusicBrainz Track ID if available if missing.MbzReleaseTrackID != "" { - matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByMBZTrackID(missing, missing.CreatedAt) + matches, err := p.ds.MediaFile().FindRecentFilesByMBZTrackID(p.ctx, missing, missing.CreatedAt) if err != nil { log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err) } else { @@ -251,7 +251,7 @@ func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (mod } // Second tier: Search by intrinsic properties (title, size, suffix, etc.) - matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByProperties(missing, missing.CreatedAt) + matches, err := p.ds.MediaFile().FindRecentFilesByProperties(p.ctx, missing, missing.CreatedAt) if err != nil { log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err) return model.MediaFile{}, err @@ -298,25 +298,25 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error // 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. moved.ID = missing.ID - if err := tx.MediaFile(ctx).Put(&moved); err != nil { + if err := tx.MediaFile().Put(ctx, &moved); err != nil { return fmt.Errorf("update matched track: %w", err) } // Discard the new mediafile row (the one that was moved to) - if err := tx.MediaFile(ctx).Delete(target.ID); err != nil { + if err := tx.MediaFile().Delete(ctx, target.ID); err != nil { return fmt.Errorf("delete discarded track: %w", err) } if reassignAlbum { // Reassign direct album annotations (starred, rating) log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) - if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { log.Warn(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(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil { + if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil { if !errors.Is(err, model.ErrNotFound) { log.Warn(ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) } @@ -360,7 +360,7 @@ func (p *phaseMissingTracks) purgeMissing() error { var deletedCount int64 err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { var err error - deletedCount, err = tx.MediaFile(ctx).DeleteAllMissing() + deletedCount, err = tx.MediaFile().DeleteAllMissing(ctx) return err }, "scanner: purge missing") if err != nil { diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index b7aa52f90..f61aa4244 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -131,8 +131,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -144,7 +144,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) }) @@ -156,8 +156,8 @@ var _ = Describe("phaseMissingTracks", func() { probe = &probeTxDS{MockDataStore: ds.(*tests.MockDataStore)} probe.MockedAlbum = tests.CreateMockAlbumRepo() phase = createPhaseMissingTracks(ctx, state, probe) - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) }) It("claims the target album before the transaction, so a concurrent move skips it", func() { @@ -190,8 +190,8 @@ var _ = Describe("phaseMissingTracks", func() { It("keeps the moved track", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) _, err := phase.processMissingTracks(&missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -199,7 +199,7 @@ var _ = Describe("phaseMissingTracks", func() { }) Expect(err).ToNot(HaveOccurred()) - movedTrack, err := ds.MediaFile(ctx).Get("1") + movedTrack, err := ds.MediaFile().Get(ctx, "1") Expect(err).ToNot(HaveOccurred()) Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) }) @@ -217,8 +217,8 @@ var _ = Describe("phaseMissingTracks", func() { } missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) _, err := phase.processMissingTracks(&missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -233,8 +233,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -246,7 +246,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) Expect(movedTrack.Size).To(Equal(matchedTrack.Size)) }) @@ -255,8 +255,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.flac", Tags: model.Tags{"title": []string{"different title"}}, Size: 200} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -268,7 +268,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) Expect(movedTrack.Size).To(Equal(matchedTrack.Size)) }) @@ -278,9 +278,9 @@ var _ = Describe("phaseMissingTracks", func() { matchedEquivalent := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} matchedExact := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedEquivalent) - _ = ds.MediaFile(ctx).Put(&matchedExact) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedEquivalent) + _ = ds.MediaFile().Put(ctx, &matchedExact) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -293,7 +293,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal(matchedExact.Path)) Expect(movedTrack.Size).To(Equal(matchedExact.Size)) }) @@ -303,9 +303,9 @@ var _ = Describe("phaseMissingTracks", func() { matched1 := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file2.flac", Title: "another title", Size: 200} matched2 := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file3.mp3", Title: "different title", Size: 100} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matched1) - _ = ds.MediaFile(ctx).Put(&matched2) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matched1) + _ = ds.MediaFile().Put(ctx, &matched2) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -318,7 +318,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeFalse()) // The missing track should still be the same - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal(missingTrack.Path)) Expect(movedTrack.Title).To(Equal(missingTrack.Title)) Expect(movedTrack.Size).To(Equal(missingTrack.Size)) @@ -333,9 +333,9 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack2 := model.MediaFile{ID: "2", PID: "A", Path: "old_dir2/song.mp3", Title: "title1", Size: 100} matchedTrack := model.MediaFile{ID: "3", PID: "A", Path: "new_dir/song.mp3", Title: "title1", Size: 200} - _ = ds.MediaFile(ctx).Put(&missingTrack1) - _ = ds.MediaFile(ctx).Put(&missingTrack2) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack1) + _ = ds.MediaFile().Put(ctx, &missingTrack2) + _ = ds.MediaFile().Put(ctx, &matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack1, missingTrack2}, @@ -349,11 +349,11 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // The matched track should have been consumed by the first missing track - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) // The second missing track should remain unchanged - unmatchedTrack, _ := ds.MediaFile(ctx).Get("2") + unmatchedTrack, _ := ds.MediaFile().Get(ctx, "2") Expect(unmatchedTrack.Path).To(Equal(missingTrack2.Path)) }) @@ -361,8 +361,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -370,7 +370,7 @@ var _ = Describe("phaseMissingTracks", func() { } // Simulate an error when moving the matched track by deleting the track from the DB - _ = ds.MediaFile(ctx).Delete("2") + _ = ds.MediaFile().Delete(ctx, "2") _, err := phase.processMissingTracks(in) Expect(err).To(HaveOccurred()) @@ -514,8 +514,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&movedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &movedTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -529,7 +529,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the move was performed - updatedTrack, _ := ds.MediaFile(ctx).Get("missing1") + updatedTrack, _ := ds.MediaFile().Get(ctx, "missing1") Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -566,8 +566,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&movedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &movedTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -581,7 +581,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the move was performed - updatedTrack, _ := ds.MediaFile(ctx).Get("missing2") + updatedTrack, _ := ds.MediaFile().Get(ctx, "missing2") Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -612,8 +612,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&sameLibTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &sameLibTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -670,9 +670,9 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-5 * time.Minute), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&mbzTrack) - _ = ds.MediaFile(ctx).Put(&intrinsicTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &mbzTrack) + _ = ds.MediaFile().Put(ctx, &intrinsicTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -686,7 +686,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the MBZ track was chosen (not the intrinsic one) - updatedTrack, _ := ds.MediaFile(ctx).Get("missing4") + updatedTrack, _ := ds.MediaFile().Get(ctx, "missing4") Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -718,8 +718,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&equivalentTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &equivalentTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -733,7 +733,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the equivalent match was accepted - updatedTrack, _ := ds.MediaFile(ctx).Get("missing5") + updatedTrack, _ := ds.MediaFile().Get(ctx, "missing5") Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -788,9 +788,9 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-5 * time.Minute), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&match1) - _ = ds.MediaFile(ctx).Put(&match2) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &match1) + _ = ds.MediaFile().Put(ctx, &match2) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -804,7 +804,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeFalse()) // Verify no move was performed - unchangedTrack, _ := ds.MediaFile(ctx).Get("missing6") + unchangedTrack, _ := ds.MediaFile().Get(ctx, "missing6") Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3")) Expect(unchangedTrack.LibraryID).To(Equal(1)) }) @@ -844,7 +844,7 @@ var _ = Describe("phaseMissingTracks", func() { var albumRepo *tests.MockAlbumRepo BeforeEach(func() { - albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + albumRepo = ds.Album().(*tests.MockAlbumRepo) albumRepo.ReassignAnnotationCalls = make(map[string]string) albumRepo.CopyAttributesCalls = make(map[string]string) }) @@ -868,8 +868,8 @@ var _ = Describe("phaseMissingTracks", func() { Size: 100, } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -879,7 +879,7 @@ var _ = Describe("phaseMissingTracks", func() { _, err := phase.processMissingTracks(in) Expect(err).ToNot(HaveOccurred()) - movedTrack, _ := ds.MediaFile(ctx).Get("1") + movedTrack, _ := ds.MediaFile().Get(ctx, "1") Expect(movedTrack.Path).To(Equal("new/song.mp3")) Expect(movedTrack.CreatedAt).To(Equal(originalTime)) }) @@ -905,21 +905,21 @@ var _ = Describe("phaseMissingTracks", func() { {ID: "new-album", LibraryID: 2, CreatedAt: time.Now()}, }) - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &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") + movedTrack, _ := ds.MediaFile().Get(ctx, "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") + newAlbum, err := albumRepo.Get(ctx, "new-album") Expect(err).ToNot(HaveOccurred()) Expect(newAlbum.CreatedAt).To(Equal(originalTime)) }) @@ -939,14 +939,14 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: time.Now(), } - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &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") + movedTrack, _ := ds.MediaFile().Get(ctx, "missing-same") Expect(movedTrack.CreatedAt).To(Equal(originalTime)) // CopyAttributes should NOT have been called (same album) @@ -964,7 +964,7 @@ var _ = Describe("phaseMissingTracks", func() { ) BeforeEach(func() { - albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + albumRepo = ds.Album().(*tests.MockAlbumRepo) albumRepo.ReassignAnnotationCalls = make(map[string]string) oldAlbumID = "old-album-id" @@ -999,8 +999,8 @@ var _ = Describe("phaseMissingTracks", func() { } // Store both tracks in the database - _ = ds.MediaFile(ctx).Put(&missingTrack) - _ = ds.MediaFile(ctx).Put(&matchedTrack) + _ = ds.MediaFile().Put(ctx, &missingTrack) + _ = ds.MediaFile().Put(ctx, &matchedTrack) }) When("album ID changes during cross-library move", func() { @@ -1033,7 +1033,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(err).ToNot(HaveOccurred()) // Verify that the track was still moved (ID should be updated) - movedTrack, err := ds.MediaFile(ctx).Get(missingTrack.ID) + movedTrack, err := ds.MediaFile().Get(ctx, missingTrack.ID) Expect(err).ToNot(HaveOccurred()) Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) }) diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go index 964ab7408..58ad9cb80 100644 --- a/scanner/phase_3_refresh_albums.go +++ b/scanner/phase_3_refresh_albums.go @@ -26,7 +26,7 @@ import ( // 5. As a last step, it refreshes the artist statistics to reflect the changes type phaseRefreshAlbums struct { ds model.DataStore - ctx context.Context + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx refreshed atomic.Uint32 skipped atomic.Uint32 state *scanState @@ -47,7 +47,7 @@ func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] { func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error { count := 0 for _, lib := range p.state.libraries { - cursor, err := p.ds.Album(p.ctx).GetTouchedAlbums(lib.ID) + cursor, err := p.ds.Album().GetTouchedAlbums(p.ctx, lib.ID) if err != nil { return fmt.Errorf("loading touched albums: %w", err) } @@ -76,7 +76,7 @@ func (p *phaseRefreshAlbums) stages() []ppl.Stage[*model.Album] { } func (p *phaseRefreshAlbums) filterUnmodified(album *model.Album) (*model.Album, error) { - mfs, err := p.ds.MediaFile(p.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": album.ID}}) + mfs, err := p.ds.MediaFile().GetAll(p.ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": album.ID}}) if err != nil { log.Error(p.ctx, "Error loading media files for album", "album_id", album.ID, err) return nil, err @@ -104,7 +104,7 @@ func (p *phaseRefreshAlbums) refreshAlbum(album *model.Album) (*model.Album, err } start := time.Now() err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Album(ctx).Put(album) + return tx.Album().Put(ctx, album) }, "scanner: refresh album") log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start), err) if err != nil { @@ -135,7 +135,7 @@ func (p *phaseRefreshAlbums) finalize(err error) error { var cnt int64 err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { var txErr error - cnt, txErr = tx.Album(ctx).RefreshPlayCounts() + cnt, txErr = tx.Album().RefreshPlayCounts(ctx) return txErr }, "scanner: refresh album play counts") if err != nil { @@ -147,7 +147,7 @@ func (p *phaseRefreshAlbums) finalize(err error) error { start = time.Now() err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { var txErr error - cnt, txErr = tx.Artist(ctx).RefreshPlayCounts() + cnt, txErr = tx.Artist().RefreshPlayCounts(ctx) return txErr }, "scanner: refresh artist play counts") if err != nil { diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go index 1f0baf428..2743da070 100644 --- a/scanner/phase_3_refresh_albums_test.go +++ b/scanner/phase_3_refresh_albums_test.go @@ -104,7 +104,7 @@ var _ = Describe("phaseRefreshAlbums", func() { Describe("refreshAlbum", func() { It("refreshes the album in the database", func() { - Expect(albumRepo.CountAll()).To(Equal(int64(0))) + Expect(albumRepo.CountAll(ctx)).To(Equal(int64(0))) album := &model.Album{ID: "album1", Name: "Album 1"} result, err := phase.refreshAlbum(album) @@ -112,7 +112,7 @@ var _ = Describe("phaseRefreshAlbums", func() { Expect(result).ToNot(BeNil()) Expect(result.ID).To(Equal("album1")) - savedAlbum, err := albumRepo.Get("album1") + savedAlbum, err := albumRepo.Get(ctx, "album1") Expect(err).ToNot(HaveOccurred()) Expect(savedAlbum).ToNot(BeNil()) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index 4e11fa81d..bb67c1ba3 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -19,7 +19,7 @@ import ( ) type phasePlaylists struct { - ctx context.Context + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx scanState *scanState ds model.DataStore pls playlists.Playlists @@ -53,7 +53,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { // Resolve the admin at phase time (the producer runs late in the scan), so an // admin created while the scan was in progress is picked up. Assigned once, // before any put() below, so the channel send synchronizes it with the stages. - admin, err := p.ds.User(p.ctx).FindFirstAdmin() + admin, err := p.ds.User().FindFirstAdmin(p.ctx) if err != nil && !errors.Is(err, model.ErrNotFound) { return fmt.Errorf("finding admin user: %w", err) } @@ -71,9 +71,9 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { p.pendingImport = pending var cursor model.FolderCursor if p.pendingImport { - cursor, err = p.ds.Folder(p.ctx).GetAllWithPlaylists() + cursor, err = p.ds.Folder().GetAllWithPlaylists(p.ctx) } else { - cursor, err = p.ds.Folder(p.ctx).GetTouchedWithPlaylists() + cursor, err = p.ds.Folder().GetTouchedWithPlaylists(p.ctx) } if err != nil { return fmt.Errorf("loading folders with playlists: %w", err) @@ -102,7 +102,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { // the scan does not complete as successful without recording the recovery). func (p *phasePlaylists) deferImport() error { err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Property(ctx).Put(consts.PlaylistsImportPendingFlagKey, "1") + return tx.Property().Put(ctx, consts.PlaylistsImportPendingFlagKey, "1") }, "scanner: defer playlist import") if err != nil { return fmt.Errorf("recording pending playlist import: %w", err) @@ -113,7 +113,7 @@ func (p *phasePlaylists) deferImport() error { } func (p *phasePlaylists) importPending() (bool, error) { - v, err := p.ds.Property(p.ctx).DefaultGet(consts.PlaylistsImportPendingFlagKey, "0") + v, err := p.ds.Property().DefaultGet(p.ctx, consts.PlaylistsImportPendingFlagKey, "0") return v == "1", err } @@ -150,7 +150,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. } item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} - if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil { + if err := p.ds.ArtworkQueue().Enqueue(p.ctx, item); err != nil { log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) } p.refreshed.Add(1) @@ -167,7 +167,7 @@ func (p *phasePlaylists) finalize(err error) error { p.scanState.changesDetected.Store(true) } if p.pendingImport && err == nil { - if derr := p.ds.Property(p.ctx).Delete(consts.PlaylistsImportPendingFlagKey); derr != nil { + if derr := p.ds.Property().Delete(p.ctx, consts.PlaylistsImportPendingFlagKey); derr != nil { log.Warn(p.ctx, "Scanner: Could not clear pending playlist-import flag", derr) } } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 93ec1a36d..303af338f 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -38,7 +38,7 @@ var _ = Describe("phasePlaylists", func() { folderRepo = &mockFolderRepository{} userRepo = tests.CreateMockUserRepo() // An admin user exists by default, so playlist import proceeds. - Expect(userRepo.Put(&model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) + Expect(userRepo.Put(ctx, &model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{ MockedFolder: folderRepo, @@ -102,7 +102,7 @@ var _ = Describe("phasePlaylists", func() { Expect(err).ToNot(HaveOccurred()) Expect(called).To(BeFalse()) - v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) Expect(v).To(Equal("1")) }) @@ -113,7 +113,7 @@ var _ = Describe("phasePlaylists", func() { Expect(err).To(MatchError(ContainSubstring("finding admin user"))) // Must NOT have set the pending flag on a real error. - _, getErr := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + _, getErr := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) Expect(getErr).To(HaveOccurred()) }) @@ -127,7 +127,7 @@ var _ = Describe("phasePlaylists", func() { }) It("imports all playlist folders when the pending flag is set", func() { - Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) folderRepo.SetAllData(map[*model.Folder]error{ {Path: "/path/to/folder1"}: nil, {Path: "/path/to/folder2"}: nil, @@ -146,22 +146,22 @@ var _ = Describe("phasePlaylists", func() { Describe("finalize", func() { It("clears the pending flag after a successful pending import", func() { - Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) phase.pendingImport = true Expect(phase.finalize(nil)).To(Succeed()) - _, err := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + _, err := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) Expect(err).To(HaveOccurred()) // deleted }) It("keeps the pending flag when the import failed", func() { - Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) phase.pendingImport = true Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred()) - v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) Expect(v).To(Equal("1")) }) }) @@ -204,7 +204,7 @@ var _ = Describe("phasePlaylists", func() { _, err := phase.processPlaylistsInFolder(folder) Expect(err).ToNot(HaveOccurred()) - queued, err := ds.ArtworkQueue(ctx).DequeueBatch(10) + queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 10) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "pl"), @@ -264,11 +264,11 @@ func cursorFromData(data map[*model.Folder]error) model.FolderCursor { } } -func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { +func (f *mockFolderRepository) GetTouchedWithPlaylists(context.Context) (model.FolderCursor, error) { return cursorFromData(f.data), nil } -func (f *mockFolderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { +func (f *mockFolderRepository) GetAllWithPlaylists(context.Context) (model.FolderCursor, error) { return cursorFromData(f.allData), nil } diff --git a/scanner/scanner.go b/scanner/scanner.go index a8a192771..cd2fe3c8d 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -88,7 +88,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] } // Get libraries and optionally filter by targets - allLibs, err := s.ds.Library(ctx).GetAll() + allLibs, err := s.ds.Library().GetAll(ctx) if err != nil { state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) return @@ -131,8 +131,8 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] if state.isSelectiveScan() { scanType += "-selective" } - _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType) - _ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) + _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, scanType) + _ = s.ds.Property().Put(ctx, consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) // if there was a full scan in progress, force a full scan if !state.fullScan { @@ -141,9 +141,9 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) state.fullScan = true if state.isSelectiveScan() { - _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective") + _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, "full-selective") } else { - _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full") + _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, "full") } break } @@ -190,12 +190,12 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] ) if err != nil { log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) - _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error()) + _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, err.Error()) state.sendError(err) return } - _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "") + _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, "") if state.changesDetected.Load() { state.sendProgress(&ProgressInfo{ChangesDetected: true}) @@ -218,7 +218,7 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt if lib.LastScanStartedAt.IsZero() { // This is a new scan - mark it as started err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Library(ctx).ScanBegin(lib.ID, state.fullScan) + return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan) }, "scanner: begin library scan") if err != nil { log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) @@ -227,7 +227,7 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt } // Reload library to get updated state (timestamps, etc.) - reloadedLib, err := s.ds.Library(ctx).Get(lib.ID) + reloadedLib, err := s.ds.Library().Get(ctx, lib.ID) if err != nil { log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err) state.sendWarning(err.Error()) @@ -291,7 +291,7 @@ func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanS var n int64 err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { var err error - n, err = tx.ArtworkQueue(ctx).EnqueueAllMissing(kind, model.ArtworkPriorityScan) + n, err = tx.ArtworkQueue().EnqueueAllMissing(ctx, kind, model.ArtworkPriorityScan) return err }, "scanner: enqueue missing artwork") if err != nil { @@ -312,7 +312,7 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun return nil } start := time.Now() - stats, err := s.ds.Artist(ctx).RefreshStats(state.fullScan) + stats, err := s.ds.Artist().RefreshStats(ctx, state.fullScan) if err != nil { log.Error(ctx, "Scanner: Error refreshing artists stats", err) return fmt.Errorf("refreshing artists stats: %w", err) @@ -321,7 +321,7 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun start = time.Now() err = s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Tag(ctx).UpdateCounts() + return tx.Tag().UpdateCounts(ctx) }, "scanner: update tag counts") if err != nil { log.Error(ctx, "Scanner: Error updating tag counts", err) @@ -337,18 +337,18 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) start := time.Now() return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { for _, lib := range state.libraries { - if err := tx.Library(ctx).ScanEnd(lib.ID); err != nil { + if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) } - if err := tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track); err != nil { + if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil { return fmt.Errorf("updating track PID conf: %w", err) } - if err := tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album); err != nil { + if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil { return fmt.Errorf("updating album PID conf: %w", err) } if state.changesDetected.Load() { log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) - if err := tx.Library(ctx).RefreshStats(lib.ID); err != nil { + if err := tx.Library().RefreshStats(ctx, lib.ID); err != nil { return fmt.Errorf("refreshing library stats for %s: %w", lib.Name, err) } } else { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 65410d500..e6797df36 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -82,7 +82,7 @@ func BenchmarkScan(b *testing.B) { }) lib := model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - err := ds.Library(context.Background()).Put(&lib) + err := ds.Library().Put(b.Context(), &lib) if err != nil { b.Fatal(err) } diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index c0d5d4ece..546baf756 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -75,7 +75,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { IsAdmin: true, NewPassword: "password", } - Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) s = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) @@ -83,8 +83,8 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"} - Expect(ds.Library(ctx).Put(&lib1)).To(Succeed()) - Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) + Expect(ds.Library().Put(ctx, &lib1)).To(Succeed()) + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) }) runScanner := func(ctx context.Context, fullScan bool) error { @@ -122,7 +122,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library media files - rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, Sort: "title", }) @@ -138,7 +138,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } // Check Jazz library media files - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, Sort: "title", }) @@ -158,7 +158,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library albums - rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, Sort: "name", }) @@ -172,7 +172,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(rockAlbums[1].SongCount).To(Equal(2)) // Check Jazz library albums - jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, Sort: "name", }) @@ -190,7 +190,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library folders - rockFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + rockFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -201,7 +201,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } // Check Jazz library folders - jazzFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + jazzFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -218,7 +218,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { // Check library-artist associations // Get all artists and check library associations - allArtists, err := ds.Artist(ctx).GetAll() + allArtists, err := ds.Artist().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) rockArtistNames := []string{} @@ -262,7 +262,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library stats - rockLib, err := ds.Library(ctx).Get(lib1.ID) + rockLib, err := ds.Library().Get(ctx, lib1.ID) Expect(err).ToNot(HaveOccurred()) Expect(rockLib.TotalSongs).To(Equal(4)) Expect(rockLib.TotalAlbums).To(Equal(2)) @@ -271,7 +271,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files) // Check Jazz library stats - jazzLib, err := ds.Library(ctx).Get(lib2.ID) + jazzLib, err := ds.Library().Get(ctx, lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(4)) Expect(jazzLib.TotalAlbums).To(Equal(2)) @@ -285,25 +285,25 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify rock library stats - rockLib, err := ds.Library(ctx).Get(lib1.ID) + rockLib, err := ds.Library().Get(ctx, lib1.ID) Expect(err).ToNot(HaveOccurred()) Expect(rockLib.TotalSongs).To(Equal(4)) Expect(rockLib.TotalAlbums).To(Equal(2)) // Verify jazz library stats - jazzLib, err := ds.Library(ctx).Get(lib2.ID) + jazzLib, err := ds.Library().Get(ctx, lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(4)) Expect(jazzLib.TotalAlbums).To(Equal(2)) // Verify that libraries don't interfere with each other - rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockFiles).To(HaveLen(4)) - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -316,7 +316,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify that rock library only contains rock content - rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -325,7 +325,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps")) // Verify that jazz library only contains jazz content - jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -365,7 +365,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { var rockCount, jazzCount int64 // Get Jeff Beck artist ID - jeffArtists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + jeffArtists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "Jeff Beck"}, }) Expect(err).ToNot(HaveOccurred()) @@ -389,14 +389,14 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(jazzCount).To(Equal(int64(1))) // Verify Jeff Beck albums are in correct libraries - rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockAlbums).To(HaveLen(1)) Expect(rockAlbums[0].Name).To(Equal("Truth")) - jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"}, }) Expect(err).ToNot(HaveOccurred()) @@ -426,13 +426,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify initial state - rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockFiles).To(HaveLen(1)) - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -442,13 +442,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Verify counts remain the same - rockFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockFiles).To(HaveLen(1)) - jazzFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -485,7 +485,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Check that only the rock library file is marked as missing - missingRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + missingRockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib1.ID}, squirrel.Eq{"missing": true}, @@ -496,7 +496,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill")) // Check that jazz library files are not affected - missingJazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + missingJazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib2.ID}, squirrel.Eq{"missing": true}, @@ -506,7 +506,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(missingJazzFiles).To(HaveLen(0)) // Verify non-missing files - presentRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + presentRockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib1.ID}, squirrel.Eq{"missing": false}, @@ -548,7 +548,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors") // Jazz library should have been scanned successfully - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -557,7 +557,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader")) // Rock library may have partial content (depending on scanner implementation) - rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -565,12 +565,12 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { _ = rockFiles // Verify jazz library stats are correct - jazzLib, err := ds.Library(ctx).Get(lib2.ID) + jazzLib, err := ds.Library().Get(ctx, lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(2)) // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -586,20 +586,20 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors") // Jazz library should be completely unaffected - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(jazzFiles).To(HaveLen(2)) // Jazz library statistics should be accurate - jazzLib, err := ds.Library(ctx).Get(lib2.ID) + jazzLib, err := ds.Library().Get(ctx, lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(2)) Expect(jazzLib.TotalAlbums).To(Equal(1)) // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -623,7 +623,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { It("should propagate database errors and stop scanning", func() { // Install mock repo that injects DB error mfRepo := &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(ctx), + MediaFileRepository: ds.RealDS.MediaFile(), GetMissingAndMatchingError: errors.New("database connection failed"), } ds.MockedMediaFile = mfRepo @@ -632,7 +632,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed"))) // Error should be recorded in scanner properties - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(ContainSubstring("database connection failed")) }) @@ -640,7 +640,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { It("should preserve error information in scanner properties", func() { // Install mock repo that injects DB error mfRepo := &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(ctx), + MediaFileRepository: ds.RealDS.MediaFile(), GetMissingAndMatchingError: errors.New("critical database error"), } ds.MockedMediaFile = mfRepo @@ -649,12 +649,12 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(HaveOccurred()) // Check that error is recorded in scanner properties - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(ContainSubstring("critical database error")) // Scan type should still be recorded - scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") Expect(scanType).To(BeElementOf("incremental", "quick")) }) }) @@ -687,7 +687,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error") // Jazz library should scan completely successfully - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -695,13 +695,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(jazzFiles[0].Title).To(Equal("Chameleon")) // Jazz library statistics should be accurate - jazzLib, err := ds.Library(ctx).Get(lib2.ID) + jazzLib, err := ds.Library().Get(ctx, lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(1)) Expect(jazzLib.TotalAlbums).To(Equal(1)) // Rock library may have partial content (depending on scanner implementation) - rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -709,7 +709,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { _ = rockFiles // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -724,22 +724,22 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption") // Verify that the working parts completed successfully - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(jazzFiles).To(HaveLen(1)) // Scanner properties should reflect successful completion despite warnings - scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") Expect(scanType).To(Equal("full")) // Start time should be recorded - startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + startTimeStr, _ := ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") Expect(startTimeStr).ToNot(BeEmpty()) // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -780,30 +780,30 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery") // Verify both libraries now have content (at least jazz should work) - rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) // The scanner should recover and import both rock files Expect(len(rockFiles)).To(Equal(2)) - jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(jazzFiles).To(HaveLen(1)) // Both libraries should have correct content counts - rockLib, err := ds.Library(ctx).Get(lib1.ID) + rockLib, err := ds.Library().Get(ctx, lib1.ID) Expect(err).ToNot(HaveOccurred()) Expect(rockLib.TotalSongs).To(Equal(2)) - jazzLib, err := ds.Library(ctx).Get(lib2.ID) + jazzLib, err := ds.Library().Get(ctx, lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(1)) // Error should be empty (successful recovery) - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -822,15 +822,15 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Validate properties - scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") Expect(scanType).To(Equal("full")) - startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + startTimeStr, _ := ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") Expect(startTimeStr).ToNot(BeEmpty()) _, err := time.Parse(time.RFC3339, startTimeStr) Expect(err).ToNot(HaveOccurred()) - lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index acaa8f850..2f27b74ce 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -63,13 +63,13 @@ var _ = Describe("ScanFolders", Ordered, func() { IsAdmin: true, NewPassword: "password", } - Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) s = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) // Initialize fake filesystem fsys = storagetest.FakeFS{} @@ -101,7 +101,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(warnings).To(BeEmpty()) // Verify all tracks in rock and jazz folders (including subdirectories) were imported - allFiles, err := ds.MediaFile(ctx).GetAll() + allFiles, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) // Should have 5 tracks (all rock and jazz tracks including subdirectories) @@ -123,7 +123,7 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) - Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1")) + Expect(ds.Property().Get(ctx, consts.DBAnalyzePendingKey)).To(Equal("1")) }) }) @@ -135,26 +135,26 @@ var _ = Describe("ScanFolders", Ordered, func() { }) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + Expect(ds.Property().Get(ctx, consts.DBAnalyzePendingKey)).To(Equal("0")) fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + Expect(ds.Property().Get(ctx, consts.DBAnalyzePendingKey)).To(Equal("0")) }) It("does not treat an interrupted scan in an untargeted library as a full scan", func() { otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} - Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) - Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed()) + Expect(ds.Library().Put(ctx, &otherLib)).To(Succeed()) + Expect(ds.Library().ScanBegin(ctx, lib.ID, true)).To(Succeed()) lastAnalyze := "2026-07-09T12:00:00Z" - Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) - Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.DBAnalyzePendingKey, "0")).To(Succeed()) _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) + Expect(ds.Property().Get(ctx, consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) @@ -187,7 +187,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify initial state - all folders exist - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) Expect(err).ToNot(HaveOccurred()) Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2 @@ -204,7 +204,7 @@ var _ = Describe("ScanFolders", Ordered, func() { } // Verify all tracks exist - allTracks, err := ds.MediaFile(ctx).GetAll() + allTracks, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(allTracks).To(HaveLen(4)) @@ -239,29 +239,29 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify the deleted child folder is now marked as missing - deletedFolder, err := ds.Folder(ctx).Get(album2FolderID) + deletedFolder, err := ds.Folder().Get(ctx, album2FolderID) Expect(err).ToNot(HaveOccurred()) Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing") // Verify the deleted folder's tracks are marked as missing for _, trackID := range album2TrackIDs { - track, err := ds.MediaFile(ctx).Get(trackID) + track, err := ds.MediaFile().Get(ctx, trackID) Expect(err).ToNot(HaveOccurred()) Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing") } // Verify the parent folder is still present and not marked as missing - parentFolder, err := ds.Folder(ctx).Get(artistFolderID) + parentFolder, err := ds.Folder().Get(ctx, artistFolderID) Expect(err).ToNot(HaveOccurred()) Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing") // Verify the sibling folder and its tracks are still present and not missing - siblingFolder, err := ds.Folder(ctx).Get(album1FolderID) + siblingFolder, err := ds.Folder().Get(ctx, album1FolderID) Expect(err).ToNot(HaveOccurred()) Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing") for _, trackID := range album1TrackIDs { - track, err := ds.MediaFile(ctx).Get(trackID) + track, err := ds.MediaFile().Get(ctx, trackID) Expect(err).ToNot(HaveOccurred()) Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing") } @@ -283,7 +283,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify nested folders were created - allFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + allFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) Expect(err).ToNot(HaveOccurred()) Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure") @@ -301,7 +301,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify all Help! folders (including nested ones) are marked as missing - missingFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + missingFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib.ID}, squirrel.Eq{"missing": true}, @@ -311,7 +311,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing") // Verify all tracks in deleted folders are marked as missing - allTracks, err := ds.MediaFile(ctx).GetAll() + allTracks, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(allTracks).To(HaveLen(6)) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 30f4a2b97..4ce8cce1e 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -78,7 +78,7 @@ var _ = Describe("Scanner", Ordered, func() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} mfRepo = &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(ctx), + MediaFileRepository: ds.RealDS.MediaFile(), } ds.MockedMediaFile = mfRepo @@ -90,13 +90,13 @@ var _ = Describe("Scanner", Ordered, func() { IsAdmin: true, NewPassword: "password", } - Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) s = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) }) runScanner := func(ctx context.Context, fullScan bool) error { @@ -108,14 +108,14 @@ var _ = Describe("Scanner", Ordered, func() { // so a later scan can only queue genuine reprocessing. resolveQueuedArtwork := func() []model.ArtworkQueueItem { GinkgoHelper() - queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) for _, it := range queued { - Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: it.ItemKind, ItemID: it.ItemID, ImageType: it.ImageType, Hash: "resolved", Source: "embedded", UpdatedAt: time.Now(), })).To(Succeed()) - Expect(ds.ArtworkQueue(ctx).DeleteIfUnchanged(it.ItemKind, it.ItemID, it.ImageType, it.RetryAt)).To(Succeed()) + Expect(ds.ArtworkQueue().DeleteIfUnchanged(ctx, it.ItemKind, it.ItemID, it.ImageType, it.RetryAt)).To(Succeed()) } return queued } @@ -140,7 +140,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import all folders", func() { Expect(runScanner(ctx, true)).To(Succeed()) - folders, _ := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + folders, _ := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) paths := slice.Map(folders, func(f model.Folder) string { return f.Name }) Expect(paths).To(SatisfyAll( HaveLen(4), @@ -150,7 +150,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import all mediafiles", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mfs, _ := ds.MediaFile(ctx).GetAll() + mfs, _ := ds.MediaFile().GetAll(ctx) paths := slice.Map(mfs, func(f model.MediaFile) string { return f.Title }) Expect(paths).To(SatisfyAll( HaveLen(7), @@ -163,7 +163,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import all albums", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, _ := ds.Album(ctx).GetAll(model.QueryOptions{Sort: "name"}) + albums, _ := ds.Album().GetAll(ctx, model.QueryOptions{Sort: "name"}) Expect(albums).To(HaveLen(2)) Expect(albums[0]).To(SatisfyAll( HaveField("Name", Equal("Help!")), @@ -177,9 +177,9 @@ var _ = Describe("Scanner", Ordered, func() { It("should enqueue artwork resolution for the scanned albums and artists", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, _ := ds.Album(ctx).GetAll() - artists, _ := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) - queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + albums, _ := ds.Album().GetAll(ctx) + artists, _ := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) + queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) for _, al := range albums { @@ -204,7 +204,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) - requeued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + requeued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(requeued).To(BeEmpty()) }) @@ -213,14 +213,14 @@ var _ = Describe("Scanner", Ordered, func() { It("should update the media_file", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + mf, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(mf[0].Tags).ToNot(HaveKey("barcode")) fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"barcode": "123"}) Expect(runScanner(ctx, true)).To(Succeed()) - mf, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + mf, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(mf[0].Tags).To(HaveKeyWithValue(model.TagName("barcode"), []string{"123"})) }) @@ -234,9 +234,9 @@ var _ = Describe("Scanner", Ordered, func() { fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) Expect(runScanner(ctx, false)).To(Succeed()) - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) Expect(err).ToNot(HaveOccurred()) - requeued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + requeued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(requeued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "al"), @@ -248,7 +248,7 @@ var _ = Describe("Scanner", Ordered, func() { tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") Expect(runScanner(ctx, true)).To(Succeed()) - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) Expect(albums[0].Participants.First(model.RoleProducer).Name).To(BeEmpty()) @@ -257,7 +257,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) Expect(runScanner(ctx, false)).To(Succeed()) - albums, err = ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + albums, err = ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) Expect(albums[0].SongCount).To(Equal(3)) @@ -266,12 +266,12 @@ var _ = Describe("Scanner", Ordered, func() { It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + mf, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(mf).ToNot(BeEmpty()) trackID := mf[0].ID - Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, Source: "embedded", Hash: "stalehash", })).To(Succeed()) @@ -279,7 +279,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) Expect(runScanner(ctx, true)).To(Succeed()) - _, err = ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, trackID, model.ImageTypePrimary) + _, err = ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, trackID, model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -291,21 +291,21 @@ var _ = Describe("Scanner", Ordered, func() { albumID := func(name string) string { GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(1)) return albums[0].ID } artistID := func(name string) string { GinkgoHelper() - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}}) + artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}}) Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(1)) return artists[0].ID } queuedItems := func() []model.ArtworkQueueItem { GinkgoHelper() - queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) return queued } @@ -464,7 +464,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should not import the ignored file", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(1)) for _, mf := range mfs { @@ -486,11 +486,11 @@ var _ = Describe("Scanner", Ordered, func() { It("should import as one album", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, err := ds.Album(ctx).GetAll() + albums, err := ds.Album().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(1)) - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) for _, mf := range mfs { @@ -512,7 +512,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import as two distinct albums", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Sort: "release_date"}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Sort: "release_date"}) Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(2)) Expect(albums[0]).To(SatisfyAll( @@ -551,7 +551,7 @@ var _ = Describe("Scanner", Ordered, func() { By("Doing a full scan") Expect(runScanner(ctx, true)).To(Succeed()) - Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(4))) + Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) findByPath = createFindByPath(ctx, ds) }) @@ -559,7 +559,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping"))) Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(5))) + Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(5))) mf, err := findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") Expect(err).ToNot(HaveOccurred()) Expect(mf.Title).To(Equal("I'm Only Sleeping")) @@ -569,7 +569,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.UpdateTags("The Beatles/Revolver/02 - Eleanor Rigby.mp3", _t{"title": "Eleanor Rigby (remix)"}) Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(4))) + Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) mf, _ := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") Expect(mf.Title).To(Equal("Eleanor Rigby (remix)")) }) @@ -578,7 +578,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.Add("The Beatles/Revolver/01 - Taxman.mp3", revolver(track(1, "Taxman", _t{"bitrate": 640}))) Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(4))) + Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) mf, _ := findByPath("The Beatles/Revolver/01 - Taxman.mp3") Expect(mf.BitRate).To(Equal(640)) }) @@ -591,7 +591,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file is marked as missing") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(3))) mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") @@ -612,14 +612,14 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the old file is not in the library") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") Expect(err).To(MatchError(model.ErrNotFound)) By("Checking the new file is in the library") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, })).To(BeZero()) mf, err := findByPath("The Beatles/Help!/02 - Eleanor Rigby.mp3") @@ -641,7 +641,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("I/O read error"))) By("Checking the both instances of the file are in the lib") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Taxman"}, })).To(Equal(int64(2))) @@ -650,7 +650,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the old file is not in the library") - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Taxman"}, }) Expect(err).ToNot(HaveOccurred()) @@ -671,14 +671,14 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the old file is not in the library") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, })).To(BeZero()) _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") Expect(err).To(MatchError(model.ErrNotFound)) By("Checking the new file is in the library") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.flac") @@ -698,7 +698,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file is marked as missing") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(3))) mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") @@ -712,7 +712,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file is not marked as missing") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") @@ -737,7 +737,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file was found in the new folder") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) mf, err = findByPath("The Beatles/Help!/02 - Eleanor Rigby.mp3") @@ -751,7 +751,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Verifying initial state has 5 tracks") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(5))) @@ -790,7 +790,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf.Missing).To(BeFalse()) By("Verifying only 2 non-missing tracks remain (Help! tracks)") - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(2))) }) @@ -815,7 +815,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) nonMissingArtists := func() []string { - aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"missing": false}}) + aa, err := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": false}}) Expect(err).ToNot(HaveOccurred()) return slice.Map(aa, func(a model.Artist) string { return a.Name }) } @@ -860,7 +860,7 @@ var _ = Describe("Scanner", Ordered, func() { It("does not override artist fields when importing an undertagged file", func() { By("Making sure artist in the DB contains MBID and sort name") - aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + aa, err := ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -887,7 +887,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf.SortArtistName).To(BeEmpty()) By("Makingsure the artist in the DB has not changed") - aa, err = ds.Artist(ctx).GetAll(model.QueryOptions{ + aa, err = ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -915,7 +915,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) By("Checking files are marked as missing but not deleted") - count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -943,7 +943,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking missing files are deleted") - count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -970,7 +970,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking files are marked as missing but not deleted") - count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -992,7 +992,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) By("Checking missing files are deleted") - count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -1020,10 +1020,10 @@ var _ = Describe("Scanner", Ordered, func() { simulateInterruptedScan := func(fullScan bool) { // Call ScanBegin to properly set LastScanStartedAt and FullScanInProgress // This simulates what would happen if a scan was interrupted (ScanBegin called but ScanEnd not) - Expect(ds.Library(ctx).ScanBegin(lib.ID, fullScan)).To(Succeed()) + Expect(ds.Library().ScanBegin(ctx, lib.ID, fullScan)).To(Succeed()) // Verify the update was persisted - reloaded, err := ds.Library(ctx).Get(lib.ID) + reloaded, err := ds.Library().Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(reloaded.LastScanStartedAt).ToNot(BeZero()) Expect(reloaded.FullScanInProgress).To(Equal(fullScan)) @@ -1035,7 +1035,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify files were imported - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) @@ -1056,7 +1056,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify the comment was updated (which means the folder was processed and file re-imported) - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1071,7 +1071,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify files were imported - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) @@ -1090,7 +1090,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Verify the comment was updated (folder was processed despite unchanged hash) - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1105,12 +1105,12 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify files were imported - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) // Library should have LastScanStartedAt cleared after successful scan - updatedLib, err := ds.Library(ctx).Get(lib.ID) + updatedLib, err := ds.Library().Get(ctx, lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(updatedLib.LastScanStartedAt).To(BeZero()) Expect(updatedLib.FullScanInProgress).To(BeFalse()) @@ -1125,7 +1125,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify the comment was updated - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1144,7 +1144,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Verify the comment was NOT updated (folder was skipped) - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1163,7 +1163,7 @@ var _ = Describe("Scanner", Ordered, func() { refreshStatsCalls = nil // Create a mock artist repository that tracks RefreshStats calls - originalArtistRepo := ds.RealDS.Artist(ctx) + originalArtistRepo := ds.RealDS.Artist() ds.MockedArtist = &testArtistRepo{ ArtistRepository: originalArtistRepo, callTracker: &refreshStatsCalls, @@ -1209,7 +1209,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify initial artist stats - should have 1 album, 1 song - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1228,7 +1228,7 @@ var _ = Describe("Scanner", Ordered, func() { By("Verifying artist stats were updated correctly") // Fetch the artist again to check updated stats - artists, err = ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err = ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1277,8 +1277,8 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).ToNot(Succeed()) - Expect(ds.Folder(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) - Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) + Expect(ds.Folder().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) + Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) }) }) }) @@ -1298,7 +1298,7 @@ func (b *busyPersistDS) WithTxRetry(ctx context.Context, block func(context.Cont func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) { return func(path string) (*model.MediaFile, error) { - list, err := ds.MediaFile(ctx).FindByPaths([]string{path}) + list, err := ds.MediaFile().FindByPaths(ctx, []string{path}) if err != nil { return nil, err } @@ -1315,16 +1315,16 @@ type mockMediaFileRepo struct { cursorCalls atomic.Int32 } -func (m *mockMediaFileRepo) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) { +func (m *mockMediaFileRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { m.cursorCalls.Add(1) - return m.MediaFileRepository.GetCursor(options...) + return m.MediaFileRepository.GetCursor(ctx, options...) } -func (m *mockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { +func (m *mockMediaFileRepo) GetMissingAndMatching(ctx context.Context, libId int) (model.MediaFileCursor, error) { if m.GetMissingAndMatchingError != nil { return nil, m.GetMissingAndMatchingError } - return m.MediaFileRepository.GetMissingAndMatching(libId) + return m.MediaFileRepository.GetMissingAndMatching(ctx, libId) } type testArtistRepo struct { @@ -1332,7 +1332,7 @@ type testArtistRepo struct { callTracker *[]bool } -func (m *testArtistRepo) RefreshStats(allArtists bool) (int64, error) { +func (m *testArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { *m.callTracker = append(*m.callTracker, allArtists) - return m.ArtistRepository.RefreshStats(allArtists) + return m.ArtistRepository.RefreshStats(ctx, allArtists) } diff --git a/scanner/watcher.go b/scanner/watcher.go index baf94b79b..1ac5468f0 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -23,7 +23,7 @@ type Watcher interface { } type watcher struct { - mainCtx context.Context + mainCtx context.Context //nolint:containedctx // watcher lifecycle ctx ds model.DataStore scanner model.Scanner triggerWait time.Duration @@ -60,7 +60,7 @@ func (w *watcher) Run(ctx context.Context) error { w.mainCtx = ctx // Start watchers for all existing libraries - libs, err := w.ds.Library(ctx).GetAll() + libs, err := w.ds.Library().GetAll(ctx) if err != nil { return fmt.Errorf("getting libraries: %w", err) } diff --git a/server/auth.go b/server/auth.go index 2aaa93e63..3e58359da 100644 --- a/server/auth.go +++ b/server/auth.go @@ -49,7 +49,7 @@ func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { } func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { - user, err := validateLogin(ds.User(r.Context()), username, password) + user, err := validateLogin(r.Context(), ds.User(), username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again") return @@ -127,7 +127,7 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - c, err := ds.User(r.Context()).CountAll() + c, err := ds.User().CountAll(r.Context()) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) return @@ -157,7 +157,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password IsAdmin: true, LastLoginAt: new(time.Now()), } - err := ds.User(ctx).Put(&initialUser) + err := ds.User().Put(ctx, &initialUser) if err != nil { log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err) return fmt.Errorf("creating initial user: %w", err) @@ -165,8 +165,8 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password return nil } -func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) { - u, err := userRepo.FindByUsernameWithPassword(userName) +func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) { + u, err := userRepo.FindByUsernameWithPassword(ctx, userName) if errors.Is(err, model.ErrNotFound) { return nil, nil } @@ -176,9 +176,9 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m if u.Password != password { return nil, nil } - err = userRepo.UpdateLastLoginAt(u.ID) + err = userRepo.UpdateLastLoginAt(ctx, u.ID) if err != nil { - log.Error("Could not update LastLoginAt", "user", userName) + log.Error(ctx, "Could not update LastLoginAt", "user", userName) } return u, nil } @@ -244,7 +244,7 @@ func UsernameFromConfig(*http.Request) string { } func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) { - user, err := ds.User(ctx).FindByUsername(username) + user, err := ds.User().FindByUsername(ctx, username) if err == nil { ctx = log.NewContext(ctx, "username", username) ctx = request.WithUsername(ctx, user.UserName) @@ -309,7 +309,7 @@ func tokenAllowed(ctx context.Context) bool { // epoch the handler bumped reaches the token the client stores. type refreshingWriter struct { http.ResponseWriter - ctx context.Context + ctx context.Context //nolint:containedctx // ResponseWriter wrapper defers work to Write, which has no ctx token jwt.Token once sync.Once } @@ -377,12 +377,13 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any } } - userRepo := ds.User(r.Context()) - user, err := userRepo.FindByUsernameWithPassword(username) + ctx := r.Context() + userRepo := ds.User() + user, err := userRepo.FindByUsernameWithPassword(ctx, username) if user == nil || err != nil { log.Info(r, "User passed in header not found", "user", username) // Check if this is the first user being created - count, _ := userRepo.CountAll() + count, _ := userRepo.CountAll(ctx) isFirstUser := count == 0 newUser := model.User{ @@ -393,19 +394,19 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(), IsAdmin: isFirstUser, // Make the first user an admin } - err := userRepo.Put(&newUser) + err := userRepo.Put(ctx, &newUser) if err != nil { log.Error(r, "Could not create new user", "user", username, err) return nil } - user, err = userRepo.FindByUsernameWithPassword(username) + user, err = userRepo.FindByUsernameWithPassword(ctx, username) if user == nil || err != nil { log.Error(r, "Created user but failed to fetch it", "user", username) return nil } } - err = userRepo.UpdateLastLoginAt(user.ID) + err = userRepo.UpdateLastLoginAt(ctx, user.ID) if err != nil { log.Error(r, "Could not update LastLoginAt", "user", username, err) return nil diff --git a/server/auth_test.go b/server/auth_test.go index abe144a12..1095fafc9 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -28,6 +28,12 @@ import ( ) var _ = Describe("Auth", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + Describe("User login", func() { var ds model.DataStore var req *http.Request @@ -48,8 +54,8 @@ var _ = Describe("Auth", func() { }) It("creates an admin user with the specified password", func() { - usr := ds.User(context.Background()) - u, err := usr.FindByUsername("johndoe") + usr := ds.User() + u, err := usr.FindByUsername(ctx, "johndoe") Expect(err).To(BeNil()) Expect(u.Password).ToNot(BeEmpty()) Expect(u.IsAdmin).To(BeTrue()) @@ -99,8 +105,8 @@ var _ = Describe("Auth", func() { fs := os.DirFS("tests/fixtures") BeforeEach(func() { - usr := ds.User(context.Background()) - _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + usr := ds.User() + _ = usr.Put(ctx, &model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) req = httptest.NewRequest("GET", "/index.html", nil) req.Header.Add("Remote-User", "janedoe") resp = httptest.NewRecorder() @@ -232,8 +238,8 @@ var _ = Describe("Auth", func() { }) It("logs in successfully if user exists", func() { - usr := ds.User(context.Background()) - _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + usr := ds.User() + _ = usr.Put(ctx, &model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) login(ds)(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) @@ -397,14 +403,14 @@ var _ = Describe("Auth", func() { Expect(result["isAdmin"]).To(BeTrue()) // Verify user was created as admin - u, err := ds.User(context.Background()).FindByUsername("firstuser") + u, err := ds.User().FindByUsername(ctx, "firstuser") Expect(err).To(BeNil()) Expect(u.IsAdmin).To(BeTrue()) }) It("does not make subsequent users admins", func() { // Create the first user - _ = ds.User(context.Background()).Put(&model.User{ + _ = ds.User().Put(ctx, &model.User{ ID: "existing-user-id", UserName: "existinguser", Name: "Existing User", @@ -419,7 +425,7 @@ var _ = Describe("Auth", func() { Expect(result["isAdmin"]).To(BeFalse()) // Verify user was created as non-admin - u, err := ds.User(context.Background()).FindByUsername("seconduser") + u, err := ds.User().FindByUsername(ctx, "seconduser") Expect(err).To(BeNil()) Expect(u.IsAdmin).To(BeFalse()) }) @@ -434,9 +440,9 @@ var _ = Describe("Auth", func() { conf.Server.SessionTimeout = time.Hour ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(context.TODO()).(*tests.MockedUserRepo) + ur := ds.User().(*tests.MockedUserRepo) usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2} - Expect(ur.Put(usr)).To(Succeed()) + Expect(ur.Put(ctx, usr)).To(Succeed()) }) serve := func(token string) *httptest.ResponseRecorder { diff --git a/server/events/sse.go b/server/events/sse.go index 565d8c016..e4d6a05e5 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -34,7 +34,7 @@ type ( id uint64 event string data string - senderCtx context.Context + senderCtx context.Context //nolint:containedctx // queued message carries the sender ctx } messageChan chan message clientsChan chan client diff --git a/server/initial_setup.go b/server/initial_setup.go index e75220abe..be9e14ae9 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -17,23 +17,23 @@ import ( func initialSetup(ds model.DataStore) { ctx := context.TODO() err := ds.WithTx(func(tx model.DataStore) error { - if err := tx.Library(ctx).StoreMusicFolder(); err != nil { + if err := tx.Library().StoreMusicFolder(ctx); err != nil { return err } - properties := tx.Property(ctx) - _, err := properties.Get(consts.InitialSetupFlagKey) + properties := tx.Property() + _, err := properties.Get(ctx, consts.InitialSetupFlagKey) if err == nil { return nil } log.Info("Running initial setup") if conf.Server.DevAutoCreateAdminPassword != "" { - if err = createInitialAdminUser(tx, conf.Server.DevAutoCreateAdminPassword); err != nil { + if err = createInitialAdminUser(ctx, tx, conf.Server.DevAutoCreateAdminPassword); err != nil { return err } } - err = properties.Put(consts.InitialSetupFlagKey, time.Now().String()) + err = properties.Put(ctx, consts.InitialSetupFlagKey, time.Now().String()) return err }, "initial setup") if err != nil { @@ -42,9 +42,9 @@ func initialSetup(ds model.DataStore) { } // If the Dev Admin user is not present, create it -func createInitialAdminUser(ds model.DataStore, initialPassword string) error { - users := ds.User(context.TODO()) - c, err := users.CountAll(model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) +func createInitialAdminUser(ctx context.Context, ds model.DataStore, initialPassword string) error { + users := ds.User() + c, err := users.CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) if err != nil { return fmt.Errorf("could not access User table: %w", err) } @@ -60,7 +60,7 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error { NewPassword: initialPassword, IsAdmin: true, } - if err := users.Put(&initialUser); err != nil { + if err := users.Put(ctx, &initialUser); err != nil { return fmt.Errorf("could not create initial admin user: %w", err) } } diff --git a/server/initial_setup_test.go b/server/initial_setup_test.go index 0ce8a39fa..0c85d9d0a 100644 --- a/server/initial_setup_test.go +++ b/server/initial_setup_test.go @@ -15,7 +15,7 @@ type failingPutUserRepo struct { err error } -func (r *failingPutUserRepo) Put(*model.User) error { return r.err } +func (r *failingPutUserRepo) Put(context.Context, *model.User) error { return r.err } func dsWithFailingPut(err error) model.DataStore { return &tests.MockDataStore{MockedUser: &failingPutUserRepo{UserRepository: tests.CreateMockUserRepo(), err: err}} @@ -23,37 +23,39 @@ func dsWithFailingPut(err error) model.DataStore { var _ = Describe("initial_setup", func() { var ds model.DataStore + var ctx context.Context BeforeEach(func() { ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() }) Describe("createInitialAdminUser", func() { It("creates a new admin user with specified password if User table is empty", func() { - Expect(createInitialAdminUser(ds, "pass123")).To(BeNil()) - ur := ds.User(context.TODO()) - admin, err := ur.FindByUsername("admin") + Expect(createInitialAdminUser(ctx, ds, "pass123")).To(BeNil()) + ur := ds.User() + admin, err := ur.FindByUsername(ctx, "admin") Expect(err).To(BeNil()) Expect(admin.Password).To(Equal("pass123")) }) It("does not create a new admin user if User table is not empty", func() { - Expect(createInitialAdminUser(ds, "first")).To(BeNil()) - ur := ds.User(context.TODO()) - Expect(ur.CountAll()).To(Equal(int64(1))) - Expect(createInitialAdminUser(ds, "second")).To(BeNil()) - Expect(ur.CountAll()).To(Equal(int64(1))) + Expect(createInitialAdminUser(ctx, ds, "first")).To(BeNil()) + ur := ds.User() + Expect(ur.CountAll(ctx)).To(Equal(int64(1))) + Expect(createInitialAdminUser(ctx, ds, "second")).To(BeNil()) + Expect(ur.CountAll(ctx)).To(Equal(int64(1))) }) It("returns the error when the user cannot be stored", func() { boom := errors.New("db is down") - Expect(createInitialAdminUser(dsWithFailingPut(boom), "pass123")).To(MatchError(boom)) + Expect(createInitialAdminUser(ctx, dsWithFailingPut(boom), "pass123")).To(MatchError(boom)) }) It("returns the error when the user table cannot be read", func() { boom := errors.New("db is down") ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: boom}} - Expect(createInitialAdminUser(ds, "pass123")).To(MatchError(boom)) + Expect(createInitialAdminUser(ctx, ds, "pass123")).To(MatchError(boom)) }) }) }) diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index ec84f65e0..a589798be 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -28,16 +28,16 @@ func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id s switch e := entity.(type) { case *model.Album: if u.HasLibraryAccess(e.LibraryID) { - return api.ds.Album(ctx), "album" + return api.ds.Album(), "album" } case *model.Artist: - return api.ds.Artist(ctx), "artist" + return api.ds.Artist(), "artist" case *model.MediaFile: if u.HasLibraryAccess(e.LibraryID) { - return api.ds.MediaFile(ctx), "song" + return api.ds.MediaFile(), "song" } case *model.Playlist: - return api.ds.Playlist(ctx), "playlist" + return api.ds.Playlist(), "playlist" } // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. http.Error(w, "Not Found", http.StatusNotFound) @@ -74,7 +74,7 @@ func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred b if repo == nil { return } - if err := repo.SetStar(starred, id); err != nil { + if err := repo.SetStar(r.Context(), starred, id); err != nil { api.internalError(w, r, err) return } @@ -97,7 +97,7 @@ func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating if repo == nil { return } - if err := repo.SetRating(rating, id); err != nil { + if err := repo.SetRating(r.Context(), rating, id); err != nil { api.internalError(w, r, err) return } diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index dd1487011..a436503ba 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -32,7 +32,7 @@ var _ = Describe("Annotations", func() { Describe("markFavorite / unmarkFavorite", func() { It("stars a song and returns IsFavorite=true", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) @@ -46,7 +46,7 @@ var _ = Describe("Annotations", func() { }) It("stars an album and returns IsFavorite=true", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) @@ -60,7 +60,7 @@ var _ = Describe("Annotations", func() { }) It("stars an artist without checking library access (artists span multiple libraries)", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() // alice only has access to library 1, but artists aren't gated per-library. @@ -75,7 +75,7 @@ var _ = Describe("Annotations", func() { }) It("stars a visible playlist", func() { - playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) @@ -86,7 +86,7 @@ var _ = Describe("Annotations", func() { }) It("unstars a song and returns IsFavorite=false", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) @@ -100,7 +100,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 and does not star an album in a library the user can't access", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 @@ -111,7 +111,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 and does not star a song in a library the user can't access", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 @@ -130,7 +130,7 @@ var _ = Describe("Annotations", func() { }) It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true) + ds.Album().(*tests.MockAlbumRepo).SetError(true) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1"))) @@ -139,7 +139,7 @@ var _ = Describe("Annotations", func() { }) It("emits a refreshResource event when starring a song", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) @@ -150,7 +150,7 @@ var _ = Describe("Annotations", func() { }) It("emits a refreshResource event when starring an album", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) @@ -161,7 +161,7 @@ var _ = Describe("Annotations", func() { }) It("does not emit an event when the item is not accessible", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) @@ -174,7 +174,7 @@ var _ = Describe("Annotations", func() { Describe("setRating / removeRating", func() { It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) @@ -189,7 +189,7 @@ var _ = Describe("Annotations", func() { }) It("rates an album", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) @@ -200,7 +200,7 @@ var _ = Describe("Annotations", func() { }) It("rates a visible playlist", func() { - playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser()) @@ -211,7 +211,7 @@ var _ = Describe("Annotations", func() { }) It("removes a rating", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) @@ -225,7 +225,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 and does not rate an album in a library the user can't access", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 @@ -236,7 +236,7 @@ var _ = Describe("Annotations", func() { }) It("rounds an odd rating to the nearest star instead of truncating", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) @@ -247,7 +247,7 @@ var _ = Describe("Annotations", func() { }) It("stores the minimum star for Rating=1 instead of clearing the rating", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) @@ -258,7 +258,7 @@ var _ = Describe("Annotations", func() { }) It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) @@ -269,7 +269,7 @@ var _ = Describe("Annotations", func() { }) It("clamps a Rating above 10 to Navidrome's max (5)", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) @@ -280,7 +280,7 @@ var _ = Describe("Annotations", func() { }) It("clamps a negative Rating to Navidrome's min (0)", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) @@ -291,7 +291,7 @@ var _ = Describe("Annotations", func() { }) It("emits a refreshResource event when rating a song", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index a64dcfe8f..0c6ac9b98 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -1,6 +1,7 @@ package jellyfin import ( + "context" "net/http" "net/http/httptest" "strings" @@ -18,6 +19,12 @@ import ( ) var _ = Describe("Router", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) @@ -49,8 +56,8 @@ var _ = Describe("Router", func() { It("registers a player on a general authenticated request, not just playback reports", func() { ds := &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) + ur := ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) @@ -95,7 +102,7 @@ var _ = Describe("Router", func() { ds := &tests.MockDataStore{} auth.Init(ds) usr := model.User{ID: testID("alice"), UserName: "alice"} - Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + Expect(ds.User().Put(ctx, &usr)).To(Succeed()) token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin) Expect(err).ToNot(HaveOccurred()) api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New()) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index 32b4a1432..264d5b6ca 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -24,7 +24,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { } // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. - usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) + usr, err := api.ds.User().FindByUsernameWithPassword(ctx, body.Username) if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) @@ -37,7 +37,7 @@ func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.Use ctx := r.Context() // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a // never/stale "Last Login" in the admin UI. - if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { + if err := api.ds.User().UpdateLastLoginAt(ctx, usr.ID); err != nil { log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err) } diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index 0821614d6..dafad9244 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -17,13 +17,15 @@ import ( ) var _ = Describe("AuthenticateByName", func() { + var ctx context.Context var api *Router var ds *tests.MockDataStore BeforeEach(func() { + ctx = GinkgoT().Context() ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) + ur := ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) @@ -105,15 +107,15 @@ var _ = Describe("AuthenticateByName", func() { api.authenticateByName(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - ur := ds.User(context.Background()).(*tests.MockedUserRepo) - usr, err := ur.FindByUsername("alice") + ur := ds.User().(*tests.MockedUserRepo) + usr, err := ur.FindByUsername(ctx, "alice") Expect(err).ToNot(HaveOccurred()) Expect(usr.LastLoginAt).ToNot(BeNil()) }) It("reflects an administrator in the User.Policy", func() { - ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) + ur := ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(ctx, &model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateByName", @@ -136,8 +138,8 @@ var _ = Describe("AuthenticateByName", func() { }) It("rejects an empty password even for a user with an empty stored password with 401", func() { - ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed()) + ur := ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(ctx, &model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateByName", diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 25c44e748..baebbf0f3 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -77,7 +77,7 @@ func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { return } opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} - labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) + labels, err := api.ds.Tag().GetAll(ctx, model.TagRecordLabel, opts) if err != nil { api.internalError(w, r, err) return @@ -97,12 +97,12 @@ func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) return } genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} - genres, err := api.ds.Genre(ctx).GetAll(genreOpts) + genres, err := api.ds.Genre().GetAll(ctx, genreOpts) if err != nil { api.internalError(w, r, err) return } - years, err := api.ds.Album(ctx).GetYears(scope...) + years, err := api.ds.Album().GetYears(ctx, scope...) if err != nil { api.internalError(w, r, err) return diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 657c7a42c..48a62c4e5 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -33,7 +33,7 @@ var _ = Describe("Browsing", func() { Describe("getArtists", func() { It("lists artists via /Artists", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) + ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -45,7 +45,7 @@ var _ = Describe("Browsing", func() { }) It("handles /Artists/AlbumArtists the same way", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) + ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -56,7 +56,7 @@ var _ = Describe("Browsing", func() { }) It("scopes results to the user's accessible libraries", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} @@ -70,7 +70,7 @@ var _ = Describe("Browsing", func() { }) It("scopes to a single library when ParentId is an accessible library id", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} @@ -85,7 +85,7 @@ var _ = Describe("Browsing", func() { }) It("does not let ParentId= narrow the scope", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}} // no access to library 99 @@ -100,7 +100,7 @@ var _ = Describe("Browsing", func() { }) It("forwards SearchTerm to the repo's Search method", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) @@ -112,7 +112,7 @@ var _ = Describe("Browsing", func() { }) It("bounds a search the client left unbounded, and clamps an oversized one", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() @@ -129,7 +129,7 @@ var _ = Describe("Browsing", func() { }) It("forwards StartIndex/Limit as Offset/Max", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) @@ -140,7 +140,7 @@ var _ = Describe("Browsing", func() { }) It("does not restrict results for an admin user", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) @@ -158,7 +158,7 @@ var _ = Describe("Browsing", func() { DescribeTable("restricts to favorites", func(url string, handler func(*Router) http.HandlerFunc) { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", url, nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) @@ -180,7 +180,7 @@ var _ = Describe("Browsing", func() { ) It("404s a malformed ParentId instead of listing every library's artists", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) @@ -210,7 +210,7 @@ var _ = Describe("Browsing", func() { Describe("getStudios", func() { It("scopes results to the user's accessible libraries", func() { - tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + tagRepo := ds.Tag().(*tests.MockTagRepo) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) invoke(api.getStudios, w, r) @@ -224,7 +224,7 @@ var _ = Describe("Browsing", func() { // An empty scope (admin, or a non-admin with no explicit library grants) must be treated // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". It("does not restrict results for an admin user", func() { - tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + tagRepo := ds.Tag().(*tests.MockTagRepo) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) invoke(api.getStudios, w, r) @@ -242,7 +242,7 @@ var _ = Describe("Browsing", func() { Describe("getQueryFiltersLegacy", func() { It("scopes genres to the user's accessible libraries", func() { - genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + genreRepo := ds.Genre().(*tests.MockedGenreRepo) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) invoke(api.getQueryFiltersLegacy, w, r) @@ -254,7 +254,7 @@ var _ = Describe("Browsing", func() { }) It("does not restrict genres for an admin user", func() { - genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + genreRepo := ds.Genre().(*tests.MockedGenreRepo) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) invoke(api.getQueryFiltersLegacy, w, r) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 156e79ce9..35daaf598 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -87,10 +87,10 @@ var _ = Describe("Authentication", func() { Expect(pw.Code).To(Equal(http.StatusOK)) // A real password change through the repository, which is what revokes in production. - admin, err := ds.User(ctx).Get(testID("admin-1")) + admin, err := ds.User().Get(ctx, testID("admin-1")) Expect(err).ToNot(HaveOccurred()) admin.NewPassword = "rotated" - Expect(ds.User(ctx).Put(admin)).To(Succeed()) + Expect(ds.User().Put(ctx, admin)).To(Succeed()) r = httptest.NewRequest("GET", "/Users/Me", nil) r.Header.Set("X-Emby-Token", res.AccessToken) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 5aa38cba1..4f3cd82b5 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -244,7 +244,7 @@ func enc(id string) string { return dto.EncodeID(id) } // guessing repository filter column names. func albumID(name string) string { - albums, err := ds.Album(ctx).GetAll() + albums, err := ds.Album().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) for _, a := range albums { if a.Name == name { @@ -256,7 +256,7 @@ func albumID(name string) string { } func songID(title string) string { - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) for _, mf := range mfs { if mf.Title == title { @@ -268,7 +268,7 @@ func songID(title string) string { } func artistID(name string) string { - artists, err := ds.Artist(ctx).GetAll() + artists, err := ds.Artist().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) for _, a := range artists { if a.Name == name { @@ -280,7 +280,7 @@ func artistID(name string) string { } func genreID(name string) string { - genres, err := ds.Genre(ctx).GetAll() + genres, err := ds.Genre().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) for _, g := range genres { if g.Name == name { @@ -399,7 +399,7 @@ func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *mo // songAgent looks a seeded track up by title (titles are unique in the seed) and builds an // agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. func songAgent(title string) agents.Song { - mfs, err := ds.MediaFile(ctx).GetAll() + mfs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) for _, mf := range mfs { if mf.Title == title { diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go index d3a521332..905d4e16f 100644 --- a/server/jellyfin/e2e/multiuser_test.go +++ b/server/jellyfin/e2e/multiuser_test.go @@ -19,8 +19,8 @@ var _ = Describe("Multi-user access control", func() { It("hides all content from a user with no library access", func() { noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"} - Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) - loaded, err := ds.User(ctx).FindByUsername("noaccess") + Expect(ds.User().Put(ctx, &noAccess)).To(Succeed()) + loaded, err := ds.User().FindByUsername(ctx, "noaccess") Expect(err).ToNot(HaveOccurred()) q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 3dd53227f..49c8e5a8c 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -66,13 +66,13 @@ var _ = Describe("Playlists", func() { // dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get // dropped while the well-formed entries are still used to create a playlist. It("404s when one of the Ids is malformed, without creating a playlist", func() { - before, err := ds.Playlist(ctx).CountAll() + before, err := ds.Playlist().CountAll(ctx) Expect(err).ToNot(HaveOccurred()) body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound)) - after, err := ds.Playlist(ctx).CountAll() + after, err := ds.Playlist().CountAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(after).To(Equal(before)) }) @@ -318,14 +318,14 @@ var _ = Describe("Playlists", func() { Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). To(Equal(http.StatusNoContent)) - pls, err := ds.Playlist(ctx).Get(plID) + pls, err := ds.Playlist().Get(ctx, plID) Expect(err).ToNot(HaveOccurred()) Expect(pls.UploadedImage).ToNot(BeEmpty()) _, statErr := os.Stat(pls.UploadedImagePath()) Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) - pls, _ = ds.Playlist(ctx).Get(plID) + pls, _ = ds.Playlist().Get(ctx, plID) Expect(pls.UploadedImage).To(BeEmpty()) }) @@ -338,7 +338,7 @@ var _ = Describe("Playlists", func() { // from their tag-keyed cache until the next scan. It("clears the resolved image tag after a cover upload", func() { plID := createPlaylist("Cover Tag", nil) - Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: plID, Hash: "1111111111111111", })).To(Succeed()) @@ -378,7 +378,7 @@ var _ = Describe("Playlists", func() { It("renames a playlist", func() { plID := createPlaylist("Old Name", nil) Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) - pls, _ := ds.Playlist(ctx).Get(plID) + pls, _ := ds.Playlist().Get(ctx, plID) Expect(pls.Name).To(Equal("New Name")) }) @@ -410,7 +410,7 @@ var _ = Describe("Playlists", func() { q := playlistItems(plID) Expect(q.TotalRecordCount).To(Equal(1)) Expect(q.Items[0].Name).To(Equal("So What")) - pls, _ := ds.Playlist(ctx).Get(plID) + pls, _ := ds.Playlist().Get(ctx, plID) Expect(pls.Name).To(Equal("Combo Renamed")) Expect(pls.Public).To(BeTrue()) }) @@ -424,13 +424,13 @@ var _ = Describe("Playlists", func() { // An id that decodes to "" would tell Create to make a new playlist instead of updating one — // itemIDParam must 404 before that decode ever runs, not silently create one. It("404s for a malformed playlist id, without creating a playlist", func() { - before, err := ds.Playlist(ctx).CountAll() + before, err := ds.Playlist().CountAll(ctx) Expect(err).ToNot(HaveOccurred()) w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`) Expect(w.Code).To(Equal(http.StatusNotFound)) - after, err := ds.Playlist(ctx).CountAll() + after, err := ds.Playlist().CountAll(ctx) Expect(err).ToNot(HaveOccurred()) Expect(after).To(Equal(before)) }) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go index c5c42cb2b..22c87b108 100644 --- a/server/jellyfin/e2e/sessions_test.go +++ b/server/jellyfin/e2e/sessions_test.go @@ -27,12 +27,12 @@ var _ = Describe("Sessions", func() { It("counts a play stopped past the threshold", func() { id := songID("So What") - mf, err := ds.MediaFile(ctx).Get(id) + mf, err := ds.MediaFile().Get(ctx, id) Expect(err).ToNot(HaveOccurred()) // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) - mf, err = ds.MediaFile(ctx).Get(id) + mf, err = ds.MediaFile().Get(ctx, id) Expect(err).ToNot(HaveOccurred()) Expect(mf.PlayCount).To(BeNumerically(">=", 1)) }) @@ -48,7 +48,7 @@ var _ = Describe("Sessions", func() { id := songID("Help!") Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) - mf, err := ds.MediaFile(ctx).Get(id) + mf, err := ds.MediaFile().Get(ctx, id) Expect(err).ToNot(HaveOccurred()) Expect(mf.PlayCount).To(Equal(int64(0))) }) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 06054f8bf..a5d2505ba 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -68,9 +68,9 @@ var _ = Describe("Similar", func() { // Seed an album in a second library the regular user has no access to, and point a // provider similar-song at it. otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} - Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + Expect(ds.Library().Put(ctx, &otherLib)).To(Succeed()) otherAlbum := model.Album{ID: testID("other-album"), Name: "Other Album", LibraryID: 2} - Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed()) + Expect(ds.Album().Put(ctx, &otherAlbum)).To(Succeed()) providerFake.similarSongs = model.MediaFiles{ {ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 0c9e0380c..6af533dbb 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -82,16 +82,16 @@ func hashFromTag(r *http.Request) string { // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing // album -> artist -> media file -> playlist. func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { - if al, err := api.ds.Album(ctx).Get(itemId); err == nil { + if al, err := api.ds.Album().Get(ctx, itemId); err == nil { return al.CoverArtID().String() } - if ar, err := api.ds.Artist(ctx).Get(itemId); err == nil { + if ar, err := api.ds.Artist().Get(ctx, itemId); err == nil { return ar.CoverArtID().String() } - if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil { + if mf, err := api.ds.MediaFile().Get(ctx, itemId); err == nil { return mf.CoverArtID().String() } - if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { + if pl, err := api.ds.Playlist().Get(ctx, itemId); err == nil { return pl.CoverArtID().String() } return (model.ArtworkID{}).String() diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index b7b262895..b7cf9b7e7 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -68,7 +68,7 @@ var _ = Describe("Images", func() { DescribeTable("derives the requested size from the Jellyfin size params", func(query string, wantSize int) { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} @@ -97,7 +97,7 @@ var _ = Describe("Images", func() { It("streams album artwork", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} @@ -138,7 +138,7 @@ var _ = Describe("Images", func() { It("sniffs the Content-Type instead of hardcoding it", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) fa := &fakeArtwork{data: png} @@ -153,7 +153,7 @@ var _ = Describe("Images", func() { It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) + ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} @@ -169,7 +169,7 @@ var _ = Describe("Images", func() { // silently falls back to the placeholder. It("resolves artwork under an elevated admin context", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} @@ -185,7 +185,7 @@ var _ = Describe("Images", func() { It("serves immutable when the tag param asserts the current hash", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} @@ -203,7 +203,7 @@ var _ = Describe("Images", func() { It("revalidates via no-cache when no tag is provided", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 23e35dd59..28de4c31e 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -382,7 +382,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ if q.parentId == dto.PlaylistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. q.types = []string{"Playlist"} - } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { + } else if _, err := api.ds.Album().Get(ctx, q.parentId); err == nil { q.types = []string{"Audio"} } } @@ -412,7 +412,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil } if repo, ok := api.playlistTracksRepo(ctx, q); ok { - return api.playlistTrackPage(repo, q.fields, q.offset, q.limit) + return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit) } if q.search != "" { q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) @@ -702,7 +702,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } - repo := api.ds.Album(ctx) + repo := api.ds.Album() filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's // albums"; contributingArtistIds means "albums they only appear on" (Featured On). @@ -733,23 +733,23 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if q.search != "" { albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { - return repo.Search(q.search, o) + return repo.Search(ctx, q.search, o) }) if err != nil { return itemsResult{}, err } return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil } - total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Album, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursor(ctx, opts) }, toItem) return streamed(open, int(total), opts.Offset), nil } func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } - repo := api.ds.MediaFile(ctx) + repo := api.ds.MediaFile() filters := squirrel.And{} // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. switch { @@ -782,7 +782,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if q.search != "" { mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { - return repo.Search(q.search, o) + return repo.Search(ctx, q.search, o) }) if err != nil { return itemsResult{}, err @@ -795,9 +795,9 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite opts.Sort = filter.SongsByAlbum(q.entityParent).Sort } // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. - total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { - return repo.GetCursorWithArtwork(opts) + return repo.GetCursorWithArtwork(ctx, opts) }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -806,7 +806,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. // genreIds isn't applied to search — a name lookup, like role (see below). func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { - repo := api.ds.Artist(ctx) + repo := api.ds.Artist() toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) } // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a @@ -818,7 +818,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} } artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { - return repo.Search(q.search, o) + return repo.Search(ctx, q.search, o) }) if err != nil { return itemsResult{}, err @@ -834,9 +834,9 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i opts.Filters = filters opts = filter.ArtistsByRole(opts, role) opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) - total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Artist, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursor(ctx, opts) }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -845,7 +845,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i // the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the // length of the full list and paging is in-memory — nothing for a cursor to page over. func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { - genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) + genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) if err != nil { return itemsResult{}, err } @@ -859,13 +859,13 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q if preds := q.filters.predicates(); len(preds) > 0 { opts.Filters = squirrel.And(preds) } - repo := api.ds.Playlist(ctx) - total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + repo := api.ds.Playlist() + total, err := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) if err != nil { return itemsResult{}, err } open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursor(ctx, opts) }, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) }) return streamed(open, int(total), opts.Offset), nil } @@ -882,21 +882,21 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this // the home screen and library tabs 404. if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { - if lib, err := api.ds.Library(ctx).Get(libID); err == nil { + if lib, err := api.ds.Library().Get(ctx, libID); err == nil { return dto.LibraryToBaseItem(*lib), true } } - if al, err := api.ds.Album(ctx).Get(id); err == nil { + if al, err := api.ds.Album().Get(ctx, id); err == nil { if !u.HasLibraryAccess(al.LibraryID) { return dto.BaseItemDto{}, false } return dto.AlbumToBaseItem(*al, fields), true } - if ar, err := api.ds.Artist(ctx).Get(id); err == nil { + if ar, err := api.ds.Artist().Get(ctx, id); err == nil { // Artist.Get already scopes to the user's libraries via library_artist. return dto.ArtistToBaseItem(*ar, fields), true } - if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if mf, err := api.ds.MediaFile().Get(ctx, id); err == nil { if !u.HasLibraryAccess(mf.LibraryID) { return dto.BaseItemDto{}, false } @@ -906,7 +906,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if pl, err := api.playlists.Get(ctx, id); err == nil { return dto.PlaylistToBaseItem(*pl, fields), true } - if g, err := api.ds.Genre(ctx).Get(id); err == nil { + if g, err := api.ds.Genre().Get(ctx, id); err == nil { return dto.GenreToBaseItem(*g), true } return dto.BaseItemDto{}, false @@ -917,7 +917,7 @@ func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]mode songs := make(map[string]model.MediaFile, len(ids)) // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. for chunk := range slice.CollectChunks(slices.Values(ids), 500) { - mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) + mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) if err != nil { log.Error(ctx, "Jellyfin API: error fetching songs by id", err) continue @@ -997,9 +997,9 @@ func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} } opts = filter.ApplyLibraryFilter(opts, scopeIDs) - repo := api.ds.Album(ctx) + repo := api.ds.Album() open := streamCursor(func() (func(func(model.Album, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursor(ctx, opts) }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) api.writeItemsArray(w, r, streamed(open, 0, 0)) } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index a8dfb7bb2..18789a34b 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -49,7 +49,7 @@ var _ = Describe("Items", func() { Describe("getItems", func() { It("lists albums when IncludeItemTypes=MusicAlbum", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -62,8 +62,8 @@ var _ = Describe("Items", func() { }) It("lists an album's songs when ParentId is an album and type is Audio", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -75,8 +75,8 @@ var _ = Describe("Items", func() { }) It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -126,8 +126,8 @@ var _ = Describe("Items", func() { It("falls through to the type dispatch when ParentId is not a playlist", func() { fp.getErr = model.ErrNotFound - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) @@ -140,7 +140,7 @@ var _ = Describe("Items", func() { }) It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -151,8 +151,8 @@ var _ = Describe("Items", func() { // looking for tracks outside any album; answering with every track streams the whole library. Describe("Recursive=false", func() { BeforeEach(func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) }) It("returns no songs for a library parent, as tracks are never its direct children", func() { @@ -223,21 +223,21 @@ var _ = Describe("Items", func() { }) It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) sql, _, err := albumRepo.Options.Filters.ToSql() Expect(err).NotTo(HaveOccurred()) Expect(sql).To(ContainSubstring("album_artists")) }) It("lists artists when IncludeItemTypes=MusicArtist", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) + ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -258,7 +258,7 @@ var _ = Describe("Items", func() { }) It("lists playlists when IncludeItemTypes=Playlist", func() { - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) + ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -272,8 +272,8 @@ var _ = Describe("Items", func() { }) It("merges results from every requested type in IncludeItemTypes", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -287,11 +287,11 @@ var _ = Describe("Items", func() { }) It("merges favorite songs, albums, and playlists", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) @@ -312,8 +312,8 @@ var _ = Describe("Items", func() { }) It("applies StartIndex/Limit to the merged multi-type result set", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -326,9 +326,9 @@ var _ = Describe("Items", func() { }) It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) @@ -341,7 +341,7 @@ var _ = Describe("Items", func() { DescribeTable("translates the Filters list and its standalone equivalents", func(query string, wantSQL, notWantSQL []string) { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser()) @@ -381,8 +381,8 @@ var _ = Describe("Items", func() { // annotation predicate there is "no such column: starred" -> 500. DescribeTable("does not push annotation filters into a search", func(itemType, filters string) { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser()) @@ -390,9 +390,9 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusOK)) var opts model.QueryOptions if itemType == "MusicAlbum" { - opts = ds.Album(context.Background()).(*tests.MockAlbumRepo).Options + opts = ds.Album().(*tests.MockAlbumRepo).Options } else { - opts = ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).Options + opts = ds.MediaFile().(*tests.MockMediaFileRepo).Options } if opts.Filters == nil { return @@ -410,7 +410,7 @@ var _ = Describe("Items", func() { ) It("forwards SearchTerm to the repo's Search method", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) @@ -422,7 +422,7 @@ var _ = Describe("Items", func() { }) It("caps a search the client left unbounded", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) @@ -432,7 +432,7 @@ var _ = Describe("Items", func() { }) It("honors an explicit search Limit up to the ceiling", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). @@ -443,7 +443,7 @@ var _ = Describe("Items", func() { }) It("clamps a search Limit that would materialize the library", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). @@ -454,7 +454,7 @@ var _ = Describe("Items", func() { }) It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). @@ -472,8 +472,8 @@ var _ = Describe("Items", func() { for i := range songs { songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). WithContext(ctxUser()) @@ -486,9 +486,9 @@ var _ = Describe("Items", func() { }) It("bounds the multi-type search window however large StartIndex is", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). WithContext(ctxUser()) @@ -505,8 +505,8 @@ var _ = Describe("Items", func() { for i := range songs { songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), @@ -526,8 +526,8 @@ var _ = Describe("Items", func() { } // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), @@ -546,8 +546,8 @@ var _ = Describe("Items", func() { for i := range songs { songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). WithContext(ctxUser()) @@ -565,8 +565,8 @@ var _ = Describe("Items", func() { } // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), @@ -581,7 +581,7 @@ var _ = Describe("Items", func() { }) It("reports a search total beyond the fetched page instead of the page length", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ + ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{ {ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"}, }) w := httptest.NewRecorder() @@ -595,7 +595,7 @@ var _ = Describe("Items", func() { }) It("forwards StartIndex/Limit as Offset/Max", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) @@ -609,7 +609,7 @@ var _ = Describe("Items", func() { // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=; without // this, queryItems ignored Ids and returned the default type-dispatched list instead. It("returns exactly the requested item when Ids has a single id", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -623,8 +623,8 @@ var _ = Describe("Items", func() { }) It("returns items of different types for a lowercase ids param with multiple ids", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -640,7 +640,7 @@ var _ = Describe("Items", func() { }) It("resolves song ids with one batched IN query, not a Get per id", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser()) @@ -656,8 +656,8 @@ var _ = Describe("Items", func() { }) It("omits an id in a library the user can't access, without erroring the whole batch", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1 w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -673,9 +673,9 @@ var _ = Describe("Items", func() { Describe("sorting", func() { DescribeTable("translates SortBy into the repo's sort keys", func(itemType, sortBy, want string) { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser()) @@ -710,7 +710,7 @@ var _ = Describe("Items", func() { // we honor the first value for all keys, matching Jellyfin's fallback for extra keys. DescribeTable("reads the first SortOrder value for the whole sort", func(sortOrder, want string) { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", @@ -728,7 +728,7 @@ var _ = Describe("Items", func() { Describe("library scoping", func() { It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} @@ -742,7 +742,7 @@ var _ = Describe("Items", func() { }) It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} @@ -756,7 +756,7 @@ var _ = Describe("Items", func() { }) It("scopes a MusicArtist listing to the user's accessible libraries", func() { - artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo := ds.Artist().(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} @@ -770,7 +770,7 @@ var _ = Describe("Items", func() { }) It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} @@ -785,7 +785,7 @@ var _ = Describe("Items", func() { }) It("does not let ParentId= scope results to that library", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}} // no access to library 99 @@ -803,7 +803,7 @@ var _ = Describe("Items", func() { }) It("does not restrict a default MusicAlbum listing for an admin user", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) @@ -824,7 +824,7 @@ var _ = Describe("Items", func() { // still reach the entity filter, not the unfiltered default. Describe("stale and malformed id filtering", func() { It("404s a malformed ParentId instead of listing every song", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -832,7 +832,7 @@ var _ = Describe("Items", func() { }) It("404s a malformed AlbumArtistIds instead of listing every album", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -840,7 +840,7 @@ var _ = Describe("Items", func() { }) It("404s a malformed ArtistIds instead of listing every song", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -848,7 +848,7 @@ var _ = Describe("Items", func() { }) It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser()) @@ -860,7 +860,7 @@ var _ = Describe("Items", func() { }) It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser()) @@ -875,8 +875,8 @@ var _ = Describe("Items", func() { Describe("mixed IncludeItemTypes merge", func() { BeforeEach(func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) }) It("returns a mix of both types, not all of one", func() { @@ -927,7 +927,7 @@ var _ = Describe("Items", func() { }) It("propagates a per-type query error", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -938,7 +938,7 @@ var _ = Describe("Items", func() { Describe("getItem", func() { It("returns an album by id", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) @@ -959,7 +959,7 @@ var _ = Describe("Items", func() { }) It("returns 404 for an album in a library the user can't access", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) @@ -968,7 +968,7 @@ var _ = Describe("Items", func() { }) It("returns 404 for a song in a library the user can't access", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1 r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) @@ -977,7 +977,7 @@ var _ = Describe("Items", func() { }) It("returns an album to an admin even when it's outside their (empty) Libraries", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) @@ -993,7 +993,7 @@ var _ = Describe("Items", func() { It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 1, Name: "Music Library"}} - ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs) + ds.Library().(*tests.MockLibraryRepo).SetData(libs) r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) @@ -1043,7 +1043,7 @@ var _ = Describe("Items", func() { // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). It("resolves a genre id as a MusicGenre item", func() { - Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) + Expect(ds.Genre().(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser()) r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) @@ -1057,7 +1057,7 @@ var _ = Describe("Items", func() { }) It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { - ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) + ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin()) r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) @@ -1073,7 +1073,7 @@ var _ = Describe("Items", func() { Describe("getLatest", func() { It("returns a bare array of the newest albums", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) invoke(api.getLatest, w, r) @@ -1085,7 +1085,7 @@ var _ = Describe("Items", func() { }) It("scopes to the user's accessible libraries", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index 2cf53a280..fb00563de 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -52,7 +52,7 @@ var _ = Describe("getLyrics", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Silent Song", LibraryID: 1}, }) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 0ae4f6071..4788c84be 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -162,7 +162,7 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) { if err != nil || claims.Subject == "" { return model.User{}, false } - usr, err := api.ds.User(r.Context()).FindByUsername(claims.Subject) + usr, err := api.ds.User().FindByUsername(r.Context(), claims.Subject) if err != nil { log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) return model.User{}, false diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index a3b88799b..2ab9f35eb 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -18,13 +18,15 @@ import ( ) var _ = Describe("authenticate middleware", func() { + var ctx context.Context var api *Router var ds *tests.MockDataStore BeforeEach(func() { + ctx = GinkgoT().Context() ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) + ur := ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) @@ -100,9 +102,9 @@ var _ = Describe("authenticate middleware", func() { var usr *model.User BeforeEach(func() { - ur := ds.User(context.Background()).(*tests.MockedUserRepo) + ur := ds.User().(*tests.MockedUserRepo) usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3} - Expect(ur.Put(usr)).To(Succeed()) + Expect(ur.Put(ctx, usr)).To(Succeed()) }) serve := func(token string) *httptest.ResponseRecorder { diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 5fd2df8c9..5569f4f83 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -146,14 +146,14 @@ func (api *Router) clearPlaylist(ctx context.Context, id string) error { // playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the // whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing // tracks, and counts the same set, like GetWithTracks. -func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { - total, err := repo.CountAll(model.QueryOptions{Filters: notMissing}) +func (api *Router) playlistTrackPage(ctx context.Context, repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { + total, err := repo.CountAll(ctx, model.QueryOptions{Filters: notMissing}) if err != nil { return itemsResult{}, err } opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing} open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursor(ctx, opts) }, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) return streamed(open, int(total), offset), nil } @@ -188,7 +188,7 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { return } // PlaylistInfo carries every track id, so this can't be paged — but it needs no track data. - trackIDs, err := repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Filters: notMissing}) + trackIDs, err := repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Filters: notMissing}) if err != nil { api.internalError(w, r, err) return @@ -216,7 +216,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { } p := req.Params(r) fields := dto.ParseFields(p.Strings("fields")...) - res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) + res, err := api.playlistTrackPage(ctx, repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) return @@ -249,9 +249,9 @@ func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []strin for _, id := range ids { if _, ok := songs[id]; ok { out = append(out, id) // already a song - } else if _, err := api.ds.Album(ctx).Get(id); err == nil { + } else if _, err := api.ds.Album().Get(ctx, id); err == nil { out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...) - } else if _, err := api.ds.Artist(ctx).Get(id); err == nil { + } else if _, err := api.ds.Artist().Get(ctx, id); err == nil { out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...) } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) @@ -263,7 +263,7 @@ func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []strin } func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string { - mfs, err := api.ds.MediaFile(ctx).GetAll(opts) + mfs, err := api.ds.MediaFile().GetAll(ctx, opts) if err != nil { log.Error(ctx, "Jellyfin: error expanding container to tracks", err) return nil diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index edaa63dd1..d38ef3b44 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -299,27 +299,27 @@ var _ = Describe("Playlists", func() { } It("passes a bare song id through unchanged", func() { - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) createWith(testID("s1")) Expect(fp.createdIds).To(Equal([]string{testID("s1")})) }) It("expands an album id into its songs, filtered by album", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")}, }) createWith(testID("al1")) Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters)) + Expect(ds.MediaFile().(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters)) }) It("expands an artist id into its songs", func() { - ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) + ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) createWith(testID("ar1")) Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters)) + Expect(ds.MediaFile().(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters)) }) It("expands a playlist id into its tracks' media file ids", func() { diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go index c478b1894..f7fa1b979 100644 --- a/server/jellyfin/quickconnect.go +++ b/server/jellyfin/quickconnect.go @@ -77,7 +77,7 @@ func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) http.Error(w, "Forbidden", http.StatusForbidden) return } - usr, err := api.ds.User(ctx).Get(userID) + usr, err := api.ds.User().Get(ctx, userID) if errors.Is(err, model.ErrNotFound) { http.Error(w, "Unknown user", http.StatusNotFound) return @@ -118,7 +118,7 @@ func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.R http.Error(w, "Unknown secret", http.StatusNotFound) return } - usr, err := api.ds.User(ctx).Get(userID) + usr, err := api.ds.User().Get(ctx, userID) if errors.Is(err, model.ErrNotFound) { log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID) http.Error(w, "Unauthorized", http.StatusUnauthorized) diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go index 0478a356a..94573e6ff 100644 --- a/server/jellyfin/quickconnect_test.go +++ b/server/jellyfin/quickconnect_test.go @@ -1,7 +1,6 @@ package jellyfin import ( - "context" "encoding/json" "errors" "net/http" @@ -42,9 +41,9 @@ var _ = Describe("QuickConnect", func() { conf.Server.Jellyfin.QuickConnect = true ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(context.Background()).(*tests.MockedUserRepo) + ur := ds.User().(*tests.MockedUserRepo) for _, u := range []model.User{alice, bob, admin} { - Expect(ur.Put(&u)).To(Succeed()) + Expect(ur.Put(GinkgoT().Context(), &u)).To(Succeed()) } qc = quickconnect.New() api = &Router{ds: ds, quickConnect: qc} @@ -294,7 +293,7 @@ var _ = Describe("QuickConnect", func() { It("returns 500 when the user lookup fails", func() { req := initiate() _, _ = qc.Authorize(req.Code, alice.ID) - ds.User(context.Background()).(*tests.MockedUserRepo).Error = errors.New("db down") + ds.User().(*tests.MockedUserRepo).Error = errors.New("db down") Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) }) }) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 1bd887f43..50503160c 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -216,7 +216,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. continue } seen[s.AlbumID] = true - if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { + if al, err := api.ds.Album().Get(ctx, s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { items = append(items, dto.AlbumToBaseItem(*al, nil)) if len(items) >= limit { break diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index cffcce0a8..42d04a632 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -122,7 +122,7 @@ var _ = Describe("getInstantMix", func() { DeferCleanup(func() { similarWait = old }) ds := &tests.MockDataStore{} - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Seed Song", LibraryID: 1}, }) release := make(chan struct{}) @@ -150,7 +150,7 @@ var _ = Describe("getInstantMix", func() { songs = append(songs, model.MediaFile{ID: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) } ds := &tests.MockDataStore{} - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} w := httptest.NewRecorder() @@ -191,7 +191,7 @@ var _ = Describe("getSimilarAlbums", func() { // With no external agent the provider falls back to the album's own tracks, which map // straight back to the requested album. ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: testID("al-1"), Name: "Seed Album", LibraryID: 1}, }) api := &Router{ds: ds, provider: &fakeSimilarProvider{ @@ -212,7 +212,7 @@ var _ = Describe("getSimilarAlbums", func() { It("returns albums derived from the provider's similar songs", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: testID("al-2"), Name: "Other", LibraryID: 1}, }) api := &Router{ds: ds, provider: &fakeSimilarProvider{ diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index b4be0e244..79098aafd 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -1,7 +1,6 @@ package jellyfin import ( - "context" "net/http" "net/http/httptest" "strings" @@ -87,8 +86,8 @@ var _ = Describe("handleSocket", func() { BeforeEach(func() { ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) + ur := ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(GinkgoT().Context(), &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index b9809bd29..515a6fcb8 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -29,7 +29,7 @@ func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) ( if !ok { return nil, false } - mf, err := api.ds.MediaFile(ctx).Get(id) + mf, err := api.ds.MediaFile().Get(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return nil, false diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 1a6d1badc..7a1112f37 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -42,7 +42,7 @@ var _ = Describe("Stream", func() { Describe("getPlaybackInfo", func() { It("returns a media source for an accessible track", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) w := httptest.NewRecorder() @@ -61,7 +61,7 @@ var _ = Describe("Stream", func() { }) It("returns 404 for a track in a library the user can't access", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() @@ -102,7 +102,7 @@ var _ = Describe("Stream", func() { } It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ @@ -113,7 +113,7 @@ var _ = Describe("Stream", func() { }) It("advertises no Lyric stream when the pipeline finds nothing", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) @@ -121,7 +121,7 @@ var _ = Describe("Stream", func() { }) It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ @@ -132,7 +132,7 @@ var _ = Describe("Stream", func() { }) It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ @@ -145,7 +145,7 @@ var _ = Describe("Stream", func() { It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() { // Own ID: an erroring loader isn't cached, but a shared ID could still pick up // another test's cached (non-error) result and mask this assertion. - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) api.lyrics = &fakeLyricsService{err: errors.New("boom")} @@ -166,7 +166,7 @@ var _ = Describe("Stream", func() { Describe("streamAudio", func() { It("invokes the transcode decider and streamer for an accessible track", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.content = "audio-bytes" @@ -182,7 +182,7 @@ var _ = Describe("Stream", func() { }) It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() @@ -207,7 +207,7 @@ var _ = Describe("Stream", func() { }) It("converts the bps audioBitRate param to kbps", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) w := httptest.NewRecorder() @@ -219,7 +219,7 @@ var _ = Describe("Stream", func() { }) It("uses the audioCodec param as target format when no container is given", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) w := httptest.NewRecorder() @@ -231,7 +231,7 @@ var _ = Describe("Stream", func() { }) It("returns 500 and logs when the streamer fails", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.err = errors.New("boom") @@ -246,7 +246,7 @@ var _ = Describe("Stream", func() { Describe("HEAD requests", func() { head := func(query string) *httptest.ResponseRecorder { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) streamer.content = "audio-bytes" @@ -276,7 +276,7 @@ var _ = Describe("Stream", func() { Describe("streamUniversal", func() { universal := func(query string) { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Suffix: "mp3", LibraryID: 1}, }) w := httptest.NewRecorder() @@ -315,7 +315,7 @@ var _ = Describe("Stream", func() { Describe("streamHls", func() { BeforeEach(func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, }) }) @@ -370,21 +370,21 @@ var _ = Describe("Stream", func() { }) It("returns 404 for a track in a library the user can't access", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2}, }) Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) }) It("returns 404 when the id doesn't match any media file", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) }) }) Describe("streamFile", func() { It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.content = "audio-bytes" @@ -401,7 +401,7 @@ var _ = Describe("Stream", func() { }) It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 49e41470c..2aae4c9c0 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -51,11 +51,11 @@ func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) st *cached = newServerID() return *cached } - id, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) + id, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): id = newServerID() - if err := ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + if err := ds.Property().Put(ctx, consts.JellyfinServerIDKey, id); err != nil { log.Error(ctx, "Jellyfin API: could not persist server id", err) return id } diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 2350a7342..d339043a7 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -182,10 +182,10 @@ var _ = Describe("System", func() { }) It("does not overwrite or pin over a stored id when the property read fails transiently", func() { - Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed()) + Expect(ds.Property().Put(ctx, consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed()) r := &Router{ds: ds} - props := ds.Property(ctx).(*tests.MockedPropertyRepo) + props := ds.Property().(*tests.MockedPropertyRepo) props.Error = errors.New("database is locked") degraded := r.serverID(ctx) Expect(degraded).ToNot(BeEmpty()) @@ -194,7 +194,7 @@ var _ = Describe("System", func() { // Once the DB recovers, the stored id is intact and served again. Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) - stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) + stored, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) Expect(err).ToNot(HaveOccurred()) Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) }) @@ -205,7 +205,7 @@ var _ = Describe("System", func() { }) It("strips dashes from an already-persisted id", func() { - Expect(ds.Property(ctx).Put( + Expect(ds.Property().Put(ctx, consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed()) r := &Router{ds: ds} Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427")) diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index dddcd99da..d0a54ee63 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -17,7 +17,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { u, _ := request.UserFrom(ctx) // u.Libraries comes from a projection without counts or stats, and clients hide a library that // looks empty, so the rows are re-read in full here. - libs, err := api.ds.Library(ctx).GetAll() + libs, err := api.ds.Library().GetAll(ctx) if err != nil { api.internalError(w, r, err) return @@ -55,7 +55,7 @@ func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { continue } seen[key] = true - usr, err := api.ds.User(ctx).FindByUsername(name) + usr, err := api.ds.User().FindByUsername(ctx, name) if err != nil { log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) continue diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index c6bb99f71..793a64a29 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -18,10 +18,11 @@ import ( ) var _ = Describe("Users", func() { + var ctx context.Context var api *Router // The repo holds the full rows; the user carries the id/name-only copy its projection returns. authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - api.ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs) + api.ds.Library().(*tests.MockLibraryRepo).SetData(libs) stripped := make(model.Libraries, len(libs)) for i, lib := range libs { stripped[i] = model.Library{ID: lib.ID, Name: lib.Name} @@ -29,7 +30,10 @@ var _ = Describe("Users", func() { ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: stripped}) return r.WithContext(ctx) } - BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) + BeforeEach(func() { + ctx = GinkgoT().Context() + api = &Router{ds: &tests.MockDataStore{}} + }) Describe("getUserViews", func() { It("returns one view per accessible library", func() { @@ -117,9 +121,9 @@ var _ = Describe("Users", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) - Expect(ur.Put(&model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) + ur = api.ds.User().(*tests.MockedUserRepo) + Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) + Expect(ur.Put(ctx, &model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) }) It("returns an empty list when the config is unset", func() { diff --git a/server/middlewares.go b/server/middlewares.go index 674337e92..b65a2d6e1 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -380,7 +380,7 @@ func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - err := ds.User(ctx).UpdateLastAccessAt(usr.ID) + err := ds.User().UpdateLastAccessAt(ctx, usr.ID) if err != nil { log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName, "elapsed", time.Since(start), err) diff --git a/server/middlewares_test.go b/server/middlewares_test.go index a9b0bc99e..15cf70341 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -381,7 +381,7 @@ var _ = Describe("middlewares", func() { id = uuid.NewString() ds = &tests.MockDataStore{} lastAccessTime = time.Now() - Expect(ds.User(ctx).Put(&model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})). + Expect(ds.User().Put(ctx, &model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})). To(Succeed()) middleware = UpdateLastAccessMiddleware(ds) @@ -407,14 +407,14 @@ var _ = Describe("middlewares", func() { callMiddleware(req) - user, _ := ds.MockedUser.FindByUsername("johndoe") + user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second)) }) It("skip fast successive requests", func() { // First request callMiddleware(req) - user, _ := ds.MockedUser.FindByUsername("johndoe") + user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") lastAccessTime = *user.LastAccessAt // Store the last access time // Second request @@ -422,7 +422,7 @@ var _ = Describe("middlewares", func() { callMiddleware(req) // The second request should not have changed the last access time - user, _ = ds.MockedUser.FindByUsername("johndoe") + user, _ = ds.MockedUser.FindByUsername(ctx, "johndoe") Expect(user.LastAccessAt).To(Equal(&lastAccessTime)) }) }) @@ -431,7 +431,7 @@ var _ = Describe("middlewares", func() { req = req.WithContext(context.Background()) callMiddleware(req) - usr, _ := ds.MockedUser.FindByUsername("johndoe") + usr, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") Expect(usr.LastAccessAt).To(Equal(&lastAccessTime)) }) }) diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 193f88eda..91508c825 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -15,14 +15,12 @@ import ( ) func (api *Router) addArtistRoute(r chi.Router) { - constructor := func(ctx context.Context) rest.Repository { - return api.ds.Resource(ctx, model.Artist{}) - } + repo := api.ds.Artist() r.Route("/artist", func(r chi.Router) { - r.Get("/", rest.GetAll(constructor)) + r.Get("/", rest.GetAll(repo)) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(constructor)) + r.Get("/", rest.Get(repo)) r.Post("/image", api.uploadArtistImage()) r.Delete("/image", api.deleteArtistImage()) }) @@ -32,7 +30,7 @@ func (api *Router) addArtistRoute(r chi.Router) { func (api *Router) uploadArtistImage() http.HandlerFunc { return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { artistID := chi.URLParamFromCtx(ctx, "id") - ar, err := api.ds.Artist(ctx).Get(artistID) + ar, err := api.ds.Artist().Get(ctx, artistID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -46,7 +44,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { } ar.UploadedImage = filename ar.UpdatedAt = new(time.Now()) - if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil { return err } api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) @@ -57,7 +55,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { func (api *Router) deleteArtistImage() http.HandlerFunc { return handleImageDelete(func(ctx context.Context) error { artistID := chi.URLParamFromCtx(ctx, "id") - ar, err := api.ds.Artist(ctx).Get(artistID) + ar, err := api.ds.Artist().Get(ctx, artistID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -69,7 +67,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { } ar.UploadedImage = "" ar.UpdatedAt = new(time.Now()) - if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil { return err } api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 7c4f00fdd..6ac41f07a 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -19,17 +20,19 @@ import ( ) var _ = Describe("Config API", func() { + var ctx context.Context var ds model.DataStore var router http.Handler var adminUser, regularUser model.User BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) conf.Server.EnableSharing = false conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users @@ -49,8 +52,8 @@ var _ = Describe("Config API", func() { } // Store in mock datastore - Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) - Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) }) Describe("GET /api/config", func() { diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index 7c96312ed..f1e6c4539 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -13,7 +13,7 @@ import ( ) func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) { - file, err := ds.MediaFile(ctx).Get(id) + file, err := ds.MediaFile().Get(ctx, id) if err != nil { return nil, err } diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index cef2e06ad..cc05a30e5 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -21,17 +22,19 @@ import ( ) var _ = Describe("Library API", func() { + var ctx context.Context var ds model.DataStore var router http.Handler var adminUser, regularUser model.User var library1, library2 model.Library BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users @@ -63,10 +66,10 @@ var _ = Describe("Library API", func() { } // Store in mock datastore - Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) - Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) - Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed()) - Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) + Expect(ds.Library().Put(ctx, &library1)).To(Succeed()) + Expect(ds.Library().Put(ctx, &library2)).To(Succeed()) }) Describe("Library CRUD Operations", func() { @@ -293,7 +296,7 @@ var _ = Describe("Library API", func() { Describe("GET /api/user/{id}/library", func() { It("returns user's libraries", func() { // Set up user libraries - err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2}) + err := ds.User().SetUserLibraries(ctx, regularUser.ID, []int{1, 2}) Expect(err).ToNot(HaveOccurred()) req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken) diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go index ae6e301a8..294a26efe 100644 --- a/server/nativeapi/metadata_test.go +++ b/server/nativeapi/metadata_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -38,6 +39,7 @@ func (f *fakeProvider) calls() []string { } var _ = Describe("Metadata API", func() { + var ctx context.Context var ds *tests.MockDataStore var artRepo *tests.MockArtworkRepo var queueRepo *tests.MockArtworkQueueRepo @@ -47,6 +49,7 @@ var _ = Describe("Metadata API", func() { var adminToken, userToken string BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) conf.Server.EnableSharing = false artRepo = tests.CreateMockArtworkRepo() @@ -54,9 +57,9 @@ var _ = Describe("Metadata API", func() { albumRepo = tests.CreateMockAlbumRepo() artistRepo := tests.CreateMockArtistRepo() playlistRepo := tests.CreateMockPlaylistRepo() - Expect(albumRepo.Put(&model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) - Expect(artistRepo.Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) - Expect(playlistRepo.Put(&model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) + Expect(albumRepo.Put(ctx, &model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) + Expect(artistRepo.Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + Expect(playlistRepo.Put(ctx, &model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) ds = &tests.MockDataStore{ MockedArtwork: artRepo, MockedArtworkQueue: queueRepo, @@ -66,13 +69,13 @@ var _ = Describe("Metadata API", func() { } auth.Init(ds) provider = &fakeProvider{} - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) + nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) router = server.JWTVerifier(nativeRouter) adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} - Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) - Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) var err error adminToken, err = auth.CreateToken(&adminUser) @@ -83,7 +86,7 @@ var _ = Describe("Metadata API", func() { Describe("POST /api/metadata/{kind}/{id}/refresh", func() { It("clears state and enqueues a Bump for admins", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", })).To(Succeed()) @@ -93,10 +96,10 @@ var _ = Describe("Metadata API", func() { Expect(w.Code).To(Equal(http.StatusNoContent)) - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) - queued, err := queueRepo.DequeueBatch(1000) + queued, err := queueRepo.DequeueBatch(ctx, 1000) Expect(err).ToNot(HaveOccurred()) Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "al"), diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index de6595e00..a906e36b9 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -14,24 +14,21 @@ import ( ) type missingRepository struct { - model.ResourceRepository + rest.Repository[model.MediaFile] mfRepo model.MediaFileRepository } -func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor { - return func(ctx context.Context) rest.Repository { - return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})} - } +func newMissingRepository(ds model.DataStore) rest.Repository[model.MediaFile] { + mf := ds.MediaFile() + return &missingRepository{Repository: mf, mfRepo: mf} } -func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) { - opt := r.parseOptions(options) - return r.ResourceRepository.Count(opt) +func (r *missingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { + return r.Repository.Count(ctx, r.parseOptions(options)) } -func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { - opt := r.parseOptions(options) - return r.ResourceRepository.ReadAll(opt) +func (r *missingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) { + return r.Repository.ReadAll(ctx, r.parseOptions(options)) } func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions { @@ -44,8 +41,8 @@ func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.Query return opt } -func (r *missingRepository) Read(id string) (any, error) { - mf, err := r.mfRepo.Get(id) +func (r *missingRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) { + mf, err := r.mfRepo.Get(ctx, id) if err != nil { return nil, err } @@ -55,10 +52,6 @@ func (r *missingRepository) Read(id string) (any, error) { return mf, nil } -func (r *missingRepository) EntityName() string { - return "missing_files" -} - func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -86,5 +79,3 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { writeDeleteManyResponse(w, r, ids) } } - -var _ model.ResourceRepository = &missingRepository{} diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go index 9d7575a0c..a53f13fc3 100644 --- a/server/nativeapi/missing_test.go +++ b/server/nativeapi/missing_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -35,12 +36,12 @@ var _ = Describe("Missing Files Endpoint", func() { auth.Init(ds) user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"} - Expect(userRepo.Put(&user)).To(Succeed()) + Expect(userRepo.Put(GinkgoT().Context(), &user)).To(Succeed()) var err error token, err = auth.CreateToken(&user) Expect(err).ToNot(HaveOccurred()) - router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) + router = server.JWTVerifier(New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) }) DescribeTable("GET /missing/{id}", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index a7c53df09..97ad14be2 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -60,25 +60,25 @@ func (api *Router) routes() http.Handler { r := chi.NewRouter() // Public - api.RX(r, "/translation", newTranslationRepository, false) + rx(r, "/translation", newTranslationRepository(), false) // Protected r.Group(func(r chi.Router) { r.Use(server.Authenticator(api.ds)) 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.R(r, "/album", model.Album{}, false) + rx(r, "/user", api.users.Repository(), true) + rx(r, "/song", api.ds.MediaFile(), false) + rx(r, "/album", api.ds.Album(), false) api.addArtistRoute(r) - api.R(r, "/genre", model.Genre{}, false) - api.R(r, "/player", model.Player{}, true) - api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) + rx(r, "/genre", api.ds.Genre(), false) + rx(r, "/player", api.ds.Player(), true) + rx(r, "/transcoding", api.ds.Transcoding(), conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - api.R(r, "/tag", model.Tag{}, false) - api.R(r, "/scrobble", model.Scrobble{}, false) + rx(r, "/tag", api.ds.Tag(), false) + rx(r, "/scrobble", api.ds.Scrobble(), false) if conf.Server.EnableSharing { - api.RX(r, "/share", api.share.NewRepository, true) + rx(r, "/share", api.share.Repository(), true) } api.addPlaylistRoute(r) @@ -96,47 +96,38 @@ func (api *Router) routes() http.Handler { api.addUserLibraryRoute(r) api.addPluginRoute(r) api.addMetadataRoute(r) - api.RX(r, "/library", api.libs.NewRepository, true) + rx(r, "/library", api.libs.Repository(), true) }) }) return r } -func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable bool) { - constructor := func(ctx context.Context) rest.Repository { - return api.ds.Resource(ctx, model) - } - api.RX(r, pathPrefix, constructor, persistable) -} - -func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { +func rx[T any](r chi.Router, pathPrefix string, repo rest.Repository[T], persistable bool) { r.Route(pathPrefix, func(r chi.Router) { - r.Get("/", rest.GetAll(constructor)) + r.Get("/", rest.GetAll(repo)) if persistable { - r.Post("/", rest.Post(constructor)) + r.Post("/", rest.Post(repo)) } r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(constructor)) + r.Get("/", rest.Get(repo)) if persistable { - r.Put("/", rest.Put(constructor)) - r.Delete("/", rest.Delete(constructor)) + r.Put("/", rest.Put(repo)) + r.Delete("/", rest.Delete(repo)) } }) }) } func (api *Router) addPlaylistRoute(r chi.Router) { - constructor := func(ctx context.Context) rest.Repository { - return api.playlists.NewRepository(ctx) - } + repo := api.playlists.Repository() r.Route("/playlist", func(r chi.Router) { - r.Get("/", rest.GetAll(constructor)) + r.Get("/", rest.GetAll(repo)) r.Post("/", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Content-type") == "application/json" { - rest.Post(constructor)(w, r) + rest.Post(repo)(w, r) return } createPlaylistFromM3U(api.playlists)(w, r) @@ -144,9 +135,9 @@ func (api *Router) addPlaylistRoute(r chi.Router) { r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(constructor)) - r.Put("/", rest.Put(constructor)) - r.Delete("/", rest.Delete(constructor)) + r.Get("/", rest.Get(repo)) + r.Put("/", rest.Put(repo)) + r.Delete("/", rest.Delete(repo)) r.Post("/image", uploadPlaylistImage(api.playlists)) r.Delete("/image", deletePlaylistImage(api.playlists)) }) @@ -198,7 +189,7 @@ func (api *Router) addQueueRoute(r chi.Router) { func (api *Router) addMissingFilesRoute(r chi.Router) { r.Route("/missing", func(r chi.Router) { - api.RX(r, "/", newMissingRepository(api.ds), false) + rx(r, "/", newMissingRepository(api.ds), false) r.Delete("/", deleteMissingFiles(api.maintenance)) }) } diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index 954c872a7..f151b1d72 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -2,6 +2,7 @@ package nativeapi import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -21,6 +23,7 @@ import ( var _ = Describe("Song Endpoints", func() { var ( + ctx context.Context router http.Handler ds *tests.MockDataStore mfRepo *tests.MockMediaFileRepo @@ -31,6 +34,7 @@ var _ = Describe("Song Endpoints", func() { ) BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute @@ -56,7 +60,7 @@ var _ = Describe("Song Endpoints", func() { IsAdmin: false, NewPassword: "testpass", } - err := userRepo.Put(&testUser) + err := userRepo.Put(ctx, &testUser) Expect(err).ToNot(HaveOccurred()) // Create test songs @@ -95,7 +99,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) @@ -369,7 +373,7 @@ var _ = Describe("Song Endpoints", func() { IsAdmin: true, NewPassword: "adminpass", } - err := userRepo.Put(&adminUser) + err := userRepo.Put(ctx, &adminUser) Expect(err).ToNot(HaveOccurred()) // Create JWT token for admin user @@ -392,7 +396,7 @@ var _ = Describe("Song Endpoints", func() { IsAdmin: false, NewPassword: "userpass", } - err := userRepo.Put(®ularUser) + err := userRepo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Create JWT token for regular user diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 00c1575a5..82f138492 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -19,8 +19,6 @@ import ( "github.com/navidrome/navidrome/utils/str" ) -type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc - // writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown. func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { switch { @@ -35,7 +33,7 @@ func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { } } -func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { +func playlistTracksHandler(pls playlists.Playlists, handler func(rest.Repository[model.PlaylistTrack]) http.HandlerFunc, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plsId := chi.URLParam(r, "playlistId") tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r)) @@ -43,12 +41,12 @@ func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refresh http.Error(w, "not found", http.StatusNotFound) return } - handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r) + handler(tracks).ServeHTTP(w, r) } } func getPlaylist(pls playlists.Playlists) http.HandlerFunc { - handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool { + handler := playlistTracksHandler(pls, rest.GetAll[model.PlaylistTrack], func(r *http.Request) bool { return req.Params(r).Int64Or("_start", 0) == 0 }) return func(w http.ResponseWriter, r *http.Request) { @@ -61,7 +59,7 @@ func getPlaylist(pls playlists.Playlists) http.HandlerFunc { } func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc { - return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true }) + return playlistTracksHandler(pls, rest.Get[model.PlaylistTrack], func(*http.Request) bool { return true }) } func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc { diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 349b4a662..82e3bc86a 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -97,7 +97,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { IsAdmin: false, NewPassword: "testpass", } - err := userRepo.Put(&testUser) + err := userRepo.Put(GinkgoT().Context(), &testUser) Expect(err).ToNot(HaveOccurred()) nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) @@ -235,23 +235,15 @@ type mockPlaylistTrackRepo struct { tracks model.PlaylistTracks } -func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) { +func (m *mockPlaylistTrackRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) { return int64(len(m.tracks)), nil } -func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) { +func (m *mockPlaylistTrackRepo) ReadAll(context.Context, ...rest.QueryOptions) ([]model.PlaylistTrack, 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) { +func (m *mockPlaylistTrackRepo) Read(_ context.Context, id string) (*model.PlaylistTrack, error) { for _, t := range m.tracks { if t.ID == id { return &t, nil @@ -262,7 +254,8 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) { type mockPlaylistsService struct { playlists.Playlists - tracksRepo rest.Repository + repo rest.Repository[model.Playlist] + tracksRepo rest.Repository[model.PlaylistTrack] playlist *model.Playlist removeImageFn func(ctx context.Context, id string) error setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error @@ -282,6 +275,10 @@ func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader i return model.ErrNotFound } +func (m *mockPlaylistsService) Repository() rest.Repository[model.Playlist] { + return m.repo +} + func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { if m.playlist == nil { return nil, model.ErrNotFound @@ -289,6 +286,6 @@ func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*mode return m.playlist, nil } -func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository { +func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository[model.PlaylistTrack] { return m.tracksRepo } diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index a7d261681..d34bf23fc 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -15,17 +15,15 @@ import ( ) func (api *Router) addPluginRoute(r chi.Router) { - constructor := func(ctx context.Context) rest.Repository { - return api.ds.Plugin(ctx) - } + repo := api.ds.Plugin() r.Route("/plugin", func(r chi.Router) { r.Use(pluginsEnabledMiddleware) - r.Get("/", rest.GetAll(constructor)) + r.Get("/", rest.GetAll(repo)) r.Post("/rescan", api.rescanPlugins) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(constructor)) + r.Get("/", rest.Get(repo)) r.Put("/", api.updatePlugin) }) }) @@ -68,10 +66,10 @@ type PluginUpdateRequest struct { func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") ctx := r.Context() - repo := api.ds.Plugin(ctx) + repo := api.ds.Plugin() // Get existing plugin to verify it exists - if _, err := repo.Get(id); err != nil { + if _, err := repo.Get(ctx, id); err != nil { if errors.Is(err, rest.ErrPermissionDenied) { http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) return @@ -123,7 +121,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { if enableErr := api.pluginManager.EnablePlugin(ctx, id); enableErr != nil { log.Error(ctx, "Error enabling plugin", "id", id, enableErr) // Refresh plugin from DB to get the error - plugin, err := repo.Get(id) + plugin, err := repo.Get(ctx, id) if err != nil { log.Error(ctx, "Error getting updated plugin after enable failure", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -153,7 +151,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { } // Refresh and return updated plugin - plugin, err := repo.Get(id) + plugin, err := repo.Get(ctx, id) if err != nil { log.Error(ctx, "Error getting updated plugin", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -204,7 +202,7 @@ func validateAndUpdateConfig(ctx context.Context, pm PluginManager, id, configJS // Returns an error if validation or update fails (error response already written). func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { // Get current values if not provided in request - plugin, err := repo.Get(id) + plugin, err := repo.Get(ctx, id) if err != nil { log.Error(ctx, "Error getting plugin for users update", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -237,7 +235,7 @@ func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.Pl // Returns an error if validation or update fails (error response already written). func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { // Get current values if not provided in request - plugin, err := repo.Get(id) + plugin, err := repo.Get(ctx, id) if err != nil { log.Error(ctx, "Error getting plugin for libraries update", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 4e45ddb92..c18d61e65 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" @@ -21,6 +22,7 @@ import ( ) var _ = Describe("Plugin API", func() { + var ctx context.Context var ds *tests.MockDataStore var mockManager *tests.MockPluginManager var router http.Handler @@ -28,13 +30,14 @@ var _ = Describe("Plugin API", func() { var testPlugin1, testPlugin2 model.Plugin BeforeEach(func() { + ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) conf.Server.EnableSharing = false conf.Server.Plugins.Enabled = true ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) + nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users @@ -71,8 +74,8 @@ var _ = Describe("Plugin API", func() { } // Store users in mock datastore - Expect(ds.User(GinkgoT().Context()).Put(&adminUser)).To(Succeed()) - Expect(ds.User(GinkgoT().Context()).Put(®ularUser)).To(Succeed()) + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) }) Context("when plugins are disabled", func() { @@ -104,10 +107,9 @@ var _ = Describe("Plugin API", func() { Expect(err).ToNot(HaveOccurred()) // Store test plugins as admin - ctx := GinkgoT().Context() adminCtx := request.WithUser(ctx, adminUser) - Expect(ds.Plugin(adminCtx).Put(&testPlugin1)).To(Succeed()) - Expect(ds.Plugin(adminCtx).Put(&testPlugin2)).To(Succeed()) + Expect(ds.Plugin().Put(adminCtx, &testPlugin1)).To(Succeed()) + Expect(ds.Plugin().Put(adminCtx, &testPlugin2)).To(Succeed()) }) Describe("GET /api/plugin", func() { @@ -160,9 +162,9 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when EnablePlugin is called mockManager.EnablePluginFn = func(ctx context.Context, id string) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Enabled = true - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"enabled":true}`) @@ -186,9 +188,9 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginConfig is called mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Config = configJSON - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`) @@ -226,9 +228,9 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginConfig is called mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Config = configJSON - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"config":""}`) @@ -251,10 +253,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`) @@ -279,10 +281,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"allUsers":true}`) @@ -307,10 +309,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`) @@ -348,10 +350,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin(adminCtx).Get(id) + p, _ := ds.Plugin().Get(adminCtx, id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin(adminCtx).Put(p) + return ds.Plugin().Put(adminCtx, p) } body := bytes.NewBufferString(`{"users":""}`) diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go index a7700c02c..05188106c 100644 --- a/server/nativeapi/queue.go +++ b/server/nativeapi/queue.go @@ -32,7 +32,7 @@ func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) b // retrieveExistingQueue retrieves an existing play queue for a user with proper error handling. // Returns the queue (nil if not found) and false if an error occurred and response was sent. func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) { - existing, err := ds.PlayQueue(ctx).Retrieve(userID) + existing, err := ds.PlayQueue().Retrieve(ctx, userID) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Error retrieving queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -70,8 +70,8 @@ func getQueue(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() user, _ := request.UserFrom(ctx) - repo := ds.PlayQueue(ctx) - pq, err := repo.RetrieveWithMediaFiles(user.ID) + repo := ds.PlayQueue() + pq, err := repo.RetrieveWithMediaFiles(ctx, user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Error retrieving queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -112,7 +112,7 @@ func saveQueue(ds model.DataStore) http.HandlerFunc { ChangedBy: client, Items: items, } - if err := ds.PlayQueue(ctx).Store(pq); err != nil { + if err := ds.PlayQueue().Store(ctx, pq); err != nil { log.Error(ctx, "Error saving queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -191,7 +191,7 @@ func updateQueue(ds model.DataStore) http.HandlerFunc { } // Perform partial update of the specified columns only - if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil { + if err := ds.PlayQueue().Store(ctx, pq, cols...); err != nil { log.Error(ctx, "Error updating queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -204,7 +204,7 @@ func clearQueue(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() user, _ := request.UserFrom(ctx) - if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil { + if err := ds.PlayQueue().Clear(ctx, user.ID); err != nil { log.Error(ctx, "Error clearing queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go index 0aad09718..6e68fec0e 100644 --- a/server/nativeapi/queue_test.go +++ b/server/nativeapi/queue_test.go @@ -25,7 +25,7 @@ var _ = Describe("Queue Endpoints", func() { repo = &tests.MockPlayQueueRepo{} user = model.User{ID: "u1", UserName: "user"} userRepo = tests.CreateMockUserRepo() - _ = userRepo.Put(&user) + _ = userRepo.Put(GinkgoT().Context(), &user) ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} }) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 3e88af287..4bacbea7a 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -14,17 +14,15 @@ import ( ) func (api *Router) addRadioRoute(r chi.Router) { - constructor := func(ctx context.Context) rest.Repository { - return api.ds.Resource(ctx, model.Radio{}) - } + repo := api.ds.Radio() r.Route("/radio", func(r chi.Router) { - r.Get("/", rest.GetAll(constructor)) - r.Post("/", rest.Post(constructor)) + r.Get("/", rest.GetAll(repo)) + r.Post("/", rest.Post(repo)) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(constructor)) - r.Put("/", rest.Put(constructor)) - r.Delete("/", rest.Delete(constructor)) + r.Get("/", rest.Get(repo)) + r.Put("/", rest.Put(repo)) + r.Delete("/", rest.Delete(repo)) r.Post("/image", api.uploadRadioImage()) r.Delete("/image", api.deleteRadioImage()) }) @@ -34,7 +32,7 @@ func (api *Router) addRadioRoute(r chi.Router) { func (api *Router) uploadRadioImage() http.HandlerFunc { return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { radioID := chi.URLParamFromCtx(ctx, "id") - radio, err := api.ds.Radio(ctx).Get(radioID) + radio, err := api.ds.Radio().Get(ctx, radioID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -47,7 +45,7 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { return err } radio.UploadedImage = filename - if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil { return err } api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) @@ -58,7 +56,7 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { func (api *Router) deleteRadioImage() http.HandlerFunc { return handleImageDelete(func(ctx context.Context) error { radioID := chi.URLParamFromCtx(ctx, "id") - radio, err := api.ds.Radio(ctx).Get(radioID) + radio, err := api.ds.Radio().Get(ctx, radioID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -69,7 +67,7 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return err } radio.UploadedImage = "" - if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil { return err } api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) diff --git a/server/nativeapi/translations.go b/server/nativeapi/translations.go index 39d071279..fc4651c4c 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -23,28 +23,28 @@ type translation struct { TermCount int `json:"termCount"` } -func newTranslationRepository(context.Context) rest.Repository { +func newTranslationRepository() rest.Repository[translation] { return &translationRepository{} } type translationRepository struct{} -func (r *translationRepository) Read(id string) (any, error) { +func (r *translationRepository) Read(_ context.Context, id string) (*translation, error) { translations, _ := loadTranslations() if t, ok := translations[id]; ok { - return t, nil + return &t, nil } return nil, rest.ErrNotFound } // Count simple implementation, does not support any `options` -func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) { +func (r *translationRepository) Count(context.Context, ...rest.QueryOptions) (int64, error) { _, count := loadTranslations() return count, nil } // ReadAll simple implementation, only returns IDs. Does not support any `options` -func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) { +func (r *translationRepository) ReadAll(context.Context, ...rest.QueryOptions) ([]translation, error) { translations, _ := loadTranslations() var result []translation for _, t := range translations { @@ -54,14 +54,6 @@ func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) { return result, nil } -func (r *translationRepository) EntityName() string { - return "translation" -} - -func (r *translationRepository) NewInstance() any { - return &translation{} -} - var loadTranslations = sync.OnceValues(func() (map[string]translation, int64) { translations := make(map[string]translation) fsys := resources.FS() @@ -140,4 +132,4 @@ func countTranslatedTerms(obj map[string]any) int { return count } -var _ rest.Repository = (*translationRepository)(nil) +var _ rest.Repository[translation] = (*translationRepository)(nil) diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go index 27454fc7d..81f28893f 100644 --- a/server/nativeapi/user_password_token_refresh_test.go +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -29,10 +30,12 @@ func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {} // Pins that the token-epoch handoff survives a real request through the real middleware chain. var _ = Describe("PUT /user/{id}: token refresh on self password change", func() { + var ctx context.Context var ds model.DataStore var router http.Handler BeforeEach(func() { + ctx = GinkgoT().Context() // db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package. DeferCleanup(configtest.SetupConfig()) conf.Server.EnableUserEditing = true @@ -45,13 +48,13 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() auth.Init(ds) userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) }) It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() { usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"} - Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + Expect(ds.User().Put(ctx, &usr)).To(Succeed()) token, err := auth.CreateToken(&usr) Expect(err).ToNot(HaveOccurred()) @@ -72,7 +75,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() claims, err := auth.Validate(refreshed) Expect(err).ToNot(HaveOccurred()) - reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID) + reloaded, err := ds.User().Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(reloaded.TokenEpoch).To(Equal(1)) Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch)) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 3d624f661..37ae56c2b 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -26,7 +26,7 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - share, err := pub.ds.Share(ctx).Get(info.shareID) + share, err := pub.ds.Share().Get(ctx, info.shareID) if err != nil { checkShareError(ctx, w, err, info.shareID) return @@ -35,14 +35,14 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { checkShareError(ctx, w, model.ErrExpired, info.shareID) return } - shareOwner, err := pub.ds.User(ctx).Get(share.UserID) + shareOwner, err := pub.ds.User().Get(ctx, share.UserID) if err != nil { log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - mf, err := pub.ds.MediaFile(ctx).Get(info.id) + mf, err := pub.ds.MediaFile().Get(ctx, info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { http.Error(w, "not found", http.StatusNotFound) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 870dfa8ef..4b4a3545b 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -107,12 +107,14 @@ var _ = Describe("encodeMediafileShare", func() { }) var _ = Describe("handleStream", func() { + var ctx context.Context var ds *tests.MockDataStore var shareRepo *tests.MockShareRepo var streamer *mockStreamer var pub *Router BeforeEach(func() { + ctx = GinkgoT().Context() auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) ds = &tests.MockDataStore{} shareRepo = &tests.MockShareRepo{} @@ -132,7 +134,7 @@ var _ = Describe("handleStream", func() { shareRepo.ID = "share123" shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(&owner)).To(Succeed()) + Expect(userRepo.Put(ctx, &owner)).To(Succeed()) ds.MockedUser = userRepo mfRepo := tests.CreateMockMediaFileRepo() mfRepo.SetData(model.MediaFiles{mf}) @@ -171,7 +173,7 @@ var _ = Describe("handleStream", func() { It("returns 404 when the track is not a member of the share", func() { owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(&owner)).To(Succeed()) + Expect(userRepo.Put(ctx, &owner)).To(Succeed()) ds.MockedUser = userRepo mfRepo := tests.CreateMockMediaFileRepo() mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) diff --git a/server/serve_index.go b/server/serve_index.go index 651c8c907..4b093b953 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -32,7 +32,7 @@ func IndexWithShare(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.H // Injects the config in the `index.html` template func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - c, err := ds.User(r.Context()).CountAll() + c, err := ds.User().CountAll(r.Context()) firstTime := c == 0 && err == nil t, err := getIndexTemplate(r, fs) diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 23215a5ef..e2df55c4b 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "fmt" "net/http" @@ -341,7 +342,7 @@ type mockedUserRepo struct { empty bool } -func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) { +func (u *mockedUserRepo) CountAll(context.Context, ...model.QueryOptions) (int64, error) { if u.empty { return 0, nil } diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index ba5f88a68..0a0c65ec7 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -71,14 +71,14 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { opts.Offset = p.IntOr("offset", 0) opts.Max = min(p.IntOr("size", 10), 500) - albums, err := api.ds.Album(r.Context()).GetAll(opts) + albums, err := api.ds.Album().GetAll(r.Context(), opts) if err != nil { log.Error(r, "Error retrieving albums", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") } - count, err := api.ds.Album(r.Context()).CountAll(opts) + count, err := api.ds.Album().CountAll(r.Context(), opts) if err != nil { log.Error(r, "Error counting albums", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") @@ -137,7 +137,7 @@ func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums func() error { artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds) var err error - artists, err = api.ds.Artist(ctx).GetAll(artistOpts) + artists, err = api.ds.Artist().GetAll(ctx, artistOpts) if err != nil { log.Error(r, "Error retrieving starred artists", err) } @@ -147,7 +147,7 @@ func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums func() error { albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) var err error - albums, err = api.ds.Album(ctx).GetAll(albumOpts) + albums, err = api.ds.Album().GetAll(ctx, albumOpts) if err != nil { log.Error(r, "Error retrieving starred albums", err) } @@ -157,7 +157,7 @@ func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums func() error { mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) var err error - mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts) + mediaFiles, err = api.ds.MediaFile().GetAll(ctx, mediaFileOpts) if err != nil { log.Error(r, "Error retrieving starred mediaFiles", err) } @@ -244,7 +244,7 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) opts.Max = size - songs, err := api.ds.MediaFile(r.Context()).GetRandom(opts) + songs, err := api.ds.MediaFile().GetRandom(r.Context(), opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err @@ -286,5 +286,5 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) func (api *Router) getSongs(ctx context.Context, offset, size int, opts filter.Options) (model.MediaFiles, error) { opts.Offset = offset opts.Max = size - return api.ds.MediaFile(ctx).GetAll(opts) + return api.ds.MediaFile().GetAll(ctx, opts) } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 220376b15..c4a8847d4 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -6,7 +6,6 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -21,12 +20,11 @@ var _ = Describe("Album Lists", func() { var ds model.DataStore var mockRepo *tests.MockAlbumRepo var w *httptest.ResponseRecorder - ctx := log.NewContext(context.TODO()) BeforeEach(func() { ds = &tests.MockDataStore{} auth.Init(ds) - mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockRepo = ds.Album().(*tests.MockAlbumRepo) router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) @@ -236,7 +234,7 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) }) It("should return random songs", func() { @@ -328,7 +326,7 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) }) It("should return songs by genre", func() { @@ -422,9 +420,9 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) }) It("should return starred items", func() { @@ -484,9 +482,9 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) }) It("should return starred items in ID3 format", func() { diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 7ac492ca8..6a4c4962d 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -15,8 +15,8 @@ import ( func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.MediaFile(r.Context()) - bookmarks, err := repo.GetBookmarks() + repo := api.ds.MediaFile() + bookmarks, err := repo.GetBookmarks(r.Context()) if err != nil { return nil, err } @@ -46,8 +46,8 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) comment, _ := p.String("comment") position := p.Int64Or("position", 0) - repo := api.ds.MediaFile(r.Context()) - ok, err := repo.Exists(id) + repo := api.ds.MediaFile() + ok, err := repo.Exists(r.Context(), id) if err != nil { return nil, err } @@ -55,7 +55,7 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) return nil, newError(responses.ErrorDataNotFound, "Song not found") } - err = repo.AddBookmark(id, comment, position) + err = repo.AddBookmark(r.Context(), id, comment, position) if err != nil { return nil, err } @@ -69,8 +69,8 @@ func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) return nil, err } - repo := api.ds.MediaFile(r.Context()) - err = repo.DeleteBookmark(id) + repo := api.ds.MediaFile() + err = repo.DeleteBookmark(r.Context(), id) if err != nil { return nil, err } @@ -80,8 +80,8 @@ func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.PlayQueue(r.Context()) - pq, err := repo.RetrieveWithMediaFiles(user.ID) + repo := api.ds.PlayQueue() + pq, err := repo.RetrieveWithMediaFiles(r.Context(), user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } @@ -140,8 +140,8 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { UpdatedAt: time.Time{}, } - repo := api.ds.PlayQueue(r.Context()) - err := repo.Store(pq) + repo := api.ds.PlayQueue() + err := repo.Store(r.Context(), pq) if err != nil { return nil, err } @@ -151,8 +151,8 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.PlayQueue(r.Context()) - pq, err := repo.RetrieveWithMediaFiles(user.ID) + repo := api.ds.PlayQueue() + pq, err := repo.RetrieveWithMediaFiles(r.Context(), user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } @@ -215,8 +215,8 @@ func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, e UpdatedAt: time.Time{}, } - repo := api.ds.PlayQueue(r.Context()) - err = repo.Store(pq) + repo := api.ds.PlayQueue() + err = repo.Store(r.Context(), pq) if err != nil { return nil, err } diff --git a/server/subsonic/bookmarks_test.go b/server/subsonic/bookmarks_test.go index 0fcb81ab9..387ba6ab2 100644 --- a/server/subsonic/bookmarks_test.go +++ b/server/subsonic/bookmarks_test.go @@ -21,7 +21,7 @@ var _ = Describe("Bookmarks", func() { ds = &tests.MockDataStore{} router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"}) - mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mfRepo = ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo.SetData(model.MediaFiles{{ID: "visible"}}) }) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index ac0edb69f..d74468940 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -33,7 +33,7 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { ctx := r.Context() - lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + lastScanStr, err := api.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") if err != nil { log.Error(ctx, "Error retrieving last scan start time", err) return nil, 0, err @@ -45,7 +45,7 @@ func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time var indexes model.ArtistIndexes if lastScan.After(ifModifiedSince) { - indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist) + indexes, err = api.ds.Artist().GetIndex(ctx, false, libIds, model.RoleAlbumArtist) if err != nil { log.Error(ctx, "Error retrieving Indexes", err) return nil, 0, err @@ -167,7 +167,7 @@ func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { id, _ := p.String("id") ctx := r.Context() - artist, err := api.ds.Artist(ctx).Get(id) + artist, err := api.ds.Artist().Get(ctx, id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Requested ArtistID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Artist not found") @@ -191,7 +191,7 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - album, err := api.ds.Album(ctx).Get(id) + album, err := api.ds.Album().Get(ctx, id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Requested AlbumID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Album not found") @@ -201,7 +201,7 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { return nil, err } - mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(id)) + mfs, err := api.ds.MediaFile().GetAll(ctx, filter.SongsByAlbum(id)) if err != nil { log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err) return nil, err @@ -247,7 +247,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { id, _ := p.String("id") ctx := r.Context() - mf, err := api.ds.MediaFile(ctx).Get(id) + mf, err := api.ds.MediaFile().Get(ctx, id) if errors.Is(err, model.ErrNotFound) { log.Error(r, "Requested MediaFileID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Song not found") @@ -264,7 +264,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) + genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) if err != nil { log.Error(r, err) return nil, err @@ -421,7 +421,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis dir.Starred = artist.StarredAt } - albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID)) + albums, err := api.ds.Album().GetAll(ctx, filter.AlbumsByArtistID(artist.ID)) if err != nil { return nil, err } @@ -435,7 +435,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response a := &responses.ArtistWithAlbumsID3{} a.ArtistID3 = toArtistID3(r, *artist) - albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID)) + albums, err := api.ds.Album().GetAll(ctx, filter.AlbumsByArtistID(artist.ID)) if err != nil { return nil, err } @@ -463,7 +463,7 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.Starred = album.StarredAt } - mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID)) + mfs, err := api.ds.MediaFile().GetAll(ctx, filter.SongsByAlbum(album.ID)) if err != nil { return nil, err } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index d34da2f37..71d758cfd 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -83,14 +83,14 @@ var _ = Describe("Browsing", func() { ctx = contextWithUser(ctx, "user-id", 2, 3) // Setup minimal mock library data for working tests - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{ {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, {ID: 3, Name: "Test Library 3", Path: "/music/library3"}, }) // Setup mock artist data - mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) + mockArtistRepo := ds.Artist().(*tests.MockArtistRepo) mockArtistRepo.SetData(model.Artists{ {ID: "1", Name: "Test Artist 1"}, {ID: "2", Name: "Test Artist 2"}, @@ -132,14 +132,14 @@ var _ = Describe("Browsing", func() { ctx = contextWithUser(ctx, "user-id", 1, 2) // Setup minimal mock library data for working tests - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo := ds.Library().(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{ {ID: 1, Name: "Test Library 1", Path: "/music/library1"}, {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, }) // Setup mock artist data - mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) + mockArtistRepo := ds.Artist().(*tests.MockArtistRepo) mockArtistRepo.SetData(model.Artists{ {ID: "1", Name: "Test Artist 1"}, {ID: "2", Name: "Test Artist 2"}, diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 58e877b0d..fdfb562fd 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -222,10 +222,10 @@ func createUser(id, username, name string, isAdmin bool) model.User { IsAdmin: isAdmin, NewPassword: "password", } - Expect(ds.User(ctx).Put(&user)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed()) + Expect(ds.User().Put(ctx, &user)).To(Succeed()) + Expect(ds.User().SetUserLibraries(ctx, user.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := ds.User(ctx).FindByUsername(user.UserName) + loadedUser, err := ds.User().FindByUsername(ctx, user.UserName) Expect(err).ToNot(HaveOccurred()) user.Libraries = loadedUser.Libraries return user diff --git a/server/subsonic/e2e/subsonic_album_lists_test.go b/server/subsonic/e2e/subsonic_album_lists_test.go index 6d32a3c88..e1be3f133 100644 --- a/server/subsonic/e2e/subsonic_album_lists_test.go +++ b/server/subsonic/e2e/subsonic_album_lists_test.go @@ -142,7 +142,7 @@ var _ = Describe("Album List Endpoints", func() { setupTestDB() // Star an album so the starred filter returns results - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -166,7 +166,7 @@ var _ = Describe("Album List Endpoints", func() { setupTestDB() // Rate an album so the highest filter returns results - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Kind of Blue"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 9324ea9e3..9394c8830 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -108,8 +108,8 @@ var _ = Describe("Artwork Serving", Ordered, func() { // harness's MaxOpenConns=1, so wipe the golden content and import this library fresh. wipeScannedContent() artLib := model.Library{Name: "Artwork Library", Path: musicDir} - Expect(ds.Library(ctx).Put(&artLib)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{artLib.ID})).To(Succeed()) + Expect(ds.Library().Put(ctx, &artLib)).To(Succeed()) + Expect(ds.User().SetUserLibraries(ctx, adminUser.ID, []int{artLib.ID})).To(Succeed()) s := scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) @@ -148,20 +148,20 @@ var _ = Describe("Artwork Serving", Ordered, func() { It("drains the queue: folder art is acquired, the artless album settles absent", func() { // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. for _, id := range []string{artfulID, artlessID} { - Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, })).To(Succeed()) } runWorkerUntil(ctx, worker, func() bool { - found, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) + found, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) if err != nil || found.Hash == "" { return false } - absent, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artlessID, model.ImageTypePrimary) + absent, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artlessID, model.ImageTypePrimary) return err == nil && absent.Hash == "" }) - ia, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) + ia, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder")) artfulHash = ia.Hash @@ -277,7 +277,7 @@ func wipeScannedContent() { func albumIDByName(name string) string { GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) return albums[0].ID diff --git a/server/subsonic/e2e/subsonic_bookmarks_test.go b/server/subsonic/e2e/subsonic_bookmarks_test.go index 726b41743..e2d659b9b 100644 --- a/server/subsonic/e2e/subsonic_bookmarks_test.go +++ b/server/subsonic/e2e/subsonic_bookmarks_test.go @@ -19,7 +19,7 @@ var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { BeforeAll(func() { // Get a media file ID from the database to use for bookmarks - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1}) + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).ToNot(BeEmpty()) trackID = mfs[0].ID @@ -69,7 +69,7 @@ var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { BeforeAll(func() { // Get multiple media file IDs from the database - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 3, Sort: "title"}) + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 3, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(len(mfs)).To(BeNumerically(">=", 2)) for _, mf := range mfs { diff --git a/server/subsonic/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go index 992f9e0fb..8aa93ee2e 100644 --- a/server/subsonic/e2e/subsonic_browsing_test.go +++ b/server/subsonic/e2e/subsonic_browsing_test.go @@ -14,7 +14,7 @@ var _ = Describe("Browsing Endpoints", func() { }) getBeatlesId := func() string { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -105,7 +105,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns an album directory with its tracks as children", func() { - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -159,7 +159,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns artist with a single album", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"name": "Led Zeppelin"}, }) Expect(err).ToNot(HaveOccurred()) @@ -177,7 +177,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getAlbum", func() { It("returns album with its tracks", func() { - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -193,7 +193,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("includes correct track metadata", func() { - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -210,7 +210,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns album with correct artist and year", func() { - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Kind of Blue"}, }) Expect(err).ToNot(HaveOccurred()) @@ -236,7 +236,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getSong", func() { It("returns a song by its ID", func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) @@ -260,7 +260,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns correct metadata for a jazz track", func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "So What"}, }) Expect(err).ToNot(HaveOccurred()) @@ -343,7 +343,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getAlbumInfo", func() { It("returns album info for a valid album", func() { - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -359,7 +359,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getAlbumInfo2", func() { It("returns album info for a valid album", func() { - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -434,7 +434,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getSimilarSongs", func() { It("returns a response for a valid song ID", func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) @@ -451,7 +451,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getSimilarSongs2", func() { It("returns a response for a valid song ID", func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go index 74b5238f2..4b90dd143 100644 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ b/server/subsonic/e2e/subsonic_media_annotation_test.go @@ -17,19 +17,19 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { BeforeAll(func() { // Look up a song from the scanned data - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID // Look up an album - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) albumID = albums[0].ID // Look up an artist - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) Expect(err).ToNot(HaveOccurred()) Expect(artists).ToNot(BeEmpty()) artistID = artists[0].ID @@ -97,12 +97,12 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { var songID, albumID string BeforeAll(func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) albumID = albums[0].ID @@ -141,7 +141,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Describe("Scrobble", func() { It("submits a scrobble for a song", func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) @@ -163,7 +163,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { var songID string BeforeAll(func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID diff --git a/server/subsonic/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go index 268d93b82..b0ef4fd72 100644 --- a/server/subsonic/e2e/subsonic_media_retrieval_test.go +++ b/server/subsonic/e2e/subsonic_media_retrieval_test.go @@ -21,7 +21,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { BeforeAll(func() { // All test tracks are mp3 at 320kbps - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) trackID = songs[0].ID @@ -110,7 +110,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { BeforeAll(func() { // All test tracks are mp3 at 320kbps - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) trackID = songs[0].ID diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index 18e8c6391..8158be1de 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -44,10 +44,10 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Create the second library in the DB (Put auto-assigns admin users) lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"} - Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) // Reload admin user to get both libraries in the Libraries field - loadedAdmin, err := ds.User(ctx).FindByUsername(adminUser.UserName) + loadedAdmin, err := ds.User().FindByUsername(ctx, adminUser.UserName) Expect(err).ToNot(HaveOccurred()) adminWithLibs = *loadedAdmin @@ -65,10 +65,10 @@ var _ = Describe("Multi-Library Support", Ordered, func() { IsAdmin: false, NewPassword: "password", } - Expect(ds.User(ctx).Put(&userLib1Only)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(userLib1Only.ID, []int{lib.ID})).To(Succeed()) + Expect(ds.User().Put(ctx, &userLib1Only)).To(Succeed()) + Expect(ds.User().SetUserLibraries(ctx, userLib1Only.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := ds.User(ctx).FindByUsername(userLib1Only.UserName) + loadedUser, err := ds.User().FindByUsername(ctx, userLib1Only.UserName) Expect(err).ToNot(HaveOccurred()) userLib1Only.Libraries = loadedUser.Libraries }) @@ -181,7 +181,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { BeforeAll(func() { // Look up one song from each library - lib1Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + lib1Songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"media_file.library_id": lib.ID}, Max: 1, Sort: "title", }) @@ -189,7 +189,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(lib1Songs).ToNot(BeEmpty()) lib1SongID = lib1Songs[0].ID - lib2Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + lib2Songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"media_file.library_id": lib2.ID}, Max: 1, Sort: "title", }) @@ -248,7 +248,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { var lib2AlbumID string BeforeAll(func() { - lib2Albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + lib2Albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_playlists_test.go b/server/subsonic/e2e/subsonic_playlists_test.go index 467535df7..7a6d7df5d 100644 --- a/server/subsonic/e2e/subsonic_playlists_test.go +++ b/server/subsonic/e2e/subsonic_playlists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { setupTestDB() // Look up song IDs from scanned data for playlist operations - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6}) Expect(err).ToNot(HaveOccurred()) Expect(len(songs)).To(BeNumerically(">=", 5)) for _, s := range songs { @@ -244,7 +244,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6}) Expect(err).ToNot(HaveOccurred()) Expect(len(songs)).To(BeNumerically(">=", 3)) for _, s := range songs { @@ -438,7 +438,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { setupTestDB() // Look up a song ID for mutation tests - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID @@ -450,7 +450,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Public: false, Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, } - Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, smartPls)).To(Succeed()) smartPlaylistID = smartPls.ID }) @@ -525,7 +525,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID @@ -543,7 +543,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}, } - Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, boolPls)).To(Succeed()) boolPlaylistID = boolPls.ID // Create smart playlist with string "true" @@ -552,7 +552,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}}, } - Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, stringPls)).To(Succeed()) stringPlaylistID = stringPls.ID // Create smart playlist with string "true" in nested any group (exact issue #4826 scenario) @@ -565,7 +565,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { }, }}, } - Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, nestedPls)).To(Succeed()) nestedPlaylistID = nestedPls.ID }) @@ -607,7 +607,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}}, } - Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) resp := doReq("getPlaylist", "id", pls.ID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -620,7 +620,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, } - Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) resp := doReq("getPlaylist", "id", pls.ID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -633,14 +633,14 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}}, } - Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, boolPls)).To(Succeed()) stringPls := &model.Playlist{ Name: "Genre Missing String2", OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, } - Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, stringPls)).To(Succeed()) boolResp := doReq("getPlaylist", "id", boolPls.ID) stringResp := doReq("getPlaylist", "id", stringPls.ID) @@ -654,19 +654,19 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, } - Expect(ds.Playlist(ctx).Put(allPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, allPls)).To(Succeed()) missingPls := &model.Playlist{ Name: "Missing " + fieldName, OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}}, } - Expect(ds.Playlist(ctx).Put(missingPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, missingPls)).To(Succeed()) presentPls := &model.Playlist{ Name: "Present " + fieldName, OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}}, } - Expect(ds.Playlist(ctx).Put(presentPls)).To(Succeed()) + Expect(ds.Playlist().Put(ctx, presentPls)).To(Succeed()) allResp := doReq("getPlaylist", "id", allPls.ID) missingResp := doReq("getPlaylist", "id", missingPls.ID) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 1ae68dc1a..cf3e55d74 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -18,14 +18,14 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { conf.Server.EnableSharing = true setupTestDB() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) albumID = albums[0].ID - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) @@ -139,7 +139,7 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { userA = createUser("share-user-a", "share-user-a", "Share User A", false) userB = createUser("share-user-b", "share-user-b", "Share User B", false) - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -221,7 +221,7 @@ var _ = Describe("Sharing Downloadable Default", func() { resp := doReq("createShare", append([]string{"id", albumID}, params...)...) Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Shares.Share).To(HaveLen(1)) - share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID) + share, err := ds.Share().Get(ctx, resp.Shares.Share[0].ID) Expect(err).ToNot(HaveOccurred()) return share } @@ -248,7 +248,7 @@ var _ = Describe("Sharing Downloadable Default", func() { resp := doReq("updateShare", "id", share.ID, "description", "Updated") Expect(resp.Status).To(Equal(responses.StatusOK)) - updated, err := ds.Share(ctx).Get(share.ID) + updated, err := ds.Share().Get(ctx, share.ID) Expect(err).ToNot(HaveOccurred()) Expect(updated.Description).To(Equal("Updated")) Expect(updated.Downloadable).To(BeTrue()) @@ -261,7 +261,7 @@ var _ = Describe("Sharing Downloadable Default", func() { resp := doReq("updateShare", "id", share.ID, "downloadable", "false") Expect(resp.Status).To(Equal(responses.StatusOK)) - updated, err := ds.Share(ctx).Get(share.ID) + updated, err := ds.Share().Get(ctx, share.ID) Expect(err).ToNot(HaveOccurred()) Expect(updated.Downloadable).To(BeFalse()) Expect(updated.Description).To(Equal("Keep me")) @@ -273,7 +273,7 @@ var _ = Describe("Sharing Downloadable Default", func() { resp := doReq("updateShare", "id", share.ID, "description", "") Expect(resp.Status).To(Equal(responses.StatusOK)) - updated, err := ds.Share(ctx).Get(share.ID) + updated, err := ds.Share().Get(ctx, share.ID) Expect(err).ToNot(HaveOccurred()) Expect(updated.Description).To(BeEmpty()) }) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index c0cb1d359..80fa658b7 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -100,14 +100,14 @@ var _ = Describe("Sonic Similarity Endpoints", func() { ) BeforeEach(func() { - songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) comeTogether = songs[0] - songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + songs, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"title": "Something"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_stream_test.go b/server/subsonic/e2e/subsonic_stream_test.go index 281524636..81998760f 100644 --- a/server/subsonic/e2e/subsonic_stream_test.go +++ b/server/subsonic/e2e/subsonic_stream_test.go @@ -21,7 +21,7 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile(ctx).GetAll() + songs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) byTitle := map[string]string{} for _, s := range songs { diff --git a/server/subsonic/e2e/subsonic_transcode_test.go b/server/subsonic/e2e/subsonic_transcode_test.go index afe7d52ca..313066938 100644 --- a/server/subsonic/e2e/subsonic_transcode_test.go +++ b/server/subsonic/e2e/subsonic_transcode_test.go @@ -127,7 +127,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile(ctx).GetAll() + songs, err := ds.MediaFile().GetAll(ctx) Expect(err).ToNot(HaveOccurred()) byTitle := map[string]string{} for _, s := range songs { @@ -153,29 +153,29 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // It makes a dummy request to register the player, then updates it via the repository. setPlayerMaxBitRate := func(maxBitRate int) { doReq("ping") - player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") Expect(err).ToNot(HaveOccurred()) player.MaxBitRate = maxBitRate - Expect(ds.Player(ctx).Put(player)).To(Succeed()) + Expect(ds.Player().Put(ctx, player)).To(Succeed()) } setPlayerForcedFormat := func(format string) { doReq("ping") - player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") Expect(err).ToNot(HaveOccurred()) - trc, err := ds.Transcoding(ctx).FindByFormat(format) + trc, err := ds.Transcoding().FindByFormat(ctx, format) Expect(err).ToNot(HaveOccurred()) player.TranscodingId = trc.ID - Expect(ds.Player(ctx).Put(player)).To(Succeed()) + Expect(ds.Player().Put(ctx, player)).To(Succeed()) } AfterEach(func() { // Reset player MaxBitRate to 0 after each test - player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") if err == nil { player.MaxBitRate = 0 player.TranscodingId = "" - _ = ds.Player(ctx).Put(player) + _ = ds.Player().Put(ctx, player) } }) @@ -595,13 +595,13 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(token).ToNot(BeEmpty()) // Save original UpdatedAt and restore after test - mf, err := ds.MediaFile(ctx).Get(mp3TrackID) + mf, err := ds.MediaFile().Get(ctx, mp3TrackID) Expect(err).ToNot(HaveOccurred()) originalUpdatedAt := mf.UpdatedAt // Update the media file's UpdatedAt to simulate a change after token issuance mf.UpdatedAt = time.Now().Add(time.Hour) - Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) + Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed()) // Attempt to stream with the now-stale token w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) @@ -609,7 +609,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // Restore original UpdatedAt mf.UpdatedAt = originalUpdatedAt - Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) + Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed()) }) It("returns 500 when stream creation fails", func() { diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index 9630425d2..89e5cbc9b 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -53,7 +53,7 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { } // Validate all libraries in targets exist and user has access to them - userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID) + userLibraries, err := api.ds.User().GetUserLibraries(ctx, loggedUser.ID) if err != nil { return nil, newError(responses.ErrorGeneric, "Internal error") } @@ -67,7 +67,7 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { // Special case: if single library with empty path and it's the only library in DB, call ScanAll if len(targets) == 1 && targets[0].FolderPath == "" { - allLibs, err := api.ds.Library(ctx).GetAll() + allLibs, err := api.ds.Library().GetAll(ctx) if err != nil { return nil, newError(responses.ErrorGeneric, "Internal error") } diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index 771fc3352..e2b8827f4 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -80,7 +80,7 @@ var _ = Describe("LibraryScanning", func() { It("triggers a selective scan with single target parameter", func() { // Setup mocks mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -116,7 +116,7 @@ var _ = Describe("LibraryScanning", func() { It("triggers a selective scan with multiple target parameters", func() { // Setup mocks mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -154,7 +154,7 @@ var _ = Describe("LibraryScanning", func() { It("triggers a selective full scan with target and fullScan parameters", func() { // Setup mocks mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries("admin-id", []int{1}) + _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -235,7 +235,7 @@ var _ = Describe("LibraryScanning", func() { It("returns error when library does not exist", func() { // Setup mocks - user has access to library 1 and 2 only mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -264,7 +264,7 @@ var _ = Describe("LibraryScanning", func() { It("calls ScanAll when single library with empty path and only one library exists", func() { // Setup mocks - single library in DB mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries("admin-id", []int{1}) + _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1}) mockLibraryRepo := &tests.MockLibraryRepo{} mockLibraryRepo.SetData(model.Libraries{ {ID: 1, Name: "Music Library", Path: "/music"}, @@ -302,7 +302,7 @@ var _ = Describe("LibraryScanning", func() { It("calls ScanFolders when single library with empty path but multiple libraries exist", func() { // Setup mocks - multiple libraries in DB mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) mockLibraryRepo := &tests.MockLibraryRepo{} mockLibraryRepo.SetData(model.Libraries{ {ID: 1, Name: "Music Library", Path: "/music"}, diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index a43162cf4..d6d160570 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -48,19 +48,19 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { } switch entity.(type) { case *model.Artist: - repo = api.ds.Artist(ctx) + repo = api.ds.Artist() resource = "artist" case *model.Album: - repo = api.ds.Album(ctx) + repo = api.ds.Album() resource = "album" case *model.Playlist: - repo = api.ds.Playlist(ctx) + repo = api.ds.Playlist() resource = "playlist" default: - repo = api.ds.MediaFile(ctx) + repo = api.ds.MediaFile() resource = "song" } - err = repo.SetRating(rating, id) + err = repo.SetRating(ctx, rating, id) if err != nil { return err } @@ -129,19 +129,19 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error } switch entity.(type) { case *model.Artist: - repo = tx.Artist(ctx) + repo = tx.Artist() resource = "artist" case *model.Album: - repo = tx.Album(ctx) + repo = tx.Album() resource = "album" case *model.Playlist: - repo = tx.Playlist(ctx) + repo = tx.Playlist() resource = "playlist" default: - repo = tx.MediaFile(ctx) + repo = tx.MediaFile() resource = "song" } - if err := repo.SetStar(star, id); err != nil { + if err := repo.SetStar(ctx, star, id); err != nil { return err } event = event.With(resource, id) @@ -210,7 +210,7 @@ func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []ti } func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error { - mf, err := api.ds.MediaFile(ctx).Get(trackId) + mf, err := api.ds.MediaFile().Get(ctx, trackId) if err != nil { return err } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1b16dfc68..9948929e3 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -77,7 +77,7 @@ var _ = Describe("MediaAnnotationController", func() { Context("submission=false", func() { var req *http.Request BeforeEach(func() { - _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "12"}) + _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "12"}) ctx = request.WithPlayer(ctx, model.Player{ID: "player-1"}) req = newGetRequest("id=12", "submission=false") req = req.WithContext(ctx) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 8a5152a9d..f5e2ceb25 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -29,7 +29,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses return nil, err } ctx := r.Context() - u, err := api.ds.User(ctx).FindByUsername(username) + u, err := api.ds.User().FindByUsername(ctx, username) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } - mediaFile, err := api.ds.MediaFile(r.Context()).Get(id) + mediaFile, err := api.ds.MediaFile().Get(r.Context(), id) if err != nil { return nil, err } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7610c866a..ad193518d 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { albumRepo := &tests.MockAlbumRepo{} albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible radioRepo := tests.CreateMockedRadioRepo() - Expect(radioRepo.Put(&model.Radio{ID: "rd1", Name: "Radio"})).To(Succeed()) + Expect(radioRepo.Put(GinkgoT().Context(), &model.Radio{ID: "rd1", Name: "Radio"})).To(Succeed()) ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, MockedAlbum: albumRepo, @@ -293,8 +293,8 @@ type mockedMediaFile struct { tests.MockMediaFileRepo } -func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) { - data, err := m.MockMediaFileRepo.GetAll(opts...) +func (m *mockedMediaFile) GetAll(ctx context.Context, opts ...model.QueryOptions) (model.MediaFiles, error) { + data, err := m.MockMediaFileRepo.GetAll(ctx, opts...) if err != nil { return nil, err } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 35e13eaa5..6617661a9 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -109,7 +109,7 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { username, isInternalAuth := fromInternalOrProxyAuth(r) if username != "" { authType := If(isInternalAuth, "internal", "reverse-proxy") - usr, err = ds.User(ctx).FindByUsername(username) + usr, err = ds.User().FindByUsername(ctx, username) if errors.Is(err, context.Canceled) { log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) return @@ -139,7 +139,7 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { return } - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + usr, err = ds.User().FindByUsernameWithPassword(ctx, username) if err == nil { err = validateCredentials(usr, pass, token, salt, jwt) } diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 62de09e76..0879ee540 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -42,11 +42,13 @@ func newPostRequest(queryParam string, formFields ...string) *http.Request { } var _ = Describe("Middlewares", func() { + var ctx context.Context var next *mockHandler var w *httptest.ResponseRecorder var ds model.DataStore BeforeEach(func() { + ctx = GinkgoT().Context() next = &mockHandler{} w = httptest.NewRecorder() ds = &tests.MockDataStore{} @@ -147,8 +149,8 @@ var _ = Describe("Middlewares", func() { Describe("Authenticate", func() { BeforeEach(func() { - ur := ds.User(context.TODO()) - _ = ur.Put(&model.User{ + ur := ds.User() + _ = ur.Put(ctx, &model.User{ UserName: "admin", NewPassword: "wordpass", }) @@ -344,7 +346,7 @@ var _ = Describe("Middlewares", func() { It("counts attempts against unknown usernames", func() { failTimes(3, "u=newuser", "p=secret") - _ = ds.User(context.TODO()).Put(&model.User{UserName: "newuser", NewPassword: "secret"}) + _ = ds.User().Put(ctx, &model.User{UserName: "newuser", NewPassword: "secret"}) serve(newGetRequest("u=newuser", "p=secret")) Expect(next.called).To(BeFalse()) @@ -365,7 +367,7 @@ var _ = Describe("Middlewares", func() { }) It("does not count server errors", func() { - userRepo := ds.User(context.TODO()).(*tests.MockedUserRepo) + userRepo := ds.User().(*tests.MockedUserRepo) userRepo.Error = errors.New("db down") failTimes(5, "u=admin", "p=wordpass") userRepo.Error = nil @@ -375,7 +377,7 @@ var _ = Describe("Middlewares", func() { }) It("does not block other usernames from the same IP", func() { - _ = ds.User(context.TODO()).Put(&model.User{UserName: "other", NewPassword: "otherpass"}) + _ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"}) failTimes(3, "u=admin", "p=WRONG") serve(newGetRequest("u=other", "p=otherpass")) @@ -422,7 +424,7 @@ var _ = Describe("Middlewares", func() { conf.Server.AuthRequestLimit = 5 conf.Server.AuthWindowLength = time.Minute gate = &gatedUserRepo{ - UserRepository: ds.User(context.TODO()), + UserRepository: ds.User(), entered: make(chan struct{}, 64), proceed: make(chan struct{}), } @@ -583,14 +585,14 @@ var _ = Describe("Middlewares", func() { var usr *model.User BeforeEach(func() { - ur := ds.User(context.TODO()) - _ = ur.Put(&model.User{ + ur := ds.User() + _ = ur.Put(ctx, &model.User{ UserName: "admin", NewPassword: "wordpass", }) var err error - usr, err = ur.FindByUsernameWithPassword("admin") + usr, err = ur.FindByUsernameWithPassword(ctx, "admin") if err != nil { panic(err) } @@ -728,7 +730,7 @@ type gatedDataStore struct { users model.UserRepository } -func (g *gatedDataStore) User(context.Context) model.UserRepository { return g.users } +func (g *gatedDataStore) User() model.UserRepository { return g.users } type gatedUserRepo struct { model.UserRepository @@ -737,11 +739,11 @@ type gatedUserRepo struct { lookups atomic.Int32 } -func (g *gatedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { +func (g *gatedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { g.lookups.Add(1) g.entered <- struct{}{} <-g.proceed - return g.UserRepository.FindByUsernameWithPassword(username) + return g.UserRepository.FindByUsernameWithPassword(ctx, username) } type countingHandler struct{ calls atomic.Int32 } diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 1fb266f1d..2c4033467 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -32,7 +32,7 @@ func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio(ctx).Put(radio) + err = api.ds.Radio().Put(ctx, radio) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, er return nil, err } - err = api.ds.Radio(r.Context()).Delete(id) + err = api.ds.Radio().Delete(r.Context(), id) if err != nil { return nil, err } @@ -56,7 +56,7 @@ func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, er func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - radios, err := api.ds.Radio(ctx).GetAll(model.QueryOptions{Sort: "name"}) + radios, err := api.ds.Radio().GetAll(ctx, model.QueryOptions{Sort: "name"}) if err != nil { return nil, err } @@ -119,7 +119,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name") + err = api.ds.Radio().Put(ctx, radio, "StreamUrl", "HomePageUrl", "Name") if err != nil { return nil, err } diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index 35233a98f..fb370fc9d 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -42,7 +42,7 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { return sp, nil } -type searchFunc[T any] func(q string, options ...model.QueryOptions) (T, error) +type searchFunc[T any] func(ctx context.Context, q string, options ...model.QueryOptions) (T, error) func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options model.QueryOptions, result *T) func() error { return func() error { @@ -52,7 +52,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options m typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") var err error start := time.Now() - *result, err = s(q, options) + *result, err = s(ctx, q, options) if err != nil { log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) } else { @@ -79,9 +79,9 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI // Run searches in parallel g, ctx := errgroup.WithContext(ctx) - g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, songOpts, &mediaFiles)) - g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, albumOpts, &albums)) - g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, artistOpts, &artists)) + g.Go(callSearch(ctx, api.ds.MediaFile().Search, q, songOpts, &mediaFiles)) + g.Go(callSearch(ctx, api.ds.Album().Search, q, albumOpts, &albums)) + g.Go(callSearch(ctx, api.ds.Artist().Search, q, artistOpts, &artists)) err := g.Wait() if err == nil { log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index d31a50cfa..177fa2133 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -26,9 +26,9 @@ var _ = Describe("Search", func() { router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options - mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) - mockArtistRepo = ds.Artist(nil).(*tests.MockArtistRepo) - mockMediaFileRepo = ds.MediaFile(nil).(*tests.MockMediaFileRepo) + mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) + mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) + mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) }) Context("musicFolderId parameter", func() { diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index c4b735832..e5f4f2a54 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" @@ -16,8 +15,8 @@ import ( ) func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { - repo := api.share.NewRepository(r.Context()).(model.ShareRepository) - shares, err := repo.GetAll(model.QueryOptions{Sort: "created_at desc"}) + repo := api.share.Repository() + shares, err := repo.GetAll(r.Context(), model.QueryOptions{Sort: "created_at desc"}) if err != nil { return nil, err } @@ -60,7 +59,7 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - repo := api.share.NewRepository(r.Context()) + repo := api.share.Repository() share := &model.Share{ Description: description, Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads), @@ -68,12 +67,12 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { ResourceIDs: strings.Join(ids, ","), } - id, err := repo.(rest.Persistable).Save(share) + id, err := repo.Save(r.Context(), share) if err != nil { return nil, err } - share, err = repo.(model.ShareRepository).Get(id) + share, err = repo.Get(r.Context(), id) if err != nil { return nil, err } @@ -90,18 +89,17 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - repo := api.share.NewRepository(r.Context()) + repo := api.share.Repository() // The update always writes description and downloadable, so read back the // stored value for whichever one the client omitted. description := p.StringPtr("description") downloadable := p.BoolPtr("downloadable") if description == nil || downloadable == nil { - current, err := repo.Read(id) + cur, err := repo.Read(r.Context(), id) if err != nil { return nil, err } - cur := current.(*model.Share) description = cmp.Or(description, &cur.Description) downloadable = cmp.Or(downloadable, &cur.Downloadable) } @@ -113,7 +111,7 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { ExpiresAt: new(p.TimeOr("expires", time.Time{})), } - err = repo.(rest.Persistable).Update(id, share) + err = repo.Update(r.Context(), id, *share) if err != nil { return nil, err } @@ -128,8 +126,8 @@ func (api *Router) DeleteShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - repo := api.share.NewRepository(r.Context()) - err = repo.(rest.Persistable).Delete(id) + repo := api.share.Repository() + err = repo.Delete(r.Context(), id) if err != nil { return nil, err } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index b4a6b821c..fd26ccc4d 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -27,7 +27,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su format, _ := p.String("format") timeOffset := p.IntOr("timeOffset", 0) - mf, err := api.ds.MediaFile(ctx).Get(id) + mf, err := api.ds.MediaFile().Get(ctx, id) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index d64bce605..7a011a616 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -310,7 +310,7 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } // Get media file - mf, err := api.ds.MediaFile(ctx).Get(mediaID) + mf, err := api.ds.MediaFile().Get(ctx, mediaID) if err != nil { if errors.Is(err, model.ErrNotFound) { return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID) @@ -399,7 +399,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* } // Fetch the media file - mf, err := api.ds.MediaFile(ctx).Get(mediaID) + mf, err := api.ds.MediaFile().Get(ctx, mediaID) if err != nil { if errors.Is(err, model.ErrNotFound) { http.Error(w, "Not Found", http.StatusNotFound) diff --git a/tests/harness/harness.go b/tests/harness/harness.go index 5949c4fae..92196ef26 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -61,14 +61,14 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { auth.Init(ds) h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(ds.Library(ctx).Put(&h.Library)).To(Succeed()) + Expect(ds.Library().Put(ctx, &h.Library)).To(Succeed()) for _, u := range users { seeded := *u seeded.NewPassword = "password" - Expect(ds.User(ctx).Put(&seeded)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(u.ID, []int{h.Library.ID})).To(Succeed()) - loaded, err := ds.User(ctx).FindByUsername(u.UserName) + Expect(ds.User().Put(ctx, &seeded)).To(Succeed()) + Expect(ds.User().SetUserLibraries(ctx, u.ID, []int{h.Library.ID})).To(Succeed()) + loaded, err := ds.User().FindByUsername(ctx, u.UserName) Expect(err).ToNot(HaveOccurred()) u.Libraries = loaded.Libraries } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 1b14f225b..f8100c189 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "errors" "sync" "time" @@ -39,7 +40,7 @@ func (m *MockAlbumRepo) SetData(albums model.Albums) { } } -func (m *MockAlbumRepo) Exists(id string) (bool, error) { +func (m *MockAlbumRepo) Exists(_ context.Context, id string) (bool, error) { if m.Err { return false, errors.New("unexpected error") } @@ -47,7 +48,7 @@ func (m *MockAlbumRepo) Exists(id string) (bool, error) { return found, nil } -func (m *MockAlbumRepo) Get(id string) (*model.Album, error) { +func (m *MockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) { if m.Err { return nil, errors.New("unexpected error") } @@ -57,7 +58,7 @@ func (m *MockAlbumRepo) Get(id string) (*model.Album, error) { return nil, model.ErrNotFound } -func (m *MockAlbumRepo) Put(al *model.Album) error { +func (m *MockAlbumRepo) Put(_ context.Context, al *model.Album) error { if m.Err { return errors.New("unexpected error") } @@ -71,7 +72,7 @@ func (m *MockAlbumRepo) Put(al *model.Album) error { return nil } -func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { +func (m *MockAlbumRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Albums, error) { if len(qo) > 0 { // Recording the last options is a read-path write, and callers resolve concurrently. m.optionsMu.Lock() @@ -84,8 +85,8 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } -func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { - res, err := m.GetAll(qo...) +func (m *MockAlbumRepo) GetCursor(ctx context.Context, qo ...model.QueryOptions) (model.AlbumCursor, error) { + res, err := m.GetAll(ctx, qo...) if err != nil { return nil, err } @@ -98,7 +99,7 @@ func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, }, nil } -func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { +func (m *MockAlbumRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { if m.Err { return errors.New("unexpected error") } @@ -109,11 +110,11 @@ func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { } return model.ErrNotFound } -func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) { +func (m *MockAlbumRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { return int64(len(m.All)), nil } -func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { +func (m *MockAlbumRepo) GetTouchedAlbums(_ context.Context, libID int) (model.AlbumCursor, error) { if m.Err { return nil, errors.New("unexpected error") } @@ -135,11 +136,11 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { }, nil } -func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { - return m.Put(album) +func (m *MockAlbumRepo) UpdateExternalInfo(ctx context.Context, album *model.Album) error { + return m.Put(ctx, album) } -func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { +func (m *MockAlbumRepo) Search(_ context.Context, q string, options ...model.QueryOptions) (model.Albums, error) { m.SearchQuery = q if len(options) > 0 { m.Options = options[0] @@ -152,7 +153,7 @@ func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.A } // ReassignAnnotation reassigns annotations from one album to another -func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error { +func (m *MockAlbumRepo) ReassignAnnotation(_ context.Context, prevID string, newID string) error { if m.Err { return errors.New("unexpected error") } @@ -165,7 +166,7 @@ func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error { } // CopyAttributes copies attributes from one album to another -func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error { +func (m *MockAlbumRepo) CopyAttributes(_ context.Context, fromID, toID string, columns ...string) error { if m.Err { return errors.New("unexpected error") } @@ -191,7 +192,7 @@ func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) e } // SetRating sets the rating for an album -func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { +func (m *MockAlbumRepo) SetRating(_ context.Context, rating int, itemID string) error { if m.Err { return errors.New("unexpected error") } @@ -202,7 +203,7 @@ func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { } // SetStar sets the starred status for albums -func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { +func (m *MockAlbumRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { if m.Err { return errors.New("unexpected error") } @@ -214,7 +215,7 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { return nil } -func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) { +func (m *MockAlbumRepo) GetYears(_ context.Context, libraryIDs ...int) ([]int, error) { if m.Err { return nil, errors.New("error") } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index db7d54d5d..fe63edd94 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "errors" "time" @@ -32,7 +33,7 @@ func (m *MockArtistRepo) SetData(artists model.Artists) { } } -func (m *MockArtistRepo) Exists(id string) (bool, error) { +func (m *MockArtistRepo) Exists(_ context.Context, id string) (bool, error) { if m.Err { return false, errors.New("Error!") } @@ -40,7 +41,7 @@ func (m *MockArtistRepo) Exists(id string) (bool, error) { return found, nil } -func (m *MockArtistRepo) Get(id string) (*model.Artist, error) { +func (m *MockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) { if m.Err { return nil, errors.New("Error!") } @@ -50,7 +51,7 @@ func (m *MockArtistRepo) Get(id string) (*model.Artist, error) { return nil, model.ErrNotFound } -func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { +func (m *MockArtistRepo) Put(_ context.Context, ar *model.Artist, columsToUpdate ...string) error { if m.Err { return errors.New("error") } @@ -64,7 +65,7 @@ func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { return nil } -func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { +func (m *MockArtistRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { if m.Err { return errors.New("error") } @@ -76,7 +77,7 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } -func (m *MockArtistRepo) SetStar(starred bool, itemIDs ...string) error { +func (m *MockArtistRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { if m.Err { return errors.New("error") } @@ -88,7 +89,7 @@ func (m *MockArtistRepo) SetStar(starred bool, itemIDs ...string) error { return nil } -func (m *MockArtistRepo) SetRating(rating int, itemID string) error { +func (m *MockArtistRepo) SetRating(_ context.Context, rating int, itemID string) error { if m.Err { return errors.New("error") } @@ -98,7 +99,7 @@ func (m *MockArtistRepo) SetRating(rating int, itemID string) error { return nil } -func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { +func (m *MockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] } @@ -116,8 +117,8 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } -func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - res, err := m.GetAll(options...) +func (m *MockArtistRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { + res, err := m.GetAll(ctx, options...) if err != nil { return nil, err } @@ -130,30 +131,30 @@ func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistC }, nil } -func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { - return m.Put(artist) +func (m *MockArtistRepo) UpdateExternalInfo(ctx context.Context, artist *model.Artist) error { + return m.Put(ctx, artist) } -func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { +func (m *MockArtistRepo) RefreshStats(_ context.Context, allArtists bool) (int64, error) { if m.Err { return 0, errors.New("mock repo error") } return int64(len(m.Data)), nil } -func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) { +func (m *MockArtistRepo) RefreshPlayCounts(_ context.Context) (int64, error) { if m.Err { return 0, errors.New("mock repo error") } return int64(len(m.Data)), nil } -func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { +func (m *MockArtistRepo) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { if m.Err { return nil, errors.New("mock repo error") } - artists, err := m.GetAll() + artists, err := m.GetAll(ctx) if err != nil { return nil, err } @@ -181,14 +182,14 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } -func (m *MockArtistRepo) CountAll(...model.QueryOptions) (int64, error) { +func (m *MockArtistRepo) CountAll(context.Context, ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("mock repo error") } return int64(len(m.Data)), nil } -func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { +func (m *MockArtistRepo) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] } @@ -196,7 +197,7 @@ func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model. return nil, errors.New("unexpected error") } // Simple mock implementation - just return all artists for testing - return m.GetAll() + return m.GetAll(ctx) } var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index c482e2150..c6a7917f0 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -2,6 +2,7 @@ package tests import ( "cmp" + "context" "slices" "sync" "time" @@ -26,7 +27,7 @@ func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} } -func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { +func (m *MockArtworkQueueRepo) Get(_ context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -39,7 +40,7 @@ func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*mode return &it, nil } -func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { +func (m *MockArtworkQueueRepo) Enqueue(_ context.Context, items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -73,7 +74,7 @@ func (m *MockArtworkQueueRepo) enqueueLocked(items []model.ArtworkQueueItem) { } // EnqueueIfMissing mirrors the SQL anti-join: skip anything that already has an item_artwork row. -func (m *MockArtworkQueueRepo) EnqueueIfMissing(items ...model.ArtworkQueueItem) error { +func (m *MockArtworkQueueRepo) EnqueueIfMissing(_ context.Context, items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -96,7 +97,7 @@ func (m *MockArtworkQueueRepo) EnqueueIfMissing(items ...model.ArtworkQueueItem) return nil } -func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { +func (m *MockArtworkQueueRepo) DequeueBatch(_ context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -118,7 +119,7 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.Art return res, nil } -func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(_ context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -134,7 +135,7 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, return nil } -func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { +func (m *MockArtworkQueueRepo) DeleteIfUnchanged(_ context.Context, kind, id, imageType string, retryAt time.Time) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -147,7 +148,7 @@ func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, ret return nil } -func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { +func (m *MockArtworkQueueRepo) PurgeDangling(context.Context) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -174,7 +175,7 @@ func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, prioritie (len(priorities) == 0 || slices.Contains(priorities, it.Priority)) } -func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { +func (m *MockArtworkQueueRepo) PurgeQueued(_ context.Context, kinds []model.Kind, priorities []int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -191,7 +192,7 @@ func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) return purged, nil } -func (m *MockArtworkQueueRepo) Count() (int64, error) { +func (m *MockArtworkQueueRepo) Count(context.Context) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -200,7 +201,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } -func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { +func (m *MockArtworkQueueRepo) CountQueued(_ context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -226,7 +227,7 @@ func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) return res, nil } -func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { +func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(_ context.Context, items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -276,7 +277,7 @@ func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) return res } -func (m *MockArtworkQueueRepo) CountBySource(kind model.Kind, sources []string) (int64, error) { +func (m *MockArtworkQueueRepo) CountBySource(_ context.Context, kind model.Kind, sources []string) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -285,7 +286,7 @@ func (m *MockArtworkQueueRepo) CountBySource(kind model.Kind, sources []string) return int64(len(m.matchingSource(kind, sources))), nil } -func (m *MockArtworkQueueRepo) SourcesInUse(kind model.Kind) ([]string, error) { +func (m *MockArtworkQueueRepo) SourcesInUse(_ context.Context, kind model.Kind) ([]string, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -295,7 +296,7 @@ func (m *MockArtworkQueueRepo) SourcesInUse(kind model.Kind) ([]string, error) { return slice.Unique(sources), nil } -func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { +func (m *MockArtworkQueueRepo) EnqueueBySource(_ context.Context, kind model.Kind, sources []string, priority int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -322,7 +323,7 @@ func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string } // EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. -func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { +func (m *MockArtworkQueueRepo) EnqueueAllMissing(_ context.Context, kind model.Kind, priority int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 5d76a0169..350c0e9f6 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "maps" "sync" "time" @@ -26,7 +27,7 @@ func CreateMockArtworkRepo() *MockArtworkRepo { func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } -func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { +func (m *MockArtworkRepo) GetImage(_ context.Context, hash string) (*model.Artwork, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -38,7 +39,7 @@ func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { return nil, model.ErrNotFound } -func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { +func (m *MockArtworkRepo) PutImage(_ context.Context, a *model.Artwork) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -50,7 +51,7 @@ func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { return nil } -func (m *MockArtworkRepo) GetMimeByHash() (map[string]string, error) { +func (m *MockArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -63,7 +64,7 @@ func (m *MockArtworkRepo) GetMimeByHash() (map[string]string, error) { return mimes, nil } -func (m *MockArtworkRepo) PurgeDanglingItems() (int64, error) { +func (m *MockArtworkRepo) PurgeDanglingItems(context.Context) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -84,7 +85,7 @@ func (m *MockArtworkRepo) PurgeDanglingItems() (int64, error) { return purged, nil } -func (m *MockArtworkRepo) PurgeOrphans(createdBefore time.Time) (int64, error) { +func (m *MockArtworkRepo) PurgeOrphans(_ context.Context, createdBefore time.Time) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -110,7 +111,7 @@ func (m *MockArtworkRepo) referenced(hash string) bool { return false } -func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { +func (m *MockArtworkRepo) GetItemArtwork(_ context.Context, kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -122,7 +123,7 @@ func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) return nil, model.ErrNotFound } -func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace string) error { +func (m *MockArtworkRepo) PutLastFailure(_ context.Context, kind model.Kind, id, imageType, trace string) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -136,7 +137,7 @@ func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace s return nil } -func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { +func (m *MockArtworkRepo) PutItemArtwork(_ context.Context, ia *model.ItemArtwork) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -153,7 +154,7 @@ func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { return nil } -func (m *MockArtworkRepo) DeleteForItems(kind model.Kind, ids []string) error { +func (m *MockArtworkRepo) DeleteForItems(_ context.Context, kind model.Kind, ids []string) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -167,7 +168,7 @@ func (m *MockArtworkRepo) DeleteForItems(kind model.Kind, ids []string) error { return nil } -func (m *MockArtworkRepo) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { +func (m *MockArtworkRepo) GetInfoForItems(_ context.Context, kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 6a0ebbb31..db798eece 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -38,76 +38,76 @@ type MockDataStore struct { GCError error } -func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { +func (db *MockDataStore) Library() model.LibraryRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedLibrary != nil { return db.MockedLibrary } if db.RealDS != nil { - return db.RealDS.Library(ctx) + return db.RealDS.Library() } db.MockedLibrary = &MockLibraryRepo{} return db.MockedLibrary } -func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { +func (db *MockDataStore) Folder() model.FolderRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedFolder != nil { return db.MockedFolder } if db.RealDS != nil { - return db.RealDS.Folder(ctx) + return db.RealDS.Folder() } db.MockedFolder = struct{ model.FolderRepository }{} return db.MockedFolder } -func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { +func (db *MockDataStore) Tag() model.TagRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedTag != nil { return db.MockedTag } if db.RealDS != nil { - return db.RealDS.Tag(ctx) + return db.RealDS.Tag() } db.MockedTag = &MockTagRepo{} return db.MockedTag } -func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { +func (db *MockDataStore) Album() model.AlbumRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedAlbum != nil { return db.MockedAlbum } if db.RealDS != nil { - return db.RealDS.Album(ctx) + return db.RealDS.Album() } db.MockedAlbum = CreateMockAlbumRepo() return db.MockedAlbum } -func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { +func (db *MockDataStore) Artist() model.ArtistRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedArtist != nil { return db.MockedArtist } if db.RealDS != nil { - return db.RealDS.Artist(ctx) + return db.RealDS.Artist() } db.MockedArtist = CreateMockArtistRepo() return db.MockedArtist } -func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { +func (db *MockDataStore) MediaFile() model.MediaFileRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.RealDS != nil && db.MockedMediaFile == nil { - return db.RealDS.MediaFile(ctx) + return db.RealDS.MediaFile() } if db.MockedMediaFile == nil { db.MockedMediaFile = CreateMockMediaFileRepo() @@ -115,128 +115,128 @@ func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepositor return db.MockedMediaFile } -func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { +func (db *MockDataStore) Genre() model.GenreRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedGenre != nil { return db.MockedGenre } if db.RealDS != nil { - return db.RealDS.Genre(ctx) + return db.RealDS.Genre() } db.MockedGenre = &MockedGenreRepo{} return db.MockedGenre } -func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { +func (db *MockDataStore) Playlist() model.PlaylistRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedPlaylist != nil { return db.MockedPlaylist } if db.RealDS != nil { - return db.RealDS.Playlist(ctx) + return db.RealDS.Playlist() } db.MockedPlaylist = CreateMockPlaylistRepo() return db.MockedPlaylist } -func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { +func (db *MockDataStore) PlayQueue() model.PlayQueueRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedPlayQueue != nil { return db.MockedPlayQueue } if db.RealDS != nil { - return db.RealDS.PlayQueue(ctx) + return db.RealDS.PlayQueue() } db.MockedPlayQueue = &MockPlayQueueRepo{} return db.MockedPlayQueue } -func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { +func (db *MockDataStore) UserProps() model.UserPropsRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedUserProps != nil { return db.MockedUserProps } if db.RealDS != nil { - return db.RealDS.UserProps(ctx) + return db.RealDS.UserProps() } db.MockedUserProps = &MockedUserPropsRepo{} return db.MockedUserProps } -func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { +func (db *MockDataStore) Property() model.PropertyRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedProperty != nil { return db.MockedProperty } if db.RealDS != nil { - return db.RealDS.Property(ctx) + return db.RealDS.Property() } db.MockedProperty = &MockedPropertyRepo{} return db.MockedProperty } -func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { +func (db *MockDataStore) Share() model.ShareRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedShare != nil { return db.MockedShare } if db.RealDS != nil { - return db.RealDS.Share(ctx) + return db.RealDS.Share() } db.MockedShare = &MockShareRepo{} return db.MockedShare } -func (db *MockDataStore) User(ctx context.Context) model.UserRepository { +func (db *MockDataStore) User() model.UserRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedUser != nil { return db.MockedUser } if db.RealDS != nil { - return db.RealDS.User(ctx) + return db.RealDS.User() } db.MockedUser = CreateMockUserRepo() return db.MockedUser } -func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { +func (db *MockDataStore) Transcoding() model.TranscodingRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedTranscoding != nil { return db.MockedTranscoding } if db.RealDS != nil { - return db.RealDS.Transcoding(ctx) + return db.RealDS.Transcoding() } db.MockedTranscoding = struct{ model.TranscodingRepository }{} return db.MockedTranscoding } -func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { +func (db *MockDataStore) Player() model.PlayerRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedPlayer != nil { return db.MockedPlayer } if db.RealDS != nil { - return db.RealDS.Player(ctx) + return db.RealDS.Player() } db.MockedPlayer = struct{ model.PlayerRepository }{} return db.MockedPlayer } -func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { +func (db *MockDataStore) ScrobbleBuffer() model.ScrobbleBufferRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.RealDS != nil && db.MockedScrobbleBuffer == nil { - return db.RealDS.ScrobbleBuffer(ctx) + return db.RealDS.ScrobbleBuffer() } db.scrobbleBufferMu.Lock() defer db.scrobbleBufferMu.Unlock() @@ -246,75 +246,75 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe return db.MockedScrobbleBuffer } -func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { +func (db *MockDataStore) Scrobble() model.ScrobbleRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedScrobble != nil { return db.MockedScrobble } if db.RealDS != nil { - return db.RealDS.Scrobble(ctx) + return db.RealDS.Scrobble() } - db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} + db.MockedScrobble = &MockScrobbleRepo{} return db.MockedScrobble } -func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { +func (db *MockDataStore) Radio() model.RadioRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedRadio != nil { return db.MockedRadio } if db.RealDS != nil { - return db.RealDS.Radio(ctx) + return db.RealDS.Radio() } db.MockedRadio = CreateMockedRadioRepo() return db.MockedRadio } -func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { +func (db *MockDataStore) Plugin() model.PluginRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedPlugin != nil { return db.MockedPlugin } if db.RealDS != nil { - return db.RealDS.Plugin(ctx) + return db.RealDS.Plugin() } db.MockedPlugin = CreateMockPluginRepo() return db.MockedPlugin } -func (db *MockDataStore) Artwork(ctx context.Context) model.ArtworkRepository { +func (db *MockDataStore) Artwork() model.ArtworkRepository { db.repoMu.Lock() defer db.repoMu.Unlock() - return db.artworkLocked(ctx) + return db.artworkLocked() } // artworkLocked is the body of Artwork for callers already holding repoMu; repoMu is a plain // Mutex, so re-entering through the exported method would deadlock. -func (db *MockDataStore) artworkLocked(ctx context.Context) model.ArtworkRepository { +func (db *MockDataStore) artworkLocked() model.ArtworkRepository { if db.MockedArtwork != nil { return db.MockedArtwork } if db.RealDS != nil { - return db.RealDS.Artwork(ctx) + return db.RealDS.Artwork() } db.MockedArtwork = CreateMockArtworkRepo() return db.MockedArtwork } -func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { +func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository { db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedArtworkQueue != nil { return db.MockedArtworkQueue } if db.RealDS != nil { - return db.RealDS.ArtworkQueue(ctx) + return db.RealDS.ArtworkQueue() } q := CreateMockArtworkQueueRepo() - if aw, ok := db.artworkLocked(ctx).(*MockArtworkRepo); ok { + if aw, ok := db.artworkLocked().(*MockArtworkRepo); ok { q.ItemArtworkSource = aw } db.MockedArtworkQueue = q @@ -333,37 +333,6 @@ func (db *MockDataStore) WithTxRetry(ctx context.Context, block func(ctx context return block(ctx, db) } -func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository { - switch m.(type) { - case model.MediaFile, *model.MediaFile: - return db.MediaFile(ctx).(model.ResourceRepository) - case model.Album, *model.Album: - return db.Album(ctx).(model.ResourceRepository) - case model.Artist, *model.Artist: - return db.Artist(ctx).(model.ResourceRepository) - case model.User, *model.User: - return db.User(ctx).(model.ResourceRepository) - case model.Playlist, *model.Playlist: - return db.Playlist(ctx).(model.ResourceRepository) - case model.Radio, *model.Radio: - return db.Radio(ctx).(model.ResourceRepository) - case model.Share, *model.Share: - return db.Share(ctx).(model.ResourceRepository) - case model.Genre, *model.Genre: - return db.Genre(ctx).(model.ResourceRepository) - case model.Tag, *model.Tag: - return db.Tag(ctx).(model.ResourceRepository) - case model.Transcoding, *model.Transcoding: - return db.Transcoding(ctx).(model.ResourceRepository) - case model.Player, *model.Player: - return db.Player(ctx).(model.ResourceRepository) - case model.Plugin, *model.Plugin: - return db.Plugin(ctx).(model.ResourceRepository) - default: - return struct{ model.ResourceRepository }{} - } -} - func (db *MockDataStore) GC(context.Context, ...int) error { db.GCCalled = true if db.GCError != nil { diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index ad3ee1a6a..917e2c626 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -1,6 +1,9 @@ package tests import ( + "context" + + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" ) @@ -16,7 +19,7 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) { +func (r *MockedGenreRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Genres, error) { if len(options) > 0 { r.Options = options[0] } @@ -32,7 +35,7 @@ func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, e return all, nil } -func (r *MockedGenreRepo) Get(id string) (*model.Genre, error) { +func (r *MockedGenreRepo) Get(_ context.Context, id string) (*model.Genre, error) { if r.Error != nil { return nil, r.Error } @@ -51,3 +54,21 @@ func (r *MockedGenreRepo) Put(g *model.Genre) error { r.Data[g.ID] = *g return nil } + +func (r *MockedGenreRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) { + if r.Error != nil { + return 0, r.Error + } + r.init() + return int64(len(r.Data)), nil +} + +func (r *MockedGenreRepo) Read(ctx context.Context, id string) (*model.Genre, error) { + return r.Get(ctx, id) +} + +func (r *MockedGenreRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Genre, error) { + return r.GetAll(ctx) +} + +var _ model.GenreRepository = (*MockedGenreRepo)(nil) diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 0f7af2aab..e21dcccce 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -27,7 +27,7 @@ func (m *MockLibraryRepo) SetData(data model.Libraries) { } } -func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error) { +func (m *MockLibraryRepo) GetAll(_ context.Context, _ ...model.QueryOptions) (model.Libraries, error) { if m.Err != nil { return nil, m.Err } @@ -42,7 +42,7 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error) return libraries, nil } -func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) { +func (m *MockLibraryRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { if m.Err != nil { return 0, m.Err } @@ -71,7 +71,7 @@ func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) { return int64(len(m.Data)), nil } -func (m *MockLibraryRepo) Get(id int) (*model.Library, error) { +func (m *MockLibraryRepo) Get(_ context.Context, id int) (*model.Library, error) { if m.Err != nil { return nil, m.Err } @@ -81,7 +81,7 @@ func (m *MockLibraryRepo) Get(id int) (*model.Library, error) { return nil, model.ErrNotFound } -func (m *MockLibraryRepo) GetPath(id int) (string, error) { +func (m *MockLibraryRepo) GetPath(_ context.Context, id int) (string, error) { if m.Err != nil { return "", m.Err } @@ -91,7 +91,7 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } -func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) error { +func (m *MockLibraryRepo) Put(_ context.Context, library *model.Library, colsToUpdate ...string) error { m.PutCols = colsToUpdate if m.PutFn != nil { return m.PutFn(library) @@ -106,7 +106,7 @@ func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) er return nil } -func (m *MockLibraryRepo) Delete(id int) error { +func (m *MockLibraryRepo) Delete(_ context.Context, id int) error { if m.Err != nil { return m.Err } @@ -117,48 +117,48 @@ func (m *MockLibraryRepo) Delete(id int) error { return nil } -func (m *MockLibraryRepo) StoreMusicFolder() error { +func (m *MockLibraryRepo) StoreMusicFolder(_ context.Context) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) AddArtist(id int, artistID string) error { +func (m *MockLibraryRepo) AddArtist(_ context.Context, id int, artistID string) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error { +func (m *MockLibraryRepo) ScanBegin(_ context.Context, id int, fullScan bool) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) ScanEnd(id int) error { +func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) ScanInProgress() (bool, error) { +func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) { if m.Err != nil { return false, m.Err } return false, nil } -func (m *MockLibraryRepo) RefreshStats(id int) error { +func (m *MockLibraryRepo) RefreshStats(_ context.Context, id int) error { return nil } // User-library association methods - mock implementations -func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) { +func (m *MockLibraryRepo) GetUsersWithLibraryAccess(_ context.Context, libraryID int) (model.Users, error) { if m.Err != nil { return nil, m.Err } @@ -166,31 +166,22 @@ func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, return model.Users{}, nil } -func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) { - return m.CountAll() +func (m *MockLibraryRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { + return m.CountAll(ctx) } -func (m *MockLibraryRepo) Read(id string) (any, error) { +func (m *MockLibraryRepo) Read(ctx context.Context, id string) (*model.Library, error) { idInt, _ := strconv.Atoi(id) - return m.Get(idInt) + return m.Get(ctx, idInt) } -func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (any, error) { - return m.GetAll() -} - -func (m *MockLibraryRepo) EntityName() string { - return "library" -} - -func (m *MockLibraryRepo) NewInstance() any { - return &model.Library{} +func (m *MockLibraryRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Library, error) { + return m.GetAll(ctx) } // REST Repository methods (string-based IDs) -func (m *MockLibraryRepo) Save(entity any) (string, error) { - lib := entity.(*model.Library) +func (m *MockLibraryRepo) Save(_ context.Context, lib *model.Library) (string, error) { if m.Err != nil { return "", m.Err } @@ -214,8 +205,8 @@ func (m *MockLibraryRepo) Save(entity any) (string, error) { return strconv.Itoa(lib.ID), nil } -func (m *MockLibraryRepo) Update(id string, entity any, cols ...string) error { - lib := entity.(*model.Library) +func (m *MockLibraryRepo) Update(_ context.Context, id string, entity model.Library, _ ...string) error { + lib := &entity if m.Err != nil { return m.Err } @@ -307,4 +298,4 @@ func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID stri } var _ model.LibraryRepository = (*MockLibraryRepo)(nil) -var _ model.ResourceRepository = (*MockLibraryRepo)(nil) +var _ rest.Repository[model.Library] = (*MockLibraryRepo)(nil) diff --git a/tests/mock_library_service.go b/tests/mock_library_service.go index 78693197d..f5e1f0387 100644 --- a/tests/mock_library_service.go +++ b/tests/mock_library_service.go @@ -14,7 +14,7 @@ type MockLibraryService struct { *MockLibraryRepo } -// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface +// MockLibraryRestAdapter adapts MockLibraryRepo to the REST repository interface type MockLibraryRestAdapter struct { *MockLibraryRepo } @@ -33,12 +33,15 @@ func NewMockLibraryService() *MockLibraryService { return &MockLibraryService{MockLibraryRepo: repo} } -func (m *MockLibraryService) NewRepository(ctx context.Context) rest.Repository { +func (m *MockLibraryService) Repository() rest.Repository[model.Library] { return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo} } -// rest.Repository interface implementation - -func (a *MockLibraryRestAdapter) Delete(id string) error { - return a.DeleteByStringID(id) +func (a *MockLibraryRestAdapter) Delete(_ context.Context, ids ...string) error { + for _, id := range ids { + if err := a.DeleteByStringID(id); err != nil { + return err + } + } + return nil } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 2093a007d..392a4d8e0 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -2,6 +2,7 @@ package tests import ( "cmp" + "context" "errors" "maps" "slices" @@ -55,7 +56,7 @@ func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { } } -func (m *MockMediaFileRepo) Exists(id string) (bool, error) { +func (m *MockMediaFileRepo) Exists(_ context.Context, id string) (bool, error) { if m.Err { return false, errors.New("error") } @@ -63,7 +64,7 @@ func (m *MockMediaFileRepo) Exists(id string) (bool, error) { return found, nil } -func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { +func (m *MockMediaFileRepo) Get(_ context.Context, id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") } @@ -77,7 +78,7 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { return nil, model.ErrNotFound } -func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error { +func (m *MockMediaFileRepo) AddBookmark(_ context.Context, id, _ string, _ int64) error { if m.Err { return errors.New("error") } @@ -85,7 +86,7 @@ func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error { return nil } -func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { +func (m *MockMediaFileRepo) GetWithParticipants(_ context.Context, id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") } @@ -95,11 +96,11 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er return nil, model.ErrNotFound } -func (m *MockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(options...) +func (m *MockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(ctx, options...) } -func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.MediaFiles, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -117,8 +118,8 @@ func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, return result, nil } -func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFiles, error) { - res, err := m.GetAll(qo...) +func (m *MockMediaFileRepo) GetRandom(ctx context.Context, qo ...model.QueryOptions) (model.MediaFiles, error) { + res, err := m.GetAll(ctx, qo...) if err != nil { return nil, err } @@ -128,8 +129,8 @@ func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFile return res, nil } -func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFileCursor, error) { - res, err := m.GetAll(qo...) +func (m *MockMediaFileRepo) GetCursor(ctx context.Context, qo ...model.QueryOptions) (model.MediaFileCursor, error) { + res, err := m.GetAll(ctx, qo...) if err != nil { return nil, err } @@ -142,11 +143,11 @@ func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFile }, nil } -func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (model.MediaFileCursor, error) { - return m.GetCursor(qo...) +func (m *MockMediaFileRepo) GetCursorWithArtwork(ctx context.Context, qo ...model.QueryOptions) (model.MediaFileCursor, error) { + return m.GetCursor(ctx, qo...) } -func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { +func (m *MockMediaFileRepo) Put(_ context.Context, mf *model.MediaFile) error { if m.Err { return errors.New("error") } @@ -157,7 +158,7 @@ func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { return nil } -func (m *MockMediaFileRepo) UpdateProbeData(id string, data string) error { +func (m *MockMediaFileRepo) UpdateProbeData(_ context.Context, id string, data string) error { if m.Err { return errors.New("error") } @@ -168,7 +169,7 @@ func (m *MockMediaFileRepo) UpdateProbeData(id string, data string) error { return model.ErrNotFound } -func (m *MockMediaFileRepo) Delete(id string) error { +func (m *MockMediaFileRepo) Delete(_ context.Context, id string) error { if m.Err { return errors.New("error") } @@ -179,7 +180,7 @@ func (m *MockMediaFileRepo) Delete(id string) error { return nil } -func (m *MockMediaFileRepo) ReassignReferences(prevID, newID string) error { +func (m *MockMediaFileRepo) ReassignReferences(_ context.Context, prevID, newID string) error { if m.Err { return errors.New("error") } @@ -190,7 +191,7 @@ func (m *MockMediaFileRepo) ReassignReferences(prevID, newID string) error { return nil } -func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { +func (m *MockMediaFileRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { if m.Err { return errors.New("error") } @@ -202,7 +203,7 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } -func (m *MockMediaFileRepo) SetStar(starred bool, itemIDs ...string) error { +func (m *MockMediaFileRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { if m.Err { return errors.New("error") } @@ -214,7 +215,7 @@ func (m *MockMediaFileRepo) SetStar(starred bool, itemIDs ...string) error { return nil } -func (m *MockMediaFileRepo) SetRating(rating int, itemID string) error { +func (m *MockMediaFileRepo) SetRating(_ context.Context, rating int, itemID string) error { if m.Err { return errors.New("error") } @@ -240,7 +241,7 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro return res, nil } -func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { +func (m *MockMediaFileRepo) GetMissingAndMatching(_ context.Context, libId int) (model.MediaFileCursor, error) { if m.Err { return nil, errors.New("error") } @@ -274,7 +275,7 @@ func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCur }, nil } -func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) { +func (m *MockMediaFileRepo) CountAll(_ context.Context, opts ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } @@ -287,7 +288,7 @@ func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) return int64(len(m.Data)), nil } -func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) { +func (m *MockMediaFileRepo) DeleteAllMissing(_ context.Context) (int64, error) { if m.Err { return 0, errors.New("error") } @@ -305,28 +306,20 @@ func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) { return count, nil } -// ResourceRepository methods -func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { - return m.CountAll() +// REST repository methods +func (m *MockMediaFileRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { + return m.CountAll(ctx) } -func (m *MockMediaFileRepo) Read(id string) (any, error) { - return m.Get(id) +func (m *MockMediaFileRepo) Read(ctx context.Context, id string) (*model.MediaFile, error) { + return m.Get(ctx, id) } -func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (any, error) { - return m.GetAll() +func (m *MockMediaFileRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.MediaFile, error) { + return m.GetAll(ctx) } -func (m *MockMediaFileRepo) EntityName() string { - return "mediafile" -} - -func (m *MockMediaFileRepo) NewInstance() any { - return &model.MediaFile{} -} - -func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.MediaFiles, error) { if len(options) > 0 { m.Options = options[0] } @@ -334,11 +327,11 @@ func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (mod return nil, errors.New("unexpected error") } // Simple mock implementation - just return all media files for testing - return m.GetAll() + return m.GetAll(ctx) } // Cross-library move detection mock methods -func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(_ context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") } @@ -360,7 +353,7 @@ func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, return result, nil } -func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) FindRecentFilesByProperties(_ context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") } @@ -386,7 +379,7 @@ func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, return result, nil } -func (m *MockMediaFileRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) { +func (m *MockMediaFileRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) { if m.MatchesCriteriaErr != nil { return false, m.MatchesCriteriaErr } @@ -394,4 +387,4 @@ func (m *MockMediaFileRepo) MatchesCriteria(string, criteria.Criteria) (bool, er } var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) -var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) +var _ rest.Repository[model.MediaFile] = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 0fa9618ae..824e701f6 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "errors" "time" @@ -43,7 +44,7 @@ func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { } } -func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { +func (m *MockPlaylistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Playlists, error) { if len(options) > 0 { m.Options = options[0] } @@ -53,8 +54,8 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } -func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { - res, err := m.GetAll(options...) +func (m *MockPlaylistRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistCursor, error) { + res, err := m.GetAll(ctx, options...) if err != nil { return nil, err } @@ -67,7 +68,7 @@ func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.Playl }, nil } -func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { +func (m *MockPlaylistRepo) Get(_ context.Context, id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") } @@ -79,11 +80,11 @@ func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { return nil, model.ErrNotFound } -func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, error) { - return m.Get(id) +func (m *MockPlaylistRepo) GetWithTracks(ctx context.Context, id string, _, _ bool) (*model.Playlist, error) { + return m.Get(ctx, id) } -func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { +func (m *MockPlaylistRepo) Put(_ context.Context, pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } @@ -97,7 +98,7 @@ func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { return nil } -func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { +func (m *MockPlaylistRepo) FindByPath(_ context.Context, path string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") } @@ -109,15 +110,15 @@ func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { return nil, model.ErrNotFound } -func (m *MockPlaylistRepo) Delete(id string) error { +func (m *MockPlaylistRepo) Delete(_ context.Context, ids ...string) error { if m.Err { return errors.New("error") } - m.Deleted = append(m.Deleted, id) + m.Deleted = append(m.Deleted, ids...) return nil } -func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { +func (m *MockPlaylistRepo) SetStar(_ context.Context, starred bool, ids ...string) error { if m.Err { return errors.New("error") } @@ -130,7 +131,7 @@ func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { return nil } -func (m *MockPlaylistRepo) SetRating(rating int, id string) error { +func (m *MockPlaylistRepo) SetRating(_ context.Context, rating int, id string) error { if m.Err { return errors.New("error") } @@ -141,26 +142,26 @@ func (m *MockPlaylistRepo) SetRating(rating int, id string) error { return nil } -func (m *MockPlaylistRepo) IncPlayCount(string, time.Time) error { +func (m *MockPlaylistRepo) IncPlayCount(context.Context, string, time.Time) error { if m.Err { return errors.New("error") } return nil } -func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { +func (m *MockPlaylistRepo) ReassignAnnotation(context.Context, string, string) error { if m.Err { return errors.New("error") } return nil } -func (m *MockPlaylistRepo) Tracks(_ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { +func (m *MockPlaylistRepo) Tracks(_ context.Context, _ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { m.TracksRefreshed = refreshSmartPlaylist return m.TracksRepo } -func (m *MockPlaylistRepo) Exists(id string) (bool, error) { +func (m *MockPlaylistRepo) Exists(_ context.Context, id string) (bool, error) { if m.Err { return false, errors.New("error") } @@ -171,14 +172,14 @@ func (m *MockPlaylistRepo) Exists(id string) (bool, error) { return false, nil } -func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { +func (m *MockPlaylistRepo) Count(_ context.Context, _ ...rest.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } return int64(len(m.Data)), nil } -func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) { +func (m *MockPlaylistRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 5666e7cc7..794393371 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -1,6 +1,8 @@ package tests import ( + "context" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -40,21 +42,21 @@ func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.Playli return tracks } -func (m *MockPlaylistTrackRepo) CountAll(_ ...model.QueryOptions) (int64, error) { +func (m *MockPlaylistTrackRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { if m.Err != nil { return 0, m.Err } return int64(len(m.Data)), nil } -func (m *MockPlaylistTrackRepo) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { +func (m *MockPlaylistTrackRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) { if m.Err != nil { return nil, m.Err } return m.page(options...), nil } -func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { +func (m *MockPlaylistTrackRepo) GetCursor(_ context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { if m.Err != nil { return nil, m.Err } @@ -68,21 +70,21 @@ func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model. }, nil } -func (m *MockPlaylistTrackRepo) GetAlbumIDs(...model.QueryOptions) ([]string, error) { +func (m *MockPlaylistTrackRepo) GetAlbumIDs(context.Context, ...model.QueryOptions) ([]string, error) { if m.Err != nil { return nil, m.Err } return m.AlbumIDs, nil } -func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { +func (m *MockPlaylistTrackRepo) GetMediaFileIDs(_ context.Context, options ...model.QueryOptions) ([]string, error) { if m.Err != nil { return nil, m.Err } return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil } -func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { +func (m *MockPlaylistTrackRepo) Add(_ context.Context, ids []string) (int, error) { m.AddedIds = append(m.AddedIds, ids...) if m.Err != nil { return 0, m.Err @@ -90,38 +92,38 @@ func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) Insert(ids []string, pos int) (int, error) { +func (m *MockPlaylistTrackRepo) Insert(ctx context.Context, ids []string, pos int) (int, error) { m.InsertPos = pos - return m.Add(ids) + return m.Add(ctx, ids) } -func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { +func (m *MockPlaylistTrackRepo) AddAlbums(_ context.Context, _ []string) (int, error) { if m.Err != nil { return 0, m.Err } return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) AddArtists(_ []string) (int, error) { +func (m *MockPlaylistTrackRepo) AddArtists(_ context.Context, _ []string) (int, error) { if m.Err != nil { return 0, m.Err } return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) AddDiscs(_ []model.DiscID) (int, error) { +func (m *MockPlaylistTrackRepo) AddDiscs(_ context.Context, _ []model.DiscID) (int, error) { if m.Err != nil { return 0, m.Err } return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) Delete(ids ...string) error { +func (m *MockPlaylistTrackRepo) Delete(_ context.Context, ids ...string) error { m.DeletedIds = append(m.DeletedIds, ids...) return m.Err } -func (m *MockPlaylistTrackRepo) Reorder(_, _ int) error { +func (m *MockPlaylistTrackRepo) Reorder(_ context.Context, _, _ int) error { m.Reordered = true return m.Err } diff --git a/tests/mock_playqueue_repo.go b/tests/mock_playqueue_repo.go index 19976db57..b445a5085 100644 --- a/tests/mock_playqueue_repo.go +++ b/tests/mock_playqueue_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "errors" "github.com/navidrome/navidrome/model" @@ -13,7 +14,7 @@ type MockPlayQueueRepo struct { LastCols []string } -func (m *MockPlayQueueRepo) Store(q *model.PlayQueue, cols ...string) error { +func (m *MockPlayQueueRepo) Store(_ context.Context, q *model.PlayQueue, cols ...string) error { if m.Err { return errors.New("error") } @@ -26,7 +27,7 @@ func (m *MockPlayQueueRepo) Store(q *model.PlayQueue, cols ...string) error { return nil } -func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { +func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(_ context.Context, userId string) (*model.PlayQueue, error) { if m.Err { return nil, errors.New("error") } @@ -40,7 +41,7 @@ func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(userId string) (*model.PlayQu return &qCopy, nil } -func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) { +func (m *MockPlayQueueRepo) Retrieve(_ context.Context, userId string) (*model.PlayQueue, error) { if m.Err { return nil, errors.New("error") } @@ -56,7 +57,7 @@ func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) { return &qCopy, nil } -func (m *MockPlayQueueRepo) Clear(userId string) error { +func (m *MockPlayQueueRepo) Clear(_ context.Context, userId string) error { if m.Err { return errors.New("error") } diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go index e65d56def..feaf9b212 100644 --- a/tests/mock_plugin_repo.go +++ b/tests/mock_plugin_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "errors" "time" @@ -29,7 +30,7 @@ func (m *MockPluginRepo) SetError(err bool) { m.Err = err } -func (m *MockPluginRepo) ClearErrors() error { +func (m *MockPluginRepo) ClearErrors(context.Context) error { if m.Err { return errors.New("unexpected error") } @@ -55,7 +56,7 @@ func (m *MockPluginRepo) SetPermitted(permitted bool) { m.Permitted = permitted } -func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) { +func (m *MockPluginRepo) Get(_ context.Context, id string) (*model.Plugin, error) { if !m.Permitted { return nil, rest.ErrPermissionDenied } @@ -68,11 +69,11 @@ func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) { return nil, model.ErrNotFound } -func (m *MockPluginRepo) Read(id string) (any, error) { - return m.Get(id) +func (m *MockPluginRepo) Read(ctx context.Context, id string) (*model.Plugin, error) { + return m.Get(ctx, id) } -func (m *MockPluginRepo) Put(p *model.Plugin) error { +func (m *MockPluginRepo) Put(_ context.Context, p *model.Plugin) error { if !m.Permitted { return rest.ErrPermissionDenied } @@ -105,7 +106,7 @@ func (m *MockPluginRepo) Put(p *model.Plugin) error { return nil } -func (m *MockPluginRepo) Delete(id string) error { +func (m *MockPluginRepo) Delete(_ context.Context, id string) error { if !m.Permitted { return rest.ErrPermissionDenied } @@ -123,7 +124,7 @@ func (m *MockPluginRepo) Delete(id string) error { return nil } -func (m *MockPluginRepo) GetAll(qo ...model.QueryOptions) (model.Plugins, error) { +func (m *MockPluginRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Plugins, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -136,7 +137,7 @@ func (m *MockPluginRepo) GetAll(qo ...model.QueryOptions) (model.Plugins, error) return m.All, nil } -func (m *MockPluginRepo) CountAll(qo ...model.QueryOptions) (int64, error) { +func (m *MockPluginRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -149,36 +150,16 @@ func (m *MockPluginRepo) CountAll(qo ...model.QueryOptions) (int64, error) { return int64(len(m.All)), nil } -// rest.Repository interface methods -func (m *MockPluginRepo) Count(options ...rest.QueryOptions) (int64, error) { +// REST repository methods +func (m *MockPluginRepo) Count(_ context.Context, _ ...rest.QueryOptions) (int64, error) { if !m.Permitted { return 0, rest.ErrPermissionDenied } return int64(len(m.All)), nil } -func (m *MockPluginRepo) EntityName() string { - return "plugin" -} - -func (m *MockPluginRepo) NewInstance() any { - return &model.Plugin{} -} - -func (m *MockPluginRepo) ReadAll(options ...rest.QueryOptions) (any, error) { - return m.GetAll() -} - -func (m *MockPluginRepo) Save(entity any) (string, error) { - p := entity.(*model.Plugin) - err := m.Put(p) - return p.ID, err -} - -func (m *MockPluginRepo) Update(id string, entity any, cols ...string) error { - p := entity.(*model.Plugin) - p.ID = id - return m.Put(p) +func (m *MockPluginRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Plugin, error) { + return m.GetAll(ctx) } var _ model.PluginRepository = (*MockPluginRepo)(nil) diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index 9adc66e6d..949f894c1 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -1,6 +1,10 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "context" + + "github.com/navidrome/navidrome/model" +) type MockedPropertyRepo struct { model.PropertyRepository @@ -14,7 +18,7 @@ func (p *MockedPropertyRepo) init() { } } -func (p *MockedPropertyRepo) Put(id string, value string) error { +func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) error { if p.Error != nil { return p.Error } @@ -23,7 +27,7 @@ func (p *MockedPropertyRepo) Put(id string, value string) error { return nil } -func (p *MockedPropertyRepo) Get(id string) (string, error) { +func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) { if p.Error != nil { return "", p.Error } @@ -34,7 +38,7 @@ func (p *MockedPropertyRepo) Get(id string) (string, error) { return "", model.ErrNotFound } -func (p *MockedPropertyRepo) Delete(id string) error { +func (p *MockedPropertyRepo) Delete(_ context.Context, id string) error { if p.Error != nil { return p.Error } @@ -46,12 +50,12 @@ func (p *MockedPropertyRepo) Delete(id string) error { return model.ErrNotFound } -func (p *MockedPropertyRepo) DefaultGet(id string, defaultValue string) (string, error) { +func (p *MockedPropertyRepo) DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - v, err := p.Get(id) + v, err := p.Get(ctx, id) if err != nil { return defaultValue, nil //nolint:nilerr } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 20f81ec45..21898ea53 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -1,6 +1,7 @@ package tests import ( + "context" "errors" "github.com/navidrome/navidrome/model" @@ -23,29 +24,27 @@ func (m *MockedRadioRepo) SetError(err bool) { m.Err = err } -func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) { +func (m *MockedRadioRepo) CountAll(_ context.Context, options ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } return int64(len(m.Data)), nil } -func (m *MockedRadioRepo) Delete(id string) error { +func (m *MockedRadioRepo) Delete(_ context.Context, ids ...string) error { if m.Err { return errors.New("Error!") } - - _, found := m.Data[id] - - if !found { - return errors.New("not found") + for _, id := range ids { + if _, found := m.Data[id]; !found { + return errors.New("not found") + } + delete(m.Data, id) } - - delete(m.Data, id) return nil } -func (m *MockedRadioRepo) Exists(id string) (bool, error) { +func (m *MockedRadioRepo) Exists(_ context.Context, id string) (bool, error) { if m.Err { return false, errors.New("Error!") } @@ -53,7 +52,7 @@ func (m *MockedRadioRepo) Exists(id string) (bool, error) { return found, nil } -func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { +func (m *MockedRadioRepo) Get(_ context.Context, id string) (*model.Radio, error) { if m.Err { return nil, errors.New("Error!") } @@ -63,7 +62,7 @@ func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { return nil, model.ErrNotFound } -func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) { +func (m *MockedRadioRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Radios, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -73,7 +72,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { +func (m *MockedRadioRepo) Put(_ context.Context, radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 2eb5e8a93..91177eeef 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "sync" "time" @@ -17,7 +18,7 @@ func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo { return &MockedScrobbleBufferRepo{} } -func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) { +func (m *MockedScrobbleBufferRepo) UserIDs(_ context.Context, service string) ([]string, error) { if m.Error != nil { return nil, m.Error } @@ -36,7 +37,7 @@ func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) { return result, nil } -func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { +func (m *MockedScrobbleBufferRepo) Enqueue(_ context.Context, service, userId, mediaFileId string, playTime time.Time) error { if m.Error != nil { return m.Error } @@ -52,7 +53,7 @@ func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, return nil } -func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.ScrobbleEntry, error) { +func (m *MockedScrobbleBufferRepo) Next(_ context.Context, service, userId string) (*model.ScrobbleEntry, error) { if m.Error != nil { return nil, m.Error } @@ -66,7 +67,7 @@ func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.Scrobble return nil, nil } -func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { +func (m *MockedScrobbleBufferRepo) Dequeue(_ context.Context, entry *model.ScrobbleEntry) error { if m.Error != nil { return m.Error } @@ -83,7 +84,7 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { return nil } -func (m *MockedScrobbleBufferRepo) Discard(service string) error { +func (m *MockedScrobbleBufferRepo) Discard(_ context.Context, service string) error { if m.Error != nil { return m.Error } @@ -99,7 +100,7 @@ func (m *MockedScrobbleBufferRepo) Discard(service string) error { return nil } -func (m *MockedScrobbleBufferRepo) Length() (int64, error) { +func (m *MockedScrobbleBufferRepo) Length(context.Context) (int64, error) { if m.Error != nil { return 0, m.Error } @@ -107,3 +108,5 @@ func (m *MockedScrobbleBufferRepo) Length() (int64, error) { defer m.mu.RUnlock() return int64(len(m.Data)), nil } + +var _ model.ScrobbleBufferRepository = (*MockedScrobbleBufferRepo)(nil) diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index d6d88d221..a76bc5d59 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -5,16 +5,16 @@ import ( "strconv" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) type MockScrobbleRepo struct { RecordedScrobbles []model.Scrobble - ctx context.Context } -func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { +func (m *MockScrobbleRepo) Get(_ context.Context, id string) (*model.Scrobble, error) { for idx := range m.RecordedScrobbles { if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { return &m.RecordedScrobbles[idx], nil @@ -24,16 +24,16 @@ func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { return nil, model.ErrNotFound } -func (m *MockScrobbleRepo) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { +func (m *MockScrobbleRepo) GetAll(_ context.Context, _ ...model.QueryOptions) (model.Scrobbles, error) { return m.RecordedScrobbles, nil } -func (m *MockScrobbleRepo) CountAll(options ...model.QueryOptions) (int64, error) { +func (m *MockScrobbleRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { return int64(len(m.RecordedScrobbles)), nil } -func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { - user, _ := request.UserFrom(m.ctx) +func (m *MockScrobbleRepo) RecordScrobble(ctx context.Context, fileID string, submissionTime time.Time) error { + user, _ := request.UserFrom(ctx) m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ MediaFileID: fileID, UserID: user.ID, @@ -42,4 +42,16 @@ func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Tim return nil } +func (m *MockScrobbleRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { + return m.CountAll(ctx) +} + +func (m *MockScrobbleRepo) Read(ctx context.Context, id string) (*model.Scrobble, error) { + return m.Get(ctx, id) +} + +func (m *MockScrobbleRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Scrobble, error) { + return m.GetAll(ctx) +} + var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 9fbf0057e..0c872dc0d 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -1,14 +1,13 @@ package tests import ( - "github.com/deluan/rest" + "context" + "github.com/navidrome/navidrome/model" ) type MockShareRepo struct { model.ShareRepository - rest.Repository - rest.Persistable Entity any ID string @@ -16,11 +15,10 @@ type MockShareRepo struct { Error error } -func (m *MockShareRepo) Save(entity any) (string, error) { +func (m *MockShareRepo) Save(_ context.Context, s *model.Share) (string, error) { if m.Error != nil { return "", m.Error } - s := entity.(*model.Share) if s.ID == "" { s.ID = "id" } @@ -28,24 +26,24 @@ func (m *MockShareRepo) Save(entity any) (string, error) { return s.ID, nil } -func (m *MockShareRepo) Update(id string, entity any, cols ...string) error { +func (m *MockShareRepo) Update(_ context.Context, id string, entity model.Share, cols ...string) error { if m.Error != nil { return m.Error } m.ID = id - m.Entity = entity + m.Entity = &entity m.Cols = cols return nil } -func (m *MockShareRepo) Exists(id string) (bool, error) { +func (m *MockShareRepo) Exists(_ context.Context, id string) (bool, error) { if m.Error != nil { return false, m.Error } return id == m.ID, nil } -func (m *MockShareRepo) Get(id string) (*model.Share, error) { +func (m *MockShareRepo) Get(_ context.Context, id string) (*model.Share, error) { if m.Error != nil { return nil, m.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go index a59035ea6..f1f252efe 100644 --- a/tests/mock_tag_repo.go +++ b/tests/mock_tag_repo.go @@ -1,6 +1,8 @@ package tests import ( + "context" + "github.com/navidrome/navidrome/model" ) @@ -13,7 +15,7 @@ type MockTagRepo struct { Err error } -func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) { +func (r *MockTagRepo) GetAll(_ context.Context, _ model.TagName, options ...model.QueryOptions) (model.TagList, error) { if len(options) > 0 { r.Options = options[0] } diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 641daca8a..52eb16eed 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -1,16 +1,20 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "context" + + "github.com/navidrome/navidrome/model" +) type MockTranscodingRepo struct { model.TranscodingRepository } -func (m *MockTranscodingRepo) Get(id string) (*model.Transcoding, error) { +func (m *MockTranscodingRepo) Get(_ context.Context, id string) (*model.Transcoding, error) { return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil } -func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, error) { +func (m *MockTranscodingRepo) FindByFormat(_ context.Context, format string) (*model.Transcoding, error) { switch format { case "mp3": return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil diff --git a/tests/mock_user_props_repo.go b/tests/mock_user_props_repo.go index 1b1e17650..278a68431 100644 --- a/tests/mock_user_props_repo.go +++ b/tests/mock_user_props_repo.go @@ -1,6 +1,10 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "context" + + "github.com/navidrome/navidrome/model" +) type MockedUserPropsRepo struct { model.UserPropsRepository @@ -14,7 +18,7 @@ func (p *MockedUserPropsRepo) init() { } } -func (p *MockedUserPropsRepo) Put(userId, key string, value string) error { +func (p *MockedUserPropsRepo) Put(_ context.Context, userId, key string, value string) error { if p.Error != nil { return p.Error } @@ -23,7 +27,7 @@ func (p *MockedUserPropsRepo) Put(userId, key string, value string) error { return nil } -func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) { +func (p *MockedUserPropsRepo) Get(_ context.Context, userId, key string) (string, error) { if p.Error != nil { return "", p.Error } @@ -34,7 +38,7 @@ func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) { return "", model.ErrNotFound } -func (p *MockedUserPropsRepo) Delete(userId, key string) error { +func (p *MockedUserPropsRepo) Delete(_ context.Context, userId, key string) error { if p.Error != nil { return p.Error } @@ -46,12 +50,12 @@ func (p *MockedUserPropsRepo) Delete(userId, key string) error { return model.ErrNotFound } -func (p *MockedUserPropsRepo) DefaultGet(userId, key string, defaultValue string) (string, error) { +func (p *MockedUserPropsRepo) DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - v, err := p.Get(userId, key) + v, err := p.Get(ctx, userId, key) if err != nil { return defaultValue, nil //nolint:nilerr } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 2d6ff3c02..58c985157 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/base64" "fmt" "strings" @@ -23,14 +24,14 @@ type MockedUserRepo struct { UserLibraries map[string][]int // userID -> libraryIDs } -func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { +func (u *MockedUserRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { if u.Error != nil { return 0, u.Error } return int64(len(u.Data)), nil } -func (u *MockedUserRepo) Put(usr *model.User) error { +func (u *MockedUserRepo) Put(_ context.Context, usr *model.User) error { if u.Error != nil { return u.Error } @@ -42,7 +43,7 @@ func (u *MockedUserRepo) Put(usr *model.User) error { return nil } -func (u *MockedUserRepo) FindByUsername(username string) (*model.User, error) { +func (u *MockedUserRepo) FindByUsername(_ context.Context, username string) (*model.User, error) { if u.Error != nil { return nil, u.Error } @@ -53,11 +54,11 @@ func (u *MockedUserRepo) FindByUsername(username string) (*model.User, error) { return usr, nil } -func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { - return u.FindByUsername(username) +func (u *MockedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { + return u.FindByUsername(ctx, username) } -func (u *MockedUserRepo) FindFirstAdmin() (*model.User, error) { +func (u *MockedUserRepo) FindFirstAdmin(_ context.Context) (*model.User, error) { if u.Error != nil { return nil, u.Error } @@ -69,7 +70,7 @@ func (u *MockedUserRepo) FindFirstAdmin() (*model.User, error) { return nil, model.ErrNotFound } -func (u *MockedUserRepo) Get(id string) (*model.User, error) { +func (u *MockedUserRepo) Get(_ context.Context, id string) (*model.User, error) { if u.Error != nil { return nil, u.Error } @@ -81,7 +82,7 @@ func (u *MockedUserRepo) Get(id string) (*model.User, error) { return nil, model.ErrNotFound } -func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, error) { +func (u *MockedUserRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Users, error) { if u.Error != nil { return nil, u.Error } @@ -92,7 +93,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err return users, nil } -func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { +func (u *MockedUserRepo) UpdateLastLoginAt(_ context.Context, id string) error { for _, usr := range u.Data { if usr.ID == id { usr.LastLoginAt = new(time.Now()) @@ -102,7 +103,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { return u.Error } -func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { +func (u *MockedUserRepo) UpdateLastAccessAt(_ context.Context, id string) error { for _, usr := range u.Data { if usr.ID == id { usr.LastAccessAt = new(time.Now()) @@ -114,7 +115,7 @@ func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { // Library association methods - mock implementations -func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) { +func (u *MockedUserRepo) GetUserLibraries(_ context.Context, userID string) (model.Libraries, error) { if u.Error != nil { return nil, u.Error } @@ -135,7 +136,7 @@ func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error return libraries, nil } -func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error { +func (u *MockedUserRepo) SetUserLibraries(_ context.Context, userID string, libraryIDs []int) error { if u.Error != nil { return u.Error } @@ -146,10 +147,19 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error return nil } -func (u *MockedUserRepo) Delete(id string) error { +func (u *MockedUserRepo) Delete(_ context.Context, ids ...string) error { if u.Error != nil { return u.Error } + for _, id := range ids { + if err := u.deleteOne(id); err != nil { + return err + } + } + return nil +} + +func (u *MockedUserRepo) deleteOne(id string) error { for key, usr := range u.Data { if usr.ID == id { delete(u.Data, key) @@ -160,19 +170,17 @@ func (u *MockedUserRepo) Delete(id string) error { return model.ErrNotFound } -func (u *MockedUserRepo) Save(entity any) (string, error) { - usr := entity.(*model.User) - if err := u.Put(usr); err != nil { +func (u *MockedUserRepo) Save(ctx context.Context, usr *model.User) (string, error) { + if err := u.Put(ctx, usr); err != nil { return "", err } return usr.ID, nil } -func (u *MockedUserRepo) Update(id string, entity any, cols ...string) error { +func (u *MockedUserRepo) Update(ctx context.Context, id string, entity model.User, _ ...string) error { if u.Error != nil { return u.Error } - usr := entity.(*model.User) - usr.ID = id - return u.Put(usr) + entity.ID = id + return u.Put(ctx, &entity) } diff --git a/tests/mock_user_service.go b/tests/mock_user_service.go index f2700de45..bde843d1a 100644 --- a/tests/mock_user_service.go +++ b/tests/mock_user_service.go @@ -1,9 +1,8 @@ package tests import ( - "context" - "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" ) // MockUserService provides a simple wrapper around MockedUserRepo @@ -13,7 +12,7 @@ type MockUserService struct { *MockedUserRepo } -// MockUserRestAdapter adapts MockedUserRepo to rest.Repository interface +// MockUserRestAdapter adapts MockedUserRepo to the REST repository interface type MockUserRestAdapter struct { *MockedUserRepo } @@ -25,6 +24,6 @@ func NewMockUserService() *MockUserService { return &MockUserService{MockedUserRepo: repo} } -func (m *MockUserService) NewRepository(ctx context.Context) rest.Repository { +func (m *MockUserService) Repository() rest.Repository[model.User] { return &MockUserRestAdapter{MockedUserRepo: m.MockedUserRepo} } From 6b05189190a924b66042e32da13792ed53abdcf4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 25 Sep 2026 18:18:58 -0400 Subject: [PATCH 580/596] chore: update Go dependencies to latest versions --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 01fb0f73d..464038d7e 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260913142955 require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.5 - github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/bmatcuk/doublestar/v4 v4.10.2 github.com/deluan/rest v1.0.1 github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 @@ -16,7 +16,7 @@ require ( github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 github.com/djherbis/stream v1.5.1 github.com/djherbis/times v1.6.0 - github.com/dustin/go-humanize v1.0.1 + github.com/dustin/go-humanize v1.1.0 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.6.4 @@ -39,8 +39,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.52 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.32.2 - github.com/onsi/gomega v1.43.0 + github.com/onsi/ginkgo/v2 v2.33.0 + github.com/onsi/gomega v1.44.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -81,15 +81,15 @@ require ( github.com/creack/pty v1.1.24 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.10.2 // indirect + github.com/ebitengine/purego v0.11.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gobwas/glob v0.2.3 // indirect + github.com/gobwas/glob v1.0.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 // indirect + github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -133,8 +133,8 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/mod v0.41.0 // indirect - golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect - golang.org/x/tools v0.49.0 // indirect + golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect + golang.org/x/tools v0.50.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index bcc1395ba..929366a76 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= -github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= +github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -47,12 +47,12 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= -github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -88,8 +88,8 @@ github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdM github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= +github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -101,8 +101,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 h1:dl4UZiszMU+NKHirOiCKTC+hRuNAQ0moHPxSg6WcU1o= -github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY= +github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -180,10 +180,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg= -github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= -github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= +github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ= +github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -309,8 +309,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -320,8 +320,8 @@ golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From 4113d954ca0cbfe5e9f89a919c09f0fe6f7d1339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 26 Sep 2026 11:43:00 -0400 Subject: [PATCH 581/596] fix(scanner): register .webm as audio/webm (#6230) Go 1.27 changed the built-in MIME type for .webm from audio/webm to video/webm, so the scanner stopped treating WebM files as audio after the Go bump in 0.64.0. Map .webm to audio/webm in mime_types.yaml so it no longer depends on the Go version. --- model/file_types_test.go | 4 ++++ resources/mime_types.yaml | 1 + 2 files changed, 5 insertions(+) diff --git a/model/file_types_test.go b/model/file_types_test.go index 93301e151..07dac645e 100644 --- a/model/file_types_test.go +++ b/model/file_types_test.go @@ -18,6 +18,10 @@ var _ = Describe("File Types()", func() { Expect(model.IsAudioFile("test.flac")).To(BeTrue()) }) + It("returns true for a WebM file", func() { + Expect(model.IsAudioFile("test.webm")).To(BeTrue()) + }) + It("returns false for a non-audio file", func() { Expect(model.IsAudioFile("test.jpg")).To(BeFalse()) }) diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index 18a2c22b5..1a963bdf7 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -29,6 +29,7 @@ types: .wvp: audio/x-wavpack .tak: audio/tak .mka: audio/x-matroska + .webm: audio/webm # Image .gif: image/gif From bb7d81a5ea1b4ad7d5479c82c9abbabc6c66a359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 26 Sep 2026 12:30:52 -0400 Subject: [PATCH 582/596] refactor(scanner): remove the unused legacy ffmpeg metadata extractor (#6231) * refactor(scanner): remove the legacy ffmpeg metadata extractor The ffmpeg extractor in scanner/metadata_old has not been wired into the scanner since the taglib-only rewrite, so it was only exercised by its own tests. Remove the package, the FFmpeg.Probe method and its ffmetadata command that only it used, and the startup fallback for Scanner.Extractor="ffmpeg". Configs that still set it keep working: unknown extractors already fall back to taglib with a warning. * fix(conf): warn and fall back to taglib for an unknown Scanner.Extractor Validate the option when loading the config, so invalid values such as the removed "ffmpeg" extractor are reported once at startup instead of only when a library storage is created. --- conf/configuration.go | 5 + conf/configuration_test.go | 21 + core/ffmpeg/ffmpeg.go | 27 -- core/ffmpeg/ffmpeg_test.go | 15 +- scanner/metadata_old/ffmpeg/ffmpeg.go | 211 --------- .../metadata_old/ffmpeg/ffmpeg_suite_test.go | 17 - scanner/metadata_old/ffmpeg/ffmpeg_test.go | 375 ---------------- scanner/metadata_old/metadata.go | 411 ------------------ .../metadata_old/metadata_internal_test.go | 144 ------ scanner/metadata_old/metadata_suite_test.go | 17 - scanner/metadata_old/metadata_test.go | 95 ---- server/initial_setup.go | 4 - tests/harness/harness.go | 2 - tests/mock_ffmpeg.go | 6 - 14 files changed, 30 insertions(+), 1320 deletions(-) delete mode 100644 scanner/metadata_old/ffmpeg/ffmpeg.go delete mode 100644 scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go delete mode 100644 scanner/metadata_old/ffmpeg/ffmpeg_test.go delete mode 100644 scanner/metadata_old/metadata.go delete mode 100644 scanner/metadata_old/metadata_internal_test.go delete mode 100644 scanner/metadata_old/metadata_suite_test.go delete mode 100644 scanner/metadata_old/metadata_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 6f295ebeb..0173f5628 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -514,6 +514,11 @@ func Load(noConfigDump bool) { Server.UICoverArtSize = newValue } + if Server.Scanner.Extractor != consts.DefaultScannerExtractor { + log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor) + Server.Scanner.Extractor = consts.DefaultScannerExtractor + } + // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. imgSize, _ := humanize.ParseBytes(Server.MaxImageSize) uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 9404f01bc..8c4c8ab86 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -402,6 +402,27 @@ var _ = Describe("Configuration", func() { }) }) + Describe("Scanner.Extractor", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("falls back to taglib for an unknown extractor", func() { + viper.SetDefault("scanner.extractor", "ffmpeg") + conf.Load(true) + Expect(conf.Server.Scanner.Extractor).To(Equal("taglib")) + }) + + It("keeps taglib", func() { + conf.Load(true) + Expect(conf.Server.Scanner.Extractor).To(Equal("taglib")) + }) + }) + Describe("EnforceNonRootUser", func() { It("defaults to false", func() { conf.Load(true) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index cc38dd9de..af59178af 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -49,7 +49,6 @@ type FFmpeg interface { Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) - Probe(ctx context.Context, files []string) (string, error) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool @@ -68,7 +67,6 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" - probeCmd = "ffmpeg %s -f ffmetadata" probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" ) @@ -149,17 +147,6 @@ func fileExists(path string) error { return nil } -func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) { - if _, err := ffmpegCmd(); err != nil { - return "", err - } - args := createProbeCommand(probeCmd, files) - log.Trace(ctx, "Executing ffmpeg command", "args", args) - cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec - output, _ := cmd.CombinedOutput() - return string(output), nil -} - func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) { if _, err := ffmpegCmd(); err != nil { return nil, err @@ -593,20 +580,6 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { return args } -func createProbeCommand(cmd string, inputs []string) []string { - var args []string - for _, s := range fixCmd(cmd) { - if s == "%s" { - for _, inp := range inputs { - args = append(args, "-i", inp) - } - } else { - args = append(args, s) - } - } - return args -} - func fixCmd(cmd string) []string { split := strings.Fields(cmd) cmdPath, _ := ffmpegCmd() diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index dbc8fa3c8..46684fe14 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -62,23 +62,16 @@ var _ = Describe("ffmpeg", func() { }) }) - Describe("createProbeCommand", func() { - It("creates a valid command line", func() { - args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"}) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"})) - }) - }) - When("ffmpegPath is set", func() { It("returns the correct ffmpeg path", func() { ffmpegPath = "/usr/bin/ffmpeg" - args := createProbeCommand(probeCmd, []string{"one.mp3"}) - Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"})) + args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0) + Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"})) }) It("returns the correct ffmpeg path with spaces", func() { ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe" - args := createProbeCommand(probeCmd, []string{"one.mp3"}) - Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"})) + args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0) + Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"})) }) }) diff --git a/scanner/metadata_old/ffmpeg/ffmpeg.go b/scanner/metadata_old/ffmpeg/ffmpeg.go deleted file mode 100644 index 8fc496c02..000000000 --- a/scanner/metadata_old/ffmpeg/ffmpeg.go +++ /dev/null @@ -1,211 +0,0 @@ -package ffmpeg - -import ( - "bufio" - "context" - "errors" - "regexp" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/scanner/metadata_old" -) - -const ExtractorID = "ffmpeg" - -type Extractor struct { - ffmpeg ffmpeg.FFmpeg -} - -func (e *Extractor) Parse(files ...string) (map[string]metadata_old.ParsedTags, error) { - output, err := e.ffmpeg.Probe(context.TODO(), files) - if err != nil { - log.Error("Cannot use ffmpeg to extract tags. Aborting", err) - return nil, err - } - fileTags := map[string]metadata_old.ParsedTags{} - if len(output) == 0 { - return fileTags, errors.New("error extracting metadata files") - } - infos := e.parseOutput(output) - for file, info := range infos { - tags, err := e.extractMetadata(file, info) - // Skip files with errors - if err == nil { - fileTags[file] = tags - } - } - return fileTags, nil -} - -func (e *Extractor) CustomMappings() metadata_old.ParsedTags { - return metadata_old.ParsedTags{ - "disc": {"tpa"}, - "has_picture": {"metadata_block_picture"}, - "originaldate": {"tdor"}, - } -} - -func (e *Extractor) Version() string { - return e.ffmpeg.Version() -} - -func (e *Extractor) extractMetadata(filePath, info string) (metadata_old.ParsedTags, error) { - tags := e.parseInfo(info) - if len(tags) == 0 { - log.Trace("Not a media file. Skipping", "filePath", filePath) - return nil, errors.New("not a media file") - } - - return tags, nil -} - -var ( - // Input #0, mp3, from 'groovin.mp3': - inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`) - - // TITLE : Back In Black - tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w\s-]+)\s*:(.*)`) - - // : Second comment line - continuationRx = regexp.MustCompile(`(?i)^\s+:(.*)`) - - // Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s` - durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`) - - // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s - bitRateRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+: Audio:.*, (\d+) kb/s`) - - // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s - // Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 - // Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s - audioStreamRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+.*: Audio: (.*), (.*) Hz, ([\w.]+),*(.*.,)*`) - - // Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` - coverRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:.+: (Video):.*`) -) - -func (e *Extractor) parseOutput(output string) map[string]string { - outputs := map[string]string{} - all := inputRegex.FindAllStringSubmatchIndex(output, -1) - for i, loc := range all { - // Filename is the first captured group - file := output[loc[2]:loc[3]] - - // File info is everything from the match, up until the beginning of the next match - info := "" - initial := loc[1] - if i < len(all)-1 { - end := all[i+1][0] - 1 - info = output[initial:end] - } else { - // if this is the last match - info = output[initial:] - } - outputs[file] = info - } - return outputs -} - -func (e *Extractor) parseInfo(info string) map[string][]string { - tags := map[string][]string{} - - reader := strings.NewReader(info) - scanner := bufio.NewScanner(reader) - lastTag := "" - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - match := tagsRx.FindStringSubmatch(line) - if len(match) > 0 { - tagName := strings.TrimSpace(strings.ToLower(match[1])) - if tagName != "" { - tagValue := strings.TrimSpace(match[2]) - tags[tagName] = append(tags[tagName], tagValue) - lastTag = tagName - continue - } - } - - if lastTag != "" { - match = continuationRx.FindStringSubmatch(line) - if len(match) > 0 { - if tags[lastTag] == nil { - tags[lastTag] = []string{""} - } - tagValue := tags[lastTag][0] - tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1]) - continue - } - } - - lastTag = "" - match = coverRx.FindStringSubmatch(line) - if len(match) > 0 { - tags["has_picture"] = []string{"true"} - continue - } - - match = durationRx.FindStringSubmatch(line) - if len(match) > 0 { - tags["duration"] = []string{e.parseDuration(match[1])} - if len(match) > 1 { - tags["bitrate"] = []string{match[2]} - } - continue - } - - match = bitRateRx.FindStringSubmatch(line) - if len(match) > 0 { - tags["bitrate"] = []string{match[1]} - } - - match = audioStreamRx.FindStringSubmatch(line) - if len(match) > 0 { - tags["samplerate"] = []string{match[2]} - tags["channels"] = []string{e.parseChannels(match[3])} - } - } - - comment := tags["comment"] - if len(comment) > 0 && comment[0] == "Cover (front)" { - delete(tags, "comment") - } - - return tags -} - -var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) - -func (e *Extractor) parseDuration(tag string) string { - d, err := time.Parse("15:04:05", tag) - if err != nil { - return "0" - } - return strconv.FormatFloat(d.Sub(zeroTime).Seconds(), 'f', 2, 32) -} - -func (e *Extractor) parseChannels(tag string) string { - switch tag { - case "mono": - return "1" - case "stereo": - return "2" - case "5.1": - return "6" - case "7.1": - return "8" - default: - return "0" - } -} - -// Inputs will always be absolute paths -func init() { - metadata_old.RegisterExtractor(ExtractorID, &Extractor{ffmpeg: ffmpeg.New()}) -} diff --git a/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go b/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go deleted file mode 100644 index 815940381..000000000 --- a/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package ffmpeg - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestFFMpeg(t *testing.T) { - tests.Init(t, true) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "FFMpeg Suite") -} diff --git a/scanner/metadata_old/ffmpeg/ffmpeg_test.go b/scanner/metadata_old/ffmpeg/ffmpeg_test.go deleted file mode 100644 index 6c7f43a5d..000000000 --- a/scanner/metadata_old/ffmpeg/ffmpeg_test.go +++ /dev/null @@ -1,375 +0,0 @@ -package ffmpeg - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Extractor", func() { - var e *Extractor - BeforeEach(func() { - e = &Extractor{} - }) - - Context("extractMetadata", func() { - It("extracts MusicBrainz custom tags", func() { - const output = ` -Input #0, ape, from './Capture/02 01 - Symphony No. 5 in C minor, Op. 67 I. Allegro con brio - Ludwig van Beethoven.ape': - Metadata: - ALBUM : Forever Classics - ARTIST : Ludwig van Beethoven - TITLE : Symphony No. 5 in C minor, Op. 67: I. Allegro con brio - MUSICBRAINZ_ALBUMSTATUS: official - MUSICBRAINZ_ALBUMTYPE: album - MusicBrainz_AlbumComment: MP3 - Musicbrainz_Albumid: 71eb5e4a-90e2-4a31-a2d1-a96485fcb667 - musicbrainz_trackid: ffe06940-727a-415a-b608-b7e45737f9d8 - Musicbrainz_Artistid: 1f9df192-a621-4f54-8850-2c5373b7eac9 - Musicbrainz_Albumartistid: 89ad4ac3-39f7-470e-963a-56509c546377 - Musicbrainz_Releasegroupid: 708b1ae1-2d3d-34c7-b764-2732b154f5b6 - musicbrainz_releasetrackid: 6fee2e35-3049-358f-83be-43b36141028b - CatalogNumber : PLD 1201 -` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(SatisfyAll( - HaveKeyWithValue("catalognumber", []string{"PLD 1201"}), - HaveKeyWithValue("musicbrainz_trackid", []string{"ffe06940-727a-415a-b608-b7e45737f9d8"}), - HaveKeyWithValue("musicbrainz_albumid", []string{"71eb5e4a-90e2-4a31-a2d1-a96485fcb667"}), - HaveKeyWithValue("musicbrainz_artistid", []string{"1f9df192-a621-4f54-8850-2c5373b7eac9"}), - HaveKeyWithValue("musicbrainz_albumartistid", []string{"89ad4ac3-39f7-470e-963a-56509c546377"}), - HaveKeyWithValue("musicbrainz_albumtype", []string{"album"}), - HaveKeyWithValue("musicbrainz_albumcomment", []string{"MP3"}), - )) - }) - - It("detects embedded cover art correctly", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': - Metadata: - compilation : 1 - Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s - Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s - Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) - }) - - It("detects embedded cover art in ffmpeg 4.4 output", func() { - const output = ` -Input #0, flac, from '/run/media/naomi/Archivio/Musica/Katy Perry/Chained to the Rhythm/01 Katy Perry featuring Skip Marley - Chained to the Rhythm.flac': - Metadata: - ARTIST : Katy Perry featuring Skip Marley - Duration: 00:03:57.91, start: 0.000000, bitrate: 983 kb/s - Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 - Stream #0:1: Video: mjpeg (Baseline), yuvj444p(pc, bt470bg/unknown/unknown), 599x518, 90k tbr, 90k tbn, 90k tbc (attached pic) - Metadata: - comment : Cover (front)` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) - }) - - It("detects embedded cover art in ogg containers", func() { - const output = ` -Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamaican In New York/01-02 Jamaican In New York (Album Version).opus': - Duration: 00:04:28.69, start: 0.007500, bitrate: 139 kb/s - Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp - Metadata: - ALBUM : Jamaican In New York - metadata_block_picture: AAAAAwAAAAppbWFnZS9qcGVnAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4Id/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ - TITLE : Jamaican In New York (Album Version)` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKey("metadata_block_picture")) - md = md.Map(e.CustomMappings()) - Expect(md).To(HaveKey("has_picture")) - }) - - It("detects embedded cover art in m4a containers", func() { - const output = ` -Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'Putumayo Presents_ Euro Groove/01 Destins et Désirs.m4a': - Metadata: - album : Putumayo Presents: Euro Groove - Duration: 00:05:15.81, start: 0.047889, bitrate: 133 kb/s - Stream #0:0[0x1](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 125 kb/s (default) - Metadata: - creation_time : 2008-03-11T21:03:23.000000Z - vendor_id : [0][0][0][0] - Stream #0:1[0x0]: Video: png, rgb24(pc), 350x350, 90k tbr, 90k tbn (attached pic) -` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) - }) - - It("gets bitrate from the stream, if available", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': - Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s - Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("bitrate", []string{"192"})) - }) - - It("parses duration with milliseconds", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': - Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("duration", []string{"302.63"})) - }) - - It("parse flac bitrates", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': - Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s - Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) - }) - - It("parse channels from the stream with bitrate", func() { - const output = ` -Input #0, flac, from '/Users/deluan/Music/Music/Media/__/Crazy For You/01-01 Crazy For You.flac': - Metadata: - TITLE : Crazy For You - Duration: 00:04:13.00, start: 0.000000, bitrate: 852 kb/s - Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 - Stream #0:1: Video: mjpeg (Progressive), yuvj444p(pc, bt470bg/unknown/unknown), 600x600, 90k tbr, 90k tbn, 90k tbc (attached pic) -` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("bitrate", []string{"852"})) - }) - - It("parse 7.1 channels from the stream", func() { - const output = ` -Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav': - Duration: 00:00:09.05, bitrate: 9216 kb/s - Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("channels", []string{"8"})) - }) - - It("parse channels from the stream without bitrate", func() { - const output = ` -Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.flac': - Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s - Stream #0:0: Audio: flac, 44100 Hz, stereo, fltp, s32 (24 bit)` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) - }) - - It("parse channels from the stream with lang", func() { - const output = ` -Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a': - Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s - Stream #0:0(eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 262 kb/s (default)` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) - }) - - It("parse channels from the stream with lang 2", func() { - const output = ` -Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a': - Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s - Stream #0:0(eng): Audio: vorbis, 44100 Hz, stereo, fltp, 192 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) - }) - - It("parse sampleRate from the stream", func() { - const output = ` -Input #0, dsf, from '/Users/deluan/Downloads/06-04 Perpetual Change.dsf': - Duration: 00:14:19.46, start: 0.000000, bitrate: 5644 kb/s - Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("samplerate", []string{"352800"})) - }) - - It("parse sampleRate from the stream", func() { - const output = ` -Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav': - Duration: 00:00:09.05, bitrate: 9216 kb/s - Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("samplerate", []string{"48000"})) - }) - - It("parses stream level tags", func() { - const output = ` -Input #0, ogg, from './01-02 Drive (Teku).opus': - Metadata: - ALBUM : Hot Wheels Acceleracers Soundtrack - Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s - Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp - Metadata: - TITLE : Drive (Teku)` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("title", []string{"Drive (Teku)"})) - }) - - It("does not overlap top level tags with the stream level tags", func() { - const output = ` -Input #0, mp3, from 'groovin.mp3': - Metadata: - title : Groovin' (feat. Daniel Sneijers, Susanne Alt) - Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s - Metadata: - title : garbage` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("title", []string{"Groovin' (feat. Daniel Sneijers, Susanne Alt)", "garbage"})) - }) - - It("parses multiline tags", func() { - const outputWithMultilineComment = ` -Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a': - Metadata: - comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/ - : - : Tracklist: - : - : 01. Saara Saara - : 02. Carta Corrente - : 03. X - : 04. Eclipse Lunar - : 05. Vírus de Sírius - : 06. Doktor Fritz - : 07. Wunderbar - : 08. Quarta Dimensão - Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s` - const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/ - -Tracklist: - -01. Saara Saara -02. Carta Corrente -03. X -04. Eclipse Lunar -05. Vírus de Sírius -06. Doktor Fritz -07. Wunderbar -08. Quarta Dimensão` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment) - Expect(md).To(HaveKeyWithValue("comment", []string{expectedComment})) - }) - - It("parses sort tags correctly", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗ノ花 - 2003/02 - ドツペルゲンガー.mp3': - Metadata: - title-sort : Dopperugengā - album : 加爾基 精液 栗ノ花 - artist : 椎名林檎 - album_artist : 椎名林檎 - title : ドツペルゲンガー - albumsort : Kalk Samen Kuri No Hana - artist_sort : Shiina, Ringo - ALBUMARTISTSORT : Shiina, Ringo -` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(SatisfyAll( - HaveKeyWithValue("title", []string{"ドツペルゲンガー"}), - HaveKeyWithValue("album", []string{"加爾基 精液 栗ノ花"}), - HaveKeyWithValue("artist", []string{"椎名林檎"}), - HaveKeyWithValue("album_artist", []string{"椎名林檎"}), - HaveKeyWithValue("title-sort", []string{"Dopperugengā"}), - HaveKeyWithValue("albumsort", []string{"Kalk Samen Kuri No Hana"}), - HaveKeyWithValue("artist_sort", []string{"Shiina, Ringo"}), - HaveKeyWithValue("albumartistsort", []string{"Shiina, Ringo"}), - )) - }) - - It("ignores cover comment", func() { - const output = ` -Input #0, mp3, from './Edie Brickell/Picture Perfect Morning/01-01 Tomorrow Comes.mp3': - Metadata: - title : Tomorrow Comes - artist : Edie Brickell - Duration: 00:03:56.12, start: 0.000000, bitrate: 332 kb/s - Stream #0:0: Audio: mp3, 44100 Hz, stereo, s16p, 320 kb/s - Stream #0:1: Video: mjpeg, yuvj420p(pc, bt470bg/unknown/unknown), 1200x1200 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc - Metadata: - comment : Cover (front)` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).ToNot(HaveKey("comment")) - }) - - It("parses tags with spaces in the name", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hut, to the Projects, to the Mansion/10 - The Struggle (interlude).mp3': - Metadata: - ALBUM ARTIST : Wyclef Jean -` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("album artist", []string{"Wyclef Jean"})) - }) - }) - - It("parses an integer TBPM tag", func() { - const output = ` - Input #0, mp3, from 'tests/fixtures/test.mp3': - Metadata: - TBPM : 123` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("tbpm", []string{"123"})) - }) - - It("parses and rounds a floating point fBPM tag", func() { - const output = ` - Input #0, ogg, from 'tests/fixtures/test.ogg': - Metadata: - FBPM : 141.7` - md, _ := e.extractMetadata("tests/fixtures/test.ogg", output) - Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"})) - }) - - It("parses replaygain data correctly", func() { - const output = ` - Input #0, mp3, from 'test.mp3': - Metadata: - REPLAYGAIN_ALBUM_PEAK: 0.9125 - REPLAYGAIN_TRACK_PEAK: 0.4512 - REPLAYGAIN_TRACK_GAIN: -1.48 dB - REPLAYGAIN_ALBUM_GAIN: +3.21518 dB - Side data: - replaygain: track gain - -1.480000, track peak - 0.000011, album gain - 3.215180, album peak - 0.000021, - ` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(SatisfyAll( - HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}), - HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}), - HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}), - HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}), - )) - }) - - It("parses lyrics with language code", func() { - const output = ` - Input #0, mp3, from 'test.mp3': - Metadata: - lyrics-eng : [00:00.00]This is - : [00:02.50]English - lyrics-xxx : [00:00.00]This is - : [00:02.50]unspecified - ` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(SatisfyAll( - HaveKeyWithValue("lyrics-eng", []string{ - "[00:00.00]This is\n[00:02.50]English", - }), - HaveKeyWithValue("lyrics-xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - }), - )) - }) - - It("parses normal LYRICS tag", func() { - const output = ` - Input #0, mp3, from 'test.mp3': - Metadata: - LYRICS : [00:00.00]This is - : [00:02.50]English - ` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md).To(HaveKeyWithValue("lyrics", []string{ - "[00:00.00]This is\n[00:02.50]English", - })) - }) -}) diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go deleted file mode 100644 index 2906a2c09..000000000 --- a/scanner/metadata_old/metadata.go +++ /dev/null @@ -1,411 +0,0 @@ -package metadata_old - -import ( - "context" - "encoding/json" - "fmt" - "math" - "os" - "path" - "regexp" - "strconv" - "strings" - "time" - - "github.com/djherbis/times" - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type Extractor interface { - Parse(files ...string) (map[string]ParsedTags, error) - CustomMappings() ParsedTags - Version() string -} - -var extractors = map[string]Extractor{} - -func RegisterExtractor(id string, parser Extractor) { - extractors[id] = parser -} - -func LogExtractors() { - for id, p := range extractors { - log.Debug("Registered metadata extractor", "id", id, "version", p.Version()) - } -} - -func Extract(files ...string) (map[string]Tags, error) { - p, ok := extractors[conf.Server.Scanner.Extractor] - if !ok { - log.Warn("Invalid 'Scanner.Extractor' option. Using default", "requested", conf.Server.Scanner.Extractor, - "validOptions", "ffmpeg,taglib", "default", consts.DefaultScannerExtractor) - p = extractors[consts.DefaultScannerExtractor] - } - - extractedTags, err := p.Parse(files...) - if err != nil { - return nil, err - } - - result := map[string]Tags{} - for filePath, tags := range extractedTags { - fileInfo, err := os.Stat(filePath) - if err != nil { - log.Warn("Error stating file. Skipping", "filePath", filePath, err) - continue - } - - tags = tags.Map(p.CustomMappings()) - result[filePath] = NewTag(filePath, fileInfo, tags) - } - - return result, nil -} - -func NewTag(filePath string, fileInfo os.FileInfo, tags ParsedTags) Tags { - for t, values := range tags { - values = removeDuplicatesAndEmpty(values) - if len(values) == 0 { - delete(tags, t) - continue - } - tags[t] = values - } - return Tags{ - filePath: filePath, - fileInfo: fileInfo, - Tags: tags, - } -} - -func removeDuplicatesAndEmpty(values []string) []string { - encountered := map[string]struct{}{} - empty := true - result := make([]string, 0, len(values)) - for _, v := range values { - if _, ok := encountered[v]; ok { - continue - } - encountered[v] = struct{}{} - empty = empty && v == "" - result = append(result, v) - } - if empty { - return nil - } - return result -} - -type ParsedTags map[string][]string - -func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags { - if customMappings == nil { - return p - } - for tagName, alternatives := range customMappings { - for _, altName := range alternatives { - if altValue, ok := p[altName]; ok { - p[tagName] = append(p[tagName], altValue...) - delete(p, altName) - } - } - } - return p -} - -type Tags struct { - filePath string - fileInfo os.FileInfo - Tags ParsedTags -} - -// Common tags - -func (t Tags) Title() string { return t.getFirstTagValue("title", "sort_name", "titlesort") } -func (t Tags) Album() string { return t.getFirstTagValue("album", "sort_album", "albumsort") } -func (t Tags) Artist() string { return t.getFirstTagValue("artist", "sort_artist", "artistsort") } -func (t Tags) AlbumArtist() string { - return t.getFirstTagValue("album_artist", "album artist", "albumartist") -} -func (t Tags) SortTitle() string { return t.getSortTag("tsot", "title", "name") } -func (t Tags) SortAlbum() string { return t.getSortTag("tsoa", "album") } -func (t Tags) SortArtist() string { return t.getSortTag("tsop", "artist") } -func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") } -func (t Tags) Genres() []string { return t.getAllTagValues("genre") } -func (t Tags) Date() (int, string) { return t.getDate("date") } -func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") } -func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") } -func (t Tags) Comment() string { return t.getFirstTagValue("comment") } -func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") } -func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } -func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") } -func (t Tags) DiscSubtitle() string { - return t.getFirstTagValue("tsst", "discsubtitle", "setsubtitle") -} -func (t Tags) CatalogNum() string { return t.getFirstTagValue("catalognumber") } -func (t Tags) Bpm() int { return (int)(math.Round(t.getFloat("tbpm", "bpm", "fbpm"))) } -func (t Tags) HasPicture() bool { return t.getFirstTagValue("has_picture") != "" } - -// MusicBrainz Identifiers - -func (t Tags) MbzReleaseTrackID() string { - return t.getMbzID("musicbrainz_releasetrackid", "musicbrainz release track id") -} - -func (t Tags) MbzRecordingID() string { - return t.getMbzID("musicbrainz_trackid", "musicbrainz track id") -} -func (t Tags) MbzAlbumID() string { return t.getMbzID("musicbrainz_albumid", "musicbrainz album id") } -func (t Tags) MbzArtistID() string { - return t.getMbzID("musicbrainz_artistid", "musicbrainz artist id") -} -func (t Tags) MbzAlbumArtistID() string { - return t.getMbzID("musicbrainz_albumartistid", "musicbrainz album artist id") -} -func (t Tags) MbzAlbumType() string { - return t.getFirstTagValue("musicbrainz_albumtype", "musicbrainz album type") -} -func (t Tags) MbzAlbumComment() string { - return t.getFirstTagValue("musicbrainz_albumcomment", "musicbrainz album comment") -} - -// Gain Properties - -func (t Tags) RGAlbumGain() float64 { - return t.getGainValue("replaygain_album_gain", "r128_album_gain") -} -func (t Tags) RGAlbumPeak() float64 { return t.getPeakValue("replaygain_album_peak") } -func (t Tags) RGTrackGain() float64 { - return t.getGainValue("replaygain_track_gain", "r128_track_gain") -} -func (t Tags) RGTrackPeak() float64 { return t.getPeakValue("replaygain_track_peak") } - -// File properties - -func (t Tags) Duration() float32 { return float32(t.getFloat("duration")) } -func (t Tags) SampleRate() int { return t.getInt("samplerate") } -func (t Tags) BitRate() int { return t.getInt("bitrate") } -func (t Tags) Channels() int { return t.getInt("channels") } -func (t Tags) ModificationTime() time.Time { return t.fileInfo.ModTime() } -func (t Tags) Size() int64 { return t.fileInfo.Size() } -func (t Tags) FilePath() string { return t.filePath } -func (t Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) } -func (t Tags) BirthTime() time.Time { - if ts := times.Get(t.fileInfo); ts.HasBirthTime() { - return ts.BirthTime() - } - return time.Now() -} - -func (t Tags) Lyrics() string { - lyricList := model.LyricList{} - basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") - - for _, value := range basicLyrics { - parsed, err := model.ParseLyrics(context.Background(), ".lrc", "xxx", []byte(value)) - if err != nil { - log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) - continue - } - if main, ok := parsed.Main(); ok { - lyricList = append(lyricList, main) - } - } - - for tag, value := range t.Tags { - if after, ok := strings.CutPrefix(tag, "lyrics-"); ok { - language := strings.TrimSpace(after) - - if language == "" { - language = "xxx" - } - - for _, text := range value { - parsed, err := model.ParseLyrics(context.Background(), ".lrc", language, []byte(text)) - if err != nil { - log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) - continue - } - if main, ok := parsed.Main(); ok { - lyricList = append(lyricList, main) - } - } - } - } - - res, err := json.Marshal(lyricList) - if err != nil { - log.Warn("Unexpected error occurred when serializing lyrics", "file", t.filePath, "error", err) - return "" - } - return string(res) -} - -func (t Tags) getGainValue(rgTagName, r128TagName string) float64 { - // Check for ReplayGain first - // ReplayGain is in the form [-]a.bb dB and normalized to -18dB - var tag = t.getFirstTagValue(rgTagName) - if tag != "" { - tag = strings.TrimSpace(strings.Replace(tag, "dB", "", 1)) - var value, err = strconv.ParseFloat(tag, 64) - if err != nil || value == math.Inf(-1) || value == math.Inf(1) { - return 0 - } - return value - } - - // If ReplayGain is not found, check for R128 gain - // R128 gain is a Q7.8 fixed point number normalized to -23dB - tag = t.getFirstTagValue(r128TagName) - if tag != "" { - var iValue, err = strconv.Atoi(tag) - if err != nil { - return 0 - } - // Convert Q7.8 to float - var value = float64(iValue) / 256.0 - // Adding 5 dB to normalize with ReplayGain level - return value + 5 - } - - return 0 -} - -func (t Tags) getPeakValue(tagName string) float64 { - var tag = t.getFirstTagValue(tagName) - var value, err = strconv.ParseFloat(tag, 64) - if err != nil || value == math.Inf(-1) || value == math.Inf(1) { - // A default of 1 for peak value results in no changes - return 1 - } - return value -} - -func (t Tags) getTags(tagNames ...string) []string { - for _, tag := range tagNames { - if v, ok := t.Tags[tag]; ok { - return v - } - } - return nil -} - -func (t Tags) getFirstTagValue(tagNames ...string) string { - ts := t.getTags(tagNames...) - if len(ts) > 0 { - return ts[0] - } - return "" -} - -func (t Tags) getAllTagValues(tagNames ...string) []string { - values := make([]string, 0, len(tagNames)*2) - for _, tag := range tagNames { - if v, ok := t.Tags[tag]; ok { - values = append(values, v...) - } - } - return values -} - -func (t Tags) getSortTag(originalTag string, tagNames ...string) string { - formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"} - all := make([]string, 1, len(tagNames)*len(formats)+1) - all[0] = originalTag - for _, tag := range tagNames { - for _, format := range formats { - name := fmt.Sprintf(format, tag) - all = append(all, name) - } - } - return t.getFirstTagValue(all...) -} - -var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) - -func (t Tags) getDate(tagNames ...string) (int, string) { - tag := t.getFirstTagValue(tagNames...) - if len(tag) < 4 { - return 0, "" - } - // first get just the year - match := dateRegex.FindStringSubmatch(tag) - if len(match) == 0 { - log.Warn("Error parsing "+tagNames[0]+" field for year", "file", t.filePath, "date", tag) - return 0, "" - } - year, _ := strconv.Atoi(match[1]) - - if len(tag) < 5 { - return year, match[1] - } - - //then try YYYY-MM-DD - if len(tag) > 10 { - tag = tag[:10] - } - layout := "2006-01-02" - _, err := time.Parse(layout, tag) - if err != nil { - layout = "2006-01" - _, err = time.Parse(layout, tag) - if err != nil { - log.Warn("Error parsing "+tagNames[0]+" field for month + day", "file", t.filePath, "date", tag) - return year, match[1] - } - } - return year, tag -} - -func (t Tags) getBool(tagNames ...string) bool { - tag := t.getFirstTagValue(tagNames...) - if tag == "" { - return false - } - i, _ := strconv.Atoi(strings.TrimSpace(tag)) - return i == 1 -} - -func (t Tags) getTuple(tagNames ...string) (int, int) { - tag := t.getFirstTagValue(tagNames...) - if tag == "" { - return 0, 0 - } - tuple := strings.Split(tag, "/") - t1, t2 := 0, 0 - t1, _ = strconv.Atoi(tuple[0]) - if len(tuple) > 1 { - t2, _ = strconv.Atoi(tuple[1]) - } else { - t2tag := t.getFirstTagValue(tagNames[0] + "total") - t2, _ = strconv.Atoi(t2tag) - } - return t1, t2 -} - -func (t Tags) getMbzID(tagNames ...string) string { - tag := t.getFirstTagValue(tagNames...) - if _, err := uuid.Parse(tag); err != nil { - return "" - } - return tag -} - -func (t Tags) getInt(tagNames ...string) int { - tag := t.getFirstTagValue(tagNames...) - i, _ := strconv.Atoi(tag) - return i -} - -func (t Tags) getFloat(tagNames ...string) float64 { - var tag = t.getFirstTagValue(tagNames...) - var value, err = strconv.ParseFloat(tag, 64) - if err != nil { - return 0 - } - return value -} diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go deleted file mode 100644 index aff1ede9c..000000000 --- a/scanner/metadata_old/metadata_internal_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package metadata_old - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Tags", func() { - DescribeTable("getDate", - func(tag string, expectedYear int, expectedDate string) { - md := &Tags{} - md.Tags = map[string][]string{"date": {tag}} - testYear, testDate := md.Date() - Expect(testYear).To(Equal(expectedYear)) - Expect(testDate).To(Equal(expectedDate)) - }, - Entry(nil, "1985", 1985, "1985"), - Entry(nil, "2002-01", 2002, "2002-01"), - Entry(nil, "1969.06", 1969, "1969"), - Entry(nil, "1980.07.25", 1980, "1980"), - Entry(nil, "2004-00-00", 2004, "2004"), - Entry(nil, "2016-12-31", 2016, "2016-12-31"), - Entry(nil, "2013-May-12", 2013, "2013"), - Entry(nil, "May 12, 2016", 2016, "2016"), - Entry(nil, "01/10/1990", 1990, "1990"), - Entry(nil, "invalid", 0, ""), - ) - - Describe("getMbzID", func() { - It("return a valid MBID", func() { - md := &Tags{} - md.Tags = map[string][]string{ - "musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"}, - "musicbrainz_releasetrackid": {"6caf16d3-0b20-3fe6-8020-52e31831bc11"}, - "musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"}, - "musicbrainz_artistid": {"89ad4ac3-39f7-470e-963a-56509c546377"}, - "musicbrainz_albumartistid": {"ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"}, - } - Expect(md.MbzRecordingID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1")) - Expect(md.MbzReleaseTrackID()).To(Equal("6caf16d3-0b20-3fe6-8020-52e31831bc11")) - Expect(md.MbzAlbumID()).To(Equal("f68c985d-f18b-4f4a-b7f0-87837cf3fbf9")) - Expect(md.MbzArtistID()).To(Equal("89ad4ac3-39f7-470e-963a-56509c546377")) - Expect(md.MbzAlbumArtistID()).To(Equal("ada7a83c-e3e1-40f1-93f9-3e73dbc9298a")) - }) - It("return empty string for invalid MBID", func() { - md := &Tags{} - md.Tags = map[string][]string{ - "musicbrainz_trackid": {"11406732-6"}, - "musicbrainz_albumid": {"11406732"}, - "musicbrainz_artistid": {"200455"}, - "musicbrainz_albumartistid": {"194"}, - } - Expect(md.MbzRecordingID()).To(Equal("")) - Expect(md.MbzAlbumID()).To(Equal("")) - Expect(md.MbzArtistID()).To(Equal("")) - Expect(md.MbzAlbumArtistID()).To(Equal("")) - }) - }) - - Describe("getAllTagValues", func() { - It("returns values from all tag names", func() { - md := &Tags{} - md.Tags = map[string][]string{ - "genre": {"Rock", "Pop", "New Wave"}, - } - - Expect(md.Genres()).To(ConsistOf("Rock", "Pop", "New Wave")) - }) - }) - - Describe("removeDuplicatesAndEmpty", func() { - It("removes duplicates", func() { - md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ - "genre": []string{"pop", "rock", "pop"}, - "date": []string{"2023-03-01", "2023-03-01"}, - "mood": []string{"happy", "sad"}, - }) - Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) - Expect(md.Tags).To(HaveKeyWithValue("date", []string{"2023-03-01"})) - Expect(md.Tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"})) - }) - It("removes empty tags", func() { - md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ - "genre": []string{"pop", "rock", "pop"}, - "mood": []string{"", ""}, - }) - Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) - Expect(md.Tags).ToNot(HaveKey("mood")) - }) - }) - - Describe("BPM", func() { - var t *Tags - BeforeEach(func() { - t = &Tags{Tags: map[string][]string{ - "fbpm": {"141.7"}, - }} - }) - - It("rounds a floating point fBPM tag", func() { - Expect(t.Bpm()).To(Equal(142)) - }) - }) - - Describe("ReplayGain", func() { - DescribeTable("getGainValue", - func(tag string, expected float64) { - md := &Tags{} - md.Tags = map[string][]string{"replaygain_track_gain": {tag}} - Expect(md.RGTrackGain()).To(Equal(expected)) - - }, - Entry("0", "0", 0.0), - Entry("1.2dB", "1.2dB", 1.2), - Entry("Infinity", "Infinity", 0.0), - Entry("Invalid value", "INVALID VALUE", 0.0), - ) - DescribeTable("getPeakValue", - func(tag string, expected float64) { - md := &Tags{} - md.Tags = map[string][]string{"replaygain_track_peak": {tag}} - Expect(md.RGTrackPeak()).To(Equal(expected)) - - }, - Entry("0", "0", 0.0), - Entry("0.5", "0.5", 0.5), - Entry("Invalid dB suffix", "0.7dB", 1.0), - Entry("Infinity", "Infinity", 1.0), - Entry("Invalid value", "INVALID VALUE", 1.0), - ) - DescribeTable("getR128GainValue", - func(tag string, expected float64) { - md := &Tags{} - md.Tags = map[string][]string{"r128_track_gain": {tag}} - Expect(md.RGTrackGain()).To(Equal(expected)) - - }, - Entry("0", "0", 5.0), - Entry("-3776", "-3776", -9.75), - Entry("Infinity", "Infinity", 0.0), - Entry("Invalid value", "INVALID VALUE", 0.0), - ) - }) -}) diff --git a/scanner/metadata_old/metadata_suite_test.go b/scanner/metadata_old/metadata_suite_test.go deleted file mode 100644 index 03ec3c847..000000000 --- a/scanner/metadata_old/metadata_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package metadata_old - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestMetadata(t *testing.T) { - tests.Init(t, true) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Metadata Suite") -} diff --git a/scanner/metadata_old/metadata_test.go b/scanner/metadata_old/metadata_test.go deleted file mode 100644 index 444bb7fc4..000000000 --- a/scanner/metadata_old/metadata_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package metadata_old_test - -import ( - "cmp" - "encoding/json" - "slices" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/scanner/metadata_old" - _ "github.com/navidrome/navidrome/scanner/metadata_old/ffmpeg" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Tags", func() { - var zero int64 = 0 - var secondTs int64 = 2500 - - makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics { - lines := []model.Line{ - {Value: "This is"}, - {Value: secondLine}, - } - - if synced { - lines[0].Start = &zero - lines[1].Start = &secondTs - } - - lyrics := model.Lyrics{ - Lang: lang, - Line: lines, - Synced: synced, - } - - return lyrics - } - - sortLyrics := func(lines model.LyricList) model.LyricList { - slices.SortFunc(lines, func(a, b model.Lyrics) int { - langDiff := cmp.Compare(a.Lang, b.Lang) - if langDiff != 0 { - return langDiff - } - return cmp.Compare(a.Line[1].Value, b.Line[1].Value) - }) - - return lines - } - - compareLyrics := func(m metadata_old.Tags, expected model.LyricList) { - lyrics := model.LyricList{} - Expect(json.Unmarshal([]byte(m.Lyrics()), &lyrics)).To(BeNil()) - Expect(sortLyrics(lyrics)).To(Equal(sortLyrics(expected))) - } - - // Only run these tests if FFmpeg is available - FFmpegContext := XContext - if ffmpeg.New().IsAvailable() { - FFmpegContext = Context - } - FFmpegContext("Extract with FFmpeg", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Scanner.Extractor = "ffmpeg" - }) - - DescribeTable("Lyrics test", - func(file string) { - path := "tests/fixtures/" + file - mds, err := metadata_old.Extract(path) - Expect(err).ToNot(HaveOccurred()) - Expect(mds).To(HaveLen(1)) - - m := mds[path] - compareLyrics(m, model.LyricList{ - makeLyrics(true, "eng", "English"), - makeLyrics(true, "xxx", "unspecified"), - }) - }, - - Entry("Parses AIFF file", "test.aiff"), - Entry("Parses MP3 files", "test.mp3"), - // Disabled, because it fails in pipeline - // Entry("Parses WAV files", "test.wav"), - - // FFMPEG behaves very weirdly for multivalued tags for non-ID3 - // Specifically, they are separated by ";, which is indistinguishable - // from other fields - ) - }) -}) diff --git a/server/initial_setup.go b/server/initial_setup.go index be9e14ae9..462e22e54 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -72,10 +72,6 @@ func checkFFmpegInstallation() { _, err := f.CmdPath() if err != nil { log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) - if conf.Server.Scanner.Extractor == "ffmpeg" { - log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") - conf.Server.Scanner.Extractor = "taglib" - } return } if !f.IsProbeAvailable() { diff --git a/tests/harness/harness.go b/tests/harness/harness.go index 92196ef26..67f3150a2 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -176,8 +176,6 @@ func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { return nil, errors.New("noop ffmpeg: extract image not supported") } -func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil } - func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { return nil, errors.New("noop ffmpeg: probe not supported") } diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index f9862767e..8e4d12f0b 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -57,12 +57,6 @@ func (ff *MockFFmpeg) ConvertAnimatedImage(_ context.Context, reader io.Reader, return io.NopCloser(bytes.NewReader(data)), nil } -func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { - if ff.Error != nil { - return "", ff.Error - } - return "", nil -} func (ff *MockFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { if ff.Error != nil { return nil, ff.Error From c22ce9ebb27c5e80b104c28a82ac70b018f07895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 26 Sep 2026 15:27:23 -0400 Subject: [PATCH 583/596] feat(api): add the API v1 foundation behind DevAPIv1 (#6227) * feat(api): add OpenAPI v1 spec skeleton, lint ruleset and bundle tooling vacuum v0.30.6's `bundle --composed` mangles component names for this spec's multi-file layout (duplicates Problem as Problem__schemas etc.), so api-bundle uses the Redocly CLI (npx @redocly/cli bundle) instead. * fix(api): pin the Redocly CLI version Tried moving components out of the root document (per libopenapi's nested_files example) so vacuum's own bundler could produce clean names, but any component declared via $ref inside components.* still gets a __-suffixed twin regardless of collisions elsewhere, so vacuum's --composed bundler can't cleanly bundle this spec. Pin the already-working Redocly fallback to an exact version instead of @latest. * fix(api): bundle the OpenAPI spec with vacuum vacuum's --composed bundler suffixes any component reached via a $ref written directly inside the root document's own components.* block, regardless of collisions elsewhere. Dropping the root-level schemas/ parameters/responses declarations (keeping only securitySchemes, and leaving every component file under api/openapi/components/ untouched) lets vacuum bundle cleanly with no __ suffixes, going back to Go-only tooling. Components nothing references yet (ListMeta, offset, limit, BadRequest, Unauthorized, Forbidden, NotFound) are absent from the bundle until a later task's operation references them. * fix(api): make spec lint rules cover all schemas and error codes nd-schema-property-descriptions targeted $.components.schemas, but our schemas live in path/response files, not the root document, so it was dead code; switched to $..properties[*] to walk every resolved schema wherever it ends up. nd-error-responses-are-problems only checked a hardcoded status-code list; switched to a patternProperties schema matching the full 4xx/5xx range. Also: api-diff now diffs against the merge-base with API_DIFF_BASE (falling back to its tip with a notice if no merge-base exists), gen no longer depends on api-gen until Task 3 wires up oapi-codegen, and api-lint suppresses vacuum's banner. * feat(api): embed the bundled OpenAPI spec and expose its version * feat(api): generate the v1 server interface with oapi-codegen * feat(api): add RFC 9457 problem responses for API v1 * feat(api): add API v1 router with /server discovery and spec routes * fix(api): serve the OpenAPI document without range support * feat(api): mount API v1 behind the DevAPIv1 flag * chore(ci): lint, regenerate and diff the OpenAPI v1 spec * refactor(api): tighten spec version access, lint rules and test naming * refactor(api): simplify spec routes, tests and OpenAPI tooling Share one If-None-Match parser (utils/req) between the image and spec routes, declare the YAML spec response as an object so tests need no decoder override, and reuse ETag/304 spec components. Install the OpenAPI tools only when missing or at a different version, fail api-diff when its base ref does not exist, and in CI cache the tools, fold regeneration into the go generate check, and fetch only the PR base commit for the breaking-change gate. * refactor(api): raise the list limit maximum to 2000 and drop the flag test * feat(api): treat added enum values as non-breaking Enums in API v1 are open: clients must accept unknown values. api-diff now downgrades response-property-enum-value-added to INFO, while removing a value from a request enum stays breaking. * feat(api): gate breaking changes on x-stability-level Every operation declares x-stability-level (alpha, beta, stable). oasdiff ignores breaking changes to alpha operations and rejects lowering a level, so unreleased endpoints can evolve while beta and stable ones stay additive. All current operations start as alpha. * feat(api): declare loginMethods as an enum Prefix generated enum constants with their type name so enums sharing a value (for example password) cannot collide in package apiv1. * feat(api): send Allow on 405 and answer HEAD wherever GET is routed chi only sets Allow in its default 405 handler, so the problem-format handler now builds it by matching each method against the v1 router. HEAD requests fall back to the GET route, as RFC 9110 expects. * refactor(api): hash the spec ETag with xxh3 The bytes are compiled in, and the digest was truncated to 64 bits anyway, so this matches the artwork ETags instead of paying for cryptographic strength we discard. * docs(api): explain the about:blank problem type * feat(api): make code the problem identifier and omit a blank type RFC 9457 says clients switch on the type URI, but no adopter surveyed ships both a populated type and a separate code. Declare code as an enum, and send type only once a problem has semantics of its own. * fix(api): advertise the configured base path in the served OpenAPI spec With BaseURL=/music the API is mounted at /music/api/v1, but the spec told clients to call /api/v1 at the host root. The server now rewrites servers[0].url to BasePath + /api/v1 when it serves the document. Relative server URLs were tested first: "." and "../v1" work in openapi-generator, Swagger UI and Redoc, but Scalar resolves them against the page origin, so it breaks even without a base path. The committed bundle keeps /api/v1, and a test pins that it appears exactly once, which the rewrite relies on. --- .github/workflows/pipeline.yml | 23 +- Makefile | 45 +- api/.oasdiff-levels.txt | 1 + api/.vacuum.yaml | 155 +++++++ api/api_suite_test.go | 17 + api/bundled/openapi.json | 262 ++++++++++++ api/bundled/openapi.yaml | 194 +++++++++ api/embed.go | 35 ++ api/embed_test.go | 37 ++ api/openapi/components/headers/ETag.yaml | 3 + api/openapi/components/parameters/limit.yaml | 9 + api/openapi/components/parameters/offset.yaml | 8 + .../components/responses/BadRequest.yaml | 5 + .../components/responses/Forbidden.yaml | 5 + .../components/responses/InternalError.yaml | 5 + .../components/responses/NotFound.yaml | 5 + .../components/responses/NotModified.yaml | 4 + .../components/responses/Unauthorized.yaml | 5 + api/openapi/components/schemas/ListMeta.yaml | 13 + api/openapi/components/schemas/Problem.yaml | 35 ++ .../components/schemas/ServerInfo.yaml | 22 + .../components/schemas/ValidationError.yaml | 10 + api/openapi/openapi.yaml | 39 ++ api/openapi/paths/openapi.yaml | 42 ++ api/openapi/paths/server.yaml | 19 + cmd/root.go | 3 + cmd/wire_gen.go | 10 +- cmd/wire_injectors.go | 8 + conf/configuration.go | 2 + consts/consts.go | 1 + go.mod | 6 + go.sum | 18 +- server/apiv1/api.go | 98 +++++ server/apiv1/api_gen.go | 390 ++++++++++++++++++ server/apiv1/api_test.go | 75 ++++ server/apiv1/apiv1_suite_test.go | 66 +++ server/apiv1/oapi-codegen.yaml | 12 + server/apiv1/problem.go | 72 ++++ server/apiv1/problem_test.go | 114 +++++ server/apiv1/server_info.go | 23 ++ server/apiv1/server_info_test.go | 61 +++ server/apiv1/spec.go | 51 +++ server/apiv1/spec_test.go | 136 ++++++ server/imghttp/headers.go | 23 +- utils/req/req.go | 18 + utils/req/req_test.go | 18 + 46 files changed, 2177 insertions(+), 26 deletions(-) create mode 100644 api/.oasdiff-levels.txt create mode 100644 api/.vacuum.yaml create mode 100644 api/api_suite_test.go create mode 100644 api/bundled/openapi.json create mode 100644 api/bundled/openapi.yaml create mode 100644 api/embed.go create mode 100644 api/embed_test.go create mode 100644 api/openapi/components/headers/ETag.yaml create mode 100644 api/openapi/components/parameters/limit.yaml create mode 100644 api/openapi/components/parameters/offset.yaml create mode 100644 api/openapi/components/responses/BadRequest.yaml create mode 100644 api/openapi/components/responses/Forbidden.yaml create mode 100644 api/openapi/components/responses/InternalError.yaml create mode 100644 api/openapi/components/responses/NotFound.yaml create mode 100644 api/openapi/components/responses/NotModified.yaml create mode 100644 api/openapi/components/responses/Unauthorized.yaml create mode 100644 api/openapi/components/schemas/ListMeta.yaml create mode 100644 api/openapi/components/schemas/Problem.yaml create mode 100644 api/openapi/components/schemas/ServerInfo.yaml create mode 100644 api/openapi/components/schemas/ValidationError.yaml create mode 100644 api/openapi/openapi.yaml create mode 100644 api/openapi/paths/openapi.yaml create mode 100644 api/openapi/paths/server.yaml create mode 100644 server/apiv1/api.go create mode 100644 server/apiv1/api_gen.go create mode 100644 server/apiv1/api_test.go create mode 100644 server/apiv1/apiv1_suite_test.go create mode 100644 server/apiv1/oapi-codegen.yaml create mode 100644 server/apiv1/problem.go create mode 100644 server/apiv1/problem_test.go create mode 100644 server/apiv1/server_info.go create mode 100644 server/apiv1/server_info_test.go create mode 100644 server/apiv1/spec.go create mode 100644 server/apiv1/spec_test.go diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2702163d3..c1ba8714c 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -92,8 +92,23 @@ jobs: exit 1 fi + - name: Resolve OpenAPI tool versions + id: api-tools + run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT" + + - name: Cache OpenAPI tools + uses: actions/cache@v6 + with: + path: bin + key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }} + + - name: Lint OpenAPI spec + run: make api-lint + - name: Run go generate - run: go generate ./... + run: | + make api-gen + go generate ./... - name: Verify no changes from go generate run: | git status --porcelain @@ -102,6 +117,12 @@ jobs: exit 1 fi + - name: Check for breaking OpenAPI changes + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} + make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }} + validate-migrations: name: Validate DB migrations runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index eccccbffb..f31d98a01 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,10 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop GOLANGCI_LINT_VERSION ?= v2.14.0 +VACUUM_VERSION ?= v0.30.6 +OAPI_CODEGEN_VERSION ?= v2.8.0 +OASDIFF_VERSION ?= v1.32.1 +API_DIFF_BASE ?= origin/master UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") @@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present fi .PHONY: install-golangci-lint +install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin + @STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \ + if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \ + echo "Installing OpenAPI tools..."; \ + GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \ + GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \ + GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \ + rm -f bin/.api-tools-* && touch $$STAMP; \ + fi +.PHONY: install-api-tools + +api-lint: install-api-tools ##@Development Lint the OpenAPI spec + ./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml +.PHONY: api-lint + +api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled + ./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml + ./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json +.PHONY: api-bundle + +api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec + ./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json +.PHONY: api-gen + +api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE) + @git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \ + BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \ + if [ -z "$$BASE" ]; then \ + echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \ + BASE=$(API_DIFF_BASE); \ + fi; \ + if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \ + git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \ + ./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \ + else \ + echo "No bundled spec at $$BASE; skipping breaking-change check"; \ + fi +.PHONY: api-diff + lint: install-golangci-lint ##@Development Lint Go code PATH=./bin:$$PATH golangci-lint run --timeout 5m .PHONY: lint @@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./... .PHONY: wire -gen: check_go_env ##@Development Run go generate for code generation +gen: check_go_env api-gen ##@Development Run go generate for code generation go generate ./... cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types diff --git a/api/.oasdiff-levels.txt b/api/.oasdiff-levels.txt new file mode 100644 index 000000000..1792adcb2 --- /dev/null +++ b/api/.oasdiff-levels.txt @@ -0,0 +1 @@ +response-property-enum-value-added INFO diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml new file mode 100644 index 000000000..bd31e23f8 --- /dev/null +++ b/api/.vacuum.yaml @@ -0,0 +1,155 @@ +extends: [[spectral:oas, recommended]] +rules: + # vacuum's `enumeration` function mis-resolves hyphenated `then.field` names, + # so the value check below targets `x-module` via `given` instead. + nd-operation-x-module-required: + description: Every operation belongs to exactly one capability module. + severity: error + given: $.paths[*][get,put,post,delete,patch] + then: + field: x-module + function: truthy + nd-operation-x-module: + description: Every operation's capability module is one of the known values. + severity: error + given: $.paths[*][get,put,post,delete,patch]['x-module'] + then: + function: enumeration + functionOptions: + values: + - core + - streaming + - download + - artwork + - lyrics + - transcoding + - annotations + - playback + - queue + - custom-tags + - grouping + - playlists + - smart-playlists + - sync + - events + - jukebox + - sharing + - radio + - admin + nd-operation-stability-level-required: + description: Every operation declares its stability level, which the breaking-change gate relies on. + severity: error + given: $.paths[*][get,put,post,delete,patch] + then: + field: x-stability-level + function: truthy + nd-operation-stability-level: + description: Every operation's stability level is alpha, beta, or stable. + severity: error + given: $.paths[*][get,put,post,delete,patch]['x-stability-level'] + then: + function: enumeration + functionOptions: + values: + - alpha + - beta + - stable + nd-operation-required-fields: + description: Operations need a stable operationId, summary, description and tags. + severity: error + given: $.paths[*][get,put,post,delete,patch] + then: + - field: operationId + function: truthy + - field: summary + function: truthy + - field: description + function: truthy + - field: tags + function: truthy + # Our schemas live in path/response files, not root components, so this + # walks every resolved `properties` map in the document via `$..` instead. + nd-schema-property-descriptions: + description: Every schema property is documented. + severity: error + given: $..properties[*] + then: + field: description + function: truthy + # patternProperties covers the full 4xx/5xx range; needs an explicit + # `properties` entry too, or `additionalProperties: false` rejects it. + nd-error-responses-are-problems: + description: 4xx and 5xx responses use application/problem+json. + severity: error + given: $.paths[*][*].responses + then: + function: schema + functionOptions: + forceValidationOnCurrentNode: true + schema: + type: object + patternProperties: + "^[45][0-9][0-9]$": + type: object + required: [content] + properties: + content: + type: object + properties: + application/problem+json: {} + required: [application/problem+json] + additionalProperties: false + # Same filter limitation applies here: "is this a list endpoint" is expressed + # as a JSON Schema if/then on the operation object instead of a `given` filter. + nd-list-endpoints-paginate: + description: List endpoints declare the shared offset and limit parameters. + severity: error + given: $.paths[*].get + then: + function: schema + functionOptions: + forceValidationOnCurrentNode: true + schema: + type: object + if: + required: [responses] + properties: + responses: + type: object + required: ['200'] + properties: + '200': + type: object + required: [content] + properties: + content: + type: object + required: [application/json] + properties: + application/json: + type: object + required: [schema] + properties: + schema: + type: object + required: [properties] + properties: + properties: + type: object + required: [items] + then: + required: [parameters] + properties: + parameters: + type: array + allOf: + - contains: + type: object + properties: + name: + const: offset + - contains: + type: object + properties: + name: + const: limit diff --git a/api/api_suite_test.go b/api/api_suite_test.go new file mode 100644 index 000000000..62a547b7c --- /dev/null +++ b/api/api_suite_test.go @@ -0,0 +1,17 @@ +package api_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAPI(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "API Spec Suite") +} diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json new file mode 100644 index 000000000..bfe794c71 --- /dev/null +++ b/api/bundled/openapi.json @@ -0,0 +1,262 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Navidrome API", + "version": "1.0.0", + "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n", + "license": { + "name": "GPL-3.0", + "url": "https://www.gnu.org/licenses/gpl-3.0.html" + } + }, + "servers": [ + { + "url": "/api/v1" + } + ], + "tags": [ + { + "name": "server", + "description": "Server discovery and the published OpenAPI document." + } + ], + "paths": { + "/server": { + "get": { + "operationId": "getServerInfo", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "server" + ], + "summary": "Describe the server", + "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", + "responses": { + "200": { + "description": "Server description.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfo" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/openapi.json": { + "get": { + "operationId": "getOpenAPISpecJSON", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "server" + ], + "summary": "Get the OpenAPI document (JSON)", + "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", + "responses": { + "200": { + "description": "The OpenAPI document.", + "headers": { + "ETag": { + "$ref": "#/components/headers/ETag" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "OpenAPI 3.0 document." + } + } + } + }, + "304": { + "$ref": "#/components/responses/NotModified" + } + } + } + }, + "/openapi.yaml": { + "get": { + "operationId": "getOpenAPISpecYAML", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "server" + ], + "summary": "Get the OpenAPI document (YAML)", + "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", + "responses": { + "200": { + "description": "The OpenAPI document.", + "headers": { + "ETag": { + "$ref": "#/components/headers/ETag" + } + }, + "content": { + "application/yaml": { + "schema": { + "type": "object", + "description": "OpenAPI 3.0 document." + } + } + } + }, + "304": { + "$ref": "#/components/responses/NotModified" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Short-lived access token minted from a device grant. Not yet applied to any operation." + } + }, + "schemas": { + "ServerInfo": { + "type": "object", + "description": "Public server description. Everything an add-server screen needs before login.", + "required": [ + "name", + "serverVersion", + "specVersion", + "setupRequired", + "loginMethods" + ], + "properties": { + "name": { + "type": "string", + "description": "Human-readable server product name." + }, + "serverVersion": { + "type": "string", + "description": "Version of the running server build." + }, + "specVersion": { + "type": "string", + "description": "Version of the OpenAPI document this server implements." + }, + "setupRequired": { + "type": "boolean", + "description": "True until the first admin user has been created." + }, + "loginMethods": { + "type": "array", + "description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.", + "items": { + "type": "string", + "enum": [ + "password" + ] + } + } + } + }, + "Problem": { + "type": "object", + "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", + "required": [ + "title", + "status", + "code" + ], + "properties": { + "type": { + "type": "string", + "description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n" + }, + "title": { + "type": "string", + "description": "Short human-readable summary, the same for all occurrences of this problem type." + }, + "status": { + "type": "integer", + "description": "HTTP status code of this response." + }, + "detail": { + "type": "string", + "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors." + }, + "code": { + "type": "string", + "description": "Machine-readable error code, and the value clients switch on. New codes may be added.", + "enum": [ + "validation", + "unauthorized", + "forbidden", + "not_found", + "method_not_allowed", + "unavailable", + "internal" + ] + }, + "errors": { + "type": "array", + "description": "Per-field failures. Present only when `code` is `validation`.", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "ValidationError": { + "type": "object", + "description": "One field-level validation failure.", + "required": [ + "field", + "message" + ], + "properties": { + "field": { + "type": "string", + "description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)." + }, + "message": { + "type": "string", + "description": "Why the value was rejected." + } + } + } + }, + "responses": { + "InternalError": { + "description": "Unexpected server failure. Details are in the server log.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "NotModified": { + "description": "Not modified.", + "headers": { + "ETag": { + "$ref": "#/components/headers/ETag" + } + } + } + }, + "headers": { + "ETag": { + "description": "Entity tag for `If-None-Match` revalidation.", + "schema": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml new file mode 100644 index 000000000..f1ae95b76 --- /dev/null +++ b/api/bundled/openapi.yaml @@ -0,0 +1,194 @@ +openapi: 3.0.3 +info: + title: Navidrome API + version: 1.0.0 + description: | + Navidrome API v1. Spec-first, additive within v1. Clients discover implemented + capability modules through `GET /server` and never sniff versions. + + Enums are open: new values may be added to any enum within v1. Clients must + accept values they do not recognise instead of failing. + + Every operation declares `x-stability-level`: `alpha` operations may change or + disappear without notice, `beta` and `stable` operations only change additively. + A level is only ever raised, never lowered. + + `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods + in its `Allow` header. + license: + name: GPL-3.0 + url: https://www.gnu.org/licenses/gpl-3.0.html +servers: + - url: /api/v1 +tags: + - name: server + description: Server discovery and the published OpenAPI document. +paths: + /server: + get: + operationId: getServerInfo + x-module: core + x-stability-level: alpha + tags: [server] + summary: Describe the server + description: | + Returns the public server description. No authentication required. + Authenticated requests will additionally receive the implemented capability modules + once authentication is available. + responses: + '200': + description: Server description. + content: + application/json: + schema: + $ref: '#/components/schemas/ServerInfo' + '500': + $ref: '#/components/responses/InternalError' + /openapi.json: + get: + operationId: getOpenAPISpecJSON + x-module: core + x-stability-level: alpha + tags: [server] + summary: Get the OpenAPI document (JSON) + description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. + responses: + '200': + description: The OpenAPI document. + headers: + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + type: object + description: OpenAPI 3.0 document. + '304': + $ref: '#/components/responses/NotModified' + /openapi.yaml: + get: + operationId: getOpenAPISpecYAML + x-module: core + x-stability-level: alpha + tags: [server] + summary: Get the OpenAPI document (YAML) + description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. + responses: + '200': + description: The OpenAPI document. + headers: + ETag: + $ref: '#/components/headers/ETag' + content: + application/yaml: + schema: + type: object + description: OpenAPI 3.0 document. + '304': + $ref: '#/components/responses/NotModified' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Short-lived access token minted from a device grant. Not yet applied to any operation. + schemas: + ServerInfo: + type: object + description: Public server description. Everything an add-server screen needs before login. + required: + - name + - serverVersion + - specVersion + - setupRequired + - loginMethods + properties: + name: + type: string + description: Human-readable server product name. + serverVersion: + type: string + description: Version of the running server build. + specVersion: + type: string + description: Version of the OpenAPI document this server implements. + setupRequired: + type: boolean + description: True until the first admin user has been created. + loginMethods: + type: array + description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. + items: + type: string + enum: + - password + Problem: + type: object + description: RFC 9457 problem details, returned for every 4xx and 5xx response. + required: + - title + - status + - code + properties: + type: + type: string + description: | + URI reference identifying the problem type. Omitted while the problem carries no semantics + beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their + own semantics get their own URI; switch on `code` instead. + title: + type: string + description: Short human-readable summary, the same for all occurrences of this problem type. + status: + type: integer + description: HTTP status code of this response. + detail: + type: string + description: Human-readable explanation specific to this occurrence. Omitted for internal errors. + code: + type: string + description: Machine-readable error code, and the value clients switch on. New codes may be added. + enum: + - validation + - unauthorized + - forbidden + - not_found + - method_not_allowed + - unavailable + - internal + errors: + type: array + description: Per-field failures. Present only when `code` is `validation`. + items: + $ref: '#/components/schemas/ValidationError' + ValidationError: + type: object + description: One field-level validation failure. + required: + - field + - message + properties: + field: + type: string + description: Name of the offending query parameter, path parameter, or body field (dotted for nested). + message: + type: string + description: Why the value was rejected. + responses: + InternalError: + description: Unexpected server failure. Details are in the server log. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + NotModified: + description: Not modified. + headers: + ETag: + $ref: '#/components/headers/ETag' + headers: + ETag: + description: Entity tag for `If-None-Match` revalidation. + schema: + type: string diff --git a/api/embed.go b/api/embed.go new file mode 100644 index 000000000..7e94c2362 --- /dev/null +++ b/api/embed.go @@ -0,0 +1,35 @@ +package api + +import ( + _ "embed" + "encoding/json" + "sync" +) + +//go:embed bundled/openapi.json +var specJSON []byte + +//go:embed bundled/openapi.yaml +var specYAML []byte + +func SpecJSON() []byte { + return specJSON +} + +func SpecYAML() []byte { + return specYAML +} + +var specVersion = sync.OnceValue(func() string { + var doc struct { + Info struct { + Version string `json:"version"` + } `json:"info"` + } + _ = json.Unmarshal(SpecJSON(), &doc) + return doc.Info.Version +}) + +func SpecVersion() string { + return specVersion() +} diff --git a/api/embed_test.go b/api/embed_test.go new file mode 100644 index 000000000..ebd5a7d1d --- /dev/null +++ b/api/embed_test.go @@ -0,0 +1,37 @@ +package api_test + +import ( + "os" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/navidrome/navidrome/api" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" +) + +var _ = Describe("Bundled spec", func() { + It("embeds a valid OpenAPI 3 document", func() { + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) + Expect(err).ToNot(HaveOccurred()) + Expect(doc.Validate(GinkgoT().Context())).To(Succeed()) + Expect(doc.Paths.Find("/server")).ToNot(BeNil()) + }) + + It("embeds the YAML variant", func() { + var doc map[string]any + Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed()) + Expect(doc).To(HaveKey("paths")) + }) + + It("reports the version from the bundle, matching the source root document", func() { + src, err := os.ReadFile("api/openapi/openapi.yaml") + Expect(err).ToNot(HaveOccurred()) + var root struct { + Info struct{ Version string } `yaml:"info"` + } + Expect(yaml.Unmarshal(src, &root)).To(Succeed()) + Expect(api.SpecVersion()).To(Equal(root.Info.Version)) + Expect(api.SpecVersion()).ToNot(BeEmpty()) + }) +}) diff --git a/api/openapi/components/headers/ETag.yaml b/api/openapi/components/headers/ETag.yaml new file mode 100644 index 000000000..0f64e6792 --- /dev/null +++ b/api/openapi/components/headers/ETag.yaml @@ -0,0 +1,3 @@ +description: Entity tag for `If-None-Match` revalidation. +schema: + type: string diff --git a/api/openapi/components/parameters/limit.yaml b/api/openapi/components/parameters/limit.yaml new file mode 100644 index 000000000..9bef4fc8b --- /dev/null +++ b/api/openapi/components/parameters/limit.yaml @@ -0,0 +1,9 @@ +name: limit +in: query +description: Maximum number of items to return. +required: false +schema: + type: integer + minimum: 1 + maximum: 2000 + default: 100 diff --git a/api/openapi/components/parameters/offset.yaml b/api/openapi/components/parameters/offset.yaml new file mode 100644 index 000000000..9145d6eaa --- /dev/null +++ b/api/openapi/components/parameters/offset.yaml @@ -0,0 +1,8 @@ +name: offset +in: query +description: Zero-based index of the first item to return. +required: false +schema: + type: integer + minimum: 0 + default: 0 diff --git a/api/openapi/components/responses/BadRequest.yaml b/api/openapi/components/responses/BadRequest.yaml new file mode 100644 index 000000000..1a6e0b657 --- /dev/null +++ b/api/openapi/components/responses/BadRequest.yaml @@ -0,0 +1,5 @@ +description: The request is malformed or fails validation. +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Forbidden.yaml b/api/openapi/components/responses/Forbidden.yaml new file mode 100644 index 000000000..6259185ea --- /dev/null +++ b/api/openapi/components/responses/Forbidden.yaml @@ -0,0 +1,5 @@ +description: The caller is authenticated but not allowed to do this. +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/InternalError.yaml b/api/openapi/components/responses/InternalError.yaml new file mode 100644 index 000000000..20e654d44 --- /dev/null +++ b/api/openapi/components/responses/InternalError.yaml @@ -0,0 +1,5 @@ +description: Unexpected server failure. Details are in the server log. +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/NotFound.yaml b/api/openapi/components/responses/NotFound.yaml new file mode 100644 index 000000000..6083a5cd1 --- /dev/null +++ b/api/openapi/components/responses/NotFound.yaml @@ -0,0 +1,5 @@ +description: No such resource or endpoint. +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/NotModified.yaml b/api/openapi/components/responses/NotModified.yaml new file mode 100644 index 000000000..36bc1a61c --- /dev/null +++ b/api/openapi/components/responses/NotModified.yaml @@ -0,0 +1,4 @@ +description: Not modified. +headers: + ETag: + $ref: ../headers/ETag.yaml diff --git a/api/openapi/components/responses/Unauthorized.yaml b/api/openapi/components/responses/Unauthorized.yaml new file mode 100644 index 000000000..0209f4dd9 --- /dev/null +++ b/api/openapi/components/responses/Unauthorized.yaml @@ -0,0 +1,5 @@ +description: Missing, invalid, or expired credentials. +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/schemas/ListMeta.yaml b/api/openapi/components/schemas/ListMeta.yaml new file mode 100644 index 000000000..6321e0588 --- /dev/null +++ b/api/openapi/components/schemas/ListMeta.yaml @@ -0,0 +1,13 @@ +type: object +description: Pagination metadata carried by every list response. +required: [total, offset, limit] +properties: + total: + type: integer + description: Total number of items matching the request, ignoring pagination. + offset: + type: integer + description: Zero-based index of the first returned item. + limit: + type: integer + description: Maximum number of items in this page. diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml new file mode 100644 index 000000000..0fd36d4b1 --- /dev/null +++ b/api/openapi/components/schemas/Problem.yaml @@ -0,0 +1,35 @@ +type: object +description: RFC 9457 problem details, returned for every 4xx and 5xx response. +required: [title, status, code] +properties: + type: + type: string + description: | + URI reference identifying the problem type. Omitted while the problem carries no semantics + beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their + own semantics get their own URI; switch on `code` instead. + title: + type: string + description: Short human-readable summary, the same for all occurrences of this problem type. + status: + type: integer + description: HTTP status code of this response. + detail: + type: string + description: Human-readable explanation specific to this occurrence. Omitted for internal errors. + code: + type: string + description: Machine-readable error code, and the value clients switch on. New codes may be added. + enum: + - validation + - unauthorized + - forbidden + - not_found + - method_not_allowed + - unavailable + - internal + errors: + type: array + description: Per-field failures. Present only when `code` is `validation`. + items: + $ref: ./ValidationError.yaml diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml new file mode 100644 index 000000000..8906924eb --- /dev/null +++ b/api/openapi/components/schemas/ServerInfo.yaml @@ -0,0 +1,22 @@ +type: object +description: Public server description. Everything an add-server screen needs before login. +required: [name, serverVersion, specVersion, setupRequired, loginMethods] +properties: + name: + type: string + description: Human-readable server product name. + serverVersion: + type: string + description: Version of the running server build. + specVersion: + type: string + description: Version of the OpenAPI document this server implements. + setupRequired: + type: boolean + description: True until the first admin user has been created. + loginMethods: + type: array + description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. + items: + type: string + enum: [password] diff --git a/api/openapi/components/schemas/ValidationError.yaml b/api/openapi/components/schemas/ValidationError.yaml new file mode 100644 index 000000000..8a1cbc4f9 --- /dev/null +++ b/api/openapi/components/schemas/ValidationError.yaml @@ -0,0 +1,10 @@ +type: object +description: One field-level validation failure. +required: [field, message] +properties: + field: + type: string + description: Name of the offending query parameter, path parameter, or body field (dotted for nested). + message: + type: string + description: Why the value was rejected. diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml new file mode 100644 index 000000000..73cbb7b27 --- /dev/null +++ b/api/openapi/openapi.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Navidrome API + version: 1.0.0 + description: | + Navidrome API v1. Spec-first, additive within v1. Clients discover implemented + capability modules through `GET /server` and never sniff versions. + + Enums are open: new values may be added to any enum within v1. Clients must + accept values they do not recognise instead of failing. + + Every operation declares `x-stability-level`: `alpha` operations may change or + disappear without notice, `beta` and `stable` operations only change additively. + A level is only ever raised, never lowered. + + `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods + in its `Allow` header. + license: + name: GPL-3.0 + url: https://www.gnu.org/licenses/gpl-3.0.html +servers: + - url: /api/v1 +tags: + - name: server + description: Server discovery and the published OpenAPI document. +paths: + /server: + $ref: ./paths/server.yaml + /openapi.json: + $ref: ./paths/openapi.yaml#/json + /openapi.yaml: + $ref: ./paths/openapi.yaml#/yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Short-lived access token minted from a device grant. Not yet applied to any operation. diff --git a/api/openapi/paths/openapi.yaml b/api/openapi/paths/openapi.yaml new file mode 100644 index 000000000..3c25dc8b7 --- /dev/null +++ b/api/openapi/paths/openapi.yaml @@ -0,0 +1,42 @@ +json: + get: + operationId: getOpenAPISpecJSON + x-module: core + x-stability-level: alpha + tags: [server] + summary: Get the OpenAPI document (JSON) + description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. + responses: + '200': + description: The OpenAPI document. + headers: + ETag: + $ref: ../components/headers/ETag.yaml + content: + application/json: + schema: + type: object + description: OpenAPI 3.0 document. + '304': + $ref: ../components/responses/NotModified.yaml +yaml: + get: + operationId: getOpenAPISpecYAML + x-module: core + x-stability-level: alpha + tags: [server] + summary: Get the OpenAPI document (YAML) + description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. + responses: + '200': + description: The OpenAPI document. + headers: + ETag: + $ref: ../components/headers/ETag.yaml + content: + application/yaml: + schema: + type: object + description: OpenAPI 3.0 document. + '304': + $ref: ../components/responses/NotModified.yaml diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml new file mode 100644 index 000000000..1f881dbb1 --- /dev/null +++ b/api/openapi/paths/server.yaml @@ -0,0 +1,19 @@ +get: + operationId: getServerInfo + x-module: core + x-stability-level: alpha + tags: [server] + summary: Describe the server + description: | + Returns the public server description. No authentication required. + Authenticated requests will additionally receive the implemented capability modules + once authentication is available. + responses: + '200': + description: Server description. + content: + application/json: + schema: + $ref: ../components/schemas/ServerInfo.yaml + '500': + $ref: ../components/responses/InternalError.yaml diff --git a/cmd/root.go b/cmd/root.go index b23674441..edfcbe69c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -133,6 +133,9 @@ func startServer(ctx context.Context) func() error { if conf.Server.Jellyfin.Enabled { a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) } + if conf.Server.DevAPIv1 { + a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx)) + } if conf.Server.Prometheus.Enabled { p := CreatePrometheus() // blocking call because takes <100ms but useful if fails diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index cb06cc047..19f92d9d5 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -31,6 +31,7 @@ import ( "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/apiv1" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" @@ -142,6 +143,13 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { return router } +func CreateAPIv1Router(ctx context.Context) *apiv1.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + router := apiv1.New(dataStore) + return router +} + func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -259,7 +267,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 527617959..c5006e07f 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -23,6 +23,7 @@ import ( "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/apiv1" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" @@ -37,6 +38,7 @@ var allProviders = wire.NewSet( subsonic.New, jellyfin.New, jellyfin.NewDiscovery, + apiv1.New, nativeapi.New, public.New, persistence.New, @@ -91,6 +93,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { )) } +func CreateAPIv1Router(ctx context.Context) *apiv1.Router { + panic(wire.Build( + allProviders, + )) +} + func CreatePublicRouter() *public.Router { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index 0173f5628..efa9cbf9a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -161,6 +161,7 @@ type configOptions struct { DevExternalArtistFetchMultiplier float64 DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool + DevAPIv1 bool } type scannerOptions struct { @@ -1124,6 +1125,7 @@ func setViperDefaults() { viper.SetDefault("devshowartistpage", true) viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) + viper.SetDefault("devapiv1", false) viper.SetDefault("devoffsetoptimize", 50000) // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, // scrobbles and the UI. See MaxOpenConns. diff --git a/consts/consts.go b/consts/consts.go index 486ea66bc..7228299c6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -59,6 +59,7 @@ const ( URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" URLPathJellyfinAPI = "/jellyfin" + URLPathAPIv1 = "/api/v1" // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. diff --git a/go.mod b/go.mod index 464038d7e..e96b8c8b3 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.6.4 + github.com/getkin/kin-openapi v0.149.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 @@ -84,6 +85,8 @@ require ( github.com/ebitengine/purego v0.11.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.4 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v1.0.0 // indirect github.com/goccy/go-json v0.10.6 // indirect @@ -92,6 +95,7 @@ require ( github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -110,6 +114,8 @@ require ( github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/go.sum b/go.sum index 929366a76..fe6dbbc3f 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= +github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA= +github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -79,6 +81,12 @@ github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTw github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= @@ -111,6 +119,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -178,6 +188,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= @@ -326,8 +340,8 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= diff --git a/server/apiv1/api.go b/server/apiv1/api.go new file mode 100644 index 000000000..a75de3d8d --- /dev/null +++ b/server/apiv1/api.go @@ -0,0 +1,98 @@ +package apiv1 + +import ( + "errors" + "net/http" + "runtime/debug" + "slices" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type Router struct { + http.Handler + ds model.DataStore +} + +func New(ds model.DataStore) *Router { + rt := &Router{ds: ds} + rt.Handler = rt.routes() + return rt +} + +func (rt *Router) routes() http.Handler { + r := chi.NewRouter() + r.Use(problemRecoverer, headAsGet(r)) + r.NotFound(func(w http.ResponseWriter, req *http.Request) { + writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint") + }) + r.MethodNotAllowed(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Allow", strings.Join(allowedMethods(r, req), ", ")) + writeProblemStatus(w, req, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "") + }) + + r.Get("/openapi.json", specHandler(withBasePath(api.SpecJSON(), `"url": `, true), "application/json")) + r.Get("/openapi.yaml", specHandler(withBasePath(api.SpecYAML(), "url: ", false), "application/yaml")) + + strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { + writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error()) + }, + ResponseErrorHandlerFunc: writeProblem, + }) + HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler}) + return r +} + +func problemRecoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(rec) + } + log.Error(r.Context(), "API v1: panic in handler", "panic", rec, "stack", string(debug.Stack())) + writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "") + }() + next.ServeHTTP(w, r) + }) +} + +var routableMethods = []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} + +// Looks routes up on mux itself: chi's RouteContext().Routes points at the parent router when mounted. +func allowedMethods(mux chi.Routes, req *http.Request) []string { + path := routePath(req) + var allowed []string + for _, m := range routableMethods { + if mux.Match(chi.NewRouteContext(), m, path) || (m == http.MethodHead && slices.Contains(allowed, http.MethodGet)) { + allowed = append(allowed, m) + } + } + return allowed +} + +func headAsGet(mux chi.Routes) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodHead && !mux.Match(chi.NewRouteContext(), http.MethodHead, routePath(req)) { + chi.RouteContext(req.Context()).RouteMethod = http.MethodGet + } + next.ServeHTTP(w, req) + }) + } +} + +func routePath(req *http.Request) string { + if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { + return rctx.RoutePath + } + return req.URL.Path +} diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go new file mode 100644 index 000000000..4bffd47fc --- /dev/null +++ b/server/apiv1/api_gen.go @@ -0,0 +1,390 @@ +// Package apiv1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package apiv1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// Defines values for ProblemCode. +const ( + ProblemCodeForbidden ProblemCode = "forbidden" + ProblemCodeInternal ProblemCode = "internal" + ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" + ProblemCodeNotFound ProblemCode = "not_found" + ProblemCodeUnauthorized ProblemCode = "unauthorized" + ProblemCodeUnavailable ProblemCode = "unavailable" + ProblemCodeValidation ProblemCode = "validation" +) + +// Valid indicates whether the value is a known member of the ProblemCode enum. +func (e ProblemCode) Valid() bool { + switch e { + case ProblemCodeForbidden: + return true + case ProblemCodeInternal: + return true + case ProblemCodeMethodNotAllowed: + return true + case ProblemCodeNotFound: + return true + case ProblemCodeUnauthorized: + return true + case ProblemCodeUnavailable: + return true + case ProblemCodeValidation: + return true + default: + return false + } +} + +// Defines values for ServerInfoLoginMethods. +const ( + ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" +) + +// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum. +func (e ServerInfoLoginMethods) Valid() bool { + switch e { + case ServerInfoLoginMethodsPassword: + return true + default: + return false + } +} + +// Problem RFC 9457 problem details, returned for every 4xx and 5xx response. +type Problem struct { + // Code Machine-readable error code, and the value clients switch on. New codes may be added. + Code ProblemCode `json:"code"` + + // Detail Human-readable explanation specific to this occurrence. Omitted for internal errors. + Detail *string `json:"detail,omitempty"` + + // Errors Per-field failures. Present only when `code` is `validation`. + Errors *[]ValidationError `json:"errors,omitempty"` + + // Status HTTP status code of this response. + Status int `json:"status"` + + // Title Short human-readable summary, the same for all occurrences of this problem type. + Title string `json:"title"` + + // Type URI reference identifying the problem type. Omitted while the problem carries no semantics + // beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their + // own semantics get their own URI; switch on `code` instead. + Type *string `json:"type,omitempty"` +} + +// ProblemCode Machine-readable error code, and the value clients switch on. New codes may be added. +type ProblemCode string + +// ServerInfo Public server description. Everything an add-server screen needs before login. +type ServerInfo struct { + // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. + LoginMethods []ServerInfoLoginMethods `json:"loginMethods"` + + // Name Human-readable server product name. + Name string `json:"name"` + + // ServerVersion Version of the running server build. + ServerVersion string `json:"serverVersion"` + + // SetupRequired True until the first admin user has been created. + SetupRequired bool `json:"setupRequired"` + + // SpecVersion Version of the OpenAPI document this server implements. + SpecVersion string `json:"specVersion"` +} + +// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. +type ServerInfoLoginMethods string + +// ValidationError One field-level validation failure. +type ValidationError struct { + // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). + Field string `json:"field"` + + // Message Why the value was rejected. + Message string `json:"message"` +} + +// InternalError RFC 9457 problem details, returned for every 4xx and 5xx response. +type InternalError = Problem + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // GetServerInfo Describe the server + // (GET /server) + GetServerInfo(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// GetServerInfo Describe the server +// (GET /server) +func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetServerInfo operation middleware +func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetServerInfo(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) + }) + + return r +} + +type InternalErrorApplicationProblemPlusJSONResponse Problem + +type GetServerInfoRequestObject struct { +} + +type GetServerInfoResponseObject interface { + VisitGetServerInfoResponse(w http.ResponseWriter) error +} + +type GetServerInfo200JSONResponse ServerInfo + +func (response GetServerInfo200JSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetServerInfo500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response GetServerInfo500ApplicationProblemPlusJSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // GetServerInfo Describe the server + // (GET /server) + GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) +} + +type StrictHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + if options.RequestErrorHandlerFunc == nil { + options.RequestErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + if options.ResponseErrorHandlerFunc == nil { + options.ResponseErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// GetServerInfo operation middleware +func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { + var request GetServerInfoRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetServerInfo(ctx, request.(GetServerInfoRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetServerInfo") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetServerInfoResponseObject); ok { + if err := validResponse.VisitGetServerInfoResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go new file mode 100644 index 000000000..2552e40b0 --- /dev/null +++ b/server/apiv1/api_test.go @@ -0,0 +1,75 @@ +package apiv1 + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Router", func() { + var router *Router + + BeforeEach(func() { + router = New(&tests.MockDataStore{}) + }) + + It("returns a 404 problem for unknown paths", func() { + w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeNotFound)) + }) + + It("returns a 405 problem listing the allowed methods for a wrong method on a known path", func() { + w := serve(router, httptest.NewRequest(http.MethodPost, "/api/v1/server", nil)) + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + Expect(w.Header().Get("Allow")).To(Equal("GET, HEAD")) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeMethodNotAllowed)) + }) + + DescribeTable("answers HEAD wherever GET is routed", + func(path, contentType string) { + w := serve(router, httptest.NewRequest(http.MethodHead, path, nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal(contentType)) + }, + Entry("server info", "/api/v1/server", "application/json"), + Entry("JSON spec", "/api/v1/openapi.json", "application/json"), + Entry("YAML spec", "/api/v1/openapi.yaml", "application/yaml"), + ) + + It("revalidates HEAD requests with If-None-Match", func() { + etag := serve(router, httptest.NewRequest(http.MethodHead, "/api/v1/openapi.json", nil)).Header().Get("ETag") + req := httptest.NewRequest(http.MethodHead, "/api/v1/openapi.json", nil) + req.Header.Set("If-None-Match", etag) + Expect(serve(router, req).Code).To(Equal(http.StatusNotModified)) + }) + + It("returns a 404 problem for HEAD on unknown paths", func() { + w := serve(router, httptest.NewRequest(http.MethodHead, "/api/v1/nope", nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Allow")).To(BeEmpty()) + }) + + panicking := func(v any) http.Handler { + return problemRecoverer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic(v) })) + } + + It("turns a handler panic into a 500 problem", func() { + w := httptest.NewRecorder() + panicking("kaboom").ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", nil)) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeInternal)) + Expect(p.Detail).To(BeNil()) + }) + + It("re-panics http.ErrAbortHandler so the server can drop the connection", func() { + Expect(func() { + panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil)) + }).To(PanicWith(http.ErrAbortHandler)) + }) +}) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go new file mode 100644 index 000000000..f89244e38 --- /dev/null +++ b/server/apiv1/apiv1_suite_test.go @@ -0,0 +1,66 @@ +package apiv1 + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAPIv1(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "API v1 Suite") +} + +var specRouter routers.Router + +var _ = BeforeSuite(func() { + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) + Expect(err).ToNot(HaveOccurred()) + specRouter, err = gorillamux.NewRouter(doc) + Expect(err).ToNot(HaveOccurred()) +}) + +// serve routes req through h mounted at /api/v1 and asserts the response conforms to the spec. +func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder { + root := chi.NewRouter() + root.Mount("/api/v1", h) + w := httptest.NewRecorder() + root.ServeHTTP(w, req) + validateAgainstSpec(req, w) + return w +} + +func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) { + route, pathParams, err := specRouter.FindRoute(req) + if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) { + return + } + ExpectWithOffset(2, err).ToNot(HaveOccurred()) + input := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{ + Request: req, PathParams: pathParams, Route: route, + }, + Status: w.Code, + Header: w.Header(), + Body: io.NopCloser(bytes.NewReader(w.Body.Bytes())), + Options: &openapi3filter.Options{IncludeResponseStatus: true}, + } + ExpectWithOffset(2, openapi3filter.ValidateResponse(req.Context(), input)).To(Succeed(), + "response for %s %s does not conform to the spec", req.Method, req.URL.Path) +} diff --git a/server/apiv1/oapi-codegen.yaml b/server/apiv1/oapi-codegen.yaml new file mode 100644 index 000000000..b9236de1a --- /dev/null +++ b/server/apiv1/oapi-codegen.yaml @@ -0,0 +1,12 @@ +package: apiv1 +output: server/apiv1/api_gen.go +generate: + chi-server: true + strict-server: true + models: true +output-options: + exclude-operation-ids: + - getOpenAPISpecJSON + - getOpenAPISpecYAML +compatibility: + always-prefix-enum-values: true diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go new file mode 100644 index 000000000..aa6389357 --- /dev/null +++ b/server/apiv1/problem.go @@ -0,0 +1,72 @@ +package apiv1 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const problemContentType = "application/problem+json" + +func writeProblem(w http.ResponseWriter, r *http.Request, err error) { + status, code := classifyError(err) + detail := err.Error() + if status == http.StatusInternalServerError { + log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err) + detail = "" + } + writeProblemStatus(w, r, status, code, detail) +} + +func classifyError(err error) (int, ProblemCode) { + switch { + case errors.Is(err, model.ErrNotFound): + return http.StatusNotFound, ProblemCodeNotFound + case errors.Is(err, model.ErrNotAuthorized): + return http.StatusForbidden, ProblemCodeForbidden + case errors.Is(err, model.ErrInvalidAuth), errors.Is(err, model.ErrExpired): + return http.StatusUnauthorized, ProblemCodeUnauthorized + case errors.Is(err, model.ErrValidation): + return http.StatusBadRequest, ProblemCodeValidation + case errors.Is(err, model.ErrNotAvailable): + return http.StatusServiceUnavailable, ProblemCodeUnavailable + } + return http.StatusInternalServerError, ProblemCodeInternal +} + +func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code ProblemCode, detail string, fieldErrors ...ValidationError) { + p := Problem{Title: http.StatusText(status), Status: status, Code: code} + if detail != "" { + p.Detail = &detail + } + if len(fieldErrors) > 0 { + p.Errors = &fieldErrors + } + w.Header().Set("Content-Type", problemContentType) + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(p); err != nil { + log.Warn(r.Context(), "API v1: could not write problem response", err) + } +} + +func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + var fieldErrors []ValidationError + var required *RequiredParamError + var invalid *InvalidParamFormatError + var tooMany *TooManyValuesForParamError + var unmarshal *UnmarshalingParamError + switch { + case errors.As(err, &required): + fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"}) + case errors.As(err, &invalid): + fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()}) + case errors.As(err, &tooMany): + fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) + case errors.As(err, &unmarshal): + fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()}) + } + writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...) +} diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go new file mode 100644 index 000000000..256296b3c --- /dev/null +++ b/server/apiv1/problem_test.go @@ -0,0 +1,114 @@ +package apiv1 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func decodeProblem(w *httptest.ResponseRecorder) Problem { + var p Problem + ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &p)).To(Succeed()) + return p +} + +var _ = Describe("problem", func() { + var w *httptest.ResponseRecorder + var r *http.Request + + BeforeEach(func() { + w = httptest.NewRecorder() + r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil) + }) + + Describe("writeProblem", func() { + DescribeTable("maps domain errors to status and code", + func(err error, status int, code ProblemCode) { + writeProblem(w, r, err) + Expect(w.Code).To(Equal(status)) + Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType)) + p := decodeProblem(w) + Expect(p.Status).To(Equal(status)) + Expect(p.Code).To(Equal(code)) + Expect(p.Title).To(Equal(http.StatusText(status))) + Expect(p.Type).To(BeNil()) + Expect(w.Body.String()).ToNot(ContainSubstring(`"type"`)) + }, + Entry("not found", model.ErrNotFound, http.StatusNotFound, ProblemCodeNotFound), + Entry("not authorized", model.ErrNotAuthorized, http.StatusForbidden, ProblemCodeForbidden), + Entry("invalid auth", model.ErrInvalidAuth, http.StatusUnauthorized, ProblemCodeUnauthorized), + Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), + Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), + Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), + Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal), + ) + + DescribeTable("keeps the wrapping context as detail for client errors", + func(err error) { + writeProblem(w, r, err) + p := decodeProblem(w) + Expect(p.Status).To(Equal(http.StatusNotFound)) + Expect(p.Detail).ToNot(BeNil()) + Expect(*p.Detail).To(ContainSubstring("album 123")) + }, + Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)), + Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)), + ) + + It("hides details for internal errors", func() { + writeProblem(w, r, errors.New("db password is hunter2")) + p := decodeProblem(w) + Expect(p.Detail).To(BeNil()) + }) + }) + + Describe("writeProblemStatus", func() { + It("writes field errors only when provided", func() { + writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "bad input", + ValidationError{Field: "limit", Message: "must be <= 2000"}) + p := decodeProblem(w) + Expect(p.Errors).ToNot(BeNil()) + Expect(*p.Errors).To(HaveLen(1)) + Expect((*p.Errors)[0].Field).To(Equal("limit")) + }) + + It("omits detail when empty", func() { + writeProblemStatus(w, r, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "") + Expect(w.Body.String()).ToNot(ContainSubstring(`"detail"`)) + Expect(w.Body.String()).ToNot(ContainSubstring(`"errors"`)) + }) + }) + + Describe("bindingErrorHandler", func() { + DescribeTable("maps parameter binding errors to a validation problem with the field", + func(err error, field, message string) { + bindingErrorHandler(w, r, err) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + p := decodeProblem(w) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(p.Errors).ToNot(BeNil()) + Expect(*p.Errors).To(HaveLen(1)) + Expect((*p.Errors)[0].Field).To(Equal(field)) + Expect((*p.Errors)[0].Message).To(ContainSubstring(message)) + }, + Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"), + Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"), + Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"), + Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"), + ) + + It("still returns a validation problem for unknown binding errors", func() { + bindingErrorHandler(w, r, errors.New("weird")) + p := decodeProblem(w) + Expect(p.Status).To(Equal(http.StatusBadRequest)) + Expect(p.Code).To(Equal(ProblemCodeValidation)) + Expect(p.Errors).To(BeNil()) + }) + }) +}) diff --git a/server/apiv1/server_info.go b/server/apiv1/server_info.go new file mode 100644 index 000000000..458363efe --- /dev/null +++ b/server/apiv1/server_info.go @@ -0,0 +1,23 @@ +package apiv1 + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/consts" +) + +func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObject) (GetServerInfoResponseObject, error) { + count, err := rt.ds.User().CountAll(ctx) + if err != nil { + return nil, fmt.Errorf("counting users: %w", err) + } + return GetServerInfo200JSONResponse{ + Name: "Navidrome", + ServerVersion: consts.Version, + SpecVersion: api.SpecVersion(), + SetupRequired: count == 0, + LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword}, + }, nil +} diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_info_test.go new file mode 100644 index 000000000..b9dae6f8d --- /dev/null +++ b/server/apiv1/server_info_test.go @@ -0,0 +1,61 @@ +package apiv1 + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GET /server", func() { + var ctx context.Context + var ds *tests.MockDataStore + var users *tests.MockedUserRepo + + BeforeEach(func() { + ctx = GinkgoT().Context() + users = tests.CreateMockUserRepo() + ds = &tests.MockDataStore{MockedUser: users} + }) + + get := func() (*httptest.ResponseRecorder, ServerInfo) { + w := serve(New(ds), httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)) + var info ServerInfo + if w.Code == http.StatusOK { + ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + } + return w, info + } + + It("describes the server with setupRequired when there are no users", func() { + w, info := get() + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) + Expect(info.Name).To(Equal("Navidrome")) + Expect(info.ServerVersion).To(Equal(consts.Version)) + Expect(info.SpecVersion).To(Equal(api.SpecVersion())) + Expect(info.SetupRequired).To(BeTrue()) + Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword)) + }) + + It("reports setupRequired=false once a user exists", func() { + Expect(users.Put(ctx, &model.User{ID: "u1", UserName: "admin", IsAdmin: true})).To(Succeed()) + _, info := get() + Expect(info.SetupRequired).To(BeFalse()) + }) + + It("returns a 500 problem when the user count fails", func() { + users.Error = errors.New("db down") + w, _ := get() + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal)) + }) +}) diff --git a/server/apiv1/spec.go b/server/apiv1/spec.go new file mode 100644 index 000000000..bdca19693 --- /dev/null +++ b/server/apiv1/spec.go @@ -0,0 +1,51 @@ +package apiv1 + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "path" + "strconv" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/req" + "github.com/zeebo/xxh3" +) + +func specHandler(body []byte, contentType string) http.HandlerFunc { + etag := fmt.Sprintf("%016x", xxh3.Hash(body)) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"`+etag+`"`) + w.Header().Set("Cache-Control", "no-cache") + if req.IfNoneMatch(r, etag) { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", contentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + } +} + +// withBasePath adds BasePath to the advertised server URL, since only the running server knows it. +func withBasePath(body []byte, key string, quotedInBundle bool) []byte { + serverURL := path.Join(conf.Server.BasePath, consts.URLPathAPIv1) + if serverURL == consts.URLPathAPIv1 { + return body + } + oldURL := consts.URLPathAPIv1 + if quotedInBundle { + oldURL = strconv.Quote(oldURL) + } + old := []byte(key + oldURL) + if bytes.Count(body, old) != 1 { + log.Error("API v1: server URL not found in the bundled spec, serving it without the base path", "key", key) + return body + } + // A JSON string is also a valid YAML double-quoted scalar, so one encoding escapes both formats. + newURL, _ := json.Marshal(serverURL) + return bytes.Replace(body, old, append([]byte(key), newURL...), 1) +} diff --git a/server/apiv1/spec_test.go b/server/apiv1/spec_test.go new file mode 100644 index 000000000..a2368f899 --- /dev/null +++ b/server/apiv1/spec_test.go @@ -0,0 +1,136 @@ +package apiv1 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/api" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" +) + +var _ = Describe("OpenAPI document routes", func() { + var router *Router + + BeforeEach(func() { + router = New(&tests.MockDataStore{}) + }) + + get := func(path string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + for k, v := range headers { + req.Header.Set(k, v) + } + return serve(router, req) + } + + DescribeTable("serves the embedded bundle", + func(path, contentType string, body []byte) { + w := get(path, nil) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal(contentType)) + Expect(w.Header().Get("ETag")).ToNot(BeEmpty()) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-cache")) + Expect(w.Body.Bytes()).To(Equal(body)) + }, + Entry("JSON", "/api/v1/openapi.json", "application/json", api.SpecJSON()), + Entry("YAML", "/api/v1/openapi.yaml", "application/yaml", api.SpecYAML()), + ) + + DescribeTable("revalidates with If-None-Match", + func(ifNoneMatch func(etag string) string, expected int) { + etag := get("/api/v1/openapi.json", nil).Header().Get("ETag") + w := get("/api/v1/openapi.json", map[string]string{"If-None-Match": ifNoneMatch(etag)}) + Expect(w.Code).To(Equal(expected)) + if expected == http.StatusNotModified { + Expect(w.Body.Len()).To(BeZero()) + Expect(w.Header().Get("ETag")).To(Equal(etag)) + } else { + Expect(w.Body.Bytes()).To(Equal(api.SpecJSON())) + } + }, + Entry("exact ETag", func(etag string) string { return etag }, http.StatusNotModified), + Entry("ETag in a list", func(etag string) string { return `"other", ` + etag }, http.StatusNotModified), + Entry("weak ETag", func(etag string) string { return "W/" + etag }, http.StatusNotModified), + Entry("wildcard", func(string) string { return "*" }, http.StatusNotModified), + Entry("stale ETag", func(string) string { return `"stale"` }, http.StatusOK), + ) + + It("ignores Range and returns the full document", func() { + w := get("/api/v1/openapi.json", map[string]string{"Range": "bytes=0-9"}) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Accept-Ranges")).To(BeEmpty()) + Expect(w.Body.Bytes()).To(Equal(api.SpecJSON())) + }) + + It("uses different ETags for JSON and YAML", func() { + j := get("/api/v1/openapi.json", nil) + y := get("/api/v1/openapi.yaml", nil) + Expect(j.Header().Get("ETag")).ToNot(Equal(y.Header().Get("ETag"))) + }) + + Describe("with a base path", func() { + decode := func(format string, body []byte) map[string]any { + var doc map[string]any + if format == "json" { + ExpectWithOffset(1, json.Unmarshal(body, &doc)).To(Succeed()) + } else { + ExpectWithOffset(1, yaml.Unmarshal(body, &doc)).To(Succeed()) + } + return doc + } + serverURL := func(doc map[string]any) any { + return doc["servers"].([]any)[0].(map[string]any)["url"] + } + var plainETag string + + BeforeEach(func() { + plainETag = get("/api/v1/openapi.json", nil).Header().Get("ETag") + DeferCleanup(configtest.SetupConfig()) + conf.Server.BasePath = "/music" + router = New(&tests.MockDataStore{}) + }) + + DescribeTable("advertises the server under the base path and changes nothing else", + func(path, format string, bundle []byte) { + w := get(path, nil) + Expect(w.Code).To(Equal(http.StatusOK)) + served, original := decode(format, w.Body.Bytes()), decode(format, bundle) + Expect(serverURL(served)).To(Equal("/music/api/v1")) + delete(served, "servers") + delete(original, "servers") + Expect(served).To(Equal(original)) + }, + Entry("JSON", "/api/v1/openapi.json", "json", api.SpecJSON()), + Entry("YAML", "/api/v1/openapi.yaml", "yaml", api.SpecYAML()), + ) + + It("uses its own ETag, and still revalidates", func() { + etag := get("/api/v1/openapi.json", nil).Header().Get("ETag") + Expect(etag).ToNot(Equal(plainETag)) + Expect(get("/api/v1/openapi.json", map[string]string{"If-None-Match": etag}).Code).To(Equal(http.StatusNotModified)) + }) + + It("escapes base paths that need quoting", func() { + conf.Server.BasePath = "/my music: \"live\"" + router = New(&tests.MockDataStore{}) + Expect(serverURL(decode("json", get("/api/v1/openapi.json", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1")) + Expect(serverURL(decode("yaml", get("/api/v1/openapi.yaml", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1")) + }) + }) + + DescribeTable("the bundle advertises the API path exactly once, which the base-path rewrite relies on", + func(bundle []byte) { + Expect(bytes.Count(bundle, []byte(consts.URLPathAPIv1))).To(Equal(1)) + }, + Entry("JSON", api.SpecJSON()), + Entry("YAML", api.SpecYAML()), + ) +}) diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go index 9308bcdab..354b11793 100644 --- a/server/imghttp/headers.go +++ b/server/imghttp/headers.go @@ -4,9 +4,9 @@ package imghttp import ( "net/http" - "strings" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/utils/req" ) // WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written @@ -40,28 +40,9 @@ func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Imag h.Set("Cache-Control", "public, no-cache") } - if etag != "" && ifNoneMatch(r.Header.Get("If-None-Match"), etag) { + if etag != "" && req.IfNoneMatch(r, etag) { w.WriteHeader(http.StatusNotModified) return true } return false } - -// ifNoneMatch reports whether If-None-Match asserts hash, using RFC 9110 weak comparison. -func ifNoneMatch(header, hash string) bool { - header = strings.TrimSpace(header) - if header == "" { - return false - } - if header == "*" { - return true - } - for tag := range strings.SplitSeq(header, ",") { - tag = strings.TrimSpace(tag) - tag = strings.TrimPrefix(tag, "W/") - if strings.Trim(tag, `"`) == hash { - return true - } - } - return false -} diff --git a/utils/req/req.go b/utils/req/req.go index 861cca9f7..6a863184c 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -178,3 +178,21 @@ func (r *Values) Float64Or(param string, def float64) float64 { } return f } + +// IfNoneMatch reports whether the request's If-None-Match asserts etag (unquoted), using RFC 9110 weak comparison. +func IfNoneMatch(r *http.Request, etag string) bool { + header := strings.TrimSpace(r.Header.Get("If-None-Match")) + if header == "" { + return false + } + if header == "*" { + return true + } + for tag := range strings.SplitSeq(header, ",") { + tag = strings.TrimPrefix(strings.TrimSpace(tag), "W/") + if strings.Trim(tag, `"`) == etag { + return true + } + } + return false +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index 5f9de8483..d64f51b02 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -290,3 +290,21 @@ var _ = Describe("Request Helpers", func() { }) }) }) + +var _ = Describe("IfNoneMatch", func() { + DescribeTable("matches the ETag", + func(header string, expected bool) { + r := httptest.NewRequest("GET", "/", nil) + if header != "" { + r.Header.Set("If-None-Match", header) + } + Expect(req.IfNoneMatch(r, "abc123")).To(Equal(expected)) + }, + Entry("absent header", "", false), + Entry("exact quoted tag", `"abc123"`, true), + Entry("weak tag", `W/"abc123"`, true), + Entry("tag in a list", `"other", W/"abc123"`, true), + Entry("wildcard", "*", true), + Entry("different tag", `"stale"`, false), + ) +}) From 46c432719fdd2fb34c2ec6c4f573a1488fb90215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 26 Sep 2026 23:30:50 -0400 Subject: [PATCH 584/596] fix(log): redact LastFM keys and Prometheus password in config dump (#6233) The startup Configuration dump is rendered with pretty.Sprintf("%# v"), which pads multi-line struct fields with spaces after the colon. The ApiKey and Secret redaction patterns required the quote right after the colon, so LastFM.ApiKey and LastFM.Secret were logged in clear text even with EnableLogRedacting on. Allow optional whitespace after the colon, like the other config patterns already do. Prometheus.Password had no redaction pattern at all. Add one that also skips escaped quotes, since the password can hold any character and pretty prints it Go-quoted. Add tests for the padded and unpadded forms, plus one that redacts a real pretty.Sprintf dump of LastFM- and Prometheus-shaped structs so a padding change in pretty can't bring the leak back. Reported in https://github.com/navidrome/navidrome/discussions/6232 --- log/log.go | 6 +++-- log/log_test.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/log/log.go b/log/log.go index de2f171b2..da1d7622e 100644 --- a/log/log.go +++ b/log/log.go @@ -27,14 +27,16 @@ var redacted = &Hook{ AcceptedLevels: logrus.AllLevels, RedactionList: []string{ // Keys from the config - "(ApiKey:\")[\\w]*", - "(Secret:\")[\\w]*", + "(ApiKey:[\\s]*\")[\\w]*", + "(Secret:[\\s]*\")[\\w]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*", + // Prometheus.Password. Any character is allowed, so skip escaped quotes in the value + `(Password:[\s]*")(?:[^"\\]|\\.)*`, // UI appConfig "(subsonicToken:)[\\w]+(\\s)", diff --git a/log/log_test.go b/log/log_test.go index 82207c672..184ff57db 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/kr/pretty" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/sirupsen/logrus" @@ -94,7 +95,7 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) @@ -291,5 +292,69 @@ var _ = Describe("Logger", func() { Expect(got).ToNot(ContainSubstring("secret")) Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) }) + + // https://github.com/navidrome/navidrome/discussions/6232 + DescribeTable("redacts config keys in the startup Configuration dump", + func(line, expected string) { + Expect(Redact(line)).To(Equal(expected)) + }, + Entry("unpadded ApiKey", `ApiKey:"0123456789abcdef0123456789abcdef"`, `ApiKey:"[REDACTED]"`), + Entry("unpadded Secret", `Secret:"fedcba9876543210fedcba9876543210"`, `Secret:"[REDACTED]"`), + Entry("padded ApiKey", ` ApiKey: "0123456789abcdef0123456789abcdef",`, + ` ApiKey: "[REDACTED]",`), + Entry("padded Secret", ` Secret: "fedcba9876543210fedcba9876543210",`, + ` Secret: "[REDACTED]",`), + Entry("unpadded Prometheus Password", `Password:"p@ss w0rd!"`, `Password:"[REDACTED]"`), + Entry("padded Prometheus Password", ` Password: "p@ss w0rd!",`, ` Password: "[REDACTED]",`), + Entry("Prometheus Password with escaped quotes", ` Password: "a\"b\\\"c",`, + ` Password: "[REDACTED]",`), + ) + + It("redacts secrets in a pretty-printed config struct", func() { + // Mirrors conf.lastfmOptions and conf.prometheusOptions (conf imports log, so it can't be + // used here). pretty only breaks a struct into padded lines when it is long enough, so + // keep all the fields. + type lastfmOptions struct { + Enabled bool + ApiKey string + Secret string + Language string + ScrobbleFirstArtistOnly bool + Languages []string + } + type prometheusOptions struct { + Enabled bool + MetricsPath string + Password string + } + type configOptions struct { + Address string + LastFM lastfmOptions + Prometheus prometheusOptions + } + cfg := configOptions{ + Address: "0.0.0.0", + LastFM: lastfmOptions{ //nolint:gosec + Enabled: true, + ApiKey: "0123456789abcdef0123456789abcdef", + Secret: "fedcba9876543210fedcba9876543210", + Language: "en", + Languages: []string{"en"}, + }, + Prometheus: prometheusOptions{ //nolint:gosec + Enabled: true, + MetricsPath: "/metrics", + Password: `prom"pass-tail`, + }, + } + dump := pretty.Sprintf("Configuration: %# v", cfg) + Expect(dump).To(MatchRegexp(`ApiKey:\s{2,}"`), "the dump must use the padded layout") + + got := Redact(dump) + Expect(got).ToNot(ContainSubstring(cfg.LastFM.ApiKey)) + Expect(got).ToNot(ContainSubstring(cfg.LastFM.Secret)) + Expect(got).ToNot(ContainSubstring("pass-tail")) + Expect(got).To(ContainSubstring(`"en"`)) + }) }) }) From ce484083bf070662e3b976daeca156fd7822c332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 27 Sep 2026 14:18:24 -0400 Subject: [PATCH 585/596] fix(server): exit with an error code when the server fails to start (#6236) When a startup step failed (for example, the port was already in use), runNavidrome only logged the error and returned. In service mode, service.Run() kept waiting for a stop signal, so the process stayed up serving nothing and the service manager never restarted it. A plain run exited with code 0. runNavidrome now returns the error, unless its context was cancelled by a normal shutdown. Both the plain run and the service goroutine exit with code 1 on that error. The systemd unit no longer lists 1, 2 and 8 in SuccessExitStatus, so Restart=on-failure restarts the service on exit code 1. Fixes #6235 --- cmd/root.go | 20 ++++++++++++-------- cmd/svc.go | 9 +++++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index edfcbe69c..089f09472 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,7 +44,9 @@ Complete documentation is available at https://www.navidrome.org/docs`, preRun() }, Run: func(cmd *cobra.Command, args []string) { - runNavidrome(cmd.Context()) + if err := runNavidrome(cmd.Context()); err != nil { + log.Fatal("Fatal error in Navidrome. Aborting", err) + } }, PostRun: func(cmd *cobra.Command, args []string) { postRun() @@ -76,12 +78,12 @@ func postRun() { } // runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks. -// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit, -// it will cancel the context and exit gracefully. -func runNavidrome(ctx context.Context) { - defer db.Init(ctx)() +// If any of the services returns an error, it stops the others and returns that error, so the caller can +// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil. +func runNavidrome(parentCtx context.Context) error { + defer db.Init(parentCtx)() - g, ctx := errgroup.WithContext(ctx) + g, ctx := errgroup.WithContext(parentCtx) g.Go(startServer(ctx)) g.Go(startSignaller(ctx)) g.Go(startScheduler(ctx)) @@ -102,9 +104,11 @@ func runNavidrome(ctx context.Context) { log.Warn(ctx, "Automatic Scanning is DISABLED") } - if err := g.Wait(); err != nil { - log.Error("Fatal error in Navidrome. Aborting", err) + // Errors caused by a normal shutdown are not failures + if err := g.Wait(); err != nil && parentCtx.Err() == nil { + return err } + return nil } // mainContext returns a context that is cancelled when the process receives a signal to exit. diff --git a/cmd/svc.go b/cmd/svc.go index c71f5ef2b..4e8b1fd85 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -53,8 +53,13 @@ func (p *svcControl) Start(service.Service) error { p.done = make(chan struct{}) p.ctx, p.cancel = context.WithCancel(context.Background()) go func() { - runNavidrome(p.ctx) + err := runNavidrome(p.ctx) close(p.done) + // service.Run() only returns when it gets a stop request, so exit here to let the + // service manager see the failure and restart the service + if err != nil { + log.Fatal("Fatal error in Navidrome. Aborting", err) + } }() return nil } @@ -74,7 +79,7 @@ func (p *svcControl) Stop(service.Service) error { var svcInstance = sync.OnceValue(func() service.Service { options := make(service.KeyValue) options["Restart"] = "on-failure" - options["SuccessExitStatus"] = "1 2 8 SIGKILL" + options["SuccessExitStatus"] = "SIGKILL" options["UserService"] = false options["LogDirectory"] = conf.Server.DataFolder.String() options["SystemdScript"] = systemdScript From 4cdffd5633e05cbc29cfd71e34382bf25eae256a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 27 Sep 2026 21:56:58 -0400 Subject: [PATCH 586/596] feat(subsonic): OpenSubsonic API key authentication (#6219) * feat(persistence): store hashed API keys on players * feat(core): refresh key-bound players without renaming them Add Players.Touch, which records usage for a player already identified by an API key without guessing its identity or overwriting its name. Register also stops renaming players that have an API key. Register no longer returns player save errors (or a stale FindMatch ErrNotFound when the save is rate-limited); save failures are only logged, and only the transcoding lookup error is returned, same as Touch. * feat(subsonic): authenticate with OpenSubsonic API keys Co-authored-by: amCap1712 * feat(subsonic): add tokenInfo and advertise apiKeyAuthentication * feat(server): add endpoints to generate and revoke player API keys * feat(ui): manage player API keys Co-authored-by: amCap1712 * fix(subsonic): throttle API keys per key and IP A stale key on one device exhausted the shared per-IP bucket and locked out every valid key from the same IP. The limiter only stores a hash of the bucket string, so the key is not retained. Also adds e2e coverage of API key auth through the real repository, and clarifies the player resolution log message. * fix(ui): keep the new API key dialog open until closed The key is shown only once, so Escape and backdrop clicks no longer dismiss it. Also clarifies when the key can be used as a password. * refactor: simplify API key code paths Share the player refresh tail between Register and Touch, fold the ownership-filtered write tail into execOwned, parse the query once for apiKey conflicts, derive HasAPIKey in the player mock, share the player form inputs between create and edit, and pick the delete button by key state instead of spreading conditional props. * feat(players): set API keys through the player record The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints. * fix(players): reject API keys already in use Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create. * feat(ui): edit player API keys as a form field Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create. * fix(ui): keep new player API keys out of the record cache The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails. * fix(ui): polish player API key field Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners. * refactor: simplify player API key create and field Write the key hash in the create INSERT so the unique index settles races, re-read the created player instead of hand-building the cached record, reuse isWritable for the revoke check, and collapse the key field's derived state and generate/regenerate buttons. * fix(ui): let the API key field size like other inputs fullWidth is now opt-in instead of forced. * fix(ui): align the API key field with other player inputs Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits. * fix(ui): redirect to the player list after create Matches the other create pages. * refactor(persistence): name the write-access rule for owned rows Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures. * fix(players): apply an edit's key change and fields atomically Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path. * fix(subsonic): treat any credential param sent with apiKey as a conflict The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value. * refactor(subsonic): leave the player cookie code unchanged for key-bound requests Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master. * fix(subsonic): don't count key lookup errors as failed logins A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter. * feat(players): use nds_ as the API key prefix Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants. * feat(ui): make player API keys easier to find Label the Settings menu entry "Players & API keys", add an API key filter to the player list, show the key icon in the mobile list, and add Brazilian Portuguese translations for the new player strings. Signed-off-by: Deluan * feat(ui): always show the player API key filter Signed-off-by: Deluan * fix(ui): hide the unset Last Seen date in the player list Players created by hand have no last_seen yet, which showed as 12/31/1. Signed-off-by: Deluan --------- Signed-off-by: Deluan Co-authored-by: amCap1712 --- consts/consts.go | 1 + core/players.go | 28 +- core/players_test.go | 37 +++ ...20260924010054_add_player_api_key_hash.sql | 8 + model/player.go | 4 + persistence/player_repository.go | 121 +++++++- persistence/player_repository_test.go | 269 +++++++++++++++++- persistence/sql_base_repository.go | 40 ++- persistence/sql_base_repository_test.go | 20 ++ resources/i18n/pt-br.json | 25 +- server/subsonic/api.go | 1 + server/subsonic/e2e/subsonic_apikey_test.go | 54 ++++ server/subsonic/middlewares.go | 118 +++++++- server/subsonic/middlewares_test.go | 162 +++++++++++ server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 66 ++--- .../Responses TokenInfo should match .JSON | 10 + .../Responses TokenInfo should match .XML | 3 + server/subsonic/responses/errors.go | 38 +-- server/subsonic/responses/responses.go | 5 + server/subsonic/responses/responses_test.go | 14 + server/subsonic/system.go | 8 + server/subsonic/system_test.go | 23 ++ tests/mock_data_store.go | 2 +- tests/mock_player_repo.go | 75 +++++ ui/src/App.jsx | 2 +- ui/src/dataProvider/wrapperDataProvider.js | 9 + .../dataProvider/wrapperDataProvider.test.js | 15 + ui/src/i18n/en.json | 25 +- ui/src/layout/AppBar.jsx | 8 +- ui/src/layout/AppBar.test.jsx | 24 +- ui/src/player/ApiKeyInput.jsx | 107 +++++++ ui/src/player/ApiKeyInput.test.jsx | 172 +++++++++++ ui/src/player/PlayerCreate.jsx | 33 +++ ui/src/player/PlayerCreate.test.jsx | 47 +++ ui/src/player/PlayerEdit.jsx | 55 ++-- ui/src/player/PlayerEdit.test.jsx | 25 ++ ui/src/player/PlayerList.jsx | 17 +- ui/src/player/apiKey.js | 15 + ui/src/player/apiKey.test.js | 15 + ui/src/player/index.js | 2 + ui/src/player/playerInputs.jsx | 34 +++ 42 files changed, 1610 insertions(+), 128 deletions(-) create mode 100644 db/migrations/20260924010054_add_player_api_key_hash.sql create mode 100644 server/subsonic/e2e/subsonic_apikey_test.go create mode 100644 server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML create mode 100644 server/subsonic/system_test.go create mode 100644 tests/mock_player_repo.go create mode 100644 ui/src/player/ApiKeyInput.jsx create mode 100644 ui/src/player/ApiKeyInput.test.jsx create mode 100644 ui/src/player/PlayerCreate.jsx create mode 100644 ui/src/player/PlayerCreate.test.jsx create mode 100644 ui/src/player/PlayerEdit.test.jsx create mode 100644 ui/src/player/apiKey.js create mode 100644 ui/src/player/apiKey.test.js create mode 100644 ui/src/player/playerInputs.jsx diff --git a/consts/consts.go b/consts/consts.go index 7228299c6..9bdac9125 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -49,6 +49,7 @@ const ( DefaultEncryptionKey = "just for obfuscation" PasswordsEncryptedKey = "PasswordsEncryptedKey" PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec + APIKeyPrefix = "nds_" DevInitialUserName = "admin" DevInitialName = "Dev Admin" diff --git a/core/players.go b/core/players.go index e03d8caa2..6fe86fd70 100644 --- a/core/players.go +++ b/core/players.go @@ -17,6 +17,7 @@ import ( type Players interface { Get(ctx context.Context, playerId string) (*model.Player, error) Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) + Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) } func NewPlayers(ds model.DataStore) Players { @@ -33,7 +34,6 @@ type players struct { func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { var plr *model.Player - var trc *model.Transcoding var err error user, _ := request.UserFrom(ctx) if playerID != "" { @@ -58,7 +58,21 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent) } } - plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) + if !plr.HasAPIKey { + plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) + } + return p.refresh(ctx, plr, userAgent, ip) +} + +// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it. +func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { + if plr.Client == "" { + plr.Client = client + } + return p.refresh(ctx, &plr, userAgent, ip) +} + +func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) { plr.UserAgent = userAgent plr.IP = ip plr.LastSeen = time.Now() @@ -66,14 +80,14 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - err = p.ds.Player().Put(ctx, plr) - if err != nil { - log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err) + if err := p.ds.Player().Put(ctx, plr); err != nil { + log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err) } }) - if plr.TranscodingId != "" { - trc, err = p.ds.Transcoding().Get(ctx, plr.TranscodingId) + if plr.TranscodingId == "" { + return plr, nil, nil } + trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId) return plr, trc, err } diff --git a/core/players_test.go b/core/players_test.go index 302d63157..e452c52ba 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -114,6 +114,15 @@ var _ = Describe("Players", func() { Expect(trc.ID).To(Equal("1")) }) + It("does not rename a player that has an API key", func() { + plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true} + repo.add(plr) + p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).To(Equal("123")) + Expect(p.Name).To(Equal("My Phone")) + }) + Context("bad username casing", func() { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"}) @@ -130,6 +139,34 @@ var _ = Describe("Players", func() { }) }) }) + + Describe("Touch", func() { + It("records usage but keeps the name and client", func() { + plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true} + p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.Name).To(Equal("My Phone")) + Expect(p.Client).To(Equal("Symfonium")) + Expect(p.UserAgent).To(Equal("android")) + Expect(p.IP).To(Equal("1.2.3.4")) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + + It("fills in the client on first use", func() { + p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.Client).To(Equal("Symfonium")) + }) + + It("returns the player's transcoding", func() { + p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).To(Equal("123")) + Expect(trc.ID).To(Equal("1")) + }) + }) }) type mockPlayerRepository struct { diff --git a/db/migrations/20260924010054_add_player_api_key_hash.sql b/db/migrations/20260924010054_add_player_api_key_hash.sql new file mode 100644 index 000000000..bbc4cf4d9 --- /dev/null +++ b/db/migrations/20260924010054_add_player_api_key_hash.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- +goose StatementBegin +alter table player add column api_key_hash varchar default null; +create unique index if not exists player_api_key_hash on player(api_key_hash); +-- +goose StatementEnd + +-- +goose Down +SELECT 1; diff --git a/model/player.go b/model/player.go index 2e4484a10..c03058419 100644 --- a/model/player.go +++ b/model/player.go @@ -21,6 +21,8 @@ type Player struct { MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"` ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"` ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"` + HasAPIKey bool `structs:"-" db:"has_api_key" json:"hasApiKey"` + APIKey *string `structs:"-" json:"apiKey,omitempty"` } type Players []Player @@ -33,4 +35,6 @@ type PlayerRepository interface { Put(ctx context.Context, p *Player) error CountAll(ctx context.Context, options ...QueryOptions) (int64, error) CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error) + FindByAPIKey(ctx context.Context, key string) (*Player, error) + SetAPIKey(ctx context.Context, playerID, key string) error } diff --git a/persistence/player_repository.go b/persistence/player_repository.go index e46a8d82d..ba5d26794 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -2,10 +2,16 @@ package persistence import ( "context" + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" ) @@ -17,7 +23,8 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository { r := &playerRepository{} r.db = db r.registerModel(&model.Player{}, map[string]filterFunc{ - "name": containsFilter("player.name"), + "name": containsFilter("player.name"), + "hasapikey": hasAPIKeyFilter, }) r.setSortMappings(map[string]string{ "user_name": "username", //TODO rename all user_name and userName to username @@ -25,6 +32,13 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository { return r } +func hasAPIKeyFilter(_ string, value any) Sqlizer { + if v, _ := value.(string); strings.EqualFold(v, "true") { + return NotEq{"player.api_key_hash": nil} + } + return Eq{"player.api_key_hash": nil} +} + func (r *playerRepository) Put(ctx context.Context, p *model.Player) error { _, err := r.put(ctx, p.ID, p) return err @@ -32,7 +46,7 @@ func (r *playerRepository) Put(ctx context.Context, p *model.Player) error { func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder { return r.newSelect(ctx, options...). - Columns("player.*"). + Columns("player.*", "player.api_key_hash is not null as has_api_key"). Join("user ON player.user_id = user.id"). Columns("user.user_name username") } @@ -103,32 +117,115 @@ func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOpt return res, err } -// isPermitted authorizes creating a new record, based on the owner declared in the request body. -// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a -// player they own. Updates must not use this (the body owner is attacker-controlled); they go -// through updateOwned, which authorizes against the persisted user_id in the WHERE clause. -func (r *playerRepository) isPermitted(ctx context.Context, p *model.Player) bool { - u := loggedUser(ctx) - return u.IsAdmin || p.UserId == u.ID +var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`) + +func apiKeyValidationError(msg string) error { + return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}} +} + +func validateAPIKey(key string) error { + if !apiKeyFormat.MatchString(key) { + return apiKeyValidationError("resources.player.validation.apiKeyFormat") + } + return nil } func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) { - if !r.isPermitted(ctx, t) { + u := loggedUser(ctx) + if t.UserId == "" && u.ID != invalidUserId { + t.UserId = u.ID + } + if t.UserId != u.ID { return "", rest.ErrPermissionDenied } - return r.put(ctx, "", t) // Save only creates; edits go through the owner-scoped Update + // Hand-made players are only reachable through a key, so one is required + if t.APIKey == nil || *t.APIKey == "" { + return "", apiKeyValidationError("ra.validation.required") + } + if err := validateAPIKey(*t.APIKey); err != nil { + return "", err + } + values, err := toSQLArgs(t) + if err != nil { + return "", err + } + // Save only creates, so the key hash goes in the same INSERT and the unique index settles races + values["id"] = id.NewRandom() + values["api_key_hash"] = hashAPIKey(*t.APIKey) + _, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values)) + if isUniqueViolation(err) { + return "", apiKeyValidationError("ra.validation.unique") + } + if err != nil { + return "", err + } + return values["id"].(string), nil } func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error { t := &entity t.ID = id - return r.updateOwned(ctx, id, t, cols...) + if t.APIKey == nil { + return r.updateOwned(ctx, id, t, cols...) + } + // The key and the other columns are two writes; commit both or neither + return r.inTx(func(tx *playerRepository) error { + if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil { + return err + } + return tx.updateOwned(ctx, id, t, cols...) + }) +} + +func (r *playerRepository) inTx(block func(tx *playerRepository) error) error { + conn, ok := r.db.(*dbx.DB) + if !ok { + return block(r) // already inside a transaction + } + return conn.Transactional(func(tx *dbx.Tx) error { + return block(NewPlayerRepository(tx).(*playerRepository)) + }) } func (r *playerRepository) Delete(ctx context.Context, ids ...string) error { return r.deleteOwnedAll(ctx, ids...) } +// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed. +func hashAPIKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) { + sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)}) + var res model.Player + if err := r.queryOne(ctx, sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for +// admins, so nobody can mint a login for someone else. +func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error { + if key == "" { + return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil}) + } + if err := validateAPIKey(key); err != nil { + return err + } + err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)}) + if isUniqueViolation(err) { + return apiKeyValidationError("ra.validation.unique") + } + return err +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} + var _ model.PlayerRepository = (*playerRepository)(nil) var _ rest.Repository[model.Player] = (*playerRepository)(nil) var _ rest.Persistable[model.Player] = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f12f3e74e..69afa9556 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "errors" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" @@ -12,6 +13,14 @@ import ( "github.com/pocketbase/dbx" ) +const testAPIKey = "nds_0123456789abcdefghijkl" + +func expectAPIKeyError(err error, msg string) { + var verr *rest.ValidationError + ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue()) + ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg)) +} + var _ = Describe("PlayerRepository", func() { var adminRepo *playerRepository var database *dbx.DB @@ -178,11 +187,12 @@ var _ = Describe("PlayerRepository", func() { clone := player clone.ID = "" clone.IP = "192.168.1.1" + clone.APIKey = new(testAPIKey) id, err := repo.Save(repoCtx, &clone) if clone.UserId == "" { Expect(err).To(HaveOccurred()) - } else if !admin && player.Username == adminPlayer1.Username { + } else if player.UserId != userPlayer.UserId { Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.UserId = "" } else { @@ -202,12 +212,13 @@ var _ = Describe("PlayerRepository", func() { } else { Expect(count).To(Equal(baseCount + 1)) Expect(err).To(BeNil()) + clone.APIKey = nil + clone.HasAPIKey = true Expect(*newItem).To(Equal(clone)) } }, Entry("same user", userPlayer), Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), ) }) @@ -251,6 +262,259 @@ var _ = Describe("PlayerRepository", func() { Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) + Describe("API keys", func() { + const key = testAPIKey + const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV" + var ownerCtx, otherCtx context.Context + + BeforeEach(func() { + ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) + otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser) + }) + + storedHash := func(id string) string { + var row struct { + Hash string `db:"api_key_hash"` + } + Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}"). + Bind(dbx.Params{"id": id}).One(&row)).To(Succeed()) + return row.Hash + } + + Describe("SetAPIKey", func() { + It("stores only the hash and finds the player by the key", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + + Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) + plr, err := adminRepo.FindByAPIKey(ctx, key) + Expect(err).ToNot(HaveOccurred()) + Expect(plr.ID).To(Equal(regularPlayer.ID)) + Expect(plr.HasAPIKey).To(BeTrue()) + Expect(plr.APIKey).To(BeNil()) + }) + + It("replaces the previous key", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed()) + + _, err := adminRepo.FindByAPIKey(ctx, key) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = adminRepo.FindByAPIKey(ctx, otherKey) + Expect(err).ToNot(HaveOccurred()) + }) + + DescribeTable("rejects malformed keys", + func(bad string) { + err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad) + expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + }, + Entry("no prefix", "0123456789abcdefghijklmn"), + Entry("too short", "nds_short"), + Entry("too long", key+"x"), + Entry("bad chars", "nds_0123456789abcdefghij-!"), + ) + + It("revokes with an empty key, by the owner or an admin", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed()) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + }) + + It("accepts revoking a player that has no key", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) + }) + + It("does not let an admin set a key on another user's player", func() { + Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied)) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + }) + + It("does not let another user set or revoke", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied)) + Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied)) + Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) + }) + + It("returns not found for a missing player", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound)) + Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound)) + }) + + It("does not find unknown or empty keys", func() { + _, err := adminRepo.FindByAPIKey(ctx, otherKey) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = adminRepo.FindByAPIKey(ctx, "") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("drops the key with the player", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed()) + _, err := adminRepo.FindByAPIKey(ctx, key) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("hasApiKey filter", func() { + BeforeEach(func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + }) + + filtered := func(value string) []string { + res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}}) + Expect(err).ToNot(HaveOccurred()) + var ids []string + for _, p := range res { + ids = append(ids, p.ID) + } + return ids + } + + It("lists only players with a key", func() { + Expect(filtered("true")).To(ConsistOf(regularPlayer.ID)) + count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("lists only players without a key", func() { + Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID)) + }) + }) + + Describe("Save (create)", func() { + It("creates the player with the key, owned by the logged-in user", func() { + id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)}) + Expect(err).ToNot(HaveOccurred()) + + plr, err := adminRepo.FindByAPIKey(ctx, key) + Expect(err).ToNot(HaveOccurred()) + Expect(plr.ID).To(Equal(id)) + Expect(plr.UserId).To(Equal(regularUser.ID)) + }) + + It("requires a key", func() { + count, _ := adminRepo.CountAll(ctx) + _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"}) + expectAPIKeyError(err, "ra.validation.required") + + _, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")}) + expectAPIKeyError(err, "ra.validation.required") + Expect(adminRepo.CountAll(ctx)).To(Equal(count)) + }) + + It("rejects a malformed key without creating the player", func() { + count, _ := adminRepo.CountAll(ctx) + _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")}) + expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") + Expect(adminRepo.CountAll(ctx)).To(Equal(count)) + }) + + It("does not let an admin create a keyed player for another user", func() { + count, _ := adminRepo.CountAll(ctx) + _, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)}) + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + _, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID}) + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + Expect(adminRepo.CountAll(ctx)).To(Equal(count)) + }) + + It("rejects a key already used by another player without creating the player", func() { + Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) + count, _ := adminRepo.CountAll(ctx) + _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)}) + expectAPIKeyError(err, "ra.validation.unique") + Expect(adminRepo.CountAll(ctx)).To(Equal(count)) + }) + }) + + Describe("Update (edit)", func() { + It("rolls back the key change when the rest of the edit fails", func() { + _, err := database.NewQuery(`create trigger fail_player_rename before update of name on player + when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute() + }) + + plr := regularPlayer + plr.Name = "boom" + plr.APIKey = new(key) + Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed()) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + }) + + It("keeps the key when apiKey is absent (a normal edit)", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + + plr := regularPlayer + plr.Name = "Renamed" + Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed()) + Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed()) + + found, err := adminRepo.FindByAPIKey(ctx, key) + Expect(err).ToNot(HaveOccurred()) + Expect(found.Name).To(Equal("Renamed")) + }) + + It("sets a new key when apiKey has a value", func() { + plr := regularPlayer + plr.APIKey = new(key) + Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed()) + Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) + }) + + It("revokes the key when apiKey is empty", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + plr := regularPlayer + plr.APIKey = new("") + Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed()) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + }) + + It("lets an admin edit another user's keyed player without touching the key", func() { + Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) + plr := regularPlayer + plr.MaxBitRate = 192 + Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed()) + Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) + }) + + It("refuses an admin setting a key on another user's player and leaves other columns alone", func() { + plr := regularPlayer + plr.Name = "Hijacked" + plr.APIKey = new(key) + Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied)) + + got, err := adminRepo.Get(ctx, regularPlayer.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Name).To(Equal(regularPlayer.Name)) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + }) + + It("refuses a key already used by another player and leaves other columns alone", func() { + Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) + plr := regularPlayer + plr.Name = "Renamed" + plr.APIKey = new(key) + err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey") + expectAPIKeyError(err, "ra.validation.unique") + + got, err := adminRepo.Get(ctx, regularPlayer.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Name).To(Equal(regularPlayer.Name)) + Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) + Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key))) + }) + }) + }) + Describe("Ownership enforcement (cross-tenant write protection)", func() { var regularRepo *playerRepository var regularCtx context.Context @@ -287,6 +551,7 @@ var _ = Describe("PlayerRepository", func() { Name: "HIJACKED", UserId: regularUser.ID, ReportRealPath: true, + APIKey: new(testAPIKey), } id, err := regularRepo.Save(regularCtx, &spoofed) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index be88156d8..03cc6a01b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -85,6 +85,22 @@ func (r sqlRepository) addRestriction(ctx context.Context, sql ...Sqlizer) Sqliz return s } +// writeAccess says who may change a row in a table with a user_id column. +type writeAccess int + +const ( + ownerOrAdmin writeAccess = iota // admins may write any row + ownerOnly // even admins may only write their own rows +) + +// ownedRow matches the row rowID only if the logged-in user may write it under access. +func (r sqlRepository) ownedRow(ctx context.Context, rowID string, access writeAccess) Sqlizer { + if access == ownerOnly { + return And{Eq{"id": rowID}, Eq{"user_id": loggedUser(ctx).ID}} + } + return r.addRestriction(ctx, Eq{"id": rowID}) +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -494,15 +510,12 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo } updateValues := filterUpdateValues(values, id, colsToUpdate...) delete(updateValues, "user_id") // ownership is immutable on update - update := Update(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})).SetMap(updateValues) - count, err := r.executeSQL(ctx, update) - if err != nil { - return err - } - if count == 0 { - return r.classifyOwnedWriteMiss(ctx, id) - } - return nil + return r.updateOwnedRow(ctx, id, ownerOrAdmin, updateValues) +} + +// updateOwnedRow sets values on the row rowID if the logged-in user may write it under access. +func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access writeAccess, values map[string]any) error { + return r.runRowWrite(ctx, rowID, Update(r.tableName).SetMap(values).Where(r.ownedRow(ctx, rowID, access))) } // deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for @@ -511,12 +524,17 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo // does not match and is left untouched. The failure path mirrors updateOwned (see // classifyOwnedWriteMiss), so there is no TOCTOU on the delete. func (r sqlRepository) deleteOwned(ctx context.Context, id string) error { - count, err := r.executeSQL(ctx, Delete(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id}))) + return r.runRowWrite(ctx, id, Delete(r.tableName).Where(r.ownedRow(ctx, id, ownerOrAdmin))) +} + +// runRowWrite executes q, a write already filtered by ownedRow(rowID, …), and classifies a miss. +func (r sqlRepository) runRowWrite(ctx context.Context, rowID string, q Sqlizer) error { + count, err := r.executeSQL(ctx, q) if err != nil { return err } if count == 0 { - return r.classifyOwnedWriteMiss(ctx, id) + return r.classifyOwnedWriteMiss(ctx, rowID) } return nil } diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 33a8140f8..4b42e7f1d 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -19,6 +19,26 @@ var _ = Describe("sqlRepository", func() { r.tableName = "table" }) + Describe("ownedRow", func() { + DescribeTable("matches the row, limited to what the logged-in user may write", + func(user model.User, access writeAccess, expectedSQL string, expectedArgs ...any) { + userCtx := request.WithUser(GinkgoT().Context(), user) + sql, args, err := r.ownedRow(userCtx, "row-1", access).ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal(expectedSQL)) + Expect(args).To(Equal(expectedArgs)) + }, + Entry("admin, ownerOrAdmin: any row", model.User{ID: "admin", IsAdmin: true}, ownerOrAdmin, + "(id = ?)", "row-1"), + Entry("regular, ownerOrAdmin: own rows", model.User{ID: "user"}, ownerOrAdmin, + "(id = ? AND user_id = ?)", "row-1", "user"), + Entry("admin, ownerOnly: own rows", model.User{ID: "admin", IsAdmin: true}, ownerOnly, + "(id = ? AND user_id = ?)", "row-1", "admin"), + Entry("regular, ownerOnly: own rows", model.User{ID: "user"}, ownerOnly, + "(id = ? AND user_id = ?)", "row-1", "user"), + ) + }) + Describe("applyOptions", func() { var sq squirrel.SelectBuilder BeforeEach(func() { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 6fcde56ca..f0d8a0e06 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -182,6 +182,7 @@ }, "player": { "name": "Tocador |||| Tocadores", + "menuName": "Tocadores e chaves de API", "fields": { "name": "Nome", "transcodingId": "Conversão", @@ -190,7 +191,29 @@ "userName": "Usuário", "lastSeen": "Últ. acesso", "reportRealPath": "Use paths reais", - "scrobbleEnabled": "Enviar scrobbles para serviços externos" + "scrobbleEnabled": "Enviar scrobbles para serviços externos", + "hasApiKey": "Chave de API" + }, + "actions": { + "generateApiKey": "Gerar chave de API", + "regenerateApiKey": "Gerar nova", + "revokeApiKey": "Revogar", + "copyApiKey": "Copiar" + }, + "message": { + "apiKeyActive": "Este tocador tem uma chave de API. Use-a no seu app como chave de API, ou como senha se o app não usar autenticação por token.", + "apiKeyNone": "Sem chave de API. Gere uma para conectar um app a este tocador.", + "apiKeyNoneOther": "Sem chave de API.", + "apiKeyPending": "Copie esta chave agora. Ela será salva quando você clicar em Salvar e não será exibida novamente.", + "apiKeyRevokePending": "A chave de API será removida quando você salvar.", + "deleteWithKeyTitle": "Excluir tocador", + "deleteWithKeyContent": "Este tocador tem uma chave de API. Os apps que a usam vão parar de funcionar." + }, + "notifications": { + "apiKeyCopied": "Chave de API copiada para o clipboard" + }, + "validation": { + "apiKeyFormat": "Formato de chave de API inválido" } }, "transcoding": { diff --git a/server/subsonic/api.go b/server/subsonic/api.go index deedc46c7..fe724741c 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -107,6 +107,7 @@ func (api *Router) routes() http.Handler { r.Use(getPlayer(api.players)) h(r, "ping", api.Ping) h(r, "getLicense", api.GetLicense) + h(r, "tokenInfo", api.TokenInfo) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/e2e/subsonic_apikey_test.go b/server/subsonic/e2e/subsonic_apikey_test.go new file mode 100644 index 000000000..bb263c15d --- /dev/null +++ b/server/subsonic/e2e/subsonic_apikey_test.go @@ -0,0 +1,54 @@ +package e2e + +import ( + "net/http/httptest" + "net/url" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("API key authentication", func() { + var key string + + BeforeEach(func() { + setupTestDB() + userCtx := request.WithUser(ctx, regularUser) + player := &model.Player{ID: "apikey-player", Name: "Phone", UserId: regularUser.ID, Client: "test-client"} + Expect(ds.Player().Put(userCtx, player)).To(Succeed()) + key = "nds_0123456789abcdefghijkl" + Expect(ds.Player().SetAPIKey(userCtx, player.ID, key)).To(Succeed()) + }) + + doKeyReq := func(endpoint, apiKey string) *responses.Subsonic { + q := url.Values{"apiKey": {apiKey}, "v": {"1.16.1"}, "c": {"test-client"}, "f": {"json"}} + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil)) + return parseJSONResponse(w) + } + + It("authenticates ping with only the key", func() { + resp := doKeyReq("ping", key) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("reports the key owner in tokenInfo", func() { + resp := doKeyReq("tokenInfo", key) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TokenInfo).ToNot(BeNil()) + Expect(resp.TokenInfo.Username).To(Equal(regularUser.UserName)) + }) + + It("rejects an unknown key with error 44", func() { + resp := doKeyReq("ping", "nds_unknown") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(int32(44))) + }) +}) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 6617661a9..fdb4af28d 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -65,14 +65,15 @@ func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requiredParameters []string + p := req.Params(r) username, _ := fromInternalOrProxyAuth(r) - if username != "" { + apiKey, _ := p.String("apiKey") + if username != "" || apiKey != "" { requiredParameters = []string{"v", "c"} } else { requiredParameters = []string{"u", "v", "c"} } - p := req.Params(r) for _, param := range requiredParameters { if _, err := p.String(param); err != nil { log.Warn(r, err) @@ -104,10 +105,14 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { ctx := r.Context() var usr *model.User + var keyPlayer *model.Player var err error + p := req.Params(r) + apiKey, _ := p.String("apiKey") username, isInternalAuth := fromInternalOrProxyAuth(r) - if username != "" { + switch { + case username != "": authType := If(isInternalAuth, "internal", "reverse-proxy") usr, err = ds.User().FindByUsername(ctx, username) if errors.Is(err, context.Canceled) { @@ -119,8 +124,16 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } else if err != nil { log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) } - } else { - p := req.Params(r) + case apiKey != "": + usr, keyPlayer, err = authenticateAPIKey(ctx, ds, limiter, r, apiKey) + if err != nil { + if ctx.Err() == nil { + sendError(w, r, err) + } + return + } + ctx = request.WithUsername(ctx, usr.UserName) + default: username, _ := p.String("u") pass, _ := p.String("p") token, _ := p.String("t") @@ -142,6 +155,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { usr, err = ds.User().FindByUsernameWithPassword(ctx, username) if err == nil { err = validateCredentials(usr, pass, token, salt, jwt) + if errors.Is(err, model.ErrInvalidAuth) && pass != "" && jwt == "" { + keyPlayer, err = playerFromPasswordKey(ctx, ds, usr, pass) + } } invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth) slot.release(invalidLogin) @@ -162,11 +178,77 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } ctx = request.WithUser(ctx, *usr) + if keyPlayer != nil { + ctx = request.WithPlayer(ctx, *keyPlayer) + } next.ServeHTTP(w, r.WithContext(ctx)) }) } } +var apiKeyConflicts = []string{"u", "p", "t", "s", "jwt"} + +func authenticateAPIKey(ctx context.Context, ds model.DataStore, limiter *authLimiter, r *http.Request, key string) (*model.User, *model.Player, error) { + query := r.URL.Query() + for _, param := range apiKeyConflicts { + if query.Has(param) { + log.Warn(ctx, "API: apiKey sent with other credentials", "auth", "apikey", "param", param, "remoteAddr", r.RemoteAddr) + return nil, nil, newError(responses.ErrorMultipleAuthMechanismsProvided) + } + } + + // Per key, so a stale key on one device cannot lock out valid keys sharing the IP + slot, allowed := limiter.acquire(ctx, "apikey\x00"+server.ClientIP(r)+"\x00"+key) + if !allowed { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + log.Warn(ctx, "API: Too many failed API key attempts", "auth", "apikey", "remoteAddr", r.RemoteAddr) + return nil, nil, newError(responses.ErrorInvalidAPIKey) + } + + player, err := ds.Player().FindByAPIKey(ctx, key) + var usr *model.User + if err == nil { + usr, err = ds.User().Get(ctx, player.UserId) + } + slot.release(errors.Is(err, model.ErrNotFound)) + switch { + case errors.Is(err, context.Canceled): + return nil, nil, err + case errors.Is(err, model.ErrNotFound): + log.Warn(ctx, "API: Invalid API key", "auth", "apikey", "remoteAddr", r.RemoteAddr) + return nil, nil, newError(responses.ErrorInvalidAPIKey) + case err != nil: + log.Error(ctx, "API: Error authenticating API key", "auth", "apikey", "remoteAddr", r.RemoteAddr, err) + return nil, nil, newError(responses.ErrorAuthenticationFail) + } + return usr, player, nil +} + +// playerFromPasswordKey lets clients that only have a password field log in with an API key. +// It returns ErrInvalidAuth when pass is not a key of usr, so only real failures skip the limiter count. +func playerFromPasswordKey(ctx context.Context, ds model.DataStore, usr *model.User, pass string) (*model.Player, error) { + key := decodePassword(pass) + if !strings.HasPrefix(key, consts.APIKeyPrefix) { + return nil, model.ErrInvalidAuth + } + plr, err := ds.Player().FindByAPIKey(ctx, key) + if errors.Is(err, model.ErrNotFound) || (err == nil && plr.UserId != usr.ID) { + return nil, model.ErrInvalidAuth + } + return plr, err +} + +func decodePassword(pass string) string { + if strings.HasPrefix(pass, "enc:") { + if dec, err := hex.DecodeString(pass[4:]); err == nil { + return string(dec) + } + } + return pass +} + func adminOnly(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loggedUser, ok := request.UserFrom(r.Context()) @@ -194,12 +276,7 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error claims.Subject == user.UserName && auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil case pass != "": - if strings.HasPrefix(pass, "enc:") { - if dec, err := hex.DecodeString(pass[4:]); err == nil { - pass = string(dec) - } - } - valid = pass == user.Password + valid = decodePassword(pass) == user.Password case token != "": t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt))) valid = t == token @@ -217,12 +294,20 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { ctx := r.Context() userName, _ := request.UsernameFrom(ctx) client, _ := request.ClientFrom(ctx) - playerId := playerIDFromCookie(r, userName) ip, _, _ := net.SplitHostPort(r.RemoteAddr) userAgent := canonicalUserAgent(r) - player, trc, err := players.Register(ctx, playerId, client, userAgent, ip) + + var player *model.Player + var trc *model.Transcoding + var err error + keyPlayer, boundByKey := request.PlayerFrom(ctx) + if boundByKey { + player, trc, err = players.Touch(ctx, keyPlayer, client, userAgent, ip) + } else { + player, trc, err = players.Register(ctx, playerIDFromCookie(r, userName), client, userAgent, ip) + } if err != nil { - log.Error(ctx, "Could not register player", "username", userName, "client", client, err) + log.Error(ctx, "Could not resolve player", "username", userName, "client", client, err) } else { ctx = request.WithPlayer(ctx, *player) if trc != nil { @@ -230,6 +315,11 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) + // A key already identifies the player, so the cookie would only add a second, weaker signal + if boundByKey { + next.ServeHTTP(w, r) + return + } cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 0879ee540..b3ad972b7 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "crypto/md5" + "encoding/hex" "errors" "fmt" "net/http" @@ -119,6 +120,14 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeTrue()) }) + It("does not require u when apiKey is present", func() { + r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test") + cp := checkRequiredParameters(next) + cp.ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + }) + It("fails when user is missing", func() { r := newGetRequest("v=1.15", "c=test") cp := checkRequiredParameters(next) @@ -311,6 +320,101 @@ var _ = Describe("Middlewares", func() { }) }) + When("using API key authentication", func() { + var key string + serve := func(params ...string) { + authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...)) + } + + BeforeEach(func() { + usr, err := ds.User().FindByUsername(ctx, "admin") + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed()) + key = "nds_0123456789abcdefghijkl" + Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed()) + }) + + It("authenticates the owner and binds the key's player", func() { + serve("apiKey=" + key) + + Expect(next.called).To(BeTrue()) + user, _ := request.UserFrom(next.req.Context()) + Expect(user.UserName).To(Equal("admin")) + username, _ := request.UsernameFrom(next.req.Context()) + Expect(username).To(Equal("admin")) + player, ok := request.PlayerFrom(next.req.Context()) + Expect(ok).To(BeTrue()) + Expect(player.ID).To(Equal("player-1")) + }) + + It("accepts the key in a POST form body", func() { + r := newPostRequest("", "apiKey="+key) + cp := postFormToQueryParams(authenticate(ds)(next)) + cp.ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + player, _ := request.PlayerFrom(next.req.Context()) + Expect(player.ID).To(Equal("player-1")) + }) + + It("rejects an unknown key with error 44", func() { + serve("apiKey=nds_unknown") + + Expect(w.Body.String()).To(ContainSubstring(`code="44"`)) + Expect(next.called).To(BeFalse()) + }) + + DescribeTable("rejects apiKey mixed with other credentials with error 43", + func(extra string) { + serve("apiKey="+key, extra) + + Expect(w.Body.String()).To(ContainSubstring(`code="43"`)) + Expect(next.called).To(BeFalse()) + }, + Entry("u", "u=admin"), + Entry("p", "p=wordpass"), + Entry("t", "t=abc"), + Entry("s", "s=abc"), + Entry("jwt", "jwt=abc"), + Entry("empty u", "u="), + Entry("empty p", "p="), + ) + + Context("key sent as the password", func() { + It("authenticates and binds the key's player", func() { + serve("u=admin", "p="+key) + + Expect(next.called).To(BeTrue()) + player, ok := request.PlayerFrom(next.req.Context()) + Expect(ok).To(BeTrue()) + Expect(player.ID).To(Equal("player-1")) + }) + + It("accepts the hex-encoded form", func() { + serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key))) + + Expect(next.called).To(BeTrue()) + }) + + It("still accepts a real password that starts with the key prefix", func() { + Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed()) + serve("u=prefixed", "p=nds_secret") + + Expect(next.called).To(BeTrue()) + _, ok := request.PlayerFrom(next.req.Context()) + Expect(ok).To(BeFalse()) + }) + + It("rejects another user's key with error 40", func() { + Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed()) + serve("u=other", "p="+key) + + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) + }) + }) + }) + When("failed attempts reach AuthRequestLimit", func() { var cp http.Handler @@ -376,6 +480,21 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeTrue()) }) + It("does not count server errors when a key is sent as the password", func() { + usr, _ := ds.User().FindByUsername(ctx, "admin") + playerRepo := ds.Player().(*tests.MockPlayerRepo) + Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) + key := "nds_0123456789abcdefghijkl" + Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) + + playerRepo.Error = errors.New("db down") + failTimes(5, "u=admin", "p="+key) + playerRepo.Error = nil + + serve(newGetRequest("u=admin", "p="+key)) + Expect(next.called).To(BeTrue()) + }) + It("does not block other usernames from the same IP", func() { _ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"}) failTimes(3, "u=admin", "p=WRONG") @@ -405,6 +524,25 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeTrue()) }) + It("throttles a repeated bad key without locking out valid keys from the same IP", func() { + usr, _ := ds.User().FindByUsername(ctx, "admin") + playerRepo := ds.Player().(*tests.MockPlayerRepo) + Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) + key := "nds_0123456789abcdefghijkl" + Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) + + for range 3 { + Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`)) + } + playerRepo.APIKeys["nds_bad"] = "player-1" + rec := serve(newGetRequest("apiKey=nds_bad")) + Expect(next.called).To(BeFalse()) + Expect(rec.Body.String()).To(ContainSubstring(`code="44"`)) + + serve(newGetRequest("apiKey=" + key)) + Expect(next.called).To(BeTrue()) + }) + It("is disabled when AuthRequestLimit is 0", func() { conf.Server.AuthRequestLimit = 0 cp = authenticate(ds)(next) @@ -532,6 +670,24 @@ var _ = Describe("Middlewares", func() { Expect(cookieStr).To(BeEmpty()) }) + Context("player bound by an API key", func() { + BeforeEach(func() { + r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"})) + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + }) + + It("uses the key's player", func() { + Expect(mockedPlayers.touched).To(BeTrue()) + player, _ := request.PlayerFrom(next.req.Context()) + Expect(player.ID).To(Equal("keyed")) + }) + + It("does not set the player cookie", func() { + Expect(w.Header().Get("Set-Cookie")).To(BeEmpty()) + }) + }) + Context("PlayerId specified in Cookies", func() { BeforeEach(func() { cookie := &http.Cookie{ @@ -712,6 +868,12 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { type mockPlayers struct { core.Players transcoding *model.Transcoding + touched bool +} + +func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) { + mp.touched = true + return &plr, mp.transcoding, nil } func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 2b2a31bf3..21c407039 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -16,6 +16,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, {Name: "topSongsByArtistId", Versions: []int32{1}}, + {Name: "apiKeyAuthentication", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 2615a652d..8740c9971 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,44 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(7), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - Context("with sonic similarity plugin", func() { - BeforeEach(func() { - sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) - }) - - It("should return 7 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -93,8 +62,41 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 9 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(9), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON new file mode 100644 index 000000000..f2e251f49 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON @@ -0,0 +1,10 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "tokenInfo": { + "username": "deluan" + } +} diff --git a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML new file mode 100644 index 000000000..7ea786bb9 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go index 42e5427b3..9c9dd10f6 100644 --- a/server/subsonic/responses/errors.go +++ b/server/subsonic/responses/errors.go @@ -1,25 +1,29 @@ package responses const ( - ErrorGeneric int32 = 0 - ErrorMissingParameter int32 = 10 - ErrorClientTooOld int32 = 20 - ErrorServerTooOld int32 = 30 - ErrorAuthenticationFail int32 = 40 - ErrorAuthorizationFail int32 = 50 - ErrorTrialExpired int32 = 60 - ErrorDataNotFound int32 = 70 + ErrorGeneric int32 = 0 + ErrorMissingParameter int32 = 10 + ErrorClientTooOld int32 = 20 + ErrorServerTooOld int32 = 30 + ErrorAuthenticationFail int32 = 40 + ErrorMultipleAuthMechanismsProvided int32 = 43 + ErrorInvalidAPIKey int32 = 44 + ErrorAuthorizationFail int32 = 50 + ErrorTrialExpired int32 = 60 + ErrorDataNotFound int32 = 70 ) -var errors = map[int32]string{ - ErrorGeneric: "A generic error", - ErrorMissingParameter: "Required parameter is missing", - ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", - ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", - ErrorAuthenticationFail: "Wrong username or password", - ErrorAuthorizationFail: "User is not authorized for the given operation", - ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", - ErrorDataNotFound: "The requested data was not found", +var errors = map[int32]string{ //nolint:gosec // G101 false positive: error messages, not credentials + ErrorGeneric: "A generic error", + ErrorMissingParameter: "Required parameter is missing", + ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", + ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", + ErrorAuthenticationFail: "Wrong username or password", + ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided", + ErrorInvalidAPIKey: "Invalid API key", + ErrorAuthorizationFail: "User is not authorized for the given operation", + ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", + ErrorDataNotFound: "The requested data was not found", } func ErrorMsg(code int32) string { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..51d0020b2 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -63,6 +63,7 @@ type Subsonic struct { PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` + TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"` } const ( @@ -596,6 +597,10 @@ type OpenSubsonicExtension struct { type OpenSubsonicExtensions []OpenSubsonicExtension +type TokenInfo struct { + Username string `xml:"username,attr" json:"username"` +} + type ItemGenre struct { Name string `xml:"name,attr" json:"name"` } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 586e46b63..027ac11d5 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1015,6 +1015,20 @@ var _ = Describe("Responses", func() { }) }) + Describe("TokenInfo", func() { + BeforeEach(func() { + response.OpenSubsonic = true + response.TokenInfo = &TokenInfo{Username: "deluan"} + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + Describe("InternetRadioStations", func() { BeforeEach(func() { response.InternetRadioStations = &InternetRadioStations{} diff --git a/server/subsonic/system.go b/server/subsonic/system.go index e14099942..4a59e7898 100644 --- a/server/subsonic/system.go +++ b/server/subsonic/system.go @@ -3,6 +3,7 @@ package subsonic import ( "net/http" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" ) @@ -15,3 +16,10 @@ func (api *Router) GetLicense(_ *http.Request) (*responses.Subsonic, error) { response.License = &responses.License{Valid: true} return response, nil } + +func (api *Router) TokenInfo(r *http.Request) (*responses.Subsonic, error) { + user, _ := request.UserFrom(r.Context()) + response := newResponse() + response.TokenInfo = &responses.TokenInfo{Username: user.UserName} + return response, nil +} diff --git a/server/subsonic/system_test.go b/server/subsonic/system_test.go new file mode 100644 index 000000000..a8c85238e --- /dev/null +++ b/server/subsonic/system_test.go @@ -0,0 +1,23 @@ +package subsonic + +import ( + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TokenInfo", func() { + It("returns the authenticated username", func() { + api := &Router{} + r := httptest.NewRequest("GET", "/tokenInfo", nil) + r = r.WithContext(request.WithUser(r.Context(), model.User{UserName: "deluan"})) + + resp, err := api.TokenInfo(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.TokenInfo.Username).To(Equal("deluan")) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index db798eece..a5e4126cf 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -228,7 +228,7 @@ func (db *MockDataStore) Player() model.PlayerRepository { if db.RealDS != nil { return db.RealDS.Player() } - db.MockedPlayer = struct{ model.PlayerRepository }{} + db.MockedPlayer = CreateMockPlayerRepo() return db.MockedPlayer } diff --git a/tests/mock_player_repo.go b/tests/mock_player_repo.go new file mode 100644 index 000000000..56835f0cc --- /dev/null +++ b/tests/mock_player_repo.go @@ -0,0 +1,75 @@ +package tests + +import ( + "context" + "maps" + "slices" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +func CreateMockPlayerRepo() *MockPlayerRepo { + return &MockPlayerRepo{Data: map[string]*model.Player{}, APIKeys: map[string]string{}} +} + +// MockPlayerRepo keeps API keys in plaintext (key -> player ID); hashing belongs to the real repository. +type MockPlayerRepo struct { + model.PlayerRepository + Error error + Data map[string]*model.Player + APIKeys map[string]string +} + +func (m *MockPlayerRepo) Get(_ context.Context, id string) (*model.Player, error) { + if m.Error != nil { + return nil, m.Error + } + p, ok := m.Data[id] + if !ok { + return nil, model.ErrNotFound + } + cp := *p + cp.HasAPIKey = slices.Contains(slices.Collect(maps.Values(m.APIKeys)), id) + return &cp, nil +} + +func (m *MockPlayerRepo) Put(_ context.Context, p *model.Player) error { + if m.Error != nil { + return m.Error + } + if p.ID == "" { + p.ID = id.NewRandom() + } + cp := *p + m.Data[p.ID] = &cp + return nil +} + +func (m *MockPlayerRepo) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) { + if m.Error != nil { + return nil, m.Error + } + if playerID, ok := m.APIKeys[key]; ok { + return m.Get(ctx, playerID) + } + return nil, model.ErrNotFound +} + +func (m *MockPlayerRepo) SetAPIKey(_ context.Context, playerID, key string) error { + if m.Error != nil { + return m.Error + } + if _, ok := m.Data[playerID]; !ok { + return model.ErrNotFound + } + m.removeKeys(playerID) + if key != "" { + m.APIKeys[key] = playerID + } + return nil +} + +func (m *MockPlayerRepo) removeKeys(playerID string) { + maps.DeleteFunc(m.APIKeys, func(_ string, v string) bool { return v == playerID }) +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d10aa5a33..4de369394 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -141,7 +141,7 @@ const Admin = (props) => { , permissions === 'admin' ? ( { return userResponse } +// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached +const createPlayer = async (resource, params) => { + const { data } = await dataProvider.create(resource, params) + return dataProvider.getOne(resource, { id: data.id }) +} + const wrapperDataProvider = { ...dataProvider, getList: (resource, params) => { @@ -194,6 +200,9 @@ const wrapperDataProvider = { return createUser(params) } const [r, p] = mapResource(resource, params) + if (resource === 'player') { + return createPlayer(r, p) + } return dataProvider.create(r, p) }, delete: (resource, params) => { diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js index 4225a5a54..1c33aad5d 100644 --- a/ui/src/dataProvider/wrapperDataProvider.test.js +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -88,6 +88,21 @@ describe('wrapperDataProvider', () => { }) }) + describe('create player', () => { + it('returns the server record, never the plaintext API key', async () => { + const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' } + const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' } + mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } }) + mockProvider.getOne.mockResolvedValue({ data: saved }) + + const result = await wrapperDataProvider.create('player', { data }) + + expect(mockProvider.create).toHaveBeenCalledWith('player', { data }) + expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' }) + expect(result.data).toEqual(saved) + }) + }) + describe('refreshMetadata', () => { it('posts to the album metadata refresh endpoint', () => { mockHttpClient.mockResolvedValue({ json: {} }) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index f5af05b7b..851b47ee3 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -182,6 +182,7 @@ }, "player": { "name": "Player |||| Players", + "menuName": "Players & API keys", "fields": { "name": "Name", "transcodingId": "Transcoding", @@ -190,7 +191,29 @@ "userName": "Username", "lastSeen": "Last Seen At", "reportRealPath": "Report Real Path", - "scrobbleEnabled": "Send Scrobbles to external services" + "scrobbleEnabled": "Send Scrobbles to external services", + "hasApiKey": "API Key" + }, + "actions": { + "generateApiKey": "Generate API key", + "regenerateApiKey": "Regenerate", + "revokeApiKey": "Revoke", + "copyApiKey": "Copy" + }, + "message": { + "apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.", + "apiKeyNone": "No API key. Generate one to connect an app to this player.", + "apiKeyNoneOther": "No API key.", + "apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.", + "apiKeyRevokePending": "The API key will be removed when you save.", + "deleteWithKeyTitle": "Delete player", + "deleteWithKeyContent": "This player has an API key. Apps using it will stop working." + }, + "notifications": { + "apiKeyCopied": "API key copied to clipboard" + }, + "validation": { + "apiKeyFormat": "Invalid API key format" } }, "transcoding": { diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 7de111e67..460d33bb9 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -102,9 +102,11 @@ const CustomUserMenu = ({ onClick, ...rest }) => { } const renderSettingsMenuItemLink = (resource, id) => { - const label = translate(`resources.${resource.name}.name`, { - smart_count: id ? 1 : 2, - }) + const label = resource.options.label + ? translate(resource.options.label) + : translate(`resources.${resource.name}.name`, { + smart_count: id ? 1 : 2, + }) const link = id ? `/${resource.name}/${id}` : `/${resource.name}` return ( ({ resources: [] })) + vi.mock('react-admin', () => ({ AppBar: ({ userMenu }) =>
{userMenu}
, + MenuItemLink: ({ primaryText }) =>
{primaryText}
, useTranslate: () => (x) => x, usePermissions: () => ({ permissions: 'admin' }), - getResources: () => [], + getResources: () => mocks.resources, })) vi.mock('./NowPlayingPanel', () => ({ @@ -41,6 +44,7 @@ describe('', () => { config.devActivityPanel = true config.enableNowPlaying = true config.enableQuickConnect = false + mocks.resources = [] store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -84,4 +88,22 @@ describe('', () => { expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0) expect(screen.queryAllByText('menu.about')).not.toHaveLength(0) }) + + it('uses the resource label for settings items when set', () => { + mocks.resources = [ + { + name: 'player', + hasList: true, + options: { subMenu: 'settings', label: 'resources.player.menuName' }, + }, + { name: 'transcoding', hasList: true, options: { subMenu: 'settings' } }, + ] + render( + + + , + ) + expect(screen.getByText('resources.player.menuName')).toBeInTheDocument() + expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument() + }) }) diff --git a/ui/src/player/ApiKeyInput.jsx b/ui/src/player/ApiKeyInput.jsx new file mode 100644 index 000000000..1f2dd1ed2 --- /dev/null +++ b/ui/src/player/ApiKeyInput.jsx @@ -0,0 +1,107 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useInput, useNotify, useTranslate } from 'react-admin' +import { Button, TextField } from '@material-ui/core' +import { FaKey } from 'react-icons/fa' +import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md' +import { isWritable } from '../common/playlistUtils' +import { generateApiKey } from './apiKey' + +const identity = (v) => v +const MASK = '•'.repeat(26) + +const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => { + const translate = useTranslate() + const notify = useNotify() + // Identity format/parse keep "" (revoke) distinct from undefined (untouched) + const { + input: { value, onChange }, + meta: { error, touched }, + } = useInput({ ...props, format: identity, parse: identity }) + + const isOwner = isCreate || record?.userId === localStorage.getItem('userId') + const pending = !!value + const revoking = value === '' && !!record?.hasApiKey + const saved = value == null && !!record?.hasApiKey + const hasKey = pending || saved + + const copy = () => { + const fallback = () => + prompt(translate('message.shareCopyToClipboard'), value) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard + .writeText(value) + .then( + () => notify('resources.player.notifications.apiKeyCopied'), + fallback, + ) + } else { + fallback() + } + } + + const helperText = pending + ? 'resources.player.message.apiKeyPending' + : revoking + ? 'resources.player.message.apiKeyRevokePending' + : saved + ? 'resources.player.message.apiKeyActive' + : isOwner + ? 'resources.player.message.apiKeyNone' + : 'resources.player.message.apiKeyNoneOther' + + return ( +
+ +
+ {pending && ( + + )} + {isOwner && ( + + )} + {saved && isWritable(record?.userId) && ( + + )} +
+
+ ) +} + +ApiKeyInput.propTypes = { + source: PropTypes.string.isRequired, + record: PropTypes.object, + isCreate: PropTypes.bool, + fullWidth: PropTypes.bool, + className: PropTypes.string, + validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]), +} + +export default ApiKeyInput diff --git a/ui/src/player/ApiKeyInput.test.jsx b/ui/src/player/ApiKeyInput.test.jsx new file mode 100644 index 000000000..dd4269d6e --- /dev/null +++ b/ui/src/player/ApiKeyInput.test.jsx @@ -0,0 +1,172 @@ +import * as React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Form } from 'react-final-form' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import ApiKeyInput from './ApiKeyInput' + +const hooks = vi.hoisted(() => ({ notify: vi.fn() })) +const KEY = 'nds_0123456789abcdefghijkl' +const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/ + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useNotify: () => hooks.notify, + useTranslate: () => (key) => key, + } +}) + +const renderInput = ({ + record, + initialValues = {}, + isCreate = false, + fullWidth, +}) => { + let values + const utils = render( +
{}} + initialValues={initialValues} + render={({ values: v }) => { + values = v + return ( + + ) + }} + />, + ) + return { ...utils, values: () => values } +} + +const text = (key) => screen.queryByText(key) + +describe('ApiKeyInput', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.setItem('userId', 'owner') + localStorage.setItem('role', 'regular') + }) + + it('shows a pending key with copy and regenerate on create', () => { + const { values } = renderInput({ + record: {}, + isCreate: true, + initialValues: { apiKey: KEY }, + }) + expect(screen.getByDisplayValue(KEY)).toBeInTheDocument() + expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'resources.player.actions.copyApiKey', + }), + ).toBeInTheDocument() + expect( + text('resources.player.actions.revokeApiKey'), + ).not.toBeInTheDocument() + + fireEvent.click( + screen.getByText('resources.player.actions.regenerateApiKey'), + ) + expect(values().apiKey).toMatch(KEY_FORMAT) + expect(values().apiKey).not.toBe(KEY) + }) + + it('masks a saved key and lets the owner regenerate or revoke', () => { + const { values } = renderInput({ + record: { id: 'p1', userId: 'owner', hasApiKey: true }, + }) + expect(screen.queryByDisplayValue(/^nds_/)).not.toBeInTheDocument() + expect(text('resources.player.message.apiKeyActive')).toBeInTheDocument() + expect( + text('resources.player.actions.regenerateApiKey'), + ).toBeInTheDocument() + + fireEvent.click(screen.getByText('resources.player.actions.revokeApiKey')) + expect(values().apiKey).toBe('') + expect( + text('resources.player.message.apiKeyRevokePending'), + ).toBeInTheDocument() + expect(text('resources.player.actions.generateApiKey')).toBeInTheDocument() + }) + + it('lets the owner generate a key when there is none', () => { + const { values } = renderInput({ + record: { id: 'p1', userId: 'owner', hasApiKey: false }, + }) + expect(values().apiKey).toBeUndefined() + expect(text('resources.player.message.apiKeyNone')).toBeInTheDocument() + + fireEvent.click(screen.getByText('resources.player.actions.generateApiKey')) + expect(values().apiKey).toMatch(KEY_FORMAT) + expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument() + }) + + it('lets an admin revoke but not set a key on another user player', () => { + localStorage.setItem('role', 'admin') + renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } }) + expect( + text('resources.player.actions.regenerateApiKey'), + ).not.toBeInTheDocument() + expect(text('resources.player.actions.revokeApiKey')).toBeInTheDocument() + }) + + it('falls back to a prompt when the clipboard write fails', async () => { + vi.stubGlobal('isSecureContext', true) + vi.stubGlobal('prompt', vi.fn()) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + try { + renderInput({ + record: {}, + isCreate: true, + initialValues: { apiKey: KEY }, + }) + fireEvent.click( + screen.getByRole('button', { + name: 'resources.player.actions.copyApiKey', + }), + ) + await waitFor(() => + expect(window.prompt).toHaveBeenCalledWith( + 'message.shareCopyToClipboard', + KEY, + ), + ) + expect(hooks.notify).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + delete navigator.clipboard + } + }) + + it('shows a neutral message to an admin viewing another user player with no key', () => { + localStorage.setItem('role', 'admin') + renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: false } }) + expect(text('resources.player.message.apiKeyNoneOther')).toBeInTheDocument() + expect(text('resources.player.message.apiKeyNone')).not.toBeInTheDocument() + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) + + it('shows no actions to another regular user', () => { + renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } }) + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) + + it('is not full width unless asked', () => { + const record = { id: 'p1', userId: 'owner', hasApiKey: true } + const { container, unmount } = renderInput({ record }) + expect(container.querySelector('.MuiFormControl-fullWidth')).toBeNull() + unmount() + + const { container: wide } = renderInput({ record, fullWidth: true }) + expect(wide.querySelector('.MuiFormControl-fullWidth')).not.toBeNull() + }) +}) diff --git a/ui/src/player/PlayerCreate.jsx b/ui/src/player/PlayerCreate.jsx new file mode 100644 index 000000000..bf02cde1b --- /dev/null +++ b/ui/src/player/PlayerCreate.jsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react' +import { Create, SimpleForm, required, useTranslate } from 'react-admin' +import { Title } from '../common' +import { playerInputs } from './playerInputs' +import ApiKeyInput from './ApiKeyInput' +import { generateApiKey } from './apiKey' + +const PlayerCreateTitle = () => { + const translate = useTranslate() + const resourceName = translate('resources.player.name', { smart_count: 1 }) + return ( + + ) +} + +const PlayerCreate = (props) => { + // Memoized so re-renders don't swap the key the user may have already copied + const initialValues = useMemo(() => ({ apiKey: generateApiKey() }), []) + return ( + <Create title={<PlayerCreateTitle />} {...props}> + <SimpleForm + variant="outlined" + redirect="list" + initialValues={initialValues} + > + {playerInputs()} + <ApiKeyInput source="apiKey" isCreate validate={required()} /> + </SimpleForm> + </Create> + ) +} + +export default PlayerCreate diff --git a/ui/src/player/PlayerCreate.test.jsx b/ui/src/player/PlayerCreate.test.jsx new file mode 100644 index 000000000..dc1f817db --- /dev/null +++ b/ui/src/player/PlayerCreate.test.jsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { render } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import PlayerCreate from './PlayerCreate' +import ApiKeyInput from './ApiKeyInput' + +const hooks = vi.hoisted(() => ({ forms: [] })) + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + Create: ({ children }) => children, + SimpleForm: (props) => { + hooks.forms.push(props) + return null + }, + } +}) + +describe('PlayerCreate', () => { + beforeEach(() => { + hooks.forms = [] + }) + + it('pre-fills one generated API key that survives re-renders', () => { + const { rerender } = render(<PlayerCreate resource="player" />) + rerender(<PlayerCreate resource="player" />) + + const [first, second] = hooks.forms.map((f) => f.initialValues) + expect(hooks.forms).toHaveLength(2) + expect(first.apiKey).toMatch(/^nds_[0-9A-Za-z]{22}$/) + expect(second).toBe(first) + }) + + it('requires the API key', () => { + render(<PlayerCreate resource="player" />) + + const input = React.Children.toArray(hooks.forms[0].children).find( + (child) => child.type === ApiKeyInput, + ) + expect(input.props.source).toBe('apiKey') + expect(input.props.isCreate).toBe(true) + expect(input.props.validate('')).toBeTruthy() + expect(input.props.validate('nds_0123456789abcdefghijkl')).toBeUndefined() + }) +}) diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index 1826500bd..08f389698 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -1,17 +1,17 @@ import { - TextInput, - BooleanInput, TextField, Edit, - required, SimpleForm, - SelectInput, - ReferenceInput, useTranslate, + DeleteButton, + DeleteWithConfirmButton, + SaveButton, + Toolbar, } from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' import { Title } from '../common' -import config from '../config' -import { BITRATE_CHOICES } from '../consts' +import ApiKeyInput from './ApiKeyInput' +import { playerInputs } from './playerInputs' const PlayerTitle = ({ record }) => { const translate = useTranslate() @@ -19,24 +19,35 @@ const PlayerTitle = ({ record }) => { return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> } +const useToolbarStyles = makeStyles({ + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}) + +const PlayerEditToolbar = (props) => ( + <Toolbar {...props} classes={useToolbarStyles()}> + <SaveButton /> + {props.record?.hasApiKey ? ( + <DeleteWithConfirmButton + mutationMode="pessimistic" + confirmTitle="resources.player.message.deleteWithKeyTitle" + confirmContent="resources.player.message.deleteWithKeyContent" + /> + ) : ( + <DeleteButton /> + )} + </Toolbar> +) + const PlayerEdit = (props) => ( - <Edit title={<PlayerTitle />} {...props}> - <SimpleForm variant={'outlined'}> - <TextInput source="name" validate={[required()]} /> - <ReferenceInput - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput source="name" resettable /> - </ReferenceInput> - <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> - <BooleanInput source="reportRealPath" fullWidth /> - {(config.lastFMEnabled || config.listenBrainzEnabled) && ( - <BooleanInput source="scrobbleEnabled" fullWidth /> - )} + <Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}> + <SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}> + {playerInputs()} <TextField source="client" /> <TextField source="userName" /> + <ApiKeyInput source="apiKey" /> </SimpleForm> </Edit> ) diff --git a/ui/src/player/PlayerEdit.test.jsx b/ui/src/player/PlayerEdit.test.jsx new file mode 100644 index 000000000..38fa2cc8e --- /dev/null +++ b/ui/src/player/PlayerEdit.test.jsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { render } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import PlayerEdit from './PlayerEdit' + +const hooks = vi.hoisted(() => ({ editProps: null })) + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + Edit: (props) => { + hooks.editProps = props + return null + }, + } +}) + +describe('PlayerEdit', () => { + // An optimistic or undoable save would put the new key in react-admin's cache + it('saves pessimistically', () => { + render(<PlayerEdit resource="player" id="p1" />) + expect(hooks.editProps.mutationMode).toBe('pessimistic') + }) +}) diff --git a/ui/src/player/PlayerList.jsx b/ui/src/player/PlayerList.jsx index a2b009bad..c7bd32570 100644 --- a/ui/src/player/PlayerList.jsx +++ b/ui/src/player/PlayerList.jsx @@ -2,18 +2,20 @@ import React from 'react' import { Datagrid, TextField, - DateField, FunctionField, ReferenceField, Filter, SearchInput, + NullableBooleanInput, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' -import { SimpleList, List } from '../common' +import { FaKey } from 'react-icons/fa' +import { SimpleList, List, DateField } from '../common' const PlayerFilter = (props) => ( <Filter {...props} variant={'outlined'}> <SearchInput id="search" source="name" alwaysOn /> + <NullableBooleanInput source="hasApiKey" alwaysOn /> </Filter> ) @@ -30,7 +32,11 @@ const PlayerList = ({ permissions, ...props }) => { <SimpleList primaryText={(r) => r.name} secondaryText={(r) => r.userName} - tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')} + tertiaryText={(r) => ( + <> + {r.hasApiKey && <FaKey />} {r.maxBitRate ? r.maxBitRate : '-'} + </> + )} /> ) : ( <Datagrid rowClick="edit"> @@ -43,6 +49,11 @@ const PlayerList = ({ permissions, ...props }) => { source="maxBitRate" render={(r) => (r.maxBitRate ? r.maxBitRate : '-')} /> + <FunctionField + source="hasApiKey" + sortable={false} + render={(r) => (r.hasApiKey ? <FaKey /> : null)} + /> <DateField source="lastSeen" showTime sortByOrder={'DESC'} /> </Datagrid> )} diff --git a/ui/src/player/apiKey.js b/ui/src/player/apiKey.js new file mode 100644 index 000000000..27d683052 --- /dev/null +++ b/ui/src/player/apiKey.js @@ -0,0 +1,15 @@ +const API_KEY_PREFIX = 'nds_' +const ALPHABET = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +const KEY_LENGTH = 22 + +// Bytes >= 248 are dropped so every character is equally likely (248 = 4 * 62). +export const generateApiKey = () => { + let key = '' + while (key.length < KEY_LENGTH) { + for (const b of window.crypto.getRandomValues(new Uint8Array(32))) { + if (b < 248 && key.length < KEY_LENGTH) key += ALPHABET[b % 62] + } + } + return API_KEY_PREFIX + key +} diff --git a/ui/src/player/apiKey.test.js b/ui/src/player/apiKey.test.js new file mode 100644 index 000000000..4c41871e7 --- /dev/null +++ b/ui/src/player/apiKey.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { generateApiKey } from './apiKey' + +describe('generateApiKey', () => { + it('returns the prefix plus 22 base62 characters', () => { + for (let i = 0; i < 50; i++) { + expect(generateApiKey()).toMatch(/^nds_[0-9A-Za-z]{22}$/) + } + }) + + it('returns a different key each time', () => { + const keys = new Set(Array.from({ length: 100 }, generateApiKey)) + expect(keys.size).toBe(100) + }) +}) diff --git a/ui/src/player/index.js b/ui/src/player/index.js index aaa3d58d7..c28cfa414 100644 --- a/ui/src/player/index.js +++ b/ui/src/player/index.js @@ -1,9 +1,11 @@ import { BsFillMusicPlayerFill } from 'react-icons/bs' import PlayerList from './PlayerList' import PlayerEdit from './PlayerEdit' +import PlayerCreate from './PlayerCreate' export default { list: PlayerList, edit: PlayerEdit, + create: PlayerCreate, icon: BsFillMusicPlayerFill, } diff --git a/ui/src/player/playerInputs.jsx b/ui/src/player/playerInputs.jsx new file mode 100644 index 000000000..a8b719a81 --- /dev/null +++ b/ui/src/player/playerInputs.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import { + BooleanInput, + ReferenceInput, + SelectInput, + TextInput, + required, +} from 'react-admin' +import config from '../config' +import { BITRATE_CHOICES } from '../consts' + +// Returned as an array, not a component, so SimpleForm still injects its props into each input. +export const playerInputs = () => + [ + <TextInput key="name" source="name" validate={[required()]} />, + <ReferenceInput + key="transcodingId" + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput>, + <SelectInput + key="maxBitRate" + source="maxBitRate" + resettable + choices={BITRATE_CHOICES} + />, + <BooleanInput key="reportRealPath" source="reportRealPath" fullWidth />, + (config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput key="scrobbleEnabled" source="scrobbleEnabled" fullWidth /> + ), + ].filter(Boolean) From ab5869123dad0d435bda3040849a9b9c0fc11888 Mon Sep 17 00:00:00 2001 From: DawidKrynski <dawid_krynski.64@wp.pl> Date: Mon, 28 Sep 2026 14:07:05 +0200 Subject: [PATCH 587/596] fix(playlists): include co-credited album artists when adding an artist to a playlist - #6240 (#6241) * fix(persistence): include co-credited album artists when adding an artist to a playlist - #6240 Signed-off-by: Dawid Krynski <188586034+DawidKrynski@users.noreply.github.com> * test(persistence): cover first album artist and track-artist-only in AddArtists The joint track now uses a track artist that is not an album artist, and the AddArtists specs check all three cases: the first album artist still matches, a co-credited album artist matches, and a track-artist-only ID adds nothing. The last case guards against widening the role filter. --------- Signed-off-by: Dawid Krynski <188586034+DawidKrynski@users.noreply.github.com> Co-authored-by: Dawid Krynski <188586034+DawidKrynski@users.noreply.github.com> Co-authored-by: Deluan <deluan@navidrome.org> --- persistence/playlist_track_repository.go | 4 +- persistence/playlist_track_repository_test.go | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 341aab8af..392446cef 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -234,7 +234,9 @@ func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []stri } func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) { - return r.addMediaFileIds(ctx, Eq{"album_artist_id": artistIds}) + // Match by album-artist participation, not the deprecated album_artist_id + // column, which only holds the first album artist. + return r.addMediaFileIds(ctx, ParticipantIDFilter("media_file", artistIds, model.RoleAlbumArtist)) } func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) { diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 3c532c405..119c57116 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -219,6 +219,45 @@ var _ = Describe("PlaylistTrackRepository", func() { }) }) + Describe("AddArtists", func() { + var tracks model.PlaylistTrackRepository + var joint model.MediaFile + + BeforeEach(func() { + mfRepo := NewMediaFileRepository(GetDBXBuilder()) + joint = mf(model.MediaFile{ID: "pls-coartist-track", Title: "Joint Track", ArtistID: artistPunctuation.ID, + Artist: artistPunctuation.Name, AlbumID: "pls-coartist-album", Album: "Joint Album", + AlbumArtistID: artistKraftwerk.ID, AlbumArtist: artistKraftwerk.Name, Path: p("joint/track.mp3")}) + joint.Participants[model.RoleAlbumArtist] = model.ParticipantList{ + {Artist: artistKraftwerk}, + {Artist: artistBeatles}, + } + Expect(mfRepo.Put(ctx, &joint)).To(Succeed()) + DeferCleanup(func() { _ = mfRepo.Delete(ctx, joint.ID) }) + + plsRepo := NewPlaylistRepository(GetDBXBuilder()) + pls := model.Playlist{Name: "Co-album-artist", OwnerID: adminUser.ID, OwnerName: adminUser.UserName} + Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { _ = plsRepo.Delete(ctx, pls.ID) }) + tracks = plsRepo.Tracks(ctx, pls.ID, false) + }) + + It("adds tracks where the artist is the first album artist", func() { + Expect(tracks.AddArtists(ctx, []string{artistKraftwerk.ID})).To(Equal(1)) + Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID)) + }) + + It("adds tracks where the artist is not the first album artist", func() { + Expect(tracks.AddArtists(ctx, []string{artistBeatles.ID})).To(Equal(1)) + Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID)) + }) + + It("does not add tracks where the artist is only the track artist", func() { + Expect(tracks.AddArtists(ctx, []string{artistPunctuation.ID})).To(Equal(0)) + Expect(tracks.GetMediaFileIDs(ctx)).To(BeEmpty()) + }) + }) + Describe("library access", func() { var otherLib model.Library var restrictedUser model.User From 612c8290f956c6a35a75c4adad6aa12f10da9c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 28 Sep 2026 08:48:36 -0400 Subject: [PATCH 588/596] feat(ui): show read-only values in edit forms as dimmed, themable inputs (#6238) --- ui/src/common/ReadOnlyFields.jsx | 99 +++++++++++++++ ui/src/common/ReadOnlyFields.test.jsx | 121 +++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/library/LibraryEdit.jsx | 166 +++++++------------------- ui/src/player/PlayerEdit.jsx | 7 +- ui/src/playlist/PlaylistEdit.jsx | 12 +- ui/src/radio/RadioEdit.jsx | 12 +- ui/src/share/ShareEdit.jsx | 31 +++-- ui/src/user/UserEdit.jsx | 11 +- ui/src/user/UserEdit.test.jsx | 6 +- 10 files changed, 306 insertions(+), 160 deletions(-) create mode 100644 ui/src/common/ReadOnlyFields.jsx create mode 100644 ui/src/common/ReadOnlyFields.test.jsx diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx new file mode 100644 index 000000000..c56643fbd --- /dev/null +++ b/ui/src/common/ReadOnlyFields.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import PropTypes from 'prop-types' +import get from 'lodash/get' +import { FieldTitle, useRecordContext } from 'react-admin' +import { TextField } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { useDateLocale } from '../i18n/useDateLocale' +import { + formatBytes, + formatDateTime, + formatDuration2, + formatNumber, +} from '../utils/formatters' +import { isDateSet } from '../utils/validations' + +const useStyles = makeStyles( + (theme) => ({ + inputRoot: { + '&:hover $notchedOutline': { + borderColor: theme.palette.divider, + }, + }, + notchedOutline: { + borderColor: theme.palette.divider, + }, + }), + { name: 'NDReadOnlyField' }, +) + +const identity = (v) => v + +// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form +export const ReadOnlyTextField = ({ + source, + label, + resource, + className, + fullWidth, + format = identity, + ...props +}) => { + const classes = useStyles(props) + const record = useRecordContext(props) + const value = get(record, source) + + return ( + <TextField + id={source} + className={className} + label={<FieldTitle label={label} source={source} resource={resource} />} + value={value == null ? '' : format(value)} + variant="outlined" + margin="dense" + fullWidth={fullWidth} + focused={false} + helperText=" " + InputProps={{ + readOnly: true, + classes: { + root: classes.inputRoot, + notchedOutline: classes.notchedOutline, + }, + }} + inputProps={{ tabIndex: -1 }} + /> + ) +} + +ReadOnlyTextField.propTypes = { + source: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + record: PropTypes.object, + resource: PropTypes.string, + className: PropTypes.string, + classes: PropTypes.object, + fullWidth: PropTypes.bool, + format: PropTypes.func, +} + +export const ReadOnlyDateField = (props) => { + const locale = useDateLocale() + const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '') + return <ReadOnlyTextField format={format} {...props} /> +} + +export const ReadOnlyNumberField = (props) => { + const locale = useDateLocale() + return ( + <ReadOnlyTextField format={(v) => formatNumber(v, locale)} {...props} /> + ) +} + +export const ReadOnlySizeField = (props) => ( + <ReadOnlyTextField format={formatBytes} {...props} /> +) + +export const ReadOnlyDurationField = (props) => ( + <ReadOnlyTextField format={formatDuration2} {...props} /> +) diff --git a/ui/src/common/ReadOnlyFields.test.jsx b/ui/src/common/ReadOnlyFields.test.jsx new file mode 100644 index 000000000..00543a786 --- /dev/null +++ b/ui/src/common/ReadOnlyFields.test.jsx @@ -0,0 +1,121 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + ReadOnlyDateField, + ReadOnlyDurationField, + ReadOnlyNumberField, + ReadOnlySizeField, + ReadOnlyTextField, +} from './ReadOnlyFields' + +vi.mock('react-admin', async (importOriginal) => ({ + ...(await importOriginal()), + useLocale: vi.fn(), +})) + +describe('ReadOnlyFields', () => { + const record = { + id: '1', + client: 'NavidromeUI', + createdAt: '2026-09-17T14:30:00Z', + lastVisitedAt: '0001-01-01T00:00:00Z', + count: 1234567, + zero: 0, + size: 1536000, + duration: 3725, + } + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue('de') + }) + + const renderField = (Field, props) => + render(<Field record={record} resource="player" {...props} />) + + describe('<ReadOnlyTextField>', () => { + it('shows the record value with the translated field label', () => { + renderField(ReadOnlyTextField, { source: 'client' }) + const input = screen.getByLabelText('resources.player.fields.client') + expect(input).toHaveValue('NavidromeUI') + }) + + it('cannot be edited or reached with the Tab key', () => { + renderField(ReadOnlyTextField, { source: 'client' }) + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('readonly') + expect(input).toHaveAttribute('tabindex', '-1') + }) + + it('shows an empty value when the record has no value', () => { + renderField(ReadOnlyTextField, { source: 'userName' }) + expect(screen.getByRole('textbox')).toHaveValue('') + }) + + it('uses an explicit label when given', () => { + renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' }) + expect(screen.getByLabelText('Custom')).toBeInTheDocument() + }) + + it('applies a custom format', () => { + renderField(ReadOnlyTextField, { + source: 'client', + format: (v) => v.toUpperCase(), + }) + expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI') + }) + + it('exposes theme-overridable class names', () => { + const { container } = renderField(ReadOnlyTextField, { + source: 'client', + }) + expect( + container.querySelector('[class*="NDReadOnlyField-inputRoot"]'), + ).toBeInTheDocument() + expect( + container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'), + ).toBeInTheDocument() + }) + }) + + describe('<ReadOnlyDateField>', () => { + it('formats the date using the selected language', () => { + renderField(ReadOnlyDateField, { source: 'createdAt' }) + expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /) + }) + + it('shows an empty value when the date is not set', () => { + renderField(ReadOnlyDateField, { source: 'lastVisitedAt' }) + expect(screen.getByRole('textbox')).toHaveValue('') + }) + }) + + describe('<ReadOnlyNumberField>', () => { + it('formats the number using the selected language', () => { + renderField(ReadOnlyNumberField, { source: 'count' }) + expect(screen.getByRole('textbox')).toHaveValue('1.234.567') + }) + + it('shows zero', () => { + renderField(ReadOnlyNumberField, { source: 'zero' }) + expect(screen.getByRole('textbox')).toHaveValue('0') + }) + }) + + describe('<ReadOnlySizeField>', () => { + it('formats bytes as a human-readable size', () => { + renderField(ReadOnlySizeField, { source: 'size' }) + expect(screen.getByRole('textbox')).toHaveValue('1.46 MB') + }) + }) + + describe('<ReadOnlyDurationField>', () => { + it('formats seconds as a human-readable duration', () => { + renderField(ReadOnlyDurationField, { source: 'duration' }) + expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s') + }) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 0177df326..fb8f40f00 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -15,6 +15,7 @@ export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' +export * from './ReadOnlyFields' export * from './ShuffleAllButton' export * from './SimpleList' export * from './SizeField' diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index 7e89c892c..53d17ac7f 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -6,7 +6,6 @@ import { BooleanInput, required, SaveButton, - DateField, useTranslate, useMutation, useNotify, @@ -16,8 +15,13 @@ import { import { Typography, Box } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import DeleteLibraryButton from './DeleteLibraryButton' -import { Title } from '../common' -import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js' +import { + ReadOnlyDateField, + ReadOnlyDurationField, + ReadOnlyNumberField, + ReadOnlySizeField, + Title, +} from '../common' const useStyles = makeStyles({ toolbar: { @@ -26,6 +30,8 @@ const useStyles = makeStyles({ }, }) +const readOnlyProps = { resource: 'library', fullWidth: true } + const LibraryTitle = ({ record }) => { const translate = useTranslate() const resourceName = translate('resources.library.name', { smart_count: 1 }) @@ -125,132 +131,40 @@ const LibraryEdit = (props) => { {translate('resources.library.sections.statistics')} </Typography> - <Box display="flex"> - <Box flex={1} mr="0.5em"> - <TextInput - InputProps={{ readOnly: true }} - resource={'library'} - source={'totalSongs'} - label={translate('resources.library.fields.totalSongs')} - fullWidth - variant="outlined" - /> - </Box> - <Box flex={1} ml="0.5em"> - <TextInput - InputProps={{ readOnly: true }} - resource={'library'} - source={'totalAlbums'} - label={translate( - 'resources.library.fields.totalAlbums', - )} - fullWidth - variant="outlined" - /> - </Box> - </Box> - - <Box display="flex"> - <Box flex={1} mr="0.5em"> - <TextInput - InputProps={{ readOnly: true }} - resource={'library'} - source={'totalArtists'} - label={translate( - 'resources.library.fields.totalArtists', - )} - fullWidth - variant="outlined" - /> - </Box> - <Box flex={1} ml="0.5em"> - <TextInput - InputProps={{ readOnly: true }} - resource={'library'} - source={'totalSize'} - label={translate('resources.library.fields.totalSize')} - format={(v) => formatBytes(v, 2)} - fullWidth - variant="outlined" - /> - </Box> - </Box> - - <Box display="flex"> - <Box flex={1} mr="0.5em"> - <TextInput - InputProps={{ readOnly: true }} - resource={'library'} - source={'totalDuration'} - label={translate( - 'resources.library.fields.totalDuration', - )} - format={formatDuration2} - fullWidth - variant="outlined" - /> - </Box> - <Box flex={1} ml="0.5em"> - <TextInput - InputProps={{ readOnly: true }} - resource={'library'} - source={'totalMissingFiles'} - label={translate( - 'resources.library.fields.totalMissingFiles', - )} - fullWidth - variant="outlined" - /> - </Box> - </Box> - - {/* Timestamps Section */} - <Box mb="1em"> - <Typography - variant="body2" - color="textSecondary" - gutterBottom - > - {translate('resources.library.fields.lastScanAt')} - </Typography> - <DateField - variant="body1" - source="lastScanAt" - showTime - record={formProps.record} + <Box + display="grid" + gridTemplateColumns="1fr 1fr" + gridColumnGap="1em" + > + <ReadOnlyNumberField + source="totalSongs" + {...readOnlyProps} /> - </Box> - - <Box mb="1em"> - <Typography - variant="body2" - color="textSecondary" - gutterBottom - > - {translate('resources.library.fields.updatedAt')} - </Typography> - <DateField - variant="body1" - source="updatedAt" - showTime - record={formProps.record} + <ReadOnlyNumberField + source="totalAlbums" + {...readOnlyProps} /> - </Box> - - <Box mb="2em"> - <Typography - variant="body2" - color="textSecondary" - gutterBottom - > - {translate('resources.library.fields.createdAt')} - </Typography> - <DateField - variant="body1" - source="createdAt" - showTime - record={formProps.record} + <ReadOnlyNumberField + source="totalArtists" + {...readOnlyProps} /> + <ReadOnlySizeField source="totalSize" {...readOnlyProps} /> + <ReadOnlyDurationField + source="totalDuration" + {...readOnlyProps} + /> + <ReadOnlyNumberField + source="totalMissingFiles" + {...readOnlyProps} + /> + <Box gridColumn="1 / -1"> + <ReadOnlyDateField + source="lastScanAt" + {...readOnlyProps} + /> + </Box> + <ReadOnlyDateField source="updatedAt" {...readOnlyProps} /> + <ReadOnlyDateField source="createdAt" {...readOnlyProps} /> </Box> </Box> </Box> diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index 08f389698..d785eb04e 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -1,5 +1,4 @@ import { - TextField, Edit, SimpleForm, useTranslate, @@ -9,7 +8,7 @@ import { Toolbar, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' -import { Title } from '../common' +import { ReadOnlyTextField, Title } from '../common' import ApiKeyInput from './ApiKeyInput' import { playerInputs } from './playerInputs' @@ -45,8 +44,8 @@ const PlayerEdit = (props) => ( <Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}> <SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}> {playerInputs()} - <TextField source="client" /> - <TextField source="userName" /> + <ReadOnlyTextField source="client" /> + <ReadOnlyTextField source="userName" /> <ApiKeyInput source="apiKey" /> </SimpleForm> </Edit> diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx index f6882e366..c7594fa13 100644 --- a/ui/src/playlist/PlaylistEdit.jsx +++ b/ui/src/playlist/PlaylistEdit.jsx @@ -3,7 +3,6 @@ import { FormDataConsumer, SimpleForm, TextInput, - TextField, BooleanInput, required, useTranslate, @@ -11,13 +10,14 @@ import { ReferenceInput, SelectInput, } from 'react-admin' -import { isWritable, Title } from '../common' +import { isWritable, ReadOnlyTextField, Title } from '../common' const SyncFragment = ({ formData, variant, ...rest }) => { + if (!formData.path) return null return ( <> - {formData.path && <BooleanInput source="sync" {...rest} />} - {formData.path && <TextField source="path" {...rest} />} + <BooleanInput source="sync" {...rest} /> + <ReadOnlyTextField source="path" {...rest} /> </> ) } @@ -56,10 +56,10 @@ const PlaylistEditForm = (props) => { /> </ReferenceInput> ) : ( - <TextField source="ownerName" /> + <ReadOnlyTextField source="ownerName" /> )} <BooleanInput source="public" disabled={!isWritable(record.ownerId)} /> - <FormDataConsumer> + <FormDataConsumer fullWidth> {(formDataProps) => <SyncFragment {...formDataProps} />} </FormDataConsumer> </SimpleForm> diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index bbe001e6f..af879deaa 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -1,5 +1,4 @@ import { - DateField, Edit, required, SimpleForm, @@ -9,7 +8,12 @@ import { import { CardMedia } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' -import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' +import { + Title, + ImageUploadOverlay, + ReadOnlyDateField, + useImageLoadingState, +} from '../common' import subsonic from '../subsonic' import config from '../config' import { RADIO_PLACEHOLDER_IMAGE } from '../consts' @@ -65,8 +69,8 @@ const RadioEdit = (props) => { fullWidth validate={[urlValidate]} /> - <DateField variant="body1" source="updatedAt" showTime /> - <DateField variant="body1" source="createdAt" showTime /> + <ReadOnlyDateField source="updatedAt" /> + <ReadOnlyDateField source="createdAt" /> </SimpleForm> </Edit> ) diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx index 2cf7f2df7..a222d3369 100644 --- a/ui/src/share/ShareEdit.jsx +++ b/ui/src/share/ShareEdit.jsx @@ -2,13 +2,16 @@ import { DateTimeInput, BooleanInput, Edit, - NumberField, SimpleForm, TextInput, } from 'react-admin' import { sharePlayerUrl } from '../utils' import { Link } from '@material-ui/core' -import { DateField } from '../common' +import { + ReadOnlyDateField, + ReadOnlyNumberField, + ReadOnlyTextField, +} from '../common' import config from '../config' export const ShareEdit = (props) => { @@ -16,20 +19,26 @@ export const ShareEdit = (props) => { const url = sharePlayerUrl(id) return ( <Edit {...props}> - <SimpleForm {...rest}> - <Link source="URL" href={url} target="_blank" rel="noopener noreferrer"> + <SimpleForm variant={'outlined'} {...rest}> + <Link + source="URL" + href={url} + target="_blank" + rel="noopener noreferrer" + variant="inherit" + > {url} </Link> <TextInput source="description" /> {config.enableDownloads && <BooleanInput source="downloadable" />} <DateTimeInput source="expiresAt" /> - <TextInput source="contents" disabled /> - <TextInput source="format" disabled /> - <TextInput source="maxBitRate" disabled /> - <TextInput source="username" disabled /> - <NumberField source="visitCount" disabled /> - <DateField source="lastVisitedAt" disabled showTime /> - <DateField source="createdAt" disabled showTime /> + <ReadOnlyTextField source="contents" /> + <ReadOnlyTextField source="format" /> + <ReadOnlyTextField source="maxBitRate" /> + <ReadOnlyTextField source="username" /> + <ReadOnlyNumberField source="visitCount" /> + <ReadOnlyDateField source="lastVisitedAt" /> + <ReadOnlyDateField source="createdAt" /> </SimpleForm> </Edit> ) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index c5d9c75a4..feadafff1 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -3,7 +3,6 @@ import { makeStyles } from '@material-ui/core/styles' import { TextInput, BooleanInput, - DateField, PasswordInput, Edit, required, @@ -21,7 +20,7 @@ import { useRecordContext, } from 'react-admin' import { Typography } from '@material-ui/core' -import { Title } from '../common' +import { ReadOnlyDateField, Title } from '../common' import DeleteUserButton from './DeleteUserButton' import { LibrarySelectionField } from './LibrarySelectionField.jsx' import { validateUserForm } from './userValidation' @@ -183,10 +182,10 @@ const UserEdit = (props) => { helperText={translate('resources.user.helperTexts.scrobbleFilter')} /> - <DateField variant="body1" source="lastLoginAt" showTime /> - <DateField variant="body1" source="lastAccessAt" showTime /> - <DateField variant="body1" source="updatedAt" showTime /> - <DateField variant="body1" source="createdAt" showTime /> + <ReadOnlyDateField source="lastLoginAt" /> + <ReadOnlyDateField source="lastAccessAt" /> + <ReadOnlyDateField source="updatedAt" /> + <ReadOnlyDateField source="createdAt" /> </SimpleForm> </Edit> ) diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 74405cc13..837b25ad6 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -51,9 +51,6 @@ vi.mock('react-admin', () => ({ BooleanInput: ({ source }) => ( <input type="checkbox" data-testid={`boolean-input-${source}`} /> ), - DateField: ({ source }) => ( - <div data-testid={`date-field-${source}`}>Date</div> - ), PasswordInput: ({ source }) => ( <input type="password" data-testid={`password-input-${source}`} /> ), @@ -82,6 +79,9 @@ vi.mock('./DeleteUserButton', () => ({ vi.mock('../common', () => ({ Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>, + ReadOnlyDateField: ({ source }) => ( + <div data-testid={`date-field-${source}`}>Date</div> + ), })) // Mock Material-UI From a62d1629f0ff56208460f17aa407b44a41569f0c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <mvanhorn@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:06:44 -0700 Subject: [PATCH 589/596] fix(ui): honor EnableCoverAnimation for theme cover animations (#6234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: honor cover animation setting in Squiddies Glass Fixes #5170 * fix(ui): move cover animation check into AlbumDetails Apply a noCoverAnimation class from AlbumDetails when enableCoverAnimation is off, so every theme gets the fix. Drop the Squiddies Glass theme changes and its test, and cover the class in AlbumDetails.test.jsx. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/src/album/AlbumDetails.jsx | 11 ++++- ui/src/album/AlbumDetails.test.jsx | 70 +++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 7fbc479db..8d636a047 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -16,6 +16,7 @@ import { useRecordContext, useTranslate, } from 'react-admin' +import clsx from 'clsx' import Lightbox from 'react-image-lightbox' import config from '../config' import 'react-image-lightbox/style.css' @@ -79,6 +80,9 @@ const useStyles = makeStyles( alignItems: 'center', justifyContent: 'center', }, + noCoverAnimation: { + '&, &::before, &::after': { animation: 'none' }, + }, cover: { objectFit: 'contain', cursor: 'pointer', @@ -250,7 +254,12 @@ const AlbumDetails = (props) => { return ( <Card className={classes.root}> <div className={classes.cardContents}> - <div className={classes.coverParent}> + <div + className={clsx( + classes.coverParent, + !config.enableCoverAnimation && classes.noCoverAnimation, + )} + > <Artwork record={record} fit="contain" diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx index 484045444..afc4bf0a0 100644 --- a/ui/src/album/AlbumDetails.test.jsx +++ b/ui/src/album/AlbumDetails.test.jsx @@ -3,7 +3,29 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest' import { render } from '@testing-library/react' import { RecordContextProvider } from 'react-admin' import { useMediaQuery } from '@material-ui/core' -import { Details } from './AlbumDetails' +import { createTheme, ThemeProvider } from '@material-ui/core/styles' +import config from '../config' +import AlbumDetails, { Details } from './AlbumDetails' + +vi.mock('../subsonic', () => ({ + default: { + getAlbumInfo: () => + Promise.resolve({ + json: { 'subsonic-response': { status: 'ok', albumInfo: {} } }, + }), + getCoverArtUrl: () => '', + }, +})) + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useDataProvider: () => ({ getOne: vi.fn() }), + useNotify: () => vi.fn(), + useRefresh: () => vi.fn(), + } +}) // Mock useMediaQuery vi.mock('@material-ui/core', async () => { @@ -343,3 +365,49 @@ describe('Details component', () => { }) }) }) + +describe('AlbumDetails cover animation', () => { + const albumRecord = { + id: '123', + name: 'Test Album', + songCount: 12, + duration: 3600, + size: 102400, + } + const originalEnableCoverAnimation = config.enableCoverAnimation + + beforeEach(() => { + vi.mocked(useMediaQuery).mockReturnValue(false) + }) + + afterEach(() => { + config.enableCoverAnimation = originalEnableCoverAnimation + }) + + const renderAlbum = () => + render( + <ThemeProvider theme={createTheme()}> + <RecordContextProvider value={albumRecord}> + <AlbumDetails /> + </RecordContextProvider> + </ThemeProvider>, + ) + + test('applies noCoverAnimation when enableCoverAnimation is false', () => { + config.enableCoverAnimation = false + const { container } = renderAlbum() + const cover = container.querySelector('[class*="coverParent"]') + + expect(cover).not.toBeNull() + expect(cover.className).toMatch(/noCoverAnimation/) + }) + + test('omits noCoverAnimation when enableCoverAnimation is true', () => { + config.enableCoverAnimation = true + const { container } = renderAlbum() + const cover = container.querySelector('[class*="coverParent"]') + + expect(cover).not.toBeNull() + expect(cover.className).not.toMatch(/noCoverAnimation/) + }) +}) From 3a31f702b529fe3d9843894e024d3a0316c11650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Dav=C3=B3?= <david@ddavo.me> Date: Tue, 29 Sep 2026 04:50:13 +0200 Subject: [PATCH 590/596] feat(ui): add played filter to album list (#6207) --- persistence/album_repository.go | 1 + persistence/album_repository_test.go | 74 ++++++++++++++++++++++++++++ persistence/sql_annotations_test.go | 2 + ui/src/album/AlbumList.jsx | 1 + ui/src/i18n/en.json | 3 +- 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 8e7662b8f..6a940a320 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -131,6 +131,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { "recently_played": recentlyPlayedFilter, "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), + "played": annotationBoolFilter("play_count"), "missing": booleanFilter, "genre_id": genreFilter(AlbumGenres), "role_total_id": allRolesFilter, diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 7652e703d..0235805c0 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -417,6 +417,80 @@ var _ = Describe("AlbumRepository", func() { } }) }) + + Describe("played", func() { + var playedAlbum model.Album + + BeforeEach(func() { + playedAlbum = model.Album{ID: "played-album", Name: "Played Album", LibraryID: 1, SongCount: 1} + Expect(albumRepo.Put(ctx, &playedAlbum)).To(Succeed()) + Expect(albumRepo.IncPlayCount(ctx, playedAlbum.ID, time.Now())).To(Succeed()) + }) + + AfterEach(func() { + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": playedAlbum.ID})) + _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": playedAlbum.ID})) + }) + + It("false includes items without annotations", func() { + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + Filters: map[string]any{"played": "false"}, + }) + Expect(err).ToNot(HaveOccurred()) + albums := res + + var found bool + for _, a := range albums { + if a.ID == albumWithoutAnnotation.ID { + found = true + break + } + } + Expect(found).To(BeTrue(), "Album without annotation should be included in played=false filter") + }) + + It("true excludes items without annotations", func() { + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + Filters: map[string]any{"played": "true"}, + }) + Expect(err).ToNot(HaveOccurred()) + albums := res + + for _, a := range albums { + Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) + } + }) + + It("true includes items with play count", func() { + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + Filters: map[string]any{"played": "true"}, + }) + Expect(err).ToNot(HaveOccurred()) + albums := res + + var found bool + for _, a := range albums { + if a.ID == playedAlbum.ID { + found = true + Expect(a.PlayCount).To(BeNumerically(">", 0)) + break + } + } + Expect(found).To(BeTrue(), "Album with play count should be included in played=true filter") + }) + + It("false excludes items with play count", func() { + res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + Filters: map[string]any{"played": "false"}, + }) + Expect(err).ToNot(HaveOccurred()) + albums := res + + for _, a := range albums { + Expect(a.ID).ToNot(Equal(playedAlbum.ID)) + } + }) + }) }) Describe("Album.PlayCount", func() { diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index ec28c610a..6761331d9 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -91,6 +91,8 @@ var _ = Describe("Annotation Filters", func() { Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)), Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)), Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)), + Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)), + Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)), ) It("returns nil if value is not a string", func() { diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 3cb8a648e..81e4dcd14 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -163,6 +163,7 @@ const AlbumFilter = (props) => { /> </ReferenceInput> <NullableBooleanInput source="compilation" /> + <NullableBooleanInput source="played" defaultValue={false} /> <NumberInput source="year" /> {config.enableFavourites && ( <NullableBooleanInput diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 851b47ee3..a04b6e311 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -83,7 +83,8 @@ "grouping": "Grouping", "media": "Media", "mood": "Mood", - "missing": "Missing" + "missing": "Missing", + "played": "Played" }, "actions": { "playAll": "Play", From e03ce871773c434b95ec2a623243c6397df160f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 29 Sep 2026 11:21:14 -0400 Subject: [PATCH 591/596] fix(ui): make Undo and AMusic Save buttons readable (#6249) --- ui/src/layout/Notification.jsx | 25 ++++++++++++++++++++----- ui/src/themes/amusic.js | 15 +++++++++++++++ ui/src/themes/nuclear.js | 7 +++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/ui/src/layout/Notification.jsx b/ui/src/layout/Notification.jsx index 001d3fc01..ff641ec22 100644 --- a/ui/src/layout/Notification.jsx +++ b/ui/src/layout/Notification.jsx @@ -1,11 +1,26 @@ import React from 'react' import { Notification as RANotification } from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' -const Notification = (props) => ( - <RANotification - {...props} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - /> +// RA's primary.light Undo is unreadable on the light snackbar of dark themes +const useStyles = makeStyles( + { + undo: { + color: 'inherit', + }, + }, + { name: 'NDNotification' }, ) +const Notification = (props) => { + const classes = useStyles() + return ( + <RANotification + {...props} + classes={{ undo: classes.undo }} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + /> + ) +} + export default Notification diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index 55205baf3..eef7f90d6 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -79,6 +79,16 @@ export default { color: '#eee', backgroundColor: '#ff4e6b', }, + containedPrimary: { + color: '#fff', + backgroundColor: '#D60017', + '&:hover': { + backgroundColor: '#a30011', + '@media (hover: none)': { + backgroundColor: '#D60017', + }, + }, + }, textSizeSmall: { fontSize: '0.8rem', paddingRight: '0.5rem', @@ -192,6 +202,11 @@ export default { paddingBottom: '1rem', }, }, + NDNotification: { + undo: { + color: '#fff', + }, + }, RaConfirm: { confirmPrimary: { color: '#fff', diff --git a/ui/src/themes/nuclear.js b/ui/src/themes/nuclear.js index b34896c97..bf804139e 100644 --- a/ui/src/themes/nuclear.js +++ b/ui/src/themes/nuclear.js @@ -86,6 +86,13 @@ export default { }, }, }, + NDNotification: { + undo: { + '& .MuiButton-label': { + color: 'inherit', + }, + }, + }, MuiChip: { root: { backgroundColor: nukeCol['accent'], From 0e1893530b844898cbf87825fdefd4b13e1ff3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 29 Sep 2026 13:23:36 -0400 Subject: [PATCH 592/596] fix(ui): don't crash the playlist list when rows lose their record (#6250) * fix(ui): don't crash playlist list rows that lost their record react-admin 3 evicts records fetched more than 10 minutes ago whenever another getList for the same resource completes, but the list keeps its cached ids. The Datagrid then renders those rows with an undefined record, and the Public and Auto-import switches crashed reading record.id. This happened when the playlist list was left open and the sidebar or the add to playlist dialog reloaded a smaller set of playlists. Both switches now render nothing when the row has no record; the next list refresh fills the row in again. * refactor(ui): merge playlist list toggles into one ToggleField The Public and Auto-import switches were copies that differed only in the field they flip. ToggleField now flips its source field, and ToggleAutoImport just shows it for playlists that have a file path. The tests render inside TestContext, so they use react-admin's real hooks instead of mocks. --- ui/src/playlist/PlaylistList.jsx | 46 ++++++--------------------- ui/src/playlist/PlaylistList.test.jsx | 25 ++++++++++++++- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index d2b17b108..14d819a4e 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -67,15 +67,15 @@ const PlaylistFilter = (props) => { ) } -const TogglePublicInput = ({ resource, source }) => { +export const ToggleField = ({ resource, source }) => { const record = useRecordContext() const notify = useNotify() - const [togglePublic] = useUpdate( + const [toggle] = useUpdate( resource, - record.id, + record?.id, { ...record, - public: !record.public, + [source]: !record?.[source], }, { undoable: false, @@ -86,10 +86,12 @@ const TogglePublicInput = ({ resource, source }) => { ) const handleClick = (e) => { - togglePublic() + toggle() e.stopPropagation() } + if (!record) return null + return ( <Switch checked={record[source]} @@ -99,35 +101,9 @@ const TogglePublicInput = ({ resource, source }) => { ) } -const ToggleAutoImport = ({ resource, source }) => { +export const ToggleAutoImport = (props) => { const record = useRecordContext() - const notify = useNotify() - const [ToggleAutoImport] = useUpdate( - resource, - record.id, - { - ...record, - sync: !record.sync, - }, - { - undoable: false, - onFailure: (error) => { - notify('ra.page.error', 'warning') - }, - }, - ) - const handleClick = (e) => { - ToggleAutoImport() - e.stopPropagation() - } - - return record.path ? ( - <Switch - checked={record[source]} - onClick={handleClick} - disabled={!isWritable(record.ownerId)} - /> - ) : null + return record?.path ? <ToggleField {...props} /> : null } const PlaylistListBulkActions = (props) => { @@ -169,9 +145,7 @@ const PlaylistList = (props) => { updatedAt: isDesktop && ( <DateField source="updatedAt" sortByOrder={'DESC'} /> ), - public: !isXsmall && ( - <TogglePublicInput source="public" sortByOrder={'DESC'} /> - ), + public: !isXsmall && <ToggleField source="public" sortByOrder={'DESC'} />, comment: <TextField source="comment" />, sync: !isXsmall && ( <ToggleAutoImport source="sync" sortByOrder={'DESC'} /> diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 4fbc6d516..6c714b827 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -1,7 +1,8 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' -import { PlaylistLove } from './PlaylistList' +import { TestContext } from 'ra-test' +import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' vi.mock('../config', () => ({ default: { enableFavourites: true }, @@ -32,3 +33,25 @@ describe('<PlaylistLove />', () => { }) }) }) + +// react-admin evicts records older than 10 minutes while the list still holds +// their ids, so rows can render with no record. +describe('playlist toggles without a record', () => { + it('<ToggleField /> renders nothing', () => { + const { container } = render( + <TestContext> + <ToggleField resource="playlist" source="public" /> + </TestContext>, + ) + expect(container.innerHTML).toBe('') + }) + + it('<ToggleAutoImport /> renders nothing', () => { + const { container } = render( + <TestContext> + <ToggleAutoImport resource="playlist" source="sync" /> + </TestContext>, + ) + expect(container.innerHTML).toBe('') + }) +}) From 758e64c9996e6aa01e8428fb587292873f9f181f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 2 Oct 2026 05:05:32 -0400 Subject: [PATCH 593/596] feat(scanner): per-library PID configuration (#6252) * feat(model): add per-library PID config columns * refactor(metadata): pass PID config to ToMediaFile and add spec validation * feat(scanner): rescan only libraries whose PID config changed * feat(server): validate library PID config and rescan on change * feat(ui): edit per-library PID config * fix(ui): label the PID mode selects * fix: tighten per-library PID rescan edge cases An interrupted PID rescan no longer upgrades every library to a full scan, a save that loses the race for the scanner logs at debug, the confirm dialog only shows when the effective PID spec changes, and it now gets translation keys. * refactor(metadata): pass the library to ToMediaFile ToMediaFile and core.Inspect took the library ID and its PID config as separate arguments, so a caller could mix values from two libraries. They now take the model.Library and resolve the effective PID config from it. * chore: tidy per-library PID comments, PropTypes and migration Trim comments that restated the code, add PropTypes to the new UI components, and recreate the migration with make migration-sql. * fix(ui): show the PID spec help under its input * feat(cmd): make inspect use the file's library PID config inspect always used the global PID config, so it showed different IDs than the scanner for files in a library with an override. It now finds the file's library in the DB and uses its effective config, falling back to the global config when there is no DB or the file is outside every library. It never creates a DB. The library path matcher moves from core/playlists to model so both can use it. * refactor: simplify per-library PID code Share the DB-file check between CLI commands, move ErrAlreadyScanning to model so core no longer imports scanner, read the libraries once for insights, and let ValidatePIDSpec accept an empty spec and look tags up directly. In the scanner, use FullScanInProgress instead of a second flag, and skip recomputing album IDs when the album spec did not change. In the UI, share the PID inputs between Create and Edit, and use docsUrl. * feat(ui): add section titles to Library Create and pre-fill Custom PID specs Custom now starts from the global spec, so admins edit a working spec instead of typing one from scratch. * fix(inspect): map files with the library-relative path the scanner uses Inspect gave metadata the file's directory as typed, so folder-based PIDs never matched the DB. It now uses the path relative to the library root, through the scanner's helper, which moves to model. * fix(scanner): say when a PID rescan only covers target folders * fix: reject tag aliases in album PID specs and match root libraries Tags are stored under canonical names, so an alias in a spec always reads as empty. In an album spec that gives every album the same ID, so album specs now require the tag name. Track specs keep accepting aliases, since the default one uses them. LibraryMatcher now matches paths under a library at the filesystem root. * refactor(model): move the tag alias lookup to tag_mappings.go * test: run the library matcher and inspect tests on Windows Build test paths with filepath instead of Unix literals, so they use the OS separator like filepath.Abs output, and drop the Windows skips. * feat(ui): add pt-BR translations for per-library PID settings --- adapters/gotaglib/end_to_end_test.go | 2 +- cmd/inspect.go | 46 +++- cmd/inspect_test.go | 61 +++++ cmd/root.go | 28 ++- cmd/root_test.go | 30 +++ cmd/utils.go | 13 +- consts/consts.go | 2 - core/inspect.go | 16 +- core/inspect_test.go | 45 ++++ core/library.go | 54 ++++- core/library_test.go | 73 ++++++ core/metrics/insights.go | 9 +- core/playlists/import.go | 4 +- core/playlists/parse_m3u.go | 70 +----- core/playlists/parse_m3u_test.go | 181 -------------- ...20260929221042_add_library_pid_columns.sql | 18 ++ model/library.go | 37 +++ model/library_matcher.go | 57 +++++ model/library_matcher_test.go | 91 +++++++ model/library_test.go | 73 ++++++ model/metadata/map_mediafile.go | 10 +- model/metadata/map_mediafile_test.go | 16 +- model/metadata/map_participants_test.go | 2 +- model/metadata/metadata_test.go | 2 +- model/metadata/persistent_ids.go | 55 ++++- model/metadata/persistent_ids_test.go | 54 ++++- model/scanner.go | 3 + model/tag_mappings.go | 22 ++ model/tag_mappings_test.go | 19 ++ persistence/library_repository.go | 11 + persistence/library_repository_test.go | 32 +++ resources/i18n/pt-br.json | 19 +- scanner/controller.go | 8 +- scanner/controller_test.go | 12 +- scanner/phase_1_folders.go | 73 +++--- scanner/scanner.go | 55 ++--- scanner/scanner_internal_test.go | 39 --- scanner/scanner_multilibrary_test.go | 166 +++++++++++++ server/nativeapi/inspect.go | 7 +- server/serve_index.go | 2 + server/serve_index_test.go | 2 + tests/mock_library_repo.go | 11 + ui/src/config.js | 2 + ui/src/i18n/en.json | 19 +- ui/src/library/LibraryCreate.jsx | 31 +++ ui/src/library/LibraryEdit.jsx | 222 +++++++++++------- ui/src/library/LibraryEdit.test.jsx | 125 ++++++++++ ui/src/library/PIDInput.jsx | 114 +++++++++ ui/src/library/pidPresets.js | 33 +++ ui/src/library/pidPresets.test.js | 65 +++++ 50 files changed, 1626 insertions(+), 515 deletions(-) create mode 100644 cmd/inspect_test.go create mode 100644 core/inspect_test.go create mode 100644 db/migrations/20260929221042_add_library_pid_columns.sql create mode 100644 model/library_matcher.go create mode 100644 model/library_matcher_test.go create mode 100644 model/library_test.go create mode 100644 ui/src/library/LibraryEdit.test.jsx create mode 100644 ui/src/library/PIDInput.jsx create mode 100644 ui/src/library/pidPresets.js create mode 100644 ui/src/library/pidPresets.test.js diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index e7dd18ac1..0f9a90d94 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - return new(metadata.ToMediaFile(1, "folderID")) + return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID")) } BeforeEach(func() { diff --git a/cmd/inspect.go b/cmd/inspect.go index 5e88793cc..05f569f3e 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -1,13 +1,17 @@ package cmd import ( + "context" "encoding/json" "fmt" + "path/filepath" "strings" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -28,7 +32,7 @@ var inspectCmd = &cobra.Command{ Long: "Show file tags as seen by Navidrome", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - runInspector(args) + runInspector(cmd.Context(), args) }, } @@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) { return []byte(res.String()), nil } -func runInspector(args []string) { +func runInspector(ctx context.Context, args []string) { marshal := marshalers[format] if marshal == nil { log.Fatal("Invalid format", "format", format) } + libs := loadLibraries(ctx) + matcher := model.NewLibraryMatcher(libs) var out []core.InspectOutput for _, filePath := range args { if !model.IsAudioFile(filePath) { log.Warn("Not an audio file", "file", filePath) continue } - output, err := core.Inspect(filePath, 1, "") + lib, ok := libraryForFile(matcher, filePath) + if !ok && len(libs) > 0 { + log.Warn("File is not in any library, using the global PID config", "file", filePath) + } + output, err := core.Inspect(filePath, lib, "") if err != nil { log.Warn("Unable to process file", "file", filePath, "error", err) continue @@ -77,3 +87,33 @@ func runInspector(args []string) { data, _ := marshal(out) fmt.Println(string(data)) } + +// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB. +func loadLibraries(ctx context.Context) model.Libraries { + if dbFile, ok := existingDBFile(); !ok { + log.Warn(ctx, "No database found, using the global PID config", "path", dbFile) + return nil + } + defer db.Init(ctx)() + libs, err := persistence.New(db.Db()).Library().GetAll(ctx) + if err != nil { + log.Warn(ctx, "Could not load libraries, using the global PID config", err) + return nil + } + for i := range libs { + if absPath, err := filepath.Abs(libs[i].Path); err == nil { + libs[i].Path = absPath + } + } + return libs +} + +// libraryForFile falls back to the default library with no overrides, which uses the global PID config. +func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) { + if absPath, err := filepath.Abs(filePath); err == nil { + if lib, ok := matcher.FindLibrary(absPath); ok { + return lib, true + } + } + return model.Library{ID: model.DefaultLibraryID}, false +} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go new file mode 100644 index 000000000..728dc8770 --- /dev/null +++ b/cmd/inspect_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("inspect", func() { + Describe("libraryForFile", func() { + var matcher *model.LibraryMatcher + var root string + + BeforeEach(func() { + root = GinkgoT().TempDir() + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + matcher = model.NewLibraryMatcher(model.Libraries{ + {ID: 1, Path: filepath.Join(root, "music")}, + {ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"}, + }) + }) + + It("returns the library that contains an absolute path", func() { + lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3")) + Expect(ok).To(BeTrue()) + Expect(lib.ID).To(Equal(1)) + }) + + It("resolves a relative path against the working directory", func() { + lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3")) + Expect(ok).To(BeTrue()) + Expect(lib.PIDAlbum).To(Equal("folder")) + }) + + It("falls back to the default library without overrides", func() { + lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3")) + Expect(ok).To(BeFalse()) + Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID})) + }) + }) + + Describe("loadLibraries", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does not create a database when there is none", func() { + dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db") + conf.Server.DbPath = dbFile + "?_journal_mode=WAL" + + Expect(loadLibraries(GinkgoT().Context())).To(BeNil()) + Expect(dbFile).ToNot(BeAnExistingFile()) + }) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index 089f09472..e39c55365 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -190,16 +190,20 @@ func schedulePeriodicScan(ctx context.Context) func() error { } } -func pidHashChanged(ds model.DataStore) (bool, error) { - pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "") +// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from +// the one used by their last finished scan +func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) { + libs, err := ds.Library().GetAll(ctx) if err != nil { - return false, err + return nil, err } - pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "") - if err != nil { - return false, err + var names []string + for _, lib := range libs { + if lib.PIDChanged() { + names = append(names, lib.Name) + } } - return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil + return names, nil } // runInitialScan runs an initial scan of the music library if needed. @@ -214,12 +218,12 @@ func runInitialScan(ctx context.Context) func() error { if err != nil { return err } - pidHasChanged, err := pidHashChanged(ds) + pidChangedLibs, err := librariesWithChangedPID(ctx, ds) if err != nil { return err } scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup - scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged + scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0 time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { s := CreateScanner(ctx) @@ -227,9 +231,9 @@ func runInitialScan(ctx context.Context) func() error { case fullScanRequired == "1": log.Warn(ctx, "Full scan required after migration") _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) - case pidHasChanged: - log.Warn(ctx, "PID config changed, performing full scan") - fullScanRequired = "1" + case len(pidChangedLibs) > 0: + // Includes never-scanned libraries. The scanner rescans in full only the ones that need it + log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs) case inProgress: log.Warn(ctx, "Resuming interrupted scan") default: diff --git a/cmd/root_test.go b/cmd/root_test.go index af8d44e7e..423cd2a8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "net/http" "net/http/httptest" "path" @@ -9,6 +10,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -44,3 +47,30 @@ var _ = Describe("profilerHandler", func() { Entry("with a trailing-slash BasePath", "/music/"), ) }) + +var _ = Describe("librariesWithChangedPID", func() { + var ds *tests.MockDataStore + var libs *tests.MockLibraryRepo + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + libs = &tests.MockLibraryRepo{} + ds = &tests.MockDataStore{MockedLibrary: libs} + }) + + It("returns only the libraries whose PID config changed", func() { + pid := model.Library{}.EffectivePID() + libs.SetData(model.Libraries{ + {ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 3, Name: "Never scanned"}, + }) + Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned")) + }) + + It("returns the error from the repository", func() { + libs.Err = errors.New("db down") + _, err := librariesWithChangedPID(GinkgoT().Context(), ds) + Expect(err).To(MatchError("db down")) + }) +}) diff --git a/cmd/utils.go b/cmd/utils.go index 72ec67f90..f35a31fb1 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -18,11 +18,16 @@ import ( "github.com/navidrome/navidrome/persistence" ) -// requireExistingDB aborts the command when the database file (DbPath minus DSN -// params) does not exist. -func requireExistingDB() { +// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. +func existingDBFile() (string, bool) { path, _, _ := strings.Cut(conf.Server.DbPath, "?") - if _, err := os.Stat(path); os.IsNotExist(err) { + _, err := os.Stat(path) + return path, err == nil +} + +// requireExistingDB aborts the command when the database file does not exist. +func requireExistingDB() { + if path, ok := existingDBFile(); !ok { log.Fatal("No existing database", "path", path) } } diff --git a/consts/consts.go b/consts/consts.go index 9bdac9125..42e9ec42f 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -156,8 +156,6 @@ const ( //DefaultAlbumPID = "album_legacy" DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" - PIDAlbumKey = "PIDAlbum" - PIDTrackKey = "PIDTrack" ) const ( diff --git a/core/inspect.go b/core/inspect.go index 01ec33760..c60459b88 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -15,7 +15,7 @@ type InspectOutput struct { MappedTags *model.MediaFile `json:"mappedTags,omitempty"` } -func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) { +func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) { path, file := filepath.Split(filePath) s, err := storage.For(path) @@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e return nil, model.ErrNotFound } - md := metadata.New(path, tag) + md := metadata.New(scannerPath(lib, filePath), tag) result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: new(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(lib, folderId)), } return result, nil } + +// scannerPath returns the path the scanner uses for the file (relative to its library), so +// folder-based PIDs match the DB. Files outside the library keep their absolute path. +func scannerPath(lib model.Library, filePath string) string { + absPath, err := filepath.Abs(filePath) + if err != nil || lib.Path == "" { + return filePath + } + return model.LibraryRelativePath(lib.Path, absPath) +} diff --git a/core/inspect_test.go b/core/inspect_test.go new file mode 100644 index 000000000..0ac90990c --- /dev/null +++ b/core/inspect_test.go @@ -0,0 +1,45 @@ +package core_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Inspect", func() { + var fixtures string + + BeforeEach(func() { + var err error + fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures")) + Expect(err).ToNot(HaveOccurred()) + }) + + It("maps the file with the library-relative path the scanner uses", func() { + lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} + out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3")) + Expect(out.MappedTags.LibraryID).To(Equal(2)) + }) + + It("gives the same IDs for relative and absolute paths", func() { + lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} + abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID)) + Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID)) + }) + + It("keeps the given path for a file outside the library", func() { + filePath := filepath.Join(fixtures, "test.mp3") + out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(out.MappedTags.Path).To(Equal(filePath)) + }) +}) diff --git a/core/library.go b/core/library.go index 628ee4b7b..f1153da26 100644 --- a/core/library.go +++ b/core/library.go @@ -2,10 +2,12 @@ package core import ( "context" + "errors" "fmt" "io/fs" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/slice" @@ -200,23 +203,22 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity } pathChanged := originalLib.Path != lib.Path + pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) || + (updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack) err = r.LibraryRepository.Put(ctx, lib, cols...) if err != nil { return r.mapError(err) } - // Restart watcher and trigger scan if path was updated - if pathChanged { - if r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } + if pathChanged && r.watcher != nil { + if err := r.watcher.Watch(ctx, lib); err != nil { + log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) } + } - if r.scanner != nil { - go r.triggerScan(ctx, lib, "updated") - } + if (pathChanged || pidChanged) && r.scanner != nil { + go r.triggerScan(ctx, lib, "updated") } // Send library refresh event to all clients @@ -325,6 +327,15 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library } } + library.PIDAlbum = strings.TrimSpace(library.PIDAlbum) + library.PIDTrack = strings.TrimSpace(library.PIDTrack) + if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil { + validationErrors["pidAlbum"] = err.Error() + } + if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil { + validationErrors["pidTrack"] = err.Error() + } + if len(validationErrors) > 0 { return &rest.ValidationError{Errors: validationErrors} } @@ -332,6 +343,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library return nil } +// updatesColumn reports whether an update with these columns writes col. No columns means all of them. +func updatesColumn(cols []string, col string) bool { + return len(cols) == 0 || slices.Contains(cols, col) +} + func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { // Validate path format if !filepath.IsAbs(library.Path) { @@ -399,11 +415,27 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in return nil } +var scanWaitInterval = time.Second + func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { + // Runs in its own goroutine and outlives the HTTP request + ctx = context.WithoutCancel(ctx) + + // A running scan loaded the libraries before this change, and would reject a new request + for { + status, err := r.scanner.Status(ctx) + if err != nil || !status.Scanning { + break + } + time.Sleep(scanWaitInterval) + } + log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) start := time.Now() - warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library - if err != nil { + warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full + if errors.Is(err, model.ErrAlreadyScanning) { + log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name) + } else if err != nil { log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) } else { log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) diff --git a/core/library_test.go b/core/library_test.go index 5402eac22..e6ebb1974 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -322,6 +322,37 @@ var _ = Describe("Library Service", func() { }) }) + Describe("PID validation", func() { + pidError := func(err error, field string) string { + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + return validationErr.Errors[field] + } + + It("rejects an unknown attribute in the album PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"}) + Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`)) + }) + + It("rejects albumid in the album PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"}) + Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid")) + }) + + It("rejects an unknown attribute in the track PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"}) + Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`)) + }) + + It("trims spaces", func() { + library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "} + _, err := repo.Save(ctx, library) + Expect(err).ToNot(HaveOccurred()) + Expect(library.PIDAlbum).To(Equal("folder")) + Expect(library.PIDTrack).To(BeEmpty()) + }) + }) + Describe("Path Validation", func() { Context("Create operation", func() { It("fails when path is not absolute", func() { @@ -679,6 +710,48 @@ var _ = Describe("Library Service", func() { }, "100ms", "10ms").Should(Equal(0)) }) + It("triggers scan when updating the library PID config", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) + + library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} + Expect(repo.Update(ctx, "1", library)).To(Succeed()) + + Eventually(func() int { + return scanner.GetScanAllCallCount() + }, "1s", "10ms").Should(Equal(1)) + // A quick scan: the scanner itself rescans this library in full + Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse()) + }) + + It("does not trigger scan when the PID fields were not sent", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}}) + + // The REST layer decodes a missing pidAlbum as "". Only the sent fields count. + library := model.Library{ID: 1, Name: "Renamed", Path: tempDir} + Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed()) + + Consistently(func() int { + return scanner.GetScanAllCallCount() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("waits for a running scan before triggering a new one", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) + scanner.SetScanning(true) + + library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} + Expect(repo.Update(ctx, "1", library)).To(Succeed()) + + Consistently(func() int { + return scanner.GetScanAllCallCount() + }, "200ms", "20ms").Should(Equal(0)) + + scanner.SetScanning(false) + Eventually(func() int { + return scanner.GetScanAllCallCount() + }, "3s", "20ms").Should(Equal(1)) + }) + It("does not trigger scan when library creation fails", func() { // Try to create library with invalid data (empty name) library := &model.Library{Path: tempDir} diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 4a78a7f3f..1d2ff5df4 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "runtime/debug" + "slices" "strings" "sync" "sync/atomic" @@ -269,9 +270,13 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { if err != nil { log.Trace(ctx, "Error reading radios count", err) } - data.Library.Libraries, err = c.ds.Library().CountAll(ctx) + libs, err := c.ds.Library().GetAll(ctx) if err != nil { - log.Trace(ctx, "Error reading libraries count", err) + log.Trace(ctx, "Error reading libraries", err) + } + data.Library.Libraries = int64(len(libs)) + if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) { + data.Config.HasCustomPID = true } data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, diff --git a/core/playlists/import.go b/core/playlists/import.go index 658bd92dc..b5991b095 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde if err != nil { return nil, err } - matcher := newLibraryMatcher(libs) - lib, ok := matcher.findLibrary(dir) + matcher := model.NewLibraryMatcher(libs) + lib, ok := matcher.FindLibrary(dir) if !ok { return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) } diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 9610e9dbb..ab95b8850 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -1,13 +1,11 @@ package playlists import ( - "cmp" "context" "fmt" "io" "net/url" "path/filepath" - "slices" "strings" "time" @@ -156,61 +154,9 @@ func (r pathResolution) ToQualifiedString() (string, error) { return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil } -// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching. -type libraryMatcher struct { - libraries model.Libraries - cleanedPaths []string -} - -// findLibraryForPath finds which library contains the given absolute path. -// Returns library ID and path, or 0 and empty string if not found. -func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { - lib, ok := lm.findLibrary(absolutePath) - if !ok { - return 0, "" - } - return lib.ID, filepath.Clean(lib.Path) -} - -// findLibrary checks if the absolute path is under any of the library paths. -func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { - // Check sorted libraries (longest path first) to find the best match - for i, cleanLibPath := range lm.cleanedPaths { - // Check if absolutePath is under this library path - if strings.HasPrefix(absolutePath, cleanLibPath) { - // Ensure it's a proper path boundary (not just a prefix) - if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i], true - } - } - } - return model.Library{}, false -} - -// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). -// This ensures correct matching when library paths are prefixes of each other. -// Example: /music-classical must be checked before /music -// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical -func newLibraryMatcher(libs model.Libraries) *libraryMatcher { - // Sort libraries by path length (descending) to ensure longest paths match first. - slices.SortFunc(libs, func(i, j model.Library) int { - return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending - }) - - // Pre-clean all library paths once for efficient matching - cleanedPaths := make([]string, len(libs)) - for i, lib := range libs { - cleanedPaths[i] = filepath.Clean(lib.Path) - } - return &libraryMatcher{ - libraries: libs, - cleanedPaths: cleanedPaths, - } -} - // pathResolver handles path resolution logic for playlist imports. type pathResolver struct { - matcher *libraryMatcher + matcher *model.LibraryMatcher } // newPathResolver creates a pathResolver with libraries loaded from the datastore. @@ -219,7 +165,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er if err != nil { return nil, err } - matcher := newLibraryMatcher(libs) + matcher := model.NewLibraryMatcher(libs) return &pathResolver{matcher: matcher}, nil } @@ -246,14 +192,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu // a pathResolution with the library information. Returns an invalid resolution if // the path is not found in any library. func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { - libID, libPath := r.matcher.findLibraryForPath(absolutePath) - if libID == 0 { + lib, ok := r.matcher.FindLibrary(absolutePath) + if !ok { return pathResolution{valid: false} } return pathResolution{ absolutePath: absolutePath, - libraryPath: libPath, - libraryID: libID, + libraryPath: filepath.Clean(lib.Path), + libraryID: lib.ID, valid: true, } } @@ -288,7 +234,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { +func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -308,7 +254,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher return "" } - lib, ok := matcher.findLibrary(localPath) + lib, ok := matcher.FindLibrary(localPath) // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { return "" diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index b6a3a96f9..ced6c2b16 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -9,187 +9,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("libraryMatcher", func() { - var ds *tests.MockDataStore - var mockLibRepo *tests.MockLibraryRepo - ctx := context.Background() - - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") - mockLibRepo = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{ - MockedLibrary: mockLibRepo, - } - }) - - // Helper function to create a libraryMatcher from the mock datastore - createMatcher := func(ds model.DataStore) *libraryMatcher { - libs, err := ds.Library().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - return newLibraryMatcher(libs) - } - - Describe("Longest library path matching", func() { - It("matches the longest library path when multiple libraries share a prefix", func() { - // Setup libraries with prefix conflicts - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - {ID: 3, Path: "/music-classical/opera"}, - }) - - matcher := createMatcher(ds) - - // Test that longest path matches first and returns correct library ID - testCases := []struct { - path string - expectedLibID int - expectedLibPath string - }{ - {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"}, - {"/music-classical/track.mp3", 2, "/music-classical"}, - {"/music/track.mp3", 1, "/music"}, - {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"}, - } - - for _, tc := range testCases { - libID, libPath := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID) - Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath) - } - }) - - It("handles libraries with similar prefixes but different structures", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/home/user/music"}, - {ID: 2, Path: "/home/user/music-backup"}, - }) - - matcher := createMatcher(ds) - - // Test that music-backup library is matched correctly - libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3") - Expect(libID).To(Equal(2)) - Expect(libPath).To(Equal("/home/user/music-backup")) - - // Test that music library is still matched correctly - libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/home/user/music")) - }) - - It("matches path that is exactly the library root", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - }) - - matcher := createMatcher(ds) - - // Exact library path should match - libID, libPath := matcher.findLibraryForPath("/music-classical") - Expect(libID).To(Equal(2)) - Expect(libPath).To(Equal("/music-classical")) - }) - - It("handles complex nested library structures", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/media"}, - {ID: 2, Path: "/media/audio"}, - {ID: 3, Path: "/media/audio/classical"}, - {ID: 4, Path: "/media/audio/classical/baroque"}, - }) - - matcher := createMatcher(ds) - - testCases := []struct { - path string - expectedLibID int - expectedLibPath string - }{ - {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"}, - {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"}, - {"/media/audio/rock/track.mp3", 2, "/media/audio"}, - {"/media/video/movie.mp4", 1, "/media"}, - } - - for _, tc := range testCases { - libID, libPath := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) - Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath) - } - }) - }) - - Describe("Edge cases", func() { - It("handles empty library list", func() { - mockLibRepo.SetData([]model.Library{}) - - matcher := createMatcher(ds) - Expect(matcher).ToNot(BeNil()) - - // Should not match anything - libID, libPath := matcher.findLibraryForPath("/music/track.mp3") - Expect(libID).To(Equal(0)) - Expect(libPath).To(BeEmpty()) - }) - - It("handles single library", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - }) - - matcher := createMatcher(ds) - - libID, libPath := matcher.findLibraryForPath("/music/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/music")) - }) - - It("handles libraries with special characters in paths", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music[test]"}, - {ID: 2, Path: "/music(backup)"}, - }) - - matcher := createMatcher(ds) - Expect(matcher).ToNot(BeNil()) - - // Special characters should match literally - libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/music[test]")) - }) - }) - - Describe("Path matching order", func() { - It("ensures longest paths match first", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/a"}, - {ID: 2, Path: "/ab"}, - {ID: 3, Path: "/abc"}, - }) - - matcher := createMatcher(ds) - - // Verify that longer paths match correctly (not cut off by shorter prefix) - testCases := []struct { - path string - expectedLibID int - }{ - {"/abc/file.mp3", 3}, - {"/ab/file.mp3", 2}, - {"/a/file.mp3", 1}, - } - - for _, tc := range testCases { - libID, _ := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) - } - }) - }) -}) - var _ = Describe("pathResolver", func() { var ds *tests.MockDataStore var mockLibRepo *tests.MockLibraryRepo diff --git a/db/migrations/20260929221042_add_library_pid_columns.sql b/db/migrations/20260929221042_add_library_pid_columns.sql new file mode 100644 index 000000000..487512287 --- /dev/null +++ b/db/migrations/20260929221042_add_library_pid_columns.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +alter table library add column pid_album varchar default '' not null; +alter table library add column pid_track varchar default '' not null; +alter table library add column scanned_pid_album varchar default '' not null; +alter table library add column scanned_pid_track varchar default '' not null; + +-- Every library was scanned with the global PID config, so seed it as their scanned config. +-- This way the upgrade does not trigger a full rescan. +update library set + scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''), + scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), ''); + +delete from property where id in ('PIDAlbum', 'PIDTrack'); +-- +goose StatementEnd + +-- +goose Down +SELECT 1; diff --git a/model/library.go b/model/library.go index 1e33222ac..e80d22c89 100644 --- a/model/library.go +++ b/model/library.go @@ -1,10 +1,13 @@ package model import ( + "cmp" "context" + "strings" "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/utils/slice" ) @@ -27,6 +30,38 @@ type Library struct { TotalSize int64 `json:"totalSize" db:"total_size"` TotalDuration float64 `json:"totalDuration" db:"total_duration"` DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` + PIDAlbum string `json:"pidAlbum" db:"pid_album"` + PIDTrack string `json:"pidTrack" db:"pid_track"` + ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"` + ScannedPIDTrack string `json:"-" db:"scanned_pid_track"` +} + +// PIDConfig holds the persistent ID specs used to compute track and album IDs. +type PIDConfig struct { + Track string + Album string +} + +// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to +// the global config. +func (l Library) EffectivePID() PIDConfig { + return PIDConfig{ + Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track), + Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album), + } +} + +// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished +// scan of this library. A library that was never scanned counts as changed. +func (l Library) PIDChanged() bool { + pid := l.EffectivePID() + return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track) +} + +// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be +// rescanned in full. A library that never finished a scan has nothing to regroup. +func (l Library) NeedsPIDRescan() bool { + return !l.LastScanAt.IsZero() && l.PIDChanged() } const ( @@ -59,6 +94,8 @@ type LibraryRepository interface { // TODO These methods should be moved to a core service ScanBegin(ctx context.Context, id int, fullScan bool) error ScanEnd(ctx context.Context, id int) error + // SetScannedPID records the PID specs used by the last finished scan of the library + SetScannedPID(ctx context.Context, id int, pid PIDConfig) error ScanInProgress(ctx context.Context) (bool, error) RefreshStats(ctx context.Context, id int) error } diff --git a/model/library_matcher.go b/model/library_matcher.go new file mode 100644 index 000000000..83af96f9f --- /dev/null +++ b/model/library_matcher.go @@ -0,0 +1,57 @@ +package model + +import ( + "cmp" + "path/filepath" + "slices" + "strings" +) + +// LibraryMatcher finds the library that contains an absolute path. +type LibraryMatcher struct { + libraries Libraries + cleanedPaths []string +} + +// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music. +func NewLibraryMatcher(libs Libraries) *LibraryMatcher { + libs = slices.Clone(libs) + slices.SortFunc(libs, func(i, j Library) int { + return cmp.Compare(len(j.Path), len(i.Path)) + }) + cleanedPaths := make([]string, len(libs)) + for i, lib := range libs { + cleanedPaths[i] = filepath.Clean(lib.Path) + } + return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths} +} + +// FindLibrary returns the library whose path contains absolutePath. +func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) { + for i, libPath := range lm.cleanedPaths { + // A cleaned path only ends with a separator when it is a filesystem root + if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) || + absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) { + return lm.libraries[i], true + } + } + return Library{}, false +} + +// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it +// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged. +func LibraryRelativePath(libPath, path string) string { + if !filepath.IsAbs(path) { + return path + } + // The library root may be relative (e.g. the default "./music"); it resolves against the same cwd + absLib, err := filepath.Abs(libPath) + if err != nil { + return path + } + rel, err := filepath.Rel(absLib, path) + if err != nil || !filepath.IsLocal(rel) { + return path + } + return filepath.ToSlash(rel) +} diff --git a/model/library_matcher_test.go b/model/library_matcher_test.go new file mode 100644 index 000000000..09e6f7e33 --- /dev/null +++ b/model/library_matcher_test.go @@ -0,0 +1,91 @@ +package model_test + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LibraryMatcher", func() { + // Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does + find := func(libs model.Libraries, path string) int { + for i := range libs { + libs[i].Path = filepath.FromSlash(libs[i].Path) + } + lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path)) + if !ok { + return 0 + } + return lib.ID + } + + DescribeTable("matches the longest library path", + func(libs model.Libraries, path string, expectedID int) { + Expect(find(libs, path)).To(Equal(expectedID)) + }, + Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3), + Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2), + Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1), + Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2), + Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3), + Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2), + Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1), + Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1), + Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1), + Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2), + ) + + It("does not match a path outside every library", func() { + Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero()) + }) + + It("does not match anything without libraries", func() { + Expect(find(nil, "/music/track.mp3")).To(BeZero()) + }) + + It("does not reorder the caller's libraries", func() { + libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}} + model.NewLibraryMatcher(libs) + Expect(libs.IDs()).To(Equal([]int{1, 2})) + }) +}) + +var _ = Describe("LibraryRelativePath", func() { + // Paths are built with filepath so the "absolute" cases stay absolute on every OS + // (a Unix-style "/foo" is not absolute on Windows). + libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) + outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) + + It("returns a relative path unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) + }) + + It("rebases an absolute target when the library root is relative", func() { + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) + }) + + It("rebases an absolute path that equals the library root to '.'", func() { + Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal(".")) + }) + + It("rebases an absolute path under the library root", func() { + Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("handles a trailing slash on the library path", func() { + Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("leaves an absolute path outside the library root unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside)) + }) + + It("returns an empty path unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, "")).To(Equal("")) + }) +}) diff --git a/model/library_test.go b/model/library_test.go new file mode 100644 index 000000000..4e799e13f --- /dev/null +++ b/model/library_test.go @@ -0,0 +1,73 @@ +package model_test + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Library PID config", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "global_album" + conf.Server.PID.Track = "global_track" + }) + + Describe("EffectivePID", func() { + It("falls back to the global config", func() { + Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"})) + }) + It("uses the library overrides", func() { + lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"} + Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"})) + }) + }) + + Describe("PIDChanged", func() { + It("is false when the scanned specs match, ignoring case", func() { + lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeFalse()) + }) + It("is true when the album override differs from the scanned spec", func() { + lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true when only the track spec changed", func() { + lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true when the global config changed for a library without overrides", func() { + lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true for a library that was never scanned", func() { + Expect(model.Library{}.PIDChanged()).To(BeTrue()) + }) + }) + + Describe("NeedsPIDRescan", func() { + It("is false for a library that never finished a scan", func() { + Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse()) + }) + It("is true for a scanned library whose PID config changed", func() { + lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} + Expect(lib.NeedsPIDRescan()).To(BeTrue()) + }) + It("is false for a scanned library whose PID config did not change", func() { + lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} + Expect(lib.NeedsPIDRescan()).To(BeFalse()) + }) + }) + + It("does not expose the scanned specs in JSON", func() { + data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"}) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`)) + Expect(string(data)).ToNot(ContainSubstring("secret_")) + }) +}) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 6d12feba9..2135824d2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -8,15 +8,14 @@ import ( "math" "strconv" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) -func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { +func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile { mf := model.MediaFile{ - LibraryID: libID, + LibraryID: lib.ID, FolderID: folderID, Tags: maps.Clone(md.tags), } @@ -84,8 +83,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) // Persistent IDs - mf.PID = md.trackPID(mf) - mf.AlbumID = md.albumID(mf, conf.Server.PID.Album) + pid := lib.EffectivePID() + mf.PID = md.trackPID(mf, pid) + mf.AlbumID = md.albumID(mf, pid.Album) // BFR These IDs will go away once the UI handle multiple participants. // BFR For Legacy Subsonic compatibility, we will set them in the API handlers diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index baaf8fab5..c19398841 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -30,9 +30,23 @@ var _ = Describe("ToMediaFile", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(1, "folderID") + return md.ToMediaFile(model.Library{ID: 1}, "folderID") } + Describe("Persistent IDs", func() { + It("uses the library PID config for the album ID and for albumid in the track spec", func() { + props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}} + md = metadata.New("Jazz/Loose/01.mp3", props) + + byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID") + byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID") + + Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID)) + Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder"))) + Expect(byFolder.PID).ToNot(Equal(byTags.PID)) + }) + }) + Describe("Dates", func() { It("should parse properly tagged dates ", func() { mf = toMediaFile(model.RawTags{ diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index ec66e12b9..db652fb8b 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -38,7 +38,7 @@ var _ = Describe("Participants", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(1, "folderID") + return md.ToMediaFile(model.Library{ID: 1}, "folderID") } Describe("ARTIST(S) tags", func() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index c84d93981..a1a675006 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -323,7 +323,7 @@ var _ = Describe("Metadata", func() { tag: {tagValue}, } md = metadata.New(filePath, props) - return md.ToMediaFile(0, "0") + return md.ToMediaFile(model.Library{}, "0") } DescribeTable("Gain", diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index db315dc6b..b66ce824a 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -2,11 +2,11 @@ package metadata import ( "cmp" + "errors" "fmt" "path/filepath" "strings" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -22,12 +22,13 @@ type hashFunc = func(...string) string // attributes. Attributes can be either tags or processed values like folder, // albumid, albumartistid, etc. For each field, it gets all its attribute values // and concatenates them, then hashes the result. If a field is empty, it is -// skipped and the function looks for the next field. +// skipped and the function looks for the next field. albumSpec is the album PID +// spec used to resolve the `albumid` attribute. // // Taking hash as a parameter (instead of closing over it in a factory) keeps // mf on the stack: closing over mf would force the whole ~1KB MediaFile to the // heap on every call. -func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { +func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string { switch spec { case "track_legacy": return legacyTrackID(mf, prependLibId) @@ -41,7 +42,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, values := make([]string, len(attributes)) hasValue := false for i, attr := range attributes { - v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) + v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash) if v != "" { hasValue = true } @@ -58,15 +59,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, return hash(pid) } -func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { +func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string { attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": - if spec == conf.Server.PID.Album { + if spec == albumSpec { log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) return "" } - return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) + return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash) case "folder": return filepath.Dir(mf.Path) case "albumartistid": @@ -79,18 +80,50 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, return md.String(model.TagName(attr)) } -func (md Metadata) trackPID(mf model.MediaFile) string { - return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) +// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config". +// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them. +func ValidatePIDSpec(spec string, isAlbum bool) error { + switch { + case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy": + return nil + } + for field := range strings.SplitSeq(spec, "|") { + for attr := range strings.SplitSeq(field, ",") { + attr = strings.TrimSpace(strings.ToLower(attr)) + switch attr { + case "": + return fmt.Errorf("empty attribute in %q", spec) + case "albumid": + if isAlbum { + return errors.New("albumid cannot be used in an album PID") + } + case "folder", "albumartistid": + default: + name, ok := model.CanonicalTagName(attr) + if !ok { + return fmt.Errorf("unknown attribute %q", attr) + } + if isAlbum && string(name) != attr { + return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr) + } + } + } + } + return nil +} + +func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string { + return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash) } func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return computePID(mf, md, pidConf, true, id.NewHash) + return computePID(mf, md, pidConf, pidConf, true, id.NewHash) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return computePID(mf, md, "albumartistid", false, id.NewHash) + return computePID(mf, md, "albumartistid", "", false, id.NewHash) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 8e38bbd42..9f6eaf1f4 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -3,8 +3,7 @@ package metadata import ( "strings" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -13,16 +12,18 @@ import ( var _ = Describe("getPID", func() { var ( - md Metadata - mf model.MediaFile - sum hashFunc + md Metadata + mf model.MediaFile + sum hashFunc + albumSpec string ) getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - return computePID(mf, md, spec, prependLibId, sum) + return computePID(mf, md, spec, albumSpec, prependLibId, sum) } BeforeEach(func() { sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } + albumSpec = consts.DefaultAlbumPID }) Context("attributes are tags", func() { @@ -66,8 +67,7 @@ var _ = Describe("getPID", func() { Context("calculated attributes", func() { BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" + albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" }) When("field is title", func() { It("should return the pid", func() { @@ -121,8 +121,8 @@ var _ = Describe("getPID", func() { When("albumid configuration refers to albumid recursively", func() { It("should avoid infinite recursion", func() { // Reproduce the issue from #4920 - conf.Server.PID.Album = "albumid,album,albumversion,releasedate" - spec := conf.Server.PID.Album + albumSpec = "albumid,album,albumversion,releasedate" + spec := albumSpec md.tags = map[model.TagName][]string{ "album": {"Album Name"}, "albumversion": {"Version"}, @@ -205,8 +205,7 @@ var _ = Describe("getPID", func() { }) When("prependLibId is true with nested albumid", func() { It("should handle nested albumid calls correctly", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "album" + albumSpec = "album" spec := "albumid" md.tags = map[model.TagName][]string{"album": {"Test Album"}} mf.AlbumArtist = "Test Artist" @@ -306,3 +305,34 @@ var _ = Describe("getPID", func() { }) }) }) + +var _ = Describe("ValidatePIDSpec", func() { + DescribeTable("accepts valid specs", + func(spec string, isAlbum bool) { + Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed()) + }, + Entry("empty, meaning the global config", "", true), + Entry("default album spec", consts.DefaultAlbumPID, true), + Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false), + Entry("folder", "folder", true), + Entry("album legacy", "album_legacy", true), + Entry("track legacy", "track_legacy", false), + Entry("computed attributes", "albumartistid,album|title", true), + Entry("albumid in a track spec", "albumid,title", false), + Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true), + ) + + DescribeTable("rejects invalid specs", + func(spec string, isAlbum bool, msg string) { + Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg))) + }, + Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`), + Entry("empty field", "album||title", true, "empty attribute"), + Entry("empty attribute", "album,,title", true, "empty attribute"), + Entry("trailing separator", "album|", true, "empty attribute"), + Entry("albumid in an album spec", "albumid,album", true, "albumid"), + Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`), + Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`), + Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`), + ) +}) diff --git a/model/scanner.go b/model/scanner.go index 36c9007fb..d22c3d0d6 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -2,12 +2,15 @@ package model import ( "context" + "errors" "fmt" "strconv" "strings" "time" ) +var ErrAlreadyScanning = errors.New("already scanning") + // ScanTarget represents a specific folder within a library to be scanned. // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { diff --git a/model/tag_mappings.go b/model/tag_mappings.go index ce7d2f37b..5a8168754 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -195,6 +195,28 @@ func TagMappings() map[TagName]TagConf { return mappings } +// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name. +func CanonicalTagName(name string) (TagName, bool) { + tagName, ok := tagNameIndex()[TagName(name).ToLower()] + return tagName, ok +} + +// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases +// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid). +var tagNameIndex = sync.OnceValue(func() map[TagName]TagName { + mappings := TagMappings() + index := make(map[TagName]TagName, len(mappings)) + for name, tag := range mappings { + for _, alias := range tag.Aliases { + index[TagName(alias)] = name + } + } + for name := range mappings { + index[name] = name + } + return index +}) + func TagRolesConf() TagConf { _, cfg := parseMappings() return cfg.Roles diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go index e582c3f2f..91e54e5d4 100644 --- a/model/tag_mappings_test.go +++ b/model/tag_mappings_test.go @@ -192,3 +192,22 @@ var _ = Describe("TagConf", func() { }) }) }) + +var _ = Describe("CanonicalTagName", func() { + DescribeTable("resolves tag names and aliases", + func(name string, expected TagName) { + tagName, ok := CanonicalTagName(name) + Expect(ok).To(BeTrue()) + Expect(tagName).To(Equal(expected)) + }, + Entry("tag name", "album", TagAlbum), + Entry("alias", "talb", TagAlbum), + Entry("mixed case alias", "TALB", TagAlbum), + Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID), + ) + + It("does not resolve an unknown name", func() { + _, ok := CanonicalTagName("nosuchtag") + Expect(ok).To(BeFalse()) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index bf6b8995e..85da65cbc 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -93,6 +93,8 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, + "pid_album": l.PIDAlbum, + "pid_track": l.PIDTrack, }, colsToUpdate...) cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) @@ -176,6 +178,15 @@ func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error { return err } +func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error { + sq := Update(r.tableName). + Set("scanned_pid_album", pid.Album). + Set("scanned_pid_track", pid.Track). + Where(Eq{"id": id}) + _, err := r.executeSQL(ctx, sq) + return err +} + func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) { query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) count, err := r.count(ctx, query) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 0ff470861..bf485a06f 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -270,6 +270,38 @@ var _ = Describe("LibraryRepository", func() { }) }) + Describe("PID config", func() { + It("stores the overrides, and Put never touches the scanned specs", func() { + lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"} + Expect(repo.Put(ctx, lib)).To(Succeed()) + Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed()) + + // An update coming from the REST API has no scanned specs. It must not clear them + update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"} + Expect(repo.Put(ctx, update)).To(Succeed()) + + saved, err := repo.Get(ctx, lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.PIDAlbum).To(BeEmpty()) + Expect(saved.PIDTrack).To(Equal("title")) + Expect(saved.ScannedPIDAlbum).To(Equal("folder")) + Expect(saved.ScannedPIDTrack).To(Equal("title")) + }) + + It("keeps the overrides when a partial update does not send them", func() { + lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"} + Expect(repo.Put(ctx, lib)).To(Succeed()) + + Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed()) + + saved, err := repo.Get(ctx, lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.PIDAlbum).To(Equal("folder")) + Expect(saved.PIDTrack).To(Equal("title")) + }) + }) + Describe("Delete", func() { var adminRepo model.LibraryRepository var artistRepo model.ArtistRepository diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index f0d8a0e06..238919259 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -328,11 +328,22 @@ "totalDuration": "Duração", "defaultNewUsers": "Padrão para Novos Usuários", "createdAt": "Data de Criação", - "updatedAt": "Últ. Atualização" + "updatedAt": "Últ. Atualização", + "pidAlbum": "Agrupamento de álbuns", + "pidTrack": "Identificação das faixas" }, "sections": { "basic": "Informações Básicas", - "statistics": "Estatísticas" + "statistics": "Estatísticas", + "pid": "IDs Persistentes" + }, + "pid": { + "global": "Usar configuração global (%{value})", + "folder": "Pasta (um álbum por pasta)", + "custom": "Personalizado", + "spec": "Especificação do PID", + "help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:", + "docs": "IDs Persistentes" }, "actions": { "scan": "Scanear Biblioteca", @@ -362,7 +373,9 @@ "messages": { "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", "scanInProgress": "Scan em progresso...", - "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário" + "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário", + "pidChangeTitle": "Alterar os IDs persistentes?", + "pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo." } }, "plugin": { diff --git a/scanner/controller.go b/scanner/controller.go index 5eed6c58d..1b13c1846 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -25,7 +25,7 @@ import ( ) var ( - ErrAlreadyScanning = errors.New("already scanning") + ErrAlreadyScanning = model.ErrAlreadyScanning ) func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, @@ -304,14 +304,14 @@ func LockForMaintenance() (func(), bool) { return scanMaintenanceMux.Unlock, true } -// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted -// full scan in one of the included libraries. +// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan, +// or will rescan a library in full because its PID config changed, in one of the included libraries. func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { if fullScan { return true } return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { - return library.FullScanInProgress + return library.FullScanInProgress || library.NeedsPIDRescan() }) } diff --git a/scanner/controller_test.go b/scanner/controller_test.go index bdcb99eda..974540e32 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -2,6 +2,7 @@ package scanner_test import ( "context" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -70,14 +71,21 @@ var _ = Describe("EffectiveFullScan", func() { var ds *tests.MockDataStore BeforeEach(func() { + pid := model.Library{}.EffectivePID() libraries := &tests.MockLibraryRepo{} libraries.SetData(model.Libraries{ - {ID: 1, FullScanInProgress: true}, - {ID: 2}, + {ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, }) ds = &tests.MockDataStore{MockedLibrary: libraries} }) + It("detects a library that needs a full rescan for a PID change", func() { + targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue()) + }) + It("detects an interrupted full scan in a targeted library", func() { targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 7b6a6b097..4edaecacd 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -40,6 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) + state.markFailed(lib.ID) continue } jobs = append(jobs, job) @@ -51,12 +52,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor } type scanJob struct { - lib model.Library - fs storage.MusicFS - lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library - targetFolders []string // Specific folders to scan (including all descendants) - lock sync.Mutex - numFolders atomic.Int64 + lib model.Library + fs storage.MusicFS + lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library + targetFolders []string // Specific folders to scan (including all descendants) + prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one + lock sync.Mutex + numFolders atomic.Int64 } func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { @@ -77,16 +79,32 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full return nil, fmt.Errorf("getting fs for library: %w", err) } + pid := lib.EffectivePID() + if lib.NeedsPIDRescan() { + msg := "Scanner: PID config changed, rescanning library in full" + if len(targetFolders) > 0 { + msg = "Scanner: PID config changed, rescanning target folders in full" + } + log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders, + "album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack) + fullScan = true + } + var prevAlbumPIDConf string + if lib.ScannedPIDAlbum != pid.Album { + prevAlbumPIDConf = lib.ScannedPIDAlbum + } + // Ensure FullScanInProgress reflects the current scan request. // This is important when resuming an interrupted quick scan as a full scan: // the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly. lib.FullScanInProgress = lib.FullScanInProgress || fullScan return &scanJob{ - lib: lib, - fs: fsys, - lastUpdates: lastUpdates, - targetFolders: targetFolders, + lib: lib, + fs: fsys, + lastUpdates: lastUpdates, + targetFolders: targetFolders, + prevAlbumPIDConf: prevAlbumPIDConf, }, nil } @@ -122,14 +140,13 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry { // The phaseFolders struct implements the phase interface, providing methods to produce // folder entries, process folders, persist changes to the database, and log the results. type phaseFolders struct { - jobs []*scanJob - ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early - stopWalk context.CancelCauseFunc - state *scanState - prevAlbumPIDConf string - imageChanges *imageChangeCollector + jobs []*scanJob + ds model.DataStore + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx + walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early + stopWalk context.CancelCauseFunc + state *scanState + imageChanges *imageChangeCollector } func (p *phaseFolders) description() string { @@ -138,12 +155,6 @@ func (p *phaseFolders) description() string { func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { return ppl.NewProducer(func(put func(entry *folderEntry)) error { - var err error - p.prevAlbumPIDConf, err = p.ds.Property().DefaultGet(p.ctx, consts.PIDAlbumKey, "") - if err != nil { - return fmt.Errorf("getting album PID conf: %w", err) - } - // TODO Parallelize multiple job when we have multiple libraries var total int64 var totalChanged int64 @@ -173,7 +184,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { // Check if folder is outdated if folder.isOutdated() { - if !p.state.fullScan { + if !folder.job.lib.FullScanInProgress { // Ancestor folders need a row even with no files of their own: artwork // resolution climbs them, and an image added later needs a state to diff. if folder.isEmpty() && folder.isNew() { @@ -239,7 +250,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { for afPath, af := range entry.audioFiles { fullPath := path.Join(entry.path, afPath) dbTrack, foundInDB := dbTracks[fullPath] - if !foundInDB || p.state.fullScan { + if !foundInDB || entry.job.lib.FullScanInProgress { filesToImport[fullPath] = dbTrack } else { info, err := af.Info() @@ -289,18 +300,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string } for filePath, info := range allInfo { md := metadata.New(filePath, info) - track := md.ToMediaFile(entry.job.lib.ID, entry.id) + track := md.ToMediaFile(entry.job.lib, entry.id) tracks = append(tracks, track) for _, t := range track.Tags.FlattenAll() { uniqueTags[t.ID] = t } // Keep track of any album ID changes, to reassign annotations later - prevAlbumID := "" + prevAlbumID := track.AlbumID if prev := toImport[filePath]; prev != nil { prevAlbumID = prev.AlbumID - } else { - prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf) + } else if entry.job.prevAlbumPIDConf != "" { + prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf) } _, ok := entry.albumIDMap[track.AlbumID] if prevAlbumID != track.AlbumID && !ok { @@ -453,7 +464,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en if len(queueItems) > 0 { queue := tx.ArtworkQueue() enqueue := queue.Enqueue - if p.state.fullScan { + if entry.job.lib.FullScanInProgress { enqueue = queue.EnqueueIfMissing } if err := enqueue(ctx, queueItems...); err != nil { diff --git a/scanner/scanner.go b/scanner/scanner.go index cd2fe3c8d..305c443f4 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -4,13 +4,11 @@ import ( "context" "fmt" "maps" - "path/filepath" "slices" "sync/atomic" "time" ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -32,6 +30,7 @@ type scanState struct { libraries model.Libraries // Store libraries list for consistency across phases targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection + failedLibs map[int]bool // Libraries that could not be scanned in this run } func (s *scanState) sendProgress(info *ProgressInfo) { @@ -48,29 +47,15 @@ func (s *scanState) sendWarning(msg string) { s.sendProgress(&ProgressInfo{Warning: msg}) } -func (s *scanState) sendError(err error) { - s.sendProgress(&ProgressInfo{Error: err.Error()}) +func (s *scanState) markFailed(libID int) { + if s.failedLibs == nil { + s.failedLibs = map[int]bool{} + } + s.failedLibs[libID] = true } -// libraryRelativePath rebases an absolute scan target path onto the library root, since the -// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside -// the library root, are returned unchanged. -func libraryRelativePath(libPath, folderPath string) string { - if !filepath.IsAbs(folderPath) { - return folderPath - } - // The library root may be relative (e.g. the default "./music"); it must be made absolute - // to match against an absolute target, and it resolves against the same cwd as the scanner's fs. - absLib, err := filepath.Abs(libPath) - if err != nil { - return folderPath - } - rel, err := filepath.Rel(absLib, folderPath) - if err != nil || !filepath.IsLocal(rel) { - return folderPath - } - // The scanner's fs.FS is an io/fs, which always uses forward slashes. - return filepath.ToSlash(rel) +func (s *scanState) sendError(err error) { + s.sendProgress(&ProgressInfo{Error: err.Error()}) } func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { @@ -104,7 +89,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] }) for _, target := range targets { - folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath) + folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath) if folderPath == "" { folderPath = "." } @@ -137,6 +122,10 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // if there was a full scan in progress, force a full scan if !state.fullScan { for _, lib := range state.libraries { + // A pending PID rescan already restarts in full through its own job + if lib.NeedsPIDRescan() { + continue + } if lib.FullScanInProgress { log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) state.fullScan = true @@ -215,10 +204,13 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt var successfulLibs []model.Library for _, lib := range state.libraries { - if lib.LastScanStartedAt.IsZero() { + // A library with a changed PID config restarts its scan: resuming would skip the folders that + // the interrupted scan already processed with the old config + pidRescan := lib.NeedsPIDRescan() + if lib.LastScanStartedAt.IsZero() || pidRescan { // This is a new scan - mark it as started err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan) + return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan) }, "scanner: begin library scan") if err != nil { log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) @@ -340,11 +332,12 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) } - if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil { - return fmt.Errorf("updating track PID conf: %w", err) - } - if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil { - return fmt.Errorf("updating album PID conf: %w", err) + // A selective scan covers only part of the library, so the rest may still use the old PID + // config. A library that could not be scanned did not apply it either. + if !state.isSelectiveScan() && !state.failedLibs[lib.ID] { + if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil { + return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err) + } } if state.changesDetected.Load() { log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index 0778bd6ec..e8abb7c7d 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -4,8 +4,6 @@ package scanner import ( "context" "errors" - "os" - "path/filepath" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -13,43 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("libraryRelativePath", func() { - // Paths are built with filepath so the "absolute" cases stay absolute on every OS - // (a Unix-style "/foo" is not absolute on Windows). - libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) - outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) - - It("returns a relative path unchanged", func() { - Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) - }) - - It("rebases an absolute target when the library root is relative", func() { - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) - }) - - It("rebases an absolute path that equals the library root to '.'", func() { - Expect(libraryRelativePath(libRoot, libRoot)).To(Equal(".")) - }) - - It("rebases an absolute path under the library root", func() { - Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("handles a trailing slash on the library path", func() { - Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("leaves an absolute path outside the library root unchanged", func() { - Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside)) - }) - - It("returns an empty path unchanged", func() { - Expect(libraryRelativePath(libRoot, "")).To(Equal("")) - }) -}) - type mockPhase struct { num int produceFunc func() ppl.Producer[int] diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 546baf756..f6634c875 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -835,4 +835,170 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(lastError).To(BeEmpty()) }) }) + + Context("Per-library PID config", func() { + albumsOf := func(libID int) model.Albums { + // The mock datastore's GC is a no-op, so run the real one to purge the albums left + // empty by a regroup, as the scanner does in production + Expect(ds.RealDS.GC(ctx)).To(Succeed()) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + Filters: squirrel.Eq{"library_id": libID, "missing": false}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + return albums + } + trackByTitle := func(libID int, title string) model.MediaFile { + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + Filters: squirrel.Eq{"library_id": libID, "title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1)) + return mfs[0] + } + rockTitles := func() []string { + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title }) + } + // changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would + // restore the title from the file tags, a quick scan leaves it alone. + changeRockInDB := func() { + _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID) + Expect(err).ToNot(HaveOccurred()) + } + // changeBlueTrainInDB does the same for one jazz track + changeBlueTrainInDB := func() { + _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID) + Expect(err).ToNot(HaveOccurred()) + } + + BeforeEach(func() { + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969}) + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + }) + + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959}) + coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960}) + blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957}) + _ = createFS("jazz", fstest.MapFS{ + "Loose/01 - So What.mp3": miles(track(1, "So What")), + "Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), + "Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")), + }) + }) + + It("regroups only the library whose PID config changed, keeping annotations", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(albumsOf(lib2.ID)).To(HaveLen(3)) + + // Star Blue Train, to check the star follows the album to its new ID + oldBlueTrain := trackByTitle(lib2.ID, "Blue Train") + Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed()) + changeRockInDB() + + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Jazz is grouped by folder now: "Loose" is one album + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID)) + + newBlueTrain := trackByTitle(lib2.ID, "Blue Train") + Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID)) + album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID) + Expect(err).ToNot(HaveOccurred()) + Expect(album.Starred).To(BeTrue()) + + // Rock only got a quick scan + Expect(rockTitles()).To(ConsistOf("Changed In DB")) + + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) + Expect(jazz.PIDChanged()).To(BeFalse()) + rock, err := ds.Library().Get(ctx, lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rock.PIDChanged()).To(BeFalse()) + }) + + It("rescans only libraries that follow the global config", func() { + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, true)).To(Succeed()) + changeRockInDB() + changeBlueTrainInDB() + + conf.Server.PID.Album = "album" + Expect(runScanner(ctx, false)).To(Succeed()) + + // Rock follows the global config, so it was rescanned in full and its title restored + Expect(rockTitles()).To(ConsistOf("Come Together")) + // Jazz has its own override, so it only got a quick scan + trackByTitle(lib2.ID, "Blue Train In DB") + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) + }) + + It("restarts an interrupted scan when the PID config changed meanwhile", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Simulate a quick scan of jazz that was interrupted after it had processed every folder: + // the folders were updated after the (old) scan start time + _, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?", + time.Now().Add(-time.Hour), lib2.ID) + Expect(err).ToNot(HaveOccurred()) + + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Every folder was revisited with the new config + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + }) + + It("does not turn an interrupted PID rescan into a full scan of every library", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + changeRockInDB() + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + + // Simulate a PID full scan of jazz that was interrupted + Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Rock only got a quick scan, jazz was rescanned with the new config + Expect(rockTitles()).To(ConsistOf("Changed In DB")) + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + }) + + It("does not record the PID config for a library that could not be scanned", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"} + Expect(ds.Library().Put(ctx, &broken)).To(Succeed()) + + // The scan reports an error for the broken library, and still finishes the others + _ = runScanner(ctx, false) + + reloaded, err := ds.Library().Get(ctx, broken.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.PIDChanged()).To(BeTrue()) + }) + + It("does not record the PID config after a selective scan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}}) + Expect(err).ToNot(HaveOccurred()) + + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.PIDChanged()).To(BeTrue()) + }) + }) }) diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index f1e6c4539..61013dd5a 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -22,7 +22,12 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec return nil, model.ErrNotFound } - return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID) + lib, err := ds.Library().Get(ctx, file.LibraryID) + if err != nil { + return nil, err + } + + return core.Inspect(file.AbsolutePath(), *lib, file.FolderID) } func inspect(ds model.DataStore) http.HandlerFunc { diff --git a/server/serve_index.go b/server/serve_index.go index 4b093b953..167197403 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, + "pidAlbum": conf.Server.PID.Album, + "pidTrack": conf.Server.PID.Track, "enableNowPlaying": conf.Server.EnableNowPlaying, "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e2df55c4b..277513768 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -89,6 +89,8 @@ var _ = Describe("serveIndex", func() { Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), + Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"), + Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true), diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index e21dcccce..6de2b9265 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -145,6 +145,17 @@ func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error { return nil } +func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error { + if m.Err != nil { + return m.Err + } + if lib, ok := m.Data[id]; ok { + lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track + m.Data[id] = lib + } + return nil +} + func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) { if m.Err != nil { return false, m.Err diff --git a/ui/src/config.js b/ui/src/config.js index e406e47cf..62b3cb822 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -32,6 +32,8 @@ const defaultConfig = { listenBrainzEnabled: true, enableExternalServices: true, enableCoverAnimation: true, + pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID + pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID enableNowPlaying: true, playbackReportIntervalMs: 60000, devShowArtistPage: true, diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index a04b6e311..f694ea75f 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -331,11 +331,22 @@ "totalDuration": "Duration", "defaultNewUsers": "Default for New Users", "createdAt": "Created", - "updatedAt": "Updated" + "updatedAt": "Updated", + "pidAlbum": "Album grouping", + "pidTrack": "Track identity" }, "sections": { "basic": "Basic Information", - "statistics": "Statistics" + "statistics": "Statistics", + "pid": "Persistent IDs" + }, + "pid": { + "global": "Use global setting (%{value})", + "folder": "Folder (one album per folder)", + "custom": "Custom", + "spec": "PID spec", + "help": "Tags and attributes that identify an item. See the documentation for the syntax:", + "docs": "Persistent IDs" }, "actions": { "scan": "Scan Library", @@ -365,7 +376,9 @@ "messages": { "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.", "scanInProgress": "Scan in progress...", - "noLibrariesAssigned": "No libraries assigned to this user" + "noLibrariesAssigned": "No libraries assigned to this user", + "pidChangeTitle": "Change persistent IDs?", + "pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one." } }, "plugin": { diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx index 0e69964b6..8166bb2f3 100644 --- a/ui/src/library/LibraryCreate.jsx +++ b/ui/src/library/LibraryCreate.jsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react' +import PropTypes from 'prop-types' import { Create, SimpleForm, @@ -10,7 +11,34 @@ import { useNotify, useRedirect, } from 'react-admin' +import { Typography } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import { Title } from '../common' +import { PIDInputs } from './PIDInput' + +const useStyles = makeStyles((theme) => ({ + spaced: { marginTop: theme.spacing(3) }, +})) + +// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly +const SectionTitle = ({ label, spaced }) => { + const translate = useTranslate() + const classes = useStyles() + return ( + <Typography + variant="h6" + gutterBottom + className={spaced ? classes.spaced : undefined} + > + {translate(label)} + </Typography> + ) +} + +SectionTitle.propTypes = { + label: PropTypes.string.isRequired, + spaced: PropTypes.bool, +} const LibraryCreate = (props) => { const translate = useTranslate() @@ -73,9 +101,12 @@ const LibraryCreate = (props) => { return ( <Create title={<Title subTitle={title} />} {...props}> <SimpleForm save={save} variant={'outlined'}> + <SectionTitle label="resources.library.sections.basic" /> <TextInput source="name" validate={[required()]} /> <TextInput source="path" validate={[required()]} fullWidth /> <BooleanInput source="defaultNewUsers" /> + <SectionTitle label="resources.library.sections.pid" spaced /> + <PIDInputs /> </SimpleForm> </Create> ) diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index 53d17ac7f..c42c7ac4b 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -1,9 +1,11 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' +import PropTypes from 'prop-types' import { Edit, FormWithRedirect, TextInput, BooleanInput, + Confirm, required, SaveButton, useTranslate, @@ -22,6 +24,9 @@ import { ReadOnlySizeField, Title, } from '../common' +import config from '../config' +import { PIDInputs } from './PIDInput' +import { pidConfigChanged } from './pidPresets' const useStyles = makeStyles({ toolbar: { @@ -53,8 +58,131 @@ const CustomToolbar = ({ showDelete, ...props }) => ( </Toolbar> ) -const LibraryEdit = (props) => { +export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => { const translate = useTranslate() + const [confirmOpen, setConfirmOpen] = useState(false) + + // Every submit path (Save button and Enter key) goes through here, so a PID change always asks first + const submit = () => { + if ( + pidConfigChanged( + formProps.form.getState().values, + formProps.record, + config, + ) + ) { + setConfirmOpen(true) + return + } + formProps.handleSubmit() + } + + const handleConfirm = () => { + setConfirmOpen(false) + formProps.handleSubmit() + } + + return ( + <form + onSubmit={(event) => { + event.preventDefault() + submit() + }} + > + <Box p="1em" maxWidth="800px"> + <Box display="flex"> + <Box flex={1} mr="1em"> + {/* Basic Information */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.basic')} + </Typography> + + <TextInput + source="name" + label={translate('resources.library.fields.name')} + validate={[required()]} + variant="outlined" + /> + <TextInput + source="path" + label={translate('resources.library.fields.path')} + validate={[required()]} + fullWidth + variant="outlined" + InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1 + /> + <BooleanInput + source="defaultNewUsers" + label={translate('resources.library.fields.defaultNewUsers')} + variant="outlined" + /> + + <Box mt="2em" /> + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.pid')} + </Typography> + <PIDInputs /> + + <Box mt="2em" /> + + {/* Statistics - Two Column Layout */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.statistics')} + </Typography> + + <Box + display="grid" + gridTemplateColumns="1fr 1fr" + gridColumnGap="1em" + > + <ReadOnlyNumberField source="totalSongs" {...readOnlyProps} /> + <ReadOnlyNumberField source="totalAlbums" {...readOnlyProps} /> + <ReadOnlyNumberField source="totalArtists" {...readOnlyProps} /> + <ReadOnlySizeField source="totalSize" {...readOnlyProps} /> + <ReadOnlyDurationField + source="totalDuration" + {...readOnlyProps} + /> + <ReadOnlyNumberField + source="totalMissingFiles" + {...readOnlyProps} + /> + <Box gridColumn="1 / -1"> + <ReadOnlyDateField source="lastScanAt" {...readOnlyProps} /> + </Box> + <ReadOnlyDateField source="updatedAt" {...readOnlyProps} /> + <ReadOnlyDateField source="createdAt" {...readOnlyProps} /> + </Box> + </Box> + </Box> + </Box> + + <CustomToolbar + handleSubmitWithRedirect={submit} + pristine={formProps.pristine} + saving={formProps.saving} + record={formProps.record} + showDelete={canDelete} + /> + <Confirm + isOpen={confirmOpen} + loading={formProps.saving} + title="resources.library.messages.pidChangeTitle" + content="resources.library.messages.pidChangeConfirm" + onConfirm={handleConfirm} + onClose={() => setConfirmOpen(false)} + /> + </form> + ) +} + +LibraryEditForm.propTypes = { + formProps: PropTypes.object.isRequired, + canEditPath: PropTypes.bool, + canDelete: PropTypes.bool, +} + +const LibraryEdit = (props) => { const [mutate] = useMutation() const notify = useNotify() const redirect = useRedirect() @@ -93,91 +221,11 @@ const LibraryEdit = (props) => { {...props} save={save} render={(formProps) => ( - <form onSubmit={formProps.handleSubmit}> - <Box p="1em" maxWidth="800px"> - <Box display="flex"> - <Box flex={1} mr="1em"> - {/* Basic Information */} - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.basic')} - </Typography> - - <TextInput - source="name" - label={translate('resources.library.fields.name')} - validate={[required()]} - variant="outlined" - /> - <TextInput - source="path" - label={translate('resources.library.fields.path')} - validate={[required()]} - fullWidth - variant="outlined" - InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1 - /> - <BooleanInput - source="defaultNewUsers" - label={translate( - 'resources.library.fields.defaultNewUsers', - )} - variant="outlined" - /> - - <Box mt="2em" /> - - {/* Statistics - Two Column Layout */} - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.statistics')} - </Typography> - - <Box - display="grid" - gridTemplateColumns="1fr 1fr" - gridColumnGap="1em" - > - <ReadOnlyNumberField - source="totalSongs" - {...readOnlyProps} - /> - <ReadOnlyNumberField - source="totalAlbums" - {...readOnlyProps} - /> - <ReadOnlyNumberField - source="totalArtists" - {...readOnlyProps} - /> - <ReadOnlySizeField source="totalSize" {...readOnlyProps} /> - <ReadOnlyDurationField - source="totalDuration" - {...readOnlyProps} - /> - <ReadOnlyNumberField - source="totalMissingFiles" - {...readOnlyProps} - /> - <Box gridColumn="1 / -1"> - <ReadOnlyDateField - source="lastScanAt" - {...readOnlyProps} - /> - </Box> - <ReadOnlyDateField source="updatedAt" {...readOnlyProps} /> - <ReadOnlyDateField source="createdAt" {...readOnlyProps} /> - </Box> - </Box> - </Box> - </Box> - - <CustomToolbar - handleSubmitWithRedirect={formProps.handleSubmitWithRedirect} - pristine={formProps.pristine} - saving={formProps.saving} - record={formProps.record} - showDelete={canDelete} - /> - </form> + <LibraryEditForm + formProps={formProps} + canEditPath={canEditPath} + canDelete={canDelete} + /> )} /> </Edit> diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx new file mode 100644 index 000000000..926adc839 --- /dev/null +++ b/ui/src/library/LibraryEdit.test.jsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { + FormWithRedirect, + RecordContextProvider, + SaveContextProvider, +} from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { LibraryEditForm } from './LibraryEdit' +import config from '../config' + +const record = { + id: '2', + name: 'Jazz', + path: '/music/jazz', + pidAlbum: '', + pidTrack: '', +} + +// Edit provides a save context in the app. SaveButton only reads these setters from it +const saveContext = { + save: vi.fn(), + setOnSuccess: vi.fn(), + setOnFailure: vi.fn(), + setTransform: vi.fn(), +} + +const renderForm = (save) => + render( + <TestContext> + <SaveContextProvider value={saveContext}> + <RecordContextProvider value={record}> + <FormWithRedirect + record={record} + save={save} + render={(formProps) => ( + <LibraryEditForm + formProps={formProps} + canEditPath + canDelete={false} + /> + )} + /> + </RecordContextProvider> + </SaveContextProvider> + </TestContext>, + ) + +const chooseAlbumGrouping = (optionText) => { + fireEvent.mouseDown( + screen.getByLabelText('resources.library.fields.pidAlbum'), + ) + fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText)) +} + +const dialogTitle = 'resources.library.messages.pidChangeTitle' + +describe('LibraryEditForm', () => { + afterEach(cleanup) + + it('saves directly when the PID config did not change', async () => { + const save = vi.fn() + renderForm(save) + fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), { + target: { value: 'Jazz Renamed' }, + }) + fireEvent.click(screen.getByText('ra.action.save')) + await waitFor(() => expect(save).toHaveBeenCalled()) + expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument() + }) + + it('asks before saving a PID change, and Cancel keeps the edits', async () => { + const save = vi.fn() + renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.click(screen.getByText('ra.action.save')) + + expect(await screen.findByText(dialogTitle)).toBeInTheDocument() + expect(save).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('ra.action.cancel')) + await waitFor(() => + expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(), + ) + expect(save).not.toHaveBeenCalled() + expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument() + }) + + it('saves the PID change after Confirm', async () => { + const save = vi.fn() + renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.click(screen.getByText('ra.action.save')) + fireEvent.click(await screen.findByText('ra.action.confirm')) + + await waitFor(() => expect(save).toHaveBeenCalled()) + expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' }) + }) + + it('pre-fills a Custom spec with the global spec', () => { + renderForm(vi.fn()) + chooseAlbumGrouping('resources.library.pid.custom') + expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue( + config.pidAlbum, + ) + }) + + it('asks before saving when the form is submitted with Enter', async () => { + const save = vi.fn() + const { container } = renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.submit(container.querySelector('form')) + + expect(await screen.findByText(dialogTitle)).toBeInTheDocument() + expect(save).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/library/PIDInput.jsx b/ui/src/library/PIDInput.jsx new file mode 100644 index 000000000..6481dc392 --- /dev/null +++ b/ui/src/library/PIDInput.jsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { TextInput, required, useTranslate } from 'react-admin' +import { useField } from 'react-final-form' +import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { + PID_CUSTOM, + PID_FOLDER, + PID_GLOBAL, + pidModeFromValue, + pidValueForMode, +} from './pidPresets' +import config from '../config' +import { docsUrl } from '../utils' + +const PID_DOCS_URL = docsUrl('/docs/usage/pids/') + +const useStyles = makeStyles((theme) => ({ + help: { marginBottom: theme.spacing(1) }, +})) + +// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec +export const PIDInput = ({ source, label, globalValue, allowFolder }) => { + const translate = useTranslate() + const classes = useStyles() + const { input } = useField(source) + // Local state, so choosing Custom shows the text box before anything is typed + const [mode, setMode] = useState(() => + pidModeFromValue(input.value, allowFolder), + ) + + const choices = [ + { + id: PID_GLOBAL, + name: translate('resources.library.pid.global', { value: globalValue }), + }, + ...(allowFolder + ? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }] + : []), + { id: PID_CUSTOM, name: translate('resources.library.pid.custom') }, + ] + + const handleModeChange = (event) => { + const newMode = event.target.value + setMode(newMode) + input.onChange(pidValueForMode(newMode, globalValue)) + } + + return ( + <> + <TextField + id={`${source}-mode`} + select + fullWidth + variant="outlined" + margin="dense" + label={label} + value={mode} + onChange={handleModeChange} + > + {choices.map((choice) => ( + <MenuItem key={choice.id} value={choice.id}> + {choice.name} + </MenuItem> + ))} + </TextField> + {mode === PID_CUSTOM && ( + <> + <TextInput + source={source} + label={translate('resources.library.pid.spec')} + validate={[required()]} + fullWidth + variant="outlined" + helperText={false} + /> + <FormHelperText className={classes.help}> + {translate('resources.library.pid.help')}{' '} + <Link href={PID_DOCS_URL} target="_blank" rel="noopener noreferrer"> + {translate('resources.library.pid.docs')} + </Link> + </FormHelperText> + </> + )} + </> + ) +} + +PIDInput.propTypes = { + source: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + globalValue: PropTypes.string, + allowFolder: PropTypes.bool, +} + +export const PIDInputs = () => { + const translate = useTranslate() + return ( + <> + <PIDInput + source="pidAlbum" + label={translate('resources.library.fields.pidAlbum')} + globalValue={config.pidAlbum} + allowFolder + /> + <PIDInput + source="pidTrack" + label={translate('resources.library.fields.pidTrack')} + globalValue={config.pidTrack} + /> + </> + ) +} diff --git a/ui/src/library/pidPresets.js b/ui/src/library/pidPresets.js new file mode 100644 index 000000000..0483fc691 --- /dev/null +++ b/ui/src/library/pidPresets.js @@ -0,0 +1,33 @@ +export const PID_GLOBAL = 'global' +export const PID_FOLDER = 'folder' +export const PID_CUSTOM = 'custom' + +export const pidModeFromValue = (value, allowFolder) => { + const v = (value || '').trim() + if (v === '') return PID_GLOBAL + if (allowFolder && v === PID_FOLDER) return PID_FOLDER + return PID_CUSTOM +} + +// Returns the value to store for a dropdown choice. Custom starts from the global spec +export const pidValueForMode = (mode, globalValue) => { + switch (mode) { + case PID_GLOBAL: + return '' + case PID_FOLDER: + return PID_FOLDER + default: + return globalValue || '' + } +} + +// Reports whether the form values change the effective PID spec of the saved record. Like the +// server, it trims, treats empty as the global value and compares case-insensitively +export const pidConfigChanged = (values, record, globals) => { + const effective = (value, field) => + ((value || '').trim() || globals[field] || '').toLowerCase() + return ['pidAlbum', 'pidTrack'].some( + (field) => + effective(values[field], field) !== effective(record[field], field), + ) +} diff --git a/ui/src/library/pidPresets.test.js b/ui/src/library/pidPresets.test.js new file mode 100644 index 000000000..dab1c82e2 --- /dev/null +++ b/ui/src/library/pidPresets.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { + PID_CUSTOM, + PID_FOLDER, + PID_GLOBAL, + pidConfigChanged, + pidModeFromValue, + pidValueForMode, +} from './pidPresets' + +describe('pidModeFromValue', () => { + it('maps an empty value to the global setting', () => { + expect(pidModeFromValue('', true)).toBe(PID_GLOBAL) + expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL) + }) + it('maps folder to the Folder preset when allowed', () => { + expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER) + }) + it('maps folder to Custom when the Folder preset is not offered', () => { + expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM) + }) + it('maps any other value to Custom', () => { + expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM) + }) +}) + +describe('pidValueForMode', () => { + it('stores an empty value for the global setting', () => { + expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('') + }) + it('stores folder for the Folder preset', () => { + expect(pidValueForMode(PID_FOLDER, '')).toBe('folder') + }) + it('starts Custom from the global spec', () => { + expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title') + expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('') + }) +}) + +describe('pidConfigChanged', () => { + const record = { pidAlbum: 'folder', pidTrack: '' } + const globals = { + pidAlbum: 'musicbrainz_albumid|albumartistid,album', + pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', + } + it.each([ + ['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false], + ['a missing value equals an empty one', { pidAlbum: 'folder' }, false], + [ + 'Custom set to the global value', + { pidAlbum: 'folder', pidTrack: globals.pidTrack }, + false, + ], + ['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false], + [ + 'a whitespace-only change', + { pidAlbum: ' folder ', pidTrack: ' ' }, + false, + ], + ['the album PID changed', { pidAlbum: '', pidTrack: '' }, true], + ['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true], + ])('%s', (_, values, expected) => { + expect(pidConfigChanged(values, record, globals)).toBe(expected) + }) +}) From 95f67d2c4ef391967327f0c7dccd5d0d8b02f62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 3 Oct 2026 08:58:54 -0700 Subject: [PATCH 594/596] fix(share): reuse cached transcodes for share streams and zip downloads (#6262) * fix(share): reuse cached transcodes when streaming from share links Public share streams built the stream request with only the share's format and bit rate, leaving sample rate, bit depth and channels at zero. Regular playback resolves those through the transcode decider (e.g. 48000 Hz for Opus), and they are part of the transcoding cache key, so a track already transcoded during normal playback was transcoded again into a separate, identical cache entry when played through a share link. The public router now resolves share stream requests with the same TranscodeDecider.ResolveRequest used by the Subsonic stream endpoint, so both paths produce the same request and share cache entries. Fixes #6261 * fix(archiver): reuse cached transcodes when zipping downloads Zip downloads (album, artist, playlist and share) built the stream request with only the format and bit rate, leaving sample rate, bit depth and channels at zero. Those are part of the transcoding cache key, so a track already transcoded for playback was transcoded again into a separate cache entry when downloaded in a zip, and vice versa. The archiver now resolves each request with TranscodeDecider.ResolveRequest, the same as single-song downloads and streams. This also applies the decider's defaults, so a zip requested without a bit rate uses the target format's default bit rate instead of leaving it to ffmpeg. * fix(archiver): name zip entries after the resolved transcoding format The transcode decider can pick a different format than the one requested (for example a player's forced transcoding, or a fallback to the default downsampling format when the requested one can't be produced). Zip entry names and the playlist M3U were still built from the requested format, so an entry could end in .mp3 or .flac while holding Opus data. Each track's request is now resolved before its entry name is built, and the name uses the resolved format. --- cmd/wire_gen.go | 9 +-- core/archiver.go | 30 ++++++---- core/archiver_test.go | 58 +++++++++++++++++++- server/public/handle_streams.go | 5 +- server/public/handle_streams_test.go | 17 ++++-- server/public/public.go | 5 +- server/subsonic/e2e/subsonic_artwork_test.go | 2 +- 7 files changed, 101 insertions(+), 25 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 19f92d9d5..2a396689d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -95,8 +95,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) + archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) @@ -110,7 +111,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router @@ -159,9 +159,10 @@ func CreatePublicRouter() *public.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) - router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) + archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) + router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) return router } diff --git a/core/archiver.go b/core/archiver.go index 6f362322a..60eb44858 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -35,13 +35,14 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { - return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork} +func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { + return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork} } type archiver struct { ds model.DataStore ms stream.MediaStreamer + decider stream.TranscodeDecider shares Share artwork artwork.Artwork } @@ -78,8 +79,9 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { - file := a.albumFilename(mf, format, isMultiDisc, folder) - if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + req := a.resolveRequest(ctx, &mf, format, bitrate) + file := a.albumFilename(mf, req.Format, isMultiDisc, folder) + if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Stop iterating: continuing would just rack up more // rejections from the limiter. Close finalises whatever // tracks were already written; the rejected one is not @@ -204,8 +206,9 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { - file := a.playlistFilename(mf, format, idx) - if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + req := a.resolveRequest(ctx, &mf, format, bitrate) + file := a.playlistFilename(mf, req.Format, idx) + if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Abort the whole archive: continuing would silently emit // empty zip entries since the headers are already written. _ = z.Close() @@ -251,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } -func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { +func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request { + if format == "" || format == "raw" { + return stream.Request{Format: "raw"} + } + return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0) +} + +func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error { path := mf.AbsolutePath() // Open the source before writing the zip entry header so a rejection @@ -259,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med // archive. var r io.ReadCloser var err error - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + if req.Format != "raw" { + r, err = a.ms.NewStream(ctx, &mf, req) } else { r, err = os.Open(path) } if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err) return err } defer func() { diff --git a/core/archiver_test.go b/core/archiver_test.go index 4e00ce78c..178d1b6b9 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -26,6 +26,7 @@ var _ = Describe("Archiver", func() { var ( arch core.Archiver ms *mockMediaStreamer + dc *fakeDecider ds *mockDataStore sh *mockShare ca *mockCoverArt @@ -33,10 +34,11 @@ var _ = Describe("Archiver", func() { BeforeEach(func() { ms = &mockMediaStreamer{} + dc = &fakeDecider{} sh = &mockShare{} ds = &mockDataStore{} ca = &mockCoverArt{images: map[string][]byte{}} - arch = core.NewArchiver(ms, ds, sh, ca) + arch = core.NewArchiver(ms, dc, ds, sh, ca) }) Context("ZipAlbum", func() { @@ -66,6 +68,23 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) }) + + It("streams the request resolved by the transcode decider and names the entry after its format", func() { + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil) + ds.On("MediaFile").Return(mfRepo) + resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2} + dc.resolved = &resolved + ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once() + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) + ms.AssertExpectations(GinkgoT()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus")) + }) }) Context("ZipArtist", func() { @@ -296,6 +315,30 @@ var _ = Describe("Archiver", func() { }) Context("ZipPlaylist", func() { + It("names the entries and the M3U lines after the resolved format", func() { + pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{ + {MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}}, + }} + plRepo := &mockPlaylistRepository{} + plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) + ds.On("Playlist").Return(plRepo) + dc.resolved = &stream.Request{Format: "opus", BitRate: 128} + ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil) + + out := new(bytes.Buffer) + Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus")) + m3u, err := zr.File[1].Open() + Expect(err).ToNot(HaveOccurred()) + defer m3u.Close() + content, err := io.ReadAll(m3u) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus")) + }) + It("zips a playlist correctly", func() { tracks := []model.PlaylistTrack{ {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}}, @@ -571,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } +// fakeDecider echoes the legacy format/bitrate unless a resolved request is set. +type fakeDecider struct { + stream.TranscodeDecider + resolved *stream.Request +} + +func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + if f.resolved != nil { + return *f.resolved + } + return stream.Request{Format: format, BitRate: bitRate, Offset: offset} +} + type mockShare struct { mock.Mock core.Share diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 37ae56c2b..46c7ca210 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -60,9 +60,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ - Format: info.format, BitRate: info.bitrate, - }) + streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0) + stream, err := pub.streamer.NewStream(ctx, mf, streamReq) if err != nil { if errors.Is(err, streampkg.ErrTooManyTranscodes) { w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 4b4a3545b..965bc7e05 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -116,11 +116,11 @@ var _ = Describe("handleStream", func() { BeforeEach(func() { ctx = GinkgoT().Context() auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{} + ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo streamer = &mockStreamer{} - pub = &Router{ds: ds, streamer: streamer} + pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))} }) makeRequest := func(token string) *httptest.ResponseRecorder { @@ -152,8 +152,17 @@ var _ = Describe("handleStream", func() { makeRequest(token) Expect(streamer.called).To(BeTrue()) - Expect(streamer.req.Format).To(Equal("mp3")) - Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() { + mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2} + shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf) + + claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2})) }) It("returns 404 when the track is outside the share owner's libraries", func() { diff --git a/server/public/public.go b/server/public/public.go index 142c474bd..8239ef927 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -21,14 +21,15 @@ type Router struct { http.Handler artwork artwork.Artwork streamer stream.MediaStreamer + decider stream.TranscodeDecider archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { - p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router { + p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.Handler = p.routes() diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 9394c8830..530588759 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -137,7 +137,7 @@ var _ = Describe("Artwork Serving", Ordered, func() { artRouter = buildArtworkRouter(artSvc) router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router - pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{}) }) It("emits a bare optimistic coverArt id before the queue is drained", func() { From caa2f8a0c012328efb9d5685d67b8dc45d24a635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 6 Oct 2026 06:50:11 -0700 Subject: [PATCH 595/596] fix(ui): make playlist toggle switches visible in all themes (#6277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): make playlist toggle switches visible in all themes The Public and Auto-import switches in the playlist list did not set a color, so Material-UI used the theme's secondary color. Many themes use secondary as a surface color close to the table background, which made checked switches nearly invisible (Catppuccin, Rosé Pine, Monokai, Moonbase and others). Set color="primary" on the playlist switch, like every other switch in the app, and make primary the default MuiSwitch color in useCurrentTheme so future switches cannot regress. Fixes #6272. * refactor(ui): drop secondary switch overrides from themes Dracula, Gruvbox Dark, Tokyo Night and Tokyo Night Light styled checked MuiSwitch colorSecondary to work around the same invisible-switch problem (Gruvbox in #5064). With primary as the default switch color and every switch in the app using it, no switch renders with colorSecondary anymore, so these overrides are dead code. --- ui/src/playlist/PlaylistList.jsx | 1 + ui/src/playlist/PlaylistList.test.jsx | 27 ++++++++++++++++++++++++++ ui/src/themes/dracula.js | 10 ---------- ui/src/themes/gruvboxDark.js | 10 ---------- ui/src/themes/tokyoNight.js | 10 ---------- ui/src/themes/tokyoNightLight.js | 10 ---------- ui/src/themes/useCurrentTheme.js | 2 ++ ui/src/themes/useCurrentTheme.test.jsx | 27 ++++++++++++++++++++++++++ 8 files changed, 57 insertions(+), 40 deletions(-) diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 14d819a4e..e1695e980 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -95,6 +95,7 @@ export const ToggleField = ({ resource, source }) => { return ( <Switch checked={record[source]} + color="primary" onClick={handleClick} disabled={!isWritable(record.ownerId)} /> diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 6c714b827..c05833166 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -2,6 +2,7 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { TestContext } from 'ra-test' +import { RecordContextProvider } from 'react-admin' import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' vi.mock('../config', () => ({ @@ -14,6 +15,7 @@ vi.mock('../common', () => ({ {record?.starred ? 'starred' : 'not-starred'} </button> ), + isWritable: (ownerId) => ownerId === 'me', })) describe('<PlaylistLove />', () => { @@ -55,3 +57,28 @@ describe('playlist toggles without a record', () => { expect(container.innerHTML).toBe('') }) }) + +// Secondary is a surface color in many themes, so these toggles must use primary +describe('<ToggleField />', () => { + const renderToggle = (record) => + render( + <TestContext> + <RecordContextProvider value={record}> + <ToggleField resource="playlist" source="public" /> + </RecordContextProvider> + </TestContext>, + ) + + it.each([ + ['owner', 'me', false], + ['non-owner', 'someone-else', true], + ])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => { + renderToggle({ id: 'pl-1', public: true, ownerId }) + const input = screen.getByRole('checkbox') + const switchBase = input.closest('.MuiSwitch-switchBase') + expect(input.checked).toBe(true) + expect(input.disabled).toBe(disabled) + expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') + expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary') + }) +}) diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js index 2e4ae38e5..45559c3af 100644 --- a/ui/src/themes/dracula.js +++ b/ui/src/themes/dracula.js @@ -185,16 +185,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: green, - }, - '&$checked + $track': { - backgroundColor: green, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 0f4cbd7c4..3e2955dcd 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -121,16 +121,6 @@ export default { boxShadow: '3px 3px 5px #3c3836', }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: '#458588', - }, - '&$checked + $track': { - backgroundColor: '#458588', - }, - }, - }, NDMobileArtistDetails: { bgContainer: { background: diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js index 07d372a6b..9f6424b77 100644 --- a/ui/src/themes/tokyoNight.js +++ b/ui/src/themes/tokyoNight.js @@ -184,16 +184,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: blue, - }, - '&$checked + $track': { - backgroundColor: blue, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js index f84cd0be9..a61c0fe87 100644 --- a/ui/src/themes/tokyoNightLight.js +++ b/ui/src/themes/tokyoNightLight.js @@ -184,16 +184,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: blue, - }, - '&$checked + $track': { - backgroundColor: blue, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 4ccefe820..fbb5e9bc8 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -63,6 +63,8 @@ const useCurrentTheme = () => { ...theme.props, MuiUseMediaQuery: { noSsr: true }, MuiPopover: { disableScrollLock: true }, + // MUI defaults to secondary, which many themes use as a surface color + MuiSwitch: { color: 'primary' }, }, }), [theme], diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx index 65c3be8c6..6553d9866 100644 --- a/ui/src/themes/useCurrentTheme.test.jsx +++ b/ui/src/themes/useCurrentTheme.test.jsx @@ -3,6 +3,10 @@ import { Provider } from 'react-redux' import { createStore } from 'redux' import mediaQuery from 'css-mediaquery' import { renderHook } from '@testing-library/react-hooks' +import { render, screen } from '@testing-library/react' +import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles' +import Switch from '@material-ui/core/Switch' +import themes from './index' import useCurrentTheme from './useCurrentTheme' import { themeReducer } from '../reducers/themeReducer' import { AUTO_THEME_ID } from '../consts' @@ -161,4 +165,27 @@ describe('useCurrentTheme', () => { expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') }) }) + describe('switch color', () => { + it.each(Object.keys(themes))( + 'renders switches with the primary color in %s', + (theme) => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme })}> + {children} + </Provider> + ), + }) + render( + <ThemeProvider theme={createMuiTheme(result.current)}> + <Switch checked onChange={() => {}} /> + </ThemeProvider>, + ) + const switchBase = screen + .getByRole('checkbox') + .closest('.MuiSwitch-switchBase') + expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') + }, + ) + }) }) From 52135913d4747c8f8ddcfd8e7004202b8f53b594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 6 Oct 2026 07:57:07 -0700 Subject: [PATCH 596/596] fix(artwork): don't crash the server when a playlist's tracks can't be loaded (#6267) Playlist().Tracks returns nil when its internal Get fails (for example when the context is canceled at shutdown), and resolvePlaylist called GetAlbumIDs on it, panicking with a nil pointer dereference. The artwork drain runs on a bare goroutine, so the panic killed the whole server. resolvePlaylist now returns an error when Tracks is nil, and the worker recovers panics per item: it logs the panic with the item details and stack, and marks the item as a failed attempt so the rest of the batch still runs. Fixes #6266 --- core/artwork/resolve.go | 7 ++++-- core/artwork/resolve_test.go | 10 +++++++++ core/artwork/worker.go | 18 +++++++++++++++- core/artwork/worker_test.go | 42 ++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 40baa2495..fb07332fe 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } } - albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false). - GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) + tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) + if tracks == nil { + return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID) + } + albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if err != nil { return resolution{}, err } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index da144d8e2..2a36531bb 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() { Expect(err).To(HaveOccurred()) Expect(res).To(Equal(resolution{})) }) + + It("returns an error when the playlist tracks cannot be loaded", func() { + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}}) + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"}) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) }) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 28e51958c..4be99f92e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -4,9 +4,11 @@ import ( "bytes" "cmp" "context" + "fmt" "io" "math" "math/rand/v2" + "runtime/debug" "sync" "time" @@ -244,7 +246,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) trace := &ChainTrace{} ctx = withTrace(ctx, trace) - out, got, retryIn := w.proc.acquire(ctx, item) + out, got, retryIn := w.safeAcquire(ctx, item) queue := w.proc.ds.ArtworkQueue() switch out { @@ -286,6 +288,20 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc return out, got } +// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an +// unrecovered panic would crash the server, and the still-queued row would crash it again on restart. +func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { + defer func() { + if r := recover(); r != nil { + log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID, + "imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack())) + traceStage(ctx, "panic", fmt.Errorf("%v", r)) + out, got, retryIn = outcomeFailed, nil, 0 + } + }() + return w.proc.acquire(ctx, item) +} + // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // that never resolved has no row to update, and creating one would settle it absent. func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index a6c07b763..80ca68bc3 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla return v.MockPlaylistRepo.Get(ctx, id) } +type panickingAlbumRepo struct { + *tests.MockAlbumRepo + panicID string +} + +func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) { + if id == r.panicID { + panic("boom") + } + return r.MockAlbumRepo.Get(ctx, id) +} + func adminUserRepo() *tests.MockedUserRepo { repo := tests.CreateMockUserRepo() Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) @@ -278,6 +290,36 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("fails an item that panics, without stopping the rest of the batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + albums := tests.CreateMockAlbumRepo() + albums.SetData(model.Albums{ + {ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}}, + {ID: "alok", Name: "Album", FolderIDs: []string{"f1"}}, + }) + ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"} + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(2)) + + it := findQueued(queueRepo, "al", "alboom") + Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped") + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + Expect(it.Trace).To(ContainSubstring("boom")) + + Expect(findQueued(queueRepo, "al", "alok")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})